@novasamatech/host-papp 0.8.4 → 0.8.5

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,13 @@ export type AuthComponent = ReturnType<typeof createAuth>;
17
17
  export type OnAuthSuccess = (event: {
18
18
  session: StoredUserSession;
19
19
  identityChatPrivateKey: Uint8Array;
20
+ /**
21
+ * `papp_encr_pub` from Mobile SSO spec v0.2.2. `null` when the peer
22
+ * shipped a pre-v0.2.2 `HandshakeSuccessV2` body (no `sso_encr_pub_key`
23
+ * field on the wire). The host's SSO session transport stays inactive
24
+ * while null; chat is unaffected since it uses `identityChatPrivateKey`.
25
+ */
26
+ ssoEncPubKey: Uint8Array | null;
20
27
  }) => Promise<void> | void;
21
28
  type Params = {
22
29
  hostMetadata?: HostMetadata;
@@ -152,6 +152,7 @@ export function createAuth({ hostMetadata, deviceIdentity, deviceIdentityStore,
152
152
  const session = createStoredUserSession(localAccount, remoteAccount, createAccountId(success.rootAccountId ?? new Uint8Array(32)), {
153
153
  identityAccountId: createAccountId(success.identityAccountId),
154
154
  identityChatPublicKey: success.identityChatPublicKey,
155
+ ssoEncPubKey: success.ssoEncPubKey ?? undefined,
155
156
  });
156
157
  return userSecretRepository
157
158
  .write(session.id, {
@@ -162,7 +163,11 @@ export function createAuth({ hostMetadata, deviceIdentity, deviceIdentityStore,
162
163
  })
163
164
  .andThen(() => ssoSessionRepository.add(session))
164
165
  .andThen(() => onAuthSuccess
165
- ? ResultAsync.fromPromise(Promise.resolve(onAuthSuccess({ session, identityChatPrivateKey: success.identityChatPrivateKey })), toError).map(() => session)
166
+ ? ResultAsync.fromPromise(Promise.resolve(onAuthSuccess({
167
+ session,
168
+ identityChatPrivateKey: success.identityChatPrivateKey,
169
+ ssoEncPubKey: success.ssoEncPubKey,
170
+ })), toError).map(() => session)
166
171
  : okAsync(session));
167
172
  }
168
173
  }
@@ -131,11 +131,22 @@ export declare const VersionedHandshakeProposal: import("scale-ts").Codec<{
131
131
  }, string][];
132
132
  };
133
133
  }>;
134
- /** 32 + 32 + 32 + 65 = 161 bytes (spec v0.2.1) */
134
+ /**
135
+ * 32 + 32 + 32 + 65 + 65 = 226 bytes (spec v0.2.2).
136
+ *
137
+ * `ssoEncPubKey` is `papp_encr_pub` per spec § Encrypted response — V2 — the
138
+ * P-256 public key PApp uses for SSO session ECDH. Required to compute
139
+ * `shared_secret_session = ECDH(host_encr_secret, ssoEncPubKey)`. PApp keypair
140
+ * derivation is implementation-defined: Android derives under `//wallet//sso`
141
+ * (`SsoDerivationDomains.SSO_DERIVATION_DOMAIN`); iOS currently reuses
142
+ * `//wallet//chat`. The host treats this field as opaque public material —
143
+ * which keypair PApp picked is invisible here.
144
+ */
135
145
  export declare const HandshakeSuccessV2: import("scale-ts").Codec<{
136
146
  identityAccountId: Uint8Array<ArrayBufferLike>;
137
147
  rootAccountId: Uint8Array<ArrayBufferLike>;
138
148
  identityChatPrivateKey: Uint8Array<ArrayBufferLike>;
149
+ ssoEncPubKey: Uint8Array<ArrayBufferLike>;
139
150
  deviceEncPubKey: Uint8Array<ArrayBufferLike>;
140
151
  }>;
