@novasamatech/host-papp 0.8.4 → 0.8.6

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.
@@ -17,6 +17,8 @@ export type AuthComponent = ReturnType<typeof createAuth>;
17
17
  export type OnAuthSuccess = (event: {
18
18
  session: StoredUserSession;
19
19
  identityChatPrivateKey: Uint8Array;
20
+ /** `papp_encr_pub` — PApp's P-256 SSO ECDH public key. */
21
+ ssoEncPubKey: Uint8Array;
20
22
  }) => Promise<void> | void;
21
23
  type Params = {
22
24
  hostMetadata?: HostMetadata;
@@ -1,5 +1,6 @@
1
1
  import { createAccountId, createLocalSessionAccount, createRemoteSessionAccount } from '@novasamatech/statement-store';
2
2
  import { ResultAsync, errAsync, okAsync } from 'neverthrow';
3
+ import { createSharedSecret } from '../../crypto.js';
3
4
  import { createFlowId, emitHostPappDebugMessage } from '../../debugBus.js';
4
5
  import { AbortError } from '../../helpers/abortError.js';
5
6
  import { createState, readonly } from '../../helpers/state.js';
@@ -148,21 +149,30 @@ export function createAuth({ hostMetadata, deviceIdentity, deviceIdentityStore,
148
149
  };
149
150
  function persistAndNotify(identity, success, _flowId) {
150
151
  const localAccount = createLocalSessionAccount(createAccountId(identity.statementAccountPublicKey));
151
- const remoteAccount = createRemoteSessionAccount(createAccountId(success.peerStatementAccountId ?? new Uint8Array(32)), success.deviceEncPubKey);
152
- const session = createStoredUserSession(localAccount, remoteAccount, createAccountId(success.rootAccountId ?? new Uint8Array(32)), {
152
+ if (!success.peerStatementAccountId) {
153
+ return errAsync(new Error("Can't derive peerStatementAccountId from statement proof signer"));
154
+ }
155
+ const sharedSecret = createSharedSecret(identity.encryptionPrivateKey, success.ssoEncPubKey);
156
+ const remoteAccount = createRemoteSessionAccount(createAccountId(success.peerStatementAccountId), sharedSecret);
157
+ const session = createStoredUserSession(localAccount, remoteAccount, createAccountId(success.rootAccountId), {
153
158
  identityAccountId: createAccountId(success.identityAccountId),
154
159
  identityChatPublicKey: success.identityChatPublicKey,
160
+ ssoEncPubKey: success.ssoEncPubKey,
161
+ rootEntropySource: success.rootEntropySource,
155
162
  });
156
163
  return userSecretRepository
157
164
  .write(session.id, {
158
165
  ssSecret: identity.statementAccountSecret,
159
166
  encrSecret: identity.encryptionPrivateKey,
160
- entropy: new Uint8Array(0),
161
167
  identityChatPrivateKey: success.identityChatPrivateKey,
162
168
  })
163
169
  .andThen(() => ssoSessionRepository.add(session))
164
170
  .andThen(() => onAuthSuccess
165
- ? ResultAsync.fromPromise(Promise.resolve(onAuthSuccess({ session, identityChatPrivateKey: success.identityChatPrivateKey })), toError).map(() => session)
171
+ ? ResultAsync.fromPromise(Promise.resolve(onAuthSuccess({
172
+ session,
173
+ identityChatPrivateKey: success.identityChatPrivateKey,
174
+ ssoEncPubKey: success.ssoEncPubKey,
175
+ })), toError).map(() => session)
166
176
  : okAsync(session));
167
177
  }
168
178
  }
@@ -10,7 +10,7 @@
10
10
  *
11
11
  * `Success` carries:
12
12
  * - `identityAccountId` — user identity sr25519 accountId (32 bytes).
13
- * Adressing for chat / username lookup / session
13
+ * Addressing for chat / username lookup / session
14
14
  * topic derivation.
15
15
  * - `rootAccountId` — user root sr25519 accountId (32 bytes). Parent
16
16
  * for soft-derivation of product accounts; PApp
@@ -23,11 +23,13 @@
23
23
  * P-256 uncompressed). Tells the host which key to
24
24
  * use when addressing chat envelopes back to the
25
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.
26
+ * - `ssoEncPubKey` — `papp_encr_pub` (65 bytes, P-256 uncompressed); see
27
+ * the `HandshakeSuccessV2` codec doc below.
28
+ * - `rootEntropySource` — 32 bytes; `blake2b256_keyed(rootAccountSecret,
29
+ * "product-entropy-derivation")` per RFC-0007 (layer 1).
30
+ * Lets the host derive product entropy deterministically
31
+ * (`host_derive_entropy`) without ever holding the raw
32
+ * root account secret. See the codec doc below.
31
33
  */
32
34
  export declare const MetadataKey: import("scale-ts").Codec<{
33
35
  tag: "Custom";
@@ -96,10 +98,6 @@ export declare const HandshakeProposalV2: import("scale-ts").Codec<{
96
98
  value: undefined;
97
99
  }, string][];
98
100
  }>;
99
- /**
100
- * V2 lives at SCALE discriminant 1; index 0 is reserved for legacy V1 which
101
- * neither side emits. The `_v1Reserved` slot pushes V2 to discriminant 1.
102
- */
103
101
  export declare const VersionedHandshakeProposal: import("scale-ts").Codec<{
104
102
  tag: "_v1Reserved";
105
103
  value: undefined;
@@ -131,89 +129,21 @@ export declare const VersionedHandshakeProposal: import("scale-ts").Codec<{
131
129
  }, string][];
132
130
  };
