@novasamatech/host-papp 0.7.9-6 → 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 CHANGED
@@ -60,7 +60,11 @@ const papp = createPappAdapter({
60
60
  Custom adapters (statement store, identity RPC, storage, lazy chain client) can be supplied
61
61
  via the `adapters` option for testing or non-browser environments.
62
62
 
63
- ## Authentication and pairing
63
+ ## Authentication and pairing (V1)
64
+
65
+ The V1 SSO flow described in this section is the single-device pairing protocol used by
66
+ `papp.sso.authenticate()`. For the multi-device V2 protocol see
67
+ [V2 SSO handshake](#v2-sso-handshake) below.
64
68
 
65
69
  `papp.sso.authenticate()` runs the full pairing + attestation flow and resolves with the
66
70
  stored user session, or `null` if the flow was aborted. The flow is idempotent — calling it
@@ -216,3 +220,168 @@ const lookup = async (accountId: string) => {
216
220
  // Batch lookup
217
221
  await papp.identity.getIdentities([accountIdA, accountIdB]);
218
222
  ```
223
+
224
+ ## V2 SSO handshake
225
+
226
+ V2 is a redesign of the SSO pairing flow that supports the same user identity across
227
+ multiple devices. The host generates a stable device keypair locally, emits a
228
+ `VersionedHandshakeProposal::V2` via QR/deeplink, and an authorising peer (e.g. the user's
229
+ existing Polkadot App) responds over the Statement Store with the user identity keys signed
230
+ to authorise this device. Subsequent devices belonging to the same user reuse the same
231
+ identity, so contacts, chats, and roster events are shared between them.
232
+
233
+ V2 is **not interoperable with V1**: a V1-only peer can't decode a V2 proposal QR and vice
234
+ versa. Hosts that want to support both should branch on which protocol the peer advertises.
235
+
236
+ ### Shape of the flow
237
+
238
+ ```
239
+ host peer (authorising device)
240
+ ────────────────────────────────────────────────────────────────────────
241
+ buildPairingDeeplink(device, metadata)
242
+ → polkadotapp://pair?handshake=<hex>
243
+ scan QR, decode proposal
244
+ compute pairing topic from
245
+ the host's pubkeys
246
+ ECDH-encrypt + post:
247
+ Pending(AllowanceAllocation)
248
+ Success { encryptionKey,
249
+ accountId,
250
+ identitySignature }
251
+ Failed(reason)
252
+ service.subscribeStatements(topic) +
253
+ poll the topic every 2s
254
+
255
+ decode VersionedHandshakeResponse::V2
256
+ → ECDH-decrypt envelope with the
257
+ device encryption private key
258
+ → SCALE-decode inner payload
259
+ → state machine: Submitted → Pending →
260
+ Success | Failed
261
+ → on Success persist user identity
262
+ ```
263
+
264
+ The user identity carried in `Success` is the chat encryption pubkey + the user's identity
265
+ sr25519 accountId. The host verifies the 64-byte sr25519 `identitySignature` against the
266
+ canonical 97 bytes `statementAccountId || encryptionPublicKey`
267
+ (see `IDENTITY_SIGNATURE_PAYLOAD_BYTES`).
268
+
269
+ ### Building and rendering the QR
270
+
271
+ ```ts
272
+ import { buildPairingDeeplink } from '@novasamatech/host-papp';
273
+
274
+ const deeplink = buildPairingDeeplink(
275
+ {
276
+ statementAccountPublicKey: device.statementAccountPublicKey, // sr25519 device pubkey, 32 bytes
277
+ encryptionPublicKey: device.encryptionPublicKey, // P-256 device pubkey, 65 bytes uncompressed
278
+ },
279
+ {
280
+ hostName: 'My Host App',
281
+ hostVersion: '1.0.0',
282
+ platformType: 'macOS',
283
+ platformVersion: '15.4',
284
+ },
285
+ );
286
+
287
+ renderQrCode(deeplink); // 'polkadotapp://pair?handshake=<hex>'
288
+ ```
289
+
290
+ ### Driving the handshake
291
+
292
+ ```ts
293
+ import { startPairingV2 } from '@novasamatech/host-papp';
294
+
295
+ const pairing = startPairingV2({
296
+ statementStore: papp.adapters.statementStore, // any StatementStoreAdapter
297
+ deviceIdentity: {
298
+ statementAccountPublicKey: device.statementAccountPublicKey,
299
+ encryptionPublicKey: device.encryptionPublicKey,
300
+ encryptionPrivateKey: device.encryptionPrivateKey, // P-256 priv key, 32 bytes
301
+ },
302
+ metadata: {
303
+ hostName: 'My Host App',
304
+ hostVersion: '1.0.0',
305
+ platformType: 'macOS',
306
+ platformVersion: '15.4',
307
+ },
308
+ persistOnSuccess: async success => {
309
+ // success.identityChatPublicKey, success.userIdentityAccountId,
310
+ // success.identitySignature — persist in your secureStore.
311
+ },
312
+ });
313
+
314
+ pairing.qrPayload; // 'polkadotapp://pair?handshake=<hex>' for the QR UI
315
+
316
+ pairing.state$.subscribe(state => {
317
+ switch (state.tag) {
318
+ case 'Submitted':
319
+ // QR shown, waiting for the peer to scan
320
+ return;
321
+ case 'Pending':
322
+ // peer acknowledged; allocating Statement Store allowance on-chain
323
+ return;
324
+ case 'Success':
325
+ // identity received, device authorised
326
+ return;
327
+ case 'Failed':
328
+ // peer rejected (declined / duplicate / no-slot / tx-failed)
329
+ console.error(state.reason);
330
+ return;
331
+ }
332
+ });
333
+
334
+ // Cancel mid-flight (the Observable completes, polling stops, subscription closes):
335
+ pairing.abort();
336
+ ```
337
+
338
+ ### Surviving reloads / proper logout
339
+
340
+ The chain holds the most recent statement on the pairing topic indefinitely, so on cold
341
+ start the service will see the previous Success and replay it. To distinguish a stale
342
+ replay from a fresh re-pair, callers can pass byte-level dedupe state:
343
+
344
+ ```ts
345
+ const pairing = startPairingV2({
346
+ // ...
347
+ initialProcessedDataHex: await secureStore.get('lastProcessedHandshakeStatement'),
348
+ onStatementProcessed: hex => {
349
+ void secureStore.set('lastProcessedHandshakeStatement', hex);
350
+ },
351
+ });
352
+ ```
353
+
354
+ The service skips any incoming statement whose bytes match `initialProcessedDataHex`. PApp
355
+ re-encrypts every Success with a fresh ephemeral key + AES-GCM nonce, so a genuine re-pair
356
+ always produces different bytes and passes the dedupe.
357
+
358
+ ### Pairing topic / channel
359
+
360
+ If the host needs to derive the pairing topic or channel itself (for example to subscribe
361
+ in-line, or to verify a statement source):
362
+
363
+ ```ts
364
+ import { computePairingTopic, computePairingChannel } from '@novasamatech/host-papp';
365
+
366
+ const topic = computePairingTopic(statementAccountId, encryptionPublicKey);
367
+ const channel = computePairingChannel(statementAccountId, encryptionPublicKey);
368
+ // topic = blake2b256_keyed(encryptionPublicKey || "topic", key=statementAccountId)
369
+ // channel = blake2b256_keyed(encryptionPublicKey || "channel", key=statementAccountId)
370
+ ```
371
+
372
+ ### Codec exports
373
+
374
+ The SCALE codecs are exported as plain `Codec<T>` values for callers that need to
375
+ encode/decode statements outside the orchestrator:
376
+
377
+ | Export | Description |
378
+ | --------------------------------- | -------------------------------------------------------------------------------------------- |
379
+ | `VersionedHandshakeProposal` | Outer enum; V2 at SCALE discriminant 1, with `_v1Reserved` at 0. |
380
+ | `HandshakeProposalV2` | `{ device, metadata }` — what the QR encodes. |
381
+ | `Device` | `{ statementAccountId(32), encryptionPublicKey(65) }`. |
382
+ | `MetadataKey`, `MetadataEntry` | Metadata enum + `(MetadataKey, str)` tuple. |
383
+ | `VersionedHandshakeResponse` | Outer enum for the answer; `V1` legacy + `V2`. |
384
+ | `HandshakeResponseV2` | `{ encrypted, tmpKey(65) }` — the ECDH-wrapped envelope. |
385
+ | `EncryptedHandshakeResponseV2` | Inner payload after envelope decrypt: `Pending` (1 byte), `Success` (161 bytes), `Failed`. |
386
+ | `HandshakeSuccessV2` | `{ encryptionKey(65), accountId(32), identitySignature(64) }`. |
387
+ | `IDENTITY_SIGNATURE_PAYLOAD_BYTES`| `97` — the bytes the user identity sr25519 signs over. |
@@ -25,7 +25,7 @@ export type SsoDebugEvent = {
25
25
  flowId: string;
26
26
  timestamp: number;
27
27
  payload: {
28
- metadata: string;
28
+ metadata: unknown;
29
29
  };
30
30
  } | {
31
31
  layer: 'sso';
@@ -40,16 +40,14 @@ export type SsoDebugEvent = {
40
40
  event: 'awaiting_response';
41
41
  flowId: string;
42
42
  timestamp: number;
43
- payload: {
44
- topic: string;
45
- };
43
+ payload: Record<string, never>;
46
44
  } | {
47
45
  layer: 'sso';
48
46
  event: 'response_received';
49
47
  flowId: string;
50
48
  timestamp: number;
51
49
  payload: {
52
- sessionId: string;
50
+ identityAccountId: Uint8Array;
53
51
  };
54
52
  } | {
55
53
  layer: 'sso';
@@ -18,10 +18,16 @@ export function createIdentityRpcAdapter(lazyClient) {
18
18
  if (!results) {
19
19
  return ok({});
20
20
  }
21
- return ok(Object.fromEntries(zipWith([accounts, results], x => x).map(([accountId, raw]) => {
22
- if (!raw) {
21
+ return ok(Object.fromEntries(zipWith([accounts, results], x => x).map(([accountId, typedRaw]) => {
22
+ if (!typedRaw) {
23
23
  return [accountId, null];
24
24
  }
25
+ // Runtime metadata may expose fields in snake_case (V1) or
26
+ // camelCase (V2 multi-device). Read defensively. The .papi
27
+ // descriptor only types snake_case, so widen here.
28
+ const raw = typedRaw;
29
+ const fullUsername = raw.full_username ?? raw.fullUsername;
30
+ const liteUsername = raw.lite_username ?? raw.liteUsername;
25
31
  const credibility = raw.credibility.type == 'Lite'
26
32
  ? {
27
33
  type: 'Lite',
@@ -29,14 +35,15 @@ export function createIdentityRpcAdapter(lazyClient) {
29
35
  : {
30
36
  type: 'Person',
31
37
  alias: raw.credibility.value.alias,
32
- lastUpdate: raw.credibility.value.last_update.toString(),
38
+ lastUpdate: (raw.credibility.value.last_update ??
39
+ raw.credibility.value.lastUpdate).toString(),
33
40
  };
34
41
  return [
35
42
  accountId,
36
43
  {
37
44
  accountId: accountId,
38
- fullUsername: raw.full_username ? textDecoder.decode(raw.full_username) : null,
39
- liteUsername: textDecoder.decode(raw.lite_username),
45
+ fullUsername: fullUsername ? textDecoder.decode(fullUsername) : null,
46
+ liteUsername: liteUsername ? textDecoder.decode(liteUsername) : '',
40
47
  credibility,
41
48
  },
42
49
  ];
package/dist/index.d.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  export { SS_PASEO_STABLE_STAGE_ENDPOINTS, SS_PREVIEW_STAGE_ENDPOINTS, SS_STABLE_STAGE_ENDPOINTS } from './constants.js';
2
2
  export type { PappAdapter } from './papp.js';
3
3
  export { createPappAdapter } from './papp.js';
4
- export type { HostMetadata } from './sso/auth/impl.js';
4
+ export type { AuthComponent, HostMetadata, OnAuthSuccess } from './sso/auth/impl.js';
5
5
  export type { PairingStatus } from './sso/auth/types.js';
6
+ export type { DeviceIdentityForPairing } from './sso/auth/v2/service.js';
6
7
  export type { UserSession } from './sso/sessionManager/userSession.js';
7
8
  export type { StoredUserSession } from './sso/userSessionRepository.js';
8
9
  export type { Identity } from './identity/types.js';
package/dist/papp.d.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import type { LazyClient, StatementStoreAdapter } from '@novasamatech/statement-store';
2
2
  import type { StorageAdapter } from '@novasamatech/storage-adapter';
3
3
  import type { IdentityAdapter, IdentityRepository } from './identity/types.js';
4
- import type { AuthComponent, HostMetadata } from './sso/auth/impl.js';
4
+ import type { AuthComponent, HostMetadata, OnAuthSuccess } from './sso/auth/impl.js';
5
+ import type { DeviceIdentityForPairing } from './sso/auth/v2/service.js';
5
6
  import type { SsoSessionManager } from './sso/sessionManager/impl.js';
6
7
  import type { UserSecretRepository } from './sso/userSecretRepository.js';
7
8
  export type PappAdapter = {
@@ -18,27 +19,31 @@ type Adapters = {
18
19
  };
19
20
  type Params = {
20
21
  /**
21
- * Host app Id.
22
- * CAUTION! This value should be stable.
22
+ * Host app Id. CAUTION! This value should be stable across launches — it
23
+ * seeds the storage prefix that backs every persisted SSO blob.
23
24
  */
24
25
  appId: string;
25
26
  /**
26
- * URL for additional metadata that will be displayed during pairing process.
27
- * Content of provided json shound be
28
- * ```ts
29
- * interface Metadata {
30
- * name: string;
31
- * icon: string; // url for icon. Icon should be a rasterized image with min size 256x256 px.
32
- * }
33
- * ```
27
+ * Host environment metadata embedded inside the V2 pairing proposal QR so
28
+ * the paired device can render a request screen with the host name / icon /
29
+ * platform. All fields are optional — absence must not break pairing.
34
30
  */
35
- metadata: string;
31
+ hostMetadata?: HostMetadata;
36
32
  /**
37
- * Optional host environment metadata for Sign-In confirmation screen.
38
- * All fields are optional - absence must not break the pairing flow.
33
+ * Optional override for the device identity. Default: the SDK persists a
34
+ * fresh identity to the configured `StorageAdapter` on first run and reuses
35
+ * it on subsequent launches. Pass a factory only if you need a different
36
+ * persistence backend (Electron Keychain, native secure storage, etc.).
39
37
  */
40
- hostMetadata?: HostMetadata;
38
+ deviceIdentity?: () => Promise<DeviceIdentityForPairing> | DeviceIdentityForPairing;
39
+ /**
40
+ * Optional caller hook fired after a successful handshake — after the SDK
41
+ * has already written the session + secrets to its own repositories. Use it
42
+ * for consumer-specific bookkeeping (telemetry, custom peer caches, device-
43
+ * sync seeding). Throwing fails the `sso.authenticate()` call.
44
+ */
45
+ onAuthSuccess?: OnAuthSuccess;
41
46
  adapters?: Partial<Adapters>;
42
47
  };
43
- export declare function createPappAdapter({ appId, metadata, hostMetadata, adapters }: Params): PappAdapter;
48
+ export declare function createPappAdapter({ appId, hostMetadata, deviceIdentity, onAuthSuccess, adapters, }: Params): PappAdapter;
44
49
  export {};
package/dist/papp.js CHANGED
@@ -5,10 +5,11 @@ import { SS_STABLE_STAGE_ENDPOINTS } from './constants.js';
5
5
  import { createIdentityRepository } from './identity/impl.js';
6
6
  import { createIdentityRpcAdapter } from './identity/rpcAdapter.js';
7
7
  import { createAuth } from './sso/auth/impl.js';
8
+ import { createDeviceIdentityStore } from './sso/deviceIdentityStore.js';
8
9
  import { createSsoSessionManager } from './sso/sessionManager/impl.js';
9
10
  import { createUserSecretRepository } from './sso/userSecretRepository.js';
10
11
  import { createUserSessionRepository } from './sso/userSessionRepository.js';
11
- export function createPappAdapter({ appId, metadata, hostMetadata, adapters }) {
12
+ export function createPappAdapter({ appId, hostMetadata, deviceIdentity, onAuthSuccess, adapters, }) {
12
13
  const lazyClient = adapters?.lazyClient ??
13
14
  createLazyClient(getWsProvider(SS_STABLE_STAGE_ENDPOINTS, { heartbeatTimeout: Number.POSITIVE_INFINITY }));
14
15
  const statementStore = adapters?.statementStore ?? createPapiStatementStoreAdapter(lazyClient);
@@ -16,13 +17,16 @@ export function createPappAdapter({ appId, metadata, hostMetadata, adapters }) {
16
17
  const storage = adapters?.storage ?? createLocalStorageAdapter(appId);
17
18
  const ssoSessionRepository = createUserSessionRepository(storage);
18
19
  const userSecretRepository = createUserSecretRepository(appId, storage);
20
+ const deviceIdentityStore = createDeviceIdentityStore(appId, storage);
19
21
  return {
20
22
  sso: createAuth({
21
- metadata,
22
23
  hostMetadata,
24
+ deviceIdentity,
25
+ deviceIdentityStore,
23
26
  statementStore,
24
27
  ssoSessionRepository,
25
28
  userSecretRepository,
29
+ onAuthSuccess,
26
30
  }),
27
31
  sessions: createSsoSessionManager({ storage, statementStore, ssoSessionRepository, userSecretRepository }),
28
32
  secrets: userSecretRepository,
@@ -1,21 +1,39 @@
1
1
  import type { StatementStoreAdapter } from '@novasamatech/statement-store';
2
2
  import { ResultAsync } from 'neverthrow';
3
+ import type { DeviceIdentityStore } from '../deviceIdentityStore.js';
3
4
  import type { UserSecretRepository } from '../userSecretRepository.js';
4
5
  import type { StoredUserSession, UserSessionRepository } from '../userSessionRepository.js';
6
+ import type { HandshakeMetadata } from './v2/proposal.js';
7
+ import type { DeviceIdentityForPairing } from './v2/service.js';
8
+ export type HostMetadata = HandshakeMetadata;
5
9
  export type AuthComponent = ReturnType<typeof createAuth>;
6
- export type HostMetadata = {
7
- hostVersion?: string;
8
- osType?: string;
9
- osVersion?: string;
10
- };
10
+ /**
11
+ * Optional caller hook fired once the V2 handshake reaches Success, after the
12
+ * SDK has persisted the session and secrets and before `authenticate()`
13
+ * resolves. Receives both the persisted `StoredUserSession` and the sensitive
14
+ * `identityChatPrivateKey` (which lives in `UserSecretRepository` and isn't
15
+ * surfaced on the session shape). Throwing fails the `authenticate()` call.
16
+ */
17
+ export type OnAuthSuccess = (event: {
18
+ session: StoredUserSession;
19
+ identityChatPrivateKey: Uint8Array;
20
+ }) => Promise<void> | void;
11
21
  type Params = {
12
- metadata: string;
13
22
  hostMetadata?: HostMetadata;
23
+ /**
24
+ * Optional override for the device identity. If absent, the SDK uses an
25
+ * internal `deviceIdentityStore` backed by the host's `StorageAdapter` —
26
+ * fine for web hosts. Electron / native consumers can plug in a Keychain-
27
+ * backed identity by passing a factory that returns the same shape.
28
+ */
29
+ deviceIdentity?: () => Promise<DeviceIdentityForPairing> | DeviceIdentityForPairing;
30
+ deviceIdentityStore: DeviceIdentityStore;
14
31
  statementStore: StatementStoreAdapter;
15
32
  ssoSessionRepository: UserSessionRepository;
16
33
  userSecretRepository: UserSecretRepository;
34
+ onAuthSuccess?: OnAuthSuccess;
17
35
  };
18
- export declare function createAuth({ metadata, hostMetadata, statementStore, ssoSessionRepository, userSecretRepository, }: Params): {
36
+ export declare function createAuth({ hostMetadata, deviceIdentity, deviceIdentityStore, statementStore, ssoSessionRepository, userSecretRepository, onAuthSuccess, }: Params): {
19
37
  pairingStatus: {
20
38
  read: () => {
21
39
  step: "none";