@novasamatech/host-papp 0.7.9 → 0.8.0-0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,176 @@
1
+ /**
2
+ * SCALE codecs for the V2 SSO handshake (multi-device shape).
3
+ *
4
+ * The host emits a `VersionedHandshakeProposal::V2` via QR carrying its
5
+ * `Device { statementAccountId, encryptionPublicKey }` and metadata. The
6
+ * authorising peer (PApp) responds over the Statement Store with a
7
+ * `VersionedHandshakeResponse`; its body is ECDH-encrypted to the host's
8
+ * encryption public key with the peer's ephemeral `tmpKey`. The inner payload
9
+ * after decrypt is `EncryptedHandshakeResponseV2 = Pending | Success | Failed`.
10
+ *
11
+ * `Success` carries:
12
+ * - `identityAccountId` — user identity sr25519 accountId (32 bytes).
13
+ * Adressing for chat / username lookup / session
14
+ * topic derivation.
15
+ * - `rootAccountId` — user root sr25519 accountId (32 bytes). Parent
16
+ * for soft-derivation of product accounts; PApp
17
+ * and host MUST derive identically so a dapp sees
18
+ * the same address on every device.
19
+ * - `identityChatPrivateKey`— user identity chat P-256 private scalar (32 bytes),
20
+ * shared per the multi-device spec so this device
21
+ * can decrypt traffic addressed to the user identity
22
+ * - `deviceEncPubKey` — encryption public key of the PApp device (65 bytes,
23
+ * P-256 uncompressed). Tells the host which key to
24
+ * use when addressing chat envelopes back to the
25
+ * authorising PApp device.
26
+ *
27
+ * Total wire length of `Success` is 32 + 32 + 32 + 65 = 161 bytes. Transit security
28
+ * comes from the outer envelope's ECDH-AES wrap; no per-field signature is
29
+ * carried — multi-device authorisation is asserted by the user-identity-signed
30
+ * roster events (`DeviceAdded`/`DeviceRemoved`) published separately.
31
+ */
32
+ import { p256 } from '@noble/curves/nist.js';
33
+ import { Bytes, Enum, Struct, Tuple, Vector, _void, str } from 'scale-ts';
34
+ const AccountIdCodec = Bytes(32);
35
+ const PublicKeyCodec = Bytes(65);
36
+ const PrivateKeyCodec = Bytes(32);
37
+ // ── Proposal ────────────────────────────────────────────────────────────
38
+ export const MetadataKey = Enum({
39
+ Custom: str,
40
+ HostName: _void,
41
+ HostVersion: _void,
42
+ HostIcon: _void,
43
+ PlatformType: _void,
44
+ PlatformVersion: _void,
45
+ });
46
+ export const MetadataEntry = Tuple(MetadataKey, str);
47
+ export const Device = Struct({
48
+ statementAccountId: AccountIdCodec,
49
+ encryptionPublicKey: PublicKeyCodec,
50
+ });
51
+ export const HandshakeProposalV2 = Struct({
52
+ device: Device,
53
+ metadata: Vector(MetadataEntry),
54
+ });
55
+ /**
56
+ * V2 lives at SCALE discriminant 1; index 0 is reserved for legacy V1 which
57
+ * neither side emits. The `_v1Reserved` slot pushes V2 to discriminant 1.
58
+ */
59
+ export const VersionedHandshakeProposal = Enum({
60
+ _v1Reserved: _void,
61
+ V2: HandshakeProposalV2,
62
+ });
63
+ // ── Response (V2) ───────────────────────────────────────────────────────
64
+ /** 32 + 32 + 32 + 65 = 161 bytes (spec v0.2.1) */
65
+ export const HandshakeSuccessV2 = Struct({
66
+ identityAccountId: AccountIdCodec,
67
+ rootAccountId: AccountIdCodec,
68
+ identityChatPrivateKey: PrivateKeyCodec,
69
+ deviceEncPubKey: PublicKeyCodec,
70
+ });
71
+ /** 32 + 32 + 65 = 129 bytes (spec v0.2 — Android `feature/location-for-handshake`) */
72
+ export const HandshakeSuccessV2Legacy = Struct({
73
+ identityAccountId: AccountIdCodec,
74
+ identityChatPrivateKey: PrivateKeyCodec,
75
+ deviceEncPubKey: PublicKeyCodec,
76
+ });
77
+ /**
78
+ * Length-dispatched decoder for the inner `EncryptedHandshakeResponseV2`
79
+ * plaintext. v0.2 (Android) ships 129-byte Success bodies without
80
+ * `rootAccountId`; v0.2.1 ships 161-byte bodies with it. Surfaces
81
+ * `rootAccountId: null` for the legacy case — chat doesn't need it.
82
+ */
83
+ export const decodeEncryptedHandshakeResponseV2 = (bytes) => {
84
+ if (bytes.length === 0)
85
+ throw new Error('EncryptedHandshakeResponseV2: empty plaintext');
86
+ const tag = bytes[0];
87
+ // `slice` not `subarray` — scale-ts decoders read from buffer byteOffset 0.
88
+ const body = bytes.slice(1);
89
+ if (tag === 0) {
90
+ if (body.length === 0)
91
+ throw new Error('EncryptedHandshakeResponseV2: Pending body empty');
92
+ return { tag: 'Pending', value: { tag: 'AllowanceAllocation', value: undefined } };
93
+ }
94
+ if (tag === 1) {
95
+ if (body.length === 161) {
96
+ const decoded = HandshakeSuccessV2.dec(body);
97
+ return {
98
+ tag: 'Success',
99
+ value: {
100
+ identityAccountId: decoded.identityAccountId,
101
+ rootAccountId: decoded.rootAccountId,
102
+ identityChatPrivateKey: decoded.identityChatPrivateKey,
103
+ deviceEncPubKey: decoded.deviceEncPubKey,
104
+ },
105
+ };
106
+ }
107
+ if (body.length === 129) {
108
+ const decoded = HandshakeSuccessV2Legacy.dec(body);
109
+ return {
110
+ tag: 'Success',
111
+ value: {
112
+ identityAccountId: decoded.identityAccountId,
113
+ rootAccountId: null,
114
+ identityChatPrivateKey: decoded.identityChatPrivateKey,
115
+ deviceEncPubKey: decoded.deviceEncPubKey,
116
+ },
117
+ };
118
+ }
119
+ throw new Error(`EncryptedHandshakeResponseV2: Success body length ${body.length} not in {129, 161}`);
120
+ }
121
+ if (tag === 2) {
122
+ return { tag: 'Failed', value: str.dec(body) };
123
+ }
124
+ throw new Error(`EncryptedHandshakeResponseV2: unknown variant tag ${tag}`);
125
+ };
126
+ /**
127
+ * Derive the user identity chat P-256 public key from the shared private
128
+ * scalar received in `HandshakeSuccessV2`. Both desktop and mobile must derive
129
+ * identically (uncompressed 65-byte form) so downstream session topics agree.
130
+ */
131
+ export const deriveIdentityChatPublicKey = (privateKey) => p256.getPublicKey(privateKey, false);
132
+ /**
133
+ * Inner Pending sub-statuses. Only `AllowanceAllocation` today.
134
+ *
135
+ * Encoded with its own SCALE discriminant byte — the peer's SCALE library does
136
+ * NOT elide enum-variant indices for sealed interfaces (verified on the wire:
137
+ * `Pending(AllowanceAllocation)` arrives as `0x00 0x00`). Both sides must keep
138
+ * the discriminant when encoding so dispatch agrees.
139
+ */
140
+ export const HandshakeStatusV2 = Enum({
141
+ AllowanceAllocation: _void,
142
+ });
143
+ /**
144
+ * Inner handshake response variant. Wire shape (matches peer SCALE encoding):
145
+ *
146
+ * - Pending → 0x00 || HandshakeStatusV2 (today: 0x00 for AllowanceAllocation), 2 bytes total
147
+ * - Success → 0x01 || HandshakeSuccessV2 (161 bytes), 162 bytes total
148
+ * - Failed → 0x02 || SCALE-encoded UTF-8 reason string
149
+ *
150
+ * Earlier builds shipped a custom length-dispatched codec assuming elision —
151
+ * that assumption was wrong and produced false `Failed("")` reads on every
152
+ * `Pending` response. Native scale-ts `Enum` preserves the discriminant and
153
+ * matches the peer's wire format directly.
154
+ */
155
+ export const EncryptedHandshakeResponseV2 = Enum({
156
+ Pending: HandshakeStatusV2,
157
+ Success: HandshakeSuccessV2,
158
+ Failed: str,
159
+ });
160
+ export const HandshakeResponseV2 = Struct({
161
+ encrypted: Bytes(),
162
+ tmpKey: PublicKeyCodec,
163
+ });
164
+ /** Legacy V1 response shape — decoded for backward compat, never emitted by the V2 path. */
165
+ export const HandshakeResponseV1 = Struct({
166
+ encrypted: Bytes(),
167
+ tmpKey: PublicKeyCodec,
168
+ });
169
+ export const EncryptedHandshakeResponseV1 = Struct({
170
+ encryptionKey: PublicKeyCodec,
171
+ accountId: AccountIdCodec,
172
+ });
173
+ export const VersionedHandshakeResponse = Enum({
174
+ V1: HandshakeResponseV1,
175
+ V2: HandshakeResponseV2,
176
+ });
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Decrypt the outer envelope of a `HandshakeResponseV2` statement payload.
3
+ *
4
+ * The answering side generates a one-shot P-256 keypair, performs ECDH against
5
+ * the host device's encryption public key, and AES-GCM encrypts the sensitive
6
+ * payload (the SCALE-encoded `EncryptedHandshakeResponseV2`) with a key
7
+ * derived from the shared secret.
8
+ *
9
+ * The shared-secret-to-AES-key derivation (HKDF-SHA256 over the ECDH X
10
+ * coordinate) is delegated to `createEncryption(sharedSecret)` from
11
+ * `@novasamatech/statement-store` — byte-compatible with the existing V1
12
+ * chat-request encryption helper, so we don't fork primitives here.
13
+ */
14
+ export type HandshakeResponseEnvelope = {
15
+ encrypted: Uint8Array;
16
+ tmpKey: Uint8Array;
17
+ };
18
+ export declare const decryptResponseEnvelope: (deviceEncryptionPrivateKey: Uint8Array, envelope: HandshakeResponseEnvelope) => Uint8Array;
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Decrypt the outer envelope of a `HandshakeResponseV2` statement payload.
3
+ *
4
+ * The answering side generates a one-shot P-256 keypair, performs ECDH against
5
+ * the host device's encryption public key, and AES-GCM encrypts the sensitive
6
+ * payload (the SCALE-encoded `EncryptedHandshakeResponseV2`) with a key
7
+ * derived from the shared secret.
8
+ *
9
+ * The shared-secret-to-AES-key derivation (HKDF-SHA256 over the ECDH X
10
+ * coordinate) is delegated to `createEncryption(sharedSecret)` from
11
+ * `@novasamatech/statement-store` — byte-compatible with the existing V1
12
+ * chat-request encryption helper, so we don't fork primitives here.
13
+ */
14
+ import { p256 } from '@noble/curves/nist.js';
15
+ import { createEncryption } from '@novasamatech/statement-store';
16
+ const ecdhX = (privateKey, peerPublicKey) => p256.getSharedSecret(privateKey, peerPublicKey).slice(1, 33);
17
+ export const decryptResponseEnvelope = (deviceEncryptionPrivateKey, envelope) => {
18
+ const shared = ecdhX(deviceEncryptionPrivateKey, envelope.tmpKey);
19
+ const result = createEncryption(shared).decrypt(envelope.encrypted);
20
+ if (result.isErr())
21
+ throw result.error;
22
+ return result.value;
23
+ };
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Build the V2 SSO pairing proposal that the host emits via QR / deeplink.
3
+ *
4
+ * The encoded proposal goes into the `handshake` query parameter of a
5
+ * `polkadotapp://pair?handshake=<hex>` deeplink. The peer scans the QR,
6
+ * SCALE-decodes the bytes back into a `VersionedHandshakeProposal`, posts an
7
+ * encrypted answer to the corresponding pairing topic, and the host's
8
+ * subscription picks it up.
9
+ */
10
+ export type HandshakeProposalDevice = {
11
+ statementAccountPublicKey: Uint8Array;
12
+ encryptionPublicKey: Uint8Array;
13
+ };
14
+ export type HandshakeMetadata = {
15
+ hostName?: string;
16
+ hostVersion?: string;
17
+ hostIcon?: string;
18
+ platformType?: string;
19
+ platformVersion?: string;
20
+ custom?: Record<string, string>;
21
+ };
22
+ export declare const encodeProposal: (device: HandshakeProposalDevice, metadata: HandshakeMetadata) => Uint8Array;
23
+ export declare const buildPairingDeeplink: (device: HandshakeProposalDevice, metadata: HandshakeMetadata) => string;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Build the V2 SSO pairing proposal that the host emits via QR / deeplink.
3
+ *
4
+ * The encoded proposal goes into the `handshake` query parameter of a
5
+ * `polkadotapp://pair?handshake=<hex>` deeplink. The peer scans the QR,
6
+ * SCALE-decodes the bytes back into a `VersionedHandshakeProposal`, posts an
7
+ * encrypted answer to the corresponding pairing topic, and the host's
8
+ * subscription picks it up.
9
+ */
10
+ import { enumValue } from '@novasamatech/scale';
11
+ import { toHex } from 'polkadot-api/utils';
12
+ import { VersionedHandshakeProposal } from '../scale/handshakeV2.js';
13
+ const PAIRING_DEEPLINK_PREFIX = 'polkadotapp://pair?handshake=';
14
+ const buildMetadataEntries = (metadata) => {
15
+ const entries = [];
16
+ if (metadata.hostName !== undefined)
17
+ entries.push([enumValue('HostName', undefined), metadata.hostName]);
18
+ if (metadata.hostVersion !== undefined)
19
+ entries.push([enumValue('HostVersion', undefined), metadata.hostVersion]);
20
+ if (metadata.hostIcon !== undefined)
21
+ entries.push([enumValue('HostIcon', undefined), metadata.hostIcon]);
22
+ if (metadata.platformType !== undefined)
23
+ entries.push([enumValue('PlatformType', undefined), metadata.platformType]);
24
+ if (metadata.platformVersion !== undefined) {
25
+ entries.push([enumValue('PlatformVersion', undefined), metadata.platformVersion]);
26
+ }
27
+ for (const [key, value] of Object.entries(metadata.custom ?? {})) {
28
+ entries.push([enumValue('Custom', key), value]);
29
+ }
30
+ return entries;
31
+ };
32
+ export const encodeProposal = (device, metadata) => VersionedHandshakeProposal.enc(enumValue('V2', {
33
+ device: {
34
+ statementAccountId: device.statementAccountPublicKey,
35
+ encryptionPublicKey: device.encryptionPublicKey,
36
+ },
37
+ metadata: buildMetadataEntries(metadata),
38
+ }));
39
+ export const buildPairingDeeplink = (device, metadata) => {
40
+ const bytes = encodeProposal(device, metadata);
41
+ const hex = toHex(bytes).replace(/^0x/, '');
42
+ return `${PAIRING_DEEPLINK_PREFIX}${hex}`;
43
+ };
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Orchestrates a single V2 SSO pairing exchange.
3
+ *
4
+ * Wires the codec, envelope, topic, and state-machine pieces together:
5
+ * 1. Encode the device's `VersionedHandshakeProposal::V2` and build the
6
+ * `polkadotapp://pair?handshake=<hex>` deeplink (consumed by the QR UI).
7
+ * 2. Compute the pairing topic from the device pubkeys and subscribe to the
8
+ * Statement Store on it.
9
+ * 3. For each incoming statement: SCALE-decode `VersionedHandshakeResponse`,
10
+ * pull out the `V2` envelope, ECDH-decrypt the inner payload using the
11
+ * device's encryption private key, SCALE-decode `EncryptedHandshakeResponseV2`,
12
+ * and feed the result through the state machine via `fromInnerResponse` +
13
+ * `advance`.
14
+ * 4. On `Success`, invoke the caller-supplied `persistOnSuccess` and stop.
15
+ *
16
+ * The chain RPC subscription only delivers a statement once when it first
17
+ * appears on a topic; channel replacements (Pending → Success on the same
18
+ * channel) don't reliably get re-broadcast as new events. So the service also
19
+ * polls `queryStatements` every 2s alongside the live subscription. Polling
20
+ * stops on terminal state and on `abort()`.
21
+ *
22
+ * Returns an Observable of `HandshakeState` and an `abort()` for cleanup —
23
+ * higher layers drive a UI hook off this and update an onboarding screen.
24
+ */
25
+ import type { StatementStoreAdapter } from '@novasamatech/statement-store';
26
+ import type { Observable } from 'rxjs';
27
+ import type { HandshakeMetadata } from './proposal.js';
28
+ import type { HandshakeState, HandshakeSuccessState } from './state.js';
29
+ export type DeviceIdentityForPairing = {
30
+ statementAccountPublicKey: Uint8Array;
31
+ /**
32
+ * sr25519 secret for the device statement account. `startPairingV2` itself
33
+ * doesn't sign anything, but the post-pairing `StoredUserSession` needs it
34
+ * so the V1 sessionManager prover can issue session statements.
35
+ */
36
+ statementAccountSecret: Uint8Array;
37
+ encryptionPublicKey: Uint8Array;
38
+ encryptionPrivateKey: Uint8Array;
39
+ };
40
+ export type StartPairingDeps = {
41
+ statementStore: StatementStoreAdapter;
42
+ deviceIdentity: DeviceIdentityForPairing;
43
+ metadata: HandshakeMetadata;
44
+ persistOnSuccess?: (success: HandshakeSuccessState) => Promise<void>;
45
+ /**
46
+ * Hex of a previously-processed statement. The service will skip statements
47
+ * whose bytes match this value, treating them as already-handled. Useful for
48
+ * surviving page reloads / logouts so a stale Success on chain doesn't get
49
+ * replayed before the user re-authenticates.
50
+ */
51
+ initialProcessedDataHex?: string | null;
52
+ /**
53
+ * Fires whenever the service starts processing a new statement (i.e. after
54
+ * the byte-level dedupe passes). Callers persist the hex so the next
55
+ * `initialProcessedDataHex` value is up to date.
56
+ */
57
+ onStatementProcessed?: (dataHex: string) => void;
58
+ };
59
+ export type Pairing = {
60
+ qrPayload: string;
61
+ state$: Observable<HandshakeState>;
62
+ abort: () => void;
63
+ };
64
+ export declare const startPairingV2: (deps: StartPairingDeps) => Pairing;
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Orchestrates a single V2 SSO pairing exchange.
3
+ *
4
+ * Wires the codec, envelope, topic, and state-machine pieces together:
5
+ * 1. Encode the device's `VersionedHandshakeProposal::V2` and build the
6
+ * `polkadotapp://pair?handshake=<hex>` deeplink (consumed by the QR UI).
7
+ * 2. Compute the pairing topic from the device pubkeys and subscribe to the
8
+ * Statement Store on it.
9
+ * 3. For each incoming statement: SCALE-decode `VersionedHandshakeResponse`,
10
+ * pull out the `V2` envelope, ECDH-decrypt the inner payload using the
11
+ * device's encryption private key, SCALE-decode `EncryptedHandshakeResponseV2`,
12
+ * and feed the result through the state machine via `fromInnerResponse` +
13
+ * `advance`.
14
+ * 4. On `Success`, invoke the caller-supplied `persistOnSuccess` and stop.
15
+ *
16
+ * The chain RPC subscription only delivers a statement once when it first
17
+ * appears on a topic; channel replacements (Pending → Success on the same
18
+ * channel) don't reliably get re-broadcast as new events. So the service also
19
+ * polls `queryStatements` every 2s alongside the live subscription. Polling
20
+ * stops on terminal state and on `abort()`.
21
+ *
22
+ * Returns an Observable of `HandshakeState` and an `abort()` for cleanup —
23
+ * higher layers drive a UI hook off this and update an onboarding screen.
24
+ */
25
+ import { BehaviorSubject } from 'rxjs';
26
+ import { VersionedHandshakeResponse, decodeEncryptedHandshakeResponseV2 } from '../scale/handshakeV2.js';
27
+ import { decryptResponseEnvelope } from './envelope.js';
28
+ import { buildPairingDeeplink } from './proposal.js';
29
+ import { advance, fromInnerResponse, isTerminal, submitted } from './state.js';
30
+ import { computePairingTopic } from './topic.js';
31
+ const DEFAULT_POLL_INTERVAL_MS = 2_000;
32
+ const toHexFull = (bytes) => {
33
+ let out = '';
34
+ for (const b of bytes)
35
+ out += b.toString(16).padStart(2, '0');
36
+ return `0x${out}`;
37
+ };
38
+ const fromHexString = (hex) => {
39
+ const stripped = hex.startsWith('0x') ? hex.slice(2) : hex;
40
+ const out = new Uint8Array(stripped.length / 2);
41
+ for (let i = 0; i < out.length; i++) {
42
+ out[i] = parseInt(stripped.slice(i * 2, i * 2 + 2), 16);
43
+ }
44
+ return out;
45
+ };
46
+ const extractStatementSigner = (statement) => {
47
+ const proof = statement.proof;
48
+ if (!proof)
49
+ return null;
50
+ if (proof.type !== 'sr25519' && proof.type !== 'ed25519')
51
+ return null;
52
+ try {
53
+ return fromHexString(proof.value.signer);
54
+ }
55
+ catch {
56
+ return null;
57
+ }
58
+ };
59
+ export const startPairingV2 = (deps) => {
60
+ const persistOnSuccess = deps.persistOnSuccess;
61
+ const qrPayload = buildPairingDeeplink({
62
+ statementAccountPublicKey: deps.deviceIdentity.statementAccountPublicKey,
63
+ encryptionPublicKey: deps.deviceIdentity.encryptionPublicKey,
64
+ }, deps.metadata);
65
+ const state$ = new BehaviorSubject(submitted());
66
+ const topic = computePairingTopic(deps.deviceIdentity.statementAccountPublicKey, deps.deviceIdentity.encryptionPublicKey);
67
+ let aborted = false;
68
+ let unsubscribe = null;
69
+ let pollHandle = null;
70
+ let lastProcessedDataHex = deps.initialProcessedDataHex ?? null;
71
+ const stopPolling = () => {
72
+ if (pollHandle === null)
73
+ return;
74
+ clearInterval(pollHandle);
75
+ pollHandle = null;
76
+ };
77
+ const log = (msg, extra) => {
78
+ if (extra === undefined)
79
+ console.info(`[sso-v2] ${msg}`);
80
+ else
81
+ console.info(`[sso-v2] ${msg}`, extra);
82
+ };
83
+ log(`subscribing to pairing topic ${toHexFull(topic)}`);
84
+ log(`device statementAccountId ${toHexFull(deps.deviceIdentity.statementAccountPublicKey)}`);
85
+ log(`device encryptionPublicKey ${toHexFull(deps.deviceIdentity.encryptionPublicKey)}`);
86
+ // One-shot probe: query the topic right away so we know whether any answer
87
+ // statement was already posted before our subscription connected.
88
+ void deps.statementStore.queryStatements({ matchAll: [topic] }).match(statements => log(`queryStatements probe: ${statements.length} statement(s)`), err => log('queryStatements probe failed', err));
89
+ const handleStatement = (statement) => {
90
+ if (aborted || isTerminal(state$.value))
91
+ return;
92
+ if (!statement.data)
93
+ return;
94
+ const dataHex = toHexFull(statement.data);
95
+ if (dataHex === lastProcessedDataHex)
96
+ return;
97
+ lastProcessedDataHex = dataHex;
98
+ deps.onStatementProcessed?.(dataHex);
99
+ log(`statement received, ${statement.data.length}-byte payload`);
100
+ let envelope;
101
+ try {
102
+ const decoded = VersionedHandshakeResponse.dec(statement.data);
103
+ if (decoded.tag !== 'V2') {
104
+ log(`response is not V2 (tag=${decoded.tag}) — dropping`);
105
+ return;
106
+ }
107
+ envelope = decoded.value;
108
+ }
109
+ catch (err) {
110
+ log('VersionedHandshakeResponse.dec threw — dropping', err);
111
+ return;
112
+ }
113
+ let innerBytes;
114
+ try {
115
+ innerBytes = decryptResponseEnvelope(deps.deviceIdentity.encryptionPrivateKey, envelope);
116
+ }
117
+ catch (err) {
118
+ log('outer envelope decrypt failed — wrong recipient or tampered', err);
119
+ return;
120
+ }
121
+ const peerStatementAccountId = extractStatementSigner(statement);
122
+ let next;
123
+ try {
124
+ next = fromInnerResponse(decodeEncryptedHandshakeResponseV2(innerBytes), peerStatementAccountId);
125
+ }
126
+ catch (err) {
127
+ log(`inner decode failed; innerBytes (${innerBytes.length}b) = ${toHexFull(innerBytes)}`, err);
128
+ return;
129
+ }
130
+ log(`decoded inner response, tag=${next.tag}`);
131
+ if (next.tag === 'Failed') {
132
+ log(`failure reason: "${next.reason}" (innerBytes ${innerBytes.length}b = ${toHexFull(innerBytes)})`);
133
+ }
134
+ const advanced = advance(state$.value, next);
135
+ if (advanced === state$.value) {
136
+ // Same-tag idempotence is the common case (every poll re-fetches the
137
+ // current statement); only log when a transition was actually rejected
138
+ // for protocol-state reasons (e.g. Success → Pending).
139
+ if (advanced.tag !== next.tag) {
140
+ log(`advance() rejected ${state$.value.tag} → ${next.tag} — dropping`);
141
+ }
142
+ return;
143
+ }
144
+ log(`state ${state$.value.tag} → ${advanced.tag}`);
145
+ state$.next(advanced);
146
+ if (advanced.tag === 'Success' && persistOnSuccess !== undefined) {
147
+ void persistOnSuccess(advanced).catch(err => {
148
+ console.warn('persistHandshakeSuccess failed', err);
149
+ });
150
+ }
151
+ if (isTerminal(advanced)) {
152
+ stopPolling();
153
+ }
154
+ };
155
+ unsubscribe = deps.statementStore.subscribeStatements({ matchAll: [topic] }, page => {
156
+ log(`subscription page: ${page.statements.length} statement(s), isComplete=${page.isComplete}`);
157
+ for (const stmt of page.statements)
158
+ handleStatement(stmt);
159
+ });
160
+ // Poll because the chain RPC subscription doesn't deliver channel
161
+ // replacements as new events. handleStatement dedupes on data bytes so
162
+ // re-fetching the same Pending tick after tick is silent.
163
+ pollHandle = setInterval(() => {
164
+ if (aborted || isTerminal(state$.value))
165
+ return;
166
+ deps.statementStore.queryStatements({ matchAll: [topic] }).match(statements => {
167
+ for (const stmt of statements)
168
+ handleStatement(stmt);
169
+ }, err => log('poll queryStatements failed', err));
170
+ }, DEFAULT_POLL_INTERVAL_MS);
171
+ return {
172
+ qrPayload,
173
+ state$: state$.asObservable(),
174
+ abort: () => {
175
+ if (aborted)
176
+ return;
177
+ aborted = true;
178
+ stopPolling();
179
+ unsubscribe?.();
180
+ unsubscribe = null;
181
+ state$.complete();
182
+ },
183
+ };
184
+ };
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Handshake V2 state machine — the public-facing observable shape of an
3
+ * in-flight SSO pairing exchange.
4
+ *
5
+ * Maps directly onto the inner `EncryptedHandshakeResponseV2` enum:
6
+ *
7
+ * Idle — no proposal emitted yet
8
+ * Submitted — proposal QR shown, waiting for the first response statement
9
+ * Pending — peer acknowledged; allocating Statement Store allowance on-chain
10
+ * Success — final state; identity keys received, device authorised
11
+ * Failed — final state; peer rejected (declined / duplicate / no-slot / tx-failed)
12
+ *
13
+ * Transitions are unidirectional except for Failed → Idle (user retries).
14
+ * The state object is what UIs render and what the chat layer gates on
15
+ * before submitting any V2 statements.
16
+ */
17
+ import type { DecodedHandshakeResponseV2 } from '../scale/handshakeV2.js';
18
+ export type HandshakeIdleState = {
19
+ tag: 'Idle';
20
+ };
21
+ export type HandshakeSubmittedState = {
22
+ tag: 'Submitted';
23
+ };
24
+ export type HandshakePendingState = {
25
+ tag: 'Pending';
26
+ reason: 'AllowanceAllocation';
27
+ };
28
+ export type HandshakeSuccessState = {
29
+ tag: 'Success';
30
+ /** User identity sr25519 accountId (32 bytes). */
31
+ identityAccountId: Uint8Array;
32
+ /**
33
+ * User root sr25519 accountId (32 bytes) — the parent for soft-derivation
34
+ * of product accounts. Nullable: peers on spec v0.2 (Android
35
+ * `feature/location-for-handshake`) omit this field. Product-account
36
+ * derivation degrades gracefully when absent; chat does not use it.
37
+ */
38
+ rootAccountId: Uint8Array | null;
39
+ /**
40
+ * User identity chat P-256 private key (32 bytes raw scalar) shared by
41
+ * PApp with this device per the multi-device spec. Sensitive; persist in
42
+ * OS-keychain-backed secure storage and never forward.
43
+ */
44
+ identityChatPrivateKey: Uint8Array;
45
+ /**
46
+ * Derived locally from `identityChatPrivateKey` via P-256 scalar
47
+ * multiplication (uncompressed 65-byte form). Both sides MUST derive
48
+ * identically; downstream session topics depend on it.
49
+ */
50
+ identityChatPublicKey: Uint8Array;
51
+ /**
52
+ * Encryption public key of the authorising PApp device (65 bytes,
53
+ * P-256 uncompressed). Used by the host when addressing chat envelopes
54
+ * back to the authorising device.
55
+ */
56
+ deviceEncPubKey: Uint8Array;
57
+ /**
58
+ * The pairing-topic statement was signed by PApp's device statement
59
+ * account. `HandshakeSuccessV2` doesn't carry it in the encrypted body, so
60
+ * the pairing service lifts it off `statement.proof.value.signer` and
61
+ * attaches it here. `null` only when the statement arrived without a
62
+ * recognised proof type, in which case device-sync can't seed back to PApp.
63
+ */
64
+ peerStatementAccountId: Uint8Array | null;
65
+ };
66
+ export type HandshakeFailedState = {
67
+ tag: 'Failed';
68
+ reason: string;
69
+ };
70
+ export type HandshakeState = HandshakeIdleState | HandshakeSubmittedState | HandshakePendingState | HandshakeSuccessState | HandshakeFailedState;
71
+ export declare const idle: () => HandshakeIdleState;
72
+ export declare const submitted: () => HandshakeSubmittedState;
73
+ /**
74
+ * Translate the length-dispatched-decoded `EncryptedHandshakeResponseV2` into
75
+ * the public state. Pure — no I/O. The caller decrypts the outer envelope and
76
+ * runs `decodeEncryptedHandshakeResponseV2` first.
77
+ */
78
+ export declare const fromInnerResponse: (response: DecodedHandshakeResponseV2, peerStatementAccountId?: Uint8Array | null) => HandshakeState;
79
+ /**
80
+ * Forward-only transition guard: rejects regressions like Success → Pending.
81
+ * Idempotent on same-tag transitions (returns current by reference) so callers
82
+ * can use `next === current` to detect "no change" without per-call equality.
83
+ */
84
+ export declare const advance: (current: HandshakeState, next: HandshakeState) => HandshakeState;
85
+ export declare const isTerminal: (state: HandshakeState) => state is HandshakeSuccessState | HandshakeFailedState;
86
+ /**
87
+ * True only when the device is authorised to submit V2 statements. The
88
+ * chat-send path gates on this — V2 messages must wait for allowance.
89
+ */
90
+ export declare const canSubmitV2Statements: (state: HandshakeState) => state is HandshakeSuccessState;