133
131
  }>;
134
- /** 32 + 32 + 32 + 65 = 161 bytes (spec v0.2.1) */
135
132
  export declare const HandshakeSuccessV2: import("scale-ts").Codec<{
136
133
  identityAccountId: Uint8Array<ArrayBufferLike>;
137
134
  rootAccountId: Uint8Array<ArrayBufferLike>;
138
135
  identityChatPrivateKey: Uint8Array<ArrayBufferLike>;
136
+ ssoEncPubKey: Uint8Array<ArrayBufferLike>;
139
137
  deviceEncPubKey: Uint8Array<ArrayBufferLike>;
138
+ rootEntropySource: Uint8Array<ArrayBufferLike>;
140
139
  }>;
141
- export type HandshakeSuccessV2Value = {
142
- identityAccountId: Uint8Array;
143
- /** Nullable for v0.2 peers (Android `feature/location-for-handshake`). */
144
- rootAccountId: Uint8Array | null;
145
- identityChatPrivateKey: Uint8Array;
146
- deviceEncPubKey: Uint8Array;
147
- };
148
- /** 32 + 32 + 65 = 129 bytes (spec v0.2 — Android `feature/location-for-handshake`) */
149
- export declare const HandshakeSuccessV2Legacy: import("scale-ts").Codec<{
150
- identityAccountId: Uint8Array<ArrayBufferLike>;
151
- identityChatPrivateKey: Uint8Array<ArrayBufferLike>;
152
- deviceEncPubKey: Uint8Array<ArrayBufferLike>;
153
- }>;
154
- export type DecodedHandshakeResponseV2 = {
155
- tag: 'Pending';
156
- value: {
157
- tag: 'AllowanceAllocation';
158
- value: undefined;
159
- };
160
- } | {
161
- tag: 'Success';
162
- value: HandshakeSuccessV2Value;
163
- } | {
164
- tag: 'Failed';
165
- value: string;
166
- };
167
- /**
168
- * Length-dispatched decoder for the inner `EncryptedHandshakeResponseV2`
169
- * plaintext. v0.2 (Android) ships 129-byte Success bodies without
170
- * `rootAccountId`; v0.2.1 ships 161-byte bodies with it. Surfaces
171
- * `rootAccountId: null` for the legacy case — chat doesn't need it.
172
- */
173
- export declare const decodeEncryptedHandshakeResponseV2: (bytes: Uint8Array) => DecodedHandshakeResponseV2;
174
- /**
175
- * Derive the user identity chat P-256 public key from the shared private
176
- * scalar received in `HandshakeSuccessV2`. Both desktop and mobile must derive
177
- * identically (uncompressed 65-byte form) so downstream session topics agree.
178
- */
140
+ /** Derive the identity chat P-256 public key (uncompressed) from its private scalar. */
179
141
  export declare const deriveIdentityChatPublicKey: (privateKey: Uint8Array) => Uint8Array;
180
- /**
181
- * Inner Pending sub-statuses. Only `AllowanceAllocation` today.
182
- *
183
- * Encoded with its own SCALE discriminant byte — the peer's SCALE library does
184
- * NOT elide enum-variant indices for sealed interfaces (verified on the wire:
185
- * `Pending(AllowanceAllocation)` arrives as `0x00 0x00`). Both sides must keep
186
- * the discriminant when encoding so dispatch agrees.
187
- */
188
142
  export declare const HandshakeStatusV2: import("scale-ts").Codec<{
189
143
  tag: "AllowanceAllocation";
190
144
  value: undefined;
191
145
  }>;