141
152
  export type HandshakeSuccessV2Value = {
@@ -143,8 +154,22 @@ export type HandshakeSuccessV2Value = {
143
154
  /** Nullable for v0.2 peers (Android `feature/location-for-handshake`). */
144
155
  rootAccountId: Uint8Array | null;
145
156
  identityChatPrivateKey: Uint8Array;
157
+ /**
158
+ * Nullable for v0.2 / v0.2.1 peers (any PApp build that does not yet ship
159
+ * spec v0.2.2). The host's SSO session transport stays inactive while
160
+ * null — sign/vrf/etc operations fail at the boundary until PApp emits
161
+ * the new field.
162
+ */
163
+ ssoEncPubKey: Uint8Array | null;
146
164
  deviceEncPubKey: Uint8Array;
147
165
  };
166
+ /** 32 + 32 + 32 + 65 = 161 bytes (spec v0.2.1) */
167
+ export declare const HandshakeSuccessV2_v021: import("scale-ts").Codec<{
168
+ identityAccountId: Uint8Array<ArrayBufferLike>;
169
+ rootAccountId: Uint8Array<ArrayBufferLike>;
170
+ identityChatPrivateKey: Uint8Array<ArrayBufferLike>;
171
+ deviceEncPubKey: Uint8Array<ArrayBufferLike>;
172
+ }>;
148
173
  /** 32 + 32 + 65 = 129 bytes (spec v0.2 — Android `feature/location-for-handshake`) */
149
174
  export declare const HandshakeSuccessV2Legacy: import("scale-ts").Codec<{
150
175
  identityAccountId: Uint8Array<ArrayBufferLike>;
@@ -166,9 +191,16 @@ export type DecodedHandshakeResponseV2 = {
166
191
  };
167
192
  /**
168
193
  * 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.
194
+ * plaintext. Three Success body shapes are accepted, one per spec rev:
195
+ *
196
+ * 226 bytes (spec v0.2.2)includes `ssoEncPubKey` (`papp_encr_pub`).
197
+ * 161 bytes (spec v0.2.1) — `ssoEncPubKey` absent; surfaced as `null`.
198
+ * 129 bytes (spec v0.2) — `rootAccountId` AND `ssoEncPubKey` absent.
199
+ *
200
+ * Older peers degrade gracefully: chat continues to work via
201
+ * `identityChatPrivateKey`, but the SSO session transport (which needs
202
+ * `ssoEncPubKey` to derive `shared_secret_session`) stays inactive on
203
+ * the host until the peer upgrades.
172
204
  */
173
205
  export declare const decodeEncryptedHandshakeResponseV2: (bytes: Uint8Array) => DecodedHandshakeResponseV2;
174
206
  /**
@@ -229,6 +261,7 @@ export declare const EncryptedHandshakeResponseV2: import("scale-ts").Codec<{
229
261
  identityAccountId: Uint8Array<ArrayBufferLike>;
230
262
  rootAccountId: Uint8Array<ArrayBufferLike>;
231
263
  identityChatPrivateKey: Uint8Array<ArrayBufferLike>;
264
+ ssoEncPubKey: Uint8Array<ArrayBufferLike>;
232
265
  deviceEncPubKey: Uint8Array<ArrayBufferLike>;
233
266
  };
234
267
  }>;
@@ -61,8 +61,26 @@ export const VersionedHandshakeProposal = Enum({
61
61
  V2: HandshakeProposalV2,
62
62
  });
63
63
  // ── Response (V2) ───────────────────────────────────────────────────────
64
- /** 32 + 32 + 32 + 65 = 161 bytes (spec v0.2.1) */
64
+ /**
65
+ * 32 + 32 + 32 + 65 + 65 = 226 bytes (spec v0.2.2).
66
+ *
67
+ * `ssoEncPubKey` is `papp_encr_pub` per spec § Encrypted response — V2 — the
68
+ * P-256 public key PApp uses for SSO session ECDH. Required to compute
69
+ * `shared_secret_session = ECDH(host_encr_secret, ssoEncPubKey)`. PApp keypair
70
+ * derivation is implementation-defined: Android derives under `//wallet//sso`
71
+ * (`SsoDerivationDomains.SSO_DERIVATION_DOMAIN`); iOS currently reuses
72
+ * `//wallet//chat`. The host treats this field as opaque public material —
73
+ * which keypair PApp picked is invisible here.
74
+ */
65
75
  export const HandshakeSuccessV2 = Struct({
76
+ identityAccountId: AccountIdCodec,
77
+ rootAccountId: AccountIdCodec,
78
+ identityChatPrivateKey: PrivateKeyCodec,
79
+ ssoEncPubKey: PublicKeyCodec,
80
+ deviceEncPubKey: PublicKeyCodec,
81
+ });
82
+ /** 32 + 32 + 32 + 65 = 161 bytes (spec v0.2.1) */
83
+ export const HandshakeSuccessV2_v021 = Struct({
66
84
  identityAccountId: AccountIdCodec,
67
85
  rootAccountId: AccountIdCodec,
68
86
  identityChatPrivateKey: PrivateKeyCodec,
@@ -76,9 +94,16 @@ export const HandshakeSuccessV2Legacy = Struct({
76
94
  });
77
95
  /**
78
96
  * 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.
97
+ * plaintext. Three Success body shapes are accepted, one per spec rev:
98
+ *
99
+ * 226 bytes (spec v0.2.2)includes `ssoEncPubKey` (`papp_encr_pub`).
100
+ * 161 bytes (spec v0.2.1) — `ssoEncPubKey` absent; surfaced as `null`.
101
+ * 129 bytes (spec v0.2) — `rootAccountId` AND `ssoEncPubKey` absent.
102
+ *
103
+ * Older peers degrade gracefully: chat continues to work via
104
+ * `identityChatPrivateKey`, but the SSO session transport (which needs
105
+ * `ssoEncPubKey` to derive `shared_secret_session`) stays inactive on
106
+ * the host until the peer upgrades.
82
107
  */
83
108
  export const decodeEncryptedHandshakeResponseV2 = (bytes) => {
84
109
  if (bytes.length === 0)
@@ -92,7 +117,7 @@ export const decodeEncryptedHandshakeResponseV2 = (bytes) => {
92
117
  return { tag: 'Pending', value: { tag: 'AllowanceAllocation', value: undefined } };
93
118
  }
94
119
  if (tag === 1) {
95
- if (body.length === 161) {
120
+ if (body.length === 226) {
96
121
  const decoded = HandshakeSuccessV2.dec(body);
97
122
  return {
98
123
  tag: 'Success',
@@ -100,6 +125,20 @@ export const decodeEncryptedHandshakeResponseV2 = (bytes) => {
100
125
  identityAccountId: decoded.identityAccountId,
101
126
  rootAccountId: decoded.rootAccountId,
102
127
  identityChatPrivateKey: decoded.identityChatPrivateKey,
128
+ ssoEncPubKey: decoded.ssoEncPubKey,
129
+ deviceEncPubKey: decoded.deviceEncPubKey,
130
+ },
131
+ };
132
+ }
133
+ if (body.length === 161) {
134
+ const decoded = HandshakeSuccessV2_v021.dec(body);
135
+ return {
136
+ tag: 'Success',
137
+ value: {
138
+ identityAccountId: decoded.identityAccountId,
139
+ rootAccountId: decoded.rootAccountId,
140
+ identityChatPrivateKey: decoded.identityChatPrivateKey,
141
+ ssoEncPubKey: null,
103
142
  deviceEncPubKey: decoded.deviceEncPubKey,
104
143
  },
105
144
  };
@@ -112,11 +151,12 @@ export const decodeEncryptedHandshakeResponseV2 = (bytes) => {
112
151
  identityAccountId: decoded.identityAccountId,
113
152
  rootAccountId: null,
114
153
  identityChatPrivateKey: decoded.identityChatPrivateKey,
154
+ ssoEncPubKey: null,
115
155
  deviceEncPubKey: decoded.deviceEncPubKey,
116
156
  },
117
157
  };
118
158
  }
119
- throw new Error(`EncryptedHandshakeResponseV2: Success body length ${body.length} not in {129, 161}`);
159
+ throw new Error(`EncryptedHandshakeResponseV2: Success body length ${body.length} not in {129, 161, 226}`);
120
160
  }
121
161
  if (tag === 2) {
122
162
  return { tag: 'Failed', value: str.dec(body) };
@@ -54,6 +54,16 @@ export type HandshakeSuccessState = {
54
54
  * back to the authorising device.
55
55
  */
56
56
  deviceEncPubKey: Uint8Array;
57
+ /**
58
+ * `papp_encr_pub` from the Mobile SSO spec (v0.2.2 — 65 bytes, P-256
59
+ * uncompressed). The host's SSO session transport derives
60
+ * `shared_secret_session = ECDH(host_encr_secret, ssoEncPubKey)` from
61
+ * this. Nullable because v0.2 and v0.2.1 peers don't ship it; while
62
+ * null the host's SSO transport stays inactive (sign/vrf/etc continue
63
+ * to fail at the boundary) and chat keeps working through
64
+ * `identityChatPrivateKey`.
65
+ */
66
+ ssoEncPubKey: Uint8Array | null;
57
67
  /**
58
68
  * The pairing-topic statement was signed by PApp's device statement
59
69
  * account. `HandshakeSuccessV2` doesn't carry it in the encrypted body, so
@@ -35,6 +35,7 @@ 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,
38
39
  peerStatementAccountId,
39
40
  };
40
41
  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({
@@ -17,10 +17,12 @@ declare const storedUserSessionCodec: import("scale-ts").Codec<{
17
17
  rootAccountId: AccountId;
18
18
  identityAccountId: AccountId | undefined;
19
19
  identityChatPublicKey: Uint8Array<ArrayBufferLike> | undefined;
20
+ ssoEncPubKey: Uint8Array<ArrayBufferLike> | undefined;
20
21
  }>;
21
22
  type StoredUserSessionV2Extras = {
22
23
  identityAccountId?: AccountId;
23
24
  identityChatPublicKey?: Uint8Array;
25
+ ssoEncPubKey?: Uint8Array;
24
26
  };
25
27
  export declare function createStoredUserSession(localAccount: LocalSessionAccount, remoteAccount: RemoteSessionAccount, rootAccountId: AccountId, extras?: StoredUserSessionV2Extras): StoredUserSession;
26
28
  export declare const createUserSessionRepository: (storage: StorageAdapter) => {
@@ -38,6 +40,7 @@ export declare const createUserSessionRepository: (storage: StorageAdapter) => {
38
40
  rootAccountId: AccountId;
39
41
  identityAccountId: AccountId | undefined;
40
42
  identityChatPublicKey: Uint8Array<ArrayBufferLike> | undefined;
43
+ ssoEncPubKey: Uint8Array<ArrayBufferLike> | undefined;
41
44
  }): import("neverthrow").ResultAsync<{
42
45
  id: string;
43
46
  localAccount: {
@@ -52,6 +55,7 @@ export declare const createUserSessionRepository: (storage: StorageAdapter) => {
52
55
  rootAccountId: AccountId;
53
56
  identityAccountId: AccountId | undefined;
54
57
  identityChatPublicKey: Uint8Array<ArrayBufferLike> | undefined;
58
+ ssoEncPubKey: Uint8Array<ArrayBufferLike> | undefined;
55
59
  }, Error>;
56
60
  filter(fn: (value: {
57
61
  id: string;
@@ -67,6 +71,7 @@ export declare const createUserSessionRepository: (storage: StorageAdapter) => {
67
71
  rootAccountId: AccountId;
68
72
  identityAccountId: AccountId | undefined;
69
73
  identityChatPublicKey: Uint8Array<ArrayBufferLike> | undefined;
74
+ ssoEncPubKey: Uint8Array<ArrayBufferLike> | undefined;
70
75
  }) => boolean): import("neverthrow").ResultAsync<{
71
76
  id: string;
72
77
  localAccount: {
@@ -81,6 +86,7 @@ export declare const createUserSessionRepository: (storage: StorageAdapter) => {
81
86
  rootAccountId: AccountId;
82
87
  identityAccountId: AccountId | undefined;
83
88
  identityChatPublicKey: Uint8Array<ArrayBufferLike> | undefined;
89
+ ssoEncPubKey: Uint8Array<ArrayBufferLike> | undefined;
84
90
  }[], Error>;
85
91
  mutate(fn: (value: {
86
92
  id: string;
@@ -96,6 +102,7 @@ export declare const createUserSessionRepository: (storage: StorageAdapter) => {
96
102
  rootAccountId: AccountId;
97
103
  identityAccountId: AccountId | undefined;
98
104
  identityChatPublicKey: Uint8Array<ArrayBufferLike> | undefined;
105
+ ssoEncPubKey: Uint8Array<ArrayBufferLike> | undefined;
99
106
  }[]) => {
100
107
  id: string;
101
108
  localAccount: {
@@ -110,6 +117,7 @@ export declare const createUserSessionRepository: (storage: StorageAdapter) => {
110
117
  rootAccountId: AccountId;
111
118
  identityAccountId: AccountId | undefined;
112
119
  identityChatPublicKey: Uint8Array<ArrayBufferLike> | undefined;
120
+ ssoEncPubKey: Uint8Array<ArrayBufferLike> | undefined;
113
121
  }[]): import("neverthrow").ResultAsync<{
114
122
  id: string;
115
123
  localAccount: {
@@ -124,6 +132,7 @@ export declare const createUserSessionRepository: (storage: StorageAdapter) => {
124
132
  rootAccountId: AccountId;
125
133
  identityAccountId: AccountId | undefined;
126
134
  identityChatPublicKey: Uint8Array<ArrayBufferLike> | undefined;
135
+ ssoEncPubKey: Uint8Array<ArrayBufferLike> | undefined;
127
136
  }[], Error>;
128
137
  read(): import("neverthrow").ResultAsync<{
129
138
  id: string;
@@ -139,6 +148,7 @@ export declare const createUserSessionRepository: (storage: StorageAdapter) => {
139
148
  rootAccountId: AccountId;
140
149
  identityAccountId: AccountId | undefined;
141
150
  identityChatPublicKey: Uint8Array<ArrayBufferLike> | undefined;
151
+ ssoEncPubKey: Uint8Array<ArrayBufferLike> | undefined;
142
152
  }[], Error>;
143
153
  write(value: {
144
154
  id: string;
@@ -154,6 +164,7 @@ export declare const createUserSessionRepository: (storage: StorageAdapter) => {
154
164
  rootAccountId: AccountId;
155
165
  identityAccountId: AccountId | undefined;
156
166
  identityChatPublicKey: Uint8Array<ArrayBufferLike> | undefined;
167
+ ssoEncPubKey: Uint8Array<ArrayBufferLike> | undefined;
157
168
  }[]): import("neverthrow").ResultAsync<null, Error> | import("neverthrow").ResultAsync<{
158
169
  id: string;
159
170
  localAccount: {
@@ -168,6 +179,7 @@ export declare const createUserSessionRepository: (storage: StorageAdapter) => {
168
179
  rootAccountId: AccountId;
169
180
  identityAccountId: AccountId | undefined;
170
181
  identityChatPublicKey: Uint8Array<ArrayBufferLike> | undefined;
182
+ ssoEncPubKey: Uint8Array<ArrayBufferLike> | undefined;
171
183
  }[], Error>;
172
184
  clear(): import("neverthrow").ResultAsync<void, Error>;
173
185
  subscribe(fn: (value: {
@@ -184,6 +196,7 @@ export declare const createUserSessionRepository: (storage: StorageAdapter) => {
184
196
  rootAccountId: AccountId;
185
197
  identityAccountId: AccountId | undefined;
186
198
  identityChatPublicKey: Uint8Array<ArrayBufferLike> | undefined;
199
+ ssoEncPubKey: Uint8Array<ArrayBufferLike> | undefined;
187
200
  }[]) => void): VoidFunction;
188
201
  };
189
202
  export {};
@@ -12,6 +12,11 @@ const storedUserSessionCodec = Struct({
12
12
  rootAccountId: AccountIdCodec,
13
13
  identityAccountId: Option(AccountIdCodec),
14
14
  identityChatPublicKey: Option(Bytes(65)),
15
+ // `papp_encr_pub` (Mobile SSO spec v0.2.2, 65-byte uncompressed P-256).
16
+ // Persisted so the host can rebuild its SSO session transport
17
+ // (`shared_secret_session = ECDH(host_encr_secret, ssoEncPubKey)`) on a
18
+ // cold start without re-running the handshake. `None` for pre-v0.2.2 peers.
19
+ ssoEncPubKey: Option(Bytes(65)),
15
20
  });
16
21
  export function createStoredUserSession(localAccount, remoteAccount, rootAccountId, extras = {}) {
17
22
  return {
@@ -21,6 +26,7 @@ export function createStoredUserSession(localAccount, remoteAccount, rootAccount
21
26
  rootAccountId,
22
27
  identityAccountId: extras.identityAccountId,
23
28
  identityChatPublicKey: extras.identityChatPublicKey,
29
+ ssoEncPubKey: extras.ssoEncPubKey,
24
30
  };
25
31
  }
26
32
  export const createUserSessionRepository = (storage) => {
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.5",
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.5",
38
+ "@novasamatech/scale": "0.8.5",
39
+ "@novasamatech/statement-store": "0.8.5",
40
+ "@novasamatech/storage-adapter": "0.8.5",
41
41
  "@polkadot-api/utils": "^0.4.0",
42
42
  "@polkadot-labs/hdkd-helpers": "^0.0.30",
43
43
  "nanoevents": "9.1.0",