@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.
- package/README.md +170 -1
- package/dist/debugTypes.d.ts +3 -5
- package/dist/identity/rpcAdapter.js +12 -5
- package/dist/index.d.ts +2 -1
- package/dist/papp.d.ts +21 -16
- package/dist/papp.js +6 -2
- package/dist/sso/auth/impl.d.ts +25 -7
- package/dist/sso/auth/impl.js +126 -188
- package/dist/sso/auth/scale/handshakeV2.d.ts +260 -0
- package/dist/sso/auth/scale/handshakeV2.js +176 -0
- package/dist/sso/auth/v2/envelope.d.ts +18 -0
- package/dist/sso/auth/v2/envelope.js +23 -0
- package/dist/sso/auth/v2/proposal.d.ts +23 -0
- package/dist/sso/auth/v2/proposal.js +43 -0
- package/dist/sso/auth/v2/service.d.ts +64 -0
- package/dist/sso/auth/v2/service.js +184 -0
- package/dist/sso/auth/v2/state.d.ts +90 -0
- package/dist/sso/auth/v2/state.js +65 -0
- package/dist/sso/auth/v2/topic.d.ts +17 -0
- package/dist/sso/auth/v2/topic.js +21 -0
- package/dist/sso/deviceIdentityStore.d.ts +14 -0
- package/dist/sso/deviceIdentityStore.js +76 -0
- package/dist/sso/sessionManager/userSession.js +1 -4
- package/dist/sso/userSecretRepository.d.ts +1 -0
- package/dist/sso/userSecretRepository.js +16 -1
- package/dist/sso/userSessionRepository.d.ts +29 -1
- package/dist/sso/userSessionRepository.js +22 -5
- package/package.json +9 -6
|
@@ -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;
|