192
- export type EncryptedHandshakeResponseV2Value = {
193
- tag: 'Pending';
194
- value: {
195
- tag: 'AllowanceAllocation';
196
- value: undefined;
197
- };
198
- } | {
199
- tag: 'Success';
200
- value: HandshakeSuccessV2Value;
201
- } | {
202
- tag: 'Failed';
203
- value: string;
204
- };
205
- /**
206
- * Inner handshake response variant. Wire shape (matches peer SCALE encoding):
207
- *
208
- * - Pending → 0x00 || HandshakeStatusV2 (today: 0x00 for AllowanceAllocation), 2 bytes total
209
- * - Success → 0x01 || HandshakeSuccessV2 (161 bytes), 162 bytes total
210
- * - Failed → 0x02 || SCALE-encoded UTF-8 reason string
211
- *
212
- * Earlier builds shipped a custom length-dispatched codec assuming elision —
213
- * that assumption was wrong and produced false `Failed("")` reads on every
214
- * `Pending` response. Native scale-ts `Enum` preserves the discriminant and
215
- * matches the peer's wire format directly.
216
- */
146
+ /** Inner handshake response variant. */
217
147
  export declare const EncryptedHandshakeResponseV2: import("scale-ts").Codec<{
218
148
  tag: "Failed";
219
149
  value: string;
@@ -229,7 +159,9 @@ export declare const EncryptedHandshakeResponseV2: import("scale-ts").Codec<{
229
159
  identityAccountId: Uint8Array<ArrayBufferLike>;
230
160
  rootAccountId: Uint8Array<ArrayBufferLike>;
231
161
  identityChatPrivateKey: Uint8Array<ArrayBufferLike>;
162
+ ssoEncPubKey: Uint8Array<ArrayBufferLike>;
232
163
  deviceEncPubKey: Uint8Array<ArrayBufferLike>;
164
+ rootEntropySource: Uint8Array<ArrayBufferLike>;
233
165
  };
234
166
  }>;
235
167
  export declare const HandshakeResponseV2: import("scale-ts").Codec<{
@@ -10,7 +10,7 @@
10
10
  *
11
11
  * `Success` carries:
12
12
  * - `identityAccountId` — user identity sr25519 accountId (32 bytes).
13
- * Adressing for chat / username lookup / session
13
+ * Addressing for chat / username lookup / session
14
14
  * topic derivation.
15
15
  * - `rootAccountId` — user root sr25519 accountId (32 bytes). Parent
16
16
  * for soft-derivation of product accounts; PApp
@@ -23,18 +23,20 @@
23
23
  * P-256 uncompressed). Tells the host which key to
24
24
  * use when addressing chat envelopes back to the
25
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.
26
+ * - `ssoEncPubKey` — `papp_encr_pub` (65 bytes, P-256 uncompressed); see
27
+ * the `HandshakeSuccessV2` codec doc below.
28
+ * - `rootEntropySource` — 32 bytes; `blake2b256_keyed(rootAccountSecret,
29
+ * "product-entropy-derivation")` per RFC-0007 (layer 1).
30
+ * Lets the host derive product entropy deterministically
31
+ * (`host_derive_entropy`) without ever holding the raw
32
+ * root account secret. See the codec doc below.
31
33
  */
32
34
  import { p256 } from '@noble/curves/nist.js';
33
35
  import { Bytes, Enum, Struct, Tuple, Vector, _void, str } from 'scale-ts';
34
36
  const AccountIdCodec = Bytes(32);
35
37
  const PublicKeyCodec = Bytes(65);
36
38
  const PrivateKeyCodec = Bytes(32);
37
- // ── Proposal ────────────────────────────────────────────────────────────
39
+ const EntropySourceCodec = Bytes(32);
38
40
  export const MetadataKey = Enum({
39
41
  Custom: str,
40
42
  HostName: _void,
@@ -52,106 +54,27 @@ export const HandshakeProposalV2 = Struct({
52
54
  device: Device,
53
55
  metadata: Vector(MetadataEntry),
54
56
  });
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
57
  export const VersionedHandshakeProposal = Enum({
60
58
  _v1Reserved: _void,
61
59
  V2: HandshakeProposalV2,
62
60
  });
63
- // ── Response (V2) ───────────────────────────────────────────────────────
64
- /** 32 + 32 + 32 + 65 = 161 bytes (spec v0.2.1) */
61
+ // V2 Response
65
62
  export const HandshakeSuccessV2 = Struct({
66
63
  identityAccountId: AccountIdCodec,
67
64
  rootAccountId: AccountIdCodec,
68
65
  identityChatPrivateKey: PrivateKeyCodec,
66
+ /** PApp's P-256 SSO ECDH public key (`papp_encr_pub`). */
67
+ ssoEncPubKey: PublicKeyCodec,
69
68
  deviceEncPubKey: PublicKeyCodec,
69
+ /** Layer-1 source for deterministic product entropy derivation (RFC-0007). */
70
+ rootEntropySource: EntropySourceCodec,
70
71
  });
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
- */
72
+ /** Derive the identity chat P-256 public key (uncompressed) from its private scalar. */
131
73
  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
74
  export const HandshakeStatusV2 = Enum({
141
75
  AllowanceAllocation: _void,
142
76
  });
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
- */
77
+ /** Inner handshake response variant. */
155
78
  export const EncryptedHandshakeResponseV2 = Enum({
156
79
  Pending: HandshakeStatusV2,
157
80
  Success: HandshakeSuccessV2,
@@ -55,6 +55,10 @@ export type StartPairingDeps = {
55
55
  * `initialProcessedDataHex` value is up to date.
56
56
  */
57
57
  onStatementProcessed?: (dataHex: string) => void;
58
+ /**
59
+ * Turns on logging for statement store interaction
60
+ */
61
+ __DEBUG?: boolean;
58
62
  };
59
63
  export type Pairing = {
60
64
  qrPayload: string;
@@ -23,7 +23,7 @@
23
23
  * higher layers drive a UI hook off this and update an onboarding screen.
24
24
  */
25
25
  import { BehaviorSubject } from 'rxjs';
26
- import { VersionedHandshakeResponse, decodeEncryptedHandshakeResponseV2 } from '../scale/handshakeV2.js';
26
+ import { EncryptedHandshakeResponseV2, VersionedHandshakeResponse } from '../scale/handshakeV2.js';
27
27
  import { decryptResponseEnvelope } from './envelope.js';
28
28
  import { buildPairingDeeplink } from './proposal.js';
29
29
  import { advance, fromInnerResponse, isTerminal, submitted } from './state.js';
@@ -75,14 +75,13 @@ export const startPairingV2 = (deps) => {
75
75
  pollHandle = null;
76
76
  };
77
77
  const log = (msg, extra) => {
78
+ if (!deps.__DEBUG)
79
+ return;
78
80
  if (extra === undefined)
79
81
  console.info(`[sso-v2] ${msg}`);
80
82
  else
81
83
  console.info(`[sso-v2] ${msg}`, extra);
82
84
  };
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
85
  // One-shot probe: query the topic right away so we know whether any answer
87
86
  // statement was already posted before our subscription connected.
88
87
  void deps.statementStore.queryStatements({ matchAll: [topic] }).match(statements => log(`queryStatements probe: ${statements.length} statement(s)`), err => log('queryStatements probe failed', err));
@@ -121,7 +120,7 @@ export const startPairingV2 = (deps) => {
121
120
  const peerStatementAccountId = extractStatementSigner(statement);
122
121
  let next;
123
122
  try {
124
- next = fromInnerResponse(decodeEncryptedHandshakeResponseV2(innerBytes), peerStatementAccountId);
123
+ next = fromInnerResponse(EncryptedHandshakeResponseV2.dec(innerBytes), peerStatementAccountId);
125
124
  }
126
125
  catch (err) {
127
126
  log(`inner decode failed; innerBytes (${innerBytes.length}b) = ${toHexFull(innerBytes)}`, err);
@@ -14,7 +14,8 @@
14
14
  * The state object is what UIs render and what the chat layer gates on
15
15
  * before submitting any V2 statements.
16
16
  */
17
- import type { DecodedHandshakeResponseV2 } from '../scale/handshakeV2.js';
17
+ import type { CodecType } from 'scale-ts';
18
+ import type { EncryptedHandshakeResponseV2, HandshakeSuccessV2 } from '../scale/handshakeV2.js';
18
19
  export type HandshakeIdleState = {
19
20
  tag: 'Idle';
20
21
  };
@@ -25,35 +26,14 @@ export type HandshakePendingState = {
25
26
  tag: 'Pending';
26
27
  reason: 'AllowanceAllocation';
27
28
  };
28
- export type HandshakeSuccessState = {
29
+ export type HandshakeSuccessState = CodecType<typeof HandshakeSuccessV2> & {
29
30
  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
31
  /**
46
32
  * Derived locally from `identityChatPrivateKey` via P-256 scalar
47
33
  * multiplication (uncompressed 65-byte form). Both sides MUST derive
48
34
  * identically; downstream session topics depend on it.
49
35
  */
50
36
  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
37
  /**
58
38
  * The pairing-topic statement was signed by PApp's device statement
59
39
  * account. `HandshakeSuccessV2` doesn't carry it in the encrypted body, so
@@ -71,11 +51,11 @@ export type HandshakeState = HandshakeIdleState | HandshakeSubmittedState | Hand
71
51
  export declare const idle: () => HandshakeIdleState;
72
52
  export declare const submitted: () => HandshakeSubmittedState;
73
53
  /**
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.
54
+ * Translate a decoded `EncryptedHandshakeResponseV2` into the public state.
55
+ * Pure — no I/O. The caller decrypts the outer envelope and decodes the inner
56
+ * payload via `EncryptedHandshakeResponseV2.dec` first.
77
57
  */
78
- export declare const fromInnerResponse: (response: DecodedHandshakeResponseV2, peerStatementAccountId?: Uint8Array | null) => HandshakeState;
58
+ export declare const fromInnerResponse: (response: CodecType<typeof EncryptedHandshakeResponseV2>, peerStatementAccountId?: Uint8Array | null) => HandshakeState;
79
59
  /**
80
60
  * Forward-only transition guard: rejects regressions like Success → Pending.
81
61
  * Idempotent on same-tag transitions (returns current by reference) so callers
@@ -18,9 +18,9 @@ import { deriveIdentityChatPublicKey } from '../scale/handshakeV2.js';
18
18
  export const idle = () => ({ tag: 'Idle' });
19
19
  export const submitted = () => ({ tag: 'Submitted' });
20
20
  /**
21
- * Translate the length-dispatched-decoded `EncryptedHandshakeResponseV2` into
22
- * the public state. Pure — no I/O. The caller decrypts the outer envelope and
23
- * runs `decodeEncryptedHandshakeResponseV2` first.
21
+ * Translate a decoded `EncryptedHandshakeResponseV2` into the public state.
22
+ * Pure — no I/O. The caller decrypts the outer envelope and decodes the inner
23
+ * payload via `EncryptedHandshakeResponseV2.dec` first.
24
24
  */
25
25
  export const fromInnerResponse = (response, peerStatementAccountId = null) => {
26
26
  switch (response.tag) {
@@ -35,6 +35,8 @@ export const fromInnerResponse = (response, peerStatementAccountId = null) => {
35
35
  identityChatPrivateKey: response.value.identityChatPrivateKey,
36
36
  identityChatPublicKey: deriveIdentityChatPublicKey(response.value.identityChatPrivateKey),
37
37
  deviceEncPubKey: response.value.deviceEncPubKey,
38
+ ssoEncPubKey: response.value.ssoEncPubKey,
39
+ rootEntropySource: response.value.rootEntropySource,
38
40
  peerStatementAccountId,
39
41
  };
40
42
  case 'Failed':
@@ -79,6 +79,12 @@ export function createUserSession({ userSession, statementStore, encryption, sto
79
79
  statementStore,
80
80
  encryption,
81
81
  prover,
82
+ // V1 sessions historically derived SessionId by feeding the peer's raw
83
+ // encryption pubkey into khash; preserve that behaviour here so existing
84
+ // V1 channels stay byte-identical post-refactor. V2 callers (e.g.
85
+ // polkadot-desktop's V2SsoSession) pass the ECDH-derived shared secret
86
+ // explicitly per Mobile SSO spec v0.2.2.
87
+ sessionKey: userSession.remoteAccount.publicKey,
82
88
  maxRequestSize: MAX_SSO_REQUEST_SIZE,
83
89
  });
84
90
  const processedMessages = fieldListView({
@@ -6,7 +6,6 @@ type StoredUserSecrets = CodecType<typeof StoredUserSecretsCodec>;
6
6
  declare const StoredUserSecretsCodec: import("scale-ts").Codec<{
7
7
  ssSecret: SsSecret;
8
8
  encrSecret: EncrSecret;
9
- entropy: Uint8Array<ArrayBufferLike>;
10
9
  identityChatPrivateKey: Uint8Array<ArrayBufferLike>;
11
10
  }>;
12
11
  export type UserSecretRepository = ReturnType<typeof createUserSecretRepository>;
@@ -8,26 +8,17 @@ import { toError } from '../helpers/utils.js';
8
8
  const StoredUserSecretsCodec = Struct({
9
9
  ssSecret: BrandedBytesCodec(),
10
10
  encrSecret: BrandedBytesCodec(),
11
- entropy: Bytes(),
12
11
  // V2 addition: user identity chat private key (P-256 raw scalar, 32 bytes).
13
12
  // Sensitive — kept encrypted at rest alongside the per-session ss/encr secrets.
14
13
  identityChatPrivateKey: Bytes(32),
15
14
  });
16
15
  export function createUserSecretRepository(salt, storage) {
17
- const baseKey = 'UserSecrets';
16
+ const baseKey = 'UserSecretsV2';
18
17
  const encode = fromThrowable(StoredUserSecretsCodec.enc, toError);
19
18
  const decode = fromThrowable((value) => {
20
19
  if (!value)
21
20
  return null;
22
- try {
23
- return StoredUserSecretsCodec.dec(value);
24
- }
25
- catch {
26
- // 0.7.x V1 blobs are missing `identityChatPrivateKey` and decode short.
27
- // Treat as absent — a fresh handshake (required after 0.8.0 upgrade)
28
- // will overwrite.
29
- return null;
30
- }
21
+ return StoredUserSecretsCodec.dec(value);
31
22
  }, toError);
32
23
  const encrypt = fromThrowable((value) => {
33
24
  const aes = getAes(salt);
@@ -15,14 +15,18 @@ declare const storedUserSessionCodec: import("scale-ts").Codec<{
15
15
  pin: string | undefined;
16
16
  };
17
17
  rootAccountId: AccountId;
18
- identityAccountId: AccountId | undefined;
19
- identityChatPublicKey: Uint8Array<ArrayBufferLike> | undefined;
18
+ identityAccountId: AccountId;
19
+ identityChatPublicKey: Uint8Array<ArrayBufferLike>;
20
+ ssoEncPubKey: Uint8Array<ArrayBufferLike>;
21
+ rootEntropySource: Uint8Array<ArrayBufferLike>;
20
22
  }>;
21
23
  type StoredUserSessionV2Extras = {
22
- identityAccountId?: AccountId;
23
- identityChatPublicKey?: Uint8Array;
24
+ identityAccountId: AccountId;
25
+ identityChatPublicKey: Uint8Array;
26
+ ssoEncPubKey: Uint8Array;
27
+ rootEntropySource: Uint8Array;
24
28
  };
25
- export declare function createStoredUserSession(localAccount: LocalSessionAccount, remoteAccount: RemoteSessionAccount, rootAccountId: AccountId, extras?: StoredUserSessionV2Extras): StoredUserSession;
29
+ export declare function createStoredUserSession(localAccount: LocalSessionAccount, remoteAccount: RemoteSessionAccount, rootAccountId: AccountId, extras: StoredUserSessionV2Extras): StoredUserSession;
26
30
  export declare const createUserSessionRepository: (storage: StorageAdapter) => {
27
31
  add(value: {
28
32
  id: string;
@@ -36,8 +40,10 @@ export declare const createUserSessionRepository: (storage: StorageAdapter) => {
36
40
  pin: string | undefined;
37
41
  };
38
42
  rootAccountId: AccountId;
39
- identityAccountId: AccountId | undefined;
40
- identityChatPublicKey: Uint8Array<ArrayBufferLike> | undefined;
43
+ identityAccountId: AccountId;
44
+ identityChatPublicKey: Uint8Array<ArrayBufferLike>;
45
+ ssoEncPubKey: Uint8Array<ArrayBufferLike>;
46
+ rootEntropySource: Uint8Array<ArrayBufferLike>;
41
47
  }): import("neverthrow").ResultAsync<{
42
48
  id: string;
43
49
  localAccount: {
@@ -50,8 +56,10 @@ export declare const createUserSessionRepository: (storage: StorageAdapter) => {
50
56
  pin: string | undefined;
51
57
  };
52
58
  rootAccountId: AccountId;
53
- identityAccountId: AccountId | undefined;
54
- identityChatPublicKey: Uint8Array<ArrayBufferLike> | undefined;
59
+ identityAccountId: AccountId;
60
+ identityChatPublicKey: Uint8Array<ArrayBufferLike>;
61
+ ssoEncPubKey: Uint8Array<ArrayBufferLike>;
62
+ rootEntropySource: Uint8Array<ArrayBufferLike>;
55
63
  }, Error>;
56
64
  filter(fn: (value: {
57
65
  id: string;
@@ -65,8 +73,10 @@ export declare const createUserSessionRepository: (storage: StorageAdapter) => {
65
73
  pin: string | undefined;
66
74
  };
67
75
  rootAccountId: AccountId;
68
- identityAccountId: AccountId | undefined;
69
- identityChatPublicKey: Uint8Array<ArrayBufferLike> | undefined;
76
+ identityAccountId: AccountId;
77
+ identityChatPublicKey: Uint8Array<ArrayBufferLike>;
78
+ ssoEncPubKey: Uint8Array<ArrayBufferLike>;
79
+ rootEntropySource: Uint8Array<ArrayBufferLike>;
70
80
  }) => boolean): import("neverthrow").ResultAsync<{
71
81
  id: string;
72
82
  localAccount: {
@@ -79,8 +89,10 @@ export declare const createUserSessionRepository: (storage: StorageAdapter) => {
79
89
  pin: string | undefined;
80
90
  };
81
91
  rootAccountId: AccountId;
82
- identityAccountId: AccountId | undefined;
83
- identityChatPublicKey: Uint8Array<ArrayBufferLike> | undefined;
92
+ identityAccountId: AccountId;
93
+ identityChatPublicKey: Uint8Array<ArrayBufferLike>;
94
+ ssoEncPubKey: Uint8Array<ArrayBufferLike>;
95
+ rootEntropySource: Uint8Array<ArrayBufferLike>;
84
96
  }[], Error>;
85
97
  mutate(fn: (value: {
86
98
  id: string;
@@ -94,8 +106,10 @@ export declare const createUserSessionRepository: (storage: StorageAdapter) => {
94
106
  pin: string | undefined;
95
107
  };
96
108
  rootAccountId: AccountId;
97
- identityAccountId: AccountId | undefined;
98
- identityChatPublicKey: Uint8Array<ArrayBufferLike> | undefined;
109
+ identityAccountId: AccountId;
110
+ identityChatPublicKey: Uint8Array<ArrayBufferLike>;
111
+ ssoEncPubKey: Uint8Array<ArrayBufferLike>;
112
+ rootEntropySource: Uint8Array<ArrayBufferLike>;
99
113
  }[]) => {
100
114
  id: string;
101
115
  localAccount: {
@@ -108,8 +122,10 @@ export declare const createUserSessionRepository: (storage: StorageAdapter) => {
108
122
  pin: string | undefined;
109
123
  };
110
124
  rootAccountId: AccountId;
111
- identityAccountId: AccountId | undefined;
112
- identityChatPublicKey: Uint8Array<ArrayBufferLike> | undefined;
125
+ identityAccountId: AccountId;
126
+ identityChatPublicKey: Uint8Array<ArrayBufferLike>;
127
+ ssoEncPubKey: Uint8Array<ArrayBufferLike>;
128
+ rootEntropySource: Uint8Array<ArrayBufferLike>;
113
129
  }[]): import("neverthrow").ResultAsync<{
114
130
  id: string;
115
131
  localAccount: {
@@ -122,8 +138,10 @@ export declare const createUserSessionRepository: (storage: StorageAdapter) => {
122
138
  pin: string | undefined;
123
139
  };
124
140
  rootAccountId: AccountId;
125
- identityAccountId: AccountId | undefined;
126
- identityChatPublicKey: Uint8Array<ArrayBufferLike> | undefined;
141
+ identityAccountId: AccountId;
142
+ identityChatPublicKey: Uint8Array<ArrayBufferLike>;
143
+ ssoEncPubKey: Uint8Array<ArrayBufferLike>;
144
+ rootEntropySource: Uint8Array<ArrayBufferLike>;
127
145
  }[], Error>;
128
146
  read(): import("neverthrow").ResultAsync<{
129
147
  id: string;
@@ -137,8 +155,10 @@ export declare const createUserSessionRepository: (storage: StorageAdapter) => {
137
155
  pin: string | undefined;
138
156
  };
139
157
  rootAccountId: AccountId;
140
- identityAccountId: AccountId | undefined;
141
- identityChatPublicKey: Uint8Array<ArrayBufferLike> | undefined;
158
+ identityAccountId: AccountId;
159
+ identityChatPublicKey: Uint8Array<ArrayBufferLike>;
160
+ ssoEncPubKey: Uint8Array<ArrayBufferLike>;
161
+ rootEntropySource: Uint8Array<ArrayBufferLike>;
142
162
  }[], Error>;
143
163
  write(value: {
144
164
  id: string;
@@ -152,8 +172,10 @@ export declare const createUserSessionRepository: (storage: StorageAdapter) => {
152
172
  pin: string | undefined;
153
173
  };
154
174
  rootAccountId: AccountId;
155
- identityAccountId: AccountId | undefined;
156
- identityChatPublicKey: Uint8Array<ArrayBufferLike> | undefined;
175
+ identityAccountId: AccountId;
176
+ identityChatPublicKey: Uint8Array<ArrayBufferLike>;
177
+ ssoEncPubKey: Uint8Array<ArrayBufferLike>;
178
+ rootEntropySource: Uint8Array<ArrayBufferLike>;
157
179
  }[]): import("neverthrow").ResultAsync<null, Error> | import("neverthrow").ResultAsync<{
158
180
  id: string;
159
181
  localAccount: {
@@ -166,8 +188,10 @@ export declare const createUserSessionRepository: (storage: StorageAdapter) => {
166
188
  pin: string | undefined;
167
189
  };
168
190
  rootAccountId: AccountId;
169
- identityAccountId: AccountId | undefined;
170
- identityChatPublicKey: Uint8Array<ArrayBufferLike> | undefined;
191
+ identityAccountId: AccountId;
192
+ identityChatPublicKey: Uint8Array<ArrayBufferLike>;
193
+ ssoEncPubKey: Uint8Array<ArrayBufferLike>;
194
+ rootEntropySource: Uint8Array<ArrayBufferLike>;
171
195
  }[], Error>;
172
196
  clear(): import("neverthrow").ResultAsync<void, Error>;
173
197
  subscribe(fn: (value: {
@@ -182,8 +206,10 @@ export declare const createUserSessionRepository: (storage: StorageAdapter) => {
182
206
  pin: string | undefined;
183
207
  };
184
208
  rootAccountId: AccountId;
185
- identityAccountId: AccountId | undefined;
186
- identityChatPublicKey: Uint8Array<ArrayBufferLike> | undefined;
209
+ identityAccountId: AccountId;
210
+ identityChatPublicKey: Uint8Array<ArrayBufferLike>;
211
+ ssoEncPubKey: Uint8Array<ArrayBufferLike>;
212
+ rootEntropySource: Uint8Array<ArrayBufferLike>;
187
213
  }[]) => void): VoidFunction;
188
214
  };
189
215
  export {};
@@ -2,7 +2,7 @@ import { AccountIdCodec, LocalSessionAccountCodec, RemoteSessionAccountCodec } f
2
2
  import { fieldListView } from '@novasamatech/storage-adapter';
3
3
  import { nanoid } from 'nanoid';
4
4
  import { fromHex, toHex } from 'polkadot-api/utils';
5
- import { Bytes, Option, Struct, Vector, str } from 'scale-ts';
5
+ import { Bytes, Struct, Vector, str } from 'scale-ts';
6
6
  // V2 fields trail V1 fields so a future schema rev can append further
7
7
  // `Option`-wrapped fields without breaking decode of 0.8.0 blobs.
8
8
  const storedUserSessionCodec = Struct({
@@ -10,10 +10,18 @@ const storedUserSessionCodec = Struct({
10
10
  localAccount: LocalSessionAccountCodec,
11
11
  remoteAccount: RemoteSessionAccountCodec,
12
12
  rootAccountId: AccountIdCodec,
13
- identityAccountId: Option(AccountIdCodec),
14
- identityChatPublicKey: Option(Bytes(65)),
13
+ identityAccountId: AccountIdCodec,
14
+ identityChatPublicKey: Bytes(65),
15
+ // `papp_encr_pub` (65-byte uncompressed P-256). Persisted so the host can
16
+ // rebuild its SSO session transport (`shared_secret_session =
17
+ // ECDH(host_encr_secret, ssoEncPubKey)`) on a cold start without re-running
18
+ // the handshake.
19
+ ssoEncPubKey: Bytes(65),
20
+ // RFC-0007 layer-1 `rootEntropySource` from the handshake; consumed by the
21
+ // host's `host_derive_entropy` handler via `deriveProductEntropyFromSource`.
22
+ rootEntropySource: Bytes(32),
15
23
  });
16
- export function createStoredUserSession(localAccount, remoteAccount, rootAccountId, extras = {}) {
24
+ export function createStoredUserSession(localAccount, remoteAccount, rootAccountId, extras) {
17
25
  return {
18
26
  id: nanoid(12),
19
27
  localAccount,
@@ -21,25 +29,16 @@ export function createStoredUserSession(localAccount, remoteAccount, rootAccount
21
29
  rootAccountId,
22
30
  identityAccountId: extras.identityAccountId,
23
31
  identityChatPublicKey: extras.identityChatPublicKey,
32
+ ssoEncPubKey: extras.ssoEncPubKey,
33
+ rootEntropySource: extras.rootEntropySource,
24
34
  };
25
35
  }
26
36
  export const createUserSessionRepository = (storage) => {
27
37
  const codec = Vector(storedUserSessionCodec);
28
38
  return fieldListView({
29
39
  storage,
30
- key: 'SsoSessions',
31
- from: x => {
32
- try {
33
- return codec.dec(fromHex(x));
34
- }
35
- catch {
36
- // 0.7.x V1 blobs use the prior codec shape and won't decode against
37
- // V2's extended struct. Treat as empty so the caller (and the
38
- // fieldListView mutate machinery) start clean; the next write
39
- // overwrites the bad blob.
40
- return [];
41
- }
42
- },
40
+ key: 'SsoSessionsV2',
41
+ from: x => codec.dec(fromHex(x)),
43
42
  to: x => toHex(codec.enc(x)),
44
43
  });
45
44
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@novasamatech/host-papp",
3
3
  "type": "module",
4
- "version": "0.8.4",
4
+ "version": "0.8.6",
5
5
  "description": "Polkadot app integration",
6
6
  "license": "Apache-2.0",
7
7
  "repository": {
@@ -34,10 +34,10 @@
34
34
  "@noble/ciphers": "2.2.0",
35
35
  "@noble/curves": "2.2.0",
36
36
  "@noble/hashes": "2.2.0",
37
- "@novasamatech/host-api": "0.8.4",
38
- "@novasamatech/scale": "0.8.4",
39
- "@novasamatech/statement-store": "0.8.4",
40
- "@novasamatech/storage-adapter": "0.8.4",
37
+ "@novasamatech/host-api": "0.8.6",
38
+ "@novasamatech/scale": "0.8.6",
39
+ "@novasamatech/statement-store": "0.8.6",
40
+ "@novasamatech/storage-adapter": "0.8.6",
41
41
  "@polkadot-api/utils": "^0.4.0",
42
42
  "@polkadot-labs/hdkd-helpers": "^0.0.30",
43
43
  "nanoevents": "9.1.0",