From 0a5d34d610ebec3737efdd7084cbda14d427bd13 Mon Sep 17 00:00:00 2001 From: Ilya Date: Wed, 20 May 2026 12:55:52 -0600 Subject: [PATCH 01/31] feat(host-papp): add V2 SSO handshake codec, state machine, and pairing service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire-compatible with the V2 SSO protocol: VersionedHandshakeProposal::V2 — emitted by the host via QR carrying Device { statementAccountId, encryptionPublicKey } and metadata (HostName / HostVersion / HostIcon / PlatformType / PlatformVersion / Custom) VersionedHandshakeResponse — answer over Statement Store, ECDH-encrypted body; inner payload is `EncryptedHandshakeResponseV2 = Pending | Success | Failed`. Success carries identity_chat_pubkey + identity_sr25519_pubkey + 64-byte sr25519 identity_signature. Pieces: - scale/handshakeV2.ts — SCALE codecs. V2 is at discriminant 1 (with a `_v1Reserved: _void` slot to push it past the legacy V1 index 0). EncryptedHandshakeResponseV2 is a length-dispatched custom codec (1 byte / 161 bytes / variable str) since the peer's SCALE library elides the outer enum index for class-wrapped sealed-interface variants. - v2/topic.ts — pairing topic + channel derivation: khash(statementAccountId, encryptionPublicKey || "topic"|"channel") - v2/proposal.ts — encode + build `polkadotapp://pair?handshake=`. - v2/envelope.ts — ECDH (P-256) + AES-GCM via @novasamatech/statement-store createEncryption. - v2/state.ts — Idle -> Submitted -> Pending(AllowanceAllocation) -> Success | Failed; forward-only transitions with same-tag idempotence. - v2/service.ts — orchestrator: subscribes + polls the pairing topic, SCALE-decodes statements, drives the state machine. Exposes optional initialProcessedDataHex + onStatementProcessed so callers can persist byte-level dedupe across reloads (e.g. for proper logout). Adds rxjs and @polkadot-api/utils to host-papp deps. 60 new tests. --- .../__tests__/handshakeV2Codec.spec.ts | 212 +++++++++++++++++ .../__tests__/handshakeV2Envelope.spec.ts | 57 +++++ .../__tests__/handshakeV2Proposal.spec.ts | 73 ++++++ .../__tests__/handshakeV2Service.spec.ts | 189 +++++++++++++++ .../__tests__/handshakeV2State.spec.ts | 138 +++++++++++ .../__tests__/handshakeV2Topic.spec.ts | 50 ++++ packages/host-papp/package.json | 5 +- packages/host-papp/src/index.ts | 40 ++++ .../src/sso/auth/scale/handshakeV2.ts | 156 +++++++++++++ .../host-papp/src/sso/auth/v2/envelope.ts | 34 +++ .../host-papp/src/sso/auth/v2/proposal.ts | 76 ++++++ packages/host-papp/src/sso/auth/v2/service.ts | 217 ++++++++++++++++++ packages/host-papp/src/sso/auth/v2/state.ts | 85 +++++++ packages/host-papp/src/sso/auth/v2/topic.ts | 27 +++ 14 files changed, 1358 insertions(+), 1 deletion(-) create mode 100644 packages/host-papp/__tests__/handshakeV2Codec.spec.ts create mode 100644 packages/host-papp/__tests__/handshakeV2Envelope.spec.ts create mode 100644 packages/host-papp/__tests__/handshakeV2Proposal.spec.ts create mode 100644 packages/host-papp/__tests__/handshakeV2Service.spec.ts create mode 100644 packages/host-papp/__tests__/handshakeV2State.spec.ts create mode 100644 packages/host-papp/__tests__/handshakeV2Topic.spec.ts create mode 100644 packages/host-papp/src/sso/auth/scale/handshakeV2.ts create mode 100644 packages/host-papp/src/sso/auth/v2/envelope.ts create mode 100644 packages/host-papp/src/sso/auth/v2/proposal.ts create mode 100644 packages/host-papp/src/sso/auth/v2/service.ts create mode 100644 packages/host-papp/src/sso/auth/v2/state.ts create mode 100644 packages/host-papp/src/sso/auth/v2/topic.ts diff --git a/packages/host-papp/__tests__/handshakeV2Codec.spec.ts b/packages/host-papp/__tests__/handshakeV2Codec.spec.ts new file mode 100644 index 00000000..21cfd7f0 --- /dev/null +++ b/packages/host-papp/__tests__/handshakeV2Codec.spec.ts @@ -0,0 +1,212 @@ +import { describe, expect, it } from 'vitest'; + +import type { MetadataEntry } from '../src/sso/auth/scale/handshakeV2.js'; +import { + Device, + EncryptedHandshakeResponseV1, + EncryptedHandshakeResponseV2, + HandshakeProposalV2, + HandshakeResponseV1, + HandshakeResponseV2, + HandshakeStatusV2, + HandshakeSuccessV2, + IDENTITY_SIGNATURE_PAYLOAD_BYTES, + MetadataKey, + VersionedHandshakeProposal, + VersionedHandshakeResponse, +} from '../src/sso/auth/scale/handshakeV2.js'; + +const makeDevice = () => ({ + statementAccountId: new Uint8Array(32).fill(0xa1), + encryptionPublicKey: new Uint8Array(65).fill(0x04), +}); + +describe('IDENTITY_SIGNATURE_PAYLOAD_BYTES', () => { + it('equals 32 + 65 = 97 bytes', () => { + expect(IDENTITY_SIGNATURE_PAYLOAD_BYTES).toBe(97); + }); +}); + +describe('MetadataKey', () => { + it('round-trips Custom with arbitrary string', () => { + const m = { tag: 'Custom' as const, value: 'app.theme' }; + expect(MetadataKey.dec(MetadataKey.enc(m))).toEqual(m); + }); + + it('round-trips each unit variant', () => { + const variants = ['HostName', 'HostVersion', 'HostIcon', 'PlatformType', 'PlatformVersion'] as const; + for (const tag of variants) { + const m = { tag, value: undefined }; + expect(MetadataKey.dec(MetadataKey.enc(m))).toEqual(m); + } + }); +}); + +describe('Device', () => { + it('round-trips a 32-byte accountId and 65-byte uncompressed public key', () => { + const d = makeDevice(); + expect(Device.dec(Device.enc(d))).toEqual(d); + }); + + it('encodes Device with the expected fixed length (32 + 65 = 97 bytes)', () => { + expect(Device.enc(makeDevice()).length).toBe(97); + }); +}); + +describe('HandshakeProposalV2', () => { + it('round-trips with empty metadata', () => { + const p = { device: makeDevice(), metadata: [] }; + expect(HandshakeProposalV2.dec(HandshakeProposalV2.enc(p))).toEqual(p); + }); + + it('round-trips with mixed metadata variants', () => { + const metadata: ReturnType[] = [ + [{ tag: 'HostName' as const, value: undefined }, 'Polkadot Desktop'], + [{ tag: 'HostVersion' as const, value: undefined }, '0.1.0'], + [{ tag: 'PlatformType' as const, value: undefined }, 'macOS'], + [{ tag: 'Custom' as const, value: 'app.theme' }, 'dark'], + ]; + const p = { device: makeDevice(), metadata }; + expect(HandshakeProposalV2.dec(HandshakeProposalV2.enc(p))).toEqual(p); + }); +}); + +describe('VersionedHandshakeProposal', () => { + it('round-trips a V2 proposal', () => { + const versioned = { + tag: 'V2' as const, + value: { device: makeDevice(), metadata: [] }, + }; + expect(VersionedHandshakeProposal.dec(VersionedHandshakeProposal.enc(versioned))).toEqual(versioned); + }); + + // V2 lives at SCALE discriminant 1 (index 0 reserved for legacy V1 which + // neither side emits). Mismatch here means the peer can't decode the + // Desktop-emitted pairing QR. + it('emits V2 at byte discriminant 1', () => { + const encoded = VersionedHandshakeProposal.enc({ + tag: 'V2', + value: { device: makeDevice(), metadata: [] }, + }); + expect(encoded[0]).toBe(1); + }); +}); + +describe('HandshakeSuccessV2', () => { + it('round-trips encryptionKey, accountId, and identity signature', () => { + const s = { + encryptionKey: new Uint8Array(65).fill(0x04), + accountId: new Uint8Array(32).fill(0xb2), + identitySignature: new Uint8Array(64).fill(0xcc), + }; + expect(HandshakeSuccessV2.dec(HandshakeSuccessV2.enc(s))).toEqual(s); + }); +}); + +describe('EncryptedHandshakeResponseV2', () => { + it('round-trips Pending (single byte, no inner status — peer wire-compat)', () => { + const r = { tag: 'Pending' as const, value: undefined }; + expect(EncryptedHandshakeResponseV2.dec(EncryptedHandshakeResponseV2.enc(r))).toEqual(r); + }); + + it('encodes Pending as a single 0x00 byte', () => { + const encoded = EncryptedHandshakeResponseV2.enc({ tag: 'Pending', value: undefined }); + expect(encoded).toEqual(new Uint8Array([0x00])); + }); + + it('round-trips Success', () => { + const r = { + tag: 'Success' as const, + value: { + encryptionKey: new Uint8Array(65).fill(0x04), + accountId: new Uint8Array(32).fill(0xb2), + identitySignature: new Uint8Array(64).fill(0xcc), + }, + }; + expect(EncryptedHandshakeResponseV2.dec(EncryptedHandshakeResponseV2.enc(r))).toEqual(r); + }); + + // Pinned wire format: Success is 161 bytes, no outer discriminant — just + // the three fixed-length fields concatenated. + it('encodes Success as 161 bytes (peer wire-compat)', () => { + const encoded = EncryptedHandshakeResponseV2.enc({ + tag: 'Success', + value: { + encryptionKey: new Uint8Array(65).fill(0x04), + accountId: new Uint8Array(32).fill(0xb2), + identitySignature: new Uint8Array(64).fill(0xcc), + }, + }); + expect(encoded.length).toBe(161); + expect(encoded[0]).toBe(0x04); + expect(encoded[65]).toBe(0xb2); + expect(encoded[97]).toBe(0xcc); + }); + + it('decodes a 161-byte payload as Success even though it has no discriminant byte', () => { + const bytes = new Uint8Array(161); + bytes[0] = 0x04; // P-256 uncompressed marker — first byte of encryptionKey + const decoded = EncryptedHandshakeResponseV2.dec(bytes); + expect(decoded.tag).toBe('Success'); + }); + + it('round-trips Failed with a reason string', () => { + const r = { tag: 'Failed' as const, value: 'user declined on mobile' }; + expect(EncryptedHandshakeResponseV2.dec(EncryptedHandshakeResponseV2.enc(r))).toEqual(r); + }); +}); + +describe('HandshakeStatusV2', () => { + it('round-trips AllowanceAllocation', () => { + const s = { tag: 'AllowanceAllocation' as const, value: undefined }; + expect(HandshakeStatusV2.dec(HandshakeStatusV2.enc(s))).toEqual(s); + }); +}); + +describe('HandshakeResponseV2', () => { + it('round-trips with arbitrary ciphertext and ephemeral key', () => { + const r = { + encrypted: new Uint8Array([1, 2, 3, 4, 5]), + tmpKey: new Uint8Array(65).fill(0x04), + }; + expect(HandshakeResponseV2.dec(HandshakeResponseV2.enc(r))).toEqual(r); + }); +}); + +describe('VersionedHandshakeResponse', () => { + it('round-trips a V2 response', () => { + const r = { + tag: 'V2' as const, + value: { encrypted: new Uint8Array([7, 8, 9]), tmpKey: new Uint8Array(65).fill(0x04) }, + }; + expect(VersionedHandshakeResponse.dec(VersionedHandshakeResponse.enc(r))).toEqual(r); + }); + + it('decodes a legacy V1 response from older mobile clients', () => { + const r = { + tag: 'V1' as const, + value: { encrypted: new Uint8Array([1, 2]), tmpKey: new Uint8Array(65).fill(0x04) }, + }; + expect(VersionedHandshakeResponse.dec(VersionedHandshakeResponse.enc(r))).toEqual(r); + }); +}); + +describe('EncryptedHandshakeResponseV1 (legacy)', () => { + it('round-trips encryptionKey and accountId', () => { + const r = { + encryptionKey: new Uint8Array(65).fill(0x04), + accountId: new Uint8Array(32).fill(0xb2), + }; + expect(EncryptedHandshakeResponseV1.dec(EncryptedHandshakeResponseV1.enc(r))).toEqual(r); + }); +}); + +describe('HandshakeResponseV1 (legacy)', () => { + it('round-trips with arbitrary ciphertext and ephemeral key', () => { + const r = { + encrypted: new Uint8Array([0xff, 0xee]), + tmpKey: new Uint8Array(65).fill(0x04), + }; + expect(HandshakeResponseV1.dec(HandshakeResponseV1.enc(r))).toEqual(r); + }); +}); diff --git a/packages/host-papp/__tests__/handshakeV2Envelope.spec.ts b/packages/host-papp/__tests__/handshakeV2Envelope.spec.ts new file mode 100644 index 00000000..ec35069c --- /dev/null +++ b/packages/host-papp/__tests__/handshakeV2Envelope.spec.ts @@ -0,0 +1,57 @@ +import { p256 } from '@noble/curves/nist.js'; +import { createEncryption } from '@novasamatech/statement-store'; +import { describe, expect, it } from 'vitest'; + +import { decryptResponseEnvelope } from '../src/sso/auth/v2/envelope.js'; + +const ecdhX = (priv: Uint8Array, pub: Uint8Array): Uint8Array => p256.getSharedSecret(priv, pub).slice(1, 33); + +// Build a HandshakeResponseV2-shaped envelope mirroring the answering peer's +// encrypt path: caller-side ephemeral P-256 keypair, ECDH against the +// device's encryption public key, then `createEncryption(shared).encrypt(...)`. +const wrap = (devicePublicKey: Uint8Array, payload: Uint8Array): { encrypted: Uint8Array; tmpKey: Uint8Array } => { + const tmpPrivate = p256.utils.randomSecretKey(); + const tmpKey = p256.getPublicKey(tmpPrivate, false); + const shared = ecdhX(tmpPrivate, devicePublicKey); + const result = createEncryption(shared).encrypt(payload); + if (result.isErr()) throw result.error; + return { encrypted: result.value, tmpKey }; +}; + +describe('decryptResponseEnvelope', () => { + it('round-trips a payload encrypted with a one-time-use ephemeral key against the device public key', () => { + const devicePrivate = p256.utils.randomSecretKey(); + const devicePublic = p256.getPublicKey(devicePrivate, false); + + const inner = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9]); + const envelope = wrap(devicePublic, inner); + + const decrypted = decryptResponseEnvelope(devicePrivate, envelope); + expect(decrypted).toEqual(inner); + }); + + it('throws on a tmpKey that was not the actual encryption peer', () => { + const devicePrivate = p256.utils.randomSecretKey(); + const devicePublic = p256.getPublicKey(devicePrivate, false); + + const envelope = wrap(devicePublic, new Uint8Array([0xff])); + const wrongTmpPrivate = p256.utils.randomSecretKey(); + const wrongTmpKey = p256.getPublicKey(wrongTmpPrivate, false); + + expect(() => + decryptResponseEnvelope(devicePrivate, { encrypted: envelope.encrypted, tmpKey: wrongTmpKey }), + ).toThrow(); + }); + + it('throws on a tampered ciphertext (auth-tag failure)', () => { + const devicePrivate = p256.utils.randomSecretKey(); + const devicePublic = p256.getPublicKey(devicePrivate, false); + + const envelope = wrap(devicePublic, new Uint8Array([0x42, 0x42, 0x42])); + const tampered = new Uint8Array(envelope.encrypted); + const lastIdx = tampered.length - 1; + tampered[lastIdx] = (tampered[lastIdx] ?? 0) ^ 1; // flip a bit in the auth tag + + expect(() => decryptResponseEnvelope(devicePrivate, { encrypted: tampered, tmpKey: envelope.tmpKey })).toThrow(); + }); +}); diff --git a/packages/host-papp/__tests__/handshakeV2Proposal.spec.ts b/packages/host-papp/__tests__/handshakeV2Proposal.spec.ts new file mode 100644 index 00000000..276c72ce --- /dev/null +++ b/packages/host-papp/__tests__/handshakeV2Proposal.spec.ts @@ -0,0 +1,73 @@ +import { fromHex } from 'polkadot-api/utils'; +import { describe, expect, it } from 'vitest'; + +import { VersionedHandshakeProposal } from '../src/sso/auth/scale/handshakeV2.js'; +import type { HandshakeMetadata } from '../src/sso/auth/v2/proposal.js'; +import { buildPairingDeeplink, encodeProposal } from '../src/sso/auth/v2/proposal.js'; + +const device = { + statementAccountPublicKey: new Uint8Array(32).fill(0xa1), + encryptionPublicKey: new Uint8Array(65).fill(0x04), +}; + +const metadata: HandshakeMetadata = { + hostName: 'Polkadot Desktop', + hostVersion: '1.2.3', + platformType: 'macOS', + platformVersion: '15.0', +}; + +describe('encodeProposal', () => { + it('round-trips through VersionedHandshakeProposal', () => { + const encoded = encodeProposal(device, metadata); + const decoded = VersionedHandshakeProposal.dec(encoded); + + expect(decoded.tag).toBe('V2'); + if (decoded.tag !== 'V2') return; + expect(decoded.value.device.statementAccountId).toEqual(device.statementAccountPublicKey); + expect(decoded.value.device.encryptionPublicKey).toEqual(device.encryptionPublicKey); + }); + + it('emits V2 at byte discriminant 1 (peer wire-compat)', () => { + const encoded = encodeProposal(device, metadata); + expect(encoded[0]).toBe(1); + }); + + it('encodes hostName under MetadataKey.HostName', () => { + const encoded = encodeProposal(device, metadata); + const decoded = VersionedHandshakeProposal.dec(encoded); + if (decoded.tag !== 'V2') throw new Error('expected V2'); + + const entries = decoded.value.metadata.map(([key, value]) => ({ tag: key.tag, value })); + expect(entries).toContainEqual({ tag: 'HostName', value: 'Polkadot Desktop' }); + expect(entries).toContainEqual({ tag: 'HostVersion', value: '1.2.3' }); + expect(entries).toContainEqual({ tag: 'PlatformType', value: 'macOS' }); + expect(entries).toContainEqual({ tag: 'PlatformVersion', value: '15.0' }); + }); + + it('omits absent metadata fields', () => { + const encoded = encodeProposal(device, { hostName: 'just a host' }); + const decoded = VersionedHandshakeProposal.dec(encoded); + if (decoded.tag !== 'V2') throw new Error('expected V2'); + + expect(decoded.value.metadata).toHaveLength(1); + expect(decoded.value.metadata[0]?.[0]?.tag).toBe('HostName'); + }); +}); + +describe('buildPairingDeeplink', () => { + it('builds a polkadotapp://pair?handshake= URL', () => { + const url = buildPairingDeeplink(device, metadata); + expect(url).toMatch(/^polkadotapp:\/\/pair\?handshake=[0-9a-f]+$/); + }); + + it('embeds the encoded proposal hex in the handshake query param', () => { + const url = buildPairingDeeplink(device, metadata); + const match = url.match(/^polkadotapp:\/\/pair\?handshake=(.+)$/); + expect(match).not.toBeNull(); + if (!match) return; + + const expectedBytes = encodeProposal(device, metadata); + expect(fromHex(`0x${match[1] ?? ''}`)).toEqual(expectedBytes); + }); +}); diff --git a/packages/host-papp/__tests__/handshakeV2Service.spec.ts b/packages/host-papp/__tests__/handshakeV2Service.spec.ts new file mode 100644 index 00000000..454ab182 --- /dev/null +++ b/packages/host-papp/__tests__/handshakeV2Service.spec.ts @@ -0,0 +1,189 @@ +import { p256 } from '@noble/curves/nist.js'; +import type { Statement, StatementStoreAdapter } from '@novasamatech/statement-store'; +import { createEncryption } from '@novasamatech/statement-store'; +import { okAsync } from 'neverthrow'; +import { firstValueFrom, lastValueFrom, take, toArray } from 'rxjs'; +import { describe, expect, it, vi } from 'vitest'; + +import { EncryptedHandshakeResponseV2, VersionedHandshakeResponse } from '../src/sso/auth/scale/handshakeV2.js'; +import type { DeviceIdentityForPairing } from '../src/sso/auth/v2/service.js'; +import { startPairingV2 } from '../src/sso/auth/v2/service.js'; +import type { HandshakeSuccessState } from '../src/sso/auth/v2/state.js'; +import { computePairingTopic } from '../src/sso/auth/v2/topic.js'; + +const ecdhX = (priv: Uint8Array, pub: Uint8Array): Uint8Array => p256.getSharedSecret(priv, pub).slice(1, 33); + +const buildDeviceIdentity = (): DeviceIdentityForPairing => { + const encryptionPrivateKey = p256.utils.randomSecretKey(); + return { + statementAccountPublicKey: new Uint8Array(32).fill(0xa1), + encryptionPrivateKey, + encryptionPublicKey: p256.getPublicKey(encryptionPrivateKey, false), + }; +}; + +const wrapInnerResponse = ( + device: DeviceIdentityForPairing, + inner: Uint8Array, +): { encrypted: Uint8Array; tmpKey: Uint8Array } => { + const tmpPrivate = p256.utils.randomSecretKey(); + const tmpKey = p256.getPublicKey(tmpPrivate, false); + const shared = ecdhX(tmpPrivate, device.encryptionPublicKey); + const result = createEncryption(shared).encrypt(inner); + if (result.isErr()) throw result.error; + return { encrypted: result.value, tmpKey }; +}; + +const buildStatement = (device: DeviceIdentityForPairing, innerBytes: Uint8Array): Statement => { + const envelope = wrapInnerResponse(device, innerBytes); + return { + data: VersionedHandshakeResponse.enc({ tag: 'V2', value: envelope }), + }; +}; + +type FakeAdapter = StatementStoreAdapter & { emit: (statements: Statement[]) => void; lastFilter?: unknown }; + +const makeFakeStore = (): FakeAdapter => { + let cb: ((page: { statements: Statement[]; isComplete: boolean }) => unknown) | null = null; + const adapter: FakeAdapter = { + queryStatements: vi.fn().mockReturnValue(okAsync([])), + submitStatement: vi.fn(), + subscribeStatements: vi.fn((filter, callback) => { + adapter.lastFilter = filter; + cb = callback; + return () => { + cb = null; + }; + }), + emit: stmts => { + cb?.({ statements: stmts, isComplete: false }); + }, + }; + return adapter; +}; + +describe('startPairingV2', () => { + it('exposes a polkadotapp:// pairing deeplink as qrPayload', () => { + const device = buildDeviceIdentity(); + const store = makeFakeStore(); + + const pairing = startPairingV2({ + statementStore: store, + deviceIdentity: device, + metadata: { hostName: 'Polkadot Desktop' }, + }); + + expect(pairing.qrPayload).toMatch(/^polkadotapp:\/\/pair\?handshake=[0-9a-f]+$/); + pairing.abort(); + }); + + it('subscribes to the device pairing topic on startup', () => { + const device = buildDeviceIdentity(); + const store = makeFakeStore(); + + const pairing = startPairingV2({ + statementStore: store, + deviceIdentity: device, + metadata: {}, + }); + + const expectedTopic = computePairingTopic(device.statementAccountPublicKey, device.encryptionPublicKey); + expect(store.lastFilter).toEqual({ matchAll: [expectedTopic] }); + pairing.abort(); + }); + + it('starts in Submitted state', async () => { + const device = buildDeviceIdentity(); + const store = makeFakeStore(); + const pairing = startPairingV2({ statementStore: store, deviceIdentity: device, metadata: {} }); + + const first = await firstValueFrom(pairing.state$); + expect(first.tag).toBe('Submitted'); + pairing.abort(); + }); + + it('transitions Submitted → Pending → Success on the canonical response sequence', async () => { + const device = buildDeviceIdentity(); + const store = makeFakeStore(); + const persistOnSuccess = vi.fn().mockResolvedValue(undefined); + const pairing = startPairingV2({ + statementStore: store, + deviceIdentity: device, + metadata: {}, + persistOnSuccess, + }); + + const states$ = pairing.state$.pipe(take(3), toArray()); + const collected = lastValueFrom(states$); + + const pendingBytes = EncryptedHandshakeResponseV2.enc({ tag: 'Pending', value: undefined }); + store.emit([buildStatement(device, pendingBytes)]); + + const success: HandshakeSuccessState = { + tag: 'Success', + identityChatPublicKey: new Uint8Array(65).fill(0x04), + userIdentityAccountId: new Uint8Array(32).fill(0xb2), + identitySignature: new Uint8Array(64).fill(0xcc), + }; + const successBytes = EncryptedHandshakeResponseV2.enc({ + tag: 'Success', + value: { + encryptionKey: success.identityChatPublicKey, + accountId: success.userIdentityAccountId, + identitySignature: success.identitySignature, + }, + }); + store.emit([buildStatement(device, successBytes)]); + + const states = await collected; + expect(states.map(s => s.tag)).toEqual(['Submitted', 'Pending', 'Success']); + expect(persistOnSuccess).toHaveBeenCalledOnce(); + expect(persistOnSuccess).toHaveBeenCalledWith(expect.objectContaining({ tag: 'Success' })); + pairing.abort(); + }); + + it('transitions to Failed on a Failed inner response', async () => { + const device = buildDeviceIdentity(); + const store = makeFakeStore(); + const pairing = startPairingV2({ statementStore: store, deviceIdentity: device, metadata: {} }); + + const states$ = pairing.state$.pipe(take(2), toArray()); + const collected = lastValueFrom(states$); + + const failedBytes = EncryptedHandshakeResponseV2.enc({ tag: 'Failed', value: 'duplicate' }); + store.emit([buildStatement(device, failedBytes)]); + + const states = await collected; + expect(states.map(s => s.tag)).toEqual(['Submitted', 'Failed']); + expect(states[1]).toMatchObject({ tag: 'Failed', reason: 'duplicate' }); + pairing.abort(); + }); + + it('drops statements that cannot be decrypted (wrong recipient or tampered)', async () => { + const device = buildDeviceIdentity(); + const store = makeFakeStore(); + const pairing = startPairingV2({ statementStore: store, deviceIdentity: device, metadata: {} }); + + // Statement encrypted to a different device + const otherDevice = buildDeviceIdentity(); + const innerBytes = EncryptedHandshakeResponseV2.enc({ + tag: 'Failed', + value: 'should be dropped', + }); + store.emit([buildStatement(otherDevice, innerBytes)]); + + // Should still be in Submitted (no transition) + const state = await firstValueFrom(pairing.state$); + expect(state.tag).toBe('Submitted'); + pairing.abort(); + }); + + it('abort() is idempotent and tears down cleanly', () => { + const device = buildDeviceIdentity(); + const store = makeFakeStore(); + const pairing = startPairingV2({ statementStore: store, deviceIdentity: device, metadata: {} }); + + expect(() => pairing.abort()).not.toThrow(); + expect(() => pairing.abort()).not.toThrow(); + }); +}); diff --git a/packages/host-papp/__tests__/handshakeV2State.spec.ts b/packages/host-papp/__tests__/handshakeV2State.spec.ts new file mode 100644 index 00000000..e4713e50 --- /dev/null +++ b/packages/host-papp/__tests__/handshakeV2State.spec.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from 'vitest'; + +import { EncryptedHandshakeResponseV2 } from '../src/sso/auth/scale/handshakeV2.js'; +import type { HandshakeState } from '../src/sso/auth/v2/state.js'; +import { + advance, + canSubmitV2Statements, + fromInnerResponse, + idle, + isTerminal, + submitted, +} from '../src/sso/auth/v2/state.js'; + +const decode = (value: ReturnType) => + EncryptedHandshakeResponseV2.dec(EncryptedHandshakeResponseV2.enc(value)); + +describe('fromInnerResponse', () => { + it('maps Pending (single discriminant byte, no inner status) to Pending state', () => { + const r = decode({ tag: 'Pending', value: undefined }); + expect(fromInnerResponse(r)).toEqual({ tag: 'Pending', reason: 'AllowanceAllocation' }); + }); + + it('decodes a 1-byte Pending payload (peer wire-compat)', () => { + const r = EncryptedHandshakeResponseV2.dec(new Uint8Array([0x00])); + expect(r.tag).toBe('Pending'); + expect(fromInnerResponse(r)).toEqual({ tag: 'Pending', reason: 'AllowanceAllocation' }); + }); + + it('maps Success to Success state with all three key fields', () => { + const r = decode({ + tag: 'Success', + value: { + encryptionKey: new Uint8Array(65).fill(0x04), + accountId: new Uint8Array(32).fill(0xb2), + identitySignature: new Uint8Array(64).fill(0xcc), + }, + }); + const state = fromInnerResponse(r); + expect(state.tag).toBe('Success'); + if (state.tag === 'Success') { + expect(state.identityChatPublicKey.length).toBe(65); + expect(state.userIdentityAccountId.length).toBe(32); + expect(state.identitySignature.length).toBe(64); + } + }); + + it('maps Failed to Failed state with reason string', () => { + const r = decode({ tag: 'Failed', value: 'no slot available' }); + expect(fromInnerResponse(r)).toEqual({ tag: 'Failed', reason: 'no slot available' }); + }); +}); + +describe('advance', () => { + it('Idle → Submitted is allowed', () => { + expect(advance(idle(), submitted())).toEqual(submitted()); + }); + + it('Submitted → Pending is allowed', () => { + const pending: HandshakeState = { tag: 'Pending', reason: 'AllowanceAllocation' }; + expect(advance(submitted(), pending)).toEqual(pending); + }); + + it('Pending → Success is allowed', () => { + const pending: HandshakeState = { tag: 'Pending', reason: 'AllowanceAllocation' }; + const success: HandshakeState = { + tag: 'Success', + identityChatPublicKey: new Uint8Array(65), + userIdentityAccountId: new Uint8Array(32), + identitySignature: new Uint8Array(64), + }; + expect(advance(pending, success)).toEqual(success); + }); + + it('terminal states are absorbing — Success cannot regress to Pending', () => { + const success: HandshakeState = { + tag: 'Success', + identityChatPublicKey: new Uint8Array(65), + userIdentityAccountId: new Uint8Array(32), + identitySignature: new Uint8Array(64), + }; + const pending: HandshakeState = { tag: 'Pending', reason: 'AllowanceAllocation' }; + expect(advance(success, pending)).toEqual(success); + }); + + it('terminal states are absorbing — Failed cannot regress to Pending', () => { + const failed: HandshakeState = { tag: 'Failed', reason: 'declined' }; + const pending: HandshakeState = { tag: 'Pending', reason: 'AllowanceAllocation' }; + expect(advance(failed, pending)).toEqual(failed); + }); + + it('Submitted → Idle is rejected (no backwards regression)', () => { + expect(advance(submitted(), idle())).toEqual(submitted()); + }); + + it('Idle → Pending is rejected (must Submit first)', () => { + const pending: HandshakeState = { tag: 'Pending', reason: 'AllowanceAllocation' }; + expect(advance(idle(), pending)).toEqual(idle()); + }); +}); + +describe('isTerminal', () => { + it('returns true for Success', () => { + expect( + isTerminal({ + tag: 'Success', + identityChatPublicKey: new Uint8Array(65), + userIdentityAccountId: new Uint8Array(32), + identitySignature: new Uint8Array(64), + }), + ).toBe(true); + }); + + it('returns true for Failed', () => { + expect(isTerminal({ tag: 'Failed', reason: 'declined' })).toBe(true); + }); + + it('returns false for Idle / Submitted / Pending', () => { + expect(isTerminal(idle())).toBe(false); + expect(isTerminal(submitted())).toBe(false); + expect(isTerminal({ tag: 'Pending', reason: 'AllowanceAllocation' })).toBe(false); + }); +}); + +describe('canSubmitV2Statements', () => { + it('only true in Success', () => { + const success: HandshakeState = { + tag: 'Success', + identityChatPublicKey: new Uint8Array(65), + userIdentityAccountId: new Uint8Array(32), + identitySignature: new Uint8Array(64), + }; + expect(canSubmitV2Statements(success)).toBe(true); + expect(canSubmitV2Statements(idle())).toBe(false); + expect(canSubmitV2Statements(submitted())).toBe(false); + expect(canSubmitV2Statements({ tag: 'Pending', reason: 'AllowanceAllocation' })).toBe(false); + expect(canSubmitV2Statements({ tag: 'Failed', reason: 'x' })).toBe(false); + }); +}); diff --git a/packages/host-papp/__tests__/handshakeV2Topic.spec.ts b/packages/host-papp/__tests__/handshakeV2Topic.spec.ts new file mode 100644 index 00000000..45c37907 --- /dev/null +++ b/packages/host-papp/__tests__/handshakeV2Topic.spec.ts @@ -0,0 +1,50 @@ +import { khash } from '@novasamatech/statement-store'; +import { mergeUint8 } from '@polkadot-api/utils'; +import { describe, expect, it } from 'vitest'; + +import { computePairingChannel, computePairingTopic } from '../src/sso/auth/v2/topic.js'; + +const statementAccountId = new Uint8Array(32).fill(0xa1); +const encryptionPublicKey = new Uint8Array(65).fill(0x04); + +describe('computePairingTopic', () => { + it('matches spec: khash(statementAccountId, encryptionPublicKey || "topic")', () => { + const expected = khash(statementAccountId, mergeUint8([encryptionPublicKey, new TextEncoder().encode('topic')])); + expect(computePairingTopic(statementAccountId, encryptionPublicKey)).toEqual(expected); + }); + + it('produces a 32-byte output', () => { + expect(computePairingTopic(statementAccountId, encryptionPublicKey).length).toBe(32); + }); + + it('is deterministic', () => { + const a = computePairingTopic(statementAccountId, encryptionPublicKey); + const b = computePairingTopic(statementAccountId, encryptionPublicKey); + expect(a).toEqual(b); + }); + + it('differs when the device account id differs', () => { + const a = computePairingTopic(statementAccountId, encryptionPublicKey); + const b = computePairingTopic(new Uint8Array(32).fill(0xa2), encryptionPublicKey); + expect(a).not.toEqual(b); + }); + + it('differs when the encryption public key differs', () => { + const a = computePairingTopic(statementAccountId, encryptionPublicKey); + const b = computePairingTopic(statementAccountId, new Uint8Array(65).fill(0x05)); + expect(a).not.toEqual(b); + }); +}); + +describe('computePairingChannel', () => { + it('matches spec: khash(statementAccountId, encryptionPublicKey || "channel")', () => { + const expected = khash(statementAccountId, mergeUint8([encryptionPublicKey, new TextEncoder().encode('channel')])); + expect(computePairingChannel(statementAccountId, encryptionPublicKey)).toEqual(expected); + }); + + it('differs from the topic for the same device', () => { + const topic = computePairingTopic(statementAccountId, encryptionPublicKey); + const channel = computePairingChannel(statementAccountId, encryptionPublicKey); + expect(topic).not.toEqual(channel); + }); +}); diff --git a/packages/host-papp/package.json b/packages/host-papp/package.json index cbdc88fd..02114829 100644 --- a/packages/host-papp/package.json +++ b/packages/host-papp/package.json @@ -38,12 +38,15 @@ "@novasamatech/scale": "0.7.9", "@novasamatech/statement-store": "0.7.9", "@novasamatech/storage-adapter": "0.7.9", + "@polkadot-api/utils": "^0.4.0", "@polkadot-labs/hdkd-helpers": "^0.0.30", "nanoevents": "9.1.0", "nanoid": "5.1.9", "neverthrow": "^8.2.0", "polkadot-api": ">=2", - "scale-ts": "1.6.1" + "rxjs": "^7.8.2", + "scale-ts": "1.6.1", + "verifiablejs": "1.2.0" }, "publishConfig": { "access": "public" diff --git a/packages/host-papp/src/index.ts b/packages/host-papp/src/index.ts index 2bcb821d..bd4a9d76 100644 --- a/packages/host-papp/src/index.ts +++ b/packages/host-papp/src/index.ts @@ -16,3 +16,43 @@ export type { SigningRequest, } from './sso/sessionManager/scale/signing.js'; export type { RingVrfAliasRequest, RingVrfAliasResponse } from './sso/sessionManager/scale/ringVrf.js'; + +// ── V2 SSO handshake ───────────────────────────────────────────────────── + +export type { EncryptedHandshakeResponseV2Value } from './sso/auth/scale/handshakeV2.js'; +export { + Device, + EncryptedHandshakeResponseV1, + EncryptedHandshakeResponseV2, + HandshakeProposalV2, + HandshakeResponseV1, + HandshakeResponseV2, + HandshakeStatusV2, + HandshakeSuccessV2, + IDENTITY_SIGNATURE_PAYLOAD_BYTES, + MetadataEntry, + MetadataKey, + VersionedHandshakeProposal, + VersionedHandshakeResponse, +} from './sso/auth/scale/handshakeV2.js'; + +export { computePairingChannel, computePairingTopic } from './sso/auth/v2/topic.js'; + +export type { HandshakeMetadata, HandshakeProposalDevice } from './sso/auth/v2/proposal.js'; +export { buildPairingDeeplink, encodeProposal } from './sso/auth/v2/proposal.js'; + +export type { HandshakeResponseEnvelope } from './sso/auth/v2/envelope.js'; +export { decryptResponseEnvelope } from './sso/auth/v2/envelope.js'; + +export type { + HandshakeFailedState, + HandshakeIdleState, + HandshakePendingState, + HandshakeState, + HandshakeSubmittedState, + HandshakeSuccessState, +} from './sso/auth/v2/state.js'; +export { advance, canSubmitV2Statements, fromInnerResponse, idle, isTerminal, submitted } from './sso/auth/v2/state.js'; + +export type { DeviceIdentityForPairing, Pairing, StartPairingDeps } from './sso/auth/v2/service.js'; +export { startPairingV2 } from './sso/auth/v2/service.js'; diff --git a/packages/host-papp/src/sso/auth/scale/handshakeV2.ts b/packages/host-papp/src/sso/auth/scale/handshakeV2.ts new file mode 100644 index 00000000..ca503468 --- /dev/null +++ b/packages/host-papp/src/sso/auth/scale/handshakeV2.ts @@ -0,0 +1,156 @@ +/** + * SCALE codecs for the V2 SSO handshake. + * + * The host emits a `VersionedHandshakeProposal::V2` via QR carrying its + * `Device { statementAccountId, encryptionPublicKey }` and metadata. The + * authorising peer responds over the Statement Store with a + * `VersionedHandshakeResponse`; its body is ECDH-encrypted to the host's + * encryption public key with the peer's ephemeral `tmpKey`. The inner + * payload after decrypt is `EncryptedHandshakeResponseV2 = Pending | Success + * | Failed`. + * + * `Success` carries the user identity chat encryption pubkey, the user + * identity sr25519 accountId, and a 64-byte sr25519 signature over + * `statementAccountId || encryptionPublicKey` (97 bytes — see + * `IDENTITY_SIGNATURE_PAYLOAD_BYTES`) proving the user authorised this device. + */ + +import type { Codec } from 'scale-ts'; +import { Bytes, Enum, Struct, Tuple, Vector, _void, createCodec, str } from 'scale-ts'; + +const AccountIdCodec = Bytes(32); +const PublicKeyCodec = Bytes(65); +const SignatureCodec = Bytes(64); + +/** Bytes the user identity sr25519 signs to authorise a device: accountId(32) || encPub(65). */ +export const IDENTITY_SIGNATURE_PAYLOAD_BYTES = 32 + 65; + +// ── Proposal ──────────────────────────────────────────────────────────── + +export const MetadataKey = Enum({ + Custom: str, + HostName: _void, + HostVersion: _void, + HostIcon: _void, + PlatformType: _void, + PlatformVersion: _void, +}); + +export const MetadataEntry = Tuple(MetadataKey, str); + +export const Device = Struct({ + statementAccountId: AccountIdCodec, + encryptionPublicKey: PublicKeyCodec, +}); + +export const HandshakeProposalV2 = Struct({ + device: Device, + metadata: Vector(MetadataEntry), +}); + +/** + * V2 lives at SCALE discriminant 1; index 0 is reserved for legacy V1 which + * neither side emits. The `_v1Reserved` slot pushes V2 to discriminant 1. + */ +export const VersionedHandshakeProposal = Enum({ + _v1Reserved: _void, + V2: HandshakeProposalV2, +}); + +// ── Response (V2) ─────────────────────────────────────────────────────── + +export const HandshakeSuccessV2 = Struct({ + encryptionKey: PublicKeyCodec, + accountId: AccountIdCodec, + identitySignature: SignatureCodec, +}); + +/** + * Inner Pending sub-statuses; only `AllowanceAllocation` today. Kept for + * schema symmetry — not actually encoded on the wire because the peer + * encodes `data object AllowanceAllocation` as zero bytes, so the entire + * Pending statement is just the outer discriminant `0x00`. + */ +export const HandshakeStatusV2 = Enum({ + AllowanceAllocation: _void, +}); + +const SUCCESS_LEN = 65 + 32 + 64; +const PENDING_BYTE = 0x00; + +export type EncryptedHandshakeResponseV2Value = + | { tag: 'Pending'; value: undefined } + | { + tag: 'Success'; + value: { encryptionKey: Uint8Array; accountId: Uint8Array; identitySignature: Uint8Array }; + } + | { tag: 'Failed'; value: string }; + +const toBytes = (value: Uint8Array | ArrayBuffer | string): Uint8Array => { + if (typeof value === 'string') { + const hex = value.startsWith('0x') ? value.slice(2) : value; + const out = new Uint8Array(hex.length / 2); + for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); + return out; + } + if (value instanceof ArrayBuffer) return new Uint8Array(value); + return value; +}; + +/** + * Length-dispatched variant codec. The peer's SCALE library elides the outer + * enum index for class-wrapped sealed-interface variants, leaving each + * variant's bytes exposed directly: + * + * - Pending → 1 byte: 0x00 (the inner `AllowanceAllocation` tag; the + * outer Pending wrapper emits nothing) + * - Success → 161 bytes: HandshakeSuccessV2 fields concatenated + * (encryptionKey 65 || accountId 32 || signature 64) + * - Failed → variable: SCALE-encoded UTF-8 reason string + * + * Disambiguation is purely by byte length; protocol-state context further + * constrains which variant is plausible at any given moment. + */ +export const EncryptedHandshakeResponseV2: Codec = createCodec( + v => { + switch (v.tag) { + case 'Pending': + return new Uint8Array([PENDING_BYTE]); + case 'Success': + return HandshakeSuccessV2.enc(v.value); + case 'Failed': + return str.enc(v.value); + } + }, + raw => { + const bytes = toBytes(raw); + if (bytes.length === 1 && bytes[0] === PENDING_BYTE) { + return { tag: 'Pending', value: undefined }; + } + if (bytes.length === SUCCESS_LEN) { + return { tag: 'Success', value: HandshakeSuccessV2.dec(bytes) }; + } + return { tag: 'Failed', value: str.dec(bytes) }; + }, +); + +export const HandshakeResponseV2 = Struct({ + encrypted: Bytes(), + tmpKey: PublicKeyCodec, +}); + +/** Legacy V1 response shape — decoded for backward compat, never emitted by the V2 path. */ +export const HandshakeResponseV1 = Struct({ + encrypted: Bytes(), + tmpKey: PublicKeyCodec, +}); + +export const EncryptedHandshakeResponseV1 = Struct({ + encryptionKey: PublicKeyCodec, + accountId: AccountIdCodec, +}); + +export const VersionedHandshakeResponse = Enum({ + V1: HandshakeResponseV1, + V2: HandshakeResponseV2, +}); diff --git a/packages/host-papp/src/sso/auth/v2/envelope.ts b/packages/host-papp/src/sso/auth/v2/envelope.ts new file mode 100644 index 00000000..cd5080b2 --- /dev/null +++ b/packages/host-papp/src/sso/auth/v2/envelope.ts @@ -0,0 +1,34 @@ +/** + * Decrypt the outer envelope of a `HandshakeResponseV2` statement payload. + * + * The answering side generates a one-shot P-256 keypair, performs ECDH against + * the host device's encryption public key, and AES-GCM encrypts the sensitive + * payload (the SCALE-encoded `EncryptedHandshakeResponseV2`) with a key + * derived from the shared secret. + * + * The shared-secret-to-AES-key derivation (HKDF-SHA256 over the ECDH X + * coordinate) is delegated to `createEncryption(sharedSecret)` from + * `@novasamatech/statement-store` — byte-compatible with the existing V1 + * chat-request encryption helper, so we don't fork primitives here. + */ + +import { p256 } from '@noble/curves/nist.js'; +import { createEncryption } from '@novasamatech/statement-store'; + +export type HandshakeResponseEnvelope = { + encrypted: Uint8Array; + tmpKey: Uint8Array; +}; + +const ecdhX = (privateKey: Uint8Array, peerPublicKey: Uint8Array): Uint8Array => + p256.getSharedSecret(privateKey, peerPublicKey).slice(1, 33); + +export const decryptResponseEnvelope = ( + deviceEncryptionPrivateKey: Uint8Array, + envelope: HandshakeResponseEnvelope, +): Uint8Array => { + const shared = ecdhX(deviceEncryptionPrivateKey, envelope.tmpKey); + const result = createEncryption(shared).decrypt(envelope.encrypted); + if (result.isErr()) throw result.error; + return result.value; +}; diff --git a/packages/host-papp/src/sso/auth/v2/proposal.ts b/packages/host-papp/src/sso/auth/v2/proposal.ts new file mode 100644 index 00000000..aec91a04 --- /dev/null +++ b/packages/host-papp/src/sso/auth/v2/proposal.ts @@ -0,0 +1,76 @@ +/** + * Build the V2 SSO pairing proposal that the host emits via QR / deeplink. + * + * The encoded proposal goes into the `handshake` query parameter of a + * `polkadotapp://pair?handshake=` deeplink. The peer scans the QR, + * SCALE-decodes the bytes back into a `VersionedHandshakeProposal`, posts an + * encrypted answer to the corresponding pairing topic, and the host's + * subscription picks it up. + */ + +import { toHex } from 'polkadot-api/utils'; + +import { VersionedHandshakeProposal } from '../scale/handshakeV2.js'; + +const PAIRING_DEEPLINK_PREFIX = 'polkadotapp://pair?handshake='; + +export type HandshakeProposalDevice = { + statementAccountPublicKey: Uint8Array; + encryptionPublicKey: Uint8Array; +}; + +export type HandshakeMetadata = { + hostName?: string; + hostVersion?: string; + hostIcon?: string; + platformType?: string; + platformVersion?: string; + custom?: { name: string; value: string }[]; +}; + +type MetadataEntryT = [ + ( + | { tag: 'Custom'; value: string } + | { tag: 'HostName'; value: undefined } + | { tag: 'HostVersion'; value: undefined } + | { tag: 'HostIcon'; value: undefined } + | { tag: 'PlatformType'; value: undefined } + | { tag: 'PlatformVersion'; value: undefined } + ), + string, +]; + +const buildMetadataEntries = (metadata: HandshakeMetadata): MetadataEntryT[] => { + const entries: MetadataEntryT[] = []; + if (metadata.hostName !== undefined) entries.push([{ tag: 'HostName', value: undefined }, metadata.hostName]); + if (metadata.hostVersion !== undefined) + entries.push([{ tag: 'HostVersion', value: undefined }, metadata.hostVersion]); + if (metadata.hostIcon !== undefined) entries.push([{ tag: 'HostIcon', value: undefined }, metadata.hostIcon]); + if (metadata.platformType !== undefined) + entries.push([{ tag: 'PlatformType', value: undefined }, metadata.platformType]); + if (metadata.platformVersion !== undefined) { + entries.push([{ tag: 'PlatformVersion', value: undefined }, metadata.platformVersion]); + } + for (const c of metadata.custom ?? []) { + entries.push([{ tag: 'Custom', value: c.name }, c.value]); + } + return entries; +}; + +export const encodeProposal = (device: HandshakeProposalDevice, metadata: HandshakeMetadata): Uint8Array => + VersionedHandshakeProposal.enc({ + tag: 'V2', + value: { + device: { + statementAccountId: device.statementAccountPublicKey, + encryptionPublicKey: device.encryptionPublicKey, + }, + metadata: buildMetadataEntries(metadata), + }, + }); + +export const buildPairingDeeplink = (device: HandshakeProposalDevice, metadata: HandshakeMetadata): string => { + const bytes = encodeProposal(device, metadata); + const hex = toHex(bytes).replace(/^0x/, ''); + return `${PAIRING_DEEPLINK_PREFIX}${hex}`; +}; diff --git a/packages/host-papp/src/sso/auth/v2/service.ts b/packages/host-papp/src/sso/auth/v2/service.ts new file mode 100644 index 00000000..992bed44 --- /dev/null +++ b/packages/host-papp/src/sso/auth/v2/service.ts @@ -0,0 +1,217 @@ +/** + * Orchestrates a single V2 SSO pairing exchange. + * + * Wires the codec, envelope, topic, and state-machine pieces together: + * 1. Encode the device's `VersionedHandshakeProposal::V2` and build the + * `polkadotapp://pair?handshake=` deeplink (consumed by the QR UI). + * 2. Compute the pairing topic from the device pubkeys and subscribe to the + * Statement Store on it. + * 3. For each incoming statement: SCALE-decode `VersionedHandshakeResponse`, + * pull out the `V2` envelope, ECDH-decrypt the inner payload using the + * device's encryption private key, SCALE-decode `EncryptedHandshakeResponseV2`, + * and feed the result through the state machine via `fromInnerResponse` + + * `advance`. + * 4. On `Success`, invoke the caller-supplied `persistOnSuccess` and stop. + * + * The chain RPC subscription only delivers a statement once when it first + * appears on a topic; channel replacements (Pending → Success on the same + * channel) don't reliably get re-broadcast as new events. So the service also + * polls `queryStatements` every 2s alongside the live subscription. Polling + * stops on terminal state and on `abort()`. + * + * Returns an Observable of `HandshakeState` and an `abort()` for cleanup — + * higher layers drive a UI hook off this and update an onboarding screen. + */ + +import type { Statement, StatementStoreAdapter } from '@novasamatech/statement-store'; +import type { Observable } from 'rxjs'; +import { BehaviorSubject } from 'rxjs'; + +import { EncryptedHandshakeResponseV2, VersionedHandshakeResponse } from '../scale/handshakeV2.js'; + +import { decryptResponseEnvelope } from './envelope.js'; +import type { HandshakeMetadata } from './proposal.js'; +import { buildPairingDeeplink } from './proposal.js'; +import type { HandshakeState, HandshakeSuccessState } from './state.js'; +import { advance, fromInnerResponse, isTerminal, submitted } from './state.js'; +import { computePairingTopic } from './topic.js'; + +export type DeviceIdentityForPairing = { + statementAccountPublicKey: Uint8Array; + encryptionPublicKey: Uint8Array; + encryptionPrivateKey: Uint8Array; +}; + +export type StartPairingDeps = { + statementStore: StatementStoreAdapter; + deviceIdentity: DeviceIdentityForPairing; + metadata: HandshakeMetadata; + persistOnSuccess?: (success: HandshakeSuccessState) => Promise; + /** + * Hex of a previously-processed statement. The service will skip statements + * whose bytes match this value, treating them as already-handled. Useful for + * surviving page reloads / logouts so a stale Success on chain doesn't get + * replayed before the user re-authenticates. + */ + initialProcessedDataHex?: string | null; + /** + * Fires whenever the service starts processing a new statement (i.e. after + * the byte-level dedupe passes). Callers persist the hex so the next + * `initialProcessedDataHex` value is up to date. + */ + onStatementProcessed?: (dataHex: string) => void; +}; + +export type Pairing = { + qrPayload: string; + state$: Observable; + abort: () => void; +}; + +const DEFAULT_POLL_INTERVAL_MS = 2_000; + +const toHexFull = (bytes: Uint8Array) => { + let out = ''; + for (const b of bytes) out += b.toString(16).padStart(2, '0'); + return `0x${out}`; +}; + +export const startPairingV2 = (deps: StartPairingDeps): Pairing => { + const persistOnSuccess = deps.persistOnSuccess; + + const qrPayload = buildPairingDeeplink( + { + statementAccountPublicKey: deps.deviceIdentity.statementAccountPublicKey, + encryptionPublicKey: deps.deviceIdentity.encryptionPublicKey, + }, + deps.metadata, + ); + + const state$ = new BehaviorSubject(submitted()); + const topic = computePairingTopic( + deps.deviceIdentity.statementAccountPublicKey, + deps.deviceIdentity.encryptionPublicKey, + ); + + let aborted = false; + let unsubscribe: (() => void) | null = null; + let pollHandle: ReturnType | null = null; + let lastProcessedDataHex: string | null = deps.initialProcessedDataHex ?? null; + + const stopPolling = () => { + if (pollHandle === null) return; + clearInterval(pollHandle); + pollHandle = null; + }; + + const log = (msg: string, extra?: unknown) => { + if (extra === undefined) console.info(`[sso-v2] ${msg}`); + else console.info(`[sso-v2] ${msg}`, extra); + }; + + log(`subscribing to pairing topic ${toHexFull(topic)}`); + log(`device statementAccountId ${toHexFull(deps.deviceIdentity.statementAccountPublicKey)}`); + log(`device encryptionPublicKey ${toHexFull(deps.deviceIdentity.encryptionPublicKey)}`); + + // One-shot probe: query the topic right away so we know whether any answer + // statement was already posted before our subscription connected. + void deps.statementStore.queryStatements({ matchAll: [topic] }).match( + statements => log(`queryStatements probe: ${statements.length} statement(s)`), + err => log('queryStatements probe failed', err), + ); + + const handleStatement = (statement: Statement) => { + if (aborted || isTerminal(state$.value)) return; + if (!statement.data) return; + const dataHex = toHexFull(statement.data); + if (dataHex === lastProcessedDataHex) return; + lastProcessedDataHex = dataHex; + deps.onStatementProcessed?.(dataHex); + log(`statement received, ${statement.data.length}-byte payload`); + + let envelope: { encrypted: Uint8Array; tmpKey: Uint8Array }; + try { + const decoded = VersionedHandshakeResponse.dec(statement.data); + if (decoded.tag !== 'V2') { + log(`response is not V2 (tag=${decoded.tag}) — dropping`); + return; + } + envelope = decoded.value; + } catch (err) { + log('VersionedHandshakeResponse.dec threw — dropping', err); + return; + } + + let innerBytes: Uint8Array; + try { + innerBytes = decryptResponseEnvelope(deps.deviceIdentity.encryptionPrivateKey, envelope); + } catch (err) { + log('outer envelope decrypt failed — wrong recipient or tampered', err); + return; + } + + let next: HandshakeState; + try { + next = fromInnerResponse(EncryptedHandshakeResponseV2.dec(innerBytes)); + } catch (err) { + log(`inner decode failed; innerBytes (${innerBytes.length}b) = ${toHexFull(innerBytes)}`, err); + return; + } + + log(`decoded inner response, tag=${next.tag}`); + + const advanced = advance(state$.value, next); + if (advanced === state$.value) { + // Same-tag idempotence is the common case (every poll re-fetches the + // current statement); only log when a transition was actually rejected + // for protocol-state reasons (e.g. Success → Pending). + if (advanced.tag !== next.tag) { + log(`advance() rejected ${state$.value.tag} → ${next.tag} — dropping`); + } + return; + } + log(`state ${state$.value.tag} → ${advanced.tag}`); + state$.next(advanced); + + if (advanced.tag === 'Success' && persistOnSuccess !== undefined) { + void persistOnSuccess(advanced).catch(err => { + console.warn('persistHandshakeSuccess failed', err); + }); + } + + if (isTerminal(advanced)) { + stopPolling(); + } + }; + + unsubscribe = deps.statementStore.subscribeStatements({ matchAll: [topic] }, page => { + log(`subscription page: ${page.statements.length} statement(s), isComplete=${page.isComplete}`); + for (const stmt of page.statements) handleStatement(stmt); + }); + + // Poll because the chain RPC subscription doesn't deliver channel + // replacements as new events. handleStatement dedupes on data bytes so + // re-fetching the same Pending tick after tick is silent. + pollHandle = setInterval(() => { + if (aborted || isTerminal(state$.value)) return; + deps.statementStore.queryStatements({ matchAll: [topic] }).match( + statements => { + for (const stmt of statements) handleStatement(stmt); + }, + err => log('poll queryStatements failed', err), + ); + }, DEFAULT_POLL_INTERVAL_MS); + + return { + qrPayload, + state$: state$.asObservable(), + abort: () => { + if (aborted) return; + aborted = true; + stopPolling(); + unsubscribe?.(); + unsubscribe = null; + state$.complete(); + }, + }; +}; diff --git a/packages/host-papp/src/sso/auth/v2/state.ts b/packages/host-papp/src/sso/auth/v2/state.ts new file mode 100644 index 00000000..4c9e7591 --- /dev/null +++ b/packages/host-papp/src/sso/auth/v2/state.ts @@ -0,0 +1,85 @@ +/** + * Handshake V2 state machine — the public-facing observable shape of an + * in-flight SSO pairing exchange. + * + * Maps directly onto the inner `EncryptedHandshakeResponseV2` enum: + * + * Idle — no proposal emitted yet + * Submitted — proposal QR shown, waiting for the first response statement + * Pending — peer acknowledged; allocating Statement Store allowance on-chain + * Success — final state; identity keys received, device authorised + * Failed — final state; peer rejected (declined / duplicate / no-slot / tx-failed) + * + * Transitions are unidirectional except for Failed → Idle (user retries). + * The state object is what UIs render and what the chat layer gates on + * before submitting any V2 statements. + */ + +import type { CodecType } from 'scale-ts'; + +import type { EncryptedHandshakeResponseV2 } from '../scale/handshakeV2.js'; + +export type HandshakeIdleState = { tag: 'Idle' }; +export type HandshakeSubmittedState = { tag: 'Submitted' }; +export type HandshakePendingState = { tag: 'Pending'; reason: 'AllowanceAllocation' }; +export type HandshakeSuccessState = { + tag: 'Success'; + identityChatPublicKey: Uint8Array; + userIdentityAccountId: Uint8Array; + identitySignature: Uint8Array; +}; +export type HandshakeFailedState = { tag: 'Failed'; reason: string }; + +export type HandshakeState = + | HandshakeIdleState + | HandshakeSubmittedState + | HandshakePendingState + | HandshakeSuccessState + | HandshakeFailedState; + +export const idle = (): HandshakeIdleState => ({ tag: 'Idle' }); + +export const submitted = (): HandshakeSubmittedState => ({ tag: 'Submitted' }); + +/** + * Translate an inner-decoded `EncryptedHandshakeResponseV2` into the public + * state. Pure — no I/O. The caller decrypts the outer envelope first. + */ +export const fromInnerResponse = (response: CodecType): HandshakeState => { + switch (response.tag) { + case 'Pending': + // Only AllowanceAllocation today; widen here when the spec adds more variants. + return { tag: 'Pending', reason: 'AllowanceAllocation' }; + case 'Success': + return { + tag: 'Success', + identityChatPublicKey: response.value.encryptionKey, + userIdentityAccountId: response.value.accountId, + identitySignature: response.value.identitySignature, + }; + case 'Failed': + return { tag: 'Failed', reason: response.value }; + } +}; + +/** + * Forward-only transition guard: rejects regressions like Success → Pending. + * Idempotent on same-tag transitions (returns current by reference) so callers + * can use `next === current` to detect "no change" without per-call equality. + */ +export const advance = (current: HandshakeState, next: HandshakeState): HandshakeState => { + if (isTerminal(current)) return current; + if (current.tag === 'Idle' && next.tag !== 'Submitted') return current; + if (current.tag === 'Submitted' && next.tag === 'Idle') return current; + if (current.tag === next.tag) return current; + return next; +}; + +export const isTerminal = (state: HandshakeState): state is HandshakeSuccessState | HandshakeFailedState => + state.tag === 'Success' || state.tag === 'Failed'; + +/** + * True only when the device is authorised to submit V2 statements. The + * chat-send path gates on this — V2 messages must wait for allowance. + */ +export const canSubmitV2Statements = (state: HandshakeState): state is HandshakeSuccessState => state.tag === 'Success'; diff --git a/packages/host-papp/src/sso/auth/v2/topic.ts b/packages/host-papp/src/sso/auth/v2/topic.ts new file mode 100644 index 00000000..f61a77ce --- /dev/null +++ b/packages/host-papp/src/sso/auth/v2/topic.ts @@ -0,0 +1,27 @@ +/** + * Pairing topic + channel derivation for the V2 SSO handshake. + * + * topic = blake2b256_keyed(encryptionPublicKey || "topic", key=statementAccountId) + * channel = blake2b256_keyed(encryptionPublicKey || "channel", key=statementAccountId) + * + * Where: + * - `statementAccountId` = the host's sr25519 device public key (32 bytes) + * - `encryptionPublicKey` = the host's P-256 device public key (65 bytes uncompressed) + * + * Both sides compute the same topic/channel deterministically from the same + * pubkeys carried in the QR-coded `VersionedHandshakeProposal::V2`, so they + * agree on where the response statement is delivered without any shared + * secret negotiation. + */ + +import { khash } from '@novasamatech/statement-store'; +import { mergeUint8 } from '@polkadot-api/utils'; + +const TOPIC_SUFFIX = new TextEncoder().encode('topic'); +const CHANNEL_SUFFIX = new TextEncoder().encode('channel'); + +export const computePairingTopic = (statementAccountId: Uint8Array, encryptionPublicKey: Uint8Array): Uint8Array => + khash(statementAccountId, mergeUint8([encryptionPublicKey, TOPIC_SUFFIX])); + +export const computePairingChannel = (statementAccountId: Uint8Array, encryptionPublicKey: Uint8Array): Uint8Array => + khash(statementAccountId, mergeUint8([encryptionPublicKey, CHANNEL_SUFFIX])); From 3f64e609aee480e131df375a4d67336991a6ed83 Mon Sep 17 00:00:00 2001 From: Ilya Date: Wed, 29 Apr 2026 15:30:07 -0600 Subject: [PATCH 02/31] docs(host-papp): document the V2 SSO handshake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `## V2 SSO handshake` section covering: - protocol overview vs V1 (incompat) - flow diagram (host ↔ peer) - building the proposal QR via `buildPairingDeeplink` - driving the handshake via `startPairingV2`, the state machine, and abort - surviving reloads + proper logout via `initialProcessedDataHex` / `onStatementProcessed` byte-level dedupe - topic/channel derivation helpers - SCALE codec exports table (proposal, response, success, signature payload) The existing "Authentication and pairing" section is now flagged as the V1 flow and links to the V2 section, so callers can pick the right path. --- packages/host-papp/README.md | 171 ++++++++++++++++++++++++++++++++++- 1 file changed, 170 insertions(+), 1 deletion(-) diff --git a/packages/host-papp/README.md b/packages/host-papp/README.md index b6b9e9d8..1e90b0d5 100644 --- a/packages/host-papp/README.md +++ b/packages/host-papp/README.md @@ -60,7 +60,11 @@ const papp = createPappAdapter({ Custom adapters (statement store, identity RPC, storage, lazy chain client) can be supplied via the `adapters` option for testing or non-browser environments. -## Authentication and pairing +## Authentication and pairing (V1) + +The V1 SSO flow described in this section is the single-device pairing protocol used by +`papp.sso.authenticate()`. For the multi-device V2 protocol see +[V2 SSO handshake](#v2-sso-handshake) below. `papp.sso.authenticate()` runs the full pairing + attestation flow and resolves with the 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) => { // Batch lookup await papp.identity.getIdentities([accountIdA, accountIdB]); ``` + +## V2 SSO handshake + +V2 is a redesign of the SSO pairing flow that supports the same user identity across +multiple devices. The host generates a stable device keypair locally, emits a +`VersionedHandshakeProposal::V2` via QR/deeplink, and an authorising peer (e.g. the user's +existing Polkadot App) responds over the Statement Store with the user identity keys signed +to authorise this device. Subsequent devices belonging to the same user reuse the same +identity, so contacts, chats, and roster events are shared between them. + +V2 is **not interoperable with V1**: a V1-only peer can't decode a V2 proposal QR and vice +versa. Hosts that want to support both should branch on which protocol the peer advertises. + +### Shape of the flow + +``` +host peer (authorising device) +──────────────────────────────────────────────────────────────────────── +buildPairingDeeplink(device, metadata) + → polkadotapp://pair?handshake= + scan QR, decode proposal + compute pairing topic from + the host's pubkeys + ECDH-encrypt + post: + Pending(AllowanceAllocation) + Success { encryptionKey, + accountId, + identitySignature } + Failed(reason) +service.subscribeStatements(topic) + + poll the topic every 2s + ↓ +decode VersionedHandshakeResponse::V2 + → ECDH-decrypt envelope with the + device encryption private key + → SCALE-decode inner payload + → state machine: Submitted → Pending → + Success | Failed + → on Success persist user identity +``` + +The user identity carried in `Success` is the chat encryption pubkey + the user's identity +sr25519 accountId. The host verifies the 64-byte sr25519 `identitySignature` against the +canonical 97 bytes `statementAccountId || encryptionPublicKey` +(see `IDENTITY_SIGNATURE_PAYLOAD_BYTES`). + +### Building and rendering the QR + +```ts +import { buildPairingDeeplink } from '@novasamatech/host-papp'; + +const deeplink = buildPairingDeeplink( + { + statementAccountPublicKey: device.statementAccountPublicKey, // sr25519 device pubkey, 32 bytes + encryptionPublicKey: device.encryptionPublicKey, // P-256 device pubkey, 65 bytes uncompressed + }, + { + hostName: 'My Host App', + hostVersion: '1.0.0', + platformType: 'macOS', + platformVersion: '15.4', + }, +); + +renderQrCode(deeplink); // 'polkadotapp://pair?handshake=' +``` + +### Driving the handshake + +```ts +import { startPairingV2 } from '@novasamatech/host-papp'; + +const pairing = startPairingV2({ + statementStore: papp.adapters.statementStore, // any StatementStoreAdapter + deviceIdentity: { + statementAccountPublicKey: device.statementAccountPublicKey, + encryptionPublicKey: device.encryptionPublicKey, + encryptionPrivateKey: device.encryptionPrivateKey, // P-256 priv key, 32 bytes + }, + metadata: { + hostName: 'My Host App', + hostVersion: '1.0.0', + platformType: 'macOS', + platformVersion: '15.4', + }, + persistOnSuccess: async success => { + // success.identityChatPublicKey, success.userIdentityAccountId, + // success.identitySignature — persist in your secureStore. + }, +}); + +pairing.qrPayload; // 'polkadotapp://pair?handshake=' for the QR UI + +pairing.state$.subscribe(state => { + switch (state.tag) { + case 'Submitted': + // QR shown, waiting for the peer to scan + return; + case 'Pending': + // peer acknowledged; allocating Statement Store allowance on-chain + return; + case 'Success': + // identity received, device authorised + return; + case 'Failed': + // peer rejected (declined / duplicate / no-slot / tx-failed) + console.error(state.reason); + return; + } +}); + +// Cancel mid-flight (the Observable completes, polling stops, subscription closes): +pairing.abort(); +``` + +### Surviving reloads / proper logout + +The chain holds the most recent statement on the pairing topic indefinitely, so on cold +start the service will see the previous Success and replay it. To distinguish a stale +replay from a fresh re-pair, callers can pass byte-level dedupe state: + +```ts +const pairing = startPairingV2({ + // ... + initialProcessedDataHex: await secureStore.get('lastProcessedHandshakeStatement'), + onStatementProcessed: hex => { + void secureStore.set('lastProcessedHandshakeStatement', hex); + }, +}); +``` + +The service skips any incoming statement whose bytes match `initialProcessedDataHex`. PApp +re-encrypts every Success with a fresh ephemeral key + AES-GCM nonce, so a genuine re-pair +always produces different bytes and passes the dedupe. + +### Pairing topic / channel + +If the host needs to derive the pairing topic or channel itself (for example to subscribe +in-line, or to verify a statement source): + +```ts +import { computePairingTopic, computePairingChannel } from '@novasamatech/host-papp'; + +const topic = computePairingTopic(statementAccountId, encryptionPublicKey); +const channel = computePairingChannel(statementAccountId, encryptionPublicKey); +// topic = blake2b256_keyed(encryptionPublicKey || "topic", key=statementAccountId) +// channel = blake2b256_keyed(encryptionPublicKey || "channel", key=statementAccountId) +``` + +### Codec exports + +The SCALE codecs are exported as plain `Codec` values for callers that need to +encode/decode statements outside the orchestrator: + +| Export | Description | +| --------------------------------- | -------------------------------------------------------------------------------------------- | +| `VersionedHandshakeProposal` | Outer enum; V2 at SCALE discriminant 1, with `_v1Reserved` at 0. | +| `HandshakeProposalV2` | `{ device, metadata }` — what the QR encodes. | +| `Device` | `{ statementAccountId(32), encryptionPublicKey(65) }`. | +| `MetadataKey`, `MetadataEntry` | Metadata enum + `(MetadataKey, str)` tuple. | +| `VersionedHandshakeResponse` | Outer enum for the answer; `V1` legacy + `V2`. | +| `HandshakeResponseV2` | `{ encrypted, tmpKey(65) }` — the ECDH-wrapped envelope. | +| `EncryptedHandshakeResponseV2` | Inner payload after envelope decrypt: `Pending` (1 byte), `Success` (161 bytes), `Failed`. | +| `HandshakeSuccessV2` | `{ encryptionKey(65), accountId(32), identitySignature(64) }`. | +| `IDENTITY_SIGNATURE_PAYLOAD_BYTES`| `97` — the bytes the user identity sr25519 signs over. | From 43163a60e5117f99b81f9eff95d0cb454ac1db71 Mon Sep 17 00:00:00 2001 From: Ilya Date: Thu, 30 Apr 2026 18:17:26 -0600 Subject: [PATCH 03/31] feat(host-papp): carry identity chat priv key in V2 SSO success payload Per the multi-device chat spec (HackMD Ski9naYdWe), PApp shares the user identity chat keypair with each paired device so the device can decrypt incoming chat traffic addressed to the user identity. Today's HandshakeSuccessV2 wire payload only carries the public half; this commit extends it with a 32-byte raw P-256 private scalar (identityChatPrivateKey) appended after identitySignature. Wire impact: Success payload grows from 161 to 193 bytes. The length- dispatched EncryptedHandshakeResponseV2 codec recognises the new length as Success; older Pending (1 byte) and Failed (variable string) variants keep the same shape. Encryption is unchanged - the outer envelope's ECDH-AES wrap already protects the payload in transit. Mirrored on the responder side (PApp) per the same spec; consumers (paired devices) persist the priv only in OS-keychain-backed secure storage and never forward it. --- .../__tests__/handshakeV2Codec.spec.ts | 18 +++++++++------ .../__tests__/handshakeV2Service.spec.ts | 2 ++ .../__tests__/handshakeV2State.spec.ts | 7 +++++- .../src/sso/auth/scale/handshakeV2.ts | 23 ++++++++++++++----- packages/host-papp/src/sso/auth/v2/state.ts | 9 ++++++++ 5 files changed, 45 insertions(+), 14 deletions(-) diff --git a/packages/host-papp/__tests__/handshakeV2Codec.spec.ts b/packages/host-papp/__tests__/handshakeV2Codec.spec.ts index 21cfd7f0..4f0a25f4 100644 --- a/packages/host-papp/__tests__/handshakeV2Codec.spec.ts +++ b/packages/host-papp/__tests__/handshakeV2Codec.spec.ts @@ -93,11 +93,12 @@ describe('VersionedHandshakeProposal', () => { }); describe('HandshakeSuccessV2', () => { - it('round-trips encryptionKey, accountId, and identity signature', () => { + it('round-trips encryptionKey, accountId, identity signature, and chat priv', () => { const s = { encryptionKey: new Uint8Array(65).fill(0x04), accountId: new Uint8Array(32).fill(0xb2), identitySignature: new Uint8Array(64).fill(0xcc), + identityChatPrivateKey: new Uint8Array(32).fill(0xdd), }; expect(HandshakeSuccessV2.dec(HandshakeSuccessV2.enc(s))).toEqual(s); }); @@ -121,30 +122,33 @@ describe('EncryptedHandshakeResponseV2', () => { encryptionKey: new Uint8Array(65).fill(0x04), accountId: new Uint8Array(32).fill(0xb2), identitySignature: new Uint8Array(64).fill(0xcc), + identityChatPrivateKey: new Uint8Array(32).fill(0xdd), }, }; expect(EncryptedHandshakeResponseV2.dec(EncryptedHandshakeResponseV2.enc(r))).toEqual(r); }); - // Pinned wire format: Success is 161 bytes, no outer discriminant — just - // the three fixed-length fields concatenated. - it('encodes Success as 161 bytes (peer wire-compat)', () => { + // Pinned wire format: Success is 193 bytes, no outer discriminant — just + // the four fixed-length fields concatenated. + it('encodes Success as 193 bytes (peer wire-compat)', () => { const encoded = EncryptedHandshakeResponseV2.enc({ tag: 'Success', value: { encryptionKey: new Uint8Array(65).fill(0x04), accountId: new Uint8Array(32).fill(0xb2), identitySignature: new Uint8Array(64).fill(0xcc), + identityChatPrivateKey: new Uint8Array(32).fill(0xdd), }, }); - expect(encoded.length).toBe(161); + expect(encoded.length).toBe(193); expect(encoded[0]).toBe(0x04); expect(encoded[65]).toBe(0xb2); expect(encoded[97]).toBe(0xcc); + expect(encoded[161]).toBe(0xdd); }); - it('decodes a 161-byte payload as Success even though it has no discriminant byte', () => { - const bytes = new Uint8Array(161); + it('decodes a 193-byte payload as Success even though it has no discriminant byte', () => { + const bytes = new Uint8Array(193); bytes[0] = 0x04; // P-256 uncompressed marker — first byte of encryptionKey const decoded = EncryptedHandshakeResponseV2.dec(bytes); expect(decoded.tag).toBe('Success'); diff --git a/packages/host-papp/__tests__/handshakeV2Service.spec.ts b/packages/host-papp/__tests__/handshakeV2Service.spec.ts index 454ab182..ee5aee48 100644 --- a/packages/host-papp/__tests__/handshakeV2Service.spec.ts +++ b/packages/host-papp/__tests__/handshakeV2Service.spec.ts @@ -124,6 +124,7 @@ describe('startPairingV2', () => { identityChatPublicKey: new Uint8Array(65).fill(0x04), userIdentityAccountId: new Uint8Array(32).fill(0xb2), identitySignature: new Uint8Array(64).fill(0xcc), + identityChatPrivateKey: new Uint8Array(32).fill(0xdd), }; const successBytes = EncryptedHandshakeResponseV2.enc({ tag: 'Success', @@ -131,6 +132,7 @@ describe('startPairingV2', () => { encryptionKey: success.identityChatPublicKey, accountId: success.userIdentityAccountId, identitySignature: success.identitySignature, + identityChatPrivateKey: success.identityChatPrivateKey, }, }); store.emit([buildStatement(device, successBytes)]); diff --git a/packages/host-papp/__tests__/handshakeV2State.spec.ts b/packages/host-papp/__tests__/handshakeV2State.spec.ts index e4713e50..f0c7049b 100644 --- a/packages/host-papp/__tests__/handshakeV2State.spec.ts +++ b/packages/host-papp/__tests__/handshakeV2State.spec.ts @@ -26,13 +26,14 @@ describe('fromInnerResponse', () => { expect(fromInnerResponse(r)).toEqual({ tag: 'Pending', reason: 'AllowanceAllocation' }); }); - it('maps Success to Success state with all three key fields', () => { + it('maps Success to Success state with all four key fields', () => { const r = decode({ tag: 'Success', value: { encryptionKey: new Uint8Array(65).fill(0x04), accountId: new Uint8Array(32).fill(0xb2), identitySignature: new Uint8Array(64).fill(0xcc), + identityChatPrivateKey: new Uint8Array(32).fill(0xdd), }, }); const state = fromInnerResponse(r); @@ -41,6 +42,7 @@ describe('fromInnerResponse', () => { expect(state.identityChatPublicKey.length).toBe(65); expect(state.userIdentityAccountId.length).toBe(32); expect(state.identitySignature.length).toBe(64); + expect(state.identityChatPrivateKey.length).toBe(32); } }); @@ -67,6 +69,7 @@ describe('advance', () => { identityChatPublicKey: new Uint8Array(65), userIdentityAccountId: new Uint8Array(32), identitySignature: new Uint8Array(64), + identityChatPrivateKey: new Uint8Array(32), }; expect(advance(pending, success)).toEqual(success); }); @@ -77,6 +80,7 @@ describe('advance', () => { identityChatPublicKey: new Uint8Array(65), userIdentityAccountId: new Uint8Array(32), identitySignature: new Uint8Array(64), + identityChatPrivateKey: new Uint8Array(32), }; const pending: HandshakeState = { tag: 'Pending', reason: 'AllowanceAllocation' }; expect(advance(success, pending)).toEqual(success); @@ -128,6 +132,7 @@ describe('canSubmitV2Statements', () => { identityChatPublicKey: new Uint8Array(65), userIdentityAccountId: new Uint8Array(32), identitySignature: new Uint8Array(64), + identityChatPrivateKey: new Uint8Array(32), }; expect(canSubmitV2Statements(success)).toBe(true); expect(canSubmitV2Statements(idle())).toBe(false); diff --git a/packages/host-papp/src/sso/auth/scale/handshakeV2.ts b/packages/host-papp/src/sso/auth/scale/handshakeV2.ts index ca503468..1c2d1eb9 100644 --- a/packages/host-papp/src/sso/auth/scale/handshakeV2.ts +++ b/packages/host-papp/src/sso/auth/scale/handshakeV2.ts @@ -10,9 +10,13 @@ * | Failed`. * * `Success` carries the user identity chat encryption pubkey, the user - * identity sr25519 accountId, and a 64-byte sr25519 signature over + * identity sr25519 accountId, a 64-byte sr25519 signature over * `statementAccountId || encryptionPublicKey` (97 bytes — see - * `IDENTITY_SIGNATURE_PAYLOAD_BYTES`) proving the user authorised this device. + * `IDENTITY_SIGNATURE_PAYLOAD_BYTES`) proving the user authorised this device, + * and the user identity chat P-256 private key (32 bytes raw scalar). The + * private key is shared per the multi-device spec so the device can decrypt + * incoming traffic addressed to the user identity. Transit security comes + * from the outer envelope's ECDH-AES wrap, not from a separate per-field key. */ import type { Codec } from 'scale-ts'; @@ -21,6 +25,7 @@ import { Bytes, Enum, Struct, Tuple, Vector, _void, createCodec, str } from 'sca const AccountIdCodec = Bytes(32); const PublicKeyCodec = Bytes(65); const SignatureCodec = Bytes(64); +const PrivateKeyCodec = Bytes(32); /** Bytes the user identity sr25519 signs to authorise a device: accountId(32) || encPub(65). */ export const IDENTITY_SIGNATURE_PAYLOAD_BYTES = 32 + 65; @@ -63,6 +68,7 @@ export const HandshakeSuccessV2 = Struct({ encryptionKey: PublicKeyCodec, accountId: AccountIdCodec, identitySignature: SignatureCodec, + identityChatPrivateKey: PrivateKeyCodec, }); /** @@ -75,14 +81,19 @@ export const HandshakeStatusV2 = Enum({ AllowanceAllocation: _void, }); -const SUCCESS_LEN = 65 + 32 + 64; +const SUCCESS_LEN = 65 + 32 + 64 + 32; const PENDING_BYTE = 0x00; export type EncryptedHandshakeResponseV2Value = | { tag: 'Pending'; value: undefined } | { tag: 'Success'; - value: { encryptionKey: Uint8Array; accountId: Uint8Array; identitySignature: Uint8Array }; + value: { + encryptionKey: Uint8Array; + accountId: Uint8Array; + identitySignature: Uint8Array; + identityChatPrivateKey: Uint8Array; + }; } | { tag: 'Failed'; value: string }; @@ -104,8 +115,8 @@ const toBytes = (value: Uint8Array | ArrayBuffer | string): Uint8Array => { * * - Pending → 1 byte: 0x00 (the inner `AllowanceAllocation` tag; the * outer Pending wrapper emits nothing) - * - Success → 161 bytes: HandshakeSuccessV2 fields concatenated - * (encryptionKey 65 || accountId 32 || signature 64) + * - Success → 193 bytes: HandshakeSuccessV2 fields concatenated + * (encryptionKey 65 || accountId 32 || signature 64 || chatPriv 32) * - Failed → variable: SCALE-encoded UTF-8 reason string * * Disambiguation is purely by byte length; protocol-state context further diff --git a/packages/host-papp/src/sso/auth/v2/state.ts b/packages/host-papp/src/sso/auth/v2/state.ts index 4c9e7591..98b9e6b4 100644 --- a/packages/host-papp/src/sso/auth/v2/state.ts +++ b/packages/host-papp/src/sso/auth/v2/state.ts @@ -27,6 +27,14 @@ export type HandshakeSuccessState = { identityChatPublicKey: Uint8Array; userIdentityAccountId: Uint8Array; identitySignature: Uint8Array; + /** + * The user identity chat P-256 private key (32 bytes raw scalar) shared by + * PApp with this device per the multi-device spec — required to decrypt + * incoming chat traffic addressed to the user identity. Sensitive; the + * device should persist it in OS-keychain-backed secure storage and never + * forward it. + */ + identityChatPrivateKey: Uint8Array; }; export type HandshakeFailedState = { tag: 'Failed'; reason: string }; @@ -56,6 +64,7 @@ export const fromInnerResponse = (response: CodecType Date: Thu, 30 Apr 2026 18:28:44 -0600 Subject: [PATCH 04/31] fix(host-papp): make V2 chat priv key optional for legacy PApp builds Initial pass forced HandshakeSuccessV2 to 193 bytes (with the new chat priv field), which broke pairing against PApp builds that haven't yet shipped the multi-device extension - the codec saw 161-byte legacy Success payloads and fell through to the variable-length Failed branch. Now length-dispatched: 161 bytes decodes as legacy Success with identityChatPrivateKey = undefined, 193 bytes decodes as the new extended Success. Encode emits the 193-byte form when a priv key is provided, otherwise falls back to the legacy struct. HandshakeSuccessState.identityChatPrivateKey becomes Uint8Array | undefined so consumers can branch on availability. Send-only V2 paths continue to work without it; inbound chat-request decryption is gated on the field being present. Removes the wire-incompatibility introduced in the previous commit without rolling back the spec-aligned shape; once every PApp build ships the priv key the legacy branch can be retired. --- .../src/sso/auth/scale/handshakeV2.ts | 71 ++++++++++++++++--- packages/host-papp/src/sso/auth/v2/state.ts | 7 +- 2 files changed, 66 insertions(+), 12 deletions(-) diff --git a/packages/host-papp/src/sso/auth/scale/handshakeV2.ts b/packages/host-papp/src/sso/auth/scale/handshakeV2.ts index 1c2d1eb9..514e4e0c 100644 --- a/packages/host-papp/src/sso/auth/scale/handshakeV2.ts +++ b/packages/host-papp/src/sso/auth/scale/handshakeV2.ts @@ -64,13 +64,66 @@ export const VersionedHandshakeProposal = Enum({ // ── Response (V2) ─────────────────────────────────────────────────────── -export const HandshakeSuccessV2 = Struct({ +/** + * Pinned wire structs — `HandshakeSuccessV2Legacy` (161 bytes) for PApp builds + * before the multi-device priv-key extension, `HandshakeSuccessV2WithChatPriv` + * (193 bytes) for builds shipping the spec'd identityChatPrivateKey. Length + * dispatch in `EncryptedHandshakeResponseV2` picks the right one. Once every + * PApp build has the priv key we can collapse to a single struct. + */ +export const HandshakeSuccessV2Legacy = Struct({ + encryptionKey: PublicKeyCodec, + accountId: AccountIdCodec, + identitySignature: SignatureCodec, +}); + +export const HandshakeSuccessV2WithChatPriv = Struct({ encryptionKey: PublicKeyCodec, accountId: AccountIdCodec, identitySignature: SignatureCodec, identityChatPrivateKey: PrivateKeyCodec, }); +/** + * Backwards-compatible Success codec. + * + * Encode always emits the new (193-byte) shape; decode accepts both lengths + * and surfaces `identityChatPrivateKey: undefined` when the legacy format is + * received so consumers can branch on availability without crashing. + */ +type HandshakeSuccessV2Value = { + encryptionKey: Uint8Array; + accountId: Uint8Array; + identitySignature: Uint8Array; + identityChatPrivateKey?: Uint8Array; +}; + +export const HandshakeSuccessV2: Codec = createCodec( + v => { + if (v.identityChatPrivateKey) { + return HandshakeSuccessV2WithChatPriv.enc({ + encryptionKey: v.encryptionKey, + accountId: v.accountId, + identitySignature: v.identitySignature, + identityChatPrivateKey: v.identityChatPrivateKey, + }); + } + return HandshakeSuccessV2Legacy.enc({ + encryptionKey: v.encryptionKey, + accountId: v.accountId, + identitySignature: v.identitySignature, + }); + }, + raw => { + const bytes = toBytes(raw); + if (bytes.length === SUCCESS_LEN_WITH_CHAT_PRIV) { + return HandshakeSuccessV2WithChatPriv.dec(bytes); + } + const legacy = HandshakeSuccessV2Legacy.dec(bytes); + return { ...legacy, identityChatPrivateKey: undefined }; + }, +); + /** * Inner Pending sub-statuses; only `AllowanceAllocation` today. Kept for * schema symmetry — not actually encoded on the wire because the peer @@ -81,19 +134,15 @@ export const HandshakeStatusV2 = Enum({ AllowanceAllocation: _void, }); -const SUCCESS_LEN = 65 + 32 + 64 + 32; +const SUCCESS_LEN_LEGACY = 65 + 32 + 64; +const SUCCESS_LEN_WITH_CHAT_PRIV = 65 + 32 + 64 + 32; const PENDING_BYTE = 0x00; export type EncryptedHandshakeResponseV2Value = | { tag: 'Pending'; value: undefined } | { tag: 'Success'; - value: { - encryptionKey: Uint8Array; - accountId: Uint8Array; - identitySignature: Uint8Array; - identityChatPrivateKey: Uint8Array; - }; + value: HandshakeSuccessV2Value; } | { tag: 'Failed'; value: string }; @@ -115,8 +164,8 @@ const toBytes = (value: Uint8Array | ArrayBuffer | string): Uint8Array => { * * - Pending → 1 byte: 0x00 (the inner `AllowanceAllocation` tag; the * outer Pending wrapper emits nothing) - * - Success → 193 bytes: HandshakeSuccessV2 fields concatenated - * (encryptionKey 65 || accountId 32 || signature 64 || chatPriv 32) + * - Success → 161 bytes (legacy PApp): encryptionKey 65 || accountId 32 || signature 64 + * - Success → 193 bytes (multi-device PApp): legacy fields || identityChatPrivateKey 32 * - Failed → variable: SCALE-encoded UTF-8 reason string * * Disambiguation is purely by byte length; protocol-state context further @@ -138,7 +187,7 @@ export const EncryptedHandshakeResponseV2: Codec Date: Fri, 1 May 2026 09:29:58 -0600 Subject: [PATCH 05/31] feat(host-papp): replace V2 SSO encryption_key with identity_chat_private_key The V2 multi-device handshake spec now ships only the user identity chat P-256 private scalar (32 bytes) on the wire; the matching public key is derived locally via P-256 scalar multiplication. Wire format shrinks from 193 to 128 bytes (accountId || identityChatPrivateKey || identitySignature). Legacy 161-byte payloads (encryptionKey || accountId || signature) are still accepted for PApp builds without the multi-device extension. identitySignature now commits to (accountId || derive_pub(identityChatPrivateKey)). --- .../__tests__/handshakeV2Codec.spec.ts | 83 ++++++++++++++----- .../src/sso/auth/scale/handshakeV2.ts | 66 ++++++++++----- packages/host-papp/src/sso/auth/v2/state.ts | 7 ++ 3 files changed, 112 insertions(+), 44 deletions(-) diff --git a/packages/host-papp/__tests__/handshakeV2Codec.spec.ts b/packages/host-papp/__tests__/handshakeV2Codec.spec.ts index 4f0a25f4..6753cad8 100644 --- a/packages/host-papp/__tests__/handshakeV2Codec.spec.ts +++ b/packages/host-papp/__tests__/handshakeV2Codec.spec.ts @@ -1,3 +1,4 @@ +import { p256 } from '@noble/curves/nist.js'; import { describe, expect, it } from 'vitest'; import type { MetadataEntry } from '../src/sso/auth/scale/handshakeV2.js'; @@ -16,6 +17,9 @@ import { VersionedHandshakeResponse, } from '../src/sso/auth/scale/handshakeV2.js'; +const fixedChatPrivateKey = new Uint8Array(32).fill(0xdd); +const fixedChatPublicKey = p256.getPublicKey(fixedChatPrivateKey, false); + const makeDevice = () => ({ statementAccountId: new Uint8Array(32).fill(0xa1), encryptionPublicKey: new Uint8Array(65).fill(0x04), @@ -93,14 +97,33 @@ describe('VersionedHandshakeProposal', () => { }); describe('HandshakeSuccessV2', () => { - it('round-trips encryptionKey, accountId, identity signature, and chat priv', () => { - const s = { + it('round-trips the multi-device shape, deriving encryptionKey from identityChatPrivateKey', () => { + const input = { + encryptionKey: new Uint8Array(65).fill(0x04), // ignored on encode — derived on decode + accountId: new Uint8Array(32).fill(0xb2), + identitySignature: new Uint8Array(64).fill(0xcc), + identityChatPrivateKey: fixedChatPrivateKey, + }; + const decoded = HandshakeSuccessV2.dec(HandshakeSuccessV2.enc(input)); + expect(decoded.accountId).toEqual(input.accountId); + expect(decoded.identitySignature).toEqual(input.identitySignature); + expect(decoded.identityChatPrivateKey).toEqual(input.identityChatPrivateKey); + expect(decoded.encryptionKey).toEqual(fixedChatPublicKey); + }); + + it('round-trips a legacy 161-byte payload, surfacing identityChatPrivateKey as undefined', () => { + const legacy = { encryptionKey: new Uint8Array(65).fill(0x04), accountId: new Uint8Array(32).fill(0xb2), identitySignature: new Uint8Array(64).fill(0xcc), - identityChatPrivateKey: new Uint8Array(32).fill(0xdd), }; - expect(HandshakeSuccessV2.dec(HandshakeSuccessV2.enc(s))).toEqual(s); + const encoded = HandshakeSuccessV2.enc(legacy); + expect(encoded.length).toBe(161); + const decoded = HandshakeSuccessV2.dec(encoded); + expect(decoded.encryptionKey).toEqual(legacy.encryptionKey); + expect(decoded.accountId).toEqual(legacy.accountId); + expect(decoded.identitySignature).toEqual(legacy.identitySignature); + expect(decoded.identityChatPrivateKey).toBeUndefined(); }); }); @@ -115,43 +138,61 @@ describe('EncryptedHandshakeResponseV2', () => { expect(encoded).toEqual(new Uint8Array([0x00])); }); - it('round-trips Success', () => { - const r = { + it('round-trips Success on the multi-device wire format (128 bytes)', () => { + const input = { tag: 'Success' as const, value: { - encryptionKey: new Uint8Array(65).fill(0x04), + encryptionKey: new Uint8Array(65).fill(0x04), // ignored on encode — derived on decode accountId: new Uint8Array(32).fill(0xb2), identitySignature: new Uint8Array(64).fill(0xcc), - identityChatPrivateKey: new Uint8Array(32).fill(0xdd), + identityChatPrivateKey: fixedChatPrivateKey, }, }; - expect(EncryptedHandshakeResponseV2.dec(EncryptedHandshakeResponseV2.enc(r))).toEqual(r); + const decoded = EncryptedHandshakeResponseV2.dec(EncryptedHandshakeResponseV2.enc(input)); + expect(decoded.tag).toBe('Success'); + if (decoded.tag !== 'Success') return; + expect(decoded.value.accountId).toEqual(input.value.accountId); + expect(decoded.value.identitySignature).toEqual(input.value.identitySignature); + expect(decoded.value.identityChatPrivateKey).toEqual(input.value.identityChatPrivateKey); + expect(decoded.value.encryptionKey).toEqual(fixedChatPublicKey); }); - // Pinned wire format: Success is 193 bytes, no outer discriminant — just - // the four fixed-length fields concatenated. - it('encodes Success as 193 bytes (peer wire-compat)', () => { + // Pinned wire format: Success is 128 bytes on the multi-device shape, no + // outer discriminant — just the three fixed-length fields concatenated. + it('encodes Success as 128 bytes (multi-device wire-compat)', () => { const encoded = EncryptedHandshakeResponseV2.enc({ tag: 'Success', value: { - encryptionKey: new Uint8Array(65).fill(0x04), + encryptionKey: new Uint8Array(65).fill(0x04), // unused accountId: new Uint8Array(32).fill(0xb2), identitySignature: new Uint8Array(64).fill(0xcc), - identityChatPrivateKey: new Uint8Array(32).fill(0xdd), + identityChatPrivateKey: fixedChatPrivateKey, }, }); - expect(encoded.length).toBe(193); - expect(encoded[0]).toBe(0x04); - expect(encoded[65]).toBe(0xb2); - expect(encoded[97]).toBe(0xcc); - expect(encoded[161]).toBe(0xdd); + expect(encoded.length).toBe(128); + expect(encoded[0]).toBe(0xb2); // accountId + expect(encoded[32]).toBe(0xdd); // identityChatPrivateKey + expect(encoded[64]).toBe(0xcc); // identitySignature + }); + + it('decodes a 128-byte payload as Success and derives encryptionKey from priv key', () => { + const bytes = new Uint8Array(128); + bytes.set(new Uint8Array(32).fill(0xb2), 0); + bytes.set(fixedChatPrivateKey, 32); + bytes.set(new Uint8Array(64).fill(0xcc), 64); + const decoded = EncryptedHandshakeResponseV2.dec(bytes); + expect(decoded.tag).toBe('Success'); + if (decoded.tag !== 'Success') return; + expect(decoded.value.encryptionKey).toEqual(fixedChatPublicKey); }); - it('decodes a 193-byte payload as Success even though it has no discriminant byte', () => { - const bytes = new Uint8Array(193); + it('decodes a 161-byte payload as Success in the legacy shape (no priv key)', () => { + const bytes = new Uint8Array(161); bytes[0] = 0x04; // P-256 uncompressed marker — first byte of encryptionKey const decoded = EncryptedHandshakeResponseV2.dec(bytes); expect(decoded.tag).toBe('Success'); + if (decoded.tag !== 'Success') return; + expect(decoded.value.identityChatPrivateKey).toBeUndefined(); }); it('round-trips Failed with a reason string', () => { diff --git a/packages/host-papp/src/sso/auth/scale/handshakeV2.ts b/packages/host-papp/src/sso/auth/scale/handshakeV2.ts index 514e4e0c..110f2525 100644 --- a/packages/host-papp/src/sso/auth/scale/handshakeV2.ts +++ b/packages/host-papp/src/sso/auth/scale/handshakeV2.ts @@ -9,16 +9,19 @@ * payload after decrypt is `EncryptedHandshakeResponseV2 = Pending | Success * | Failed`. * - * `Success` carries the user identity chat encryption pubkey, the user - * identity sr25519 accountId, a 64-byte sr25519 signature over - * `statementAccountId || encryptionPublicKey` (97 bytes — see - * `IDENTITY_SIGNATURE_PAYLOAD_BYTES`) proving the user authorised this device, - * and the user identity chat P-256 private key (32 bytes raw scalar). The - * private key is shared per the multi-device spec so the device can decrypt - * incoming traffic addressed to the user identity. Transit security comes - * from the outer envelope's ECDH-AES wrap, not from a separate per-field key. + * `Success` carries the user identity sr25519 accountId, the user identity + * chat P-256 private key (32 bytes raw scalar), and a 64-byte sr25519 + * signature over `accountId || derive_pub(identityChatPrivateKey)` (97 bytes + * — see `IDENTITY_SIGNATURE_PAYLOAD_BYTES`) proving the user authorised this + * device. The matching public key is derived locally from the private scalar + * (P-256 scalar multiplication with the standard generator); both sides MUST + * derive identically before verifying the signature. The private key is + * shared per the multi-device spec so the device can decrypt incoming chat + * traffic addressed to the user identity. Transit security comes from the + * outer envelope's ECDH-AES wrap, not from a separate per-field key. */ +import { p256 } from '@noble/curves/nist.js'; import type { Codec } from 'scale-ts'; import { Bytes, Enum, Struct, Tuple, Vector, _void, createCodec, str } from 'scale-ts'; @@ -65,11 +68,19 @@ export const VersionedHandshakeProposal = Enum({ // ── Response (V2) ─────────────────────────────────────────────────────── /** - * Pinned wire structs — `HandshakeSuccessV2Legacy` (161 bytes) for PApp builds - * before the multi-device priv-key extension, `HandshakeSuccessV2WithChatPriv` - * (193 bytes) for builds shipping the spec'd identityChatPrivateKey. Length - * dispatch in `EncryptedHandshakeResponseV2` picks the right one. Once every - * PApp build has the priv key we can collapse to a single struct. + * Pinned wire structs: + * + * - `HandshakeSuccessV2Legacy` (161 bytes, encryptionKey + accountId + + * signature) for PApp builds before the multi-device priv-key extension. + * - `HandshakeSuccessV2WithChatPriv` (128 bytes, accountId + + * identityChatPrivateKey + signature) for builds shipping the spec'd + * multi-device extension. The matching `encryptionKey` is derived locally + * via P-256 scalar multiplication and surfaced on the decoded value so + * downstream consumers see the same shape regardless of wire variant. + * + * Length dispatch in `EncryptedHandshakeResponseV2` picks the right one. Once + * every PApp build has the priv-key extension we can collapse to a single + * struct. */ export const HandshakeSuccessV2Legacy = Struct({ encryptionKey: PublicKeyCodec, @@ -78,18 +89,20 @@ export const HandshakeSuccessV2Legacy = Struct({ }); export const HandshakeSuccessV2WithChatPriv = Struct({ - encryptionKey: PublicKeyCodec, accountId: AccountIdCodec, - identitySignature: SignatureCodec, identityChatPrivateKey: PrivateKeyCodec, + identitySignature: SignatureCodec, }); /** * Backwards-compatible Success codec. * - * Encode always emits the new (193-byte) shape; decode accepts both lengths - * and surfaces `identityChatPrivateKey: undefined` when the legacy format is - * received so consumers can branch on availability without crashing. + * Encode emits the new (128-byte) shape when `identityChatPrivateKey` is + * present (the `encryptionKey` field on the input value is ignored — it is + * derived from the private scalar on decode). Decode accepts both lengths + * and surfaces `identityChatPrivateKey: undefined` together with the on-wire + * `encryptionKey` when the legacy 161-byte format is received, so consumers + * can branch on availability without crashing. */ type HandshakeSuccessV2Value = { encryptionKey: Uint8Array; @@ -98,14 +111,15 @@ type HandshakeSuccessV2Value = { identityChatPrivateKey?: Uint8Array; }; +const derivePublicFromPrivate = (privateKey: Uint8Array): Uint8Array => p256.getPublicKey(privateKey, false); + export const HandshakeSuccessV2: Codec = createCodec( v => { if (v.identityChatPrivateKey) { return HandshakeSuccessV2WithChatPriv.enc({ - encryptionKey: v.encryptionKey, accountId: v.accountId, - identitySignature: v.identitySignature, identityChatPrivateKey: v.identityChatPrivateKey, + identitySignature: v.identitySignature, }); } return HandshakeSuccessV2Legacy.enc({ @@ -117,7 +131,13 @@ export const HandshakeSuccessV2: Codec = createCodec( raw => { const bytes = toBytes(raw); if (bytes.length === SUCCESS_LEN_WITH_CHAT_PRIV) { - return HandshakeSuccessV2WithChatPriv.dec(bytes); + const decoded = HandshakeSuccessV2WithChatPriv.dec(bytes); + return { + encryptionKey: derivePublicFromPrivate(decoded.identityChatPrivateKey), + accountId: decoded.accountId, + identitySignature: decoded.identitySignature, + identityChatPrivateKey: decoded.identityChatPrivateKey, + }; } const legacy = HandshakeSuccessV2Legacy.dec(bytes); return { ...legacy, identityChatPrivateKey: undefined }; @@ -135,7 +155,7 @@ export const HandshakeStatusV2 = Enum({ }); const SUCCESS_LEN_LEGACY = 65 + 32 + 64; -const SUCCESS_LEN_WITH_CHAT_PRIV = 65 + 32 + 64 + 32; +const SUCCESS_LEN_WITH_CHAT_PRIV = 32 + 32 + 64; const PENDING_BYTE = 0x00; export type EncryptedHandshakeResponseV2Value = @@ -165,7 +185,7 @@ const toBytes = (value: Uint8Array | ArrayBuffer | string): Uint8Array => { * - Pending → 1 byte: 0x00 (the inner `AllowanceAllocation` tag; the * outer Pending wrapper emits nothing) * - Success → 161 bytes (legacy PApp): encryptionKey 65 || accountId 32 || signature 64 - * - Success → 193 bytes (multi-device PApp): legacy fields || identityChatPrivateKey 32 + * - Success → 128 bytes (multi-device PApp): accountId 32 || identityChatPrivateKey 32 || signature 64 * - Failed → variable: SCALE-encoded UTF-8 reason string * * Disambiguation is purely by byte length; protocol-state context further diff --git a/packages/host-papp/src/sso/auth/v2/state.ts b/packages/host-papp/src/sso/auth/v2/state.ts index d53dd42c..d14a364c 100644 --- a/packages/host-papp/src/sso/auth/v2/state.ts +++ b/packages/host-papp/src/sso/auth/v2/state.ts @@ -24,6 +24,13 @@ export type HandshakeSubmittedState = { tag: 'Submitted' }; export type HandshakePendingState = { tag: 'Pending'; reason: 'AllowanceAllocation' }; export type HandshakeSuccessState = { tag: 'Success'; + /** + * Derived locally from `identityChatPrivateKey` via P-256 scalar + * multiplication on the multi-device wire format; read directly from the + * `encryption_key` wire field on legacy 161-byte responses. Either way + * `IDENTITY_SIGNATURE_PAYLOAD_BYTES = accountId || identityChatPublicKey` + * is the canonical form `identitySignature` commits to. + */ identityChatPublicKey: Uint8Array; userIdentityAccountId: Uint8Array; identitySignature: Uint8Array; From 659413e582a1fb15f1bace2c66af08c74a125195 Mon Sep 17 00:00:00 2001 From: Ilya Date: Tue, 19 May 2026 17:11:07 -0600 Subject: [PATCH 06/31] feat(host-papp): adopt multi-device HandshakeSuccessV2 v0.2.1 wire shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Align the V2 SSO handshake codec with the multi-device spec (https://hackmd.io/@1JCaGppGSUqHtJilikYaKw/Ski9naYdWe). Success body shape: identityAccountId(32) || rootAccountId(32) || identityChatPrivateKey(32) || deviceEncPubKey(65) = 161 bytes (spec v0.2.1) Also accept the 129-byte v0.2 variant (no rootAccountId) emitted by Android's `feature/location-for-handshake`. Surface `rootAccountId: null` in that case — chat does not need it; product-account derivation degrades gracefully. Removed: - `identitySignature` field (multi-device authorisation moves to the user-identity-signed roster events DeviceAdded/DeviceRemoved) - `IDENTITY_SIGNATURE_PAYLOAD_BYTES` export - `HandshakeSuccessV2WithChatPriv` (was the experimental 128-byte shape) Added: - `HandshakeSuccessV2Value` exported type - `decodeEncryptedHandshakeResponseV2` — explicit length-dispatched decoder for the inner plaintext - `deriveIdentityChatPublicKey` — P-256 scalar mult helper - `EncryptedHandshakeResponseV2` now built with native scale-ts `Enum` on the inner discriminant. The peer SCALE library does NOT elide the variant index, so `Pending(AllowanceAllocation)` arrives as `0x00 0x00` and was being misclassified as `Failed("")` by the prior length-only dispatch. `HandshakeSuccessState` now exposes identityAccountId, rootAccountId, identityChatPrivateKey, identityChatPublicKey (derived locally) and deviceEncPubKey. Pairing service decodes via the new `decodeEncryptedHandshakeResponseV2` and logs failure reasons with the raw inner bytes for diagnosis. --- .../__tests__/handshakeV2Codec.spec.ts | 198 +++++++------ .../__tests__/handshakeV2Service.spec.ts | 21 +- .../__tests__/handshakeV2State.spec.ts | 104 ++++--- packages/host-papp/src/index.ts | 4 +- .../src/sso/auth/scale/handshakeV2.ts | 259 ++++++++---------- packages/host-papp/src/sso/auth/v2/service.ts | 7 +- packages/host-papp/src/sso/auth/v2/state.ts | 57 ++-- 7 files changed, 331 insertions(+), 319 deletions(-) diff --git a/packages/host-papp/__tests__/handshakeV2Codec.spec.ts b/packages/host-papp/__tests__/handshakeV2Codec.spec.ts index 6753cad8..0f2aa191 100644 --- a/packages/host-papp/__tests__/handshakeV2Codec.spec.ts +++ b/packages/host-papp/__tests__/handshakeV2Codec.spec.ts @@ -1,4 +1,5 @@ import { p256 } from '@noble/curves/nist.js'; +import { str } from 'scale-ts'; import { describe, expect, it } from 'vitest'; import type { MetadataEntry } from '../src/sso/auth/scale/handshakeV2.js'; @@ -11,10 +12,12 @@ import { HandshakeResponseV2, HandshakeStatusV2, HandshakeSuccessV2, - IDENTITY_SIGNATURE_PAYLOAD_BYTES, + HandshakeSuccessV2Legacy, MetadataKey, VersionedHandshakeProposal, VersionedHandshakeResponse, + decodeEncryptedHandshakeResponseV2, + deriveIdentityChatPublicKey, } from '../src/sso/auth/scale/handshakeV2.js'; const fixedChatPrivateKey = new Uint8Array(32).fill(0xdd); @@ -25,12 +28,6 @@ const makeDevice = () => ({ encryptionPublicKey: new Uint8Array(65).fill(0x04), }); -describe('IDENTITY_SIGNATURE_PAYLOAD_BYTES', () => { - it('equals 32 + 65 = 97 bytes', () => { - expect(IDENTITY_SIGNATURE_PAYLOAD_BYTES).toBe(97); - }); -}); - describe('MetadataKey', () => { it('round-trips Custom with arbitrary string', () => { const m = { tag: 'Custom' as const, value: 'app.theme' }; @@ -96,103 +93,136 @@ describe('VersionedHandshakeProposal', () => { }); }); -describe('HandshakeSuccessV2', () => { - it('round-trips the multi-device shape, deriving encryptionKey from identityChatPrivateKey', () => { +describe('HandshakeSuccessV2 (spec v0.2.1, 161 bytes)', () => { + it('round-trips identityAccountId, rootAccountId, identityChatPrivateKey, deviceEncPubKey', () => { const input = { - encryptionKey: new Uint8Array(65).fill(0x04), // ignored on encode — derived on decode - accountId: new Uint8Array(32).fill(0xb2), - identitySignature: new Uint8Array(64).fill(0xcc), + identityAccountId: new Uint8Array(32).fill(0xa1), + rootAccountId: new Uint8Array(32).fill(0xa2), identityChatPrivateKey: fixedChatPrivateKey, + deviceEncPubKey: new Uint8Array(65).fill(0x04), }; const decoded = HandshakeSuccessV2.dec(HandshakeSuccessV2.enc(input)); - expect(decoded.accountId).toEqual(input.accountId); - expect(decoded.identitySignature).toEqual(input.identitySignature); - expect(decoded.identityChatPrivateKey).toEqual(input.identityChatPrivateKey); - expect(decoded.encryptionKey).toEqual(fixedChatPublicKey); + expect(decoded).toEqual(input); }); - it('round-trips a legacy 161-byte payload, surfacing identityChatPrivateKey as undefined', () => { - const legacy = { - encryptionKey: new Uint8Array(65).fill(0x04), - accountId: new Uint8Array(32).fill(0xb2), - identitySignature: new Uint8Array(64).fill(0xcc), - }; - const encoded = HandshakeSuccessV2.enc(legacy); + it('encodes to exactly 161 bytes', () => { + const encoded = HandshakeSuccessV2.enc({ + identityAccountId: new Uint8Array(32).fill(0xa1), + rootAccountId: new Uint8Array(32).fill(0xa2), + identityChatPrivateKey: fixedChatPrivateKey, + deviceEncPubKey: new Uint8Array(65).fill(0x04), + }); expect(encoded.length).toBe(161); - const decoded = HandshakeSuccessV2.dec(encoded); - expect(decoded.encryptionKey).toEqual(legacy.encryptionKey); - expect(decoded.accountId).toEqual(legacy.accountId); - expect(decoded.identitySignature).toEqual(legacy.identitySignature); - expect(decoded.identityChatPrivateKey).toBeUndefined(); }); }); -describe('EncryptedHandshakeResponseV2', () => { - it('round-trips Pending (single byte, no inner status — peer wire-compat)', () => { - const r = { tag: 'Pending' as const, value: undefined }; - expect(EncryptedHandshakeResponseV2.dec(EncryptedHandshakeResponseV2.enc(r))).toEqual(r); - }); - - it('encodes Pending as a single 0x00 byte', () => { - const encoded = EncryptedHandshakeResponseV2.enc({ tag: 'Pending', value: undefined }); - expect(encoded).toEqual(new Uint8Array([0x00])); - }); - - it('round-trips Success on the multi-device wire format (128 bytes)', () => { +describe('HandshakeSuccessV2Legacy (spec v0.2, 129 bytes)', () => { + it('round-trips identityAccountId, identityChatPrivateKey, deviceEncPubKey', () => { const input = { - tag: 'Success' as const, - value: { - encryptionKey: new Uint8Array(65).fill(0x04), // ignored on encode — derived on decode - accountId: new Uint8Array(32).fill(0xb2), - identitySignature: new Uint8Array(64).fill(0xcc), - identityChatPrivateKey: fixedChatPrivateKey, - }, + identityAccountId: new Uint8Array(32).fill(0xa1), + identityChatPrivateKey: fixedChatPrivateKey, + deviceEncPubKey: new Uint8Array(65).fill(0x04), }; - const decoded = EncryptedHandshakeResponseV2.dec(EncryptedHandshakeResponseV2.enc(input)); - expect(decoded.tag).toBe('Success'); - if (decoded.tag !== 'Success') return; - expect(decoded.value.accountId).toEqual(input.value.accountId); - expect(decoded.value.identitySignature).toEqual(input.value.identitySignature); - expect(decoded.value.identityChatPrivateKey).toEqual(input.value.identityChatPrivateKey); - expect(decoded.value.encryptionKey).toEqual(fixedChatPublicKey); + const decoded = HandshakeSuccessV2Legacy.dec(HandshakeSuccessV2Legacy.enc(input)); + expect(decoded).toEqual(input); }); - // Pinned wire format: Success is 128 bytes on the multi-device shape, no - // outer discriminant — just the three fixed-length fields concatenated. - it('encodes Success as 128 bytes (multi-device wire-compat)', () => { - const encoded = EncryptedHandshakeResponseV2.enc({ - tag: 'Success', - value: { - encryptionKey: new Uint8Array(65).fill(0x04), // unused - accountId: new Uint8Array(32).fill(0xb2), - identitySignature: new Uint8Array(64).fill(0xcc), - identityChatPrivateKey: fixedChatPrivateKey, - }, + it('encodes to exactly 129 bytes', () => { + const encoded = HandshakeSuccessV2Legacy.enc({ + identityAccountId: new Uint8Array(32).fill(0xa1), + identityChatPrivateKey: fixedChatPrivateKey, + deviceEncPubKey: new Uint8Array(65).fill(0x04), }); - expect(encoded.length).toBe(128); - expect(encoded[0]).toBe(0xb2); // accountId - expect(encoded[32]).toBe(0xdd); // identityChatPrivateKey - expect(encoded[64]).toBe(0xcc); // identitySignature + expect(encoded.length).toBe(129); + }); +}); + +describe('decodeEncryptedHandshakeResponseV2 (length-dispatched plaintext decoder)', () => { + it('decodes Pending(AllowanceAllocation) = 0x00 0x00', () => { + const decoded = decodeEncryptedHandshakeResponseV2(new Uint8Array([0x00, 0x00])); + expect(decoded).toEqual({ tag: 'Pending', value: { tag: 'AllowanceAllocation', value: undefined } }); }); - it('decodes a 128-byte payload as Success and derives encryptionKey from priv key', () => { - const bytes = new Uint8Array(128); - bytes.set(new Uint8Array(32).fill(0xb2), 0); - bytes.set(fixedChatPrivateKey, 32); - bytes.set(new Uint8Array(64).fill(0xcc), 64); - const decoded = EncryptedHandshakeResponseV2.dec(bytes); + it('decodes a 161-byte v0.2.1 Success body with rootAccountId', () => { + const body = HandshakeSuccessV2.enc({ + identityAccountId: new Uint8Array(32).fill(0xa1), + rootAccountId: new Uint8Array(32).fill(0xa2), + identityChatPrivateKey: fixedChatPrivateKey, + deviceEncPubKey: new Uint8Array(65).fill(0x04), + }); + const bytes = new Uint8Array(1 + body.length); + bytes[0] = 0x01; + bytes.set(body, 1); + const decoded = decodeEncryptedHandshakeResponseV2(bytes); expect(decoded.tag).toBe('Success'); if (decoded.tag !== 'Success') return; - expect(decoded.value.encryptionKey).toEqual(fixedChatPublicKey); + expect(decoded.value.rootAccountId).toEqual(new Uint8Array(32).fill(0xa2)); + expect(decoded.value.identityChatPrivateKey).toEqual(fixedChatPrivateKey); }); - it('decodes a 161-byte payload as Success in the legacy shape (no priv key)', () => { - const bytes = new Uint8Array(161); - bytes[0] = 0x04; // P-256 uncompressed marker — first byte of encryptionKey - const decoded = EncryptedHandshakeResponseV2.dec(bytes); + it('decodes a 129-byte v0.2 Success body and surfaces rootAccountId as null', () => { + const body = HandshakeSuccessV2Legacy.enc({ + identityAccountId: new Uint8Array(32).fill(0xa1), + identityChatPrivateKey: fixedChatPrivateKey, + deviceEncPubKey: new Uint8Array(65).fill(0x04), + }); + const bytes = new Uint8Array(1 + body.length); + bytes[0] = 0x01; + bytes.set(body, 1); + const decoded = decodeEncryptedHandshakeResponseV2(bytes); expect(decoded.tag).toBe('Success'); if (decoded.tag !== 'Success') return; - expect(decoded.value.identityChatPrivateKey).toBeUndefined(); + expect(decoded.value.rootAccountId).toBeNull(); + expect(decoded.value.identityAccountId).toEqual(new Uint8Array(32).fill(0xa1)); + }); + + it('rejects a Success body of unknown length', () => { + const bytes = new Uint8Array(50); + bytes[0] = 0x01; + expect(() => decodeEncryptedHandshakeResponseV2(bytes)).toThrow(/not in \{129, 161\}/); + }); + + it('decodes Failed with a UTF-8 reason string', () => { + const reason = 'duplicate'; + const reasonBytes = str.enc(reason); + const bytes = new Uint8Array(1 + reasonBytes.length); + bytes[0] = 0x02; + bytes.set(reasonBytes, 1); + expect(decodeEncryptedHandshakeResponseV2(bytes)).toEqual({ tag: 'Failed', value: reason }); + }); + + it('rejects an empty plaintext', () => { + expect(() => decodeEncryptedHandshakeResponseV2(new Uint8Array(0))).toThrow(/empty plaintext/); + }); + + it('rejects an unknown variant discriminant', () => { + expect(() => decodeEncryptedHandshakeResponseV2(new Uint8Array([0x07, 0x00]))).toThrow(/unknown variant/); + }); +}); + +describe('EncryptedHandshakeResponseV2 (native scale-ts Enum, used for encode)', () => { + it('encodes Pending(AllowanceAllocation) as 0x00 0x00', () => { + const encoded = EncryptedHandshakeResponseV2.enc({ + tag: 'Pending', + value: { tag: 'AllowanceAllocation', value: undefined }, + }); + expect(encoded).toEqual(new Uint8Array([0x00, 0x00])); + }); + + it('round-trips Success on the v0.2.1 wire format', () => { + const success = { + tag: 'Success' as const, + value: { + identityAccountId: new Uint8Array(32).fill(0xa1), + rootAccountId: new Uint8Array(32).fill(0xa2), + identityChatPrivateKey: fixedChatPrivateKey, + deviceEncPubKey: new Uint8Array(65).fill(0x04), + }, + }; + const encoded = EncryptedHandshakeResponseV2.enc(success); + expect(encoded.length).toBe(1 + 161); + expect(encoded[0]).toBe(0x01); + expect(EncryptedHandshakeResponseV2.dec(encoded)).toEqual(success); }); it('round-trips Failed with a reason string', () => { @@ -255,3 +285,9 @@ describe('HandshakeResponseV1 (legacy)', () => { expect(HandshakeResponseV1.dec(HandshakeResponseV1.enc(r))).toEqual(r); }); }); + +describe('deriveIdentityChatPublicKey', () => { + it('returns the uncompressed 65-byte P-256 public key matching @noble', () => { + expect(deriveIdentityChatPublicKey(fixedChatPrivateKey)).toEqual(fixedChatPublicKey); + }); +}); diff --git a/packages/host-papp/__tests__/handshakeV2Service.spec.ts b/packages/host-papp/__tests__/handshakeV2Service.spec.ts index ee5aee48..fe99009e 100644 --- a/packages/host-papp/__tests__/handshakeV2Service.spec.ts +++ b/packages/host-papp/__tests__/handshakeV2Service.spec.ts @@ -8,7 +8,6 @@ import { describe, expect, it, vi } from 'vitest'; import { EncryptedHandshakeResponseV2, VersionedHandshakeResponse } from '../src/sso/auth/scale/handshakeV2.js'; import type { DeviceIdentityForPairing } from '../src/sso/auth/v2/service.js'; import { startPairingV2 } from '../src/sso/auth/v2/service.js'; -import type { HandshakeSuccessState } from '../src/sso/auth/v2/state.js'; import { computePairingTopic } from '../src/sso/auth/v2/topic.js'; const ecdhX = (priv: Uint8Array, pub: Uint8Array): Uint8Array => p256.getSharedSecret(priv, pub).slice(1, 33); @@ -116,23 +115,19 @@ describe('startPairingV2', () => { const states$ = pairing.state$.pipe(take(3), toArray()); const collected = lastValueFrom(states$); - const pendingBytes = EncryptedHandshakeResponseV2.enc({ tag: 'Pending', value: undefined }); + const pendingBytes = EncryptedHandshakeResponseV2.enc({ + tag: 'Pending', + value: { tag: 'AllowanceAllocation', value: undefined }, + }); store.emit([buildStatement(device, pendingBytes)]); - const success: HandshakeSuccessState = { - tag: 'Success', - identityChatPublicKey: new Uint8Array(65).fill(0x04), - userIdentityAccountId: new Uint8Array(32).fill(0xb2), - identitySignature: new Uint8Array(64).fill(0xcc), - identityChatPrivateKey: new Uint8Array(32).fill(0xdd), - }; const successBytes = EncryptedHandshakeResponseV2.enc({ tag: 'Success', value: { - encryptionKey: success.identityChatPublicKey, - accountId: success.userIdentityAccountId, - identitySignature: success.identitySignature, - identityChatPrivateKey: success.identityChatPrivateKey, + identityAccountId: new Uint8Array(32).fill(0xa1), + rootAccountId: new Uint8Array(32).fill(0xa2), + identityChatPrivateKey: new Uint8Array(32).fill(0xdd), + deviceEncPubKey: new Uint8Array(65).fill(0x04), }, }); store.emit([buildStatement(device, successBytes)]); diff --git a/packages/host-papp/__tests__/handshakeV2State.spec.ts b/packages/host-papp/__tests__/handshakeV2State.spec.ts index f0c7049b..001549e6 100644 --- a/packages/host-papp/__tests__/handshakeV2State.spec.ts +++ b/packages/host-papp/__tests__/handshakeV2State.spec.ts @@ -1,6 +1,7 @@ +import { p256 } from '@noble/curves/nist.js'; import { describe, expect, it } from 'vitest'; -import { EncryptedHandshakeResponseV2 } from '../src/sso/auth/scale/handshakeV2.js'; +import type { DecodedHandshakeResponseV2 } from '../src/sso/auth/scale/handshakeV2.js'; import type { HandshakeState } from '../src/sso/auth/v2/state.js'; import { advance, @@ -11,43 +12,66 @@ import { submitted, } from '../src/sso/auth/v2/state.js'; -const decode = (value: ReturnType) => - EncryptedHandshakeResponseV2.dec(EncryptedHandshakeResponseV2.enc(value)); +const fixedChatPrivateKey = new Uint8Array(32).fill(0xdd); +const fixedChatPublicKey = p256.getPublicKey(fixedChatPrivateKey, false); + +const makeSuccess = (overrides: Partial = {}): HandshakeState => ({ + tag: 'Success', + identityAccountId: new Uint8Array(32).fill(0xa1), + rootAccountId: new Uint8Array(32).fill(0xa2), + identityChatPrivateKey: fixedChatPrivateKey, + identityChatPublicKey: fixedChatPublicKey, + deviceEncPubKey: new Uint8Array(65).fill(0x04), + ...overrides, +}); describe('fromInnerResponse', () => { - it('maps Pending (single discriminant byte, no inner status) to Pending state', () => { - const r = decode({ tag: 'Pending', value: undefined }); + it('maps Pending to Pending state', () => { + const r: DecodedHandshakeResponseV2 = { + tag: 'Pending', + value: { tag: 'AllowanceAllocation', value: undefined }, + }; expect(fromInnerResponse(r)).toEqual({ tag: 'Pending', reason: 'AllowanceAllocation' }); }); - it('decodes a 1-byte Pending payload (peer wire-compat)', () => { - const r = EncryptedHandshakeResponseV2.dec(new Uint8Array([0x00])); - expect(r.tag).toBe('Pending'); - expect(fromInnerResponse(r)).toEqual({ tag: 'Pending', reason: 'AllowanceAllocation' }); + it('maps v0.2.1 Success to Success state and derives identityChatPublicKey from priv key', () => { + const r: DecodedHandshakeResponseV2 = { + tag: 'Success', + value: { + identityAccountId: new Uint8Array(32).fill(0xa1), + rootAccountId: new Uint8Array(32).fill(0xa2), + identityChatPrivateKey: fixedChatPrivateKey, + deviceEncPubKey: new Uint8Array(65).fill(0x04), + }, + }; + const state = fromInnerResponse(r); + expect(state.tag).toBe('Success'); + if (state.tag !== 'Success') return; + expect(state.identityAccountId).toEqual(new Uint8Array(32).fill(0xa1)); + expect(state.rootAccountId).toEqual(new Uint8Array(32).fill(0xa2)); + expect(state.identityChatPrivateKey).toEqual(fixedChatPrivateKey); + expect(state.identityChatPublicKey).toEqual(fixedChatPublicKey); + expect(state.deviceEncPubKey).toEqual(new Uint8Array(65).fill(0x04)); }); - it('maps Success to Success state with all four key fields', () => { - const r = decode({ + it('preserves rootAccountId=null for v0.2 Success payloads', () => { + const r: DecodedHandshakeResponseV2 = { tag: 'Success', value: { - encryptionKey: new Uint8Array(65).fill(0x04), - accountId: new Uint8Array(32).fill(0xb2), - identitySignature: new Uint8Array(64).fill(0xcc), - identityChatPrivateKey: new Uint8Array(32).fill(0xdd), + identityAccountId: new Uint8Array(32).fill(0xa1), + rootAccountId: null, + identityChatPrivateKey: fixedChatPrivateKey, + deviceEncPubKey: new Uint8Array(65).fill(0x04), }, - }); + }; const state = fromInnerResponse(r); expect(state.tag).toBe('Success'); - if (state.tag === 'Success') { - expect(state.identityChatPublicKey.length).toBe(65); - expect(state.userIdentityAccountId.length).toBe(32); - expect(state.identitySignature.length).toBe(64); - expect(state.identityChatPrivateKey.length).toBe(32); - } + if (state.tag !== 'Success') return; + expect(state.rootAccountId).toBeNull(); }); it('maps Failed to Failed state with reason string', () => { - const r = decode({ tag: 'Failed', value: 'no slot available' }); + const r: DecodedHandshakeResponseV2 = { tag: 'Failed', value: 'no slot available' }; expect(fromInnerResponse(r)).toEqual({ tag: 'Failed', reason: 'no slot available' }); }); }); @@ -64,24 +88,12 @@ describe('advance', () => { it('Pending → Success is allowed', () => { const pending: HandshakeState = { tag: 'Pending', reason: 'AllowanceAllocation' }; - const success: HandshakeState = { - tag: 'Success', - identityChatPublicKey: new Uint8Array(65), - userIdentityAccountId: new Uint8Array(32), - identitySignature: new Uint8Array(64), - identityChatPrivateKey: new Uint8Array(32), - }; + const success = makeSuccess(); expect(advance(pending, success)).toEqual(success); }); it('terminal states are absorbing — Success cannot regress to Pending', () => { - const success: HandshakeState = { - tag: 'Success', - identityChatPublicKey: new Uint8Array(65), - userIdentityAccountId: new Uint8Array(32), - identitySignature: new Uint8Array(64), - identityChatPrivateKey: new Uint8Array(32), - }; + const success = makeSuccess(); const pending: HandshakeState = { tag: 'Pending', reason: 'AllowanceAllocation' }; expect(advance(success, pending)).toEqual(success); }); @@ -104,14 +116,7 @@ describe('advance', () => { describe('isTerminal', () => { it('returns true for Success', () => { - expect( - isTerminal({ - tag: 'Success', - identityChatPublicKey: new Uint8Array(65), - userIdentityAccountId: new Uint8Array(32), - identitySignature: new Uint8Array(64), - }), - ).toBe(true); + expect(isTerminal(makeSuccess())).toBe(true); }); it('returns true for Failed', () => { @@ -127,14 +132,7 @@ describe('isTerminal', () => { describe('canSubmitV2Statements', () => { it('only true in Success', () => { - const success: HandshakeState = { - tag: 'Success', - identityChatPublicKey: new Uint8Array(65), - userIdentityAccountId: new Uint8Array(32), - identitySignature: new Uint8Array(64), - identityChatPrivateKey: new Uint8Array(32), - }; - expect(canSubmitV2Statements(success)).toBe(true); + expect(canSubmitV2Statements(makeSuccess())).toBe(true); expect(canSubmitV2Statements(idle())).toBe(false); expect(canSubmitV2Statements(submitted())).toBe(false); expect(canSubmitV2Statements({ tag: 'Pending', reason: 'AllowanceAllocation' })).toBe(false); diff --git a/packages/host-papp/src/index.ts b/packages/host-papp/src/index.ts index bd4a9d76..c65f3bc8 100644 --- a/packages/host-papp/src/index.ts +++ b/packages/host-papp/src/index.ts @@ -19,7 +19,7 @@ export type { RingVrfAliasRequest, RingVrfAliasResponse } from './sso/sessionMan // ── V2 SSO handshake ───────────────────────────────────────────────────── -export type { EncryptedHandshakeResponseV2Value } from './sso/auth/scale/handshakeV2.js'; +export type { EncryptedHandshakeResponseV2Value, HandshakeSuccessV2Value } from './sso/auth/scale/handshakeV2.js'; export { Device, EncryptedHandshakeResponseV1, @@ -29,11 +29,11 @@ export { HandshakeResponseV2, HandshakeStatusV2, HandshakeSuccessV2, - IDENTITY_SIGNATURE_PAYLOAD_BYTES, MetadataEntry, MetadataKey, VersionedHandshakeProposal, VersionedHandshakeResponse, + deriveIdentityChatPublicKey, } from './sso/auth/scale/handshakeV2.js'; export { computePairingChannel, computePairingTopic } from './sso/auth/v2/topic.js'; diff --git a/packages/host-papp/src/sso/auth/scale/handshakeV2.ts b/packages/host-papp/src/sso/auth/scale/handshakeV2.ts index 110f2525..95d0d161 100644 --- a/packages/host-papp/src/sso/auth/scale/handshakeV2.ts +++ b/packages/host-papp/src/sso/auth/scale/handshakeV2.ts @@ -1,38 +1,42 @@ /** - * SCALE codecs for the V2 SSO handshake. + * SCALE codecs for the V2 SSO handshake (multi-device shape). * * The host emits a `VersionedHandshakeProposal::V2` via QR carrying its * `Device { statementAccountId, encryptionPublicKey }` and metadata. The - * authorising peer responds over the Statement Store with a + * authorising peer (PApp) responds over the Statement Store with a * `VersionedHandshakeResponse`; its body is ECDH-encrypted to the host's - * encryption public key with the peer's ephemeral `tmpKey`. The inner - * payload after decrypt is `EncryptedHandshakeResponseV2 = Pending | Success - * | Failed`. + * encryption public key with the peer's ephemeral `tmpKey`. The inner payload + * after decrypt is `EncryptedHandshakeResponseV2 = Pending | Success | Failed`. * - * `Success` carries the user identity sr25519 accountId, the user identity - * chat P-256 private key (32 bytes raw scalar), and a 64-byte sr25519 - * signature over `accountId || derive_pub(identityChatPrivateKey)` (97 bytes - * — see `IDENTITY_SIGNATURE_PAYLOAD_BYTES`) proving the user authorised this - * device. The matching public key is derived locally from the private scalar - * (P-256 scalar multiplication with the standard generator); both sides MUST - * derive identically before verifying the signature. The private key is - * shared per the multi-device spec so the device can decrypt incoming chat - * traffic addressed to the user identity. Transit security comes from the - * outer envelope's ECDH-AES wrap, not from a separate per-field key. + * `Success` carries: + * - `identityAccountId` — user identity sr25519 accountId (32 bytes). + * Adressing for chat / username lookup / session + * topic derivation. + * - `rootAccountId` — user root sr25519 accountId (32 bytes). Parent + * for soft-derivation of product accounts; PApp + * and host MUST derive identically so a dapp sees + * the same address on every device. + * - `identityChatPrivateKey`— user identity chat P-256 private scalar (32 bytes), + * shared per the multi-device spec so this device + * can decrypt traffic addressed to the user identity + * - `deviceEncPubKey` — encryption public key of the PApp device (65 bytes, + * P-256 uncompressed). Tells the host which key to + * use when addressing chat envelopes back to the + * authorising PApp device. + * + * Total wire length of `Success` is 32 + 32 + 32 + 65 = 161 bytes. Transit security + * comes from the outer envelope's ECDH-AES wrap; no per-field signature is + * carried — multi-device authorisation is asserted by the user-identity-signed + * roster events (`DeviceAdded`/`DeviceRemoved`) published separately. */ import { p256 } from '@noble/curves/nist.js'; -import type { Codec } from 'scale-ts'; -import { Bytes, Enum, Struct, Tuple, Vector, _void, createCodec, str } from 'scale-ts'; +import { Bytes, Enum, Struct, Tuple, Vector, _void, str } from 'scale-ts'; const AccountIdCodec = Bytes(32); const PublicKeyCodec = Bytes(65); -const SignatureCodec = Bytes(64); const PrivateKeyCodec = Bytes(32); -/** Bytes the user identity sr25519 signs to authorise a device: accountId(32) || encPub(65). */ -export const IDENTITY_SIGNATURE_PAYLOAD_BYTES = 32 + 65; - // ── Proposal ──────────────────────────────────────────────────────────── export const MetadataKey = Enum({ @@ -67,152 +71,123 @@ export const VersionedHandshakeProposal = Enum({ // ── Response (V2) ─────────────────────────────────────────────────────── -/** - * Pinned wire structs: - * - * - `HandshakeSuccessV2Legacy` (161 bytes, encryptionKey + accountId + - * signature) for PApp builds before the multi-device priv-key extension. - * - `HandshakeSuccessV2WithChatPriv` (128 bytes, accountId + - * identityChatPrivateKey + signature) for builds shipping the spec'd - * multi-device extension. The matching `encryptionKey` is derived locally - * via P-256 scalar multiplication and surfaced on the decoded value so - * downstream consumers see the same shape regardless of wire variant. - * - * Length dispatch in `EncryptedHandshakeResponseV2` picks the right one. Once - * every PApp build has the priv-key extension we can collapse to a single - * struct. - */ -export const HandshakeSuccessV2Legacy = Struct({ - encryptionKey: PublicKeyCodec, - accountId: AccountIdCodec, - identitySignature: SignatureCodec, +/** 32 + 32 + 32 + 65 = 161 bytes (spec v0.2.1) */ +export const HandshakeSuccessV2 = Struct({ + identityAccountId: AccountIdCodec, + rootAccountId: AccountIdCodec, + identityChatPrivateKey: PrivateKeyCodec, + deviceEncPubKey: PublicKeyCodec, }); -export const HandshakeSuccessV2WithChatPriv = Struct({ - accountId: AccountIdCodec, +export type HandshakeSuccessV2Value = { + identityAccountId: Uint8Array; + /** Nullable for v0.2 peers (Android `feature/location-for-handshake`). */ + rootAccountId: Uint8Array | null; + identityChatPrivateKey: Uint8Array; + deviceEncPubKey: Uint8Array; +}; + +/** 32 + 32 + 65 = 129 bytes (spec v0.2 — Android `feature/location-for-handshake`) */ +export const HandshakeSuccessV2Legacy = Struct({ + identityAccountId: AccountIdCodec, identityChatPrivateKey: PrivateKeyCodec, - identitySignature: SignatureCodec, + deviceEncPubKey: PublicKeyCodec, }); +export type DecodedHandshakeResponseV2 = + | { tag: 'Pending'; value: { tag: 'AllowanceAllocation'; value: undefined } } + | { tag: 'Success'; value: HandshakeSuccessV2Value } + | { tag: 'Failed'; value: string }; + /** - * Backwards-compatible Success codec. - * - * Encode emits the new (128-byte) shape when `identityChatPrivateKey` is - * present (the `encryptionKey` field on the input value is ignored — it is - * derived from the private scalar on decode). Decode accepts both lengths - * and surfaces `identityChatPrivateKey: undefined` together with the on-wire - * `encryptionKey` when the legacy 161-byte format is received, so consumers - * can branch on availability without crashing. + * Length-dispatched decoder for the inner `EncryptedHandshakeResponseV2` + * plaintext. v0.2 (Android) ships 129-byte Success bodies without + * `rootAccountId`; v0.2.1 ships 161-byte bodies with it. Surfaces + * `rootAccountId: null` for the legacy case — chat doesn't need it. */ -type HandshakeSuccessV2Value = { - encryptionKey: Uint8Array; - accountId: Uint8Array; - identitySignature: Uint8Array; - identityChatPrivateKey?: Uint8Array; -}; - -const derivePublicFromPrivate = (privateKey: Uint8Array): Uint8Array => p256.getPublicKey(privateKey, false); - -export const HandshakeSuccessV2: Codec = createCodec( - v => { - if (v.identityChatPrivateKey) { - return HandshakeSuccessV2WithChatPriv.enc({ - accountId: v.accountId, - identityChatPrivateKey: v.identityChatPrivateKey, - identitySignature: v.identitySignature, - }); +export const decodeEncryptedHandshakeResponseV2 = (bytes: Uint8Array): DecodedHandshakeResponseV2 => { + if (bytes.length === 0) throw new Error('EncryptedHandshakeResponseV2: empty plaintext'); + const tag = bytes[0]; + // `slice` not `subarray` — scale-ts decoders read from buffer byteOffset 0. + const body = bytes.slice(1); + if (tag === 0) { + if (body.length === 0) throw new Error('EncryptedHandshakeResponseV2: Pending body empty'); + return { tag: 'Pending', value: { tag: 'AllowanceAllocation', value: undefined } }; + } + if (tag === 1) { + if (body.length === 161) { + const decoded = HandshakeSuccessV2.dec(body); + return { + tag: 'Success', + value: { + identityAccountId: decoded.identityAccountId, + rootAccountId: decoded.rootAccountId, + identityChatPrivateKey: decoded.identityChatPrivateKey, + deviceEncPubKey: decoded.deviceEncPubKey, + }, + }; } - return HandshakeSuccessV2Legacy.enc({ - encryptionKey: v.encryptionKey, - accountId: v.accountId, - identitySignature: v.identitySignature, - }); - }, - raw => { - const bytes = toBytes(raw); - if (bytes.length === SUCCESS_LEN_WITH_CHAT_PRIV) { - const decoded = HandshakeSuccessV2WithChatPriv.dec(bytes); + if (body.length === 129) { + const decoded = HandshakeSuccessV2Legacy.dec(body); return { - encryptionKey: derivePublicFromPrivate(decoded.identityChatPrivateKey), - accountId: decoded.accountId, - identitySignature: decoded.identitySignature, - identityChatPrivateKey: decoded.identityChatPrivateKey, + tag: 'Success', + value: { + identityAccountId: decoded.identityAccountId, + rootAccountId: null, + identityChatPrivateKey: decoded.identityChatPrivateKey, + deviceEncPubKey: decoded.deviceEncPubKey, + }, }; } - const legacy = HandshakeSuccessV2Legacy.dec(bytes); - return { ...legacy, identityChatPrivateKey: undefined }; - }, -); + throw new Error(`EncryptedHandshakeResponseV2: Success body length ${body.length} not in {129, 161}`); + } + if (tag === 2) { + return { tag: 'Failed', value: str.dec(body) }; + } + throw new Error(`EncryptedHandshakeResponseV2: unknown variant tag ${tag}`); +}; /** - * Inner Pending sub-statuses; only `AllowanceAllocation` today. Kept for - * schema symmetry — not actually encoded on the wire because the peer - * encodes `data object AllowanceAllocation` as zero bytes, so the entire - * Pending statement is just the outer discriminant `0x00`. + * Derive the user identity chat P-256 public key from the shared private + * scalar received in `HandshakeSuccessV2`. Both desktop and mobile must derive + * identically (uncompressed 65-byte form) so downstream session topics agree. + */ +export const deriveIdentityChatPublicKey = (privateKey: Uint8Array): Uint8Array => p256.getPublicKey(privateKey, false); + +/** + * Inner Pending sub-statuses. Only `AllowanceAllocation` today. + * + * Encoded with its own SCALE discriminant byte — the peer's SCALE library does + * NOT elide enum-variant indices for sealed interfaces (verified on the wire: + * `Pending(AllowanceAllocation)` arrives as `0x00 0x00`). Both sides must keep + * the discriminant when encoding so dispatch agrees. */ export const HandshakeStatusV2 = Enum({ AllowanceAllocation: _void, }); -const SUCCESS_LEN_LEGACY = 65 + 32 + 64; -const SUCCESS_LEN_WITH_CHAT_PRIV = 32 + 32 + 64; -const PENDING_BYTE = 0x00; - export type EncryptedHandshakeResponseV2Value = - | { tag: 'Pending'; value: undefined } - | { - tag: 'Success'; - value: HandshakeSuccessV2Value; - } + | { tag: 'Pending'; value: { tag: 'AllowanceAllocation'; value: undefined } } + | { tag: 'Success'; value: HandshakeSuccessV2Value } | { tag: 'Failed'; value: string }; -const toBytes = (value: Uint8Array | ArrayBuffer | string): Uint8Array => { - if (typeof value === 'string') { - const hex = value.startsWith('0x') ? value.slice(2) : value; - const out = new Uint8Array(hex.length / 2); - for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); - return out; - } - if (value instanceof ArrayBuffer) return new Uint8Array(value); - return value; -}; - /** - * Length-dispatched variant codec. The peer's SCALE library elides the outer - * enum index for class-wrapped sealed-interface variants, leaving each - * variant's bytes exposed directly: + * Inner handshake response variant. Wire shape (matches peer SCALE encoding): * - * - Pending → 1 byte: 0x00 (the inner `AllowanceAllocation` tag; the - * outer Pending wrapper emits nothing) - * - Success → 161 bytes (legacy PApp): encryptionKey 65 || accountId 32 || signature 64 - * - Success → 128 bytes (multi-device PApp): accountId 32 || identityChatPrivateKey 32 || signature 64 - * - Failed → variable: SCALE-encoded UTF-8 reason string + * - Pending → 0x00 || HandshakeStatusV2 (today: 0x00 for AllowanceAllocation), 2 bytes total + * - Success → 0x01 || HandshakeSuccessV2 (161 bytes), 162 bytes total + * - Failed → 0x02 || SCALE-encoded UTF-8 reason string * - * Disambiguation is purely by byte length; protocol-state context further - * constrains which variant is plausible at any given moment. + * Earlier builds shipped a custom length-dispatched codec assuming elision — + * that assumption was wrong and produced false `Failed("")` reads on every + * `Pending` response. Native scale-ts `Enum` preserves the discriminant and + * matches the peer's wire format directly. */ -export const EncryptedHandshakeResponseV2: Codec = createCodec( - v => { - switch (v.tag) { - case 'Pending': - return new Uint8Array([PENDING_BYTE]); - case 'Success': - return HandshakeSuccessV2.enc(v.value); - case 'Failed': - return str.enc(v.value); - } - }, - raw => { - const bytes = toBytes(raw); - if (bytes.length === 1 && bytes[0] === PENDING_BYTE) { - return { tag: 'Pending', value: undefined }; - } - if (bytes.length === SUCCESS_LEN_LEGACY || bytes.length === SUCCESS_LEN_WITH_CHAT_PRIV) { - return { tag: 'Success', value: HandshakeSuccessV2.dec(bytes) }; - } - return { tag: 'Failed', value: str.dec(bytes) }; - }, -); +export const EncryptedHandshakeResponseV2 = Enum({ + Pending: HandshakeStatusV2, + Success: HandshakeSuccessV2, + Failed: str, +}); export const HandshakeResponseV2 = Struct({ encrypted: Bytes(), diff --git a/packages/host-papp/src/sso/auth/v2/service.ts b/packages/host-papp/src/sso/auth/v2/service.ts index 992bed44..61cae3bc 100644 --- a/packages/host-papp/src/sso/auth/v2/service.ts +++ b/packages/host-papp/src/sso/auth/v2/service.ts @@ -27,7 +27,7 @@ import type { Statement, StatementStoreAdapter } from '@novasamatech/statement-s import type { Observable } from 'rxjs'; import { BehaviorSubject } from 'rxjs'; -import { EncryptedHandshakeResponseV2, VersionedHandshakeResponse } from '../scale/handshakeV2.js'; +import { VersionedHandshakeResponse, decodeEncryptedHandshakeResponseV2 } from '../scale/handshakeV2.js'; import { decryptResponseEnvelope } from './envelope.js'; import type { HandshakeMetadata } from './proposal.js'; @@ -152,13 +152,16 @@ export const startPairingV2 = (deps: StartPairingDeps): Pairing => { let next: HandshakeState; try { - next = fromInnerResponse(EncryptedHandshakeResponseV2.dec(innerBytes)); + next = fromInnerResponse(decodeEncryptedHandshakeResponseV2(innerBytes)); } catch (err) { log(`inner decode failed; innerBytes (${innerBytes.length}b) = ${toHexFull(innerBytes)}`, err); return; } log(`decoded inner response, tag=${next.tag}`); + if (next.tag === 'Failed') { + log(`failure reason: "${next.reason}" (innerBytes ${innerBytes.length}b = ${toHexFull(innerBytes)})`); + } const advanced = advance(state$.value, next); if (advanced === state$.value) { diff --git a/packages/host-papp/src/sso/auth/v2/state.ts b/packages/host-papp/src/sso/auth/v2/state.ts index d14a364c..ba2d1b43 100644 --- a/packages/host-papp/src/sso/auth/v2/state.ts +++ b/packages/host-papp/src/sso/auth/v2/state.ts @@ -15,38 +15,41 @@ * before submitting any V2 statements. */ -import type { CodecType } from 'scale-ts'; - -import type { EncryptedHandshakeResponseV2 } from '../scale/handshakeV2.js'; +import type { DecodedHandshakeResponseV2 } from '../scale/handshakeV2.js'; +import { deriveIdentityChatPublicKey } from '../scale/handshakeV2.js'; export type HandshakeIdleState = { tag: 'Idle' }; export type HandshakeSubmittedState = { tag: 'Submitted' }; export type HandshakePendingState = { tag: 'Pending'; reason: 'AllowanceAllocation' }; export type HandshakeSuccessState = { tag: 'Success'; + /** User identity sr25519 accountId (32 bytes). */ + identityAccountId: Uint8Array; + /** + * User root sr25519 accountId (32 bytes) — the parent for soft-derivation + * of product accounts. Nullable: peers on spec v0.2 (Android + * `feature/location-for-handshake`) omit this field. Product-account + * derivation degrades gracefully when absent; chat does not use it. + */ + rootAccountId: Uint8Array | null; + /** + * User identity chat P-256 private key (32 bytes raw scalar) shared by + * PApp with this device per the multi-device spec. Sensitive; persist in + * OS-keychain-backed secure storage and never forward. + */ + identityChatPrivateKey: Uint8Array; /** * Derived locally from `identityChatPrivateKey` via P-256 scalar - * multiplication on the multi-device wire format; read directly from the - * `encryption_key` wire field on legacy 161-byte responses. Either way - * `IDENTITY_SIGNATURE_PAYLOAD_BYTES = accountId || identityChatPublicKey` - * is the canonical form `identitySignature` commits to. + * multiplication (uncompressed 65-byte form). Both sides MUST derive + * identically; downstream session topics depend on it. */ identityChatPublicKey: Uint8Array; - userIdentityAccountId: Uint8Array; - identitySignature: Uint8Array; /** - * The user identity chat P-256 private key (32 bytes raw scalar) shared by - * PApp with this device per the multi-device spec — required to decrypt - * incoming chat traffic addressed to the user identity. Sensitive; the - * device should persist it in OS-keychain-backed secure storage and never - * forward it. - * - * `undefined` when the responder is a legacy PApp build that hasn't shipped - * the multi-device priv-key extension yet. The device can still send V2 - * chat traffic (signed by its own keys); inbound decryption is gated on - * this field being present. + * Encryption public key of the authorising PApp device (65 bytes, + * P-256 uncompressed). Used by the host when addressing chat envelopes + * back to the authorising device. */ - identityChatPrivateKey: Uint8Array | undefined; + deviceEncPubKey: Uint8Array; }; export type HandshakeFailedState = { tag: 'Failed'; reason: string }; @@ -62,10 +65,11 @@ export const idle = (): HandshakeIdleState => ({ tag: 'Idle' }); export const submitted = (): HandshakeSubmittedState => ({ tag: 'Submitted' }); /** - * Translate an inner-decoded `EncryptedHandshakeResponseV2` into the public - * state. Pure — no I/O. The caller decrypts the outer envelope first. + * Translate the length-dispatched-decoded `EncryptedHandshakeResponseV2` into + * the public state. Pure — no I/O. The caller decrypts the outer envelope and + * runs `decodeEncryptedHandshakeResponseV2` first. */ -export const fromInnerResponse = (response: CodecType): HandshakeState => { +export const fromInnerResponse = (response: DecodedHandshakeResponseV2): HandshakeState => { switch (response.tag) { case 'Pending': // Only AllowanceAllocation today; widen here when the spec adds more variants. @@ -73,10 +77,11 @@ export const fromInnerResponse = (response: CodecType Date: Tue, 19 May 2026 17:11:31 -0600 Subject: [PATCH 07/31] fix(host-papp,host-chat): tolerate camelCase Resources.Consumers fields V2 multi-device runtime metadata exposes Resources.Consumers fields in camelCase, which crashed `raw.stmt_store_slots.map(...)` in the host-papp identity adapter. Read each field with snake/camel fallback and treat slots as optional. Same defensiveness applied to host-chat's getConsumerInfo. The .papi descriptor types only model snake_case, so widen via `Record` at the read site. --- packages/host-chat/src/accountService.ts | 19 +++++++++++++----- packages/host-papp/src/identity/rpcAdapter.ts | 20 ++++++++++++++----- 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/packages/host-chat/src/accountService.ts b/packages/host-chat/src/accountService.ts index d0d4a9a4..c9c4c66a 100644 --- a/packages/host-chat/src/accountService.ts +++ b/packages/host-chat/src/accountService.ts @@ -93,8 +93,16 @@ export const createAccountService = (network: Network, lazyClient: LazyClient): const consumerInfo = fromPromise(api.query.Resources?.Consumers?.getValue(address), toError); - return consumerInfo.map(raw => { - if (!raw) return null; + return consumerInfo.map(typedRaw => { + if (!typedRaw) return null; + + // Runtime metadata may expose fields in snake_case (V1) or + // camelCase (V2 multi-device). Read defensively. + const raw = typedRaw as unknown as Record & typeof typedRaw; + const fullUsername = + (raw.full_username as Uint8Array | undefined) ?? (raw.fullUsername as Uint8Array | undefined); + const liteUsername = + (raw.lite_username as Uint8Array | undefined) ?? (raw.liteUsername as Uint8Array | undefined); const credibility: Credibility = raw.credibility.type === 'Lite' @@ -104,13 +112,14 @@ export const createAccountService = (network: Network, lazyClient: LazyClient): : { type: 'Person', alias: raw.credibility.value.alias as HexString, - lastUpdate: raw.credibility.value.last_update.toString(), + lastUpdate: ((raw.credibility.value as Record).last_update ?? + (raw.credibility.value as Record).lastUpdate)!.toString(), }; return { accountId: toHex(accountId.enc(address)), - fullUsername: raw.full_username ? textDecoder.decode(raw.full_username) : null, - liteUsername: textDecoder.decode(raw.lite_username), + fullUsername: fullUsername ? textDecoder.decode(fullUsername) : null, + liteUsername: liteUsername ? textDecoder.decode(liteUsername) : '', credibility: credibility, }; }); diff --git a/packages/host-papp/src/identity/rpcAdapter.ts b/packages/host-papp/src/identity/rpcAdapter.ts index 960664b3..a7a5be1e 100644 --- a/packages/host-papp/src/identity/rpcAdapter.ts +++ b/packages/host-papp/src/identity/rpcAdapter.ts @@ -33,11 +33,20 @@ export function createIdentityRpcAdapter(lazyClient: LazyClient): IdentityAdapte return ok( Object.fromEntries( - zipWith([accounts, results], x => x).map<[string, Identity | null]>(([accountId, raw]) => { - if (!raw) { + zipWith([accounts, results], x => x).map<[string, Identity | null]>(([accountId, typedRaw]) => { + if (!typedRaw) { return [accountId, null]; } + // Runtime metadata may expose fields in snake_case (V1) or + // camelCase (V2 multi-device). Read defensively. The .papi + // descriptor only types snake_case, so widen here. + const raw = typedRaw as unknown as Record & typeof typedRaw; + const fullUsername = + (raw.full_username as Uint8Array | undefined) ?? (raw.fullUsername as Uint8Array | undefined); + const liteUsername = + (raw.lite_username as Uint8Array | undefined) ?? (raw.liteUsername as Uint8Array | undefined); + const credibility: Credibility = raw.credibility.type == 'Lite' ? { @@ -46,15 +55,16 @@ export function createIdentityRpcAdapter(lazyClient: LazyClient): IdentityAdapte : { type: 'Person', alias: raw.credibility.value.alias as HexString, - lastUpdate: raw.credibility.value.last_update.toString(), + lastUpdate: ((raw.credibility.value as Record).last_update ?? + (raw.credibility.value as Record).lastUpdate)!.toString(), }; return [ accountId, { accountId: accountId, - fullUsername: raw.full_username ? textDecoder.decode(raw.full_username) : null, - liteUsername: textDecoder.decode(raw.lite_username), + fullUsername: fullUsername ? textDecoder.decode(fullUsername) : null, + liteUsername: liteUsername ? textDecoder.decode(liteUsername) : '', credibility, }, ]; From 72e03234032fe283be5245f9188e06deacd0b324 Mon Sep 17 00:00:00 2001 From: Ilya Date: Tue, 19 May 2026 17:11:57 -0600 Subject: [PATCH 08/31] =?UTF-8?q?feat(host-chat):=20add=20multi-device=20c?= =?UTF-8?q?hat=20content=20variants=20(indices=2014=E2=80=9320)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adopt the multi-device chat content shape per the chat spec v0.1 (https://hackmd.io/@1JCaGppGSUqHtJilikYaKw/Ski9naYdWe). New MessageContent variants: - chatAccepted (14) — legacy single-device accept payload changed from `_void` to `ChatAcceptedContent { messageId: String }` for iOS V1 backward decode. - _reserved16 (16) — reserved (Android `coinagePayment`, unused on desktop) - deviceAdded (17) — `{ statementAccountId, encryptionPublicKey }` - deviceRemoved (18) — `{ statementAccountId }` - _reserved19 (19) — placeholder so deviceChatAccepted lands at the spec'd index 20. - deviceChatAccepted (20) — `{ requestId, device: DeviceInfo }`. Sent via identity-level session SessionId(B, A) encrypted with K(A,B); identity-level encryption lets all of A's devices decrypt without a per-device envelope. DeviceAdded/Removed use length-prefixed `Bytes()` instead of fixed-size codecs because substrate-sdk-android emits the `AccountId` / `EncodedPublicKey` wrapper types without `@FixedLength`, falling through to length-prefixed `Vec` on the wire. DeviceInfoContent (used by deviceChatAccepted) stays fixed-size — Android's `DeviceInfoScale` declares `@FixedLength` explicitly. --- packages/host-chat/src/codec/message.ts | 54 ++++++++++++++++++++++++- 1 file changed, 52 insertions(+), 2 deletions(-) diff --git a/packages/host-chat/src/codec/message.ts b/packages/host-chat/src/codec/message.ts index 8e61c0d8..72ebe7f1 100644 --- a/packages/host-chat/src/codec/message.ts +++ b/packages/host-chat/src/codec/message.ts @@ -1,8 +1,11 @@ import { Enum, Hex, Nullable, Status } from '@novasamatech/scale'; -import { Option, Struct, Vector, _void, compact, str, u64 } from 'scale-ts'; +import { Bytes, Option, Struct, Vector, _void, compact, str, u64 } from 'scale-ts'; import { FileVariant } from './attachment.js'; +const AccountIdCodec = Bytes(32); +const PublicKeyCodec = Bytes(65); + export const TextContent = str; export const RichTextContent = Struct({ @@ -39,6 +42,48 @@ export const EditContent = Struct({ newContent: RichTextContent, }); +// V2 multi-device roster mutations carried as chat-content variants. +// Android's `ChatMessageStatementContent.DeviceAdded`/`DeviceRemoved` use +// the `AccountId` / `EncodedPublicKey` wrapper types without `@FixedLength`, +// so substrate-sdk-android falls through to length-prefixed `Vec` on the +// wire. We accept that shape here (`Bytes()` = compact-length-prefixed bytes) +// instead of fixed-size to stay tolerant of Android's emitter. Other variants +// that use `@FixedLength` on raw `ByteArray` (e.g. `DeviceInfoContent` below) +// stay fixed-size and continue to use AccountIdCodec/PublicKeyCodec. +export const DeviceAddedContent = Struct({ + statementAccountId: Bytes(), + encryptionPublicKey: Bytes(), +}); + +export const DeviceRemovedContent = Struct({ + statementAccountId: Bytes(), +}); + +// Legacy single-device accept (index 14, iOS V1). Wire: bare `String`. +// Superseded by `deviceChatAccepted` (index 20); keep for backward decode. +export const ChatAcceptedContent = Struct({ + messageId: str, +}); + +// Per-device descriptor for the multi-device accept. Matches iOS `Chat.PeerDevice` +// and Android `DeviceInfoScale`. +export const DeviceInfoContent = Struct({ + statementAccountId: AccountIdCodec, + encryptionPublicKey: PublicKeyCodec, +}); + +// Multi-device accept (index 20, per chat spec v0.1 +// https://hackmd.io/@1JCaGppGSUqHtJilikYaKw/Ski9naYdWe): +// DeviceChatAccepted = { requestId: String, device: DeviceInfo } +// Sent via identity-level session SessionId(B, A) encrypted with K(A,B); +// identity-level encryption lets all of A's devices decrypt without per-device +// envelope. Index 19 is reserved (placeholder slot between deviceRemoved=18 +// and deviceChatAccepted=20). +export const DeviceChatAcceptedContent = Struct({ + requestId: str, + device: DeviceInfoContent, +}); + // Note: enum indices MUST match iOS/Android SCALE codecs. // Indices are auto-assigned sequentially, so order matters. export const MessageContent = Enum({ @@ -56,8 +101,13 @@ export const MessageContent = Enum({ _reserved11: _void, // 11 — reserved (unused) edit: EditContent, // 12 leftChat: _void, // 13 - chatAccepted: _void, // 14 + chatAccepted: ChatAcceptedContent, // 14 (legacy single-device accept, iOS V1) richText: RichTextContent, // 15 + _reserved16: _void, // 16 — reserved (android `coinagePayment`, unused on desktop) + deviceAdded: DeviceAddedContent, // 17 + deviceRemoved: DeviceRemovedContent, // 18 + _reserved19: _void, // 19 — reserved (placeholder so deviceChatAccepted lands on the spec'd index 20) + deviceChatAccepted: DeviceChatAcceptedContent, // 20 (multi-device accept, spec v0.1) }); export const VersionedMessageContent = Enum({ From 6bd3823d5ed4a821f97a1340776bb004997f131f Mon Sep 17 00:00:00 2001 From: Ilya Date: Wed, 20 May 2026 17:00:59 -0600 Subject: [PATCH 09/31] chore: refresh package-lock.json for new V2 SSO deps `npm ci` in CI requires package-lock.json to be in sync with package.json. The V2 SSO commits added @polkadot-api/utils, rxjs, and verifiablejs to host-papp but the lockfile wasn't refreshed when the branch was first pushed. --- package-lock.json | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 9cc5d634..6753d4b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16349,6 +16349,12 @@ "spdx-expression-parse": "^3.0.0" } }, + "node_modules/verifiablejs": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/verifiablejs/-/verifiablejs-1.2.0.tgz", + "integrity": "sha512-+VVQo9yZko+w3THKaYvLbjXdfaiw1WJnX12wJEU5jHsHrMkjDrAMWoKYcBvtlHXufJirr8FlT6v2i/0yA3/ivQ==", + "license": "GPL-3.0-or-later WITH Classpath-exception-2.0" + }, "node_modules/vite": { "version": "7.3.2", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", @@ -17194,12 +17200,15 @@ "@novasamatech/scale": "0.7.9", "@novasamatech/statement-store": "0.7.9", "@novasamatech/storage-adapter": "0.7.9", + "@polkadot-api/utils": "^0.4.0", "@polkadot-labs/hdkd-helpers": "^0.0.30", "nanoevents": "9.1.0", "nanoid": "5.1.9", "neverthrow": "^8.2.0", "polkadot-api": ">=2", - "scale-ts": "1.6.1" + "rxjs": "^7.8.2", + "scale-ts": "1.6.1", + "verifiablejs": "1.2.0" } }, "packages/host-papp-react-ui": { From 4c0fdc110de74b07cae26d3b8be16607e4c1b26b Mon Sep 17 00:00:00 2001 From: Ilya Date: Thu, 21 May 2026 08:27:21 -0600 Subject: [PATCH 10/31] refactor(host-papp): drive V2 SSO inside createAuth, drop V1 handshake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `createAuth` / `pappAdapter.sso` is now V2-driven end-to-end with the same `pairingStatus` / `authenticate()` / `abortAuthentication()` surface as before — the V2 wire format, ECDH envelope, pairing service, state machine, topic derivation, and peer-signer capture all run inside the SDK. The V1 handshake codec and ephemeral-keypair logic are gone; callers must inject a persistent `DeviceIdentityForPairing` via a thunk on `createPappAdapter`. `createPappAdapter` reshape: - drops `metadata: string` (the V1 metadata URL) — host name / icon / platform now ride inside `hostMetadata` (which mirrors V2's `HandshakeMetadata`) - requires `deviceIdentity: () => Promise` - new `onAuthSuccess`, `initialProcessedDataHex`, `onPairingStatementProcessed` callbacks for consumer-side persistence and reload-survival dedupe `AuthSuccess.peerStatementAccountId` is lifted off `statement.proof.value.signer` during pairing so device-sync can seed PApp without a follow-up chain query. Threaded through `HandshakeSuccessState` and `fromInnerResponse`. Public surface trimmed to `createAuth` and the types you need to use it. `startPairingV2`, the state-machine helpers (`idle` / `submitted` / `advance` / `fromInnerResponse` / `isTerminal` / `canSubmitV2Statements`), topic / envelope / proposal helpers (`computePairingTopic` / `computePairingChannel` / `buildPairingDeeplink` / `encodeProposal` / `decryptResponseEnvelope` / `deriveIdentityChatPublicKey`), every `Handshake*` / `*HandshakeResponse*` / `VersionedHandshake*` / `MetadataEntry` / `MetadataKey` / `Device` SCALE codec, and the `Handshake*State` / `HandshakeMetadata` / `HandshakeProposalDevice` / `HandshakeResponseEnvelope` / `Pairing` / `StartPairingDeps` types are all internal now. `PairingStatus.finished` no longer carries `session` — read the resolved `AuthSuccess` off `authenticate()` instead. `host-papp-react-ui`: - `useAuthStatus` returns `{ status, isSignedIn }` (was `signedInUser`, which depended on the V1 session shape) - `Flow.stories.tsx` updated for the new `createPappAdapter` params 523/523 tests pass. `auth.spec.ts` rewritten to drive the V2-backed `createAuth` end-to-end (success, persistOnSuccess hook, Failed inner response, abort, debug emits) using a real SCALE-encoded HandshakeSuccessV2 envelope. --- CHANGELOG.md | 44 ++ .../host-papp-react-ui/src/Flow.stories.tsx | 11 +- .../src/hooks/authStatus.ts | 11 +- packages/host-papp/__tests__/auth.spec.ts | 387 ++++++---------- .../__tests__/handshakeV2State.spec.ts | 1 + packages/host-papp/src/debugTypes.ts | 8 +- packages/host-papp/src/index.ts | 44 +- packages/host-papp/src/papp.ts | 54 ++- packages/host-papp/src/sso/auth/impl.ts | 430 +++++++----------- .../host-papp/src/sso/auth/scale/handshake.ts | 27 -- packages/host-papp/src/sso/auth/types.ts | 4 +- packages/host-papp/src/sso/auth/v2/service.ts | 24 +- packages/host-papp/src/sso/auth/v2/state.ts | 14 +- 13 files changed, 430 insertions(+), 629 deletions(-) delete mode 100644 packages/host-papp/src/sso/auth/scale/handshake.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 75155343..2a0e6873 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,47 @@ +## 0.8.0 (2026-05-21) + +### 🚀 Features + +- **host-papp:** multi-device support via V2 SSO. `createAuth` (and `pappAdapter.sso`) is now V2-driven end-to-end — the same `pairingStatus` / `authenticate()` / `abortAuthentication()` surface you already use. Desktop and web hosts pair with the multi-device iOS/Android Polkadot Mobile builds through this entry point; the V2 wire format (codecs, ECDH envelope, RxJS pairing service, state machine) runs inside the SDK and is no longer something callers need to wire up. + ```ts + const adapter = createPappAdapter({ + appId, + hostMetadata, + deviceIdentity: () => deviceIdentityService.loadOrCreate(), + onAuthSuccess: async success => { + // success.peerStatementAccountId is the PApp device id, lifted off the + // pairing-topic statement's proof.value.signer inside the SDK. + await persistHandshakeSuccess(success, success.peerStatementAccountId); + }, + initialProcessedDataHex: () => repo.readLastProcessedHandshakeStatement(), + onPairingStatementProcessed: hex => void repo.writeLastProcessedHandshakeStatement(hex), + }); + await adapter.sso.authenticate(); + ``` +- **host-papp:** `AuthSuccess` (the resolved value of `authenticate()`, exported alongside `HostMetadata` / `PairingStatus` / `DeviceIdentityForPairing`) carries `peerStatementAccountId` — the PApp device that signed the pairing-topic statement. Without it device-sync can't seed PApp as a peer, so the SDK captures it during pairing instead of asking each consumer to re-query the chain. +- **host-papp:** `createPappAdapter` accepts a `deviceIdentity` factory (resolved per `authenticate()`, so secret material never sits in adapter state between attempts), plus `onAuthSuccess` / `initialProcessedDataHex` / `onPairingStatementProcessed` hooks for consumer-side persistence and reload-survival dedupe. +- **host-papp:** `HostMetadata` reshape to mirror the V2 proposal shape — `{ hostName?, hostVersion?, hostIcon?, platformType?, platformVersion?, custom? }`. The host name/icon/platform now ride inside the QR proposal instead of being fetched from a separate URL. +- **host-chat:** new `MessageContent` variants at the spec'd indices — `chatAccepted` (14) now carries `{ messageId }` for iOS V1 backward decode; `deviceAdded` (17) `{ statementAccountId, encryptionPublicKey }`; `deviceRemoved` (18) `{ statementAccountId }`; `deviceChatAccepted` (20) `{ requestId, device }` sent on the identity-level session `SessionId(B, A)` encrypted with `K(A, B)` so all of a peer's devices can decrypt without a per-device envelope. + +### 🩹 Fixes + +- **host-papp / host-chat:** `identity/rpcAdapter` and `accountService.getConsumerInfo` tolerate both camelCase and snake_case `Resources.Consumers` metadata fields — the V2 multi-device runtime metadata emits camelCase, which previously crashed `raw.stmt_store_slots.map(...)`. +- **host-papp:** `EncryptedHandshakeResponseV2` is decoded with native `scale-ts Enum` on the inner discriminant. The peer SCALE library does not elide that index, so `Pending(AllowanceAllocation)` arrives as `0x00 0x00` and was previously being misclassified as `Failed("")` by a length-only dispatch. +- **host-chat:** `DeviceAdded` / `DeviceRemoved` use length-prefixed `Bytes()` rather than fixed-size codecs, matching `substrate-sdk-android`'s wire shape for `AccountId` / `EncodedPublicKey` wrapper types (no `@FixedLength`). + +### ⚠️ Breaking Changes + +- **host-papp:** V1 SSO handshake is gone. `createAuth` no longer derives an ephemeral sr25519 per attempt — callers must inject a persistent V2 `DeviceIdentityForPairing`. Older paired Polkadot Mobile clients (V1 wire format) will not handshake against this build, and the V1 handshake codec is removed. +- **host-papp:** `createPappAdapter` API shift. The `metadata: string` URL is dropped (host name / icon / platform now ride inside `hostMetadata`), and `deviceIdentity` is required. Consumers that previously called `createPappAdapter({ appId, metadata, hostMetadata })` need to add a `deviceIdentity` factory; see the snippet above. +- **host-papp:** public surface trimmed to `createAuth` and the types you need to use it (`AuthComponent`, `AuthSuccess`, `HostMetadata`, `PairingStatus`, `DeviceIdentityForPairing`). The V2 building blocks — `startPairingV2`, `idle` / `submitted` / `advance` / `fromInnerResponse` / `isTerminal` / `canSubmitV2Statements`, `buildPairingDeeplink` / `encodeProposal`, `computePairingTopic` / `computePairingChannel`, `decryptResponseEnvelope`, `deriveIdentityChatPublicKey`, every `Handshake*` / `*HandshakeResponse*` / `VersionedHandshake*` / `MetadataEntry` / `MetadataKey` / `Device` SCALE codec, and `HandshakeState` / `HandshakeIdleState` / `HandshakeSubmittedState` / `HandshakePendingState` / `HandshakeSuccessState` / `HandshakeFailedState` / `HandshakeMetadata` / `HandshakeProposalDevice` / `HandshakeResponseEnvelope` / `Pairing` / `StartPairingDeps` — are now internal. Consumers driving the handshake themselves should migrate to `auth.authenticate()`. +- **host-papp:** `PairingStatus.finished` no longer carries `session`. Read the resolved `AuthSuccess` off the value `authenticate()` returns instead. +- **host-papp:** the legacy 161-byte `HandshakeSuccessV2` payload (`encryptionKey || accountId || signature`) emitted by pre-multi-device PApp builds is no longer accepted — the spec v0.2.1 wire shape (`identityAccountId || rootAccountId || identityChatPrivateKey || deviceEncPubKey`, 161 bytes) is required. The 129-byte v0.2 variant emitted by Android `feature/location-for-handshake` is still accepted (with `rootAccountId === null`) for transitional compatibility. Per-device `identitySignature` and `IDENTITY_SIGNATURE_PAYLOAD_BYTES` are gone — multi-device authorisation moves to the user-identity-signed roster events (`DeviceAdded` / `DeviceRemoved`). +- **host-chat:** `MessageContent.chatAccepted` (14) payload changed from `_void` to `{ messageId: string }`. Older clients that emit `_void` will not decode. + +### ❤️ Thank You + +- Ilya Kalinin @kalininilya + ## 0.7.9 (2026-05-15) ### 🚀 Features diff --git a/packages/host-papp-react-ui/src/Flow.stories.tsx b/packages/host-papp-react-ui/src/Flow.stories.tsx index e478d532..04e4098e 100644 --- a/packages/host-papp-react-ui/src/Flow.stories.tsx +++ b/packages/host-papp-react-ui/src/Flow.stories.tsx @@ -86,14 +86,19 @@ const meta: Meta = { args: { adapter: createPappAdapter({ appId: 'https://test.com', - metadata: 'https://shorturl.at/zGkir', + deviceIdentity: () => ({ + statementAccountPublicKey: new Uint8Array(32), + encryptionPublicKey: new Uint8Array(65), + encryptionPrivateKey: new Uint8Array(32), + }), adapters: { lazyClient: createLazyClient(getWsProvider(SS_PASEO_STABLE_STAGE_ENDPOINTS)), }, hostMetadata: { + hostName: 'Storybook', hostVersion: '1.2.3', - osType: 'macOS', - osVersion: '14.4.1', + platformType: 'macOS', + platformVersion: '14.4.1', } satisfies HostMetadata, }), }, diff --git a/packages/host-papp-react-ui/src/hooks/authStatus.ts b/packages/host-papp-react-ui/src/hooks/authStatus.ts index 209a2d10..1e336941 100644 --- a/packages/host-papp-react-ui/src/hooks/authStatus.ts +++ b/packages/host-papp-react-ui/src/hooks/authStatus.ts @@ -1,19 +1,10 @@ -import { useMemo } from 'react'; - import { useAuthentication } from '../providers/AuthProvider.js'; export const useAuthStatus = () => { const { pairingStatus } = useAuthentication(); - const signedInUser = useMemo(() => { - if (pairingStatus.step === 'finished') { - return pairingStatus.session; - } - return null; - }, [pairingStatus.step]); - return { status: pairingStatus, - signedInUser, + isSignedIn: pairingStatus.step === 'finished', }; }; diff --git a/packages/host-papp/__tests__/auth.spec.ts b/packages/host-papp/__tests__/auth.spec.ts index de5b2eba..54c0c88f 100644 --- a/packages/host-papp/__tests__/auth.spec.ts +++ b/packages/host-papp/__tests__/auth.spec.ts @@ -1,139 +1,96 @@ +import { p256 } from '@noble/curves/nist.js'; import type { Statement, StatementStoreAdapter } from '@novasamatech/statement-store'; -import { ok, okAsync } from 'neverthrow'; +import { createEncryption } from '@novasamatech/statement-store'; +import { okAsync } from 'neverthrow'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { onHostPappDebugMessage } from '../src/debugBus.js'; import type { HostPappDebugEvent } from '../src/debugTypes.js'; import { createAuth } from '../src/sso/auth/impl.js'; -import type { UserSecretRepository } from '../src/sso/userSecretRepository.js'; -import type { UserSessionRepository } from '../src/sso/userSessionRepository.js'; - -const mocks = vi.hoisted(() => ({ - decrypt: vi.fn(), - generateMnemonic: vi.fn(), - handshakeEnc: vi.fn(), - responsePayloadDec: vi.fn(), - responseSensitiveDec: vi.fn(), -})); - -vi.mock('@polkadot-labs/hdkd-helpers', async importOriginal => { - const actual = await importOriginal(); - return { - ...actual, - generateMnemonic: mocks.generateMnemonic, - }; -}); - -vi.mock('../src/crypto.js', async importOriginal => { - const actual = await importOriginal(); - return { - ...actual, - deriveSr25519Account: vi.fn(() => ({ - secret: new Uint8Array(64), - publicKey: new Uint8Array(32), - entropy: new Uint8Array(32), - sign: vi.fn(), - verify: vi.fn(() => true), - })), - createEncrSecret: vi.fn(() => new Uint8Array(32)), - getEncrPub: vi.fn(() => new Uint8Array(65)), - createSharedSecret: vi.fn(() => new Uint8Array(32)), - }; +import { HandshakeSuccessV2, VersionedHandshakeResponse } from '../src/sso/auth/scale/handshakeV2.js'; +import type { DeviceIdentityForPairing } from '../src/sso/auth/v2/service.js'; + +const DEVICE_ENC_PRIV = new Uint8Array(32).fill(0x22); +const DEVICE_ENC_PUB = p256.getPublicKey(DEVICE_ENC_PRIV, false); +const DEVICE_STMT_ACCT = new Uint8Array(32).fill(0x33); + +const IDENTITY_CHAT_PRIV = new Uint8Array(32).fill(0xdd); +const IDENTITY_ACCT = new Uint8Array(32).fill(0xa1); +const ROOT_ACCT = new Uint8Array(32).fill(0xa2); +const PEER_STMT_ACCT_HEX = '0x' + '44'.repeat(32); + +const makeDeviceIdentity = (): DeviceIdentityForPairing => ({ + statementAccountPublicKey: DEVICE_STMT_ACCT, + encryptionPublicKey: DEVICE_ENC_PUB, + encryptionPrivateKey: DEVICE_ENC_PRIV, }); -vi.mock('../src/sso/auth/scale/handshake.js', () => ({ - HandshakeData: { enc: mocks.handshakeEnc }, - HandshakeResponsePayload: { dec: mocks.responsePayloadDec }, - HandshakeResponseSensitiveData: { dec: mocks.responseSensitiveDec }, -})); - -vi.mock('@novasamatech/statement-store', async importOriginal => { - const actual = await importOriginal(); - return { - ...actual, - createAccountId: vi.fn((bytes: Uint8Array) => bytes as never), - createLocalSessionAccount: vi.fn((accountId: unknown) => ({ accountId, kind: 'local' }) as never), - createRemoteSessionAccount: vi.fn( - (accountId: unknown, secret: unknown) => ({ accountId, secret, kind: 'remote' }) as never, - ), - createEncryption: vi.fn(() => ({ decrypt: mocks.decrypt })), - khash: vi.fn(() => new Uint8Array([42, 42, 42])), - }; -}); +const buildSuccessStatement = (): Statement => { + const inner = HandshakeSuccessV2.enc({ + identityAccountId: IDENTITY_ACCT, + rootAccountId: ROOT_ACCT, + identityChatPrivateKey: IDENTITY_CHAT_PRIV, + deviceEncPubKey: DEVICE_ENC_PUB, + }); + // The inner body is a length-dispatched Success (161-byte payload). Wrap it + // as the discriminated `EncryptedHandshakeResponseV2::Success` for the + // envelope. + const successEnvelope = new Uint8Array(inner.length + 1); + successEnvelope[0] = 1; // Success discriminant + successEnvelope.set(inner, 1); + + // ECDH-encrypt: peer (PApp) uses ephemeral tmpKey + device.encPub + const tmpPriv = new Uint8Array(32).fill(0x77); + const tmpPub = p256.getPublicKey(tmpPriv, false); + const shared = p256.getSharedSecret(tmpPriv, DEVICE_ENC_PUB).slice(1, 33); + const enc = createEncryption(shared as never); + const encrypted = enc.encrypt(successEnvelope)._unsafeUnwrap(); + + const statementData = VersionedHandshakeResponse.enc({ + tag: 'V2', + value: { encrypted, tmpKey: tmpPub }, + }); -vi.mock('../src/sso/userSessionRepository.js', async importOriginal => { - const actual = await importOriginal(); return { - ...actual, - createStoredUserSession: vi.fn( - (localAccount: unknown, remoteAccount: unknown, identityAccountId: unknown) => - ({ - id: 'session-id-1', - localAccount, - remoteAccount, - identityAccountId, - }) as never, - ), - }; -}); + data: statementData, + proof: { type: 'sr25519', value: { signature: '0x' + '00'.repeat(64), signer: PEER_STMT_ACCT_HEX } }, + } as Statement; +}; -type DeliverFn = (statements: Statement[]) => void; +type Deliver = (page: { statements: Statement[]; isComplete: boolean }) => void; -function buildHarness() { - let deliver: DeliverFn | null = null; +const buildHarness = (overrides: { persistOnSuccess?: () => Promise } = {}) => { + let deliver: Deliver | null = null; const unsubscribe = vi.fn(); - const subscribeStatements = vi.fn((_filter: unknown, onPage: (page: { statements: Statement[] }) => void) => { - deliver = (statements: Statement[]) => onPage({ statements }); + const subscribeStatements = vi.fn((_filter: unknown, onPage: Deliver) => { + deliver = onPage; return unsubscribe; }); + const queryStatements = vi.fn(() => okAsync([])); - const statementStore = { subscribeStatements }; - const ssoSessionRepository = { add: vi.fn(() => okAsync(undefined)) }; - const userSecretRepository = { write: vi.fn(() => okAsync(undefined)) }; + const statementStore = { subscribeStatements, queryStatements } as unknown as StatementStoreAdapter; const auth = createAuth({ - metadata: 'test-metadata', - hostMetadata: { hostVersion: '1.0', osType: 'iOS', osVersion: '18' }, - statementStore: statementStore as unknown as StatementStoreAdapter, - ssoSessionRepository: ssoSessionRepository as unknown as UserSessionRepository, - userSecretRepository: userSecretRepository as unknown as UserSecretRepository, + hostMetadata: { hostName: 'Test Host' }, + deviceIdentity: makeDeviceIdentity, + statementStore, + persistOnSuccess: overrides.persistOnSuccess, }); return { auth, - statementStore, - ssoSessionRepository, - userSecretRepository, subscribeStatements, + queryStatements, unsubscribe, - async waitForSubscription(times = 1) { - await vi.waitFor(() => expect(subscribeStatements).toHaveBeenCalledTimes(times)); - }, - deliverHandshake() { - if (!deliver) throw new Error('subscribeStatements not yet called'); - deliver([{ data: new Uint8Array([0xde, 0xad]) } as Statement]); + async waitForSubscription() { + await vi.waitFor(() => expect(subscribeStatements).toHaveBeenCalledTimes(1)); }, - deliverPage(statements: Statement[]) { + deliver(statements: Statement[]) { if (!deliver) throw new Error('subscribeStatements not yet called'); - deliver(statements); + deliver({ statements, isComplete: true }); }, }; -} - -beforeEach(() => { - mocks.decrypt.mockReset().mockReturnValue(ok(new Uint8Array([7, 7, 7]))); - mocks.generateMnemonic.mockReset().mockReturnValue('test mnemonic'); - mocks.handshakeEnc.mockReset().mockReturnValue(new Uint8Array([0xab, 0xcd])); - mocks.responsePayloadDec.mockReset().mockReturnValue({ - tag: 'v1', - value: { encrypted: new Uint8Array([1, 2]), tmpKey: new Uint8Array(65) }, - }); - mocks.responseSensitiveDec.mockReset().mockReturnValue({ - sharedSecretDerivationKey: new Uint8Array(65), - rootUserAccountId: new Uint8Array(32), - identityAccountId: new Uint8Array(32), - }); -}); +}; describe('createAuth', () => { describe('initial state', () => { @@ -146,193 +103,130 @@ describe('createAuth', () => { describe('authenticate (success path)', () => { it('returns the same in-flight ResultAsync on concurrent calls', () => { const { auth } = buildHarness(); - const first = auth.authenticate(); const second = auth.authenticate(); - expect(second).toBe(first); }); - it('resolves with stored session and persists secrets and session', async () => { + it('resolves with the V2 success when a Success statement arrives', async () => { const harness = buildHarness(); - const { auth, ssoSessionRepository, userSecretRepository } = harness; - - const promise = auth.authenticate(); + const promise = harness.auth.authenticate(); await harness.waitForSubscription(); - harness.deliverHandshake(); + harness.deliver([buildSuccessStatement()]); const result = await promise; - expect(result.isOk()).toBe(true); - expect(result._unsafeUnwrap()).toMatchObject({ id: 'session-id-1' }); - expect(userSecretRepository.write).toHaveBeenCalledWith( - 'session-id-1', - expect.objectContaining({ - ssSecret: expect.any(Uint8Array), - encrSecret: expect.any(Uint8Array), - entropy: expect.any(Uint8Array), - }), - ); - expect(ssoSessionRepository.add).toHaveBeenCalledWith(expect.objectContaining({ id: 'session-id-1' })); - }); - - it('caches the resolved result so subsequent calls return the same ResultAsync', async () => { - const harness = buildHarness(); - const { auth } = harness; - - const promise = auth.authenticate(); - await harness.waitForSubscription(); - harness.deliverHandshake(); - await promise; - - const second = auth.authenticate(); - expect(second).toBe(promise); - expect(mocks.generateMnemonic).toHaveBeenCalledTimes(1); + const success = result._unsafeUnwrap(); + expect(success).not.toBeNull(); + expect(success!.identityAccountId).toEqual(IDENTITY_ACCT); + expect(success!.peerStatementAccountId).toEqual(new Uint8Array(32).fill(0x44)); }); it('emits pairingStatus transitions: none -> initial -> pairing(deeplink) -> finished', async () => { const harness = buildHarness(); - const { auth } = harness; - const observed: Array<{ step: string; payload?: string }> = []; - auth.pairingStatus.subscribe(s => observed.push(s as never)); + const observed: { step: string; payload?: string }[] = []; + harness.auth.pairingStatus.subscribe(s => observed.push(s as never)); - const promise = auth.authenticate(); + const promise = harness.auth.authenticate(); await harness.waitForSubscription(); - harness.deliverHandshake(); + harness.deliver([buildSuccessStatement()]); await promise; const steps = observed.map(s => s.step); expect(steps[0]).toBe('none'); expect(steps).toContain('initial'); const pairing = observed.find(s => s.step === 'pairing'); - expect(pairing?.payload).toBe('polkadotapp://pair?handshake=0xabcd'); + expect(pairing?.payload).toMatch(/^polkadotapp:\/\/pair\?handshake=/); expect(steps.at(-1)).toBe('finished'); }); - it('skips statements with no data and resolves on the first decryptable one', async () => { - const harness = buildHarness(); - const { auth, ssoSessionRepository } = harness; + it('runs the persistOnSuccess hook before resolving', async () => { + const persistOnSuccess = vi.fn(() => Promise.resolve()); + const harness = buildHarness({ persistOnSuccess }); - const promise = auth.authenticate(); + const promise = harness.auth.authenticate(); await harness.waitForSubscription(); - harness.deliverPage([{ data: undefined } as Statement, { data: new Uint8Array([1, 2, 3]) } as Statement]); - const result = await promise; + harness.deliver([buildSuccessStatement()]); + const result = await promise; expect(result.isOk()).toBe(true); - expect(ssoSessionRepository.add).toHaveBeenCalledTimes(1); + expect(persistOnSuccess).toHaveBeenCalledTimes(1); + const arg = (persistOnSuccess.mock.calls[0] as unknown as [{ identityAccountId: Uint8Array }])[0]; + expect(arg.identityAccountId).toEqual(IDENTITY_ACCT); }); - }); - describe('authenticate (error paths)', () => { - it('publishes pairingError when retrieving the session throws', async () => { - mocks.responsePayloadDec.mockImplementation(() => { - throw new Error('payload broken'); - }); - const harness = buildHarness(); - const { auth } = harness; + it('fails authenticate when persistOnSuccess throws', async () => { + const persistOnSuccess = vi.fn(() => Promise.reject(new Error('persist boom'))); + const harness = buildHarness({ persistOnSuccess }); - const promise = auth.authenticate(); + const promise = harness.auth.authenticate(); await harness.waitForSubscription(); - harness.deliverHandshake(); - const result = await promise; - - expect(result.isErr()).toBe(true); - expect(auth.pairingStatus.read()).toEqual({ - step: 'pairingError', - message: 'payload broken', - }); - }); - - it('publishes pairingError when payload encoding throws synchronously', async () => { - mocks.handshakeEnc.mockImplementation(() => { - throw new Error('encode broken'); - }); - const { auth } = buildHarness(); - - const result = await auth.authenticate(); + harness.deliver([buildSuccessStatement()]); + const result = await promise; expect(result.isErr()).toBe(true); - expect(auth.pairingStatus.read()).toEqual({ - step: 'pairingError', - message: 'encode broken', - }); + expect(result._unsafeUnwrapErr().message).toBe('persist boom'); + expect(harness.auth.pairingStatus.read()).toEqual({ step: 'pairingError', message: 'persist boom' }); }); + }); - it('does not persist secrets or session when handshake fails', async () => { - mocks.handshakeEnc.mockImplementation(() => { - throw new Error('encode broken'); - }); + describe('authenticate (error paths)', () => { + it('publishes pairingError on a Failed inner response', async () => { const harness = buildHarness(); - const { auth, userSecretRepository, ssoSessionRepository } = harness; + const failedStatement: Statement = { + data: VersionedHandshakeResponse.enc({ + tag: 'V2', + value: (() => { + // Failed body = enum index 2 + length-prefixed string "declined" + const enc = createEncryption( + p256.getSharedSecret(new Uint8Array(32).fill(0x66), DEVICE_ENC_PUB).slice(1, 33) as never, + ); + // Failed body = enum index 2 + SCALE-compact length (8 << 2 = 0x20) + "declined" + const failedPayload = new Uint8Array([2, 0x20, 0x64, 0x65, 0x63, 0x6c, 0x69, 0x6e, 0x65, 0x64]); + return { + encrypted: enc.encrypt(failedPayload)._unsafeUnwrap(), + tmpKey: p256.getPublicKey(new Uint8Array(32).fill(0x66), false), + }; + })(), + }), + proof: { type: 'sr25519', value: { signature: '0x' + '00'.repeat(64), signer: PEER_STMT_ACCT_HEX } }, + } as Statement; - await auth.authenticate(); + const promise = harness.auth.authenticate(); + await harness.waitForSubscription(); + harness.deliver([failedStatement]); - expect(userSecretRepository.write).not.toHaveBeenCalled(); - expect(ssoSessionRepository.add).not.toHaveBeenCalled(); + const result = await promise; + expect(result.isErr()).toBe(true); + expect(harness.auth.pairingStatus.read()).toEqual({ step: 'pairingError', message: 'declined' }); }); }); describe('abortAuthentication', () => { - it('resolves the in-flight authenticate with ok(null)', async () => { + it('resolves the in-flight authenticate with ok(null) and resets status', async () => { const harness = buildHarness(); - const { auth } = harness; - - const promise = auth.authenticate(); + const promise = harness.auth.authenticate(); await harness.waitForSubscription(); - auth.abortAuthentication(); - // a page must arrive for the subscribe callback to observe the aborted signal - harness.deliverPage([]); + harness.auth.abortAuthentication(); const result = await promise; expect(result.isOk()).toBe(true); expect(result._unsafeUnwrap()).toBeNull(); - }); - - it('resets pairing status', async () => { - const harness = buildHarness(); - const { auth } = harness; - - const promise = auth.authenticate(); - await harness.waitForSubscription(); - auth.abortAuthentication(); - harness.deliverPage([]); - await promise; - - expect(auth.pairingStatus.read()).toEqual({ step: 'none' }); - }); - - it('does not transition pairingStatus to error state on user abort', async () => { - const harness = buildHarness(); - const { auth } = harness; - const pairing: Array<{ step: string }> = []; - auth.pairingStatus.subscribe(s => pairing.push(s as never)); - - const promise = auth.authenticate(); - await harness.waitForSubscription(); - auth.abortAuthentication(); - harness.deliverPage([]); - await promise; - - expect(pairing.some(s => s.step === 'pairingError')).toBe(false); + expect(harness.auth.pairingStatus.read()).toEqual({ step: 'none' }); }); it('clears the cached result so the next call starts a fresh attempt', async () => { const harness = buildHarness(); - const { auth } = harness; - - const first = auth.authenticate(); + const first = harness.auth.authenticate(); await harness.waitForSubscription(); - auth.abortAuthentication(); - harness.deliverPage([]); + harness.auth.abortAuthentication(); await first; - const second = auth.authenticate(); + // subscribeStatements gets called again on a fresh authenticate + const second = harness.auth.authenticate(); expect(second).not.toBe(first); - expect(mocks.generateMnemonic).toHaveBeenCalledTimes(2); - - auth.abortAuthentication(); - await harness.waitForSubscription(2); - harness.deliverPage([]); + await vi.waitFor(() => expect(harness.subscribeStatements).toHaveBeenCalledTimes(2)); + harness.auth.abortAuthentication(); await second; }); @@ -350,14 +244,13 @@ describe('createAuth', () => { return { events, unsubscribe }; } - it('emits pairing_started and attestation.started eagerly when authenticate() is called', () => { + it('emits pairing_started eagerly when authenticate() is called', async () => { const { auth } = buildHarness(); const { events, unsubscribe } = captureEvents(); try { void auth.authenticate(); - - expect(events.find(e => e.layer === 'sso' && e.event === 'pairing_started')).toMatchObject({ - payload: { metadata: 'test-metadata' }, + await vi.waitFor(() => { + expect(events.find(e => e.layer === 'sso' && e.event === 'pairing_started')).toBeTruthy(); }); } finally { auth.abortAuthentication(); @@ -365,13 +258,13 @@ describe('createAuth', () => { } }); - it('emits the full SSO pairing sequence and attestation.completed on a successful authenticate', async () => { + it('emits the full SSO pairing sequence on a successful authenticate', async () => { const harness = buildHarness(); const { events, unsubscribe } = captureEvents(); try { const promise = harness.auth.authenticate(); await harness.waitForSubscription(); - harness.deliverHandshake(); + harness.deliver([buildSuccessStatement()]); const result = await promise; expect(result.isOk()).toBe(true); @@ -388,23 +281,25 @@ describe('createAuth', () => { } }); - it('does not emit pairing_failed or attestation.failed when authentication is aborted by the user', async () => { + it('does not emit pairing_failed when authentication is aborted by the user', async () => { const harness = buildHarness(); const { events, unsubscribe } = captureEvents(); try { const promise = harness.auth.authenticate(); await harness.waitForSubscription(); harness.auth.abortAuthentication(); - harness.deliverPage([]); const result = await promise; expect(result.isOk()).toBe(true); expect(result._unsafeUnwrap()).toBeNull(); expect(events.some(e => e.layer === 'sso' && e.event === 'pairing_failed')).toBe(false); - expect(events.some(e => e.layer === 'attestation' && e.event === 'failed')).toBe(false); } finally { unsubscribe(); } }); }); }); + +beforeEach(() => { + vi.useRealTimers(); +}); diff --git a/packages/host-papp/__tests__/handshakeV2State.spec.ts b/packages/host-papp/__tests__/handshakeV2State.spec.ts index 001549e6..6131cbae 100644 --- a/packages/host-papp/__tests__/handshakeV2State.spec.ts +++ b/packages/host-papp/__tests__/handshakeV2State.spec.ts @@ -22,6 +22,7 @@ const makeSuccess = (overrides: Partial = { identityChatPrivateKey: fixedChatPrivateKey, identityChatPublicKey: fixedChatPublicKey, deviceEncPubKey: new Uint8Array(65).fill(0x04), + peerStatementAccountId: null, ...overrides, }); diff --git a/packages/host-papp/src/debugTypes.ts b/packages/host-papp/src/debugTypes.ts index 8da42ad3..fa8f657b 100644 --- a/packages/host-papp/src/debugTypes.ts +++ b/packages/host-papp/src/debugTypes.ts @@ -25,7 +25,7 @@ export type SsoDebugEvent = event: 'pairing_started'; flowId: string; timestamp: number; - payload: { metadata: string }; + payload: { metadata: unknown }; } | { layer: 'sso'; @@ -39,21 +39,21 @@ export type SsoDebugEvent = event: 'awaiting_response'; flowId: string; timestamp: number; - payload: { topic: string }; + payload: Record; } | { layer: 'sso'; event: 'response_received'; flowId: string; timestamp: number; - payload: { sessionId: string }; + payload: { identityAccountId: Uint8Array }; } | { layer: 'sso'; event: 'session_established'; flowId: string; timestamp: number; - payload: { sessionId: string }; + payload: { identityAccountId: Uint8Array }; } | { layer: 'sso'; diff --git a/packages/host-papp/src/index.ts b/packages/host-papp/src/index.ts index c65f3bc8..873af6ce 100644 --- a/packages/host-papp/src/index.ts +++ b/packages/host-papp/src/index.ts @@ -3,8 +3,10 @@ export { SS_PASEO_STABLE_STAGE_ENDPOINTS, SS_PREVIEW_STAGE_ENDPOINTS, SS_STABLE_ export type { PappAdapter } from './papp.js'; export { createPappAdapter } from './papp.js'; -export type { HostMetadata } from './sso/auth/impl.js'; +export type { AuthComponent, AuthSuccess, HostMetadata } from './sso/auth/impl.js'; export type { PairingStatus } from './sso/auth/types.js'; +export type { DeviceIdentityForPairing } from './sso/auth/v2/service.js'; + export type { UserSession } from './sso/sessionManager/userSession.js'; export type { StoredUserSession } from './sso/userSessionRepository.js'; export type { Identity } from './identity/types.js'; @@ -16,43 +18,3 @@ export type { SigningRequest, } from './sso/sessionManager/scale/signing.js'; export type { RingVrfAliasRequest, RingVrfAliasResponse } from './sso/sessionManager/scale/ringVrf.js'; - -// ── V2 SSO handshake ───────────────────────────────────────────────────── - -export type { EncryptedHandshakeResponseV2Value, HandshakeSuccessV2Value } from './sso/auth/scale/handshakeV2.js'; -export { - Device, - EncryptedHandshakeResponseV1, - EncryptedHandshakeResponseV2, - HandshakeProposalV2, - HandshakeResponseV1, - HandshakeResponseV2, - HandshakeStatusV2, - HandshakeSuccessV2, - MetadataEntry, - MetadataKey, - VersionedHandshakeProposal, - VersionedHandshakeResponse, - deriveIdentityChatPublicKey, -} from './sso/auth/scale/handshakeV2.js'; - -export { computePairingChannel, computePairingTopic } from './sso/auth/v2/topic.js'; - -export type { HandshakeMetadata, HandshakeProposalDevice } from './sso/auth/v2/proposal.js'; -export { buildPairingDeeplink, encodeProposal } from './sso/auth/v2/proposal.js'; - -export type { HandshakeResponseEnvelope } from './sso/auth/v2/envelope.js'; -export { decryptResponseEnvelope } from './sso/auth/v2/envelope.js'; - -export type { - HandshakeFailedState, - HandshakeIdleState, - HandshakePendingState, - HandshakeState, - HandshakeSubmittedState, - HandshakeSuccessState, -} from './sso/auth/v2/state.js'; -export { advance, canSubmitV2Statements, fromInnerResponse, idle, isTerminal, submitted } from './sso/auth/v2/state.js'; - -export type { DeviceIdentityForPairing, Pairing, StartPairingDeps } from './sso/auth/v2/service.js'; -export { startPairingV2 } from './sso/auth/v2/service.js'; diff --git a/packages/host-papp/src/papp.ts b/packages/host-papp/src/papp.ts index 55b8c931..9476d1b9 100644 --- a/packages/host-papp/src/papp.ts +++ b/packages/host-papp/src/papp.ts @@ -8,8 +8,9 @@ import { SS_STABLE_STAGE_ENDPOINTS } from './constants.js'; import { createIdentityRepository } from './identity/impl.js'; import { createIdentityRpcAdapter } from './identity/rpcAdapter.js'; import type { IdentityAdapter, IdentityRepository } from './identity/types.js'; -import type { AuthComponent, HostMetadata } from './sso/auth/impl.js'; +import type { AuthComponent, AuthSuccess, HostMetadata } from './sso/auth/impl.js'; import { createAuth } from './sso/auth/impl.js'; +import type { DeviceIdentityForPairing } from './sso/auth/v2/service.js'; import type { SsoSessionManager } from './sso/sessionManager/impl.js'; import { createSsoSessionManager } from './sso/sessionManager/impl.js'; import type { UserSecretRepository } from './sso/userSecretRepository.js'; @@ -37,25 +38,43 @@ type Params = { */ appId: string; /** - * URL for additional metadata that will be displayed during pairing process. - * Content of provided json shound be - * ```ts - * interface Metadata { - * name: string; - * icon: string; // url for icon. Icon should be a rasterized image with min size 256x256 px. - * } - * ``` + * Host environment metadata embedded in the pairing proposal so PApp can + * render the request screen. All fields are optional — absence must not + * break the pairing flow. */ - metadata: string; + hostMetadata?: HostMetadata; /** - * Optional host environment metadata for Sign-In confirmation screen. - * All fields are optional - absence must not break the pairing flow. + * Persistent V2 device identity. The same identity must be returned on every + * launch so PApp recognises this device as the same peer. The factory is + * invoked per `sso.authenticate()` call, so callers can lazy-load from + * keychain / IndexedDB without blocking adapter construction. */ - hostMetadata?: HostMetadata; + deviceIdentity: () => Promise | DeviceIdentityForPairing; + /** + * Caller hook fired after a successful handshake, before + * `sso.authenticate()` resolves. Throwing fails the call. + */ + onAuthSuccess?: (success: AuthSuccess) => Promise; + /** + * Reload-survival dedupe: hex of the last pairing-topic statement consumed + * by this device. Resolved per `sso.authenticate()` so callers can read + * from async storage (IndexedDB / keychain) without blocking adapter + * construction. + */ + initialProcessedDataHex?: () => Promise | string | null; + onPairingStatementProcessed?: (dataHex: string) => void; adapters?: Partial; }; -export function createPappAdapter({ appId, metadata, hostMetadata, adapters }: Params): PappAdapter { +export function createPappAdapter({ + appId, + hostMetadata, + deviceIdentity, + onAuthSuccess, + initialProcessedDataHex, + onPairingStatementProcessed, + adapters, +}: Params): PappAdapter { const lazyClient = adapters?.lazyClient ?? createLazyClient(getWsProvider(SS_STABLE_STAGE_ENDPOINTS, { heartbeatTimeout: Number.POSITIVE_INFINITY })); @@ -69,11 +88,12 @@ export function createPappAdapter({ appId, metadata, hostMetadata, adapters }: P return { sso: createAuth({ - metadata, hostMetadata, + deviceIdentity, statementStore, - ssoSessionRepository, - userSecretRepository, + persistOnSuccess: onAuthSuccess, + initialProcessedDataHex, + onStatementProcessed: onPairingStatementProcessed, }), sessions: createSsoSessionManager({ storage, statementStore, ssoSessionRepository, userSecretRepository }), secrets: userSecretRepository, diff --git a/packages/host-papp/src/sso/auth/impl.ts b/packages/host-papp/src/sso/auth/impl.ts index cba57855..71a9c707 100644 --- a/packages/host-papp/src/sso/auth/impl.ts +++ b/packages/host-papp/src/sso/auth/impl.ts @@ -1,343 +1,221 @@ -import { enumValue } from '@novasamatech/scale'; -import type { LocalSessionAccount, Statement, StatementStoreAdapter } from '@novasamatech/statement-store'; -import { - createAccountId, - createEncryption, - createLocalSessionAccount, - createRemoteSessionAccount, - khash, -} from '@novasamatech/statement-store'; -import { generateMnemonic } from '@polkadot-labs/hdkd-helpers'; -import { Result, ResultAsync, err, fromPromise, fromThrowable, ok } from 'neverthrow'; -import { mergeUint8, toHex } from 'polkadot-api/utils'; +import type { StatementStoreAdapter } from '@novasamatech/statement-store'; +import { ResultAsync, errAsync, okAsync } from 'neverthrow'; -import type { DerivedSr25519Account, EncrPublicKey, EncrSecret, SsPublicKey } from '../../crypto.js'; -import { createEncrSecret, createSharedSecret, deriveSr25519Account, getEncrPub, stringToBytes } from '../../crypto.js'; import { createFlowId, emitHostPappDebugMessage } from '../../debugBus.js'; import { AbortError } from '../../helpers/abortError.js'; import { createState, readonly } from '../../helpers/state.js'; import { toError } from '../../helpers/utils.js'; -import type { Callback } from '../../types.js'; -import type { UserSecretRepository } from '../userSecretRepository.js'; -import type { StoredUserSession, UserSessionRepository } from '../userSessionRepository.js'; -import { createStoredUserSession } from '../userSessionRepository.js'; -import { HandshakeData, HandshakeResponsePayload, HandshakeResponseSensitiveData } from './scale/handshake.js'; import type { PairingStatus } from './types.js'; +import type { HandshakeMetadata } from './v2/proposal.js'; +import type { DeviceIdentityForPairing } from './v2/service.js'; +import { startPairingV2 } from './v2/service.js'; +import type { HandshakeState, HandshakeSuccessState } from './v2/state.js'; +export type HostMetadata = HandshakeMetadata; export type AuthComponent = ReturnType; -export type HostMetadata = { - hostVersion?: string; - osType?: string; - osVersion?: string; -}; +export type AuthSuccess = HandshakeSuccessState; type Params = { - metadata: string; hostMetadata?: HostMetadata; + /** + * Persistent device identity used for the pairing. The same identity must be + * reused across launches so PApp recognises this device as the same peer + * (per-device chat addressing depends on `encryptionPublicKey`). Caller owns + * the persistence; the factory is invoked on each `authenticate()` so the + * SDK never holds key material between attempts. + */ + deviceIdentity: () => Promise | DeviceIdentityForPairing; statementStore: StatementStoreAdapter; - ssoSessionRepository: UserSessionRepository; - userSecretRepository: UserSecretRepository; + /** + * Fires once the V2 handshake reaches `Success`, before `authenticate()` + * resolves. Use it for consumer-specific bookkeeping (peer-device + * registration, contact reset, telemetry). Throwing fails the + * `authenticate()` call and surfaces as `pairingError`. + */ + persistOnSuccess?: (success: AuthSuccess) => Promise; + /** + * Hex of the last pairing-topic statement this device processed (so a stale + * `Success` doesn't get replayed on the next launch / re-pair). Resolved per + * `authenticate()` so a value freshly written by `onStatementProcessed` on + * one attempt is visible to the next. + */ + initialProcessedDataHex?: () => Promise | string | null; + onStatementProcessed?: (dataHex: string) => void; }; export function createAuth({ - metadata, hostMetadata, + deviceIdentity, statementStore, - ssoSessionRepository, - userSecretRepository, + persistOnSuccess, + initialProcessedDataHex, + onStatementProcessed, }: Params) { const pairingStatus = createState({ step: 'none' }); - let authResult: ResultAsync | null = null; - let abort: AbortController | null = null; - - function handshake(account: DerivedSr25519Account, signal: AbortSignal, flowId: string) { - const localAccount = createLocalSessionAccount(createAccountId(account.publicKey)); + let authResult: ResultAsync | null = null; + let abortHandle: (() => void) | null = null; - pairingStatus.write({ step: 'initial' }); + return { + pairingStatus: readonly(pairingStatus), - const encrKeys = createEncrKeys(account.entropy); - const handshakePayload = encrKeys.andThen(({ publicKey }) => - createHandshakePayloadV1({ - ssPublicKey: account.publicKey, - encrPublicKey: publicKey, - metadata, - hostMetadata, - }), - ); - const handshakeTopic = encrKeys.andThen(({ publicKey }) => createHandshakeTopic(localAccount, publicKey)); + authenticate(): ResultAsync { + if (authResult) return authResult; - const dataPrepared = Result.combine([handshakePayload, handshakeTopic, encrKeys]).andTee(([payload]) => { - const deeplink = createDeeplink(payload); - pairingStatus.write({ step: 'pairing', payload: deeplink }); + const flowId = createFlowId(); + pairingStatus.write({ step: 'initial' }); emitHostPappDebugMessage({ layer: 'sso', - event: 'deeplink_generated', + event: 'pairing_started', flowId, timestamp: Date.now(), - payload: { deeplink }, + payload: { metadata: hostMetadata }, }); - }); - return dataPrepared - .asyncAndThen(([, handshakeTopic, encrKeys]) => { + let aborted = false; + let pairingAbort: (() => void) | null = null; + abortHandle = () => { + aborted = true; + pairingAbort?.(); + }; + + const flow = ResultAsync.fromPromise( + Promise.all([Promise.resolve(deviceIdentity()), Promise.resolve(initialProcessedDataHex?.() ?? null)]), + toError, + ).andThen(([identity, initialHex]) => { + if (aborted) return okAsync(null); + + const pairing = startPairingV2({ + statementStore, + deviceIdentity: identity, + metadata: hostMetadata ?? {}, + initialProcessedDataHex: initialHex, + onStatementProcessed, + }); + pairingAbort = pairing.abort; + + pairingStatus.write({ step: 'pairing', payload: pairing.qrPayload }); + emitHostPappDebugMessage({ + layer: 'sso', + event: 'deeplink_generated', + flowId, + timestamp: Date.now(), + payload: { deeplink: pairing.qrPayload }, + }); emitHostPappDebugMessage({ layer: 'sso', event: 'awaiting_response', flowId, timestamp: Date.now(), - payload: { topic: toHex(handshakeTopic) }, + payload: {}, }); - const pappResponse = waitForStatements( - callback => - statementStore.subscribeStatements({ matchAll: [handshakeTopic] }, page => callback(page.statements)), - signal, - (statements, resolve) => { - for (const statement of statements) { - if (!statement.data) continue; - - const session = retrieveSession({ - localAccount, - encrSecret: encrKeys.secret, - payload: statement.data, - }).unwrapOr(null); - - if (session) { - emitHostPappDebugMessage({ - layer: 'sso', - event: 'response_received', + return ResultAsync.fromPromise( + new Promise((resolve, reject) => { + let settled = false; + const settle = (cb: () => void) => { + if (settled) return; + settled = true; + cb(); + }; + const sub = pairing.state$.subscribe({ + next: state => + onState( + state, + s => settle(() => resolve(s)), + e => settle(() => reject(e)), + () => sub?.unsubscribe(), flowId, - timestamp: Date.now(), - payload: { sessionId: session.id }, - }); - resolve(session); - break; - } - } - }, + ), + complete: () => { + if (aborted) settle(() => reject(new AbortError('Aborted by user.'))); + }, + error: e => settle(() => reject(toError(e))), + }); + }), + toError, ); - - return pappResponse.map(session => ({ - session, - secretsPayload: { - id: session.id, - ssSecret: account.secret, - encrSecret: encrKeys.secret, - entropy: account.entropy, - }, - })); - }) - .andTee(({ session }) => { - pairingStatus.write({ step: 'finished', session }); - }) - .orTee(e => { - if (!(e instanceof AbortError)) { - pairingStatus.write({ step: 'pairingError', message: e.message }); - } }); - } - const authModule = { - pairingStatus: readonly(pairingStatus), - - authenticate(): ResultAsync { - if (authResult) { - return authResult; - } - - abort = new AbortController(); - - const account = deriveSr25519Account(generateMnemonic(), '//wallet//sso'); - const ssoFlowId = createFlowId(); - emitHostPappDebugMessage({ - layer: 'sso', - event: 'pairing_started', - flowId: ssoFlowId, - timestamp: Date.now(), - payload: { metadata }, - }); - - authResult = handshake(account, abort.signal, ssoFlowId) - .andThen(({ session, secretsPayload }) => { - return userSecretRepository - .write(secretsPayload.id, { - ssSecret: secretsPayload.ssSecret, - encrSecret: secretsPayload.encrSecret, - entropy: secretsPayload.entropy, - }) - .andThen(() => ssoSessionRepository.add(session)) - .map(() => session); - }) - .andTee(session => { - if (session) { + authResult = flow + .orElse(e => (e instanceof AbortError ? okAsync(null) : errAsync(e))) + .andTee(success => { + if (success === null) { + pairingStatus.reset(); + } else { + pairingStatus.write({ step: 'finished' }); emitHostPappDebugMessage({ layer: 'sso', event: 'session_established', - flowId: ssoFlowId, + flowId, timestamp: Date.now(), - payload: { sessionId: session.id }, + payload: { identityAccountId: success.identityAccountId }, }); } - }) - .orElse(e => (e instanceof AbortError ? ok(null) : err(e))) - .andTee(() => { - abort = null; + abortHandle = null; }) .orTee(e => { - authResult = null; - abort = null; + pairingStatus.write({ step: 'pairingError', message: e.message }); emitHostPappDebugMessage({ layer: 'sso', event: 'pairing_failed', - flowId: ssoFlowId, + flowId, timestamp: Date.now(), payload: { reason: e.message }, }); + authResult = null; + abortHandle = null; }); return authResult; + + function onState( + state: HandshakeState, + resolve: (value: AuthSuccess) => void, + reject: (err: Error) => void, + unsubscribe: () => void, + flowId: string, + ) { + switch (state.tag) { + case 'Idle': + case 'Submitted': + return; + case 'Pending': + pairingStatus.write({ step: 'pending', stage: state.reason }); + return; + case 'Success': + unsubscribe(); + emitHostPappDebugMessage({ + layer: 'sso', + event: 'response_received', + flowId, + timestamp: Date.now(), + payload: { identityAccountId: state.identityAccountId }, + }); + if (persistOnSuccess) { + persistOnSuccess(state).then( + () => resolve(state), + e => reject(toError(e)), + ); + } else { + resolve(state); + } + return; + case 'Failed': + unsubscribe(); + reject(new Error(state.reason)); + return; + } + } }, abortAuthentication() { - if (abort) { - abort.abort(new AbortError('Aborted by user.')); - abort = null; - } + abortHandle?.(); + abortHandle = null; authResult = null; pairingStatus.reset(); }, }; - - return authModule; -} - -const createHandshakeTopic = fromThrowable( - (account: LocalSessionAccount, encrPublicKey: EncrPublicKey) => - khash(account.accountId, mergeUint8([encrPublicKey, stringToBytes('topic')])), - toError, -); - -const createHandshakePayloadV1 = fromThrowable( - ({ - encrPublicKey, - ssPublicKey, - metadata, - hostMetadata, - }: { - encrPublicKey: EncrPublicKey; - ssPublicKey: SsPublicKey; - metadata: string; - hostMetadata?: HostMetadata; - }) => { - const hostVersion = hostMetadata?.hostVersion; - const osType = hostMetadata?.osType; - const osVersion = hostMetadata?.osVersion; - - return HandshakeData.enc( - enumValue('v1', { - ssPublicKey, - encrPublicKey, - metadata, - hostVersion, - osType, - osVersion, - }), - ); - }, - toError, -); - -function parseHandshakePayload(payload: Uint8Array) { - const decoded = HandshakeResponsePayload.dec(payload); - - switch (decoded.tag) { - case 'v1': - return decoded.value; - default: - throw new Error('Unsupported handshake payload version'); - } -} - -const createEncrKeys = fromThrowable((entropy: Uint8Array) => { - const secret = createEncrSecret(entropy); - - return { - secret, - publicKey: getEncrPub(secret), - }; -}, toError); - -function retrieveSession({ - payload, - encrSecret, - localAccount, -}: { - payload: Uint8Array; - encrSecret: EncrSecret; - localAccount: LocalSessionAccount; -}): Result { - const { encrypted, tmpKey } = parseHandshakePayload(payload); - - const symmetricKey = createSharedSecret(encrSecret, tmpKey); - - return createEncryption(symmetricKey) - .decrypt(encrypted) - .map(decrypted => { - const { sharedSecretDerivationKey, rootUserAccountId, identityAccountId } = - HandshakeResponseSensitiveData.dec(decrypted); - const sharedSecret = createSharedSecret(encrSecret, sharedSecretDerivationKey); - const remoteAccount = createRemoteSessionAccount(createAccountId(identityAccountId), sharedSecret); - - return createStoredUserSession(localAccount, remoteAccount, createAccountId(rootUserAccountId)); - }); -} - -function createDeeplink(payload: Uint8Array) { - return `polkadotapp://pair?handshake=${toHex(payload)}`; -} - -function waitForStatements( - subscribe: (callback: Callback) => VoidFunction, - signal: AbortSignal, - callback: (statements: Statement[], resolve: (value: T) => void) => void, -): ResultAsync { - return fromPromise( - new Promise((resolve, reject) => { - const unsubscribe = subscribe(statements => { - const abortError = processSignal(signal).match( - () => null, - e => e, - ); - - if (abortError) { - unsubscribe(); - reject(abortError); - return; - } - - try { - callback(statements, value => { - unsubscribe(); - resolve(value); - }); - } catch (e) { - unsubscribe(); - reject(e); - } - }); - }), - toError, - ); -} - -function processSignal(signal: AbortSignal) { - try { - signal.throwIfAborted(); - return ok(); - } catch (e) { - return err(toError(e)); - } } diff --git a/packages/host-papp/src/sso/auth/scale/handshake.ts b/packages/host-papp/src/sso/auth/scale/handshake.ts deleted file mode 100644 index f91a8deb..00000000 --- a/packages/host-papp/src/sso/auth/scale/handshake.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Enum } from '@novasamatech/scale'; -import { Bytes, Option, Struct, str } from 'scale-ts'; - -import { EncrPubKey, SsPubKey } from '../../../crypto.js'; - -const optStr = Option(str); - -export const HandshakeData = Enum({ - v1: Struct({ - ssPublicKey: SsPubKey, - encrPublicKey: EncrPubKey, - metadata: str, - hostVersion: optStr, - osType: optStr, - osVersion: optStr, - }), -}); - -export const HandshakeResponsePayload = Enum({ - v1: Struct({ encrypted: Bytes(), tmpKey: Bytes(65) }), -}); - -export const HandshakeResponseSensitiveData = Struct({ - sharedSecretDerivationKey: Bytes(65), - rootUserAccountId: Bytes(32), - identityAccountId: Bytes(32), -}); diff --git a/packages/host-papp/src/sso/auth/types.ts b/packages/host-papp/src/sso/auth/types.ts index 421faca5..6537d6a0 100644 --- a/packages/host-papp/src/sso/auth/types.ts +++ b/packages/host-papp/src/sso/auth/types.ts @@ -1,9 +1,7 @@ -import type { StoredUserSession } from '../userSessionRepository.js'; - export type PairingStatus = | { step: 'none' } | { step: 'initial' } | { step: 'pairing'; payload: string } | { step: 'pending'; stage: string } | { step: 'pairingError'; message: string } - | { step: 'finished'; session: StoredUserSession }; + | { step: 'finished' }; diff --git a/packages/host-papp/src/sso/auth/v2/service.ts b/packages/host-papp/src/sso/auth/v2/service.ts index 61cae3bc..dba2e177 100644 --- a/packages/host-papp/src/sso/auth/v2/service.ts +++ b/packages/host-papp/src/sso/auth/v2/service.ts @@ -76,6 +76,26 @@ const toHexFull = (bytes: Uint8Array) => { return `0x${out}`; }; +const fromHexString = (hex: string): Uint8Array => { + const stripped = hex.startsWith('0x') ? hex.slice(2) : hex; + const out = new Uint8Array(stripped.length / 2); + for (let i = 0; i < out.length; i++) { + out[i] = parseInt(stripped.slice(i * 2, i * 2 + 2), 16); + } + return out; +}; + +const extractStatementSigner = (statement: Statement): Uint8Array | null => { + const proof = statement.proof; + if (!proof) return null; + if (proof.type !== 'sr25519' && proof.type !== 'ed25519') return null; + try { + return fromHexString(proof.value.signer); + } catch { + return null; + } +}; + export const startPairingV2 = (deps: StartPairingDeps): Pairing => { const persistOnSuccess = deps.persistOnSuccess; @@ -150,9 +170,11 @@ export const startPairingV2 = (deps: StartPairingDeps): Pairing => { return; } + const peerStatementAccountId = extractStatementSigner(statement); + let next: HandshakeState; try { - next = fromInnerResponse(decodeEncryptedHandshakeResponseV2(innerBytes)); + next = fromInnerResponse(decodeEncryptedHandshakeResponseV2(innerBytes), peerStatementAccountId); } catch (err) { log(`inner decode failed; innerBytes (${innerBytes.length}b) = ${toHexFull(innerBytes)}`, err); return; diff --git a/packages/host-papp/src/sso/auth/v2/state.ts b/packages/host-papp/src/sso/auth/v2/state.ts index ba2d1b43..633e469d 100644 --- a/packages/host-papp/src/sso/auth/v2/state.ts +++ b/packages/host-papp/src/sso/auth/v2/state.ts @@ -50,6 +50,14 @@ export type HandshakeSuccessState = { * back to the authorising device. */ deviceEncPubKey: Uint8Array; + /** + * The pairing-topic statement was signed by PApp's device statement + * account. `HandshakeSuccessV2` doesn't carry it in the encrypted body, so + * the pairing service lifts it off `statement.proof.value.signer` and + * attaches it here. `null` only when the statement arrived without a + * recognised proof type, in which case device-sync can't seed back to PApp. + */ + peerStatementAccountId: Uint8Array | null; }; export type HandshakeFailedState = { tag: 'Failed'; reason: string }; @@ -69,7 +77,10 @@ export const submitted = (): HandshakeSubmittedState => ({ tag: 'Submitted' }); * the public state. Pure — no I/O. The caller decrypts the outer envelope and * runs `decodeEncryptedHandshakeResponseV2` first. */ -export const fromInnerResponse = (response: DecodedHandshakeResponseV2): HandshakeState => { +export const fromInnerResponse = ( + response: DecodedHandshakeResponseV2, + peerStatementAccountId: Uint8Array | null = null, +): HandshakeState => { switch (response.tag) { case 'Pending': // Only AllowanceAllocation today; widen here when the spec adds more variants. @@ -82,6 +93,7 @@ export const fromInnerResponse = (response: DecodedHandshakeResponseV2): Handsha identityChatPrivateKey: response.value.identityChatPrivateKey, identityChatPublicKey: deriveIdentityChatPublicKey(response.value.identityChatPrivateKey), deviceEncPubKey: response.value.deviceEncPubKey, + peerStatementAccountId, }; case 'Failed': return { tag: 'Failed', reason: response.value }; From 692943a1aed0a818d4e0efc444b167a740a8fbd7 Mon Sep 17 00:00:00 2001 From: Ilya Date: Thu, 21 May 2026 09:01:03 -0600 Subject: [PATCH 11/31] docs(changelog): reframe 0.8.0 entry against the 0.7.8 baseline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous draft mixed two reference frames: some items described the diff vs. 0.7.8 (correct for a changelog), others described the diff vs. an earlier state of this branch where V2 helpers were briefly public (internal to PR #178 development, irrelevant to consumers). The latter read as "no longer something callers need to wire up" / "public surface trimmed" / "fix: EncryptedHandshakeResponseV2 misclassification" — none of those make sense for a consumer upgrading from 0.7.8, where V2 SSO didn't exist publicly at all. Reframed: - Lead Features bullet now states V2 SSO as a fresh capability behind the existing createAuth surface, with the V2 codecs/service/state- machine flagged as SDK internals (not as "no longer public"). - Dropped the "public surface trimmed" Breaking Change — those names were never in 0.7.8. - Dropped the EncryptedHandshakeResponseV2 fix bullet — that was a bug introduced and fixed inside this PR, not visible to 0.7.8 users. - Merged the legacy-payload-removal bullet into the main V1-removed bullet to avoid double-flagging the same break. --- CHANGELOG.md | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a0e6873..81ce2ad8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ### 🚀 Features -- **host-papp:** multi-device support via V2 SSO. `createAuth` (and `pappAdapter.sso`) is now V2-driven end-to-end — the same `pairingStatus` / `authenticate()` / `abortAuthentication()` surface you already use. Desktop and web hosts pair with the multi-device iOS/Android Polkadot Mobile builds through this entry point; the V2 wire format (codecs, ECDH envelope, RxJS pairing service, state machine) runs inside the SDK and is no longer something callers need to wire up. +- **host-papp:** multi-device SSO. `createAuth` / `pappAdapter.sso` keeps the existing `pairingStatus` / `authenticate()` / `abortAuthentication()` surface; what's new is the protocol underneath. Pairing now runs the V2 multi-device handshake — `VersionedHandshakeProposal::V2` carried over the QR deeplink, ECDH-encrypted Statement Store response envelope, on-chain `Pending(AllowanceAllocation)` flow — so desktop and web hosts can pair with the multi-device iOS/Android Polkadot Mobile builds through the same entry point you've been calling. The V2 codecs, pairing service, and state machine are SDK internals. ```ts const adapter = createPappAdapter({ appId, @@ -18,24 +18,21 @@ }); await adapter.sso.authenticate(); ``` -- **host-papp:** `AuthSuccess` (the resolved value of `authenticate()`, exported alongside `HostMetadata` / `PairingStatus` / `DeviceIdentityForPairing`) carries `peerStatementAccountId` — the PApp device that signed the pairing-topic statement. Without it device-sync can't seed PApp as a peer, so the SDK captures it during pairing instead of asking each consumer to re-query the chain. -- **host-papp:** `createPappAdapter` accepts a `deviceIdentity` factory (resolved per `authenticate()`, so secret material never sits in adapter state between attempts), plus `onAuthSuccess` / `initialProcessedDataHex` / `onPairingStatementProcessed` hooks for consumer-side persistence and reload-survival dedupe. -- **host-papp:** `HostMetadata` reshape to mirror the V2 proposal shape — `{ hostName?, hostVersion?, hostIcon?, platformType?, platformVersion?, custom? }`. The host name/icon/platform now ride inside the QR proposal instead of being fetched from a separate URL. +- **host-papp:** `createPappAdapter` gains a `deviceIdentity` factory (`() => Promise`, resolved per `authenticate()` so secret material never sits in adapter state between attempts), plus three callbacks for consumer-side persistence — `onAuthSuccess` (fires on Success before `authenticate()` resolves), `initialProcessedDataHex` (read), and `onPairingStatementProcessed` (write) — that together let the consumer dedupe stale pairing statements across launches. +- **host-papp:** the resolved value of `authenticate()` (`AuthSuccess`) carries `peerStatementAccountId` — the PApp device that signed the pairing-topic statement. The SDK lifts it off `proof.value.signer` during pairing so device-sync can seed PApp as a peer without a follow-up chain query. - **host-chat:** new `MessageContent` variants at the spec'd indices — `chatAccepted` (14) now carries `{ messageId }` for iOS V1 backward decode; `deviceAdded` (17) `{ statementAccountId, encryptionPublicKey }`; `deviceRemoved` (18) `{ statementAccountId }`; `deviceChatAccepted` (20) `{ requestId, device }` sent on the identity-level session `SessionId(B, A)` encrypted with `K(A, B)` so all of a peer's devices can decrypt without a per-device envelope. ### 🩹 Fixes - **host-papp / host-chat:** `identity/rpcAdapter` and `accountService.getConsumerInfo` tolerate both camelCase and snake_case `Resources.Consumers` metadata fields — the V2 multi-device runtime metadata emits camelCase, which previously crashed `raw.stmt_store_slots.map(...)`. -- **host-papp:** `EncryptedHandshakeResponseV2` is decoded with native `scale-ts Enum` on the inner discriminant. The peer SCALE library does not elide that index, so `Pending(AllowanceAllocation)` arrives as `0x00 0x00` and was previously being misclassified as `Failed("")` by a length-only dispatch. - **host-chat:** `DeviceAdded` / `DeviceRemoved` use length-prefixed `Bytes()` rather than fixed-size codecs, matching `substrate-sdk-android`'s wire shape for `AccountId` / `EncodedPublicKey` wrapper types (no `@FixedLength`). ### ⚠️ Breaking Changes -- **host-papp:** V1 SSO handshake is gone. `createAuth` no longer derives an ephemeral sr25519 per attempt — callers must inject a persistent V2 `DeviceIdentityForPairing`. Older paired Polkadot Mobile clients (V1 wire format) will not handshake against this build, and the V1 handshake codec is removed. -- **host-papp:** `createPappAdapter` API shift. The `metadata: string` URL is dropped (host name / icon / platform now ride inside `hostMetadata`), and `deviceIdentity` is required. Consumers that previously called `createPappAdapter({ appId, metadata, hostMetadata })` need to add a `deviceIdentity` factory; see the snippet above. -- **host-papp:** public surface trimmed to `createAuth` and the types you need to use it (`AuthComponent`, `AuthSuccess`, `HostMetadata`, `PairingStatus`, `DeviceIdentityForPairing`). The V2 building blocks — `startPairingV2`, `idle` / `submitted` / `advance` / `fromInnerResponse` / `isTerminal` / `canSubmitV2Statements`, `buildPairingDeeplink` / `encodeProposal`, `computePairingTopic` / `computePairingChannel`, `decryptResponseEnvelope`, `deriveIdentityChatPublicKey`, every `Handshake*` / `*HandshakeResponse*` / `VersionedHandshake*` / `MetadataEntry` / `MetadataKey` / `Device` SCALE codec, and `HandshakeState` / `HandshakeIdleState` / `HandshakeSubmittedState` / `HandshakePendingState` / `HandshakeSuccessState` / `HandshakeFailedState` / `HandshakeMetadata` / `HandshakeProposalDevice` / `HandshakeResponseEnvelope` / `Pairing` / `StartPairingDeps` — are now internal. Consumers driving the handshake themselves should migrate to `auth.authenticate()`. -- **host-papp:** `PairingStatus.finished` no longer carries `session`. Read the resolved `AuthSuccess` off the value `authenticate()` returns instead. -- **host-papp:** the legacy 161-byte `HandshakeSuccessV2` payload (`encryptionKey || accountId || signature`) emitted by pre-multi-device PApp builds is no longer accepted — the spec v0.2.1 wire shape (`identityAccountId || rootAccountId || identityChatPrivateKey || deviceEncPubKey`, 161 bytes) is required. The 129-byte v0.2 variant emitted by Android `feature/location-for-handshake` is still accepted (with `rootAccountId === null`) for transitional compatibility. Per-device `identitySignature` and `IDENTITY_SIGNATURE_PAYLOAD_BYTES` are gone — multi-device authorisation moves to the user-identity-signed roster events (`DeviceAdded` / `DeviceRemoved`). +- **host-papp:** the V1 SSO handshake is gone. Paired clients on the V1 wire format will not pair against this build — both ends must run the multi-device V2 handshake. (Polkadot Mobile builds with multi-device support are V2.) `createAuth` correspondingly drops its V1 ephemeral-keypair derivation; callers inject a persistent `DeviceIdentityForPairing` via the new `deviceIdentity` factory on `createPappAdapter`. +- **host-papp:** `createPappAdapter` API change. The `metadata: string` URL parameter is dropped — host name / icon / platform now ride inside `hostMetadata` (sent inline with the V2 QR proposal). `deviceIdentity` becomes required. +- **host-papp:** `HostMetadata` reshape — was `{ hostVersion?, osType?, osVersion? }`, now `{ hostName?, hostVersion?, hostIcon?, platformType?, platformVersion?, custom? }`. Map `osType → platformType` and `osVersion → platformVersion` when upgrading. +- **host-papp:** `PairingStatus.finished` no longer carries `session`. Read the resolved `AuthSuccess` (identity + chat keys + peer device account) off the value `authenticate()` returns instead. - **host-chat:** `MessageContent.chatAccepted` (14) payload changed from `_void` to `{ messageId: string }`. Older clients that emit `_void` will not decode. ### ❤️ Thank You From 2a21cfe18a9273da9f280029c2a9cd110a8ef3c2 Mon Sep 17 00:00:00 2001 From: Ilya Date: Thu, 21 May 2026 09:31:40 -0600 Subject: [PATCH 12/31] refactor(host-papp): absorb device-identity + persistence into the SDK; restore V1-shape auth surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 0.7.x → 0.8.0 migration is now two field renames (`metadata` → inline `hostMetadata`, `osType`/`osVersion` → `platformType`/ `platformVersion`). Everything else about the auth surface — the `pairingStatus` / `authenticate()` / `abortAuthentication()` triad, `PairingStatus.finished.session`, `authenticate()` resolving to `StoredUserSession | null` — is what it was. What moved into the SDK: - New internal `deviceIdentityStore` (encrypted via the same gcm/blake2b pattern as `UserSecretRepository`). `createPappAdapter` now defaults `deviceIdentity` to a `StorageAdapter`-backed factory that generates and persists a fresh identity on first run. Override with the new optional `deviceIdentity` param if you need a different backend (Electron Keychain, native secure storage). - Pairing-topic statement dedupe (`initialProcessedDataHex` / `onPairingStatementProcessed` in the previous draft) is now fully internal; the consumer-facing surface drops both. - On Success, `createAuth` builds a V2-shaped `StoredUserSession` and writes it + the per-session secrets to `ssoSessionRepository` and `userSecretRepository` itself. `authenticate()` returns the persisted `StoredUserSession`. The optional `onAuthSuccess` hook fires after persistence with `{ session, identityChatPrivateKey }` so consumers can fan the user-identity bits out to their own stores (e.g. polkadot-desktop's `deviceIdentityRepository`). Schema changes: - `StoredUserSession` gains `identityAccountId?` and `identityChatPublicKey?` as trailing `Option` fields; the peer device statement account is exposed via `remoteAccount.accountId`. `from` decoder wraps in try/catch and returns `[]` on V1-blob decode failure, so the SDK silently wipes 0.7.x SsoSessions on first read instead of crashing. - `UserSecretRepository` gains `identityChatPrivateKey: Bytes(32)` as a trailing required field. `decode` wraps in try/catch and returns `null` on V1-blob decode failure. - `DeviceIdentityForPairing` adds a required `statementAccountSecret` (64-byte expanded sr25519 secret) so the V1 sessionManager prover can sign session statements with the device's stable identity. Public surface stays compact: `createPappAdapter`, `PappAdapter`, `AuthComponent`, `HostMetadata`, `OnAuthSuccess`, `PairingStatus`, `DeviceIdentityForPairing`, `StoredUserSession`, `UserSession`, `Identity`, signing request/response types, `RingVrf*`, `SS_*_ENDPOINTS`. `AuthSuccess` is gone — `StoredUserSession` carries the same fields. `host-papp-react-ui`: - `useAuthStatus` restores `signedInUser` (reads `pairingStatus.finished.session`) - `Flow.stories.tsx` drops the `deviceIdentity` stub — the SDK now defaults it. 524/524 tests pass. New tests cover internal persistence, the `onAuthSuccess` hook, and the default-deviceIdentity fallback. --- CHANGELOG.md | 30 ++-- .../host-papp-react-ui/src/Flow.stories.tsx | 5 - .../src/hooks/authStatus.ts | 11 +- packages/host-papp/__tests__/auth.spec.ts | 106 ++++++++++--- .../__tests__/handshakeV2Service.spec.ts | 1 + packages/host-papp/src/debugTypes.ts | 2 +- packages/host-papp/src/index.ts | 2 +- packages/host-papp/src/papp.ts | 49 +++--- packages/host-papp/src/sso/auth/impl.ts | 143 +++++++++++------- packages/host-papp/src/sso/auth/types.ts | 4 +- packages/host-papp/src/sso/auth/v2/service.ts | 6 + .../host-papp/src/sso/deviceIdentityStore.ts | 109 +++++++++++++ .../src/sso/sessionManager/userSession.ts | 5 +- .../host-papp/src/sso/userSecretRepository.ts | 19 ++- .../src/sso/userSessionRepository.ts | 30 +++- 15 files changed, 378 insertions(+), 144 deletions(-) create mode 100644 packages/host-papp/src/sso/deviceIdentityStore.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 81ce2ad8..2f230d38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,24 +2,11 @@ ### 🚀 Features -- **host-papp:** multi-device SSO. `createAuth` / `pappAdapter.sso` keeps the existing `pairingStatus` / `authenticate()` / `abortAuthentication()` surface; what's new is the protocol underneath. Pairing now runs the V2 multi-device handshake — `VersionedHandshakeProposal::V2` carried over the QR deeplink, ECDH-encrypted Statement Store response envelope, on-chain `Pending(AllowanceAllocation)` flow — so desktop and web hosts can pair with the multi-device iOS/Android Polkadot Mobile builds through the same entry point you've been calling. The V2 codecs, pairing service, and state machine are SDK internals. - ```ts - const adapter = createPappAdapter({ - appId, - hostMetadata, - deviceIdentity: () => deviceIdentityService.loadOrCreate(), - onAuthSuccess: async success => { - // success.peerStatementAccountId is the PApp device id, lifted off the - // pairing-topic statement's proof.value.signer inside the SDK. - await persistHandshakeSuccess(success, success.peerStatementAccountId); - }, - initialProcessedDataHex: () => repo.readLastProcessedHandshakeStatement(), - onPairingStatementProcessed: hex => void repo.writeLastProcessedHandshakeStatement(hex), - }); - await adapter.sso.authenticate(); - ``` -- **host-papp:** `createPappAdapter` gains a `deviceIdentity` factory (`() => Promise`, resolved per `authenticate()` so secret material never sits in adapter state between attempts), plus three callbacks for consumer-side persistence — `onAuthSuccess` (fires on Success before `authenticate()` resolves), `initialProcessedDataHex` (read), and `onPairingStatementProcessed` (write) — that together let the consumer dedupe stale pairing statements across launches. -- **host-papp:** the resolved value of `authenticate()` (`AuthSuccess`) carries `peerStatementAccountId` — the PApp device that signed the pairing-topic statement. The SDK lifts it off `proof.value.signer` during pairing so device-sync can seed PApp as a peer without a follow-up chain query. +- **host-papp:** multi-device SSO. `createAuth` / `pappAdapter.sso` keeps the V1 `pairingStatus` / `authenticate()` / `abortAuthentication()` surface — same state transitions, `authenticate()` still resolves to a `StoredUserSession | null`, `pairingStatus.finished` still carries `session`. What's new is the protocol underneath: pairing now runs the V2 multi-device handshake (`VersionedHandshakeProposal::V2` QR proposal, ECDH-encrypted Statement Store response envelope, on-chain `Pending(AllowanceAllocation)` flow). Desktop and web hosts pair with the multi-device iOS/Android Polkadot Mobile builds through the same entry point you've been calling. The V2 codecs, pairing service, and state machine are SDK internals. +- **host-papp:** the SDK persists the V2 device identity itself. `createPappAdapter` creates and persists a fresh device identity to the configured `StorageAdapter` on first run and reuses it on subsequent launches — no consumer wiring needed. Hosts that want a different persistence backend (Electron Keychain, native secure storage) can override with an optional `deviceIdentity` factory. +- **host-papp:** the SDK persists pairing-topic statement dedupe state internally too, so stale `Success` statements on chain don't get replayed across launches without consumer plumbing. +- **host-papp:** `StoredUserSession` gains three optional V2 fields — `identityAccountId` (user identity sr25519 account), `identityChatPublicKey` (P-256 65-byte, derived locally from `identityChatPrivateKey`), and the peer device statement account exposed via `remoteAccount.accountId` — so device-sync and the chat layer can read peer state straight off the session. The matching `identityChatPrivateKey` is persisted into `UserSecretRepository` and passed to the optional `onAuthSuccess` hook for consumers that need it. +- **host-papp:** new optional `onAuthSuccess` hook on `createPappAdapter` for consumer-specific post-pairing work (telemetry, custom peer caches, device-sync seeding). Fires after the SDK has written the session + secrets to its own repositories. Receives `{ session: StoredUserSession, identityChatPrivateKey: Uint8Array }`. - **host-chat:** new `MessageContent` variants at the spec'd indices — `chatAccepted` (14) now carries `{ messageId }` for iOS V1 backward decode; `deviceAdded` (17) `{ statementAccountId, encryptionPublicKey }`; `deviceRemoved` (18) `{ statementAccountId }`; `deviceChatAccepted` (20) `{ requestId, device }` sent on the identity-level session `SessionId(B, A)` encrypted with `K(A, B)` so all of a peer's devices can decrypt without a per-device envelope. ### 🩹 Fixes @@ -29,10 +16,11 @@ ### ⚠️ Breaking Changes -- **host-papp:** the V1 SSO handshake is gone. Paired clients on the V1 wire format will not pair against this build — both ends must run the multi-device V2 handshake. (Polkadot Mobile builds with multi-device support are V2.) `createAuth` correspondingly drops its V1 ephemeral-keypair derivation; callers inject a persistent `DeviceIdentityForPairing` via the new `deviceIdentity` factory on `createPappAdapter`. -- **host-papp:** `createPappAdapter` API change. The `metadata: string` URL parameter is dropped — host name / icon / platform now ride inside `hostMetadata` (sent inline with the V2 QR proposal). `deviceIdentity` becomes required. +The migration is essentially two field renames; the auth surface is otherwise unchanged. + +- **host-papp:** `createPappAdapter` no longer accepts `metadata: string` (the V1 metadata URL) — host name / icon / platform now ride inside `hostMetadata` (sent inline with the V2 QR proposal). - **host-papp:** `HostMetadata` reshape — was `{ hostVersion?, osType?, osVersion? }`, now `{ hostName?, hostVersion?, hostIcon?, platformType?, platformVersion?, custom? }`. Map `osType → platformType` and `osVersion → platformVersion` when upgrading. -- **host-papp:** `PairingStatus.finished` no longer carries `session`. Read the resolved `AuthSuccess` (identity + chat keys + peer device account) off the value `authenticate()` returns instead. +- **host-papp:** the V1 SSO handshake is gone. Paired clients on the V1 wire format will not pair against this build — both ends must run the multi-device V2 handshake. (Polkadot Mobile builds with multi-device support are V2.) Persisted V1 SSO sessions don't migrate; they decode short against the extended V2 codec and are wiped on first read, so users need to re-pair. - **host-chat:** `MessageContent.chatAccepted` (14) payload changed from `_void` to `{ messageId: string }`. Older clients that emit `_void` will not decode. ### ❤️ Thank You diff --git a/packages/host-papp-react-ui/src/Flow.stories.tsx b/packages/host-papp-react-ui/src/Flow.stories.tsx index 04e4098e..5c91f07e 100644 --- a/packages/host-papp-react-ui/src/Flow.stories.tsx +++ b/packages/host-papp-react-ui/src/Flow.stories.tsx @@ -86,11 +86,6 @@ const meta: Meta = { args: { adapter: createPappAdapter({ appId: 'https://test.com', - deviceIdentity: () => ({ - statementAccountPublicKey: new Uint8Array(32), - encryptionPublicKey: new Uint8Array(65), - encryptionPrivateKey: new Uint8Array(32), - }), adapters: { lazyClient: createLazyClient(getWsProvider(SS_PASEO_STABLE_STAGE_ENDPOINTS)), }, diff --git a/packages/host-papp-react-ui/src/hooks/authStatus.ts b/packages/host-papp-react-ui/src/hooks/authStatus.ts index 1e336941..6e9598b9 100644 --- a/packages/host-papp-react-ui/src/hooks/authStatus.ts +++ b/packages/host-papp-react-ui/src/hooks/authStatus.ts @@ -1,10 +1,19 @@ +import { useMemo } from 'react'; + import { useAuthentication } from '../providers/AuthProvider.js'; export const useAuthStatus = () => { const { pairingStatus } = useAuthentication(); + const signedInUser = useMemo(() => { + if (pairingStatus.step === 'finished') { + return pairingStatus.session; + } + return null; + }, [pairingStatus]); + return { status: pairingStatus, - isSignedIn: pairingStatus.step === 'finished', + signedInUser, }; }; diff --git a/packages/host-papp/__tests__/auth.spec.ts b/packages/host-papp/__tests__/auth.spec.ts index 54c0c88f..58b4b269 100644 --- a/packages/host-papp/__tests__/auth.spec.ts +++ b/packages/host-papp/__tests__/auth.spec.ts @@ -9,10 +9,14 @@ import type { HostPappDebugEvent } from '../src/debugTypes.js'; import { createAuth } from '../src/sso/auth/impl.js'; import { HandshakeSuccessV2, VersionedHandshakeResponse } from '../src/sso/auth/scale/handshakeV2.js'; import type { DeviceIdentityForPairing } from '../src/sso/auth/v2/service.js'; +import type { DeviceIdentityStore } from '../src/sso/deviceIdentityStore.js'; +import type { UserSecretRepository } from '../src/sso/userSecretRepository.js'; +import type { UserSessionRepository } from '../src/sso/userSessionRepository.js'; const DEVICE_ENC_PRIV = new Uint8Array(32).fill(0x22); const DEVICE_ENC_PUB = p256.getPublicKey(DEVICE_ENC_PRIV, false); const DEVICE_STMT_ACCT = new Uint8Array(32).fill(0x33); +const DEVICE_STMT_SECRET = new Uint8Array(64).fill(0x55); const IDENTITY_CHAT_PRIV = new Uint8Array(32).fill(0xdd); const IDENTITY_ACCT = new Uint8Array(32).fill(0xa1); @@ -21,10 +25,18 @@ const PEER_STMT_ACCT_HEX = '0x' + '44'.repeat(32); const makeDeviceIdentity = (): DeviceIdentityForPairing => ({ statementAccountPublicKey: DEVICE_STMT_ACCT, + statementAccountSecret: DEVICE_STMT_SECRET, encryptionPublicKey: DEVICE_ENC_PUB, encryptionPrivateKey: DEVICE_ENC_PRIV, }); +const stubDeviceIdentityStore = (): DeviceIdentityStore => + ({ + loadOrCreate: vi.fn(() => okAsync({ ...makeDeviceIdentity(), statementAccountSecret: DEVICE_STMT_SECRET })), + readLastProcessedHandshakeStatement: vi.fn(() => okAsync(null)), + writeLastProcessedHandshakeStatement: vi.fn(() => okAsync(undefined)), + }) as unknown as DeviceIdentityStore; + const buildSuccessStatement = (): Statement => { const inner = HandshakeSuccessV2.enc({ identityAccountId: IDENTITY_ACCT, @@ -59,7 +71,7 @@ const buildSuccessStatement = (): Statement => { type Deliver = (page: { statements: Statement[]; isComplete: boolean }) => void; -const buildHarness = (overrides: { persistOnSuccess?: () => Promise } = {}) => { +const buildHarness = (overrides: { onAuthSuccess?: () => Promise } = {}) => { let deliver: Deliver | null = null; const unsubscribe = vi.fn(); const subscribeStatements = vi.fn((_filter: unknown, onPage: Deliver) => { @@ -70,11 +82,18 @@ const buildHarness = (overrides: { persistOnSuccess?: () => Promise } = {} const statementStore = { subscribeStatements, queryStatements } as unknown as StatementStoreAdapter; + const ssoSessionRepository = { add: vi.fn(() => okAsync(undefined)) } as unknown as UserSessionRepository; + const userSecretRepository = { write: vi.fn(() => okAsync(undefined)) } as unknown as UserSecretRepository; + const deviceIdentityStore = stubDeviceIdentityStore(); + const auth = createAuth({ hostMetadata: { hostName: 'Test Host' }, deviceIdentity: makeDeviceIdentity, + deviceIdentityStore, statementStore, - persistOnSuccess: overrides.persistOnSuccess, + ssoSessionRepository, + userSecretRepository, + onAuthSuccess: overrides.onAuthSuccess, }); return { @@ -82,6 +101,9 @@ const buildHarness = (overrides: { persistOnSuccess?: () => Promise } = {} subscribeStatements, queryStatements, unsubscribe, + ssoSessionRepository, + userSecretRepository, + deviceIdentityStore, async waitForSubscription() { await vi.waitFor(() => expect(subscribeStatements).toHaveBeenCalledTimes(1)); }, @@ -108,7 +130,7 @@ describe('createAuth', () => { expect(second).toBe(first); }); - it('resolves with the V2 success when a Success statement arrives', async () => { + it('persists the session and resolves with a StoredUserSession on Success', async () => { const harness = buildHarness(); const promise = harness.auth.authenticate(); await harness.waitForSubscription(); @@ -116,15 +138,19 @@ describe('createAuth', () => { const result = await promise; expect(result.isOk()).toBe(true); - const success = result._unsafeUnwrap(); - expect(success).not.toBeNull(); - expect(success!.identityAccountId).toEqual(IDENTITY_ACCT); - expect(success!.peerStatementAccountId).toEqual(new Uint8Array(32).fill(0x44)); + const session = result._unsafeUnwrap(); + expect(session).not.toBeNull(); + expect(session!.identityAccountId).toEqual(IDENTITY_ACCT); + expect(session!.remoteAccount.accountId).toEqual(new Uint8Array(32).fill(0x44)); + expect(harness.ssoSessionRepository.add).toHaveBeenCalledOnce(); + expect(harness.userSecretRepository.write).toHaveBeenCalledOnce(); + const secretsCall = (harness.userSecretRepository.write as ReturnType).mock.calls[0]; + expect(secretsCall?.[1]).toMatchObject({ identityChatPrivateKey: IDENTITY_CHAT_PRIV }); }); - it('emits pairingStatus transitions: none -> initial -> pairing(deeplink) -> finished', async () => { + it('emits pairingStatus transitions: none -> initial -> pairing(deeplink) -> finished(session)', async () => { const harness = buildHarness(); - const observed: { step: string; payload?: string }[] = []; + const observed: { step: string; payload?: string; session?: { id: string } }[] = []; harness.auth.pairingStatus.subscribe(s => observed.push(s as never)); const promise = harness.auth.authenticate(); @@ -137,27 +163,32 @@ describe('createAuth', () => { expect(steps).toContain('initial'); const pairing = observed.find(s => s.step === 'pairing'); expect(pairing?.payload).toMatch(/^polkadotapp:\/\/pair\?handshake=/); - expect(steps.at(-1)).toBe('finished'); + const finished = observed.find(s => s.step === 'finished'); + expect(finished?.session).toBeDefined(); + expect(finished?.session?.id).toBeTypeOf('string'); }); - it('runs the persistOnSuccess hook before resolving', async () => { - const persistOnSuccess = vi.fn(() => Promise.resolve()); - const harness = buildHarness({ persistOnSuccess }); + it('runs the onAuthSuccess hook with session + identityChatPrivateKey after internal persistence', async () => { + const onAuthSuccess = vi.fn(() => Promise.resolve()); + const harness = buildHarness({ onAuthSuccess }); const promise = harness.auth.authenticate(); await harness.waitForSubscription(); harness.deliver([buildSuccessStatement()]); - const result = await promise; + expect(result.isOk()).toBe(true); - expect(persistOnSuccess).toHaveBeenCalledTimes(1); - const arg = (persistOnSuccess.mock.calls[0] as unknown as [{ identityAccountId: Uint8Array }])[0]; - expect(arg.identityAccountId).toEqual(IDENTITY_ACCT); + expect(onAuthSuccess).toHaveBeenCalledTimes(1); + const arg = ( + onAuthSuccess.mock.calls[0] as unknown as [{ session: { id: string }; identityChatPrivateKey: Uint8Array }] + )[0]; + expect(arg.session.id).toBeTypeOf('string'); + expect(arg.identityChatPrivateKey).toEqual(IDENTITY_CHAT_PRIV); }); - it('fails authenticate when persistOnSuccess throws', async () => { - const persistOnSuccess = vi.fn(() => Promise.reject(new Error('persist boom'))); - const harness = buildHarness({ persistOnSuccess }); + it('fails authenticate when onAuthSuccess throws', async () => { + const onAuthSuccess = vi.fn(() => Promise.reject(new Error('hook boom'))); + const harness = buildHarness({ onAuthSuccess }); const promise = harness.auth.authenticate(); await harness.waitForSubscription(); @@ -165,8 +196,8 @@ describe('createAuth', () => { const result = await promise; expect(result.isErr()).toBe(true); - expect(result._unsafeUnwrapErr().message).toBe('persist boom'); - expect(harness.auth.pairingStatus.read()).toEqual({ step: 'pairingError', message: 'persist boom' }); + expect(result._unsafeUnwrapErr().message).toBe('hook boom'); + expect(harness.auth.pairingStatus.read()).toEqual({ step: 'pairingError', message: 'hook boom' }); }); }); @@ -177,7 +208,6 @@ describe('createAuth', () => { data: VersionedHandshakeResponse.enc({ tag: 'V2', value: (() => { - // Failed body = enum index 2 + length-prefixed string "declined" const enc = createEncryption( p256.getSharedSecret(new Uint8Array(32).fill(0x66), DEVICE_ENC_PUB).slice(1, 33) as never, ); @@ -222,7 +252,6 @@ describe('createAuth', () => { harness.auth.abortAuthentication(); await first; - // subscribeStatements gets called again on a fresh authenticate const second = harness.auth.authenticate(); expect(second).not.toBe(first); await vi.waitFor(() => expect(harness.subscribeStatements).toHaveBeenCalledTimes(2)); @@ -237,6 +266,35 @@ describe('createAuth', () => { }); }); + describe('default deviceIdentity', () => { + it('falls back to deviceIdentityStore.loadOrCreate when no deviceIdentity factory is provided', async () => { + let deliver: Deliver | null = null; + const unsubscribe = vi.fn(); + const subscribeStatements = vi.fn((_filter: unknown, onPage: Deliver) => { + deliver = onPage; + return unsubscribe; + }); + const queryStatements = vi.fn(() => okAsync([])); + const statementStore = { subscribeStatements, queryStatements } as unknown as StatementStoreAdapter; + const ssoSessionRepository = { add: vi.fn(() => okAsync(undefined)) } as unknown as UserSessionRepository; + const userSecretRepository = { write: vi.fn(() => okAsync(undefined)) } as unknown as UserSecretRepository; + const deviceIdentityStore = stubDeviceIdentityStore(); + + const auth = createAuth({ + deviceIdentityStore, + statementStore, + ssoSessionRepository, + userSecretRepository, + }); + + const promise = auth.authenticate(); + await vi.waitFor(() => expect(subscribeStatements).toHaveBeenCalledTimes(1)); + expect(deviceIdentityStore.loadOrCreate).toHaveBeenCalledOnce(); + if (deliver) (deliver as Deliver)({ statements: [buildSuccessStatement()], isComplete: true }); + await promise; + }); + }); + describe('debug emits', () => { function captureEvents() { const events: HostPappDebugEvent[] = []; diff --git a/packages/host-papp/__tests__/handshakeV2Service.spec.ts b/packages/host-papp/__tests__/handshakeV2Service.spec.ts index fe99009e..6823a94e 100644 --- a/packages/host-papp/__tests__/handshakeV2Service.spec.ts +++ b/packages/host-papp/__tests__/handshakeV2Service.spec.ts @@ -16,6 +16,7 @@ const buildDeviceIdentity = (): DeviceIdentityForPairing => { const encryptionPrivateKey = p256.utils.randomSecretKey(); return { statementAccountPublicKey: new Uint8Array(32).fill(0xa1), + statementAccountSecret: new Uint8Array(64).fill(0x55), encryptionPrivateKey, encryptionPublicKey: p256.getPublicKey(encryptionPrivateKey, false), }; diff --git a/packages/host-papp/src/debugTypes.ts b/packages/host-papp/src/debugTypes.ts index fa8f657b..9f202c2d 100644 --- a/packages/host-papp/src/debugTypes.ts +++ b/packages/host-papp/src/debugTypes.ts @@ -53,7 +53,7 @@ export type SsoDebugEvent = event: 'session_established'; flowId: string; timestamp: number; - payload: { identityAccountId: Uint8Array }; + payload: { sessionId: string }; } | { layer: 'sso'; diff --git a/packages/host-papp/src/index.ts b/packages/host-papp/src/index.ts index 873af6ce..0f20a699 100644 --- a/packages/host-papp/src/index.ts +++ b/packages/host-papp/src/index.ts @@ -3,7 +3,7 @@ export { SS_PASEO_STABLE_STAGE_ENDPOINTS, SS_PREVIEW_STAGE_ENDPOINTS, SS_STABLE_ export type { PappAdapter } from './papp.js'; export { createPappAdapter } from './papp.js'; -export type { AuthComponent, AuthSuccess, HostMetadata } from './sso/auth/impl.js'; +export type { AuthComponent, HostMetadata, OnAuthSuccess } from './sso/auth/impl.js'; export type { PairingStatus } from './sso/auth/types.js'; export type { DeviceIdentityForPairing } from './sso/auth/v2/service.js'; diff --git a/packages/host-papp/src/papp.ts b/packages/host-papp/src/papp.ts index 9476d1b9..b0b18b71 100644 --- a/packages/host-papp/src/papp.ts +++ b/packages/host-papp/src/papp.ts @@ -8,9 +8,10 @@ import { SS_STABLE_STAGE_ENDPOINTS } from './constants.js'; import { createIdentityRepository } from './identity/impl.js'; import { createIdentityRpcAdapter } from './identity/rpcAdapter.js'; import type { IdentityAdapter, IdentityRepository } from './identity/types.js'; -import type { AuthComponent, AuthSuccess, HostMetadata } from './sso/auth/impl.js'; +import type { AuthComponent, HostMetadata, OnAuthSuccess } from './sso/auth/impl.js'; import { createAuth } from './sso/auth/impl.js'; import type { DeviceIdentityForPairing } from './sso/auth/v2/service.js'; +import { createDeviceIdentityStore } from './sso/deviceIdentityStore.js'; import type { SsoSessionManager } from './sso/sessionManager/impl.js'; import { createSsoSessionManager } from './sso/sessionManager/impl.js'; import type { UserSecretRepository } from './sso/userSecretRepository.js'; @@ -33,36 +34,30 @@ type Adapters = { type Params = { /** - * Host app Id. - * CAUTION! This value should be stable. + * Host app Id. CAUTION! This value should be stable across launches — it + * seeds the storage prefix that backs every persisted SSO blob. */ appId: string; /** - * Host environment metadata embedded in the pairing proposal so PApp can - * render the request screen. All fields are optional — absence must not - * break the pairing flow. + * Host environment metadata embedded inside the V2 pairing proposal QR so + * the paired device can render a request screen with the host name / icon / + * platform. All fields are optional — absence must not break pairing. */ hostMetadata?: HostMetadata; /** - * Persistent V2 device identity. The same identity must be returned on every - * launch so PApp recognises this device as the same peer. The factory is - * invoked per `sso.authenticate()` call, so callers can lazy-load from - * keychain / IndexedDB without blocking adapter construction. + * Optional override for the device identity. Default: the SDK persists a + * fresh identity to the configured `StorageAdapter` on first run and reuses + * it on subsequent launches. Pass a factory only if you need a different + * persistence backend (Electron Keychain, native secure storage, etc.). */ - deviceIdentity: () => Promise | DeviceIdentityForPairing; + deviceIdentity?: () => Promise | DeviceIdentityForPairing; /** - * Caller hook fired after a successful handshake, before - * `sso.authenticate()` resolves. Throwing fails the call. + * Optional caller hook fired after a successful handshake — after the SDK + * has already written the session + secrets to its own repositories. Use it + * for consumer-specific bookkeeping (telemetry, custom peer caches, device- + * sync seeding). Throwing fails the `sso.authenticate()` call. */ - onAuthSuccess?: (success: AuthSuccess) => Promise; - /** - * Reload-survival dedupe: hex of the last pairing-topic statement consumed - * by this device. Resolved per `sso.authenticate()` so callers can read - * from async storage (IndexedDB / keychain) without blocking adapter - * construction. - */ - initialProcessedDataHex?: () => Promise | string | null; - onPairingStatementProcessed?: (dataHex: string) => void; + onAuthSuccess?: OnAuthSuccess; adapters?: Partial; }; @@ -71,8 +66,6 @@ export function createPappAdapter({ hostMetadata, deviceIdentity, onAuthSuccess, - initialProcessedDataHex, - onPairingStatementProcessed, adapters, }: Params): PappAdapter { const lazyClient = @@ -85,15 +78,17 @@ export function createPappAdapter({ const ssoSessionRepository = createUserSessionRepository(storage); const userSecretRepository = createUserSecretRepository(appId, storage); + const deviceIdentityStore = createDeviceIdentityStore(appId, storage); return { sso: createAuth({ hostMetadata, deviceIdentity, + deviceIdentityStore, statementStore, - persistOnSuccess: onAuthSuccess, - initialProcessedDataHex, - onStatementProcessed: onPairingStatementProcessed, + ssoSessionRepository, + userSecretRepository, + onAuthSuccess, }), sessions: createSsoSessionManager({ storage, statementStore, ssoSessionRepository, userSecretRepository }), secrets: userSecretRepository, diff --git a/packages/host-papp/src/sso/auth/impl.ts b/packages/host-papp/src/sso/auth/impl.ts index 71a9c707..88fb83b2 100644 --- a/packages/host-papp/src/sso/auth/impl.ts +++ b/packages/host-papp/src/sso/auth/impl.ts @@ -1,10 +1,16 @@ import type { StatementStoreAdapter } from '@novasamatech/statement-store'; +import { createAccountId, createLocalSessionAccount, createRemoteSessionAccount } from '@novasamatech/statement-store'; import { ResultAsync, errAsync, okAsync } from 'neverthrow'; +import type { EncrSecret, SsSecret } from '../../crypto.js'; import { createFlowId, emitHostPappDebugMessage } from '../../debugBus.js'; import { AbortError } from '../../helpers/abortError.js'; import { createState, readonly } from '../../helpers/state.js'; import { toError } from '../../helpers/utils.js'; +import type { DeviceIdentityStore } from '../deviceIdentityStore.js'; +import type { UserSecretRepository } from '../userSecretRepository.js'; +import type { StoredUserSession, UserSessionRepository } from '../userSessionRepository.js'; +import { createStoredUserSession } from '../userSessionRepository.js'; import type { PairingStatus } from './types.js'; import type { HandshakeMetadata } from './v2/proposal.js'; @@ -15,53 +21,52 @@ import type { HandshakeState, HandshakeSuccessState } from './v2/state.js'; export type HostMetadata = HandshakeMetadata; export type AuthComponent = ReturnType; -export type AuthSuccess = HandshakeSuccessState; +/** + * Optional caller hook fired once the V2 handshake reaches Success, after the + * SDK has persisted the session and secrets and before `authenticate()` + * resolves. Receives both the persisted `StoredUserSession` and the sensitive + * `identityChatPrivateKey` (which lives in `UserSecretRepository` and isn't + * surfaced on the session shape). Throwing fails the `authenticate()` call. + */ +export type OnAuthSuccess = (event: { + session: StoredUserSession; + identityChatPrivateKey: Uint8Array; +}) => Promise | void; type Params = { hostMetadata?: HostMetadata; /** - * Persistent device identity used for the pairing. The same identity must be - * reused across launches so PApp recognises this device as the same peer - * (per-device chat addressing depends on `encryptionPublicKey`). Caller owns - * the persistence; the factory is invoked on each `authenticate()` so the - * SDK never holds key material between attempts. + * Optional override for the device identity. If absent, the SDK uses an + * internal `deviceIdentityStore` backed by the host's `StorageAdapter` — + * fine for web hosts. Electron / native consumers can plug in a Keychain- + * backed identity by passing a factory that returns the same shape. */ - deviceIdentity: () => Promise | DeviceIdentityForPairing; + deviceIdentity?: () => Promise | DeviceIdentityForPairing; + deviceIdentityStore: DeviceIdentityStore; statementStore: StatementStoreAdapter; - /** - * Fires once the V2 handshake reaches `Success`, before `authenticate()` - * resolves. Use it for consumer-specific bookkeeping (peer-device - * registration, contact reset, telemetry). Throwing fails the - * `authenticate()` call and surfaces as `pairingError`. - */ - persistOnSuccess?: (success: AuthSuccess) => Promise; - /** - * Hex of the last pairing-topic statement this device processed (so a stale - * `Success` doesn't get replayed on the next launch / re-pair). Resolved per - * `authenticate()` so a value freshly written by `onStatementProcessed` on - * one attempt is visible to the next. - */ - initialProcessedDataHex?: () => Promise | string | null; - onStatementProcessed?: (dataHex: string) => void; + ssoSessionRepository: UserSessionRepository; + userSecretRepository: UserSecretRepository; + onAuthSuccess?: OnAuthSuccess; }; export function createAuth({ hostMetadata, deviceIdentity, + deviceIdentityStore, statementStore, - persistOnSuccess, - initialProcessedDataHex, - onStatementProcessed, + ssoSessionRepository, + userSecretRepository, + onAuthSuccess, }: Params) { const pairingStatus = createState({ step: 'none' }); - let authResult: ResultAsync | null = null; + let authResult: ResultAsync | null = null; let abortHandle: (() => void) | null = null; return { pairingStatus: readonly(pairingStatus), - authenticate(): ResultAsync { + authenticate(): ResultAsync { if (authResult) return authResult; const flowId = createFlowId(); @@ -81,18 +86,25 @@ export function createAuth({ pairingAbort?.(); }; - const flow = ResultAsync.fromPromise( - Promise.all([Promise.resolve(deviceIdentity()), Promise.resolve(initialProcessedDataHex?.() ?? null)]), - toError, - ).andThen(([identity, initialHex]) => { - if (aborted) return okAsync(null); + const resolveDeviceIdentity = (): ResultAsync => + deviceIdentity + ? ResultAsync.fromPromise(Promise.resolve(deviceIdentity()), toError) + : deviceIdentityStore.loadOrCreate(); + + const flow = ResultAsync.combine([ + resolveDeviceIdentity(), + deviceIdentityStore.readLastProcessedHandshakeStatement(), + ]).andThen(([identity, initialHex]) => { + if (aborted) return okAsync(null); const pairing = startPairingV2({ statementStore, deviceIdentity: identity, metadata: hostMetadata ?? {}, initialProcessedDataHex: initialHex, - onStatementProcessed, + onStatementProcessed: hex => { + void deviceIdentityStore.writeLastProcessedHandshakeStatement(hex); + }, }); pairingAbort = pairing.abort; @@ -113,7 +125,7 @@ export function createAuth({ }); return ResultAsync.fromPromise( - new Promise((resolve, reject) => { + new Promise((resolve, reject) => { let settled = false; const settle = (cb: () => void) => { if (settled) return; @@ -127,7 +139,6 @@ export function createAuth({ s => settle(() => resolve(s)), e => settle(() => reject(e)), () => sub?.unsubscribe(), - flowId, ), complete: () => { if (aborted) settle(() => reject(new AbortError('Aborted by user.'))); @@ -136,22 +147,22 @@ export function createAuth({ }); }), toError, - ); + ).andThen(success => persistAndNotify(identity, success, flowId)); }); authResult = flow - .orElse(e => (e instanceof AbortError ? okAsync(null) : errAsync(e))) - .andTee(success => { - if (success === null) { + .orElse(e => (e instanceof AbortError ? okAsync(null) : errAsync(e))) + .andTee(session => { + if (session === null) { pairingStatus.reset(); } else { - pairingStatus.write({ step: 'finished' }); + pairingStatus.write({ step: 'finished', session }); emitHostPappDebugMessage({ layer: 'sso', event: 'session_established', flowId, timestamp: Date.now(), - payload: { identityAccountId: success.identityAccountId }, + payload: { sessionId: session.id }, }); } abortHandle = null; @@ -173,10 +184,9 @@ export function createAuth({ function onState( state: HandshakeState, - resolve: (value: AuthSuccess) => void, + resolve: (value: HandshakeSuccessState) => void, reject: (err: Error) => void, unsubscribe: () => void, - flowId: string, ) { switch (state.tag) { case 'Idle': @@ -194,14 +204,7 @@ export function createAuth({ timestamp: Date.now(), payload: { identityAccountId: state.identityAccountId }, }); - if (persistOnSuccess) { - persistOnSuccess(state).then( - () => resolve(state), - e => reject(toError(e)), - ); - } else { - resolve(state); - } + resolve(state); return; case 'Failed': unsubscribe(); @@ -218,4 +221,42 @@ export function createAuth({ pairingStatus.reset(); }, }; + + function persistAndNotify( + identity: DeviceIdentityForPairing, + success: HandshakeSuccessState, + _flowId: string, + ): ResultAsync { + const localAccount = createLocalSessionAccount(createAccountId(identity.statementAccountPublicKey)); + const remoteAccount = createRemoteSessionAccount( + createAccountId(success.peerStatementAccountId ?? new Uint8Array(32)), + success.deviceEncPubKey, + ); + const session = createStoredUserSession( + localAccount, + remoteAccount, + createAccountId(success.rootAccountId ?? new Uint8Array(32)), + { + identityAccountId: createAccountId(success.identityAccountId), + identityChatPublicKey: success.identityChatPublicKey, + }, + ); + + return userSecretRepository + .write(session.id, { + ssSecret: identity.statementAccountSecret as SsSecret, + encrSecret: identity.encryptionPrivateKey as EncrSecret, + entropy: new Uint8Array(0), + identityChatPrivateKey: success.identityChatPrivateKey, + }) + .andThen(() => ssoSessionRepository.add(session)) + .andThen(() => + onAuthSuccess + ? ResultAsync.fromPromise( + Promise.resolve(onAuthSuccess({ session, identityChatPrivateKey: success.identityChatPrivateKey })), + toError, + ).map(() => session) + : okAsync(session), + ); + } } diff --git a/packages/host-papp/src/sso/auth/types.ts b/packages/host-papp/src/sso/auth/types.ts index 6537d6a0..421faca5 100644 --- a/packages/host-papp/src/sso/auth/types.ts +++ b/packages/host-papp/src/sso/auth/types.ts @@ -1,7 +1,9 @@ +import type { StoredUserSession } from '../userSessionRepository.js'; + export type PairingStatus = | { step: 'none' } | { step: 'initial' } | { step: 'pairing'; payload: string } | { step: 'pending'; stage: string } | { step: 'pairingError'; message: string } - | { step: 'finished' }; + | { step: 'finished'; session: StoredUserSession }; diff --git a/packages/host-papp/src/sso/auth/v2/service.ts b/packages/host-papp/src/sso/auth/v2/service.ts index dba2e177..3703224a 100644 --- a/packages/host-papp/src/sso/auth/v2/service.ts +++ b/packages/host-papp/src/sso/auth/v2/service.ts @@ -38,6 +38,12 @@ import { computePairingTopic } from './topic.js'; export type DeviceIdentityForPairing = { statementAccountPublicKey: Uint8Array; + /** + * sr25519 secret for the device statement account. `startPairingV2` itself + * doesn't sign anything, but the post-pairing `StoredUserSession` needs it + * so the V1 sessionManager prover can issue session statements. + */ + statementAccountSecret: Uint8Array; encryptionPublicKey: Uint8Array; encryptionPrivateKey: Uint8Array; }; diff --git a/packages/host-papp/src/sso/deviceIdentityStore.ts b/packages/host-papp/src/sso/deviceIdentityStore.ts new file mode 100644 index 00000000..41d18c97 --- /dev/null +++ b/packages/host-papp/src/sso/deviceIdentityStore.ts @@ -0,0 +1,109 @@ +import { gcm } from '@noble/ciphers/aes.js'; +import { blake2b } from '@noble/hashes/blake2.js'; +import { createSr25519Secret, deriveSr25519PublicKey } from '@novasamatech/statement-store'; +import type { StorageAdapter } from '@novasamatech/storage-adapter'; +import type { ResultAsync } from 'neverthrow'; +import { errAsync, fromPromise, okAsync } from 'neverthrow'; +import { fromHex, toHex } from 'polkadot-api/utils'; +import { Bytes, Option, Struct, str } from 'scale-ts'; + +import type { EncrPublicKey, EncrSecret, SsPublicKey, SsSecret } from '../crypto.js'; +import { getEncrPub, stringToBytes } from '../crypto.js'; +import { toError } from '../helpers/utils.js'; + +import type { DeviceIdentityForPairing } from './auth/v2/service.js'; + +const KEY = 'DeviceIdentity'; + +// Persisted shape — kept under appId-derived AES-GCM at rest, same pattern as +// UserSecretRepository. +const StoredDeviceCodec = Struct({ + statementAccountSeed: Bytes(32), + encryptionPrivateKey: Bytes(32), + lastProcessedHandshakeStatement: Option(str), +}); + +type Stored = { + statementAccountSeed: Uint8Array; + encryptionPrivateKey: Uint8Array; + lastProcessedHandshakeStatement: string | undefined; +}; + +export type DeviceIdentity = DeviceIdentityForPairing & { + statementAccountSecret: SsSecret; +}; + +export type DeviceIdentityStore = { + loadOrCreate(): ResultAsync; + readLastProcessedHandshakeStatement(): ResultAsync; + writeLastProcessedHandshakeStatement(hex: string): ResultAsync; +}; + +export function createDeviceIdentityStore(salt: string, storage: StorageAdapter): DeviceIdentityStore { + const aes = () => gcm(blake2b(stringToBytes(salt), { dkLen: 16 }), blake2b(stringToBytes('nonce'), { dkLen: 32 })); + + const decode = (raw: string | null): Stored | null => { + if (!raw) return null; + try { + const decrypted = aes().decrypt(fromHex(raw)); + return StoredDeviceCodec.dec(decrypted); + } catch { + // 0.7.x had no DeviceIdentity key; any decode failure here means a + // schema rev or tampered blob — drop it and regenerate. + return null; + } + }; + + const encode = (stored: Stored): string => toHex(aes().encrypt(StoredDeviceCodec.enc(stored))); + + const read = (): ResultAsync => storage.read(KEY).map(decode); + const write = (stored: Stored): ResultAsync => storage.write(KEY, encode(stored)).map(() => undefined); + + const expand = (stored: Stored): DeviceIdentity => { + const statementAccountSecret = createSr25519Secret(stored.statementAccountSeed) as SsSecret; + const statementAccountPublicKey = deriveSr25519PublicKey(statementAccountSecret) as SsPublicKey; + const encryptionPrivateKey = stored.encryptionPrivateKey as EncrSecret; + const encryptionPublicKey = getEncrPub(encryptionPrivateKey) as EncrPublicKey; + return { statementAccountPublicKey, statementAccountSecret, encryptionPublicKey, encryptionPrivateKey }; + }; + + const generate = (): Stored => ({ + statementAccountSeed: crypto.getRandomValues(new Uint8Array(32)), + encryptionPrivateKey: crypto.getRandomValues(new Uint8Array(32)), + lastProcessedHandshakeStatement: undefined, + }); + + return { + loadOrCreate() { + return read().andThen(existing => { + if (existing) return okAsync(expand(existing)); + const fresh = generate(); + return write(fresh).map(() => expand(fresh)); + }); + }, + readLastProcessedHandshakeStatement() { + return read().map(stored => stored?.lastProcessedHandshakeStatement ?? null); + }, + writeLastProcessedHandshakeStatement(hex: string) { + return read().andThen(existing => { + if (!existing) { + // No identity yet — caller will populate via loadOrCreate first. + return errAsync(new Error('writeLastProcessedHandshakeStatement: no device identity persisted')); + } + return write({ ...existing, lastProcessedHandshakeStatement: hex }); + }); + }, + }; +} + +// Re-export the awaitable form for convenience, since most call sites already +// live in async functions. +export const awaitDeviceIdentity = (store: DeviceIdentityStore): Promise => + fromPromise(Promise.resolve(), toError) + .andThen(() => store.loadOrCreate()) + .match( + ok => ok, + err => { + throw err; + }, + ); diff --git a/packages/host-papp/src/sso/sessionManager/userSession.ts b/packages/host-papp/src/sso/sessionManager/userSession.ts index 19879d94..3156fb2d 100644 --- a/packages/host-papp/src/sso/sessionManager/userSession.ts +++ b/packages/host-papp/src/sso/sessionManager/userSession.ts @@ -138,10 +138,7 @@ export function createUserSession({ }); return { - id: userSession.id, - localAccount: userSession.localAccount, - remoteAccount: userSession.remoteAccount, - rootAccountId: userSession.rootAccountId, + ...userSession, signPayload(payload) { return requestQueue.call(() => { diff --git a/packages/host-papp/src/sso/userSecretRepository.ts b/packages/host-papp/src/sso/userSecretRepository.ts index 029b71c8..8ca6bf7c 100644 --- a/packages/host-papp/src/sso/userSecretRepository.ts +++ b/packages/host-papp/src/sso/userSecretRepository.ts @@ -12,10 +12,14 @@ import { BrandedBytesCodec, stringToBytes } from '../crypto.js'; import { toError } from '../helpers/utils.js'; type StoredUserSecrets = CodecType; + const StoredUserSecretsCodec = Struct({ ssSecret: BrandedBytesCodec(), encrSecret: BrandedBytesCodec(), entropy: Bytes(), + // V2 addition: user identity chat private key (P-256 raw scalar, 32 bytes). + // Sensitive — kept encrypted at rest alongside the per-session ss/encr secrets. + identityChatPrivateKey: Bytes(32), }); export type UserSecretRepository = ReturnType; @@ -24,10 +28,17 @@ export function createUserSecretRepository(salt: string, storage: StorageAdapter const baseKey = 'UserSecrets'; const encode = fromThrowable(StoredUserSecretsCodec.enc, toError); - const decode = fromThrowable( - (value: Uint8Array | null) => (value ? StoredUserSecretsCodec.dec(value) : null), - toError, - ); + const decode = fromThrowable((value: Uint8Array | null) => { + if (!value) return null; + try { + return StoredUserSecretsCodec.dec(value); + } catch { + // 0.7.x V1 blobs are missing `identityChatPrivateKey` and decode short. + // Treat as absent — a fresh handshake (required after 0.8.0 upgrade) + // will overwrite. + return null; + } + }, toError); const encrypt = fromThrowable((value: Uint8Array) => { const aes = getAes(salt); diff --git a/packages/host-papp/src/sso/userSessionRepository.ts b/packages/host-papp/src/sso/userSessionRepository.ts index b5f089b4..ed318124 100644 --- a/packages/host-papp/src/sso/userSessionRepository.ts +++ b/packages/host-papp/src/sso/userSessionRepository.ts @@ -5,29 +5,41 @@ import { fieldListView } from '@novasamatech/storage-adapter'; import { nanoid } from 'nanoid'; import { fromHex, toHex } from 'polkadot-api/utils'; import type { CodecType } from 'scale-ts'; -import { Struct, Vector, str } from 'scale-ts'; +import { Bytes, Option, Struct, Vector, str } from 'scale-ts'; export type UserSessionRepository = ReturnType; export type StoredUserSession = CodecType; +// V2 fields trail V1 fields so a future schema rev can append further +// `Option`-wrapped fields without breaking decode of 0.8.0 blobs. const storedUserSessionCodec = Struct({ id: str, localAccount: LocalSessionAccountCodec, remoteAccount: RemoteSessionAccountCodec, rootAccountId: AccountIdCodec, + identityAccountId: Option(AccountIdCodec), + identityChatPublicKey: Option(Bytes(65)), }); +type StoredUserSessionV2Extras = { + identityAccountId?: AccountId; + identityChatPublicKey?: Uint8Array; +}; + export function createStoredUserSession( localAccount: LocalSessionAccount, remoteAccount: RemoteSessionAccount, rootAccountId: AccountId, + extras: StoredUserSessionV2Extras = {}, ): StoredUserSession { return { id: nanoid(12), - localAccount: localAccount, - remoteAccount: remoteAccount, + localAccount, + remoteAccount, rootAccountId, + identityAccountId: extras.identityAccountId, + identityChatPublicKey: extras.identityChatPublicKey, }; } @@ -37,7 +49,17 @@ export const createUserSessionRepository = (storage: StorageAdapter) => { return fieldListView({ storage, key: 'SsoSessions', - from: x => codec.dec(fromHex(x)), + from: x => { + try { + return codec.dec(fromHex(x)); + } catch { + // 0.7.x V1 blobs use the prior codec shape and won't decode against + // V2's extended struct. Treat as empty so the caller (and the + // fieldListView mutate machinery) start clean; the next write + // overwrites the bad blob. + return []; + } + }, to: x => toHex(codec.enc(x)), }); }; From a3934685cc618b2330b2e500ff174462c25967fd Mon Sep 17 00:00:00 2001 From: Ilya Kalinin Date: Fri, 22 May 2026 06:36:27 -0600 Subject: [PATCH 13/31] feat: multi-device SSO V2 + chat V2 (#178) --- CHANGELOG.md | 29 ++ package-lock.json | 11 +- packages/host-chat/src/accountService.ts | 19 +- packages/host-chat/src/codec/message.ts | 54 ++- .../host-papp-react-ui/src/Flow.stories.tsx | 6 +- .../src/hooks/authStatus.ts | 2 +- packages/host-papp/README.md | 171 ++++++- packages/host-papp/__tests__/auth.spec.ts | 441 ++++++++--------- .../__tests__/handshakeV2Codec.spec.ts | 293 ++++++++++++ .../__tests__/handshakeV2Envelope.spec.ts | 57 +++ .../__tests__/handshakeV2Proposal.spec.ts | 73 +++ .../__tests__/handshakeV2Service.spec.ts | 187 ++++++++ .../__tests__/handshakeV2State.spec.ts | 142 ++++++ .../__tests__/handshakeV2Topic.spec.ts | 50 ++ packages/host-papp/package.json | 5 +- packages/host-papp/src/debugTypes.ts | 6 +- packages/host-papp/src/identity/rpcAdapter.ts | 20 +- packages/host-papp/src/index.ts | 4 +- packages/host-papp/src/papp.ts | 49 +- packages/host-papp/src/sso/auth/impl.ts | 447 +++++++----------- .../host-papp/src/sso/auth/scale/handshake.ts | 27 -- .../src/sso/auth/scale/handshakeV2.ts | 211 +++++++++ .../host-papp/src/sso/auth/v2/envelope.ts | 34 ++ .../host-papp/src/sso/auth/v2/proposal.ts | 76 +++ packages/host-papp/src/sso/auth/v2/service.ts | 248 ++++++++++ packages/host-papp/src/sso/auth/v2/state.ts | 123 +++++ packages/host-papp/src/sso/auth/v2/topic.ts | 27 ++ .../host-papp/src/sso/deviceIdentityStore.ts | 109 +++++ .../src/sso/sessionManager/userSession.ts | 5 +- .../host-papp/src/sso/userSecretRepository.ts | 19 +- .../src/sso/userSessionRepository.ts | 30 +- 31 files changed, 2388 insertions(+), 587 deletions(-) create mode 100644 packages/host-papp/__tests__/handshakeV2Codec.spec.ts create mode 100644 packages/host-papp/__tests__/handshakeV2Envelope.spec.ts create mode 100644 packages/host-papp/__tests__/handshakeV2Proposal.spec.ts create mode 100644 packages/host-papp/__tests__/handshakeV2Service.spec.ts create mode 100644 packages/host-papp/__tests__/handshakeV2State.spec.ts create mode 100644 packages/host-papp/__tests__/handshakeV2Topic.spec.ts delete mode 100644 packages/host-papp/src/sso/auth/scale/handshake.ts create mode 100644 packages/host-papp/src/sso/auth/scale/handshakeV2.ts create mode 100644 packages/host-papp/src/sso/auth/v2/envelope.ts create mode 100644 packages/host-papp/src/sso/auth/v2/proposal.ts create mode 100644 packages/host-papp/src/sso/auth/v2/service.ts create mode 100644 packages/host-papp/src/sso/auth/v2/state.ts create mode 100644 packages/host-papp/src/sso/auth/v2/topic.ts create mode 100644 packages/host-papp/src/sso/deviceIdentityStore.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 75155343..2f230d38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,32 @@ +## 0.8.0 (2026-05-21) + +### 🚀 Features + +- **host-papp:** multi-device SSO. `createAuth` / `pappAdapter.sso` keeps the V1 `pairingStatus` / `authenticate()` / `abortAuthentication()` surface — same state transitions, `authenticate()` still resolves to a `StoredUserSession | null`, `pairingStatus.finished` still carries `session`. What's new is the protocol underneath: pairing now runs the V2 multi-device handshake (`VersionedHandshakeProposal::V2` QR proposal, ECDH-encrypted Statement Store response envelope, on-chain `Pending(AllowanceAllocation)` flow). Desktop and web hosts pair with the multi-device iOS/Android Polkadot Mobile builds through the same entry point you've been calling. The V2 codecs, pairing service, and state machine are SDK internals. +- **host-papp:** the SDK persists the V2 device identity itself. `createPappAdapter` creates and persists a fresh device identity to the configured `StorageAdapter` on first run and reuses it on subsequent launches — no consumer wiring needed. Hosts that want a different persistence backend (Electron Keychain, native secure storage) can override with an optional `deviceIdentity` factory. +- **host-papp:** the SDK persists pairing-topic statement dedupe state internally too, so stale `Success` statements on chain don't get replayed across launches without consumer plumbing. +- **host-papp:** `StoredUserSession` gains three optional V2 fields — `identityAccountId` (user identity sr25519 account), `identityChatPublicKey` (P-256 65-byte, derived locally from `identityChatPrivateKey`), and the peer device statement account exposed via `remoteAccount.accountId` — so device-sync and the chat layer can read peer state straight off the session. The matching `identityChatPrivateKey` is persisted into `UserSecretRepository` and passed to the optional `onAuthSuccess` hook for consumers that need it. +- **host-papp:** new optional `onAuthSuccess` hook on `createPappAdapter` for consumer-specific post-pairing work (telemetry, custom peer caches, device-sync seeding). Fires after the SDK has written the session + secrets to its own repositories. Receives `{ session: StoredUserSession, identityChatPrivateKey: Uint8Array }`. +- **host-chat:** new `MessageContent` variants at the spec'd indices — `chatAccepted` (14) now carries `{ messageId }` for iOS V1 backward decode; `deviceAdded` (17) `{ statementAccountId, encryptionPublicKey }`; `deviceRemoved` (18) `{ statementAccountId }`; `deviceChatAccepted` (20) `{ requestId, device }` sent on the identity-level session `SessionId(B, A)` encrypted with `K(A, B)` so all of a peer's devices can decrypt without a per-device envelope. + +### 🩹 Fixes + +- **host-papp / host-chat:** `identity/rpcAdapter` and `accountService.getConsumerInfo` tolerate both camelCase and snake_case `Resources.Consumers` metadata fields — the V2 multi-device runtime metadata emits camelCase, which previously crashed `raw.stmt_store_slots.map(...)`. +- **host-chat:** `DeviceAdded` / `DeviceRemoved` use length-prefixed `Bytes()` rather than fixed-size codecs, matching `substrate-sdk-android`'s wire shape for `AccountId` / `EncodedPublicKey` wrapper types (no `@FixedLength`). + +### ⚠️ Breaking Changes + +The migration is essentially two field renames; the auth surface is otherwise unchanged. + +- **host-papp:** `createPappAdapter` no longer accepts `metadata: string` (the V1 metadata URL) — host name / icon / platform now ride inside `hostMetadata` (sent inline with the V2 QR proposal). +- **host-papp:** `HostMetadata` reshape — was `{ hostVersion?, osType?, osVersion? }`, now `{ hostName?, hostVersion?, hostIcon?, platformType?, platformVersion?, custom? }`. Map `osType → platformType` and `osVersion → platformVersion` when upgrading. +- **host-papp:** the V1 SSO handshake is gone. Paired clients on the V1 wire format will not pair against this build — both ends must run the multi-device V2 handshake. (Polkadot Mobile builds with multi-device support are V2.) Persisted V1 SSO sessions don't migrate; they decode short against the extended V2 codec and are wiped on first read, so users need to re-pair. +- **host-chat:** `MessageContent.chatAccepted` (14) payload changed from `_void` to `{ messageId: string }`. Older clients that emit `_void` will not decode. + +### ❤️ Thank You + +- Ilya Kalinin @kalininilya + ## 0.7.9 (2026-05-15) ### 🚀 Features diff --git a/package-lock.json b/package-lock.json index 9cc5d634..6753d4b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16349,6 +16349,12 @@ "spdx-expression-parse": "^3.0.0" } }, + "node_modules/verifiablejs": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/verifiablejs/-/verifiablejs-1.2.0.tgz", + "integrity": "sha512-+VVQo9yZko+w3THKaYvLbjXdfaiw1WJnX12wJEU5jHsHrMkjDrAMWoKYcBvtlHXufJirr8FlT6v2i/0yA3/ivQ==", + "license": "GPL-3.0-or-later WITH Classpath-exception-2.0" + }, "node_modules/vite": { "version": "7.3.2", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", @@ -17194,12 +17200,15 @@ "@novasamatech/scale": "0.7.9", "@novasamatech/statement-store": "0.7.9", "@novasamatech/storage-adapter": "0.7.9", + "@polkadot-api/utils": "^0.4.0", "@polkadot-labs/hdkd-helpers": "^0.0.30", "nanoevents": "9.1.0", "nanoid": "5.1.9", "neverthrow": "^8.2.0", "polkadot-api": ">=2", - "scale-ts": "1.6.1" + "rxjs": "^7.8.2", + "scale-ts": "1.6.1", + "verifiablejs": "1.2.0" } }, "packages/host-papp-react-ui": { diff --git a/packages/host-chat/src/accountService.ts b/packages/host-chat/src/accountService.ts index d0d4a9a4..c9c4c66a 100644 --- a/packages/host-chat/src/accountService.ts +++ b/packages/host-chat/src/accountService.ts @@ -93,8 +93,16 @@ export const createAccountService = (network: Network, lazyClient: LazyClient): const consumerInfo = fromPromise(api.query.Resources?.Consumers?.getValue(address), toError); - return consumerInfo.map(raw => { - if (!raw) return null; + return consumerInfo.map(typedRaw => { + if (!typedRaw) return null; + + // Runtime metadata may expose fields in snake_case (V1) or + // camelCase (V2 multi-device). Read defensively. + const raw = typedRaw as unknown as Record & typeof typedRaw; + const fullUsername = + (raw.full_username as Uint8Array | undefined) ?? (raw.fullUsername as Uint8Array | undefined); + const liteUsername = + (raw.lite_username as Uint8Array | undefined) ?? (raw.liteUsername as Uint8Array | undefined); const credibility: Credibility = raw.credibility.type === 'Lite' @@ -104,13 +112,14 @@ export const createAccountService = (network: Network, lazyClient: LazyClient): : { type: 'Person', alias: raw.credibility.value.alias as HexString, - lastUpdate: raw.credibility.value.last_update.toString(), + lastUpdate: ((raw.credibility.value as Record).last_update ?? + (raw.credibility.value as Record).lastUpdate)!.toString(), }; return { accountId: toHex(accountId.enc(address)), - fullUsername: raw.full_username ? textDecoder.decode(raw.full_username) : null, - liteUsername: textDecoder.decode(raw.lite_username), + fullUsername: fullUsername ? textDecoder.decode(fullUsername) : null, + liteUsername: liteUsername ? textDecoder.decode(liteUsername) : '', credibility: credibility, }; }); diff --git a/packages/host-chat/src/codec/message.ts b/packages/host-chat/src/codec/message.ts index 8e61c0d8..72ebe7f1 100644 --- a/packages/host-chat/src/codec/message.ts +++ b/packages/host-chat/src/codec/message.ts @@ -1,8 +1,11 @@ import { Enum, Hex, Nullable, Status } from '@novasamatech/scale'; -import { Option, Struct, Vector, _void, compact, str, u64 } from 'scale-ts'; +import { Bytes, Option, Struct, Vector, _void, compact, str, u64 } from 'scale-ts'; import { FileVariant } from './attachment.js'; +const AccountIdCodec = Bytes(32); +const PublicKeyCodec = Bytes(65); + export const TextContent = str; export const RichTextContent = Struct({ @@ -39,6 +42,48 @@ export const EditContent = Struct({ newContent: RichTextContent, }); +// V2 multi-device roster mutations carried as chat-content variants. +// Android's `ChatMessageStatementContent.DeviceAdded`/`DeviceRemoved` use +// the `AccountId` / `EncodedPublicKey` wrapper types without `@FixedLength`, +// so substrate-sdk-android falls through to length-prefixed `Vec` on the +// wire. We accept that shape here (`Bytes()` = compact-length-prefixed bytes) +// instead of fixed-size to stay tolerant of Android's emitter. Other variants +// that use `@FixedLength` on raw `ByteArray` (e.g. `DeviceInfoContent` below) +// stay fixed-size and continue to use AccountIdCodec/PublicKeyCodec. +export const DeviceAddedContent = Struct({ + statementAccountId: Bytes(), + encryptionPublicKey: Bytes(), +}); + +export const DeviceRemovedContent = Struct({ + statementAccountId: Bytes(), +}); + +// Legacy single-device accept (index 14, iOS V1). Wire: bare `String`. +// Superseded by `deviceChatAccepted` (index 20); keep for backward decode. +export const ChatAcceptedContent = Struct({ + messageId: str, +}); + +// Per-device descriptor for the multi-device accept. Matches iOS `Chat.PeerDevice` +// and Android `DeviceInfoScale`. +export const DeviceInfoContent = Struct({ + statementAccountId: AccountIdCodec, + encryptionPublicKey: PublicKeyCodec, +}); + +// Multi-device accept (index 20, per chat spec v0.1 +// https://hackmd.io/@1JCaGppGSUqHtJilikYaKw/Ski9naYdWe): +// DeviceChatAccepted = { requestId: String, device: DeviceInfo } +// Sent via identity-level session SessionId(B, A) encrypted with K(A,B); +// identity-level encryption lets all of A's devices decrypt without per-device +// envelope. Index 19 is reserved (placeholder slot between deviceRemoved=18 +// and deviceChatAccepted=20). +export const DeviceChatAcceptedContent = Struct({ + requestId: str, + device: DeviceInfoContent, +}); + // Note: enum indices MUST match iOS/Android SCALE codecs. // Indices are auto-assigned sequentially, so order matters. export const MessageContent = Enum({ @@ -56,8 +101,13 @@ export const MessageContent = Enum({ _reserved11: _void, // 11 — reserved (unused) edit: EditContent, // 12 leftChat: _void, // 13 - chatAccepted: _void, // 14 + chatAccepted: ChatAcceptedContent, // 14 (legacy single-device accept, iOS V1) richText: RichTextContent, // 15 + _reserved16: _void, // 16 — reserved (android `coinagePayment`, unused on desktop) + deviceAdded: DeviceAddedContent, // 17 + deviceRemoved: DeviceRemovedContent, // 18 + _reserved19: _void, // 19 — reserved (placeholder so deviceChatAccepted lands on the spec'd index 20) + deviceChatAccepted: DeviceChatAcceptedContent, // 20 (multi-device accept, spec v0.1) }); export const VersionedMessageContent = Enum({ diff --git a/packages/host-papp-react-ui/src/Flow.stories.tsx b/packages/host-papp-react-ui/src/Flow.stories.tsx index e478d532..5c91f07e 100644 --- a/packages/host-papp-react-ui/src/Flow.stories.tsx +++ b/packages/host-papp-react-ui/src/Flow.stories.tsx @@ -86,14 +86,14 @@ const meta: Meta = { args: { adapter: createPappAdapter({ appId: 'https://test.com', - metadata: 'https://shorturl.at/zGkir', adapters: { lazyClient: createLazyClient(getWsProvider(SS_PASEO_STABLE_STAGE_ENDPOINTS)), }, hostMetadata: { + hostName: 'Storybook', hostVersion: '1.2.3', - osType: 'macOS', - osVersion: '14.4.1', + platformType: 'macOS', + platformVersion: '14.4.1', } satisfies HostMetadata, }), }, diff --git a/packages/host-papp-react-ui/src/hooks/authStatus.ts b/packages/host-papp-react-ui/src/hooks/authStatus.ts index 209a2d10..6e9598b9 100644 --- a/packages/host-papp-react-ui/src/hooks/authStatus.ts +++ b/packages/host-papp-react-ui/src/hooks/authStatus.ts @@ -10,7 +10,7 @@ export const useAuthStatus = () => { return pairingStatus.session; } return null; - }, [pairingStatus.step]); + }, [pairingStatus]); return { status: pairingStatus, diff --git a/packages/host-papp/README.md b/packages/host-papp/README.md index b6b9e9d8..1e90b0d5 100644 --- a/packages/host-papp/README.md +++ b/packages/host-papp/README.md @@ -60,7 +60,11 @@ const papp = createPappAdapter({ Custom adapters (statement store, identity RPC, storage, lazy chain client) can be supplied via the `adapters` option for testing or non-browser environments. -## Authentication and pairing +## Authentication and pairing (V1) + +The V1 SSO flow described in this section is the single-device pairing protocol used by +`papp.sso.authenticate()`. For the multi-device V2 protocol see +[V2 SSO handshake](#v2-sso-handshake) below. `papp.sso.authenticate()` runs the full pairing + attestation flow and resolves with the 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) => { // Batch lookup await papp.identity.getIdentities([accountIdA, accountIdB]); ``` + +## V2 SSO handshake + +V2 is a redesign of the SSO pairing flow that supports the same user identity across +multiple devices. The host generates a stable device keypair locally, emits a +`VersionedHandshakeProposal::V2` via QR/deeplink, and an authorising peer (e.g. the user's +existing Polkadot App) responds over the Statement Store with the user identity keys signed +to authorise this device. Subsequent devices belonging to the same user reuse the same +identity, so contacts, chats, and roster events are shared between them. + +V2 is **not interoperable with V1**: a V1-only peer can't decode a V2 proposal QR and vice +versa. Hosts that want to support both should branch on which protocol the peer advertises. + +### Shape of the flow + +``` +host peer (authorising device) +──────────────────────────────────────────────────────────────────────── +buildPairingDeeplink(device, metadata) + → polkadotapp://pair?handshake= + scan QR, decode proposal + compute pairing topic from + the host's pubkeys + ECDH-encrypt + post: + Pending(AllowanceAllocation) + Success { encryptionKey, + accountId, + identitySignature } + Failed(reason) +service.subscribeStatements(topic) + + poll the topic every 2s + ↓ +decode VersionedHandshakeResponse::V2 + → ECDH-decrypt envelope with the + device encryption private key + → SCALE-decode inner payload + → state machine: Submitted → Pending → + Success | Failed + → on Success persist user identity +``` + +The user identity carried in `Success` is the chat encryption pubkey + the user's identity +sr25519 accountId. The host verifies the 64-byte sr25519 `identitySignature` against the +canonical 97 bytes `statementAccountId || encryptionPublicKey` +(see `IDENTITY_SIGNATURE_PAYLOAD_BYTES`). + +### Building and rendering the QR + +```ts +import { buildPairingDeeplink } from '@novasamatech/host-papp'; + +const deeplink = buildPairingDeeplink( + { + statementAccountPublicKey: device.statementAccountPublicKey, // sr25519 device pubkey, 32 bytes + encryptionPublicKey: device.encryptionPublicKey, // P-256 device pubkey, 65 bytes uncompressed + }, + { + hostName: 'My Host App', + hostVersion: '1.0.0', + platformType: 'macOS', + platformVersion: '15.4', + }, +); + +renderQrCode(deeplink); // 'polkadotapp://pair?handshake=' +``` + +### Driving the handshake + +```ts +import { startPairingV2 } from '@novasamatech/host-papp'; + +const pairing = startPairingV2({ + statementStore: papp.adapters.statementStore, // any StatementStoreAdapter + deviceIdentity: { + statementAccountPublicKey: device.statementAccountPublicKey, + encryptionPublicKey: device.encryptionPublicKey, + encryptionPrivateKey: device.encryptionPrivateKey, // P-256 priv key, 32 bytes + }, + metadata: { + hostName: 'My Host App', + hostVersion: '1.0.0', + platformType: 'macOS', + platformVersion: '15.4', + }, + persistOnSuccess: async success => { + // success.identityChatPublicKey, success.userIdentityAccountId, + // success.identitySignature — persist in your secureStore. + }, +}); + +pairing.qrPayload; // 'polkadotapp://pair?handshake=' for the QR UI + +pairing.state$.subscribe(state => { + switch (state.tag) { + case 'Submitted': + // QR shown, waiting for the peer to scan + return; + case 'Pending': + // peer acknowledged; allocating Statement Store allowance on-chain + return; + case 'Success': + // identity received, device authorised + return; + case 'Failed': + // peer rejected (declined / duplicate / no-slot / tx-failed) + console.error(state.reason); + return; + } +}); + +// Cancel mid-flight (the Observable completes, polling stops, subscription closes): +pairing.abort(); +``` + +### Surviving reloads / proper logout + +The chain holds the most recent statement on the pairing topic indefinitely, so on cold +start the service will see the previous Success and replay it. To distinguish a stale +replay from a fresh re-pair, callers can pass byte-level dedupe state: + +```ts +const pairing = startPairingV2({ + // ... + initialProcessedDataHex: await secureStore.get('lastProcessedHandshakeStatement'), + onStatementProcessed: hex => { + void secureStore.set('lastProcessedHandshakeStatement', hex); + }, +}); +``` + +The service skips any incoming statement whose bytes match `initialProcessedDataHex`. PApp +re-encrypts every Success with a fresh ephemeral key + AES-GCM nonce, so a genuine re-pair +always produces different bytes and passes the dedupe. + +### Pairing topic / channel + +If the host needs to derive the pairing topic or channel itself (for example to subscribe +in-line, or to verify a statement source): + +```ts +import { computePairingTopic, computePairingChannel } from '@novasamatech/host-papp'; + +const topic = computePairingTopic(statementAccountId, encryptionPublicKey); +const channel = computePairingChannel(statementAccountId, encryptionPublicKey); +// topic = blake2b256_keyed(encryptionPublicKey || "topic", key=statementAccountId) +// channel = blake2b256_keyed(encryptionPublicKey || "channel", key=statementAccountId) +``` + +### Codec exports + +The SCALE codecs are exported as plain `Codec` values for callers that need to +encode/decode statements outside the orchestrator: + +| Export | Description | +| --------------------------------- | -------------------------------------------------------------------------------------------- | +| `VersionedHandshakeProposal` | Outer enum; V2 at SCALE discriminant 1, with `_v1Reserved` at 0. | +| `HandshakeProposalV2` | `{ device, metadata }` — what the QR encodes. | +| `Device` | `{ statementAccountId(32), encryptionPublicKey(65) }`. | +| `MetadataKey`, `MetadataEntry` | Metadata enum + `(MetadataKey, str)` tuple. | +| `VersionedHandshakeResponse` | Outer enum for the answer; `V1` legacy + `V2`. | +| `HandshakeResponseV2` | `{ encrypted, tmpKey(65) }` — the ECDH-wrapped envelope. | +| `EncryptedHandshakeResponseV2` | Inner payload after envelope decrypt: `Pending` (1 byte), `Success` (161 bytes), `Failed`. | +| `HandshakeSuccessV2` | `{ encryptionKey(65), accountId(32), identitySignature(64) }`. | +| `IDENTITY_SIGNATURE_PAYLOAD_BYTES`| `97` — the bytes the user identity sr25519 signs over. | diff --git a/packages/host-papp/__tests__/auth.spec.ts b/packages/host-papp/__tests__/auth.spec.ts index de5b2eba..58b4b269 100644 --- a/packages/host-papp/__tests__/auth.spec.ts +++ b/packages/host-papp/__tests__/auth.spec.ts @@ -1,139 +1,118 @@ +import { p256 } from '@noble/curves/nist.js'; import type { Statement, StatementStoreAdapter } from '@novasamatech/statement-store'; -import { ok, okAsync } from 'neverthrow'; +import { createEncryption } from '@novasamatech/statement-store'; +import { okAsync } from 'neverthrow'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { onHostPappDebugMessage } from '../src/debugBus.js'; import type { HostPappDebugEvent } from '../src/debugTypes.js'; import { createAuth } from '../src/sso/auth/impl.js'; +import { HandshakeSuccessV2, VersionedHandshakeResponse } from '../src/sso/auth/scale/handshakeV2.js'; +import type { DeviceIdentityForPairing } from '../src/sso/auth/v2/service.js'; +import type { DeviceIdentityStore } from '../src/sso/deviceIdentityStore.js'; import type { UserSecretRepository } from '../src/sso/userSecretRepository.js'; import type { UserSessionRepository } from '../src/sso/userSessionRepository.js'; -const mocks = vi.hoisted(() => ({ - decrypt: vi.fn(), - generateMnemonic: vi.fn(), - handshakeEnc: vi.fn(), - responsePayloadDec: vi.fn(), - responseSensitiveDec: vi.fn(), -})); - -vi.mock('@polkadot-labs/hdkd-helpers', async importOriginal => { - const actual = await importOriginal(); - return { - ...actual, - generateMnemonic: mocks.generateMnemonic, - }; -}); - -vi.mock('../src/crypto.js', async importOriginal => { - const actual = await importOriginal(); - return { - ...actual, - deriveSr25519Account: vi.fn(() => ({ - secret: new Uint8Array(64), - publicKey: new Uint8Array(32), - entropy: new Uint8Array(32), - sign: vi.fn(), - verify: vi.fn(() => true), - })), - createEncrSecret: vi.fn(() => new Uint8Array(32)), - getEncrPub: vi.fn(() => new Uint8Array(65)), - createSharedSecret: vi.fn(() => new Uint8Array(32)), - }; +const DEVICE_ENC_PRIV = new Uint8Array(32).fill(0x22); +const DEVICE_ENC_PUB = p256.getPublicKey(DEVICE_ENC_PRIV, false); +const DEVICE_STMT_ACCT = new Uint8Array(32).fill(0x33); +const DEVICE_STMT_SECRET = new Uint8Array(64).fill(0x55); + +const IDENTITY_CHAT_PRIV = new Uint8Array(32).fill(0xdd); +const IDENTITY_ACCT = new Uint8Array(32).fill(0xa1); +const ROOT_ACCT = new Uint8Array(32).fill(0xa2); +const PEER_STMT_ACCT_HEX = '0x' + '44'.repeat(32); + +const makeDeviceIdentity = (): DeviceIdentityForPairing => ({ + statementAccountPublicKey: DEVICE_STMT_ACCT, + statementAccountSecret: DEVICE_STMT_SECRET, + encryptionPublicKey: DEVICE_ENC_PUB, + encryptionPrivateKey: DEVICE_ENC_PRIV, }); -vi.mock('../src/sso/auth/scale/handshake.js', () => ({ - HandshakeData: { enc: mocks.handshakeEnc }, - HandshakeResponsePayload: { dec: mocks.responsePayloadDec }, - HandshakeResponseSensitiveData: { dec: mocks.responseSensitiveDec }, -})); +const stubDeviceIdentityStore = (): DeviceIdentityStore => + ({ + loadOrCreate: vi.fn(() => okAsync({ ...makeDeviceIdentity(), statementAccountSecret: DEVICE_STMT_SECRET })), + readLastProcessedHandshakeStatement: vi.fn(() => okAsync(null)), + writeLastProcessedHandshakeStatement: vi.fn(() => okAsync(undefined)), + }) as unknown as DeviceIdentityStore; + +const buildSuccessStatement = (): Statement => { + const inner = HandshakeSuccessV2.enc({ + identityAccountId: IDENTITY_ACCT, + rootAccountId: ROOT_ACCT, + identityChatPrivateKey: IDENTITY_CHAT_PRIV, + deviceEncPubKey: DEVICE_ENC_PUB, + }); + // The inner body is a length-dispatched Success (161-byte payload). Wrap it + // as the discriminated `EncryptedHandshakeResponseV2::Success` for the + // envelope. + const successEnvelope = new Uint8Array(inner.length + 1); + successEnvelope[0] = 1; // Success discriminant + successEnvelope.set(inner, 1); + + // ECDH-encrypt: peer (PApp) uses ephemeral tmpKey + device.encPub + const tmpPriv = new Uint8Array(32).fill(0x77); + const tmpPub = p256.getPublicKey(tmpPriv, false); + const shared = p256.getSharedSecret(tmpPriv, DEVICE_ENC_PUB).slice(1, 33); + const enc = createEncryption(shared as never); + const encrypted = enc.encrypt(successEnvelope)._unsafeUnwrap(); + + const statementData = VersionedHandshakeResponse.enc({ + tag: 'V2', + value: { encrypted, tmpKey: tmpPub }, + }); -vi.mock('@novasamatech/statement-store', async importOriginal => { - const actual = await importOriginal(); return { - ...actual, - createAccountId: vi.fn((bytes: Uint8Array) => bytes as never), - createLocalSessionAccount: vi.fn((accountId: unknown) => ({ accountId, kind: 'local' }) as never), - createRemoteSessionAccount: vi.fn( - (accountId: unknown, secret: unknown) => ({ accountId, secret, kind: 'remote' }) as never, - ), - createEncryption: vi.fn(() => ({ decrypt: mocks.decrypt })), - khash: vi.fn(() => new Uint8Array([42, 42, 42])), - }; -}); + data: statementData, + proof: { type: 'sr25519', value: { signature: '0x' + '00'.repeat(64), signer: PEER_STMT_ACCT_HEX } }, + } as Statement; +}; -vi.mock('../src/sso/userSessionRepository.js', async importOriginal => { - const actual = await importOriginal(); - return { - ...actual, - createStoredUserSession: vi.fn( - (localAccount: unknown, remoteAccount: unknown, identityAccountId: unknown) => - ({ - id: 'session-id-1', - localAccount, - remoteAccount, - identityAccountId, - }) as never, - ), - }; -}); +type Deliver = (page: { statements: Statement[]; isComplete: boolean }) => void; -type DeliverFn = (statements: Statement[]) => void; - -function buildHarness() { - let deliver: DeliverFn | null = null; +const buildHarness = (overrides: { onAuthSuccess?: () => Promise } = {}) => { + let deliver: Deliver | null = null; const unsubscribe = vi.fn(); - const subscribeStatements = vi.fn((_filter: unknown, onPage: (page: { statements: Statement[] }) => void) => { - deliver = (statements: Statement[]) => onPage({ statements }); + const subscribeStatements = vi.fn((_filter: unknown, onPage: Deliver) => { + deliver = onPage; return unsubscribe; }); + const queryStatements = vi.fn(() => okAsync([])); + + const statementStore = { subscribeStatements, queryStatements } as unknown as StatementStoreAdapter; - const statementStore = { subscribeStatements }; - const ssoSessionRepository = { add: vi.fn(() => okAsync(undefined)) }; - const userSecretRepository = { write: vi.fn(() => okAsync(undefined)) }; + const ssoSessionRepository = { add: vi.fn(() => okAsync(undefined)) } as unknown as UserSessionRepository; + const userSecretRepository = { write: vi.fn(() => okAsync(undefined)) } as unknown as UserSecretRepository; + const deviceIdentityStore = stubDeviceIdentityStore(); const auth = createAuth({ - metadata: 'test-metadata', - hostMetadata: { hostVersion: '1.0', osType: 'iOS', osVersion: '18' }, - statementStore: statementStore as unknown as StatementStoreAdapter, - ssoSessionRepository: ssoSessionRepository as unknown as UserSessionRepository, - userSecretRepository: userSecretRepository as unknown as UserSecretRepository, + hostMetadata: { hostName: 'Test Host' }, + deviceIdentity: makeDeviceIdentity, + deviceIdentityStore, + statementStore, + ssoSessionRepository, + userSecretRepository, + onAuthSuccess: overrides.onAuthSuccess, }); return { auth, - statementStore, - ssoSessionRepository, - userSecretRepository, subscribeStatements, + queryStatements, unsubscribe, - async waitForSubscription(times = 1) { - await vi.waitFor(() => expect(subscribeStatements).toHaveBeenCalledTimes(times)); - }, - deliverHandshake() { - if (!deliver) throw new Error('subscribeStatements not yet called'); - deliver([{ data: new Uint8Array([0xde, 0xad]) } as Statement]); + ssoSessionRepository, + userSecretRepository, + deviceIdentityStore, + async waitForSubscription() { + await vi.waitFor(() => expect(subscribeStatements).toHaveBeenCalledTimes(1)); }, - deliverPage(statements: Statement[]) { + deliver(statements: Statement[]) { if (!deliver) throw new Error('subscribeStatements not yet called'); - deliver(statements); + deliver({ statements, isComplete: true }); }, }; -} - -beforeEach(() => { - mocks.decrypt.mockReset().mockReturnValue(ok(new Uint8Array([7, 7, 7]))); - mocks.generateMnemonic.mockReset().mockReturnValue('test mnemonic'); - mocks.handshakeEnc.mockReset().mockReturnValue(new Uint8Array([0xab, 0xcd])); - mocks.responsePayloadDec.mockReset().mockReturnValue({ - tag: 'v1', - value: { encrypted: new Uint8Array([1, 2]), tmpKey: new Uint8Array(65) }, - }); - mocks.responseSensitiveDec.mockReset().mockReturnValue({ - sharedSecretDerivationKey: new Uint8Array(65), - rootUserAccountId: new Uint8Array(32), - identityAccountId: new Uint8Array(32), - }); -}); +}; describe('createAuth', () => { describe('initial state', () => { @@ -146,193 +125,137 @@ describe('createAuth', () => { describe('authenticate (success path)', () => { it('returns the same in-flight ResultAsync on concurrent calls', () => { const { auth } = buildHarness(); - const first = auth.authenticate(); const second = auth.authenticate(); - expect(second).toBe(first); }); - it('resolves with stored session and persists secrets and session', async () => { + it('persists the session and resolves with a StoredUserSession on Success', async () => { const harness = buildHarness(); - const { auth, ssoSessionRepository, userSecretRepository } = harness; - - const promise = auth.authenticate(); + const promise = harness.auth.authenticate(); await harness.waitForSubscription(); - harness.deliverHandshake(); + harness.deliver([buildSuccessStatement()]); const result = await promise; - expect(result.isOk()).toBe(true); - expect(result._unsafeUnwrap()).toMatchObject({ id: 'session-id-1' }); - expect(userSecretRepository.write).toHaveBeenCalledWith( - 'session-id-1', - expect.objectContaining({ - ssSecret: expect.any(Uint8Array), - encrSecret: expect.any(Uint8Array), - entropy: expect.any(Uint8Array), - }), - ); - expect(ssoSessionRepository.add).toHaveBeenCalledWith(expect.objectContaining({ id: 'session-id-1' })); + const session = result._unsafeUnwrap(); + expect(session).not.toBeNull(); + expect(session!.identityAccountId).toEqual(IDENTITY_ACCT); + expect(session!.remoteAccount.accountId).toEqual(new Uint8Array(32).fill(0x44)); + expect(harness.ssoSessionRepository.add).toHaveBeenCalledOnce(); + expect(harness.userSecretRepository.write).toHaveBeenCalledOnce(); + const secretsCall = (harness.userSecretRepository.write as ReturnType).mock.calls[0]; + expect(secretsCall?.[1]).toMatchObject({ identityChatPrivateKey: IDENTITY_CHAT_PRIV }); }); - it('caches the resolved result so subsequent calls return the same ResultAsync', async () => { + it('emits pairingStatus transitions: none -> initial -> pairing(deeplink) -> finished(session)', async () => { const harness = buildHarness(); - const { auth } = harness; + const observed: { step: string; payload?: string; session?: { id: string } }[] = []; + harness.auth.pairingStatus.subscribe(s => observed.push(s as never)); - const promise = auth.authenticate(); + const promise = harness.auth.authenticate(); await harness.waitForSubscription(); - harness.deliverHandshake(); - await promise; - - const second = auth.authenticate(); - expect(second).toBe(promise); - expect(mocks.generateMnemonic).toHaveBeenCalledTimes(1); - }); - - it('emits pairingStatus transitions: none -> initial -> pairing(deeplink) -> finished', async () => { - const harness = buildHarness(); - const { auth } = harness; - const observed: Array<{ step: string; payload?: string }> = []; - auth.pairingStatus.subscribe(s => observed.push(s as never)); - - const promise = auth.authenticate(); - await harness.waitForSubscription(); - harness.deliverHandshake(); + harness.deliver([buildSuccessStatement()]); await promise; const steps = observed.map(s => s.step); expect(steps[0]).toBe('none'); expect(steps).toContain('initial'); const pairing = observed.find(s => s.step === 'pairing'); - expect(pairing?.payload).toBe('polkadotapp://pair?handshake=0xabcd'); - expect(steps.at(-1)).toBe('finished'); + expect(pairing?.payload).toMatch(/^polkadotapp:\/\/pair\?handshake=/); + const finished = observed.find(s => s.step === 'finished'); + expect(finished?.session).toBeDefined(); + expect(finished?.session?.id).toBeTypeOf('string'); }); - it('skips statements with no data and resolves on the first decryptable one', async () => { - const harness = buildHarness(); - const { auth, ssoSessionRepository } = harness; + it('runs the onAuthSuccess hook with session + identityChatPrivateKey after internal persistence', async () => { + const onAuthSuccess = vi.fn(() => Promise.resolve()); + const harness = buildHarness({ onAuthSuccess }); - const promise = auth.authenticate(); + const promise = harness.auth.authenticate(); await harness.waitForSubscription(); - harness.deliverPage([{ data: undefined } as Statement, { data: new Uint8Array([1, 2, 3]) } as Statement]); + harness.deliver([buildSuccessStatement()]); const result = await promise; expect(result.isOk()).toBe(true); - expect(ssoSessionRepository.add).toHaveBeenCalledTimes(1); + expect(onAuthSuccess).toHaveBeenCalledTimes(1); + const arg = ( + onAuthSuccess.mock.calls[0] as unknown as [{ session: { id: string }; identityChatPrivateKey: Uint8Array }] + )[0]; + expect(arg.session.id).toBeTypeOf('string'); + expect(arg.identityChatPrivateKey).toEqual(IDENTITY_CHAT_PRIV); }); - }); - describe('authenticate (error paths)', () => { - it('publishes pairingError when retrieving the session throws', async () => { - mocks.responsePayloadDec.mockImplementation(() => { - throw new Error('payload broken'); - }); - const harness = buildHarness(); - const { auth } = harness; + it('fails authenticate when onAuthSuccess throws', async () => { + const onAuthSuccess = vi.fn(() => Promise.reject(new Error('hook boom'))); + const harness = buildHarness({ onAuthSuccess }); - const promise = auth.authenticate(); + const promise = harness.auth.authenticate(); await harness.waitForSubscription(); - harness.deliverHandshake(); - const result = await promise; - - expect(result.isErr()).toBe(true); - expect(auth.pairingStatus.read()).toEqual({ - step: 'pairingError', - message: 'payload broken', - }); - }); - - it('publishes pairingError when payload encoding throws synchronously', async () => { - mocks.handshakeEnc.mockImplementation(() => { - throw new Error('encode broken'); - }); - const { auth } = buildHarness(); - - const result = await auth.authenticate(); + harness.deliver([buildSuccessStatement()]); + const result = await promise; expect(result.isErr()).toBe(true); - expect(auth.pairingStatus.read()).toEqual({ - step: 'pairingError', - message: 'encode broken', - }); + expect(result._unsafeUnwrapErr().message).toBe('hook boom'); + expect(harness.auth.pairingStatus.read()).toEqual({ step: 'pairingError', message: 'hook boom' }); }); + }); - it('does not persist secrets or session when handshake fails', async () => { - mocks.handshakeEnc.mockImplementation(() => { - throw new Error('encode broken'); - }); + describe('authenticate (error paths)', () => { + it('publishes pairingError on a Failed inner response', async () => { const harness = buildHarness(); - const { auth, userSecretRepository, ssoSessionRepository } = harness; + const failedStatement: Statement = { + data: VersionedHandshakeResponse.enc({ + tag: 'V2', + value: (() => { + const enc = createEncryption( + p256.getSharedSecret(new Uint8Array(32).fill(0x66), DEVICE_ENC_PUB).slice(1, 33) as never, + ); + // Failed body = enum index 2 + SCALE-compact length (8 << 2 = 0x20) + "declined" + const failedPayload = new Uint8Array([2, 0x20, 0x64, 0x65, 0x63, 0x6c, 0x69, 0x6e, 0x65, 0x64]); + return { + encrypted: enc.encrypt(failedPayload)._unsafeUnwrap(), + tmpKey: p256.getPublicKey(new Uint8Array(32).fill(0x66), false), + }; + })(), + }), + proof: { type: 'sr25519', value: { signature: '0x' + '00'.repeat(64), signer: PEER_STMT_ACCT_HEX } }, + } as Statement; - await auth.authenticate(); + const promise = harness.auth.authenticate(); + await harness.waitForSubscription(); + harness.deliver([failedStatement]); - expect(userSecretRepository.write).not.toHaveBeenCalled(); - expect(ssoSessionRepository.add).not.toHaveBeenCalled(); + const result = await promise; + expect(result.isErr()).toBe(true); + expect(harness.auth.pairingStatus.read()).toEqual({ step: 'pairingError', message: 'declined' }); }); }); describe('abortAuthentication', () => { - it('resolves the in-flight authenticate with ok(null)', async () => { + it('resolves the in-flight authenticate with ok(null) and resets status', async () => { const harness = buildHarness(); - const { auth } = harness; - - const promise = auth.authenticate(); + const promise = harness.auth.authenticate(); await harness.waitForSubscription(); - auth.abortAuthentication(); - // a page must arrive for the subscribe callback to observe the aborted signal - harness.deliverPage([]); + harness.auth.abortAuthentication(); const result = await promise; expect(result.isOk()).toBe(true); expect(result._unsafeUnwrap()).toBeNull(); - }); - - it('resets pairing status', async () => { - const harness = buildHarness(); - const { auth } = harness; - - const promise = auth.authenticate(); - await harness.waitForSubscription(); - auth.abortAuthentication(); - harness.deliverPage([]); - await promise; - - expect(auth.pairingStatus.read()).toEqual({ step: 'none' }); - }); - - it('does not transition pairingStatus to error state on user abort', async () => { - const harness = buildHarness(); - const { auth } = harness; - const pairing: Array<{ step: string }> = []; - auth.pairingStatus.subscribe(s => pairing.push(s as never)); - - const promise = auth.authenticate(); - await harness.waitForSubscription(); - auth.abortAuthentication(); - harness.deliverPage([]); - await promise; - - expect(pairing.some(s => s.step === 'pairingError')).toBe(false); + expect(harness.auth.pairingStatus.read()).toEqual({ step: 'none' }); }); it('clears the cached result so the next call starts a fresh attempt', async () => { const harness = buildHarness(); - const { auth } = harness; - - const first = auth.authenticate(); + const first = harness.auth.authenticate(); await harness.waitForSubscription(); - auth.abortAuthentication(); - harness.deliverPage([]); + harness.auth.abortAuthentication(); await first; - const second = auth.authenticate(); + const second = harness.auth.authenticate(); expect(second).not.toBe(first); - expect(mocks.generateMnemonic).toHaveBeenCalledTimes(2); - - auth.abortAuthentication(); - await harness.waitForSubscription(2); - harness.deliverPage([]); + await vi.waitFor(() => expect(harness.subscribeStatements).toHaveBeenCalledTimes(2)); + harness.auth.abortAuthentication(); await second; }); @@ -343,6 +266,35 @@ describe('createAuth', () => { }); }); + describe('default deviceIdentity', () => { + it('falls back to deviceIdentityStore.loadOrCreate when no deviceIdentity factory is provided', async () => { + let deliver: Deliver | null = null; + const unsubscribe = vi.fn(); + const subscribeStatements = vi.fn((_filter: unknown, onPage: Deliver) => { + deliver = onPage; + return unsubscribe; + }); + const queryStatements = vi.fn(() => okAsync([])); + const statementStore = { subscribeStatements, queryStatements } as unknown as StatementStoreAdapter; + const ssoSessionRepository = { add: vi.fn(() => okAsync(undefined)) } as unknown as UserSessionRepository; + const userSecretRepository = { write: vi.fn(() => okAsync(undefined)) } as unknown as UserSecretRepository; + const deviceIdentityStore = stubDeviceIdentityStore(); + + const auth = createAuth({ + deviceIdentityStore, + statementStore, + ssoSessionRepository, + userSecretRepository, + }); + + const promise = auth.authenticate(); + await vi.waitFor(() => expect(subscribeStatements).toHaveBeenCalledTimes(1)); + expect(deviceIdentityStore.loadOrCreate).toHaveBeenCalledOnce(); + if (deliver) (deliver as Deliver)({ statements: [buildSuccessStatement()], isComplete: true }); + await promise; + }); + }); + describe('debug emits', () => { function captureEvents() { const events: HostPappDebugEvent[] = []; @@ -350,14 +302,13 @@ describe('createAuth', () => { return { events, unsubscribe }; } - it('emits pairing_started and attestation.started eagerly when authenticate() is called', () => { + it('emits pairing_started eagerly when authenticate() is called', async () => { const { auth } = buildHarness(); const { events, unsubscribe } = captureEvents(); try { void auth.authenticate(); - - expect(events.find(e => e.layer === 'sso' && e.event === 'pairing_started')).toMatchObject({ - payload: { metadata: 'test-metadata' }, + await vi.waitFor(() => { + expect(events.find(e => e.layer === 'sso' && e.event === 'pairing_started')).toBeTruthy(); }); } finally { auth.abortAuthentication(); @@ -365,13 +316,13 @@ describe('createAuth', () => { } }); - it('emits the full SSO pairing sequence and attestation.completed on a successful authenticate', async () => { + it('emits the full SSO pairing sequence on a successful authenticate', async () => { const harness = buildHarness(); const { events, unsubscribe } = captureEvents(); try { const promise = harness.auth.authenticate(); await harness.waitForSubscription(); - harness.deliverHandshake(); + harness.deliver([buildSuccessStatement()]); const result = await promise; expect(result.isOk()).toBe(true); @@ -388,23 +339,25 @@ describe('createAuth', () => { } }); - it('does not emit pairing_failed or attestation.failed when authentication is aborted by the user', async () => { + it('does not emit pairing_failed when authentication is aborted by the user', async () => { const harness = buildHarness(); const { events, unsubscribe } = captureEvents(); try { const promise = harness.auth.authenticate(); await harness.waitForSubscription(); harness.auth.abortAuthentication(); - harness.deliverPage([]); const result = await promise; expect(result.isOk()).toBe(true); expect(result._unsafeUnwrap()).toBeNull(); expect(events.some(e => e.layer === 'sso' && e.event === 'pairing_failed')).toBe(false); - expect(events.some(e => e.layer === 'attestation' && e.event === 'failed')).toBe(false); } finally { unsubscribe(); } }); }); }); + +beforeEach(() => { + vi.useRealTimers(); +}); diff --git a/packages/host-papp/__tests__/handshakeV2Codec.spec.ts b/packages/host-papp/__tests__/handshakeV2Codec.spec.ts new file mode 100644 index 00000000..0f2aa191 --- /dev/null +++ b/packages/host-papp/__tests__/handshakeV2Codec.spec.ts @@ -0,0 +1,293 @@ +import { p256 } from '@noble/curves/nist.js'; +import { str } from 'scale-ts'; +import { describe, expect, it } from 'vitest'; + +import type { MetadataEntry } from '../src/sso/auth/scale/handshakeV2.js'; +import { + Device, + EncryptedHandshakeResponseV1, + EncryptedHandshakeResponseV2, + HandshakeProposalV2, + HandshakeResponseV1, + HandshakeResponseV2, + HandshakeStatusV2, + HandshakeSuccessV2, + HandshakeSuccessV2Legacy, + MetadataKey, + VersionedHandshakeProposal, + VersionedHandshakeResponse, + decodeEncryptedHandshakeResponseV2, + deriveIdentityChatPublicKey, +} from '../src/sso/auth/scale/handshakeV2.js'; + +const fixedChatPrivateKey = new Uint8Array(32).fill(0xdd); +const fixedChatPublicKey = p256.getPublicKey(fixedChatPrivateKey, false); + +const makeDevice = () => ({ + statementAccountId: new Uint8Array(32).fill(0xa1), + encryptionPublicKey: new Uint8Array(65).fill(0x04), +}); + +describe('MetadataKey', () => { + it('round-trips Custom with arbitrary string', () => { + const m = { tag: 'Custom' as const, value: 'app.theme' }; + expect(MetadataKey.dec(MetadataKey.enc(m))).toEqual(m); + }); + + it('round-trips each unit variant', () => { + const variants = ['HostName', 'HostVersion', 'HostIcon', 'PlatformType', 'PlatformVersion'] as const; + for (const tag of variants) { + const m = { tag, value: undefined }; + expect(MetadataKey.dec(MetadataKey.enc(m))).toEqual(m); + } + }); +}); + +describe('Device', () => { + it('round-trips a 32-byte accountId and 65-byte uncompressed public key', () => { + const d = makeDevice(); + expect(Device.dec(Device.enc(d))).toEqual(d); + }); + + it('encodes Device with the expected fixed length (32 + 65 = 97 bytes)', () => { + expect(Device.enc(makeDevice()).length).toBe(97); + }); +}); + +describe('HandshakeProposalV2', () => { + it('round-trips with empty metadata', () => { + const p = { device: makeDevice(), metadata: [] }; + expect(HandshakeProposalV2.dec(HandshakeProposalV2.enc(p))).toEqual(p); + }); + + it('round-trips with mixed metadata variants', () => { + const metadata: ReturnType[] = [ + [{ tag: 'HostName' as const, value: undefined }, 'Polkadot Desktop'], + [{ tag: 'HostVersion' as const, value: undefined }, '0.1.0'], + [{ tag: 'PlatformType' as const, value: undefined }, 'macOS'], + [{ tag: 'Custom' as const, value: 'app.theme' }, 'dark'], + ]; + const p = { device: makeDevice(), metadata }; + expect(HandshakeProposalV2.dec(HandshakeProposalV2.enc(p))).toEqual(p); + }); +}); + +describe('VersionedHandshakeProposal', () => { + it('round-trips a V2 proposal', () => { + const versioned = { + tag: 'V2' as const, + value: { device: makeDevice(), metadata: [] }, + }; + expect(VersionedHandshakeProposal.dec(VersionedHandshakeProposal.enc(versioned))).toEqual(versioned); + }); + + // V2 lives at SCALE discriminant 1 (index 0 reserved for legacy V1 which + // neither side emits). Mismatch here means the peer can't decode the + // Desktop-emitted pairing QR. + it('emits V2 at byte discriminant 1', () => { + const encoded = VersionedHandshakeProposal.enc({ + tag: 'V2', + value: { device: makeDevice(), metadata: [] }, + }); + expect(encoded[0]).toBe(1); + }); +}); + +describe('HandshakeSuccessV2 (spec v0.2.1, 161 bytes)', () => { + it('round-trips identityAccountId, rootAccountId, identityChatPrivateKey, deviceEncPubKey', () => { + const input = { + identityAccountId: new Uint8Array(32).fill(0xa1), + rootAccountId: new Uint8Array(32).fill(0xa2), + identityChatPrivateKey: fixedChatPrivateKey, + deviceEncPubKey: new Uint8Array(65).fill(0x04), + }; + const decoded = HandshakeSuccessV2.dec(HandshakeSuccessV2.enc(input)); + expect(decoded).toEqual(input); + }); + + it('encodes to exactly 161 bytes', () => { + const encoded = HandshakeSuccessV2.enc({ + identityAccountId: new Uint8Array(32).fill(0xa1), + rootAccountId: new Uint8Array(32).fill(0xa2), + identityChatPrivateKey: fixedChatPrivateKey, + deviceEncPubKey: new Uint8Array(65).fill(0x04), + }); + expect(encoded.length).toBe(161); + }); +}); + +describe('HandshakeSuccessV2Legacy (spec v0.2, 129 bytes)', () => { + it('round-trips identityAccountId, identityChatPrivateKey, deviceEncPubKey', () => { + const input = { + identityAccountId: new Uint8Array(32).fill(0xa1), + identityChatPrivateKey: fixedChatPrivateKey, + deviceEncPubKey: new Uint8Array(65).fill(0x04), + }; + const decoded = HandshakeSuccessV2Legacy.dec(HandshakeSuccessV2Legacy.enc(input)); + expect(decoded).toEqual(input); + }); + + it('encodes to exactly 129 bytes', () => { + const encoded = HandshakeSuccessV2Legacy.enc({ + identityAccountId: new Uint8Array(32).fill(0xa1), + identityChatPrivateKey: fixedChatPrivateKey, + deviceEncPubKey: new Uint8Array(65).fill(0x04), + }); + expect(encoded.length).toBe(129); + }); +}); + +describe('decodeEncryptedHandshakeResponseV2 (length-dispatched plaintext decoder)', () => { + it('decodes Pending(AllowanceAllocation) = 0x00 0x00', () => { + const decoded = decodeEncryptedHandshakeResponseV2(new Uint8Array([0x00, 0x00])); + expect(decoded).toEqual({ tag: 'Pending', value: { tag: 'AllowanceAllocation', value: undefined } }); + }); + + it('decodes a 161-byte v0.2.1 Success body with rootAccountId', () => { + const body = HandshakeSuccessV2.enc({ + identityAccountId: new Uint8Array(32).fill(0xa1), + rootAccountId: new Uint8Array(32).fill(0xa2), + identityChatPrivateKey: fixedChatPrivateKey, + deviceEncPubKey: new Uint8Array(65).fill(0x04), + }); + const bytes = new Uint8Array(1 + body.length); + bytes[0] = 0x01; + bytes.set(body, 1); + const decoded = decodeEncryptedHandshakeResponseV2(bytes); + expect(decoded.tag).toBe('Success'); + if (decoded.tag !== 'Success') return; + expect(decoded.value.rootAccountId).toEqual(new Uint8Array(32).fill(0xa2)); + expect(decoded.value.identityChatPrivateKey).toEqual(fixedChatPrivateKey); + }); + + it('decodes a 129-byte v0.2 Success body and surfaces rootAccountId as null', () => { + const body = HandshakeSuccessV2Legacy.enc({ + identityAccountId: new Uint8Array(32).fill(0xa1), + identityChatPrivateKey: fixedChatPrivateKey, + deviceEncPubKey: new Uint8Array(65).fill(0x04), + }); + const bytes = new Uint8Array(1 + body.length); + bytes[0] = 0x01; + bytes.set(body, 1); + const decoded = decodeEncryptedHandshakeResponseV2(bytes); + expect(decoded.tag).toBe('Success'); + if (decoded.tag !== 'Success') return; + expect(decoded.value.rootAccountId).toBeNull(); + expect(decoded.value.identityAccountId).toEqual(new Uint8Array(32).fill(0xa1)); + }); + + it('rejects a Success body of unknown length', () => { + const bytes = new Uint8Array(50); + bytes[0] = 0x01; + expect(() => decodeEncryptedHandshakeResponseV2(bytes)).toThrow(/not in \{129, 161\}/); + }); + + it('decodes Failed with a UTF-8 reason string', () => { + const reason = 'duplicate'; + const reasonBytes = str.enc(reason); + const bytes = new Uint8Array(1 + reasonBytes.length); + bytes[0] = 0x02; + bytes.set(reasonBytes, 1); + expect(decodeEncryptedHandshakeResponseV2(bytes)).toEqual({ tag: 'Failed', value: reason }); + }); + + it('rejects an empty plaintext', () => { + expect(() => decodeEncryptedHandshakeResponseV2(new Uint8Array(0))).toThrow(/empty plaintext/); + }); + + it('rejects an unknown variant discriminant', () => { + expect(() => decodeEncryptedHandshakeResponseV2(new Uint8Array([0x07, 0x00]))).toThrow(/unknown variant/); + }); +}); + +describe('EncryptedHandshakeResponseV2 (native scale-ts Enum, used for encode)', () => { + it('encodes Pending(AllowanceAllocation) as 0x00 0x00', () => { + const encoded = EncryptedHandshakeResponseV2.enc({ + tag: 'Pending', + value: { tag: 'AllowanceAllocation', value: undefined }, + }); + expect(encoded).toEqual(new Uint8Array([0x00, 0x00])); + }); + + it('round-trips Success on the v0.2.1 wire format', () => { + const success = { + tag: 'Success' as const, + value: { + identityAccountId: new Uint8Array(32).fill(0xa1), + rootAccountId: new Uint8Array(32).fill(0xa2), + identityChatPrivateKey: fixedChatPrivateKey, + deviceEncPubKey: new Uint8Array(65).fill(0x04), + }, + }; + const encoded = EncryptedHandshakeResponseV2.enc(success); + expect(encoded.length).toBe(1 + 161); + expect(encoded[0]).toBe(0x01); + expect(EncryptedHandshakeResponseV2.dec(encoded)).toEqual(success); + }); + + it('round-trips Failed with a reason string', () => { + const r = { tag: 'Failed' as const, value: 'user declined on mobile' }; + expect(EncryptedHandshakeResponseV2.dec(EncryptedHandshakeResponseV2.enc(r))).toEqual(r); + }); +}); + +describe('HandshakeStatusV2', () => { + it('round-trips AllowanceAllocation', () => { + const s = { tag: 'AllowanceAllocation' as const, value: undefined }; + expect(HandshakeStatusV2.dec(HandshakeStatusV2.enc(s))).toEqual(s); + }); +}); + +describe('HandshakeResponseV2', () => { + it('round-trips with arbitrary ciphertext and ephemeral key', () => { + const r = { + encrypted: new Uint8Array([1, 2, 3, 4, 5]), + tmpKey: new Uint8Array(65).fill(0x04), + }; + expect(HandshakeResponseV2.dec(HandshakeResponseV2.enc(r))).toEqual(r); + }); +}); + +describe('VersionedHandshakeResponse', () => { + it('round-trips a V2 response', () => { + const r = { + tag: 'V2' as const, + value: { encrypted: new Uint8Array([7, 8, 9]), tmpKey: new Uint8Array(65).fill(0x04) }, + }; + expect(VersionedHandshakeResponse.dec(VersionedHandshakeResponse.enc(r))).toEqual(r); + }); + + it('decodes a legacy V1 response from older mobile clients', () => { + const r = { + tag: 'V1' as const, + value: { encrypted: new Uint8Array([1, 2]), tmpKey: new Uint8Array(65).fill(0x04) }, + }; + expect(VersionedHandshakeResponse.dec(VersionedHandshakeResponse.enc(r))).toEqual(r); + }); +}); + +describe('EncryptedHandshakeResponseV1 (legacy)', () => { + it('round-trips encryptionKey and accountId', () => { + const r = { + encryptionKey: new Uint8Array(65).fill(0x04), + accountId: new Uint8Array(32).fill(0xb2), + }; + expect(EncryptedHandshakeResponseV1.dec(EncryptedHandshakeResponseV1.enc(r))).toEqual(r); + }); +}); + +describe('HandshakeResponseV1 (legacy)', () => { + it('round-trips with arbitrary ciphertext and ephemeral key', () => { + const r = { + encrypted: new Uint8Array([0xff, 0xee]), + tmpKey: new Uint8Array(65).fill(0x04), + }; + expect(HandshakeResponseV1.dec(HandshakeResponseV1.enc(r))).toEqual(r); + }); +}); + +describe('deriveIdentityChatPublicKey', () => { + it('returns the uncompressed 65-byte P-256 public key matching @noble', () => { + expect(deriveIdentityChatPublicKey(fixedChatPrivateKey)).toEqual(fixedChatPublicKey); + }); +}); diff --git a/packages/host-papp/__tests__/handshakeV2Envelope.spec.ts b/packages/host-papp/__tests__/handshakeV2Envelope.spec.ts new file mode 100644 index 00000000..ec35069c --- /dev/null +++ b/packages/host-papp/__tests__/handshakeV2Envelope.spec.ts @@ -0,0 +1,57 @@ +import { p256 } from '@noble/curves/nist.js'; +import { createEncryption } from '@novasamatech/statement-store'; +import { describe, expect, it } from 'vitest'; + +import { decryptResponseEnvelope } from '../src/sso/auth/v2/envelope.js'; + +const ecdhX = (priv: Uint8Array, pub: Uint8Array): Uint8Array => p256.getSharedSecret(priv, pub).slice(1, 33); + +// Build a HandshakeResponseV2-shaped envelope mirroring the answering peer's +// encrypt path: caller-side ephemeral P-256 keypair, ECDH against the +// device's encryption public key, then `createEncryption(shared).encrypt(...)`. +const wrap = (devicePublicKey: Uint8Array, payload: Uint8Array): { encrypted: Uint8Array; tmpKey: Uint8Array } => { + const tmpPrivate = p256.utils.randomSecretKey(); + const tmpKey = p256.getPublicKey(tmpPrivate, false); + const shared = ecdhX(tmpPrivate, devicePublicKey); + const result = createEncryption(shared).encrypt(payload); + if (result.isErr()) throw result.error; + return { encrypted: result.value, tmpKey }; +}; + +describe('decryptResponseEnvelope', () => { + it('round-trips a payload encrypted with a one-time-use ephemeral key against the device public key', () => { + const devicePrivate = p256.utils.randomSecretKey(); + const devicePublic = p256.getPublicKey(devicePrivate, false); + + const inner = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9]); + const envelope = wrap(devicePublic, inner); + + const decrypted = decryptResponseEnvelope(devicePrivate, envelope); + expect(decrypted).toEqual(inner); + }); + + it('throws on a tmpKey that was not the actual encryption peer', () => { + const devicePrivate = p256.utils.randomSecretKey(); + const devicePublic = p256.getPublicKey(devicePrivate, false); + + const envelope = wrap(devicePublic, new Uint8Array([0xff])); + const wrongTmpPrivate = p256.utils.randomSecretKey(); + const wrongTmpKey = p256.getPublicKey(wrongTmpPrivate, false); + + expect(() => + decryptResponseEnvelope(devicePrivate, { encrypted: envelope.encrypted, tmpKey: wrongTmpKey }), + ).toThrow(); + }); + + it('throws on a tampered ciphertext (auth-tag failure)', () => { + const devicePrivate = p256.utils.randomSecretKey(); + const devicePublic = p256.getPublicKey(devicePrivate, false); + + const envelope = wrap(devicePublic, new Uint8Array([0x42, 0x42, 0x42])); + const tampered = new Uint8Array(envelope.encrypted); + const lastIdx = tampered.length - 1; + tampered[lastIdx] = (tampered[lastIdx] ?? 0) ^ 1; // flip a bit in the auth tag + + expect(() => decryptResponseEnvelope(devicePrivate, { encrypted: tampered, tmpKey: envelope.tmpKey })).toThrow(); + }); +}); diff --git a/packages/host-papp/__tests__/handshakeV2Proposal.spec.ts b/packages/host-papp/__tests__/handshakeV2Proposal.spec.ts new file mode 100644 index 00000000..276c72ce --- /dev/null +++ b/packages/host-papp/__tests__/handshakeV2Proposal.spec.ts @@ -0,0 +1,73 @@ +import { fromHex } from 'polkadot-api/utils'; +import { describe, expect, it } from 'vitest'; + +import { VersionedHandshakeProposal } from '../src/sso/auth/scale/handshakeV2.js'; +import type { HandshakeMetadata } from '../src/sso/auth/v2/proposal.js'; +import { buildPairingDeeplink, encodeProposal } from '../src/sso/auth/v2/proposal.js'; + +const device = { + statementAccountPublicKey: new Uint8Array(32).fill(0xa1), + encryptionPublicKey: new Uint8Array(65).fill(0x04), +}; + +const metadata: HandshakeMetadata = { + hostName: 'Polkadot Desktop', + hostVersion: '1.2.3', + platformType: 'macOS', + platformVersion: '15.0', +}; + +describe('encodeProposal', () => { + it('round-trips through VersionedHandshakeProposal', () => { + const encoded = encodeProposal(device, metadata); + const decoded = VersionedHandshakeProposal.dec(encoded); + + expect(decoded.tag).toBe('V2'); + if (decoded.tag !== 'V2') return; + expect(decoded.value.device.statementAccountId).toEqual(device.statementAccountPublicKey); + expect(decoded.value.device.encryptionPublicKey).toEqual(device.encryptionPublicKey); + }); + + it('emits V2 at byte discriminant 1 (peer wire-compat)', () => { + const encoded = encodeProposal(device, metadata); + expect(encoded[0]).toBe(1); + }); + + it('encodes hostName under MetadataKey.HostName', () => { + const encoded = encodeProposal(device, metadata); + const decoded = VersionedHandshakeProposal.dec(encoded); + if (decoded.tag !== 'V2') throw new Error('expected V2'); + + const entries = decoded.value.metadata.map(([key, value]) => ({ tag: key.tag, value })); + expect(entries).toContainEqual({ tag: 'HostName', value: 'Polkadot Desktop' }); + expect(entries).toContainEqual({ tag: 'HostVersion', value: '1.2.3' }); + expect(entries).toContainEqual({ tag: 'PlatformType', value: 'macOS' }); + expect(entries).toContainEqual({ tag: 'PlatformVersion', value: '15.0' }); + }); + + it('omits absent metadata fields', () => { + const encoded = encodeProposal(device, { hostName: 'just a host' }); + const decoded = VersionedHandshakeProposal.dec(encoded); + if (decoded.tag !== 'V2') throw new Error('expected V2'); + + expect(decoded.value.metadata).toHaveLength(1); + expect(decoded.value.metadata[0]?.[0]?.tag).toBe('HostName'); + }); +}); + +describe('buildPairingDeeplink', () => { + it('builds a polkadotapp://pair?handshake= URL', () => { + const url = buildPairingDeeplink(device, metadata); + expect(url).toMatch(/^polkadotapp:\/\/pair\?handshake=[0-9a-f]+$/); + }); + + it('embeds the encoded proposal hex in the handshake query param', () => { + const url = buildPairingDeeplink(device, metadata); + const match = url.match(/^polkadotapp:\/\/pair\?handshake=(.+)$/); + expect(match).not.toBeNull(); + if (!match) return; + + const expectedBytes = encodeProposal(device, metadata); + expect(fromHex(`0x${match[1] ?? ''}`)).toEqual(expectedBytes); + }); +}); diff --git a/packages/host-papp/__tests__/handshakeV2Service.spec.ts b/packages/host-papp/__tests__/handshakeV2Service.spec.ts new file mode 100644 index 00000000..6823a94e --- /dev/null +++ b/packages/host-papp/__tests__/handshakeV2Service.spec.ts @@ -0,0 +1,187 @@ +import { p256 } from '@noble/curves/nist.js'; +import type { Statement, StatementStoreAdapter } from '@novasamatech/statement-store'; +import { createEncryption } from '@novasamatech/statement-store'; +import { okAsync } from 'neverthrow'; +import { firstValueFrom, lastValueFrom, take, toArray } from 'rxjs'; +import { describe, expect, it, vi } from 'vitest'; + +import { EncryptedHandshakeResponseV2, VersionedHandshakeResponse } from '../src/sso/auth/scale/handshakeV2.js'; +import type { DeviceIdentityForPairing } from '../src/sso/auth/v2/service.js'; +import { startPairingV2 } from '../src/sso/auth/v2/service.js'; +import { computePairingTopic } from '../src/sso/auth/v2/topic.js'; + +const ecdhX = (priv: Uint8Array, pub: Uint8Array): Uint8Array => p256.getSharedSecret(priv, pub).slice(1, 33); + +const buildDeviceIdentity = (): DeviceIdentityForPairing => { + const encryptionPrivateKey = p256.utils.randomSecretKey(); + return { + statementAccountPublicKey: new Uint8Array(32).fill(0xa1), + statementAccountSecret: new Uint8Array(64).fill(0x55), + encryptionPrivateKey, + encryptionPublicKey: p256.getPublicKey(encryptionPrivateKey, false), + }; +}; + +const wrapInnerResponse = ( + device: DeviceIdentityForPairing, + inner: Uint8Array, +): { encrypted: Uint8Array; tmpKey: Uint8Array } => { + const tmpPrivate = p256.utils.randomSecretKey(); + const tmpKey = p256.getPublicKey(tmpPrivate, false); + const shared = ecdhX(tmpPrivate, device.encryptionPublicKey); + const result = createEncryption(shared).encrypt(inner); + if (result.isErr()) throw result.error; + return { encrypted: result.value, tmpKey }; +}; + +const buildStatement = (device: DeviceIdentityForPairing, innerBytes: Uint8Array): Statement => { + const envelope = wrapInnerResponse(device, innerBytes); + return { + data: VersionedHandshakeResponse.enc({ tag: 'V2', value: envelope }), + }; +}; + +type FakeAdapter = StatementStoreAdapter & { emit: (statements: Statement[]) => void; lastFilter?: unknown }; + +const makeFakeStore = (): FakeAdapter => { + let cb: ((page: { statements: Statement[]; isComplete: boolean }) => unknown) | null = null; + const adapter: FakeAdapter = { + queryStatements: vi.fn().mockReturnValue(okAsync([])), + submitStatement: vi.fn(), + subscribeStatements: vi.fn((filter, callback) => { + adapter.lastFilter = filter; + cb = callback; + return () => { + cb = null; + }; + }), + emit: stmts => { + cb?.({ statements: stmts, isComplete: false }); + }, + }; + return adapter; +}; + +describe('startPairingV2', () => { + it('exposes a polkadotapp:// pairing deeplink as qrPayload', () => { + const device = buildDeviceIdentity(); + const store = makeFakeStore(); + + const pairing = startPairingV2({ + statementStore: store, + deviceIdentity: device, + metadata: { hostName: 'Polkadot Desktop' }, + }); + + expect(pairing.qrPayload).toMatch(/^polkadotapp:\/\/pair\?handshake=[0-9a-f]+$/); + pairing.abort(); + }); + + it('subscribes to the device pairing topic on startup', () => { + const device = buildDeviceIdentity(); + const store = makeFakeStore(); + + const pairing = startPairingV2({ + statementStore: store, + deviceIdentity: device, + metadata: {}, + }); + + const expectedTopic = computePairingTopic(device.statementAccountPublicKey, device.encryptionPublicKey); + expect(store.lastFilter).toEqual({ matchAll: [expectedTopic] }); + pairing.abort(); + }); + + it('starts in Submitted state', async () => { + const device = buildDeviceIdentity(); + const store = makeFakeStore(); + const pairing = startPairingV2({ statementStore: store, deviceIdentity: device, metadata: {} }); + + const first = await firstValueFrom(pairing.state$); + expect(first.tag).toBe('Submitted'); + pairing.abort(); + }); + + it('transitions Submitted → Pending → Success on the canonical response sequence', async () => { + const device = buildDeviceIdentity(); + const store = makeFakeStore(); + const persistOnSuccess = vi.fn().mockResolvedValue(undefined); + const pairing = startPairingV2({ + statementStore: store, + deviceIdentity: device, + metadata: {}, + persistOnSuccess, + }); + + const states$ = pairing.state$.pipe(take(3), toArray()); + const collected = lastValueFrom(states$); + + const pendingBytes = EncryptedHandshakeResponseV2.enc({ + tag: 'Pending', + value: { tag: 'AllowanceAllocation', value: undefined }, + }); + store.emit([buildStatement(device, pendingBytes)]); + + const successBytes = EncryptedHandshakeResponseV2.enc({ + tag: 'Success', + value: { + identityAccountId: new Uint8Array(32).fill(0xa1), + rootAccountId: new Uint8Array(32).fill(0xa2), + identityChatPrivateKey: new Uint8Array(32).fill(0xdd), + deviceEncPubKey: new Uint8Array(65).fill(0x04), + }, + }); + store.emit([buildStatement(device, successBytes)]); + + const states = await collected; + expect(states.map(s => s.tag)).toEqual(['Submitted', 'Pending', 'Success']); + expect(persistOnSuccess).toHaveBeenCalledOnce(); + expect(persistOnSuccess).toHaveBeenCalledWith(expect.objectContaining({ tag: 'Success' })); + pairing.abort(); + }); + + it('transitions to Failed on a Failed inner response', async () => { + const device = buildDeviceIdentity(); + const store = makeFakeStore(); + const pairing = startPairingV2({ statementStore: store, deviceIdentity: device, metadata: {} }); + + const states$ = pairing.state$.pipe(take(2), toArray()); + const collected = lastValueFrom(states$); + + const failedBytes = EncryptedHandshakeResponseV2.enc({ tag: 'Failed', value: 'duplicate' }); + store.emit([buildStatement(device, failedBytes)]); + + const states = await collected; + expect(states.map(s => s.tag)).toEqual(['Submitted', 'Failed']); + expect(states[1]).toMatchObject({ tag: 'Failed', reason: 'duplicate' }); + pairing.abort(); + }); + + it('drops statements that cannot be decrypted (wrong recipient or tampered)', async () => { + const device = buildDeviceIdentity(); + const store = makeFakeStore(); + const pairing = startPairingV2({ statementStore: store, deviceIdentity: device, metadata: {} }); + + // Statement encrypted to a different device + const otherDevice = buildDeviceIdentity(); + const innerBytes = EncryptedHandshakeResponseV2.enc({ + tag: 'Failed', + value: 'should be dropped', + }); + store.emit([buildStatement(otherDevice, innerBytes)]); + + // Should still be in Submitted (no transition) + const state = await firstValueFrom(pairing.state$); + expect(state.tag).toBe('Submitted'); + pairing.abort(); + }); + + it('abort() is idempotent and tears down cleanly', () => { + const device = buildDeviceIdentity(); + const store = makeFakeStore(); + const pairing = startPairingV2({ statementStore: store, deviceIdentity: device, metadata: {} }); + + expect(() => pairing.abort()).not.toThrow(); + expect(() => pairing.abort()).not.toThrow(); + }); +}); diff --git a/packages/host-papp/__tests__/handshakeV2State.spec.ts b/packages/host-papp/__tests__/handshakeV2State.spec.ts new file mode 100644 index 00000000..6131cbae --- /dev/null +++ b/packages/host-papp/__tests__/handshakeV2State.spec.ts @@ -0,0 +1,142 @@ +import { p256 } from '@noble/curves/nist.js'; +import { describe, expect, it } from 'vitest'; + +import type { DecodedHandshakeResponseV2 } from '../src/sso/auth/scale/handshakeV2.js'; +import type { HandshakeState } from '../src/sso/auth/v2/state.js'; +import { + advance, + canSubmitV2Statements, + fromInnerResponse, + idle, + isTerminal, + submitted, +} from '../src/sso/auth/v2/state.js'; + +const fixedChatPrivateKey = new Uint8Array(32).fill(0xdd); +const fixedChatPublicKey = p256.getPublicKey(fixedChatPrivateKey, false); + +const makeSuccess = (overrides: Partial = {}): HandshakeState => ({ + tag: 'Success', + identityAccountId: new Uint8Array(32).fill(0xa1), + rootAccountId: new Uint8Array(32).fill(0xa2), + identityChatPrivateKey: fixedChatPrivateKey, + identityChatPublicKey: fixedChatPublicKey, + deviceEncPubKey: new Uint8Array(65).fill(0x04), + peerStatementAccountId: null, + ...overrides, +}); + +describe('fromInnerResponse', () => { + it('maps Pending to Pending state', () => { + const r: DecodedHandshakeResponseV2 = { + tag: 'Pending', + value: { tag: 'AllowanceAllocation', value: undefined }, + }; + expect(fromInnerResponse(r)).toEqual({ tag: 'Pending', reason: 'AllowanceAllocation' }); + }); + + it('maps v0.2.1 Success to Success state and derives identityChatPublicKey from priv key', () => { + const r: DecodedHandshakeResponseV2 = { + tag: 'Success', + value: { + identityAccountId: new Uint8Array(32).fill(0xa1), + rootAccountId: new Uint8Array(32).fill(0xa2), + identityChatPrivateKey: fixedChatPrivateKey, + deviceEncPubKey: new Uint8Array(65).fill(0x04), + }, + }; + const state = fromInnerResponse(r); + expect(state.tag).toBe('Success'); + if (state.tag !== 'Success') return; + expect(state.identityAccountId).toEqual(new Uint8Array(32).fill(0xa1)); + expect(state.rootAccountId).toEqual(new Uint8Array(32).fill(0xa2)); + expect(state.identityChatPrivateKey).toEqual(fixedChatPrivateKey); + expect(state.identityChatPublicKey).toEqual(fixedChatPublicKey); + expect(state.deviceEncPubKey).toEqual(new Uint8Array(65).fill(0x04)); + }); + + it('preserves rootAccountId=null for v0.2 Success payloads', () => { + const r: DecodedHandshakeResponseV2 = { + tag: 'Success', + value: { + identityAccountId: new Uint8Array(32).fill(0xa1), + rootAccountId: null, + identityChatPrivateKey: fixedChatPrivateKey, + deviceEncPubKey: new Uint8Array(65).fill(0x04), + }, + }; + const state = fromInnerResponse(r); + expect(state.tag).toBe('Success'); + if (state.tag !== 'Success') return; + expect(state.rootAccountId).toBeNull(); + }); + + it('maps Failed to Failed state with reason string', () => { + const r: DecodedHandshakeResponseV2 = { tag: 'Failed', value: 'no slot available' }; + expect(fromInnerResponse(r)).toEqual({ tag: 'Failed', reason: 'no slot available' }); + }); +}); + +describe('advance', () => { + it('Idle → Submitted is allowed', () => { + expect(advance(idle(), submitted())).toEqual(submitted()); + }); + + it('Submitted → Pending is allowed', () => { + const pending: HandshakeState = { tag: 'Pending', reason: 'AllowanceAllocation' }; + expect(advance(submitted(), pending)).toEqual(pending); + }); + + it('Pending → Success is allowed', () => { + const pending: HandshakeState = { tag: 'Pending', reason: 'AllowanceAllocation' }; + const success = makeSuccess(); + expect(advance(pending, success)).toEqual(success); + }); + + it('terminal states are absorbing — Success cannot regress to Pending', () => { + const success = makeSuccess(); + const pending: HandshakeState = { tag: 'Pending', reason: 'AllowanceAllocation' }; + expect(advance(success, pending)).toEqual(success); + }); + + it('terminal states are absorbing — Failed cannot regress to Pending', () => { + const failed: HandshakeState = { tag: 'Failed', reason: 'declined' }; + const pending: HandshakeState = { tag: 'Pending', reason: 'AllowanceAllocation' }; + expect(advance(failed, pending)).toEqual(failed); + }); + + it('Submitted → Idle is rejected (no backwards regression)', () => { + expect(advance(submitted(), idle())).toEqual(submitted()); + }); + + it('Idle → Pending is rejected (must Submit first)', () => { + const pending: HandshakeState = { tag: 'Pending', reason: 'AllowanceAllocation' }; + expect(advance(idle(), pending)).toEqual(idle()); + }); +}); + +describe('isTerminal', () => { + it('returns true for Success', () => { + expect(isTerminal(makeSuccess())).toBe(true); + }); + + it('returns true for Failed', () => { + expect(isTerminal({ tag: 'Failed', reason: 'declined' })).toBe(true); + }); + + it('returns false for Idle / Submitted / Pending', () => { + expect(isTerminal(idle())).toBe(false); + expect(isTerminal(submitted())).toBe(false); + expect(isTerminal({ tag: 'Pending', reason: 'AllowanceAllocation' })).toBe(false); + }); +}); + +describe('canSubmitV2Statements', () => { + it('only true in Success', () => { + expect(canSubmitV2Statements(makeSuccess())).toBe(true); + expect(canSubmitV2Statements(idle())).toBe(false); + expect(canSubmitV2Statements(submitted())).toBe(false); + expect(canSubmitV2Statements({ tag: 'Pending', reason: 'AllowanceAllocation' })).toBe(false); + expect(canSubmitV2Statements({ tag: 'Failed', reason: 'x' })).toBe(false); + }); +}); diff --git a/packages/host-papp/__tests__/handshakeV2Topic.spec.ts b/packages/host-papp/__tests__/handshakeV2Topic.spec.ts new file mode 100644 index 00000000..45c37907 --- /dev/null +++ b/packages/host-papp/__tests__/handshakeV2Topic.spec.ts @@ -0,0 +1,50 @@ +import { khash } from '@novasamatech/statement-store'; +import { mergeUint8 } from '@polkadot-api/utils'; +import { describe, expect, it } from 'vitest'; + +import { computePairingChannel, computePairingTopic } from '../src/sso/auth/v2/topic.js'; + +const statementAccountId = new Uint8Array(32).fill(0xa1); +const encryptionPublicKey = new Uint8Array(65).fill(0x04); + +describe('computePairingTopic', () => { + it('matches spec: khash(statementAccountId, encryptionPublicKey || "topic")', () => { + const expected = khash(statementAccountId, mergeUint8([encryptionPublicKey, new TextEncoder().encode('topic')])); + expect(computePairingTopic(statementAccountId, encryptionPublicKey)).toEqual(expected); + }); + + it('produces a 32-byte output', () => { + expect(computePairingTopic(statementAccountId, encryptionPublicKey).length).toBe(32); + }); + + it('is deterministic', () => { + const a = computePairingTopic(statementAccountId, encryptionPublicKey); + const b = computePairingTopic(statementAccountId, encryptionPublicKey); + expect(a).toEqual(b); + }); + + it('differs when the device account id differs', () => { + const a = computePairingTopic(statementAccountId, encryptionPublicKey); + const b = computePairingTopic(new Uint8Array(32).fill(0xa2), encryptionPublicKey); + expect(a).not.toEqual(b); + }); + + it('differs when the encryption public key differs', () => { + const a = computePairingTopic(statementAccountId, encryptionPublicKey); + const b = computePairingTopic(statementAccountId, new Uint8Array(65).fill(0x05)); + expect(a).not.toEqual(b); + }); +}); + +describe('computePairingChannel', () => { + it('matches spec: khash(statementAccountId, encryptionPublicKey || "channel")', () => { + const expected = khash(statementAccountId, mergeUint8([encryptionPublicKey, new TextEncoder().encode('channel')])); + expect(computePairingChannel(statementAccountId, encryptionPublicKey)).toEqual(expected); + }); + + it('differs from the topic for the same device', () => { + const topic = computePairingTopic(statementAccountId, encryptionPublicKey); + const channel = computePairingChannel(statementAccountId, encryptionPublicKey); + expect(topic).not.toEqual(channel); + }); +}); diff --git a/packages/host-papp/package.json b/packages/host-papp/package.json index cbdc88fd..02114829 100644 --- a/packages/host-papp/package.json +++ b/packages/host-papp/package.json @@ -38,12 +38,15 @@ "@novasamatech/scale": "0.7.9", "@novasamatech/statement-store": "0.7.9", "@novasamatech/storage-adapter": "0.7.9", + "@polkadot-api/utils": "^0.4.0", "@polkadot-labs/hdkd-helpers": "^0.0.30", "nanoevents": "9.1.0", "nanoid": "5.1.9", "neverthrow": "^8.2.0", "polkadot-api": ">=2", - "scale-ts": "1.6.1" + "rxjs": "^7.8.2", + "scale-ts": "1.6.1", + "verifiablejs": "1.2.0" }, "publishConfig": { "access": "public" diff --git a/packages/host-papp/src/debugTypes.ts b/packages/host-papp/src/debugTypes.ts index 8da42ad3..9f202c2d 100644 --- a/packages/host-papp/src/debugTypes.ts +++ b/packages/host-papp/src/debugTypes.ts @@ -25,7 +25,7 @@ export type SsoDebugEvent = event: 'pairing_started'; flowId: string; timestamp: number; - payload: { metadata: string }; + payload: { metadata: unknown }; } | { layer: 'sso'; @@ -39,14 +39,14 @@ export type SsoDebugEvent = event: 'awaiting_response'; flowId: string; timestamp: number; - payload: { topic: string }; + payload: Record; } | { layer: 'sso'; event: 'response_received'; flowId: string; timestamp: number; - payload: { sessionId: string }; + payload: { identityAccountId: Uint8Array }; } | { layer: 'sso'; diff --git a/packages/host-papp/src/identity/rpcAdapter.ts b/packages/host-papp/src/identity/rpcAdapter.ts index 960664b3..a7a5be1e 100644 --- a/packages/host-papp/src/identity/rpcAdapter.ts +++ b/packages/host-papp/src/identity/rpcAdapter.ts @@ -33,11 +33,20 @@ export function createIdentityRpcAdapter(lazyClient: LazyClient): IdentityAdapte return ok( Object.fromEntries( - zipWith([accounts, results], x => x).map<[string, Identity | null]>(([accountId, raw]) => { - if (!raw) { + zipWith([accounts, results], x => x).map<[string, Identity | null]>(([accountId, typedRaw]) => { + if (!typedRaw) { return [accountId, null]; } + // Runtime metadata may expose fields in snake_case (V1) or + // camelCase (V2 multi-device). Read defensively. The .papi + // descriptor only types snake_case, so widen here. + const raw = typedRaw as unknown as Record & typeof typedRaw; + const fullUsername = + (raw.full_username as Uint8Array | undefined) ?? (raw.fullUsername as Uint8Array | undefined); + const liteUsername = + (raw.lite_username as Uint8Array | undefined) ?? (raw.liteUsername as Uint8Array | undefined); + const credibility: Credibility = raw.credibility.type == 'Lite' ? { @@ -46,15 +55,16 @@ export function createIdentityRpcAdapter(lazyClient: LazyClient): IdentityAdapte : { type: 'Person', alias: raw.credibility.value.alias as HexString, - lastUpdate: raw.credibility.value.last_update.toString(), + lastUpdate: ((raw.credibility.value as Record).last_update ?? + (raw.credibility.value as Record).lastUpdate)!.toString(), }; return [ accountId, { accountId: accountId, - fullUsername: raw.full_username ? textDecoder.decode(raw.full_username) : null, - liteUsername: textDecoder.decode(raw.lite_username), + fullUsername: fullUsername ? textDecoder.decode(fullUsername) : null, + liteUsername: liteUsername ? textDecoder.decode(liteUsername) : '', credibility, }, ]; diff --git a/packages/host-papp/src/index.ts b/packages/host-papp/src/index.ts index 2bcb821d..0f20a699 100644 --- a/packages/host-papp/src/index.ts +++ b/packages/host-papp/src/index.ts @@ -3,8 +3,10 @@ export { SS_PASEO_STABLE_STAGE_ENDPOINTS, SS_PREVIEW_STAGE_ENDPOINTS, SS_STABLE_ export type { PappAdapter } from './papp.js'; export { createPappAdapter } from './papp.js'; -export type { HostMetadata } from './sso/auth/impl.js'; +export type { AuthComponent, HostMetadata, OnAuthSuccess } from './sso/auth/impl.js'; export type { PairingStatus } from './sso/auth/types.js'; +export type { DeviceIdentityForPairing } from './sso/auth/v2/service.js'; + export type { UserSession } from './sso/sessionManager/userSession.js'; export type { StoredUserSession } from './sso/userSessionRepository.js'; export type { Identity } from './identity/types.js'; diff --git a/packages/host-papp/src/papp.ts b/packages/host-papp/src/papp.ts index 55b8c931..b0b18b71 100644 --- a/packages/host-papp/src/papp.ts +++ b/packages/host-papp/src/papp.ts @@ -8,8 +8,10 @@ import { SS_STABLE_STAGE_ENDPOINTS } from './constants.js'; import { createIdentityRepository } from './identity/impl.js'; import { createIdentityRpcAdapter } from './identity/rpcAdapter.js'; import type { IdentityAdapter, IdentityRepository } from './identity/types.js'; -import type { AuthComponent, HostMetadata } from './sso/auth/impl.js'; +import type { AuthComponent, HostMetadata, OnAuthSuccess } from './sso/auth/impl.js'; import { createAuth } from './sso/auth/impl.js'; +import type { DeviceIdentityForPairing } from './sso/auth/v2/service.js'; +import { createDeviceIdentityStore } from './sso/deviceIdentityStore.js'; import type { SsoSessionManager } from './sso/sessionManager/impl.js'; import { createSsoSessionManager } from './sso/sessionManager/impl.js'; import type { UserSecretRepository } from './sso/userSecretRepository.js'; @@ -32,30 +34,40 @@ type Adapters = { type Params = { /** - * Host app Id. - * CAUTION! This value should be stable. + * Host app Id. CAUTION! This value should be stable across launches — it + * seeds the storage prefix that backs every persisted SSO blob. */ appId: string; /** - * URL for additional metadata that will be displayed during pairing process. - * Content of provided json shound be - * ```ts - * interface Metadata { - * name: string; - * icon: string; // url for icon. Icon should be a rasterized image with min size 256x256 px. - * } - * ``` + * Host environment metadata embedded inside the V2 pairing proposal QR so + * the paired device can render a request screen with the host name / icon / + * platform. All fields are optional — absence must not break pairing. */ - metadata: string; + hostMetadata?: HostMetadata; /** - * Optional host environment metadata for Sign-In confirmation screen. - * All fields are optional - absence must not break the pairing flow. + * Optional override for the device identity. Default: the SDK persists a + * fresh identity to the configured `StorageAdapter` on first run and reuses + * it on subsequent launches. Pass a factory only if you need a different + * persistence backend (Electron Keychain, native secure storage, etc.). */ - hostMetadata?: HostMetadata; + deviceIdentity?: () => Promise | DeviceIdentityForPairing; + /** + * Optional caller hook fired after a successful handshake — after the SDK + * has already written the session + secrets to its own repositories. Use it + * for consumer-specific bookkeeping (telemetry, custom peer caches, device- + * sync seeding). Throwing fails the `sso.authenticate()` call. + */ + onAuthSuccess?: OnAuthSuccess; adapters?: Partial; }; -export function createPappAdapter({ appId, metadata, hostMetadata, adapters }: Params): PappAdapter { +export function createPappAdapter({ + appId, + hostMetadata, + deviceIdentity, + onAuthSuccess, + adapters, +}: Params): PappAdapter { const lazyClient = adapters?.lazyClient ?? createLazyClient(getWsProvider(SS_STABLE_STAGE_ENDPOINTS, { heartbeatTimeout: Number.POSITIVE_INFINITY })); @@ -66,14 +78,17 @@ export function createPappAdapter({ appId, metadata, hostMetadata, adapters }: P const ssoSessionRepository = createUserSessionRepository(storage); const userSecretRepository = createUserSecretRepository(appId, storage); + const deviceIdentityStore = createDeviceIdentityStore(appId, storage); return { sso: createAuth({ - metadata, hostMetadata, + deviceIdentity, + deviceIdentityStore, statementStore, ssoSessionRepository, userSecretRepository, + onAuthSuccess, }), sessions: createSsoSessionManager({ storage, statementStore, ssoSessionRepository, userSecretRepository }), secrets: userSecretRepository, diff --git a/packages/host-papp/src/sso/auth/impl.ts b/packages/host-papp/src/sso/auth/impl.ts index cba57855..88fb83b2 100644 --- a/packages/host-papp/src/sso/auth/impl.ts +++ b/packages/host-papp/src/sso/auth/impl.ts @@ -1,343 +1,262 @@ -import { enumValue } from '@novasamatech/scale'; -import type { LocalSessionAccount, Statement, StatementStoreAdapter } from '@novasamatech/statement-store'; -import { - createAccountId, - createEncryption, - createLocalSessionAccount, - createRemoteSessionAccount, - khash, -} from '@novasamatech/statement-store'; -import { generateMnemonic } from '@polkadot-labs/hdkd-helpers'; -import { Result, ResultAsync, err, fromPromise, fromThrowable, ok } from 'neverthrow'; -import { mergeUint8, toHex } from 'polkadot-api/utils'; +import type { StatementStoreAdapter } from '@novasamatech/statement-store'; +import { createAccountId, createLocalSessionAccount, createRemoteSessionAccount } from '@novasamatech/statement-store'; +import { ResultAsync, errAsync, okAsync } from 'neverthrow'; -import type { DerivedSr25519Account, EncrPublicKey, EncrSecret, SsPublicKey } from '../../crypto.js'; -import { createEncrSecret, createSharedSecret, deriveSr25519Account, getEncrPub, stringToBytes } from '../../crypto.js'; +import type { EncrSecret, SsSecret } from '../../crypto.js'; import { createFlowId, emitHostPappDebugMessage } from '../../debugBus.js'; import { AbortError } from '../../helpers/abortError.js'; import { createState, readonly } from '../../helpers/state.js'; import { toError } from '../../helpers/utils.js'; -import type { Callback } from '../../types.js'; +import type { DeviceIdentityStore } from '../deviceIdentityStore.js'; import type { UserSecretRepository } from '../userSecretRepository.js'; import type { StoredUserSession, UserSessionRepository } from '../userSessionRepository.js'; import { createStoredUserSession } from '../userSessionRepository.js'; -import { HandshakeData, HandshakeResponsePayload, HandshakeResponseSensitiveData } from './scale/handshake.js'; import type { PairingStatus } from './types.js'; +import type { HandshakeMetadata } from './v2/proposal.js'; +import type { DeviceIdentityForPairing } from './v2/service.js'; +import { startPairingV2 } from './v2/service.js'; +import type { HandshakeState, HandshakeSuccessState } from './v2/state.js'; +export type HostMetadata = HandshakeMetadata; export type AuthComponent = ReturnType; -export type HostMetadata = { - hostVersion?: string; - osType?: string; - osVersion?: string; -}; +/** + * Optional caller hook fired once the V2 handshake reaches Success, after the + * SDK has persisted the session and secrets and before `authenticate()` + * resolves. Receives both the persisted `StoredUserSession` and the sensitive + * `identityChatPrivateKey` (which lives in `UserSecretRepository` and isn't + * surfaced on the session shape). Throwing fails the `authenticate()` call. + */ +export type OnAuthSuccess = (event: { + session: StoredUserSession; + identityChatPrivateKey: Uint8Array; +}) => Promise | void; type Params = { - metadata: string; hostMetadata?: HostMetadata; + /** + * Optional override for the device identity. If absent, the SDK uses an + * internal `deviceIdentityStore` backed by the host's `StorageAdapter` — + * fine for web hosts. Electron / native consumers can plug in a Keychain- + * backed identity by passing a factory that returns the same shape. + */ + deviceIdentity?: () => Promise | DeviceIdentityForPairing; + deviceIdentityStore: DeviceIdentityStore; statementStore: StatementStoreAdapter; ssoSessionRepository: UserSessionRepository; userSecretRepository: UserSecretRepository; + onAuthSuccess?: OnAuthSuccess; }; export function createAuth({ - metadata, hostMetadata, + deviceIdentity, + deviceIdentityStore, statementStore, ssoSessionRepository, userSecretRepository, + onAuthSuccess, }: Params) { const pairingStatus = createState({ step: 'none' }); let authResult: ResultAsync | null = null; - let abort: AbortController | null = null; - - function handshake(account: DerivedSr25519Account, signal: AbortSignal, flowId: string) { - const localAccount = createLocalSessionAccount(createAccountId(account.publicKey)); + let abortHandle: (() => void) | null = null; - pairingStatus.write({ step: 'initial' }); + return { + pairingStatus: readonly(pairingStatus), - const encrKeys = createEncrKeys(account.entropy); - const handshakePayload = encrKeys.andThen(({ publicKey }) => - createHandshakePayloadV1({ - ssPublicKey: account.publicKey, - encrPublicKey: publicKey, - metadata, - hostMetadata, - }), - ); - const handshakeTopic = encrKeys.andThen(({ publicKey }) => createHandshakeTopic(localAccount, publicKey)); + authenticate(): ResultAsync { + if (authResult) return authResult; - const dataPrepared = Result.combine([handshakePayload, handshakeTopic, encrKeys]).andTee(([payload]) => { - const deeplink = createDeeplink(payload); - pairingStatus.write({ step: 'pairing', payload: deeplink }); + const flowId = createFlowId(); + pairingStatus.write({ step: 'initial' }); emitHostPappDebugMessage({ layer: 'sso', - event: 'deeplink_generated', + event: 'pairing_started', flowId, timestamp: Date.now(), - payload: { deeplink }, + payload: { metadata: hostMetadata }, }); - }); - return dataPrepared - .asyncAndThen(([, handshakeTopic, encrKeys]) => { + let aborted = false; + let pairingAbort: (() => void) | null = null; + abortHandle = () => { + aborted = true; + pairingAbort?.(); + }; + + const resolveDeviceIdentity = (): ResultAsync => + deviceIdentity + ? ResultAsync.fromPromise(Promise.resolve(deviceIdentity()), toError) + : deviceIdentityStore.loadOrCreate(); + + const flow = ResultAsync.combine([ + resolveDeviceIdentity(), + deviceIdentityStore.readLastProcessedHandshakeStatement(), + ]).andThen(([identity, initialHex]) => { + if (aborted) return okAsync(null); + + const pairing = startPairingV2({ + statementStore, + deviceIdentity: identity, + metadata: hostMetadata ?? {}, + initialProcessedDataHex: initialHex, + onStatementProcessed: hex => { + void deviceIdentityStore.writeLastProcessedHandshakeStatement(hex); + }, + }); + pairingAbort = pairing.abort; + + pairingStatus.write({ step: 'pairing', payload: pairing.qrPayload }); + emitHostPappDebugMessage({ + layer: 'sso', + event: 'deeplink_generated', + flowId, + timestamp: Date.now(), + payload: { deeplink: pairing.qrPayload }, + }); emitHostPappDebugMessage({ layer: 'sso', event: 'awaiting_response', flowId, timestamp: Date.now(), - payload: { topic: toHex(handshakeTopic) }, + payload: {}, }); - const pappResponse = waitForStatements( - callback => - statementStore.subscribeStatements({ matchAll: [handshakeTopic] }, page => callback(page.statements)), - signal, - (statements, resolve) => { - for (const statement of statements) { - if (!statement.data) continue; - - const session = retrieveSession({ - localAccount, - encrSecret: encrKeys.secret, - payload: statement.data, - }).unwrapOr(null); - - if (session) { - emitHostPappDebugMessage({ - layer: 'sso', - event: 'response_received', - flowId, - timestamp: Date.now(), - payload: { sessionId: session.id }, - }); - resolve(session); - break; - } - } - }, - ); - - return pappResponse.map(session => ({ - session, - secretsPayload: { - id: session.id, - ssSecret: account.secret, - encrSecret: encrKeys.secret, - entropy: account.entropy, - }, - })); - }) - .andTee(({ session }) => { - pairingStatus.write({ step: 'finished', session }); - }) - .orTee(e => { - if (!(e instanceof AbortError)) { - pairingStatus.write({ step: 'pairingError', message: e.message }); - } - }); - } - - const authModule = { - pairingStatus: readonly(pairingStatus), - - authenticate(): ResultAsync { - if (authResult) { - return authResult; - } - - abort = new AbortController(); - - const account = deriveSr25519Account(generateMnemonic(), '//wallet//sso'); - const ssoFlowId = createFlowId(); - emitHostPappDebugMessage({ - layer: 'sso', - event: 'pairing_started', - flowId: ssoFlowId, - timestamp: Date.now(), - payload: { metadata }, + return ResultAsync.fromPromise( + new Promise((resolve, reject) => { + let settled = false; + const settle = (cb: () => void) => { + if (settled) return; + settled = true; + cb(); + }; + const sub = pairing.state$.subscribe({ + next: state => + onState( + state, + s => settle(() => resolve(s)), + e => settle(() => reject(e)), + () => sub?.unsubscribe(), + ), + complete: () => { + if (aborted) settle(() => reject(new AbortError('Aborted by user.'))); + }, + error: e => settle(() => reject(toError(e))), + }); + }), + toError, + ).andThen(success => persistAndNotify(identity, success, flowId)); }); - authResult = handshake(account, abort.signal, ssoFlowId) - .andThen(({ session, secretsPayload }) => { - return userSecretRepository - .write(secretsPayload.id, { - ssSecret: secretsPayload.ssSecret, - encrSecret: secretsPayload.encrSecret, - entropy: secretsPayload.entropy, - }) - .andThen(() => ssoSessionRepository.add(session)) - .map(() => session); - }) + authResult = flow + .orElse(e => (e instanceof AbortError ? okAsync(null) : errAsync(e))) .andTee(session => { - if (session) { + if (session === null) { + pairingStatus.reset(); + } else { + pairingStatus.write({ step: 'finished', session }); emitHostPappDebugMessage({ layer: 'sso', event: 'session_established', - flowId: ssoFlowId, + flowId, timestamp: Date.now(), payload: { sessionId: session.id }, }); } - }) - .orElse(e => (e instanceof AbortError ? ok(null) : err(e))) - .andTee(() => { - abort = null; + abortHandle = null; }) .orTee(e => { - authResult = null; - abort = null; + pairingStatus.write({ step: 'pairingError', message: e.message }); emitHostPappDebugMessage({ layer: 'sso', event: 'pairing_failed', - flowId: ssoFlowId, + flowId, timestamp: Date.now(), payload: { reason: e.message }, }); + authResult = null; + abortHandle = null; }); return authResult; + + function onState( + state: HandshakeState, + resolve: (value: HandshakeSuccessState) => void, + reject: (err: Error) => void, + unsubscribe: () => void, + ) { + switch (state.tag) { + case 'Idle': + case 'Submitted': + return; + case 'Pending': + pairingStatus.write({ step: 'pending', stage: state.reason }); + return; + case 'Success': + unsubscribe(); + emitHostPappDebugMessage({ + layer: 'sso', + event: 'response_received', + flowId, + timestamp: Date.now(), + payload: { identityAccountId: state.identityAccountId }, + }); + resolve(state); + return; + case 'Failed': + unsubscribe(); + reject(new Error(state.reason)); + return; + } + } }, abortAuthentication() { - if (abort) { - abort.abort(new AbortError('Aborted by user.')); - abort = null; - } + abortHandle?.(); + abortHandle = null; authResult = null; pairingStatus.reset(); }, }; - return authModule; -} - -const createHandshakeTopic = fromThrowable( - (account: LocalSessionAccount, encrPublicKey: EncrPublicKey) => - khash(account.accountId, mergeUint8([encrPublicKey, stringToBytes('topic')])), - toError, -); - -const createHandshakePayloadV1 = fromThrowable( - ({ - encrPublicKey, - ssPublicKey, - metadata, - hostMetadata, - }: { - encrPublicKey: EncrPublicKey; - ssPublicKey: SsPublicKey; - metadata: string; - hostMetadata?: HostMetadata; - }) => { - const hostVersion = hostMetadata?.hostVersion; - const osType = hostMetadata?.osType; - const osVersion = hostMetadata?.osVersion; - - return HandshakeData.enc( - enumValue('v1', { - ssPublicKey, - encrPublicKey, - metadata, - hostVersion, - osType, - osVersion, - }), + function persistAndNotify( + identity: DeviceIdentityForPairing, + success: HandshakeSuccessState, + _flowId: string, + ): ResultAsync { + const localAccount = createLocalSessionAccount(createAccountId(identity.statementAccountPublicKey)); + const remoteAccount = createRemoteSessionAccount( + createAccountId(success.peerStatementAccountId ?? new Uint8Array(32)), + success.deviceEncPubKey, + ); + const session = createStoredUserSession( + localAccount, + remoteAccount, + createAccountId(success.rootAccountId ?? new Uint8Array(32)), + { + identityAccountId: createAccountId(success.identityAccountId), + identityChatPublicKey: success.identityChatPublicKey, + }, ); - }, - toError, -); - -function parseHandshakePayload(payload: Uint8Array) { - const decoded = HandshakeResponsePayload.dec(payload); - - switch (decoded.tag) { - case 'v1': - return decoded.value; - default: - throw new Error('Unsupported handshake payload version'); - } -} - -const createEncrKeys = fromThrowable((entropy: Uint8Array) => { - const secret = createEncrSecret(entropy); - - return { - secret, - publicKey: getEncrPub(secret), - }; -}, toError); - -function retrieveSession({ - payload, - encrSecret, - localAccount, -}: { - payload: Uint8Array; - encrSecret: EncrSecret; - localAccount: LocalSessionAccount; -}): Result { - const { encrypted, tmpKey } = parseHandshakePayload(payload); - - const symmetricKey = createSharedSecret(encrSecret, tmpKey); - - return createEncryption(symmetricKey) - .decrypt(encrypted) - .map(decrypted => { - const { sharedSecretDerivationKey, rootUserAccountId, identityAccountId } = - HandshakeResponseSensitiveData.dec(decrypted); - const sharedSecret = createSharedSecret(encrSecret, sharedSecretDerivationKey); - const remoteAccount = createRemoteSessionAccount(createAccountId(identityAccountId), sharedSecret); - - return createStoredUserSession(localAccount, remoteAccount, createAccountId(rootUserAccountId)); - }); -} - -function createDeeplink(payload: Uint8Array) { - return `polkadotapp://pair?handshake=${toHex(payload)}`; -} - -function waitForStatements( - subscribe: (callback: Callback) => VoidFunction, - signal: AbortSignal, - callback: (statements: Statement[], resolve: (value: T) => void) => void, -): ResultAsync { - return fromPromise( - new Promise((resolve, reject) => { - const unsubscribe = subscribe(statements => { - const abortError = processSignal(signal).match( - () => null, - e => e, - ); - - if (abortError) { - unsubscribe(); - reject(abortError); - return; - } - - try { - callback(statements, value => { - unsubscribe(); - resolve(value); - }); - } catch (e) { - unsubscribe(); - reject(e); - } - }); - }), - toError, - ); -} -function processSignal(signal: AbortSignal) { - try { - signal.throwIfAborted(); - return ok(); - } catch (e) { - return err(toError(e)); + return userSecretRepository + .write(session.id, { + ssSecret: identity.statementAccountSecret as SsSecret, + encrSecret: identity.encryptionPrivateKey as EncrSecret, + entropy: new Uint8Array(0), + identityChatPrivateKey: success.identityChatPrivateKey, + }) + .andThen(() => ssoSessionRepository.add(session)) + .andThen(() => + onAuthSuccess + ? ResultAsync.fromPromise( + Promise.resolve(onAuthSuccess({ session, identityChatPrivateKey: success.identityChatPrivateKey })), + toError, + ).map(() => session) + : okAsync(session), + ); } } diff --git a/packages/host-papp/src/sso/auth/scale/handshake.ts b/packages/host-papp/src/sso/auth/scale/handshake.ts deleted file mode 100644 index f91a8deb..00000000 --- a/packages/host-papp/src/sso/auth/scale/handshake.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Enum } from '@novasamatech/scale'; -import { Bytes, Option, Struct, str } from 'scale-ts'; - -import { EncrPubKey, SsPubKey } from '../../../crypto.js'; - -const optStr = Option(str); - -export const HandshakeData = Enum({ - v1: Struct({ - ssPublicKey: SsPubKey, - encrPublicKey: EncrPubKey, - metadata: str, - hostVersion: optStr, - osType: optStr, - osVersion: optStr, - }), -}); - -export const HandshakeResponsePayload = Enum({ - v1: Struct({ encrypted: Bytes(), tmpKey: Bytes(65) }), -}); - -export const HandshakeResponseSensitiveData = Struct({ - sharedSecretDerivationKey: Bytes(65), - rootUserAccountId: Bytes(32), - identityAccountId: Bytes(32), -}); diff --git a/packages/host-papp/src/sso/auth/scale/handshakeV2.ts b/packages/host-papp/src/sso/auth/scale/handshakeV2.ts new file mode 100644 index 00000000..95d0d161 --- /dev/null +++ b/packages/host-papp/src/sso/auth/scale/handshakeV2.ts @@ -0,0 +1,211 @@ +/** + * SCALE codecs for the V2 SSO handshake (multi-device shape). + * + * The host emits a `VersionedHandshakeProposal::V2` via QR carrying its + * `Device { statementAccountId, encryptionPublicKey }` and metadata. The + * authorising peer (PApp) responds over the Statement Store with a + * `VersionedHandshakeResponse`; its body is ECDH-encrypted to the host's + * encryption public key with the peer's ephemeral `tmpKey`. The inner payload + * after decrypt is `EncryptedHandshakeResponseV2 = Pending | Success | Failed`. + * + * `Success` carries: + * - `identityAccountId` — user identity sr25519 accountId (32 bytes). + * Adressing for chat / username lookup / session + * topic derivation. + * - `rootAccountId` — user root sr25519 accountId (32 bytes). Parent + * for soft-derivation of product accounts; PApp + * and host MUST derive identically so a dapp sees + * the same address on every device. + * - `identityChatPrivateKey`— user identity chat P-256 private scalar (32 bytes), + * shared per the multi-device spec so this device + * can decrypt traffic addressed to the user identity + * - `deviceEncPubKey` — encryption public key of the PApp device (65 bytes, + * P-256 uncompressed). Tells the host which key to + * use when addressing chat envelopes back to the + * authorising PApp device. + * + * Total wire length of `Success` is 32 + 32 + 32 + 65 = 161 bytes. Transit security + * comes from the outer envelope's ECDH-AES wrap; no per-field signature is + * carried — multi-device authorisation is asserted by the user-identity-signed + * roster events (`DeviceAdded`/`DeviceRemoved`) published separately. + */ + +import { p256 } from '@noble/curves/nist.js'; +import { Bytes, Enum, Struct, Tuple, Vector, _void, str } from 'scale-ts'; + +const AccountIdCodec = Bytes(32); +const PublicKeyCodec = Bytes(65); +const PrivateKeyCodec = Bytes(32); + +// ── Proposal ──────────────────────────────────────────────────────────── + +export const MetadataKey = Enum({ + Custom: str, + HostName: _void, + HostVersion: _void, + HostIcon: _void, + PlatformType: _void, + PlatformVersion: _void, +}); + +export const MetadataEntry = Tuple(MetadataKey, str); + +export const Device = Struct({ + statementAccountId: AccountIdCodec, + encryptionPublicKey: PublicKeyCodec, +}); + +export const HandshakeProposalV2 = Struct({ + device: Device, + metadata: Vector(MetadataEntry), +}); + +/** + * V2 lives at SCALE discriminant 1; index 0 is reserved for legacy V1 which + * neither side emits. The `_v1Reserved` slot pushes V2 to discriminant 1. + */ +export const VersionedHandshakeProposal = Enum({ + _v1Reserved: _void, + V2: HandshakeProposalV2, +}); + +// ── Response (V2) ─────────────────────────────────────────────────────── + +/** 32 + 32 + 32 + 65 = 161 bytes (spec v0.2.1) */ +export const HandshakeSuccessV2 = Struct({ + identityAccountId: AccountIdCodec, + rootAccountId: AccountIdCodec, + identityChatPrivateKey: PrivateKeyCodec, + deviceEncPubKey: PublicKeyCodec, +}); + +export type HandshakeSuccessV2Value = { + identityAccountId: Uint8Array; + /** Nullable for v0.2 peers (Android `feature/location-for-handshake`). */ + rootAccountId: Uint8Array | null; + identityChatPrivateKey: Uint8Array; + deviceEncPubKey: Uint8Array; +}; + +/** 32 + 32 + 65 = 129 bytes (spec v0.2 — Android `feature/location-for-handshake`) */ +export const HandshakeSuccessV2Legacy = Struct({ + identityAccountId: AccountIdCodec, + identityChatPrivateKey: PrivateKeyCodec, + deviceEncPubKey: PublicKeyCodec, +}); + +export type DecodedHandshakeResponseV2 = + | { tag: 'Pending'; value: { tag: 'AllowanceAllocation'; value: undefined } } + | { tag: 'Success'; value: HandshakeSuccessV2Value } + | { tag: 'Failed'; value: string }; + +/** + * Length-dispatched decoder for the inner `EncryptedHandshakeResponseV2` + * plaintext. v0.2 (Android) ships 129-byte Success bodies without + * `rootAccountId`; v0.2.1 ships 161-byte bodies with it. Surfaces + * `rootAccountId: null` for the legacy case — chat doesn't need it. + */ +export const decodeEncryptedHandshakeResponseV2 = (bytes: Uint8Array): DecodedHandshakeResponseV2 => { + if (bytes.length === 0) throw new Error('EncryptedHandshakeResponseV2: empty plaintext'); + const tag = bytes[0]; + // `slice` not `subarray` — scale-ts decoders read from buffer byteOffset 0. + const body = bytes.slice(1); + if (tag === 0) { + if (body.length === 0) throw new Error('EncryptedHandshakeResponseV2: Pending body empty'); + return { tag: 'Pending', value: { tag: 'AllowanceAllocation', value: undefined } }; + } + if (tag === 1) { + if (body.length === 161) { + const decoded = HandshakeSuccessV2.dec(body); + return { + tag: 'Success', + value: { + identityAccountId: decoded.identityAccountId, + rootAccountId: decoded.rootAccountId, + identityChatPrivateKey: decoded.identityChatPrivateKey, + deviceEncPubKey: decoded.deviceEncPubKey, + }, + }; + } + if (body.length === 129) { + const decoded = HandshakeSuccessV2Legacy.dec(body); + return { + tag: 'Success', + value: { + identityAccountId: decoded.identityAccountId, + rootAccountId: null, + identityChatPrivateKey: decoded.identityChatPrivateKey, + deviceEncPubKey: decoded.deviceEncPubKey, + }, + }; + } + throw new Error(`EncryptedHandshakeResponseV2: Success body length ${body.length} not in {129, 161}`); + } + if (tag === 2) { + return { tag: 'Failed', value: str.dec(body) }; + } + throw new Error(`EncryptedHandshakeResponseV2: unknown variant tag ${tag}`); +}; + +/** + * Derive the user identity chat P-256 public key from the shared private + * scalar received in `HandshakeSuccessV2`. Both desktop and mobile must derive + * identically (uncompressed 65-byte form) so downstream session topics agree. + */ +export const deriveIdentityChatPublicKey = (privateKey: Uint8Array): Uint8Array => p256.getPublicKey(privateKey, false); + +/** + * Inner Pending sub-statuses. Only `AllowanceAllocation` today. + * + * Encoded with its own SCALE discriminant byte — the peer's SCALE library does + * NOT elide enum-variant indices for sealed interfaces (verified on the wire: + * `Pending(AllowanceAllocation)` arrives as `0x00 0x00`). Both sides must keep + * the discriminant when encoding so dispatch agrees. + */ +export const HandshakeStatusV2 = Enum({ + AllowanceAllocation: _void, +}); + +export type EncryptedHandshakeResponseV2Value = + | { tag: 'Pending'; value: { tag: 'AllowanceAllocation'; value: undefined } } + | { tag: 'Success'; value: HandshakeSuccessV2Value } + | { tag: 'Failed'; value: string }; + +/** + * Inner handshake response variant. Wire shape (matches peer SCALE encoding): + * + * - Pending → 0x00 || HandshakeStatusV2 (today: 0x00 for AllowanceAllocation), 2 bytes total + * - Success → 0x01 || HandshakeSuccessV2 (161 bytes), 162 bytes total + * - Failed → 0x02 || SCALE-encoded UTF-8 reason string + * + * Earlier builds shipped a custom length-dispatched codec assuming elision — + * that assumption was wrong and produced false `Failed("")` reads on every + * `Pending` response. Native scale-ts `Enum` preserves the discriminant and + * matches the peer's wire format directly. + */ +export const EncryptedHandshakeResponseV2 = Enum({ + Pending: HandshakeStatusV2, + Success: HandshakeSuccessV2, + Failed: str, +}); + +export const HandshakeResponseV2 = Struct({ + encrypted: Bytes(), + tmpKey: PublicKeyCodec, +}); + +/** Legacy V1 response shape — decoded for backward compat, never emitted by the V2 path. */ +export const HandshakeResponseV1 = Struct({ + encrypted: Bytes(), + tmpKey: PublicKeyCodec, +}); + +export const EncryptedHandshakeResponseV1 = Struct({ + encryptionKey: PublicKeyCodec, + accountId: AccountIdCodec, +}); + +export const VersionedHandshakeResponse = Enum({ + V1: HandshakeResponseV1, + V2: HandshakeResponseV2, +}); diff --git a/packages/host-papp/src/sso/auth/v2/envelope.ts b/packages/host-papp/src/sso/auth/v2/envelope.ts new file mode 100644 index 00000000..cd5080b2 --- /dev/null +++ b/packages/host-papp/src/sso/auth/v2/envelope.ts @@ -0,0 +1,34 @@ +/** + * Decrypt the outer envelope of a `HandshakeResponseV2` statement payload. + * + * The answering side generates a one-shot P-256 keypair, performs ECDH against + * the host device's encryption public key, and AES-GCM encrypts the sensitive + * payload (the SCALE-encoded `EncryptedHandshakeResponseV2`) with a key + * derived from the shared secret. + * + * The shared-secret-to-AES-key derivation (HKDF-SHA256 over the ECDH X + * coordinate) is delegated to `createEncryption(sharedSecret)` from + * `@novasamatech/statement-store` — byte-compatible with the existing V1 + * chat-request encryption helper, so we don't fork primitives here. + */ + +import { p256 } from '@noble/curves/nist.js'; +import { createEncryption } from '@novasamatech/statement-store'; + +export type HandshakeResponseEnvelope = { + encrypted: Uint8Array; + tmpKey: Uint8Array; +}; + +const ecdhX = (privateKey: Uint8Array, peerPublicKey: Uint8Array): Uint8Array => + p256.getSharedSecret(privateKey, peerPublicKey).slice(1, 33); + +export const decryptResponseEnvelope = ( + deviceEncryptionPrivateKey: Uint8Array, + envelope: HandshakeResponseEnvelope, +): Uint8Array => { + const shared = ecdhX(deviceEncryptionPrivateKey, envelope.tmpKey); + const result = createEncryption(shared).decrypt(envelope.encrypted); + if (result.isErr()) throw result.error; + return result.value; +}; diff --git a/packages/host-papp/src/sso/auth/v2/proposal.ts b/packages/host-papp/src/sso/auth/v2/proposal.ts new file mode 100644 index 00000000..aec91a04 --- /dev/null +++ b/packages/host-papp/src/sso/auth/v2/proposal.ts @@ -0,0 +1,76 @@ +/** + * Build the V2 SSO pairing proposal that the host emits via QR / deeplink. + * + * The encoded proposal goes into the `handshake` query parameter of a + * `polkadotapp://pair?handshake=` deeplink. The peer scans the QR, + * SCALE-decodes the bytes back into a `VersionedHandshakeProposal`, posts an + * encrypted answer to the corresponding pairing topic, and the host's + * subscription picks it up. + */ + +import { toHex } from 'polkadot-api/utils'; + +import { VersionedHandshakeProposal } from '../scale/handshakeV2.js'; + +const PAIRING_DEEPLINK_PREFIX = 'polkadotapp://pair?handshake='; + +export type HandshakeProposalDevice = { + statementAccountPublicKey: Uint8Array; + encryptionPublicKey: Uint8Array; +}; + +export type HandshakeMetadata = { + hostName?: string; + hostVersion?: string; + hostIcon?: string; + platformType?: string; + platformVersion?: string; + custom?: { name: string; value: string }[]; +}; + +type MetadataEntryT = [ + ( + | { tag: 'Custom'; value: string } + | { tag: 'HostName'; value: undefined } + | { tag: 'HostVersion'; value: undefined } + | { tag: 'HostIcon'; value: undefined } + | { tag: 'PlatformType'; value: undefined } + | { tag: 'PlatformVersion'; value: undefined } + ), + string, +]; + +const buildMetadataEntries = (metadata: HandshakeMetadata): MetadataEntryT[] => { + const entries: MetadataEntryT[] = []; + if (metadata.hostName !== undefined) entries.push([{ tag: 'HostName', value: undefined }, metadata.hostName]); + if (metadata.hostVersion !== undefined) + entries.push([{ tag: 'HostVersion', value: undefined }, metadata.hostVersion]); + if (metadata.hostIcon !== undefined) entries.push([{ tag: 'HostIcon', value: undefined }, metadata.hostIcon]); + if (metadata.platformType !== undefined) + entries.push([{ tag: 'PlatformType', value: undefined }, metadata.platformType]); + if (metadata.platformVersion !== undefined) { + entries.push([{ tag: 'PlatformVersion', value: undefined }, metadata.platformVersion]); + } + for (const c of metadata.custom ?? []) { + entries.push([{ tag: 'Custom', value: c.name }, c.value]); + } + return entries; +}; + +export const encodeProposal = (device: HandshakeProposalDevice, metadata: HandshakeMetadata): Uint8Array => + VersionedHandshakeProposal.enc({ + tag: 'V2', + value: { + device: { + statementAccountId: device.statementAccountPublicKey, + encryptionPublicKey: device.encryptionPublicKey, + }, + metadata: buildMetadataEntries(metadata), + }, + }); + +export const buildPairingDeeplink = (device: HandshakeProposalDevice, metadata: HandshakeMetadata): string => { + const bytes = encodeProposal(device, metadata); + const hex = toHex(bytes).replace(/^0x/, ''); + return `${PAIRING_DEEPLINK_PREFIX}${hex}`; +}; diff --git a/packages/host-papp/src/sso/auth/v2/service.ts b/packages/host-papp/src/sso/auth/v2/service.ts new file mode 100644 index 00000000..3703224a --- /dev/null +++ b/packages/host-papp/src/sso/auth/v2/service.ts @@ -0,0 +1,248 @@ +/** + * Orchestrates a single V2 SSO pairing exchange. + * + * Wires the codec, envelope, topic, and state-machine pieces together: + * 1. Encode the device's `VersionedHandshakeProposal::V2` and build the + * `polkadotapp://pair?handshake=` deeplink (consumed by the QR UI). + * 2. Compute the pairing topic from the device pubkeys and subscribe to the + * Statement Store on it. + * 3. For each incoming statement: SCALE-decode `VersionedHandshakeResponse`, + * pull out the `V2` envelope, ECDH-decrypt the inner payload using the + * device's encryption private key, SCALE-decode `EncryptedHandshakeResponseV2`, + * and feed the result through the state machine via `fromInnerResponse` + + * `advance`. + * 4. On `Success`, invoke the caller-supplied `persistOnSuccess` and stop. + * + * The chain RPC subscription only delivers a statement once when it first + * appears on a topic; channel replacements (Pending → Success on the same + * channel) don't reliably get re-broadcast as new events. So the service also + * polls `queryStatements` every 2s alongside the live subscription. Polling + * stops on terminal state and on `abort()`. + * + * Returns an Observable of `HandshakeState` and an `abort()` for cleanup — + * higher layers drive a UI hook off this and update an onboarding screen. + */ + +import type { Statement, StatementStoreAdapter } from '@novasamatech/statement-store'; +import type { Observable } from 'rxjs'; +import { BehaviorSubject } from 'rxjs'; + +import { VersionedHandshakeResponse, decodeEncryptedHandshakeResponseV2 } from '../scale/handshakeV2.js'; + +import { decryptResponseEnvelope } from './envelope.js'; +import type { HandshakeMetadata } from './proposal.js'; +import { buildPairingDeeplink } from './proposal.js'; +import type { HandshakeState, HandshakeSuccessState } from './state.js'; +import { advance, fromInnerResponse, isTerminal, submitted } from './state.js'; +import { computePairingTopic } from './topic.js'; + +export type DeviceIdentityForPairing = { + statementAccountPublicKey: Uint8Array; + /** + * sr25519 secret for the device statement account. `startPairingV2` itself + * doesn't sign anything, but the post-pairing `StoredUserSession` needs it + * so the V1 sessionManager prover can issue session statements. + */ + statementAccountSecret: Uint8Array; + encryptionPublicKey: Uint8Array; + encryptionPrivateKey: Uint8Array; +}; + +export type StartPairingDeps = { + statementStore: StatementStoreAdapter; + deviceIdentity: DeviceIdentityForPairing; + metadata: HandshakeMetadata; + persistOnSuccess?: (success: HandshakeSuccessState) => Promise; + /** + * Hex of a previously-processed statement. The service will skip statements + * whose bytes match this value, treating them as already-handled. Useful for + * surviving page reloads / logouts so a stale Success on chain doesn't get + * replayed before the user re-authenticates. + */ + initialProcessedDataHex?: string | null; + /** + * Fires whenever the service starts processing a new statement (i.e. after + * the byte-level dedupe passes). Callers persist the hex so the next + * `initialProcessedDataHex` value is up to date. + */ + onStatementProcessed?: (dataHex: string) => void; +}; + +export type Pairing = { + qrPayload: string; + state$: Observable; + abort: () => void; +}; + +const DEFAULT_POLL_INTERVAL_MS = 2_000; + +const toHexFull = (bytes: Uint8Array) => { + let out = ''; + for (const b of bytes) out += b.toString(16).padStart(2, '0'); + return `0x${out}`; +}; + +const fromHexString = (hex: string): Uint8Array => { + const stripped = hex.startsWith('0x') ? hex.slice(2) : hex; + const out = new Uint8Array(stripped.length / 2); + for (let i = 0; i < out.length; i++) { + out[i] = parseInt(stripped.slice(i * 2, i * 2 + 2), 16); + } + return out; +}; + +const extractStatementSigner = (statement: Statement): Uint8Array | null => { + const proof = statement.proof; + if (!proof) return null; + if (proof.type !== 'sr25519' && proof.type !== 'ed25519') return null; + try { + return fromHexString(proof.value.signer); + } catch { + return null; + } +}; + +export const startPairingV2 = (deps: StartPairingDeps): Pairing => { + const persistOnSuccess = deps.persistOnSuccess; + + const qrPayload = buildPairingDeeplink( + { + statementAccountPublicKey: deps.deviceIdentity.statementAccountPublicKey, + encryptionPublicKey: deps.deviceIdentity.encryptionPublicKey, + }, + deps.metadata, + ); + + const state$ = new BehaviorSubject(submitted()); + const topic = computePairingTopic( + deps.deviceIdentity.statementAccountPublicKey, + deps.deviceIdentity.encryptionPublicKey, + ); + + let aborted = false; + let unsubscribe: (() => void) | null = null; + let pollHandle: ReturnType | null = null; + let lastProcessedDataHex: string | null = deps.initialProcessedDataHex ?? null; + + const stopPolling = () => { + if (pollHandle === null) return; + clearInterval(pollHandle); + pollHandle = null; + }; + + const log = (msg: string, extra?: unknown) => { + if (extra === undefined) console.info(`[sso-v2] ${msg}`); + else console.info(`[sso-v2] ${msg}`, extra); + }; + + log(`subscribing to pairing topic ${toHexFull(topic)}`); + log(`device statementAccountId ${toHexFull(deps.deviceIdentity.statementAccountPublicKey)}`); + log(`device encryptionPublicKey ${toHexFull(deps.deviceIdentity.encryptionPublicKey)}`); + + // One-shot probe: query the topic right away so we know whether any answer + // statement was already posted before our subscription connected. + void deps.statementStore.queryStatements({ matchAll: [topic] }).match( + statements => log(`queryStatements probe: ${statements.length} statement(s)`), + err => log('queryStatements probe failed', err), + ); + + const handleStatement = (statement: Statement) => { + if (aborted || isTerminal(state$.value)) return; + if (!statement.data) return; + const dataHex = toHexFull(statement.data); + if (dataHex === lastProcessedDataHex) return; + lastProcessedDataHex = dataHex; + deps.onStatementProcessed?.(dataHex); + log(`statement received, ${statement.data.length}-byte payload`); + + let envelope: { encrypted: Uint8Array; tmpKey: Uint8Array }; + try { + const decoded = VersionedHandshakeResponse.dec(statement.data); + if (decoded.tag !== 'V2') { + log(`response is not V2 (tag=${decoded.tag}) — dropping`); + return; + } + envelope = decoded.value; + } catch (err) { + log('VersionedHandshakeResponse.dec threw — dropping', err); + return; + } + + let innerBytes: Uint8Array; + try { + innerBytes = decryptResponseEnvelope(deps.deviceIdentity.encryptionPrivateKey, envelope); + } catch (err) { + log('outer envelope decrypt failed — wrong recipient or tampered', err); + return; + } + + const peerStatementAccountId = extractStatementSigner(statement); + + let next: HandshakeState; + try { + next = fromInnerResponse(decodeEncryptedHandshakeResponseV2(innerBytes), peerStatementAccountId); + } catch (err) { + log(`inner decode failed; innerBytes (${innerBytes.length}b) = ${toHexFull(innerBytes)}`, err); + return; + } + + log(`decoded inner response, tag=${next.tag}`); + if (next.tag === 'Failed') { + log(`failure reason: "${next.reason}" (innerBytes ${innerBytes.length}b = ${toHexFull(innerBytes)})`); + } + + const advanced = advance(state$.value, next); + if (advanced === state$.value) { + // Same-tag idempotence is the common case (every poll re-fetches the + // current statement); only log when a transition was actually rejected + // for protocol-state reasons (e.g. Success → Pending). + if (advanced.tag !== next.tag) { + log(`advance() rejected ${state$.value.tag} → ${next.tag} — dropping`); + } + return; + } + log(`state ${state$.value.tag} → ${advanced.tag}`); + state$.next(advanced); + + if (advanced.tag === 'Success' && persistOnSuccess !== undefined) { + void persistOnSuccess(advanced).catch(err => { + console.warn('persistHandshakeSuccess failed', err); + }); + } + + if (isTerminal(advanced)) { + stopPolling(); + } + }; + + unsubscribe = deps.statementStore.subscribeStatements({ matchAll: [topic] }, page => { + log(`subscription page: ${page.statements.length} statement(s), isComplete=${page.isComplete}`); + for (const stmt of page.statements) handleStatement(stmt); + }); + + // Poll because the chain RPC subscription doesn't deliver channel + // replacements as new events. handleStatement dedupes on data bytes so + // re-fetching the same Pending tick after tick is silent. + pollHandle = setInterval(() => { + if (aborted || isTerminal(state$.value)) return; + deps.statementStore.queryStatements({ matchAll: [topic] }).match( + statements => { + for (const stmt of statements) handleStatement(stmt); + }, + err => log('poll queryStatements failed', err), + ); + }, DEFAULT_POLL_INTERVAL_MS); + + return { + qrPayload, + state$: state$.asObservable(), + abort: () => { + if (aborted) return; + aborted = true; + stopPolling(); + unsubscribe?.(); + unsubscribe = null; + state$.complete(); + }, + }; +}; diff --git a/packages/host-papp/src/sso/auth/v2/state.ts b/packages/host-papp/src/sso/auth/v2/state.ts new file mode 100644 index 00000000..633e469d --- /dev/null +++ b/packages/host-papp/src/sso/auth/v2/state.ts @@ -0,0 +1,123 @@ +/** + * Handshake V2 state machine — the public-facing observable shape of an + * in-flight SSO pairing exchange. + * + * Maps directly onto the inner `EncryptedHandshakeResponseV2` enum: + * + * Idle — no proposal emitted yet + * Submitted — proposal QR shown, waiting for the first response statement + * Pending — peer acknowledged; allocating Statement Store allowance on-chain + * Success — final state; identity keys received, device authorised + * Failed — final state; peer rejected (declined / duplicate / no-slot / tx-failed) + * + * Transitions are unidirectional except for Failed → Idle (user retries). + * The state object is what UIs render and what the chat layer gates on + * before submitting any V2 statements. + */ + +import type { DecodedHandshakeResponseV2 } from '../scale/handshakeV2.js'; +import { deriveIdentityChatPublicKey } from '../scale/handshakeV2.js'; + +export type HandshakeIdleState = { tag: 'Idle' }; +export type HandshakeSubmittedState = { tag: 'Submitted' }; +export type HandshakePendingState = { tag: 'Pending'; reason: 'AllowanceAllocation' }; +export type HandshakeSuccessState = { + tag: 'Success'; + /** User identity sr25519 accountId (32 bytes). */ + identityAccountId: Uint8Array; + /** + * User root sr25519 accountId (32 bytes) — the parent for soft-derivation + * of product accounts. Nullable: peers on spec v0.2 (Android + * `feature/location-for-handshake`) omit this field. Product-account + * derivation degrades gracefully when absent; chat does not use it. + */ + rootAccountId: Uint8Array | null; + /** + * User identity chat P-256 private key (32 bytes raw scalar) shared by + * PApp with this device per the multi-device spec. Sensitive; persist in + * OS-keychain-backed secure storage and never forward. + */ + identityChatPrivateKey: Uint8Array; + /** + * Derived locally from `identityChatPrivateKey` via P-256 scalar + * multiplication (uncompressed 65-byte form). Both sides MUST derive + * identically; downstream session topics depend on it. + */ + identityChatPublicKey: Uint8Array; + /** + * Encryption public key of the authorising PApp device (65 bytes, + * P-256 uncompressed). Used by the host when addressing chat envelopes + * back to the authorising device. + */ + deviceEncPubKey: Uint8Array; + /** + * The pairing-topic statement was signed by PApp's device statement + * account. `HandshakeSuccessV2` doesn't carry it in the encrypted body, so + * the pairing service lifts it off `statement.proof.value.signer` and + * attaches it here. `null` only when the statement arrived without a + * recognised proof type, in which case device-sync can't seed back to PApp. + */ + peerStatementAccountId: Uint8Array | null; +}; +export type HandshakeFailedState = { tag: 'Failed'; reason: string }; + +export type HandshakeState = + | HandshakeIdleState + | HandshakeSubmittedState + | HandshakePendingState + | HandshakeSuccessState + | HandshakeFailedState; + +export const idle = (): HandshakeIdleState => ({ tag: 'Idle' }); + +export const submitted = (): HandshakeSubmittedState => ({ tag: 'Submitted' }); + +/** + * Translate the length-dispatched-decoded `EncryptedHandshakeResponseV2` into + * the public state. Pure — no I/O. The caller decrypts the outer envelope and + * runs `decodeEncryptedHandshakeResponseV2` first. + */ +export const fromInnerResponse = ( + response: DecodedHandshakeResponseV2, + peerStatementAccountId: Uint8Array | null = null, +): HandshakeState => { + switch (response.tag) { + case 'Pending': + // Only AllowanceAllocation today; widen here when the spec adds more variants. + return { tag: 'Pending', reason: 'AllowanceAllocation' }; + case 'Success': + return { + tag: 'Success', + identityAccountId: response.value.identityAccountId, + rootAccountId: response.value.rootAccountId, + identityChatPrivateKey: response.value.identityChatPrivateKey, + identityChatPublicKey: deriveIdentityChatPublicKey(response.value.identityChatPrivateKey), + deviceEncPubKey: response.value.deviceEncPubKey, + peerStatementAccountId, + }; + case 'Failed': + return { tag: 'Failed', reason: response.value }; + } +}; + +/** + * Forward-only transition guard: rejects regressions like Success → Pending. + * Idempotent on same-tag transitions (returns current by reference) so callers + * can use `next === current` to detect "no change" without per-call equality. + */ +export const advance = (current: HandshakeState, next: HandshakeState): HandshakeState => { + if (isTerminal(current)) return current; + if (current.tag === 'Idle' && next.tag !== 'Submitted') return current; + if (current.tag === 'Submitted' && next.tag === 'Idle') return current; + if (current.tag === next.tag) return current; + return next; +}; + +export const isTerminal = (state: HandshakeState): state is HandshakeSuccessState | HandshakeFailedState => + state.tag === 'Success' || state.tag === 'Failed'; + +/** + * True only when the device is authorised to submit V2 statements. The + * chat-send path gates on this — V2 messages must wait for allowance. + */ +export const canSubmitV2Statements = (state: HandshakeState): state is HandshakeSuccessState => state.tag === 'Success'; diff --git a/packages/host-papp/src/sso/auth/v2/topic.ts b/packages/host-papp/src/sso/auth/v2/topic.ts new file mode 100644 index 00000000..f61a77ce --- /dev/null +++ b/packages/host-papp/src/sso/auth/v2/topic.ts @@ -0,0 +1,27 @@ +/** + * Pairing topic + channel derivation for the V2 SSO handshake. + * + * topic = blake2b256_keyed(encryptionPublicKey || "topic", key=statementAccountId) + * channel = blake2b256_keyed(encryptionPublicKey || "channel", key=statementAccountId) + * + * Where: + * - `statementAccountId` = the host's sr25519 device public key (32 bytes) + * - `encryptionPublicKey` = the host's P-256 device public key (65 bytes uncompressed) + * + * Both sides compute the same topic/channel deterministically from the same + * pubkeys carried in the QR-coded `VersionedHandshakeProposal::V2`, so they + * agree on where the response statement is delivered without any shared + * secret negotiation. + */ + +import { khash } from '@novasamatech/statement-store'; +import { mergeUint8 } from '@polkadot-api/utils'; + +const TOPIC_SUFFIX = new TextEncoder().encode('topic'); +const CHANNEL_SUFFIX = new TextEncoder().encode('channel'); + +export const computePairingTopic = (statementAccountId: Uint8Array, encryptionPublicKey: Uint8Array): Uint8Array => + khash(statementAccountId, mergeUint8([encryptionPublicKey, TOPIC_SUFFIX])); + +export const computePairingChannel = (statementAccountId: Uint8Array, encryptionPublicKey: Uint8Array): Uint8Array => + khash(statementAccountId, mergeUint8([encryptionPublicKey, CHANNEL_SUFFIX])); diff --git a/packages/host-papp/src/sso/deviceIdentityStore.ts b/packages/host-papp/src/sso/deviceIdentityStore.ts new file mode 100644 index 00000000..41d18c97 --- /dev/null +++ b/packages/host-papp/src/sso/deviceIdentityStore.ts @@ -0,0 +1,109 @@ +import { gcm } from '@noble/ciphers/aes.js'; +import { blake2b } from '@noble/hashes/blake2.js'; +import { createSr25519Secret, deriveSr25519PublicKey } from '@novasamatech/statement-store'; +import type { StorageAdapter } from '@novasamatech/storage-adapter'; +import type { ResultAsync } from 'neverthrow'; +import { errAsync, fromPromise, okAsync } from 'neverthrow'; +import { fromHex, toHex } from 'polkadot-api/utils'; +import { Bytes, Option, Struct, str } from 'scale-ts'; + +import type { EncrPublicKey, EncrSecret, SsPublicKey, SsSecret } from '../crypto.js'; +import { getEncrPub, stringToBytes } from '../crypto.js'; +import { toError } from '../helpers/utils.js'; + +import type { DeviceIdentityForPairing } from './auth/v2/service.js'; + +const KEY = 'DeviceIdentity'; + +// Persisted shape — kept under appId-derived AES-GCM at rest, same pattern as +// UserSecretRepository. +const StoredDeviceCodec = Struct({ + statementAccountSeed: Bytes(32), + encryptionPrivateKey: Bytes(32), + lastProcessedHandshakeStatement: Option(str), +}); + +type Stored = { + statementAccountSeed: Uint8Array; + encryptionPrivateKey: Uint8Array; + lastProcessedHandshakeStatement: string | undefined; +}; + +export type DeviceIdentity = DeviceIdentityForPairing & { + statementAccountSecret: SsSecret; +}; + +export type DeviceIdentityStore = { + loadOrCreate(): ResultAsync; + readLastProcessedHandshakeStatement(): ResultAsync; + writeLastProcessedHandshakeStatement(hex: string): ResultAsync; +}; + +export function createDeviceIdentityStore(salt: string, storage: StorageAdapter): DeviceIdentityStore { + const aes = () => gcm(blake2b(stringToBytes(salt), { dkLen: 16 }), blake2b(stringToBytes('nonce'), { dkLen: 32 })); + + const decode = (raw: string | null): Stored | null => { + if (!raw) return null; + try { + const decrypted = aes().decrypt(fromHex(raw)); + return StoredDeviceCodec.dec(decrypted); + } catch { + // 0.7.x had no DeviceIdentity key; any decode failure here means a + // schema rev or tampered blob — drop it and regenerate. + return null; + } + }; + + const encode = (stored: Stored): string => toHex(aes().encrypt(StoredDeviceCodec.enc(stored))); + + const read = (): ResultAsync => storage.read(KEY).map(decode); + const write = (stored: Stored): ResultAsync => storage.write(KEY, encode(stored)).map(() => undefined); + + const expand = (stored: Stored): DeviceIdentity => { + const statementAccountSecret = createSr25519Secret(stored.statementAccountSeed) as SsSecret; + const statementAccountPublicKey = deriveSr25519PublicKey(statementAccountSecret) as SsPublicKey; + const encryptionPrivateKey = stored.encryptionPrivateKey as EncrSecret; + const encryptionPublicKey = getEncrPub(encryptionPrivateKey) as EncrPublicKey; + return { statementAccountPublicKey, statementAccountSecret, encryptionPublicKey, encryptionPrivateKey }; + }; + + const generate = (): Stored => ({ + statementAccountSeed: crypto.getRandomValues(new Uint8Array(32)), + encryptionPrivateKey: crypto.getRandomValues(new Uint8Array(32)), + lastProcessedHandshakeStatement: undefined, + }); + + return { + loadOrCreate() { + return read().andThen(existing => { + if (existing) return okAsync(expand(existing)); + const fresh = generate(); + return write(fresh).map(() => expand(fresh)); + }); + }, + readLastProcessedHandshakeStatement() { + return read().map(stored => stored?.lastProcessedHandshakeStatement ?? null); + }, + writeLastProcessedHandshakeStatement(hex: string) { + return read().andThen(existing => { + if (!existing) { + // No identity yet — caller will populate via loadOrCreate first. + return errAsync(new Error('writeLastProcessedHandshakeStatement: no device identity persisted')); + } + return write({ ...existing, lastProcessedHandshakeStatement: hex }); + }); + }, + }; +} + +// Re-export the awaitable form for convenience, since most call sites already +// live in async functions. +export const awaitDeviceIdentity = (store: DeviceIdentityStore): Promise => + fromPromise(Promise.resolve(), toError) + .andThen(() => store.loadOrCreate()) + .match( + ok => ok, + err => { + throw err; + }, + ); diff --git a/packages/host-papp/src/sso/sessionManager/userSession.ts b/packages/host-papp/src/sso/sessionManager/userSession.ts index 19879d94..3156fb2d 100644 --- a/packages/host-papp/src/sso/sessionManager/userSession.ts +++ b/packages/host-papp/src/sso/sessionManager/userSession.ts @@ -138,10 +138,7 @@ export function createUserSession({ }); return { - id: userSession.id, - localAccount: userSession.localAccount, - remoteAccount: userSession.remoteAccount, - rootAccountId: userSession.rootAccountId, + ...userSession, signPayload(payload) { return requestQueue.call(() => { diff --git a/packages/host-papp/src/sso/userSecretRepository.ts b/packages/host-papp/src/sso/userSecretRepository.ts index 029b71c8..8ca6bf7c 100644 --- a/packages/host-papp/src/sso/userSecretRepository.ts +++ b/packages/host-papp/src/sso/userSecretRepository.ts @@ -12,10 +12,14 @@ import { BrandedBytesCodec, stringToBytes } from '../crypto.js'; import { toError } from '../helpers/utils.js'; type StoredUserSecrets = CodecType; + const StoredUserSecretsCodec = Struct({ ssSecret: BrandedBytesCodec(), encrSecret: BrandedBytesCodec(), entropy: Bytes(), + // V2 addition: user identity chat private key (P-256 raw scalar, 32 bytes). + // Sensitive — kept encrypted at rest alongside the per-session ss/encr secrets. + identityChatPrivateKey: Bytes(32), }); export type UserSecretRepository = ReturnType; @@ -24,10 +28,17 @@ export function createUserSecretRepository(salt: string, storage: StorageAdapter const baseKey = 'UserSecrets'; const encode = fromThrowable(StoredUserSecretsCodec.enc, toError); - const decode = fromThrowable( - (value: Uint8Array | null) => (value ? StoredUserSecretsCodec.dec(value) : null), - toError, - ); + const decode = fromThrowable((value: Uint8Array | null) => { + if (!value) return null; + try { + return StoredUserSecretsCodec.dec(value); + } catch { + // 0.7.x V1 blobs are missing `identityChatPrivateKey` and decode short. + // Treat as absent — a fresh handshake (required after 0.8.0 upgrade) + // will overwrite. + return null; + } + }, toError); const encrypt = fromThrowable((value: Uint8Array) => { const aes = getAes(salt); diff --git a/packages/host-papp/src/sso/userSessionRepository.ts b/packages/host-papp/src/sso/userSessionRepository.ts index b5f089b4..ed318124 100644 --- a/packages/host-papp/src/sso/userSessionRepository.ts +++ b/packages/host-papp/src/sso/userSessionRepository.ts @@ -5,29 +5,41 @@ import { fieldListView } from '@novasamatech/storage-adapter'; import { nanoid } from 'nanoid'; import { fromHex, toHex } from 'polkadot-api/utils'; import type { CodecType } from 'scale-ts'; -import { Struct, Vector, str } from 'scale-ts'; +import { Bytes, Option, Struct, Vector, str } from 'scale-ts'; export type UserSessionRepository = ReturnType; export type StoredUserSession = CodecType; +// V2 fields trail V1 fields so a future schema rev can append further +// `Option`-wrapped fields without breaking decode of 0.8.0 blobs. const storedUserSessionCodec = Struct({ id: str, localAccount: LocalSessionAccountCodec, remoteAccount: RemoteSessionAccountCodec, rootAccountId: AccountIdCodec, + identityAccountId: Option(AccountIdCodec), + identityChatPublicKey: Option(Bytes(65)), }); +type StoredUserSessionV2Extras = { + identityAccountId?: AccountId; + identityChatPublicKey?: Uint8Array; +}; + export function createStoredUserSession( localAccount: LocalSessionAccount, remoteAccount: RemoteSessionAccount, rootAccountId: AccountId, + extras: StoredUserSessionV2Extras = {}, ): StoredUserSession { return { id: nanoid(12), - localAccount: localAccount, - remoteAccount: remoteAccount, + localAccount, + remoteAccount, rootAccountId, + identityAccountId: extras.identityAccountId, + identityChatPublicKey: extras.identityChatPublicKey, }; } @@ -37,7 +49,17 @@ export const createUserSessionRepository = (storage: StorageAdapter) => { return fieldListView({ storage, key: 'SsoSessions', - from: x => codec.dec(fromHex(x)), + from: x => { + try { + return codec.dec(fromHex(x)); + } catch { + // 0.7.x V1 blobs use the prior codec shape and won't decode against + // V2's extended struct. Treat as empty so the caller (and the + // fieldListView mutate machinery) start clean; the next write + // overwrites the bad blob. + return []; + } + }, to: x => toHex(codec.enc(x)), }); }; From 094e4ce06b7803057e276fca5d40d087258c76ac Mon Sep 17 00:00:00 2001 From: Ilya Date: Fri, 22 May 2026 06:40:32 -0600 Subject: [PATCH 14/31] feat(host-chat): add paseo-next-v2 network config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit V2 People chain endpoints — needed by desktop env constants that have been falling back to paseo-next while the V2 entry was missing. --- packages/host-chat/src/accountService.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/host-chat/src/accountService.ts b/packages/host-chat/src/accountService.ts index c9c4c66a..1e4d5e04 100644 --- a/packages/host-chat/src/accountService.ts +++ b/packages/host-chat/src/accountService.ts @@ -23,7 +23,7 @@ type AccountService = { getConsumerInfo(address: string): ResultAsync; }; -type Network = 'paseo-next' | 'preview' | 'stable'; +type Network = 'paseo-next' | 'paseo-next-v2' | 'preview' | 'stable'; type SearchResponse = { candidateAccountId: string; @@ -146,4 +146,10 @@ const NETWORK_CONFIGS: Record = { wsUrl: 'wss://paseo-people-next-rpc.polkadot.io', apiUrl: 'https://identity-backend.parity-testnet.parity.io/api/v1', }, + 'paseo-next-v2': { + id: 'paseo-next-v2', + name: 'Paseo Next V2', + wsUrl: 'wss://paseo-people-next-system-rpc.polkadot.io', + apiUrl: 'https://identity-backend-next.parity-testnet.parity.io/api/v1', + }, }; From 34b7ff44aa317a41316d5eb940e11ce149db81ce Mon Sep 17 00:00:00 2001 From: Sergey Zhuravlev Date: Fri, 22 May 2026 14:40:38 +0200 Subject: [PATCH 15/31] docs: changelog --- CHANGELOG.md | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f230d38..9faf586b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,17 +2,14 @@ ### 🚀 Features -- **host-papp:** multi-device SSO. `createAuth` / `pappAdapter.sso` keeps the V1 `pairingStatus` / `authenticate()` / `abortAuthentication()` surface — same state transitions, `authenticate()` still resolves to a `StoredUserSession | null`, `pairingStatus.finished` still carries `session`. What's new is the protocol underneath: pairing now runs the V2 multi-device handshake (`VersionedHandshakeProposal::V2` QR proposal, ECDH-encrypted Statement Store response envelope, on-chain `Pending(AllowanceAllocation)` flow). Desktop and web hosts pair with the multi-device iOS/Android Polkadot Mobile builds through the same entry point you've been calling. The V2 codecs, pairing service, and state machine are SDK internals. -- **host-papp:** the SDK persists the V2 device identity itself. `createPappAdapter` creates and persists a fresh device identity to the configured `StorageAdapter` on first run and reuses it on subsequent launches — no consumer wiring needed. Hosts that want a different persistence backend (Electron Keychain, native secure storage) can override with an optional `deviceIdentity` factory. -- **host-papp:** the SDK persists pairing-topic statement dedupe state internally too, so stale `Success` statements on chain don't get replayed across launches without consumer plumbing. -- **host-papp:** `StoredUserSession` gains three optional V2 fields — `identityAccountId` (user identity sr25519 account), `identityChatPublicKey` (P-256 65-byte, derived locally from `identityChatPrivateKey`), and the peer device statement account exposed via `remoteAccount.accountId` — so device-sync and the chat layer can read peer state straight off the session. The matching `identityChatPrivateKey` is persisted into `UserSecretRepository` and passed to the optional `onAuthSuccess` hook for consumers that need it. -- **host-papp:** new optional `onAuthSuccess` hook on `createPappAdapter` for consumer-specific post-pairing work (telemetry, custom peer caches, device-sync seeding). Fires after the SDK has written the session + secrets to its own repositories. Receives `{ session: StoredUserSession, identityChatPrivateKey: Uint8Array }`. -- **host-chat:** new `MessageContent` variants at the spec'd indices — `chatAccepted` (14) now carries `{ messageId }` for iOS V1 backward decode; `deviceAdded` (17) `{ statementAccountId, encryptionPublicKey }`; `deviceRemoved` (18) `{ statementAccountId }`; `deviceChatAccepted` (20) `{ requestId, device }` sent on the identity-level session `SessionId(B, A)` encrypted with `K(A, B)` so all of a peer's devices can decrypt without a per-device envelope. +- **host-papp:** multi-device SSO. SSO pairing now runs the V2 multi-device handshake under the hood, so desktop and web hosts pair with the multi-device iOS/Android Polkadot Mobile builds through the same `createAuth` / `pappAdapter.sso` entry point — `pairingStatus`, `authenticate()`, `abortAuthentication()`, and the `StoredUserSession` returned to consumers are unchanged. The V2 protocol, codecs, and pairing state machine are SDK internals. +- **host-papp:** the SDK now persists the device identity and pairing-topic dedupe state itself, on the configured `StorageAdapter`, so no extra consumer wiring is needed across launches. Hosts that want a different identity backend (Electron Keychain, native secure storage) can override with an optional `deviceIdentity` factory on `createPappAdapter`. +- **host-papp:** `StoredUserSession` gains optional V2 fields for the user's identity chat public key and the peer device's statement account, so consumers building device-sync or chat-level features can read peer state straight off the session. A new optional `onAuthSuccess` hook on `createPappAdapter` fires after pairing with `{ session, identityChatPrivateKey }` for consumer-specific post-pairing work (telemetry, custom peer caches, device-sync seeding). +- **host-chat:** new message variants for the multi-device chat layer — chat-accept now carries the originating message id, and there are new variants for announcing and removing peer devices on the identity-level session, so all of a peer's devices can decrypt without a per-device envelope. ### 🩹 Fixes -- **host-papp / host-chat:** `identity/rpcAdapter` and `accountService.getConsumerInfo` tolerate both camelCase and snake_case `Resources.Consumers` metadata fields — the V2 multi-device runtime metadata emits camelCase, which previously crashed `raw.stmt_store_slots.map(...)`. -- **host-chat:** `DeviceAdded` / `DeviceRemoved` use length-prefixed `Bytes()` rather than fixed-size codecs, matching `substrate-sdk-android`'s wire shape for `AccountId` / `EncodedPublicKey` wrapper types (no `@FixedLength`). +- **host-papp / host-chat:** consumer-info parsing tolerates both camelCase and snake_case `Resources.Consumers` metadata fields — the V2 multi-device runtime metadata emits camelCase, which previously crashed account-resource resolution. ### ⚠️ Breaking Changes @@ -20,8 +17,8 @@ The migration is essentially two field renames; the auth surface is otherwise un - **host-papp:** `createPappAdapter` no longer accepts `metadata: string` (the V1 metadata URL) — host name / icon / platform now ride inside `hostMetadata` (sent inline with the V2 QR proposal). - **host-papp:** `HostMetadata` reshape — was `{ hostVersion?, osType?, osVersion? }`, now `{ hostName?, hostVersion?, hostIcon?, platformType?, platformVersion?, custom? }`. Map `osType → platformType` and `osVersion → platformVersion` when upgrading. -- **host-papp:** the V1 SSO handshake is gone. Paired clients on the V1 wire format will not pair against this build — both ends must run the multi-device V2 handshake. (Polkadot Mobile builds with multi-device support are V2.) Persisted V1 SSO sessions don't migrate; they decode short against the extended V2 codec and are wiped on first read, so users need to re-pair. -- **host-chat:** `MessageContent.chatAccepted` (14) payload changed from `_void` to `{ messageId: string }`. Older clients that emit `_void` will not decode. +- **host-papp:** the V1 SSO handshake is gone. Both ends must run the multi-device V2 handshake (Polkadot Mobile builds with multi-device support are V2). Persisted V1 SSO sessions don't migrate and are wiped on first read, so users need to re-pair. +- **host-chat:** the chat-accepted message payload changed shape (now carries the originating message id). Older clients on the V1 form will not decode. ### ❤️ Thank You From 9435d778548bf3502f8e5d98646e5dbfe7384c75 Mon Sep 17 00:00:00 2001 From: Sergey Zhuravlev Date: Fri, 22 May 2026 15:00:51 +0200 Subject: [PATCH 16/31] docs: migration guide --- docs/migration/v0.8.md | 191 ++++++++++++++++++ .../host-papp/src/sso/auth/v2/proposal.ts | 28 ++- 2 files changed, 204 insertions(+), 15 deletions(-) create mode 100644 docs/migration/v0.8.md diff --git a/docs/migration/v0.8.md b/docs/migration/v0.8.md new file mode 100644 index 00000000..40e88d1b --- /dev/null +++ b/docs/migration/v0.8.md @@ -0,0 +1,191 @@ +# Migration Guide: v0.7 → v0.8 + +This guide covers all breaking changes and new APIs introduced in v0.8, organized by feature area. + +## Overview of Breaking Changes + +- [Multi-device SSO V2 wire protocol](#multi-device-sso-v2) — V1 SSO handshake is gone; both ends must run V2; persisted V1 sessions are dropped on first read +- [`createPappAdapter` parameter change](#createpappadapter-parameter-change) — `metadata: string` URL removed; host name / icon / platform now ride inline on `hostMetadata` +- [`HostMetadata` reshape](#hostmetadata-reshape) — `hostVersion / osType / osVersion` → `hostName / hostVersion / hostIcon / platformType / platformVersion / custom` +- [`MessageContent.chatAccepted` payload](#messagecontentchataccepted-payload) — chat-accepted variant changed from `_void` to `{ messageId: string }` + +--- + +## Breaking Changes + +### Multi-device SSO V2 + +The SSO handshake has been replaced with the V2 multi-device protocol end-to-end. The consumer-facing surface on `pappAdapter.sso` — `pairingStatus`, `authenticate()`, `abortAuthentication()`, the `StoredUserSession` returned to consumers — is unchanged. + +There is no V1 ↔ V2 compatibility: + +- A host on v0.8 will not pair with a Polkadot Mobile build that only speaks V1. +- A host on v0.7 will not pair with a multi-device Polkadot Mobile build. +- Persisted v0.7 SSO session blobs decode short against the V2 codec and are silently dropped on first read — users with existing sessions need to re-pair. + +Hosts and the paired Polkadot Mobile app must upgrade together. + +--- + +### `createPappAdapter` parameter change + +The `metadata: string` URL parameter (which pointed at a JSON file describing the host's `name` / `icon`) has been removed. Host identity now ships inline with the V2 QR proposal via the structured `hostMetadata` object, which also gains platform fields. + +```ts +// Before — v0.7 +createPappAdapter({ + appId: 'my-host', + metadata: 'https://example.com/host-metadata.json', + hostMetadata: { + hostVersion: '1.2.3', + osType: 'macOS', + osVersion: '15.0', + }, +}); + +// After — v0.8 +createPappAdapter({ + appId: 'my-host', + hostMetadata: { + hostName: 'My Host', + hostVersion: '1.2.3', + hostIcon: 'https://example.com/icon-256.png', + platformType: 'macOS', + platformVersion: '15.0', + }, +}); +``` + +Hosts that previously served a `host-metadata.json` can drop the file — its contents now ride inline on `hostMetadata` as `hostName` and `hostIcon`. + +--- + +### `HostMetadata` reshape + +```ts +// Before — v0.7 +type HostMetadata = { + hostVersion?: string; + osType?: string; + osVersion?: string; +}; + +// After — v0.8 +type HostMetadata = { + hostName?: string; + hostVersion?: string; + hostIcon?: string; + platformType?: string; + platformVersion?: string; + custom?: { name: string; value: string }[]; +}; +``` + +Mechanical renames: + +| v0.7 | v0.8 | +|-------------|-------------------| +| `osType` | `platformType` | +| `osVersion` | `platformVersion` | + +The new optional fields — `hostName`, `hostIcon`, `custom` — replace what used to be fetched from the `metadata` URL and add a place for host-specific extension entries. + +--- + +### `MessageContent.chatAccepted` payload + +The legacy single-device `chatAccepted` variant used to be a `_void` marker. It now carries the originating chat-request message id so the recipient can correlate the accept with its request. + +```ts +// Before — v0.7 +{ tag: 'chatAccepted', value: undefined } + +// After — v0.8 +{ tag: 'chatAccepted', value: { messageId: '...' } } +``` + +Older clients that emit the v0.7 `_void` shape will fail to decode against the v0.8 codec. + +The new multi-device accept lives at a separate index (`deviceChatAccepted`, index 20) — see [Multi-device chat message variants](#multi-device-chat-message-variants) below. + +--- + +## New Features + +### Auto-persisted device identity + +The V2 protocol identifies the host by a per-installation device identity (sr25519 statement-account key + X25519 encryption key). The SDK now creates and persists this identity for you on first run, against the configured `StorageAdapter`, and reuses it on subsequent launches — no consumer wiring needed for web hosts. + +For Electron / native hosts that want a hardware-backed identity (Keychain, native secure storage), pass an optional `deviceIdentity` factory: + +```ts +import type { DeviceIdentityForPairing } from '@novasamatech/host-papp'; + +createPappAdapter({ + appId: 'my-host', + hostMetadata: { /* ... */ }, + async deviceIdentity(): Promise { + return { + statementAccountPublicKey, + statementAccountSecret, + encryptionPublicKey, + encryptionPrivateKey, + } + }, +}); +``` + +The factory is invoked once per pairing flow. + +### `onAuthSuccess` hook + +A new optional caller hook fires after the V2 handshake succeeds — after the SDK has already written the session and secrets to its own repositories. Use it for consumer-specific post-pairing work (telemetry, custom peer caches, device-sync seeding). + +```ts +createPappAdapter({ + appId: 'my-host', + hostMetadata: { /* ... */ }, + onAuthSuccess: ({ session, identityChatPrivateKey }) => { + // session: StoredUserSession (already persisted by the SDK) + // identityChatPrivateKey: the private half of session.identityChatPublicKey; + // lives in UserSecretRepository and is surfaced here for consumers + // whose chat / device-sync layer needs it directly. + telemetry.track('sso_paired', { sessionId: session.id }); + }, +}); +``` + +Throwing from the hook fails the pending `authenticate()` call. + +### New `StoredUserSession` V2 fields + +`StoredUserSession` gains two optional V2 fields, populated when pairing happens against a V2 mobile build: + +```ts +type StoredUserSession = { + // ... existing v0.7 fields ... + identityAccountId?: AccountId; // user's identity sr25519 account + identityChatPublicKey?: Uint8Array; // P-256 65-byte, derived locally from identityChatPrivateKey +}; +``` + +Both are `Option`-wrapped on the wire so future schema revisions can append further fields without breaking decode of v0.8.0 blobs. The matching `identityChatPrivateKey` lives in `UserSecretRepository` and is surfaced through the [`onAuthSuccess`](#onauthsuccess-hook) hook. + +### Multi-device chat message variants + +`MessageContent` gains three new variants for the multi-device chat layer: + +```ts +// Index 17 — announce a new peer device on the chat +{ tag: 'deviceAdded', value: { statementAccountId, encryptionPublicKey } } + +// Index 18 — remove a peer device +{ tag: 'deviceRemoved', value: { statementAccountId } } + +// Index 20 — multi-device accept. +// Sent on the identity-level session SessionId(B, A) encrypted with K(A, B) +// so all of A's devices can decrypt without per-device envelope. +{ tag: 'deviceChatAccepted', value: { requestId, device } } +``` + +Index 19 is reserved as a spec placeholder so `deviceChatAccepted` lands on its specified index. diff --git a/packages/host-papp/src/sso/auth/v2/proposal.ts b/packages/host-papp/src/sso/auth/v2/proposal.ts index aec91a04..a6c8cc86 100644 --- a/packages/host-papp/src/sso/auth/v2/proposal.ts +++ b/packages/host-papp/src/sso/auth/v2/proposal.ts @@ -8,6 +8,7 @@ * subscription picks it up. */ +import { enumValue } from '@novasamatech/scale'; import { toHex } from 'polkadot-api/utils'; import { VersionedHandshakeProposal } from '../scale/handshakeV2.js'; @@ -25,7 +26,7 @@ export type HandshakeMetadata = { hostIcon?: string; platformType?: string; platformVersion?: string; - custom?: { name: string; value: string }[]; + custom?: Record; }; type MetadataEntryT = [ @@ -42,32 +43,29 @@ type MetadataEntryT = [ const buildMetadataEntries = (metadata: HandshakeMetadata): MetadataEntryT[] => { const entries: MetadataEntryT[] = []; - if (metadata.hostName !== undefined) entries.push([{ tag: 'HostName', value: undefined }, metadata.hostName]); - if (metadata.hostVersion !== undefined) - entries.push([{ tag: 'HostVersion', value: undefined }, metadata.hostVersion]); - if (metadata.hostIcon !== undefined) entries.push([{ tag: 'HostIcon', value: undefined }, metadata.hostIcon]); - if (metadata.platformType !== undefined) - entries.push([{ tag: 'PlatformType', value: undefined }, metadata.platformType]); + if (metadata.hostName !== undefined) entries.push([enumValue('HostName', undefined), metadata.hostName]); + if (metadata.hostVersion !== undefined) entries.push([enumValue('HostVersion', undefined), metadata.hostVersion]); + if (metadata.hostIcon !== undefined) entries.push([enumValue('HostIcon', undefined), metadata.hostIcon]); + if (metadata.platformType !== undefined) entries.push([enumValue('PlatformType', undefined), metadata.platformType]); if (metadata.platformVersion !== undefined) { - entries.push([{ tag: 'PlatformVersion', value: undefined }, metadata.platformVersion]); + entries.push([enumValue('PlatformVersion', undefined), metadata.platformVersion]); } - for (const c of metadata.custom ?? []) { - entries.push([{ tag: 'Custom', value: c.name }, c.value]); + for (const [key, value] of Object.entries(metadata.custom ?? {})) { + entries.push([enumValue('Custom', key), value]); } return entries; }; export const encodeProposal = (device: HandshakeProposalDevice, metadata: HandshakeMetadata): Uint8Array => - VersionedHandshakeProposal.enc({ - tag: 'V2', - value: { + VersionedHandshakeProposal.enc( + enumValue('V2', { device: { statementAccountId: device.statementAccountPublicKey, encryptionPublicKey: device.encryptionPublicKey, }, metadata: buildMetadataEntries(metadata), - }, - }); + }), + ); export const buildPairingDeeplink = (device: HandshakeProposalDevice, metadata: HandshakeMetadata): string => { const bytes = encodeProposal(device, metadata); From 802e844d2e427a2cb82d555c61e00c03ade1dc3d Mon Sep 17 00:00:00 2001 From: Sergey Zhuravlev Date: Fri, 22 May 2026 15:04:02 +0200 Subject: [PATCH 17/31] chore(release): publish 0.8.0-0 --- package-lock.json | 66 +++++++++---------- packages/handoff-service/package.json | 2 +- packages/host-api-wrapper/package.json | 4 +- packages/host-api/package.json | 4 +- packages/host-chat/package.json | 8 +-- packages/host-container/package.json | 4 +- packages/host-papp-react-ui/package.json | 6 +- packages/host-papp/package.json | 10 +-- .../package.json | 4 +- packages/host-worker-sandbox/package.json | 6 +- packages/product-bulletin/package.json | 4 +- packages/product-react-renderer/package.json | 6 +- packages/scale/package.json | 2 +- packages/statement-store/package.json | 4 +- packages/storage-adapter/package.json | 2 +- 15 files changed, 66 insertions(+), 66 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6753d4b1..c0c09eac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17057,7 +17057,7 @@ }, "packages/handoff-service": { "name": "@novasamatech/handoff-service", - "version": "0.7.9", + "version": "0.8.0-0", "license": "Apache-2.0", "dependencies": { "@noble/ciphers": "2.2.0", @@ -17070,10 +17070,10 @@ }, "packages/host-api": { "name": "@novasamatech/host-api", - "version": "0.7.9", + "version": "0.8.0-0", "license": "Apache-2.0", "dependencies": { - "@novasamatech/scale": "0.7.9", + "@novasamatech/scale": "0.8.0-0", "nanoevents": "9.1.0", "nanoid": "5.1.9", "neverthrow": "^8.2.0", @@ -17082,10 +17082,10 @@ }, "packages/host-api-wrapper": { "name": "@novasamatech/host-api-wrapper", - "version": "0.7.9", + "version": "0.8.0-0", "license": "Apache-2.0", "dependencies": { - "@novasamatech/host-api": "0.7.9", + "@novasamatech/host-api": "0.8.0-0", "@polkadot-api/json-rpc-provider-proxy": "^0.4.0", "@polkadot-api/substrate-bindings": "^0.20.2", "@polkadot/extension-inject": "^0.63.1", @@ -17125,12 +17125,12 @@ }, "packages/host-chat": { "name": "@novasamatech/host-chat", - "version": "0.7.9", + "version": "0.8.0-0", "license": "Apache-2.0", "dependencies": { - "@novasamatech/scale": "0.7.9", - "@novasamatech/statement-store": "0.7.9", - "@novasamatech/storage-adapter": "0.7.9", + "@novasamatech/scale": "0.8.0-0", + "@novasamatech/statement-store": "0.8.0-0", + "@novasamatech/storage-adapter": "0.8.0-0", "nanoid": "5.1.9", "neverthrow": "^8.2.0" } @@ -17155,11 +17155,11 @@ }, "packages/host-container": { "name": "@novasamatech/host-container", - "version": "0.7.9", + "version": "0.8.0-0", "license": "Apache-2.0", "dependencies": { "@noble/hashes": "2.2.0", - "@novasamatech/host-api": "0.7.9", + "@novasamatech/host-api": "0.8.0-0", "@polkadot-api/substrate-client": "^0.7.0", "nanoevents": "9.1.0", "nanoid": "5.1.9", @@ -17190,16 +17190,16 @@ }, "packages/host-papp": { "name": "@novasamatech/host-papp", - "version": "0.7.9", + "version": "0.8.0-0", "license": "Apache-2.0", "dependencies": { "@noble/ciphers": "2.2.0", "@noble/curves": "2.2.0", "@noble/hashes": "2.2.0", - "@novasamatech/host-api": "0.7.9", - "@novasamatech/scale": "0.7.9", - "@novasamatech/statement-store": "0.7.9", - "@novasamatech/storage-adapter": "0.7.9", + "@novasamatech/host-api": "0.8.0-0", + "@novasamatech/scale": "0.8.0-0", + "@novasamatech/statement-store": "0.8.0-0", + "@novasamatech/storage-adapter": "0.8.0-0", "@polkadot-api/utils": "^0.4.0", "@polkadot-labs/hdkd-helpers": "^0.0.30", "nanoevents": "9.1.0", @@ -17213,11 +17213,11 @@ }, "packages/host-papp-react-ui": { "name": "@novasamatech/host-papp-react-ui", - "version": "0.7.9", + "version": "0.8.0-0", "license": "Apache-2.0", "dependencies": { - "@novasamatech/host-papp": "0.7.9", - "@novasamatech/statement-store": "0.7.9", + "@novasamatech/host-papp": "0.8.0-0", + "@novasamatech/statement-store": "0.8.0-0", "@novasamatech/tr-ui": "0.2.9", "@polkadot-api/utils": "^0.4.0", "@radix-ui/react-dialog": "1.1.15", @@ -17272,10 +17272,10 @@ }, "packages/host-substrate-chain-connection": { "name": "@novasamatech/host-substrate-chain-connection", - "version": "0.7.9", + "version": "0.8.0-0", "license": "Apache-2.0", "dependencies": { - "@novasamatech/storage-adapter": "0.7.9", + "@novasamatech/storage-adapter": "0.8.0-0", "@polkadot-api/json-rpc-provider": "^0.2.0", "@polkadot-api/json-rpc-provider-proxy": "^0.4.0", "@polkadot-api/ws-provider": "^0.9.0", @@ -17285,31 +17285,31 @@ }, "packages/host-worker-sandbox": { "name": "@novasamatech/host-worker-sandbox", - "version": "0.7.9", + "version": "0.8.0-0", "license": "Apache-2.0", "dependencies": { - "@novasamatech/host-api": "0.7.9", - "@novasamatech/host-container": "0.7.9", + "@novasamatech/host-api": "0.8.0-0", + "@novasamatech/host-container": "0.8.0-0", "quickjs-emscripten": "0.32.0" } }, "packages/product-bulletin": { "name": "@novasamatech/product-bulletin", - "version": "0.7.9", + "version": "0.8.0-0", "license": "Apache-2.0", "dependencies": { - "@novasamatech/host-api-wrapper": "0.7.9", + "@novasamatech/host-api-wrapper": "0.8.0-0", "@parity/bulletin-sdk": "^0.3.0", "polkadot-api": ">=2" } }, "packages/product-react-renderer": { "name": "@novasamatech/product-react-renderer", - "version": "0.7.9", + "version": "0.8.0-0", "license": "Apache-2.0", "dependencies": { - "@novasamatech/host-api": "0.7.9", - "@novasamatech/host-api-wrapper": "0.7.9", + "@novasamatech/host-api": "0.8.0-0", + "@novasamatech/host-api-wrapper": "0.8.0-0", "react-reconciler": "0.33.0", "scale-ts": "1.6.1" }, @@ -17336,7 +17336,7 @@ }, "packages/scale": { "name": "@novasamatech/scale", - "version": "0.7.9", + "version": "0.8.0-0", "license": "Apache-2.0", "dependencies": { "@polkadot-api/utils": "^0.4.0", @@ -17345,12 +17345,12 @@ }, "packages/statement-store": { "name": "@novasamatech/statement-store", - "version": "0.7.9", + "version": "0.8.0-0", "license": "Apache-2.0", "dependencies": { "@noble/ciphers": "2.2.0", "@noble/hashes": "2.2.0", - "@novasamatech/scale": "0.7.9", + "@novasamatech/scale": "0.8.0-0", "@novasamatech/sdk-statement": "^0.6.0", "@polkadot-api/substrate-bindings": "^0.20.2", "@polkadot-api/substrate-client": "^0.7.0", @@ -17394,7 +17394,7 @@ }, "packages/storage-adapter": { "name": "@novasamatech/storage-adapter", - "version": "0.7.9", + "version": "0.8.0-0", "license": "Apache-2.0", "dependencies": { "nanoevents": "^9.1.0", diff --git a/packages/handoff-service/package.json b/packages/handoff-service/package.json index ea57742f..84b66c01 100644 --- a/packages/handoff-service/package.json +++ b/packages/handoff-service/package.json @@ -1,7 +1,7 @@ { "name": "@novasamatech/handoff-service", "type": "module", - "version": "0.7.9", + "version": "0.8.0-0", "description": "HOP (Handoff Pool) file transfer service for P2P chat", "license": "Apache-2.0", "repository": { diff --git a/packages/host-api-wrapper/package.json b/packages/host-api-wrapper/package.json index 6fcee19d..d10b3306 100644 --- a/packages/host-api-wrapper/package.json +++ b/packages/host-api-wrapper/package.json @@ -1,7 +1,7 @@ { "name": "@novasamatech/host-api-wrapper", "type": "module", - "version": "0.7.9", + "version": "0.8.0-0", "description": "Host API wrapper: integrate and run your product inside Polkadot browser.", "license": "Apache-2.0", "repository": { @@ -28,7 +28,7 @@ "@polkadot/extension-inject": "^0.63.1", "@polkadot-api/json-rpc-provider-proxy": "^0.4.0", "@polkadot-api/substrate-bindings": "^0.20.2", - "@novasamatech/host-api": "0.7.9", + "@novasamatech/host-api": "0.8.0-0", "polkadot-api": ">=2", "neverthrow": "^8.2.0" }, diff --git a/packages/host-api/package.json b/packages/host-api/package.json index 547ee9b6..9f20b150 100644 --- a/packages/host-api/package.json +++ b/packages/host-api/package.json @@ -1,7 +1,7 @@ { "name": "@novasamatech/host-api", "type": "module", - "version": "0.7.9", + "version": "0.8.0-0", "description": "Host API: transport implementation for host - product integration.", "license": "Apache-2.0", "repository": { @@ -22,7 +22,7 @@ "README.md" ], "dependencies": { - "@novasamatech/scale": "0.7.9", + "@novasamatech/scale": "0.8.0-0", "nanoevents": "9.1.0", "nanoid": "5.1.9", "neverthrow": "^8.2.0", diff --git a/packages/host-chat/package.json b/packages/host-chat/package.json index c7974f9e..c815de8a 100644 --- a/packages/host-chat/package.json +++ b/packages/host-chat/package.json @@ -1,7 +1,7 @@ { "name": "@novasamatech/host-chat", "type": "module", - "version": "0.7.9", + "version": "0.8.0-0", "private": "true", "description": "Host statement store chat integration", "license": "Apache-2.0", @@ -41,9 +41,9 @@ "README.md" ], "dependencies": { - "@novasamatech/scale": "0.7.9", - "@novasamatech/statement-store": "0.7.9", - "@novasamatech/storage-adapter": "0.7.9", + "@novasamatech/scale": "0.8.0-0", + "@novasamatech/statement-store": "0.8.0-0", + "@novasamatech/storage-adapter": "0.8.0-0", "nanoid": "5.1.9", "neverthrow": "^8.2.0" }, diff --git a/packages/host-container/package.json b/packages/host-container/package.json index 35dc0d8f..ea267703 100644 --- a/packages/host-container/package.json +++ b/packages/host-container/package.json @@ -1,7 +1,7 @@ { "name": "@novasamatech/host-container", "type": "module", - "version": "0.7.9", + "version": "0.8.0-0", "description": "Host container for hosting and managing products within the Polkadot ecosystem.", "license": "Apache-2.0", "repository": { @@ -28,7 +28,7 @@ "@noble/hashes": "2.2.0", "polkadot-api": ">=2", "@polkadot-api/substrate-client": "^0.7.0", - "@novasamatech/host-api": "0.7.9", + "@novasamatech/host-api": "0.8.0-0", "nanoevents": "9.1.0", "nanoid": "5.1.9", "neverthrow": "^8.2.0" diff --git a/packages/host-papp-react-ui/package.json b/packages/host-papp-react-ui/package.json index 8cef571e..9b26efbe 100644 --- a/packages/host-papp-react-ui/package.json +++ b/packages/host-papp-react-ui/package.json @@ -1,7 +1,7 @@ { "name": "@novasamatech/host-papp-react-ui", "type": "module", - "version": "0.7.9", + "version": "0.8.0-0", "description": "Polkadot app UI Flow", "license": "Apache-2.0", "repository": { @@ -30,8 +30,8 @@ "react-dom": ">=18" }, "dependencies": { - "@novasamatech/host-papp": "0.7.9", - "@novasamatech/statement-store": "0.7.9", + "@novasamatech/host-papp": "0.8.0-0", + "@novasamatech/statement-store": "0.8.0-0", "@polkadot-api/utils": "^0.4.0", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-popover": "1.1.15", diff --git a/packages/host-papp/package.json b/packages/host-papp/package.json index 02114829..ac829da8 100644 --- a/packages/host-papp/package.json +++ b/packages/host-papp/package.json @@ -1,7 +1,7 @@ { "name": "@novasamatech/host-papp", "type": "module", - "version": "0.7.9", + "version": "0.8.0-0", "description": "Polkadot app integration", "license": "Apache-2.0", "repository": { @@ -34,10 +34,10 @@ "@noble/ciphers": "2.2.0", "@noble/curves": "2.2.0", "@noble/hashes": "2.2.0", - "@novasamatech/host-api": "0.7.9", - "@novasamatech/scale": "0.7.9", - "@novasamatech/statement-store": "0.7.9", - "@novasamatech/storage-adapter": "0.7.9", + "@novasamatech/host-api": "0.8.0-0", + "@novasamatech/scale": "0.8.0-0", + "@novasamatech/statement-store": "0.8.0-0", + "@novasamatech/storage-adapter": "0.8.0-0", "@polkadot-api/utils": "^0.4.0", "@polkadot-labs/hdkd-helpers": "^0.0.30", "nanoevents": "9.1.0", diff --git a/packages/host-substrate-chain-connection/package.json b/packages/host-substrate-chain-connection/package.json index b2a3f895..1e5a63f7 100644 --- a/packages/host-substrate-chain-connection/package.json +++ b/packages/host-substrate-chain-connection/package.json @@ -1,7 +1,7 @@ { "name": "@novasamatech/host-substrate-chain-connection", "type": "module", - "version": "0.7.9", + "version": "0.8.0-0", "description": "Chain connection pool with ref counting and provider branching for Polkadot API", "license": "Apache-2.0", "repository": { @@ -25,7 +25,7 @@ "README.md" ], "dependencies": { - "@novasamatech/storage-adapter": "0.7.9", + "@novasamatech/storage-adapter": "0.8.0-0", "@polkadot-api/ws-provider": "^0.9.0", "@polkadot-api/json-rpc-provider": "^0.2.0", "@polkadot-api/json-rpc-provider-proxy": "^0.4.0", diff --git a/packages/host-worker-sandbox/package.json b/packages/host-worker-sandbox/package.json index 00863295..7e33f09b 100644 --- a/packages/host-worker-sandbox/package.json +++ b/packages/host-worker-sandbox/package.json @@ -1,7 +1,7 @@ { "name": "@novasamatech/host-worker-sandbox", "type": "module", - "version": "0.7.9", + "version": "0.8.0-0", "description": "QuickJS-based sandbox for running product worker code with Triangle Host API.", "license": "Apache-2.0", "repository": { @@ -25,8 +25,8 @@ "README.md" ], "dependencies": { - "@novasamatech/host-api": "0.7.9", - "@novasamatech/host-container": "0.7.9", + "@novasamatech/host-api": "0.8.0-0", + "@novasamatech/host-container": "0.8.0-0", "quickjs-emscripten": "0.32.0" }, "publishConfig": { diff --git a/packages/product-bulletin/package.json b/packages/product-bulletin/package.json index d3b8f09d..05edd43b 100644 --- a/packages/product-bulletin/package.json +++ b/packages/product-bulletin/package.json @@ -1,7 +1,7 @@ { "name": "@novasamatech/product-bulletin", "type": "module", - "version": "0.7.9", + "version": "0.8.0-0", "description": "Bulletin Chain client adapter for Polkadot product applications", "license": "Apache-2.0", "repository": { @@ -27,7 +27,7 @@ "README.md" ], "dependencies": { - "@novasamatech/host-api-wrapper": "0.7.9", + "@novasamatech/host-api-wrapper": "0.8.0-0", "@parity/bulletin-sdk": "^0.3.0", "polkadot-api": ">=2" }, diff --git a/packages/product-react-renderer/package.json b/packages/product-react-renderer/package.json index 3d1142d4..b4eaa37d 100644 --- a/packages/product-react-renderer/package.json +++ b/packages/product-react-renderer/package.json @@ -1,7 +1,7 @@ { "name": "@novasamatech/product-react-renderer", "type": "module", - "version": "0.7.9", + "version": "0.8.0-0", "description": "React wrapper for custom renderer format from host-api-wrapper", "license": "Apache-2.0", "repository": { @@ -24,8 +24,8 @@ "README.md" ], "dependencies": { - "@novasamatech/host-api": "0.7.9", - "@novasamatech/host-api-wrapper": "0.7.9", + "@novasamatech/host-api": "0.8.0-0", + "@novasamatech/host-api-wrapper": "0.8.0-0", "scale-ts": "1.6.1", "react-reconciler": "0.33.0" }, diff --git a/packages/scale/package.json b/packages/scale/package.json index c4ae6c7e..6ceef95f 100644 --- a/packages/scale/package.json +++ b/packages/scale/package.json @@ -1,7 +1,7 @@ { "name": "@novasamatech/scale", "type": "module", - "version": "0.7.9", + "version": "0.8.0-0", "description": "additional scale-ts bindings", "license": "Apache-2.0", "repository": { diff --git a/packages/statement-store/package.json b/packages/statement-store/package.json index 6e6fd46b..ce5e7a73 100644 --- a/packages/statement-store/package.json +++ b/packages/statement-store/package.json @@ -1,7 +1,7 @@ { "name": "@novasamatech/statement-store", "type": "module", - "version": "0.7.9", + "version": "0.8.0-0", "description": "Statement store integration", "license": "Apache-2.0", "repository": { @@ -25,7 +25,7 @@ "README.md" ], "dependencies": { - "@novasamatech/scale": "0.7.9", + "@novasamatech/scale": "0.8.0-0", "@novasamatech/sdk-statement": "^0.6.0", "@polkadot-api/substrate-bindings": "^0.20.2", "@polkadot-api/substrate-client": "^0.7.0", diff --git a/packages/storage-adapter/package.json b/packages/storage-adapter/package.json index 3985e626..499760d2 100644 --- a/packages/storage-adapter/package.json +++ b/packages/storage-adapter/package.json @@ -1,7 +1,7 @@ { "name": "@novasamatech/storage-adapter", "type": "module", - "version": "0.7.9", + "version": "0.8.0-0", "description": "Statement store integration", "license": "Apache-2.0", "repository": { From 107c7fb91c049cdab8a082df4fc8376e21564730 Mon Sep 17 00:00:00 2001 From: Ilya Date: Fri, 22 May 2026 07:11:54 -0600 Subject: [PATCH 18/31] fix(host-chat): drop private flag so the package is publishable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit host-chat was marked private:"true" and silently skipped by the release pipeline — the 0.8.0-0 release only shipped host-api/host-papp/etc. Downstream desktop has to pin host-chat to a file: workspace ref, which breaks CI consumers that don't have the sibling SDK checkout. Removing the flag lets the next release publish it alongside the rest of the V2 SDK surface. --- packages/host-chat/package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/host-chat/package.json b/packages/host-chat/package.json index c7974f9e..2961407e 100644 --- a/packages/host-chat/package.json +++ b/packages/host-chat/package.json @@ -2,7 +2,6 @@ "name": "@novasamatech/host-chat", "type": "module", "version": "0.7.9", - "private": "true", "description": "Host statement store chat integration", "license": "Apache-2.0", "repository": { From e6d6be8030f8b24ba12e48adab556b2ce8fdba39 Mon Sep 17 00:00:00 2001 From: Ilya Date: Fri, 22 May 2026 07:20:37 -0600 Subject: [PATCH 19/31] docs(host-chat): replace stale host-container copy with a real README The existing README was an unedited copy of host-container's docs (wrong title, none of the host-chat surface mentioned). Rewrite to cover what the package actually does: createAccountService, network selection, search / getConsumerInfo semantics, and the codec subpath exports. --- packages/host-chat/README.md | 331 +++++++---------------------------- 1 file changed, 68 insertions(+), 263 deletions(-) diff --git a/packages/host-chat/README.md b/packages/host-chat/README.md index 25ca90b2..b027cab7 100644 --- a/packages/host-chat/README.md +++ b/packages/host-chat/README.md @@ -1,297 +1,102 @@ -# @novasamatech/host-container +# @novasamatech/host-chat -A robust solution for hosting and managing decentralized applications (dapps) within the Polkadot ecosystem. +Account lookup and chat-message codecs for host applications integrating with the Polkadot People chain. ## Overview -Host container provides the infrastructure layer for securely embedding and communicating with third-party dapps. -It handles the isolation boundary, message routing, lifecycle management, and security concerns inherent in hosting untrusted web content. +`@novasamatech/host-chat` exposes the read side of the chat domain: discovering Polkadot +accounts by username and resolving their on-chain identity from `Resources.Consumers`. It +also publishes the SCALE codecs used by the chat wire protocol (messages, attachments, +local-message envelopes) so host applications can decode statements they receive over the +statement store. + +The package is UI-framework agnostic. The main entry point returns plain async functions +backed by [`neverthrow`](https://github.com/supermacro/neverthrow) `ResultAsync`, and the +codec exports are pure SCALE codecs with no runtime side effects. ## Installation ```shell -npm install @novasamatech/host-container --save -E -``` - -### Basic Container Setup - -```ts -import { createContainer, createIframeProvider } from '@novasamatech/host-container'; - -const iframe = document.createElement('iframe'); - -const provider = createIframeProvider({ - iframe, - url: 'https://dapp.example.com' -}); -const container = createContainer(provider); - -document.body.appendChild(iframe); -``` - -## API reference - -### handleFeature - -```ts -container.handleFeature((params, { ok, err }) => { - if (params.tag === 'Chat') { - return ok(supportedChains.has(params.value)); - } - return ok(false); -}); -``` - -### handlePermissionRequest - -```ts -container.handlePermissionRequest(async (params, { ok, err }) => { - if (params.tag === 'ChainConnect') { - // Show permission dialog to user - const approved = await showPermissionDialog(params.value); - return approved ? ok(undefined) : err({ tag: 'Rejected' }); - } - return err({ tag: 'Unknown', value: { reason: 'Unsupported permission type' } }); -}); -``` - -### handleStorageRead - -```ts -container.handleStorageRead(async (key, { ok, err }) => { - const value = await storage.get(key); - return ok(value ?? null); -}); -``` - -### handleStorageWrite - -```ts -container.handleStorageWrite(async ([key, value], { ok, err }) => { - try { - await storage.set(key, value); - return ok(undefined); - } catch (e) { - return err({ tag: 'Full' }); - } -}); -``` - -### handleStorageClear - -```ts -container.handleStorageClear(async (key, { ok, err }) => { - await storage.delete(key); - return ok(undefined); -}); -``` - -### handleAccountGet - -```ts -container.handleAccountGet(async ([dotnsId, derivationIndex], { ok, err }) => { - const account = await getProductAccount(dotnsId, derivationIndex); - if (account) { - return ok({ publicKey: account.publicKey, name: account.name ?? null }); - } - return err({ tag: 'NotConnected' }); -}); -``` - -### handleAccountGetAlias - -```ts -container.handleAccountGetAlias(async ([dotnsId, derivationIndex], { ok, err }) => { - const alias = await getAccountAlias(dotnsId, derivationIndex); - if (alias) { - return ok({ context: alias.context, alias: alias.alias }); - } - return err(new RequestCredentialsErr.NotConnected()); -}); -``` - -### handleAccountCreateProof - -```ts -container.handleAccountCreateProof(async ([[dotnsId, derivationIndex], ringLocation, message], { ok, err }) => { - try { - const proof = await createRingProof(dotnsId, derivationIndex, ringLocation, message); - return ok(proof); - } catch (e) { - return err({ tag: 'RingNotFound' }); - } -}); -``` - -### handleGetLegacyAccounts - -```ts -container.handleGetLegacyAccounts(async (_, { ok, err }) => { - const accounts = await getLegacyAccounts(); - return ok(accounts); -}); -``` - -### handleCreateTransaction - -```ts -container.handleCreateTransaction(async ([productAccountId, payload], { ok, err }) => { - try { - const signedTx = await createTransaction(productAccountId, payload); - return ok(signedTx); - } catch (e) { - return err({ tag: 'Rejected' }); - } -}); -``` - -### handleCreateTransactionWithLegacyAccount - -```ts -container.handleCreateTransactionWithLegacyAccount(async (payload, { ok, err }) => { - try { - const signedTx = await createTransactionWithLegacyAccount(payload); - return ok(signedTx); - } catch (e) { - return err({ tag: 'Rejected' }); - } -}); -``` - -### handleSignRaw - -```ts -container.handleSignRaw(async (payload, { ok, err }) => { - try { - const result = await signRaw(payload); - return ok({ signature: result.signature, signedTransaction: result.signedTransaction }); - } catch (e) { - return err({ tag: 'Rejected' }); - } -}); -``` - -### handleSignPayload - -```ts -container.handleSignPayload(async (payload, { ok, err }) => { - try { - const result = await signPayload(payload); - return ok({ signature: result.signature, signedTransaction: result.signedTransaction ?? null }); - } catch (e) { - return err({ tag: 'Rejected' }); - } -}); -``` - -### handleChatCreateContact - -```ts -container.handleChatCreateContact(async (contact, { ok, err }) => { - await chatService.registerContact(contact); - return ok(undefined); -}); +npm install @novasamatech/host-chat --save -E ``` -### handleChatPostMessage +## Getting started ```ts -container.handleChatPostMessage(async (message, { ok, err }) => { - const messageId = await chatService.postMessage(message); - return ok({ messageId }); -}); -``` - -### handleChatActionSubscribe +import { createAccountService } from '@novasamatech/host-chat'; +import { createLazyClient } from '@novasamatech/statement-store'; -```ts -container.handleChatActionSubscribe((_, send, interrupt) => { - const listener = (action) => send(action); - chatService.on('action', listener); - return () => chatService.off('action', listener); -}); -``` +const lazyClient = createLazyClient(/* chain provider */); +const accounts = createAccountService('paseo-next-v2', lazyClient); -### handleStatementStoreCreateProof - -```ts -container.handleStatementStoreCreateProof(async ([[dotnsId, derivationIndex], statement], { ok, err }) => { - try { - const proof = await createStatementProof(dotnsId, derivationIndex, statement); - return ok(proof); - } catch (e) { - return err({ tag: 'UnableToSign' }); +// Search the off-chain username index for accounts whose username starts with `alice`. +const search = await accounts.search('alice', 'ASSIGNED'); +if (search.isOk()) { + for (const hit of search.value) { + console.log(hit.candidateAccountId, hit.username); } -}); -``` - -### handleJsonRpcMessageSubscribe - -```ts -import { getWsProvider } from 'polkadot-api/ws-provider'; - -const provider = getWsProvider('wss://rpc.polkadot.io'); -container.handleJsonRpcMessageSubscribe( - { genesisHash: '0x...' }, - provider -); -``` - -### isReady +} -```ts -const ready = await container.isReady(); -if (ready) { - console.log('Container is ready'); +// Resolve a specific account's on-chain identity. +const identity = await accounts.getConsumerInfo('5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY'); +if (identity.isOk() && identity.value) { + console.log(identity.value.fullUsername, identity.value.credibility); } ``` -### dispose +### Networks -```ts -container.dispose(); -``` +`createAccountService` accepts one of: -### subscribeConnectionStatus +| Network | People chain endpoint | +| ---------------- | -------------------------------------- | +| `stable` | Polkadot People | +| `preview` | Westend People | +| `paseo-next` | Paseo People (V1) | +| `paseo-next-v2` | Paseo People (V2 multi-device) | -```ts -const unsubscribe = container.subscribeConnectionStatus((status) => { - console.log('Connection status:', status); -}); -``` +Each network entry pins both the People chain WebSocket URL (used via `lazyClient`) and +the off-chain identity-backend REST endpoint that `search` queries. -## PAPI provider support +## API -Host container supports [PAPI](https://papi.how/) request redirection from product to host container. -It can be useful to deduplicate socket connections or light client instances between multiple dapps. +### `createAccountService(network, lazyClient)` -To support this feature, you should add two additional handlers to the container: +Returns an object with two methods: -### Chain support check -```ts -const genesisHash = '0x...'; +- **`search(query, status)`** — query the off-chain username index. `status` is + `'ASSIGNED' | 'PENDING'`. Resolves to a list of `{ candidateAccountId, username, status, + onchainData, createdAt, updatedAt }` rows. +- **`getConsumerInfo(address)`** — resolve a single SS58 address to an `Identity` + (`{ accountId, fullUsername, liteUsername, credibility }`) by reading + `Resources.Consumers` from the People chain. Returns `null` if the account has no + consumer entry. Tolerates both snake_case (V1) and camelCase (V2) runtime field names. -container.handleFeature(async (feature) => { - return feature.tag === 'Chain' && feature.value === genesisHash; -}); -``` +Both methods return `ResultAsync<…, Error>`; call `.isOk()` / `.isErr()` to discriminate. + +## Codec subpath exports -### Provider implementation +The chat wire codecs are exposed under explicit subpaths so they can be tree-shaken +independently of the main entry point: ```ts -import { getWsProvider } from 'polkadot-api/ws-provider'; +import { + ChatMessage, + TextContent, + RichTextContent, + ChatAcceptedContent, + DeviceAddedContent, + DeviceRemovedContent, +} from '@novasamatech/host-chat/codec/message'; -const genesisHash = '0x...'; -const provider = getWsProvider('wss://...'); +import { + FileMeta, + FileVariant, + P2PMixnetFile, +} from '@novasamatech/host-chat/codec/attachment'; -container.connectToPapiProvider(genesisHash, provider); +import type { ChatSession } from '@novasamatech/host-chat/session'; ``` -## Known pitfalls - -### CSP error on iframe loading -If a dapp is hosted on a different domain than the container and uses HTTPS, you should add this meta tag to your host application HTML: - -```html - -``` +These are byte-compatible with the Android / iOS Polkadot Mobile clients — modify with +care, the indices are pinned by the protocol. From 57db32c4443493f27b70c878c95c320608457f5d Mon Sep 17 00:00:00 2001 From: Sergey Zhuravlev Date: Fri, 22 May 2026 15:31:49 +0200 Subject: [PATCH 20/31] docs: changelog --- CHANGELOG.md | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f230d38..9faf586b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,17 +2,14 @@ ### 🚀 Features -- **host-papp:** multi-device SSO. `createAuth` / `pappAdapter.sso` keeps the V1 `pairingStatus` / `authenticate()` / `abortAuthentication()` surface — same state transitions, `authenticate()` still resolves to a `StoredUserSession | null`, `pairingStatus.finished` still carries `session`. What's new is the protocol underneath: pairing now runs the V2 multi-device handshake (`VersionedHandshakeProposal::V2` QR proposal, ECDH-encrypted Statement Store response envelope, on-chain `Pending(AllowanceAllocation)` flow). Desktop and web hosts pair with the multi-device iOS/Android Polkadot Mobile builds through the same entry point you've been calling. The V2 codecs, pairing service, and state machine are SDK internals. -- **host-papp:** the SDK persists the V2 device identity itself. `createPappAdapter` creates and persists a fresh device identity to the configured `StorageAdapter` on first run and reuses it on subsequent launches — no consumer wiring needed. Hosts that want a different persistence backend (Electron Keychain, native secure storage) can override with an optional `deviceIdentity` factory. -- **host-papp:** the SDK persists pairing-topic statement dedupe state internally too, so stale `Success` statements on chain don't get replayed across launches without consumer plumbing. -- **host-papp:** `StoredUserSession` gains three optional V2 fields — `identityAccountId` (user identity sr25519 account), `identityChatPublicKey` (P-256 65-byte, derived locally from `identityChatPrivateKey`), and the peer device statement account exposed via `remoteAccount.accountId` — so device-sync and the chat layer can read peer state straight off the session. The matching `identityChatPrivateKey` is persisted into `UserSecretRepository` and passed to the optional `onAuthSuccess` hook for consumers that need it. -- **host-papp:** new optional `onAuthSuccess` hook on `createPappAdapter` for consumer-specific post-pairing work (telemetry, custom peer caches, device-sync seeding). Fires after the SDK has written the session + secrets to its own repositories. Receives `{ session: StoredUserSession, identityChatPrivateKey: Uint8Array }`. -- **host-chat:** new `MessageContent` variants at the spec'd indices — `chatAccepted` (14) now carries `{ messageId }` for iOS V1 backward decode; `deviceAdded` (17) `{ statementAccountId, encryptionPublicKey }`; `deviceRemoved` (18) `{ statementAccountId }`; `deviceChatAccepted` (20) `{ requestId, device }` sent on the identity-level session `SessionId(B, A)` encrypted with `K(A, B)` so all of a peer's devices can decrypt without a per-device envelope. +- **host-papp:** multi-device SSO. SSO pairing now runs the V2 multi-device handshake under the hood, so desktop and web hosts pair with the multi-device iOS/Android Polkadot Mobile builds through the same `createAuth` / `pappAdapter.sso` entry point — `pairingStatus`, `authenticate()`, `abortAuthentication()`, and the `StoredUserSession` returned to consumers are unchanged. The V2 protocol, codecs, and pairing state machine are SDK internals. +- **host-papp:** the SDK now persists the device identity and pairing-topic dedupe state itself, on the configured `StorageAdapter`, so no extra consumer wiring is needed across launches. Hosts that want a different identity backend (Electron Keychain, native secure storage) can override with an optional `deviceIdentity` factory on `createPappAdapter`. +- **host-papp:** `StoredUserSession` gains optional V2 fields for the user's identity chat public key and the peer device's statement account, so consumers building device-sync or chat-level features can read peer state straight off the session. A new optional `onAuthSuccess` hook on `createPappAdapter` fires after pairing with `{ session, identityChatPrivateKey }` for consumer-specific post-pairing work (telemetry, custom peer caches, device-sync seeding). +- **host-chat:** new message variants for the multi-device chat layer — chat-accept now carries the originating message id, and there are new variants for announcing and removing peer devices on the identity-level session, so all of a peer's devices can decrypt without a per-device envelope. ### 🩹 Fixes -- **host-papp / host-chat:** `identity/rpcAdapter` and `accountService.getConsumerInfo` tolerate both camelCase and snake_case `Resources.Consumers` metadata fields — the V2 multi-device runtime metadata emits camelCase, which previously crashed `raw.stmt_store_slots.map(...)`. -- **host-chat:** `DeviceAdded` / `DeviceRemoved` use length-prefixed `Bytes()` rather than fixed-size codecs, matching `substrate-sdk-android`'s wire shape for `AccountId` / `EncodedPublicKey` wrapper types (no `@FixedLength`). +- **host-papp / host-chat:** consumer-info parsing tolerates both camelCase and snake_case `Resources.Consumers` metadata fields — the V2 multi-device runtime metadata emits camelCase, which previously crashed account-resource resolution. ### ⚠️ Breaking Changes @@ -20,8 +17,8 @@ The migration is essentially two field renames; the auth surface is otherwise un - **host-papp:** `createPappAdapter` no longer accepts `metadata: string` (the V1 metadata URL) — host name / icon / platform now ride inside `hostMetadata` (sent inline with the V2 QR proposal). - **host-papp:** `HostMetadata` reshape — was `{ hostVersion?, osType?, osVersion? }`, now `{ hostName?, hostVersion?, hostIcon?, platformType?, platformVersion?, custom? }`. Map `osType → platformType` and `osVersion → platformVersion` when upgrading. -- **host-papp:** the V1 SSO handshake is gone. Paired clients on the V1 wire format will not pair against this build — both ends must run the multi-device V2 handshake. (Polkadot Mobile builds with multi-device support are V2.) Persisted V1 SSO sessions don't migrate; they decode short against the extended V2 codec and are wiped on first read, so users need to re-pair. -- **host-chat:** `MessageContent.chatAccepted` (14) payload changed from `_void` to `{ messageId: string }`. Older clients that emit `_void` will not decode. +- **host-papp:** the V1 SSO handshake is gone. Both ends must run the multi-device V2 handshake (Polkadot Mobile builds with multi-device support are V2). Persisted V1 SSO sessions don't migrate and are wiped on first read, so users need to re-pair. +- **host-chat:** the chat-accepted message payload changed shape (now carries the originating message id). Older clients on the V1 form will not decode. ### ❤️ Thank You From e2fe5a690f1b902a337738ffab18074505d230c8 Mon Sep 17 00:00:00 2001 From: Ilya Date: Wed, 27 May 2026 09:43:15 -0600 Subject: [PATCH 21/31] feat(sso): support Mobile SSO spec v0.2.2 (sso_encr_pub_key) + clean createSession sessionKey API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two coordinated changes across statement-store and host-papp to support the V2 SSO session transport on host applications per Mobile SSO spec v0.2.2 (https://hackmd.io/F_GpT6GDT4aOvjrTDbRfKw). ## statement-store: explicit sessionKey on createSession `createSession` now takes a `sessionKey: Uint8Array` parameter for the SessionId khash derivation, instead of (mis)using `remoteAccount.publicKey` for that purpose. The conflation worked for V1 by accident — V1 enc pubkeys were 33-byte compressed P-256 and fit blake2b's 64-byte key limit. V2's 65-byte uncompressed P-256 broke it. With the param explicit: - V1 callers (host-papp's createUserSession) pass `sessionKey: userSession.remoteAccount.publicKey` and preserve byte-identical behaviour. - V2 callers (polkadot-desktop's V2SsoSession) pass the ECDH-derived shared_secret_session, conforming to the spec: SessionId(A, B) = khash(shared_secret_session, "session" : A.acct : B.acct : "/" : "/") Breaking change for direct callers of `createSession`: must now pass `sessionKey`. The only in-tree caller (host-papp/createUserSession) is updated. session.spec.ts mock updated to pass `sessionKey = remoteAccount.publicKey` (preserves expectations). ## host-papp: Mobile SSO spec v0.2.2 - `HandshakeSuccessV2` codec gains `ssoEncPubKey: PublicKeyCodec`, i.e. `papp_encr_pub` from spec §Encrypted response — V2. New encoded Success body length: 226 bytes (32 + 32 + 32 + 65 + 65). - `HandshakeSuccessV2_v021` retains the previous 161-byte shape for back-compat decoding. - Length-dispatched `decodeEncryptedHandshakeResponseV2` accepts all three Success body shapes seen in the wild: 226 bytes (spec v0.2.2) — includes ssoEncPubKey 161 bytes (spec v0.2.1) — surfaces ssoEncPubKey: null 129 bytes (spec v0.2) — surfaces ssoEncPubKey: null AND rootAccountId: null - `HandshakeSuccessV2Value` and `HandshakeSuccessState` carry `ssoEncPubKey: Uint8Array | null`. - `OnAuthSuccess` callback's event payload now includes `ssoEncPubKey: Uint8Array | null` alongside the existing `identityChatPrivateKey`. Host applications use this to drive the V2 SSO session transport — pre-v0.2.2 peers leave it null and the host leaves the transport inactive while chat continues to work via `identityChatPrivateKey`. `createUserSession` passes `sessionKey: userSession.remoteAccount.publicKey` to preserve V1 session behaviour after the statement-store API change. ## Backward compatibility - V1 SSO sessions: unchanged (host-papp passes V1 enc pubkey as sessionKey). - V2 SSO sessions: existed in name only before (host applications couldn't actually open them because the V2 enc pubkey was too long for blake2b's key limit); now openable per spec. - v0.2.1 / v0.2 handshake-response peers: handshake completes, ssoEncPubKey surfaces as null on `OnAuthSuccess` so host applications can leave the SSO transport inactive without breaking chat. Coordinated host-side PR (vendors the built dist of this branch under vendor/ for CI build verification): https://github.com/paritytech/polkadot-desktop/pull/523 --- packages/host-papp/__tests__/auth.spec.ts | 7 +- .../__tests__/handshakeV2Codec.spec.ts | 64 ++++++++++++++++--- .../__tests__/handshakeV2Service.spec.ts | 1 + packages/host-papp/src/sso/auth/impl.ts | 15 ++++- .../src/sso/auth/scale/handshakeV2.ts | 60 +++++++++++++++-- packages/host-papp/src/sso/auth/v2/state.ts | 11 ++++ .../src/sso/sessionManager/userSession.ts | 6 ++ .../src/session/session.spec.ts | 1 + .../statement-store/src/session/session.ts | 19 +++++- 9 files changed, 165 insertions(+), 19 deletions(-) diff --git a/packages/host-papp/__tests__/auth.spec.ts b/packages/host-papp/__tests__/auth.spec.ts index 58b4b269..846e1d2a 100644 --- a/packages/host-papp/__tests__/auth.spec.ts +++ b/packages/host-papp/__tests__/auth.spec.ts @@ -21,6 +21,8 @@ const DEVICE_STMT_SECRET = new Uint8Array(64).fill(0x55); const IDENTITY_CHAT_PRIV = new Uint8Array(32).fill(0xdd); const IDENTITY_ACCT = new Uint8Array(32).fill(0xa1); const ROOT_ACCT = new Uint8Array(32).fill(0xa2); +// `papp_encr_pub` from Mobile SSO spec v0.2.2 (HandshakeSuccessV2.sso_encr_pub_key) +const SSO_ENC_PUB = new Uint8Array(65).fill(0x07); const PEER_STMT_ACCT_HEX = '0x' + '44'.repeat(32); const makeDeviceIdentity = (): DeviceIdentityForPairing => ({ @@ -42,10 +44,11 @@ const buildSuccessStatement = (): Statement => { identityAccountId: IDENTITY_ACCT, rootAccountId: ROOT_ACCT, identityChatPrivateKey: IDENTITY_CHAT_PRIV, + ssoEncPubKey: SSO_ENC_PUB, deviceEncPubKey: DEVICE_ENC_PUB, }); - // The inner body is a length-dispatched Success (161-byte payload). Wrap it - // as the discriminated `EncryptedHandshakeResponseV2::Success` for the + // The inner body is a length-dispatched Success (226-byte payload, spec v0.2.2). + // Wrap it as the discriminated `EncryptedHandshakeResponseV2::Success` for the // envelope. const successEnvelope = new Uint8Array(inner.length + 1); successEnvelope[0] = 1; // Success discriminant diff --git a/packages/host-papp/__tests__/handshakeV2Codec.spec.ts b/packages/host-papp/__tests__/handshakeV2Codec.spec.ts index 0f2aa191..86634443 100644 --- a/packages/host-papp/__tests__/handshakeV2Codec.spec.ts +++ b/packages/host-papp/__tests__/handshakeV2Codec.spec.ts @@ -13,6 +13,7 @@ import { HandshakeStatusV2, HandshakeSuccessV2, HandshakeSuccessV2Legacy, + HandshakeSuccessV2_v021, MetadataKey, VersionedHandshakeProposal, VersionedHandshakeResponse, @@ -93,20 +94,45 @@ describe('VersionedHandshakeProposal', () => { }); }); -describe('HandshakeSuccessV2 (spec v0.2.1, 161 bytes)', () => { - it('round-trips identityAccountId, rootAccountId, identityChatPrivateKey, deviceEncPubKey', () => { +describe('HandshakeSuccessV2 (spec v0.2.2, 226 bytes)', () => { + it('round-trips all fields incl. ssoEncPubKey', () => { const input = { identityAccountId: new Uint8Array(32).fill(0xa1), rootAccountId: new Uint8Array(32).fill(0xa2), identityChatPrivateKey: fixedChatPrivateKey, + ssoEncPubKey: new Uint8Array(65).fill(0x07), deviceEncPubKey: new Uint8Array(65).fill(0x04), }; const decoded = HandshakeSuccessV2.dec(HandshakeSuccessV2.enc(input)); expect(decoded).toEqual(input); }); - it('encodes to exactly 161 bytes', () => { + it('encodes to exactly 226 bytes (32 + 32 + 32 + 65 + 65)', () => { const encoded = HandshakeSuccessV2.enc({ + identityAccountId: new Uint8Array(32).fill(0xa1), + rootAccountId: new Uint8Array(32).fill(0xa2), + identityChatPrivateKey: fixedChatPrivateKey, + ssoEncPubKey: new Uint8Array(65).fill(0x07), + deviceEncPubKey: new Uint8Array(65).fill(0x04), + }); + expect(encoded.length).toBe(226); + }); +}); + +describe('HandshakeSuccessV2_v021 (legacy spec v0.2.1, 161 bytes)', () => { + it('round-trips identityAccountId, rootAccountId, identityChatPrivateKey, deviceEncPubKey', () => { + const input = { + identityAccountId: new Uint8Array(32).fill(0xa1), + rootAccountId: new Uint8Array(32).fill(0xa2), + identityChatPrivateKey: fixedChatPrivateKey, + deviceEncPubKey: new Uint8Array(65).fill(0x04), + }; + const decoded = HandshakeSuccessV2_v021.dec(HandshakeSuccessV2_v021.enc(input)); + expect(decoded).toEqual(input); + }); + + it('encodes to exactly 161 bytes', () => { + const encoded = HandshakeSuccessV2_v021.enc({ identityAccountId: new Uint8Array(32).fill(0xa1), rootAccountId: new Uint8Array(32).fill(0xa2), identityChatPrivateKey: fixedChatPrivateKey, @@ -143,8 +169,27 @@ describe('decodeEncryptedHandshakeResponseV2 (length-dispatched plaintext decode expect(decoded).toEqual({ tag: 'Pending', value: { tag: 'AllowanceAllocation', value: undefined } }); }); - it('decodes a 161-byte v0.2.1 Success body with rootAccountId', () => { + it('decodes a 226-byte v0.2.2 Success body with ssoEncPubKey', () => { const body = HandshakeSuccessV2.enc({ + identityAccountId: new Uint8Array(32).fill(0xa1), + rootAccountId: new Uint8Array(32).fill(0xa2), + identityChatPrivateKey: fixedChatPrivateKey, + ssoEncPubKey: new Uint8Array(65).fill(0x07), + deviceEncPubKey: new Uint8Array(65).fill(0x04), + }); + const bytes = new Uint8Array(1 + body.length); + bytes[0] = 0x01; + bytes.set(body, 1); + const decoded = decodeEncryptedHandshakeResponseV2(bytes); + expect(decoded.tag).toBe('Success'); + if (decoded.tag !== 'Success') return; + expect(decoded.value.ssoEncPubKey).toEqual(new Uint8Array(65).fill(0x07)); + expect(decoded.value.rootAccountId).toEqual(new Uint8Array(32).fill(0xa2)); + expect(decoded.value.identityChatPrivateKey).toEqual(fixedChatPrivateKey); + }); + + it('decodes a 161-byte v0.2.1 Success body and surfaces ssoEncPubKey as null', () => { + const body = HandshakeSuccessV2_v021.enc({ identityAccountId: new Uint8Array(32).fill(0xa1), rootAccountId: new Uint8Array(32).fill(0xa2), identityChatPrivateKey: fixedChatPrivateKey, @@ -156,11 +201,12 @@ describe('decodeEncryptedHandshakeResponseV2 (length-dispatched plaintext decode const decoded = decodeEncryptedHandshakeResponseV2(bytes); expect(decoded.tag).toBe('Success'); if (decoded.tag !== 'Success') return; + expect(decoded.value.ssoEncPubKey).toBeNull(); expect(decoded.value.rootAccountId).toEqual(new Uint8Array(32).fill(0xa2)); expect(decoded.value.identityChatPrivateKey).toEqual(fixedChatPrivateKey); }); - it('decodes a 129-byte v0.2 Success body and surfaces rootAccountId as null', () => { + it('decodes a 129-byte v0.2 Success body and surfaces rootAccountId AND ssoEncPubKey as null', () => { const body = HandshakeSuccessV2Legacy.enc({ identityAccountId: new Uint8Array(32).fill(0xa1), identityChatPrivateKey: fixedChatPrivateKey, @@ -173,13 +219,14 @@ describe('decodeEncryptedHandshakeResponseV2 (length-dispatched plaintext decode expect(decoded.tag).toBe('Success'); if (decoded.tag !== 'Success') return; expect(decoded.value.rootAccountId).toBeNull(); + expect(decoded.value.ssoEncPubKey).toBeNull(); expect(decoded.value.identityAccountId).toEqual(new Uint8Array(32).fill(0xa1)); }); it('rejects a Success body of unknown length', () => { const bytes = new Uint8Array(50); bytes[0] = 0x01; - expect(() => decodeEncryptedHandshakeResponseV2(bytes)).toThrow(/not in \{129, 161\}/); + expect(() => decodeEncryptedHandshakeResponseV2(bytes)).toThrow(/not in \{129, 161, 226\}/); }); it('decodes Failed with a UTF-8 reason string', () => { @@ -209,18 +256,19 @@ describe('EncryptedHandshakeResponseV2 (native scale-ts Enum, used for encode)', expect(encoded).toEqual(new Uint8Array([0x00, 0x00])); }); - it('round-trips Success on the v0.2.1 wire format', () => { + it('round-trips Success on the v0.2.2 wire format', () => { const success = { tag: 'Success' as const, value: { identityAccountId: new Uint8Array(32).fill(0xa1), rootAccountId: new Uint8Array(32).fill(0xa2), identityChatPrivateKey: fixedChatPrivateKey, + ssoEncPubKey: new Uint8Array(65).fill(0x07), deviceEncPubKey: new Uint8Array(65).fill(0x04), }, }; const encoded = EncryptedHandshakeResponseV2.enc(success); - expect(encoded.length).toBe(1 + 161); + expect(encoded.length).toBe(1 + 226); expect(encoded[0]).toBe(0x01); expect(EncryptedHandshakeResponseV2.dec(encoded)).toEqual(success); }); diff --git a/packages/host-papp/__tests__/handshakeV2Service.spec.ts b/packages/host-papp/__tests__/handshakeV2Service.spec.ts index 6823a94e..7be3699f 100644 --- a/packages/host-papp/__tests__/handshakeV2Service.spec.ts +++ b/packages/host-papp/__tests__/handshakeV2Service.spec.ts @@ -128,6 +128,7 @@ describe('startPairingV2', () => { identityAccountId: new Uint8Array(32).fill(0xa1), rootAccountId: new Uint8Array(32).fill(0xa2), identityChatPrivateKey: new Uint8Array(32).fill(0xdd), + ssoEncPubKey: new Uint8Array(65).fill(0x07), deviceEncPubKey: new Uint8Array(65).fill(0x04), }, }); diff --git a/packages/host-papp/src/sso/auth/impl.ts b/packages/host-papp/src/sso/auth/impl.ts index 88fb83b2..7df938ca 100644 --- a/packages/host-papp/src/sso/auth/impl.ts +++ b/packages/host-papp/src/sso/auth/impl.ts @@ -31,6 +31,13 @@ export type AuthComponent = ReturnType; export type OnAuthSuccess = (event: { session: StoredUserSession; identityChatPrivateKey: Uint8Array; + /** + * `papp_encr_pub` from Mobile SSO spec v0.2.2. `null` when the peer + * shipped a pre-v0.2.2 `HandshakeSuccessV2` body (no `sso_encr_pub_key` + * field on the wire). The host's SSO session transport stays inactive + * while null; chat is unaffected since it uses `identityChatPrivateKey`. + */ + ssoEncPubKey: Uint8Array | null; }) => Promise | void; type Params = { @@ -253,7 +260,13 @@ export function createAuth({ .andThen(() => onAuthSuccess ? ResultAsync.fromPromise( - Promise.resolve(onAuthSuccess({ session, identityChatPrivateKey: success.identityChatPrivateKey })), + Promise.resolve( + onAuthSuccess({ + session, + identityChatPrivateKey: success.identityChatPrivateKey, + ssoEncPubKey: success.ssoEncPubKey, + }), + ), toError, ).map(() => session) : okAsync(session), diff --git a/packages/host-papp/src/sso/auth/scale/handshakeV2.ts b/packages/host-papp/src/sso/auth/scale/handshakeV2.ts index 95d0d161..61f6aa21 100644 --- a/packages/host-papp/src/sso/auth/scale/handshakeV2.ts +++ b/packages/host-papp/src/sso/auth/scale/handshakeV2.ts @@ -71,11 +71,22 @@ export const VersionedHandshakeProposal = Enum({ // ── Response (V2) ─────────────────────────────────────────────────────── -/** 32 + 32 + 32 + 65 = 161 bytes (spec v0.2.1) */ +/** + * 32 + 32 + 32 + 65 + 65 = 226 bytes (spec v0.2.2). + * + * `ssoEncPubKey` is `papp_encr_pub` per spec § Encrypted response — V2 — the + * P-256 public key PApp uses for SSO session ECDH. Required to compute + * `shared_secret_session = ECDH(host_encr_secret, ssoEncPubKey)`. PApp keypair + * derivation is implementation-defined: Android derives under `//wallet//sso` + * (`SsoDerivationDomains.SSO_DERIVATION_DOMAIN`); iOS currently reuses + * `//wallet//chat`. The host treats this field as opaque public material — + * which keypair PApp picked is invisible here. + */ export const HandshakeSuccessV2 = Struct({ identityAccountId: AccountIdCodec, rootAccountId: AccountIdCodec, identityChatPrivateKey: PrivateKeyCodec, + ssoEncPubKey: PublicKeyCodec, deviceEncPubKey: PublicKeyCodec, }); @@ -84,9 +95,24 @@ export type HandshakeSuccessV2Value = { /** Nullable for v0.2 peers (Android `feature/location-for-handshake`). */ rootAccountId: Uint8Array | null; identityChatPrivateKey: Uint8Array; + /** + * Nullable for v0.2 / v0.2.1 peers (any PApp build that does not yet ship + * spec v0.2.2). The host's SSO session transport stays inactive while + * null — sign/vrf/etc operations fail at the boundary until PApp emits + * the new field. + */ + ssoEncPubKey: Uint8Array | null; deviceEncPubKey: Uint8Array; }; +/** 32 + 32 + 32 + 65 = 161 bytes (spec v0.2.1) */ +export const HandshakeSuccessV2_v021 = Struct({ + identityAccountId: AccountIdCodec, + rootAccountId: AccountIdCodec, + identityChatPrivateKey: PrivateKeyCodec, + deviceEncPubKey: PublicKeyCodec, +}); + /** 32 + 32 + 65 = 129 bytes (spec v0.2 — Android `feature/location-for-handshake`) */ export const HandshakeSuccessV2Legacy = Struct({ identityAccountId: AccountIdCodec, @@ -101,9 +127,16 @@ export type DecodedHandshakeResponseV2 = /** * Length-dispatched decoder for the inner `EncryptedHandshakeResponseV2` - * plaintext. v0.2 (Android) ships 129-byte Success bodies without - * `rootAccountId`; v0.2.1 ships 161-byte bodies with it. Surfaces - * `rootAccountId: null` for the legacy case — chat doesn't need it. + * plaintext. Three Success body shapes are accepted, one per spec rev: + * + * 226 bytes (spec v0.2.2) — includes `ssoEncPubKey` (`papp_encr_pub`). + * 161 bytes (spec v0.2.1) — `ssoEncPubKey` absent; surfaced as `null`. + * 129 bytes (spec v0.2) — `rootAccountId` AND `ssoEncPubKey` absent. + * + * Older peers degrade gracefully: chat continues to work via + * `identityChatPrivateKey`, but the SSO session transport (which needs + * `ssoEncPubKey` to derive `shared_secret_session`) stays inactive on + * the host until the peer upgrades. */ export const decodeEncryptedHandshakeResponseV2 = (bytes: Uint8Array): DecodedHandshakeResponseV2 => { if (bytes.length === 0) throw new Error('EncryptedHandshakeResponseV2: empty plaintext'); @@ -115,7 +148,7 @@ export const decodeEncryptedHandshakeResponseV2 = (bytes: Uint8Array): DecodedHa return { tag: 'Pending', value: { tag: 'AllowanceAllocation', value: undefined } }; } if (tag === 1) { - if (body.length === 161) { + if (body.length === 226) { const decoded = HandshakeSuccessV2.dec(body); return { tag: 'Success', @@ -123,6 +156,20 @@ export const decodeEncryptedHandshakeResponseV2 = (bytes: Uint8Array): DecodedHa identityAccountId: decoded.identityAccountId, rootAccountId: decoded.rootAccountId, identityChatPrivateKey: decoded.identityChatPrivateKey, + ssoEncPubKey: decoded.ssoEncPubKey, + deviceEncPubKey: decoded.deviceEncPubKey, + }, + }; + } + if (body.length === 161) { + const decoded = HandshakeSuccessV2_v021.dec(body); + return { + tag: 'Success', + value: { + identityAccountId: decoded.identityAccountId, + rootAccountId: decoded.rootAccountId, + identityChatPrivateKey: decoded.identityChatPrivateKey, + ssoEncPubKey: null, deviceEncPubKey: decoded.deviceEncPubKey, }, }; @@ -135,11 +182,12 @@ export const decodeEncryptedHandshakeResponseV2 = (bytes: Uint8Array): DecodedHa identityAccountId: decoded.identityAccountId, rootAccountId: null, identityChatPrivateKey: decoded.identityChatPrivateKey, + ssoEncPubKey: null, deviceEncPubKey: decoded.deviceEncPubKey, }, }; } - throw new Error(`EncryptedHandshakeResponseV2: Success body length ${body.length} not in {129, 161}`); + throw new Error(`EncryptedHandshakeResponseV2: Success body length ${body.length} not in {129, 161, 226}`); } if (tag === 2) { return { tag: 'Failed', value: str.dec(body) }; diff --git a/packages/host-papp/src/sso/auth/v2/state.ts b/packages/host-papp/src/sso/auth/v2/state.ts index 633e469d..d7c7dcce 100644 --- a/packages/host-papp/src/sso/auth/v2/state.ts +++ b/packages/host-papp/src/sso/auth/v2/state.ts @@ -50,6 +50,16 @@ export type HandshakeSuccessState = { * back to the authorising device. */ deviceEncPubKey: Uint8Array; + /** + * `papp_encr_pub` from the Mobile SSO spec (v0.2.2 — 65 bytes, P-256 + * uncompressed). The host's SSO session transport derives + * `shared_secret_session = ECDH(host_encr_secret, ssoEncPubKey)` from + * this. Nullable because v0.2 and v0.2.1 peers don't ship it; while + * null the host's SSO transport stays inactive (sign/vrf/etc continue + * to fail at the boundary) and chat keeps working through + * `identityChatPrivateKey`. + */ + ssoEncPubKey: Uint8Array | null; /** * The pairing-topic statement was signed by PApp's device statement * account. `HandshakeSuccessV2` doesn't carry it in the encrypted body, so @@ -93,6 +103,7 @@ export const fromInnerResponse = ( identityChatPrivateKey: response.value.identityChatPrivateKey, identityChatPublicKey: deriveIdentityChatPublicKey(response.value.identityChatPrivateKey), deviceEncPubKey: response.value.deviceEncPubKey, + ssoEncPubKey: response.value.ssoEncPubKey, peerStatementAccountId, }; case 'Failed': diff --git a/packages/host-papp/src/sso/sessionManager/userSession.ts b/packages/host-papp/src/sso/sessionManager/userSession.ts index 3156fb2d..abf437bf 100644 --- a/packages/host-papp/src/sso/sessionManager/userSession.ts +++ b/packages/host-papp/src/sso/sessionManager/userSession.ts @@ -128,6 +128,12 @@ export function createUserSession({ statementStore, encryption, prover, + // V1 sessions historically derived SessionId by feeding the peer's raw + // encryption pubkey into khash; preserve that behaviour here so existing + // V1 channels stay byte-identical post-refactor. V2 callers (e.g. + // polkadot-desktop's V2SsoSession) pass the ECDH-derived shared secret + // explicitly per Mobile SSO spec v0.2.2. + sessionKey: userSession.remoteAccount.publicKey, }); const processedMessages = fieldListView({ diff --git a/packages/statement-store/src/session/session.spec.ts b/packages/statement-store/src/session/session.spec.ts index 6c4ed578..e0921ddd 100644 --- a/packages/statement-store/src/session/session.spec.ts +++ b/packages/statement-store/src/session/session.spec.ts @@ -79,6 +79,7 @@ function makeSession(overrides?: { statementStore: adapter, encryption: mockEncryption(), prover: mockProver, + sessionKey: remoteAccount.publicKey, maxRequestSize, }); return { session, adapter }; diff --git a/packages/statement-store/src/session/session.ts b/packages/statement-store/src/session/session.ts index 121f49e1..ef26ee8a 100644 --- a/packages/statement-store/src/session/session.ts +++ b/packages/statement-store/src/session/session.ts @@ -27,6 +27,20 @@ export type SessionParams = { statementStore: StatementStoreAdapter; encryption: Encryption; prover: StatementProver; + /** + * Keyed-hash input for SessionId derivation: + * SessionId(A, B) = khash(sessionKey, "session" : AccountId(A) : AccountId(B) : "/" : "/") + * + * Required because blake2b's key length is bounded (≤ 64 bytes) and the + * caller must decide what semantically goes here. V1 sessions historically + * passed `remoteAccount.publicKey` (33-byte compressed P-256 — fits) which + * conflated the encryption pubkey with the session-derivation key. The V2 + * spec (Mobile SSO v0.2.2) is explicit that SessionId is keyed by the + * ECDH-derived shared secret, so V2 callers should pass that 32-byte value + * directly. Pass any 32–64 byte material; out-of-range inputs make blake2b + * throw. + */ + sessionKey: Uint8Array; maxRequestSize?: number; }; @@ -75,10 +89,11 @@ export function createSession({ statementStore, encryption, prover, + sessionKey, maxRequestSize = DEFAULT_MAX_REQUEST_SIZE, }: SessionParams): Session { - const outgoingSessionId = createSessionId(remoteAccount.publicKey, localAccount, remoteAccount); - const incomingSessionId = createSessionId(remoteAccount.publicKey, remoteAccount, localAccount); + const outgoingSessionId = createSessionId(sessionKey, localAccount, remoteAccount); + const incomingSessionId = createSessionId(sessionKey, remoteAccount, localAccount); const state: SessionState = { phase: 'initialization', From c3cda4850a18cb29321b95f4a4a05ba4db5fd1d8 Mon Sep 17 00:00:00 2001 From: Ilya Date: Wed, 27 May 2026 17:14:15 -0600 Subject: [PATCH 22/31] feat(host-chat): align chat-message + attachment codec with Android `P2PMixnetFile` now carries the sender-stamped hop endpoint and per-meta blurhash thumbnail that Android (and iOS) stamp on outgoing attachments: P2PMixnetFile = { identifier, claimTicket, nodeEndpoint, meta } NodeEndpoint = wssUrl({ url }) // 0 ImageFileMeta = { ..., thumbnail: Option(Vec) } VideoFileMeta = { ..., thumbnail: Option(Vec) } Receivers use `nodeEndpoint` to validate the hop URL against their bulletin-chain allowlist before opening a socket; `thumbnail` is the UTF-8 bytes of a Wolt-spec blurhash string for a placeholder render while the full file downloads. `MessageContent` replaces five `_void` placeholders with the actual payload-bearing variants Android emits, so a sync envelope (or a chat message) that mixes them with text/richText decodes cleanly instead of mis-advancing the cursor: - 8 dataChannelOffer { sdp: Bytes, purpose: AUDIO_CALL|VIDEO_CALL } - 9 dataChannelAnswer { offerMessageId, sdp } - 10 dataChannelIceCandidate { offerMessageId, sdp } (renamed from `dataChannelCandidates`) - 11 dataChannelClosed { offerMessageId } - 16 coinagePayment { totalValue: compact, coinKeys: Vec> } Indices 6 and 19 stay reserved (no Android counterpart). New codec spec covers the data-channel + coinage round-trips and pins the enum discriminants for 16, 11, and 8. --- .../host-chat/src/codec/attachment.spec.ts | 67 +++++++++- packages/host-chat/src/codec/attachment.ts | 15 ++- packages/host-chat/src/codec/message.spec.ts | 115 ++++++++++++++++++ packages/host-chat/src/codec/message.ts | 50 +++++++- 4 files changed, 235 insertions(+), 12 deletions(-) create mode 100644 packages/host-chat/src/codec/message.spec.ts diff --git a/packages/host-chat/src/codec/attachment.spec.ts b/packages/host-chat/src/codec/attachment.spec.ts index ec6c742a..20857d4e 100644 --- a/packages/host-chat/src/codec/attachment.spec.ts +++ b/packages/host-chat/src/codec/attachment.spec.ts @@ -1,6 +1,14 @@ import { describe, expect, it } from 'vitest'; -import { FileMeta, FileVariant, GeneralFileMeta, ImageFileMeta, P2PMixnetFile, VideoFileMeta } from './attachment.js'; +import { + FileMeta, + FileVariant, + GeneralFileMeta, + ImageFileMeta, + NodeEndpoint, + P2PMixnetFile, + VideoFileMeta, +} from './attachment.js'; describe('attachment codecs', () => { describe('GeneralFileMeta', () => { @@ -13,28 +21,53 @@ describe('attachment codecs', () => { }); describe('ImageFileMeta', () => { - it('round-trips', () => { + it('round-trips without thumbnail', () => { const original = { general: { mimeType: 'image/jpeg', fileSize: 500_000 }, width: 1920, height: 1080, + thumbnail: undefined, }; const encoded = ImageFileMeta.enc(original); const decoded = ImageFileMeta.dec(encoded); expect(decoded).toEqual(original); }); + + it('round-trips with blurhash thumbnail', () => { + const original = { + general: { mimeType: 'image/jpeg', fileSize: 500_000 }, + width: 1920, + height: 1080, + thumbnail: new TextEncoder().encode('L6PZfSi_.AyE_3t7t7R**0o#DgR4'), + }; + const encoded = ImageFileMeta.enc(original); + const decoded = ImageFileMeta.dec(encoded); + expect(decoded.thumbnail).toEqual(original.thumbnail); + }); }); describe('VideoFileMeta', () => { - it('round-trips', () => { + it('round-trips without thumbnail', () => { const original = { general: { mimeType: 'video/mp4', fileSize: 10_000_000 }, duration: 120, + thumbnail: undefined, }; const encoded = VideoFileMeta.enc(original); const decoded = VideoFileMeta.dec(encoded); expect(decoded).toEqual(original); }); + + it('round-trips with blurhash thumbnail', () => { + const original = { + general: { mimeType: 'video/mp4', fileSize: 10_000_000 }, + duration: 120, + thumbnail: new TextEncoder().encode('LKO2?U%2Tw=w]~RBVZRi};RPxuwH'), + }; + const encoded = VideoFileMeta.enc(original); + const decoded = VideoFileMeta.dec(encoded); + expect(decoded.thumbnail).toEqual(original.thumbnail); + }); }); describe('FileMeta', () => { @@ -43,11 +76,20 @@ describe('attachment codecs', () => { { tag: 'general' as const, value: { mimeType: 'application/pdf', fileSize: 1024 } }, { tag: 'image' as const, - value: { general: { mimeType: 'image/png', fileSize: 2048 }, width: 800, height: 600 }, + value: { + general: { mimeType: 'image/png', fileSize: 2048 }, + width: 800, + height: 600, + thumbnail: undefined, + }, }, { tag: 'video' as const, - value: { general: { mimeType: 'video/mp4', fileSize: 4096 }, duration: 60 }, + value: { + general: { mimeType: 'video/mp4', fileSize: 4096 }, + duration: 60, + thumbnail: new TextEncoder().encode('blurhashstring'), + }, }, ]; @@ -59,17 +101,28 @@ describe('attachment codecs', () => { }); }); + describe('NodeEndpoint', () => { + it('round-trips wssUrl', () => { + const original = { tag: 'wssUrl' as const, value: { url: 'wss://hop-a.example/chat' } }; + const encoded = NodeEndpoint.enc(original); + const decoded = NodeEndpoint.dec(encoded); + expect(decoded).toEqual(original); + }); + }); + describe('P2PMixnetFile', () => { - it('round-trips', () => { + it('round-trips with nodeEndpoint and image thumbnail', () => { const original = { identifier: new Uint8Array(32).fill(0xaa), claimTicket: new Uint8Array(32).fill(0xbb), + nodeEndpoint: { tag: 'wssUrl' as const, value: { url: 'wss://bulletin.example/hop' } }, meta: { tag: 'image' as const, value: { general: { mimeType: 'image/jpeg', fileSize: 500_000 }, width: 1920, height: 1080, + thumbnail: new TextEncoder().encode('L6PZfSi_.AyE_3t7t7R**0o#DgR4'), }, }, }; @@ -77,6 +130,7 @@ describe('attachment codecs', () => { const decoded = P2PMixnetFile.dec(encoded); expect(decoded.identifier).toEqual(original.identifier); expect(decoded.claimTicket).toEqual(original.claimTicket); + expect(decoded.nodeEndpoint).toEqual(original.nodeEndpoint); expect(decoded.meta).toEqual(original.meta); }); }); @@ -88,6 +142,7 @@ describe('attachment codecs', () => { value: { identifier: new Uint8Array(32).fill(0x11), claimTicket: new Uint8Array(32).fill(0x22), + nodeEndpoint: { tag: 'wssUrl' as const, value: { url: 'wss://hop.example/path' } }, meta: { tag: 'general' as const, value: { mimeType: 'text/plain', fileSize: 10 } }, }, }; diff --git a/packages/host-chat/src/codec/attachment.ts b/packages/host-chat/src/codec/attachment.ts index 36fbad6e..9db32a1f 100644 --- a/packages/host-chat/src/codec/attachment.ts +++ b/packages/host-chat/src/codec/attachment.ts @@ -1,5 +1,9 @@ import { Enum } from '@novasamatech/scale'; -import { Bytes, Struct, str, u32 } from 'scale-ts'; +import { Bytes, Option, Struct, str, u32 } from 'scale-ts'; + +// UTF-8 bytes of a Wolt-spec blurhash string (componentX=4, componentY=3). +// Wire type is Vec; the bytes themselves are the ASCII blurhash. +export const MediaThumbnail = Bytes(); export const GeneralFileMeta = Struct({ mimeType: str, @@ -10,11 +14,13 @@ export const ImageFileMeta = Struct({ general: GeneralFileMeta, width: u32, height: u32, + thumbnail: Option(MediaThumbnail), }); export const VideoFileMeta = Struct({ general: GeneralFileMeta, duration: u32, + thumbnail: Option(MediaThumbnail), }); export const FileMeta = Enum({ @@ -23,9 +29,16 @@ export const FileMeta = Enum({ video: VideoFileMeta, }); +// Hop node endpoint stamped onto outgoing attachments so the receiver can validate +// the URL against its bulletin-chain hop allowlist before opening a socket. +export const NodeEndpoint = Enum({ + wssUrl: Struct({ url: str }), +}); + export const P2PMixnetFile = Struct({ identifier: Bytes(), claimTicket: Bytes(), + nodeEndpoint: NodeEndpoint, meta: FileMeta, }); diff --git a/packages/host-chat/src/codec/message.spec.ts b/packages/host-chat/src/codec/message.spec.ts new file mode 100644 index 00000000..60a50e7b --- /dev/null +++ b/packages/host-chat/src/codec/message.spec.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from 'vitest'; + +import { + CoinagePaymentContent, + DataChannelAnswerContent, + DataChannelClosedContent, + DataChannelIceCandidateContent, + DataChannelOfferContent, + MessageContent, +} from './message.js'; + +describe('DataChannel content codecs', () => { + it('DataChannelOfferContent round-trips with AUDIO_CALL purpose', () => { + const original = { + sdp: new TextEncoder().encode('v=0\r\no=- 4611...\r\n'), + purpose: 'AUDIO_CALL' as const, + }; + const decoded = DataChannelOfferContent.dec(DataChannelOfferContent.enc(original)); + expect(decoded.purpose).toBe('AUDIO_CALL'); + expect(decoded.sdp).toEqual(original.sdp); + }); + + it('DataChannelAnswerContent round-trips', () => { + const original = { offerMessageId: 'offer-1', sdp: new Uint8Array([0xaa, 0xbb, 0xcc]) }; + const decoded = DataChannelAnswerContent.dec(DataChannelAnswerContent.enc(original)); + expect(decoded).toEqual(original); + }); + + it('DataChannelIceCandidateContent round-trips', () => { + const original = { offerMessageId: 'offer-1', sdp: new Uint8Array([0xde, 0xad, 0xbe, 0xef]) }; + const decoded = DataChannelIceCandidateContent.dec(DataChannelIceCandidateContent.enc(original)); + expect(decoded).toEqual(original); + }); + + it('DataChannelClosedContent round-trips', () => { + const original = { offerMessageId: 'offer-1' }; + const decoded = DataChannelClosedContent.dec(DataChannelClosedContent.enc(original)); + expect(decoded).toEqual(original); + }); +}); + +describe('CoinagePaymentContent', () => { + it('round-trips with a small Balance and a couple of coin keys', () => { + // scale-ts `compact` decodes to `number` for values ≤ 2^30 and `bigint` + // for larger ones; normalise to BigInt before asserting. + const original = { + totalValue: 1_234_567, + coinKeys: [new Uint8Array(32).fill(0x11), new Uint8Array(32).fill(0x22)], + }; + const decoded = CoinagePaymentContent.dec(CoinagePaymentContent.enc(original)); + expect(BigInt(decoded.totalValue)).toBe(1_234_567n); + expect(decoded.coinKeys).toHaveLength(2); + expect(decoded.coinKeys[0]).toEqual(original.coinKeys[0]); + expect(decoded.coinKeys[1]).toEqual(original.coinKeys[1]); + }); + + it('round-trips a large Balance (forces bigint path)', () => { + const huge = 2n ** 100n; + const original = { + totalValue: huge, + coinKeys: [new Uint8Array(32).fill(0x33)], + }; + const decoded = CoinagePaymentContent.dec(CoinagePaymentContent.enc(original)); + expect(BigInt(decoded.totalValue)).toBe(huge); + }); + + it('round-trips with an empty coin-keys list', () => { + const original = { totalValue: 0, coinKeys: [] }; + const decoded = CoinagePaymentContent.dec(CoinagePaymentContent.enc(original)); + expect(BigInt(decoded.totalValue)).toBe(0n); + expect(decoded.coinKeys).toEqual([]); + }); +}); + +describe('MessageContent enum discriminants', () => { + it('coinagePayment lands on discriminant 16 and round-trips inside MessageContent', () => { + const value = { + tag: 'coinagePayment' as const, + value: { totalValue: 42, coinKeys: [new Uint8Array(32).fill(0xab)] }, + }; + const encoded = MessageContent.enc(value); + expect(encoded[0]).toBe(16); + const decoded = MessageContent.dec(encoded); + expect(decoded.tag).toBe('coinagePayment'); + if (decoded.tag !== 'coinagePayment') throw new Error('unreachable'); + expect(BigInt(decoded.value.totalValue)).toBe(42n); + expect(decoded.value.coinKeys[0]).toEqual(value.value.coinKeys[0]); + }); + + it('dataChannelClosed lands on discriminant 11 and round-trips inside MessageContent', () => { + const value = { + tag: 'dataChannelClosed' as const, + value: { offerMessageId: 'offer-x' }, + }; + const encoded = MessageContent.enc(value); + expect(encoded[0]).toBe(11); + const decoded = MessageContent.dec(encoded); + expect(decoded.tag).toBe('dataChannelClosed'); + if (decoded.tag !== 'dataChannelClosed') throw new Error('unreachable'); + expect(decoded.value.offerMessageId).toBe('offer-x'); + }); + + it('dataChannelOffer lands on discriminant 8 and round-trips inside MessageContent', () => { + const value = { + tag: 'dataChannelOffer' as const, + value: { sdp: new Uint8Array([1, 2, 3]), purpose: 'VIDEO_CALL' as const }, + }; + const encoded = MessageContent.enc(value); + expect(encoded[0]).toBe(8); + const decoded = MessageContent.dec(encoded); + if (decoded.tag !== 'dataChannelOffer') throw new Error('unreachable'); + expect(decoded.value.purpose).toBe('VIDEO_CALL'); + expect(decoded.value.sdp).toEqual(value.value.sdp); + }); +}); diff --git a/packages/host-chat/src/codec/message.ts b/packages/host-chat/src/codec/message.ts index 72ebe7f1..e82419af 100644 --- a/packages/host-chat/src/codec/message.ts +++ b/packages/host-chat/src/codec/message.ts @@ -84,6 +84,46 @@ export const DeviceChatAcceptedContent = Struct({ device: DeviceInfoContent, }); +// WebRTC data-channel signalling carried as chat-content variants. Android's +// `ChatMessageStatementContent.DataChannelOffer/Answer/IceCandidate/Closed` +// use plain `ByteArray` (no `@FixedLength`), which substrate-sdk-android +// encodes as length-prefixed `Vec`. None of these are interpreted by +// the host on desktop, but the codecs must consume the payload bytes so +// that any sync/chat envelope mixing them with other variants still decodes +// cleanly — leaving these as `_void` mis-advances the decoder by N bytes +// and corrupts every following entry in the envelope. +export const DataChannelPurpose = Status('AUDIO_CALL', 'VIDEO_CALL'); + +export const DataChannelOfferContent = Struct({ + sdp: Bytes(), + purpose: DataChannelPurpose, +}); + +export const DataChannelAnswerContent = Struct({ + offerMessageId: str, + sdp: Bytes(), +}); + +export const DataChannelIceCandidateContent = Struct({ + offerMessageId: str, + sdp: Bytes(), +}); + +export const DataChannelClosedContent = Struct({ + offerMessageId: str, +}); + +// Android `CoinagePayment` (index 16): +// { totalValue: Balance (compact-encoded), coinKeys: Vec> } +// Desktop doesn't act on coinage, but the codec must consume the payload +// for the same reason as the data-channel variants above — otherwise a +// sync `SyncEntity::Messages` carrying a coinage message wipes out every +// following message in the same envelope at decode time. +export const CoinagePaymentContent = Struct({ + totalValue: compact, + coinKeys: Vector(Bytes()), +}); + // Note: enum indices MUST match iOS/Android SCALE codecs. // Indices are auto-assigned sequentially, so order matters. export const MessageContent = Enum({ @@ -95,15 +135,15 @@ export const MessageContent = Enum({ reactionRemoved: ReactionContent, // 5 _reserved6: _void, // 6 — reserved (unused) reply: ReplyContent, // 7 - dataChannelOffer: _void, // 8 - dataChannelAnswer: _void, // 9 - dataChannelCandidates: _void, // 10 - _reserved11: _void, // 11 — reserved (unused) + dataChannelOffer: DataChannelOfferContent, // 8 + dataChannelAnswer: DataChannelAnswerContent, // 9 + dataChannelIceCandidate: DataChannelIceCandidateContent, // 10 (renamed from `dataChannelCandidates`; Android: `DataChannelIceCandidate`) + dataChannelClosed: DataChannelClosedContent, // 11 (Android: `DataChannelClosed`) edit: EditContent, // 12 leftChat: _void, // 13 chatAccepted: ChatAcceptedContent, // 14 (legacy single-device accept, iOS V1) richText: RichTextContent, // 15 - _reserved16: _void, // 16 — reserved (android `coinagePayment`, unused on desktop) + coinagePayment: CoinagePaymentContent, // 16 (Android-only; consumed for cross-device sync wire-stability) deviceAdded: DeviceAddedContent, // 17 deviceRemoved: DeviceRemovedContent, // 18 _reserved19: _void, // 19 — reserved (placeholder so deviceChatAccepted lands on the spec'd index 20) From 53b2a648c6bb717e7e37fc2b3c8bdf202f880002 Mon Sep 17 00:00:00 2001 From: Ilya Date: Wed, 27 May 2026 17:14:54 -0600 Subject: [PATCH 23/31] fix(handoff-service): sign canonical claim payload + expose hop_ack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `hop_claim` was signing the raw 32-byte content hash with the ticket- derived sr25519 key. The HOP server (per `substrate/client/hop/src/types.rs`) validates the signature against `blake2b256("hop-claim-v1:" || hash)`, identical to Android's `HopSigningPayloads.claim`. With the wrong message, sigs fail verification and the server returns "Data not found" — indistinguishable from a truly missing entry, which sent receivers down the wrong debug path. HopSigningPayloads.{claim, ack, submit}(...) — byte-identical to Android `downloadFile` now signs `HopSigningPayloads.claim(hash)` for the metadata claim and each chunk claim. Cross-verified that @scure/sr25519 sigs produced this way verify under @polkadot/util-crypto's WASM `sr25519Verify` (same schnorrkel substrate context Android uses). `HopClient` gains `ack(hash, signature)` so callers can complete the claim+ack pair the server expects for clean eviction. `downloadFile` keeps the call best-effort and out of band — firing it on the same WSS between claims stalls the next chunk's response, so the receive path opts to rely on the server's claim-time eviction instead. --- packages/handoff-service/src/crypto/index.ts | 2 + .../src/crypto/signingPayloads.ts | 56 +++++++++++++++++++ .../src/fileLoader/fileLoader.spec.ts | 4 +- .../src/fileLoader/fileLoader.ts | 27 +++++++-- packages/handoff-service/src/rpc/client.ts | 11 ++++ 5 files changed, 93 insertions(+), 7 deletions(-) create mode 100644 packages/handoff-service/src/crypto/signingPayloads.ts diff --git a/packages/handoff-service/src/crypto/index.ts b/packages/handoff-service/src/crypto/index.ts index 747d162d..75c3362e 100644 --- a/packages/handoff-service/src/crypto/index.ts +++ b/packages/handoff-service/src/crypto/index.ts @@ -10,3 +10,5 @@ export { generateTicket, signWithTicket, } from './ticket.js'; + +export { HopSigningPayloads } from './signingPayloads.js'; diff --git a/packages/handoff-service/src/crypto/signingPayloads.ts b/packages/handoff-service/src/crypto/signingPayloads.ts new file mode 100644 index 00000000..257db8ea --- /dev/null +++ b/packages/handoff-service/src/crypto/signingPayloads.ts @@ -0,0 +1,56 @@ +import { blake2b } from '@noble/hashes/blake2.js'; + +/** + * Domain-separated 32-byte payloads that the HOP node verifies for + * submit/claim/ack. Byte layouts must remain identical to + * `substrate/client/hop/src/types.rs` — `signing_payload` and + * `submit_signing_payload` — and match the Android client + * (`HopSigningPayloads` in `feature_chats_impl.data.hop`). + * + * Without this domain separation, the HOP server rejects the signature and + * surfaces the failure as "Data not found" (the server doesn't reveal that + * the data is present but the signature didn't verify). + */ + +const textEncoder = new TextEncoder(); + +const SUBMIT_CONTEXT = textEncoder.encode('hop-submit-v1:'); +const CLAIM_CONTEXT = textEncoder.encode('hop-claim-v1:'); +const ACK_CONTEXT = textEncoder.encode('hop-ack-v1:'); + +function concat(...parts: Uint8Array[]): Uint8Array { + const total = parts.reduce((n, p) => n + p.length, 0); + const out = new Uint8Array(total); + let offset = 0; + for (const p of parts) { + out.set(p, offset); + offset += p.length; + } + return out; +} + +function blake2b256(data: Uint8Array): Uint8Array { + return blake2b(data, { dkLen: 32 }); +} + +function u64LeBytes(value: bigint): Uint8Array { + const out = new Uint8Array(8); + let v = value; + for (let i = 0; i < 8; i++) { + out[i] = Number(v & 0xffn); + v >>= 8n; + } + return out; +} + +export const HopSigningPayloads = { + submit(data: Uint8Array, submitTimestampMs: bigint): Uint8Array { + return blake2b256(concat(SUBMIT_CONTEXT, blake2b256(data), u64LeBytes(submitTimestampMs))); + }, + claim(hash: Uint8Array): Uint8Array { + return blake2b256(concat(CLAIM_CONTEXT, hash)); + }, + ack(hash: Uint8Array): Uint8Array { + return blake2b256(concat(ACK_CONTEXT, hash)); + }, +}; diff --git a/packages/handoff-service/src/fileLoader/fileLoader.spec.ts b/packages/handoff-service/src/fileLoader/fileLoader.spec.ts index 16bf1970..5be5ee22 100644 --- a/packages/handoff-service/src/fileLoader/fileLoader.spec.ts +++ b/packages/handoff-service/src/fileLoader/fileLoader.spec.ts @@ -39,13 +39,15 @@ function createMockHopClient() { return okAsync(entry); }); + const ackMock = vi.fn(() => okAsync(null)); const client: HopClient = { submit: submitMock, claim: claimMock, + ack: ackMock, poolStatus: vi.fn(() => okAsync({ entryCount: 0, totalBytes: 0, maxBytes: 10_000_000 })), }; - return { client, submitMock, claimMock, submittedEntries }; + return { client, submitMock, claimMock, ackMock, submittedEntries }; } describe('file loader', () => { diff --git a/packages/handoff-service/src/fileLoader/fileLoader.ts b/packages/handoff-service/src/fileLoader/fileLoader.ts index 46860c31..bd5c6ae2 100644 --- a/packages/handoff-service/src/fileLoader/fileLoader.ts +++ b/packages/handoff-service/src/fileLoader/fileLoader.ts @@ -5,6 +5,7 @@ import { errAsync, okAsync } from 'neverthrow'; import { UploadedFile } from '../codec.js'; import { + HopSigningPayloads, createFileEncryption, deriveEncryptionKey, derivePublicKey, @@ -88,16 +89,32 @@ export type DownloadParams = { onProgress?: (received: number, total: number) => void; }; +// Sign the canonical claim payload (`blake2b256("hop-claim-v1:" || hash)`) +// instead of the raw hash — the HOP server validates the signature against +// this exact payload, identical to Android's `HopSigningPayloads.claim`. +// Signing the raw hash was the previous behaviour; the server then rejected +// the signature and returned the error as "Data not found", indistinguishable +// from an actually-missing entry. +function signClaimPayload(claimTicket: Uint8Array, hash: Uint8Array): Uint8Array { + return signWithTicket(claimTicket, HopSigningPayloads.claim(hash)); +} + export function downloadFile(params: DownloadParams): ResultAsync { const { identifier, claimTicket, hopClient, onProgress } = params; const encryptionKey = deriveEncryptionKey(claimTicket); const encryption = createFileEncryption(encryptionKey); - const metadataSignature = signWithTicket(claimTicket, identifier); + const claim = (hash: Uint8Array): ResultAsync => { + // Skip ack — claim already evicts the entry server-side, and firing a + // fire-and-forget ack on the same WSS during chunk loops can stall the + // next claim's response (observed in practice). Android calls ack + // explicitly per claim, but it's not required for the receive to + // complete; the server keeps cleanup paths idempotent. + return hopClient.claim(hash, signClaimPayload(claimTicket, hash)); + }; - return hopClient - .claim(identifier, metadataSignature) + return claim(identifier) .andThen(encryptedMetadata => { try { const metadataBytes = encryption.decrypt(encryptedMetadata); @@ -116,9 +133,7 @@ export function downloadFile(params: DownloadParams): ResultAsync { - const chunkSignature = signWithTicket(claimTicket, chunkHash); - - return hopClient.claim(chunkHash, chunkSignature).andThen(encryptedChunk => { + return claim(chunkHash).andThen(encryptedChunk => { try { const chunk = encryption.decrypt(encryptedChunk); onProgress?.(i + 1, totalChunks); diff --git a/packages/handoff-service/src/rpc/client.ts b/packages/handoff-service/src/rpc/client.ts index b5b9ae03..d36c91ca 100644 --- a/packages/handoff-service/src/rpc/client.ts +++ b/packages/handoff-service/src/rpc/client.ts @@ -8,6 +8,7 @@ import type { HexString, PoolStatus, RequestFn } from './types.js'; export type HopClient = { submit(data: Uint8Array, recipients: Uint8Array[]): ResultAsync; claim(hash: Uint8Array, signature: Uint8Array): ResultAsync; + ack(hash: Uint8Array, signature: Uint8Array): ResultAsync; poolStatus(): ResultAsync; }; @@ -47,6 +48,16 @@ export function createHopClient(requestFn: RequestFn): HopClient { ); }, + ack(hash, signature) { + // hop_ack acknowledges a successful claim so the server can evict the + // entry. Android calls this after every successful claim; failure is + // non-fatal for the receiver (best-effort cleanup). + return fromPromise( + requestFn('hop_ack', [toHexString(hash), encodeSr25519Signature(signature)]).then(() => null), + toError, + ); + }, + poolStatus() { return fromPromise(requestFn('hop_poolStatus', []), toError); }, From 5ea314e78d58ba0cfa23e2b57590823295db3b7d Mon Sep 17 00:00:00 2001 From: Ilya Date: Mon, 1 Jun 2026 12:25:10 -0600 Subject: [PATCH 24/31] feat(host-papp): persist ssoEncPubKey on the stored session Append `ssoEncPubKey` (papp_encr_pub, Mobile SSO spec v0.2.2) as an Option field on storedUserSessionCodec and write it in persistAndNotify. This lets a host rebuild its SSO session transport (shared_secret_session = ECDH(host_encr_secret, ssoEncPubKey)) on a cold start without re-running the handshake. None for pre-v0.2.2 peers. --- packages/host-papp/src/sso/auth/impl.ts | 1 + packages/host-papp/src/sso/userSessionRepository.ts | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/packages/host-papp/src/sso/auth/impl.ts b/packages/host-papp/src/sso/auth/impl.ts index 7df938ca..39caac01 100644 --- a/packages/host-papp/src/sso/auth/impl.ts +++ b/packages/host-papp/src/sso/auth/impl.ts @@ -246,6 +246,7 @@ export function createAuth({ { identityAccountId: createAccountId(success.identityAccountId), identityChatPublicKey: success.identityChatPublicKey, + ssoEncPubKey: success.ssoEncPubKey ?? undefined, }, ); diff --git a/packages/host-papp/src/sso/userSessionRepository.ts b/packages/host-papp/src/sso/userSessionRepository.ts index ed318124..5d6e622f 100644 --- a/packages/host-papp/src/sso/userSessionRepository.ts +++ b/packages/host-papp/src/sso/userSessionRepository.ts @@ -20,11 +20,17 @@ const storedUserSessionCodec = Struct({ rootAccountId: AccountIdCodec, identityAccountId: Option(AccountIdCodec), identityChatPublicKey: Option(Bytes(65)), + // `papp_encr_pub` (Mobile SSO spec v0.2.2, 65-byte uncompressed P-256). + // Persisted so the host can rebuild its SSO session transport + // (`shared_secret_session = ECDH(host_encr_secret, ssoEncPubKey)`) on a + // cold start without re-running the handshake. `None` for pre-v0.2.2 peers. + ssoEncPubKey: Option(Bytes(65)), }); type StoredUserSessionV2Extras = { identityAccountId?: AccountId; identityChatPublicKey?: Uint8Array; + ssoEncPubKey?: Uint8Array; }; export function createStoredUserSession( @@ -40,6 +46,7 @@ export function createStoredUserSession( rootAccountId, identityAccountId: extras.identityAccountId, identityChatPublicKey: extras.identityChatPublicKey, + ssoEncPubKey: extras.ssoEncPubKey, }; } From ebaa4fe87250ecaad041d541126a531781ada6d1 Mon Sep 17 00:00:00 2001 From: cuteWarmFrog Date: Wed, 27 May 2026 12:26:42 +0200 Subject: [PATCH 25/31] feat(host-papp): add watchIdentity subscription primitive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Today every identity read goes through getIdentity / getIdentities, which cache to localStorage and only refetch entries whose stored value is null. Once an account is observed as Lite, its on-chain promotion to Person is never picked up — the cache pins the stale value forever. Reframe the storage cache from authority to latency cache and make the chain the source of truth: - IdentityAdapter.watchIdentity(accountId): Observable -- live subscription via Resources.Consumers.watchValue. - IdentityRepository.watchIdentity: wraps the adapter with distinctUntilChanged, write-through to StorageAdapter on every distinct non-null emission, and a 15s initial-emission fallback (emits null) so a cold or unreachable WS doesn't leave consumers spinning. share() in front of the tap so the fallback's takeUntil subscription doesn't double the writes. - host-papp-react-ui useIdentity now subscribes via watchIdentity. The hook auto-rerenders on chain changes and unsubscribes on unmount. - Refactored the inline decoder out of readIdentities into a shared decodeRawIdentity so the batch read and the subscription decode identically. getIdentity / getIdentities are unchanged in this PR. They still return the cached value; the difference is that the cache is now kept fresh by any active watcher, so existing call sites benefit without code changes. Tests: 8 new cases on the repository covering emission forwarding, distinctUntilChanged, write-through (including the null skip), the fallback timeout, fallback cancellation when the source emits first, and adapter-error propagation. 532/532 across the workspace pass. --- .../host-papp-react-ui/src/hooks/identity.ts | 29 ++- .../__tests__/identityRepository.spec.ts | 193 ++++++++++++++++++ packages/host-papp/src/identity/impl.ts | 53 ++++- packages/host-papp/src/identity/rpcAdapter.ts | 110 ++++++---- packages/host-papp/src/identity/types.ts | 17 ++ 5 files changed, 342 insertions(+), 60 deletions(-) create mode 100644 packages/host-papp/__tests__/identityRepository.spec.ts diff --git a/packages/host-papp-react-ui/src/hooks/identity.ts b/packages/host-papp-react-ui/src/hooks/identity.ts index deb2eb4a..1ab9aa55 100644 --- a/packages/host-papp-react-ui/src/hooks/identity.ts +++ b/packages/host-papp-react-ui/src/hooks/identity.ts @@ -15,32 +15,27 @@ export function useIdentity(accountId: AccountId | null) { useEffect(() => { if (!hexAccountId) { setPending(false); + setIdentity(null); return; } - let mounted = true; - setPending(true); - papp.identity.getIdentity(hexAccountId).match( - identity => { - if (mounted) { - setIdentity(identity); - setPending(false); - } + + const subscription = papp.identity.watchIdentity(hexAccountId).subscribe({ + next: value => { + setIdentity(value); + setPending(false); }, - () => { - if (mounted) { - setIdentity(null); - setPending(false); - } + error: () => { + setIdentity(null); + setPending(false); }, - ); + }); return () => { - setPending(false); - mounted = false; + subscription.unsubscribe(); }; - }, [hexAccountId]); + }, [hexAccountId, papp]); return [identity, pending] as const; } diff --git a/packages/host-papp/__tests__/identityRepository.spec.ts b/packages/host-papp/__tests__/identityRepository.spec.ts new file mode 100644 index 00000000..64e8b290 --- /dev/null +++ b/packages/host-papp/__tests__/identityRepository.spec.ts @@ -0,0 +1,193 @@ +import { createMemoryAdapter } from '@novasamatech/storage-adapter'; +import { errAsync, okAsync } from 'neverthrow'; +import { Subject, throwError } from 'rxjs'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createIdentityRepository } from '../src/identity/impl.js'; +import type { Identity, IdentityAdapter } from '../src/identity/types.js'; + +const TIMEOUT_MS = 100; + +function lite(accountId = 'acc-1', liteUsername = 'alice.01'): Identity { + return { accountId, fullUsername: null, liteUsername, credibility: { type: 'Lite' } }; +} + +function person(accountId = 'acc-1', fullUsername = 'alice'): Identity { + return { + accountId, + fullUsername, + liteUsername: 'alice.01', + credibility: { type: 'Person', alias: '0xdeadbeef', lastUpdate: '42' }, + }; +} + +function makeAdapter(stream: Subject): IdentityAdapter { + return { + readIdentities: vi.fn(() => okAsync({})), + watchIdentity: vi.fn(() => stream.asObservable()), + }; +} + +describe('createIdentityRepository.watchIdentity', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('forwards each chain emission to the subscriber', () => { + const source = new Subject(); + const storage = createMemoryAdapter(); + const repo = createIdentityRepository({ + adapter: makeAdapter(source), + storage, + initialEmissionTimeoutMs: TIMEOUT_MS, + }); + + const emissions: (Identity | null)[] = []; + repo.watchIdentity('acc-1').subscribe(v => emissions.push(v)); + + source.next(lite()); + source.next(person()); + + expect(emissions).toEqual([lite(), person()]); + }); + + it('collapses identical consecutive emissions via distinctUntilChanged', () => { + const source = new Subject(); + const storage = createMemoryAdapter(); + const repo = createIdentityRepository({ + adapter: makeAdapter(source), + storage, + initialEmissionTimeoutMs: TIMEOUT_MS, + }); + + const emissions: (Identity | null)[] = []; + repo.watchIdentity('acc-1').subscribe(v => emissions.push(v)); + + source.next(lite()); + source.next({ ...lite() }); // structurally equal but new ref + source.next(person()); + + expect(emissions).toEqual([lite(), person()]); + }); + + it('writes each distinct non-null emission through to storage', async () => { + const source = new Subject(); + const writes: Record = {}; + const storage = { + read: vi.fn(() => okAsync(null)), + write: vi.fn((key: string, value: string) => { + writes[key] = value; + return okAsync(undefined); + }), + remove: vi.fn(() => okAsync(undefined)), + }; + const repo = createIdentityRepository({ + adapter: makeAdapter(source), + storage, + initialEmissionTimeoutMs: TIMEOUT_MS, + }); + + repo.watchIdentity('acc-1').subscribe(); + + source.next(lite()); + source.next(person()); + + expect(storage.write).toHaveBeenCalledTimes(2); + expect(JSON.parse(writes['identity_acc-1']!)).toEqual(person()); + }); + + it('does not write a null emission through to storage', () => { + const source = new Subject(); + const storage = { + read: vi.fn(() => okAsync(null)), + write: vi.fn(() => okAsync(undefined)), + remove: vi.fn(() => okAsync(undefined)), + }; + const repo = createIdentityRepository({ + adapter: makeAdapter(source), + storage, + initialEmissionTimeoutMs: TIMEOUT_MS, + }); + + repo.watchIdentity('acc-1').subscribe(); + source.next(null); + + expect(storage.write).not.toHaveBeenCalled(); + }); + + it('emits null after the initial-emission timeout when the source is silent', () => { + const source = new Subject(); + const storage = createMemoryAdapter(); + const repo = createIdentityRepository({ + adapter: makeAdapter(source), + storage, + initialEmissionTimeoutMs: TIMEOUT_MS, + }); + + const emissions: (Identity | null)[] = []; + repo.watchIdentity('acc-1').subscribe(v => emissions.push(v)); + + vi.advanceTimersByTime(TIMEOUT_MS); + + expect(emissions).toEqual([null]); + }); + + it('cancels the fallback once the source emits first', () => { + const source = new Subject(); + const storage = createMemoryAdapter(); + const repo = createIdentityRepository({ + adapter: makeAdapter(source), + storage, + initialEmissionTimeoutMs: TIMEOUT_MS, + }); + + const emissions: (Identity | null)[] = []; + repo.watchIdentity('acc-1').subscribe(v => emissions.push(v)); + + source.next(person()); + vi.advanceTimersByTime(TIMEOUT_MS * 2); + + expect(emissions).toEqual([person()]); + }); + + it('still emits real chain values that arrive after the fallback null', () => { + const source = new Subject(); + const storage = createMemoryAdapter(); + const repo = createIdentityRepository({ + adapter: makeAdapter(source), + storage, + initialEmissionTimeoutMs: TIMEOUT_MS, + }); + + const emissions: (Identity | null)[] = []; + repo.watchIdentity('acc-1').subscribe(v => emissions.push(v)); + + vi.advanceTimersByTime(TIMEOUT_MS); + source.next(person()); + + expect(emissions).toEqual([null, person()]); + }); + + it('forwards adapter errors to the subscriber', () => { + const adapter: IdentityAdapter = { + readIdentities: vi.fn(() => errAsync(new Error('rpc'))), + watchIdentity: vi.fn(() => throwError(() => new Error('pallet missing'))), + }; + const repo = createIdentityRepository({ + adapter, + storage: createMemoryAdapter(), + initialEmissionTimeoutMs: TIMEOUT_MS, + }); + + const errors: Error[] = []; + repo.watchIdentity('acc-1').subscribe({ + error: e => errors.push(e), + }); + + expect(errors).toHaveLength(1); + expect(errors[0]?.message).toBe('pallet missing'); + }); +}); diff --git a/packages/host-papp/src/identity/impl.ts b/packages/host-papp/src/identity/impl.ts index 246ee86a..d06e56cb 100644 --- a/packages/host-papp/src/identity/impl.ts +++ b/packages/host-papp/src/identity/impl.ts @@ -1,18 +1,47 @@ import type { StorageAdapter } from '@novasamatech/storage-adapter'; import { Result, ResultAsync, err, ok, okAsync } from 'neverthrow'; +import type { Observable } from 'rxjs'; +import { distinctUntilChanged, map, merge, share, takeUntil, tap, timer } from 'rxjs'; import { toError } from '../helpers/utils.js'; import type { Identity, IdentityAdapter, IdentityRepository } from './types.js'; +/** + * Hard ceiling for `watchIdentity`'s first emission. Without this, a cold or + * unreachable WS would leave consumers spinning indefinitely. After the + * timeout the stream emits `null`; a real chain emission still arrives later + * and takes over, thanks to `distinctUntilChanged`. + */ +export const WATCH_IDENTITY_INITIAL_TIMEOUT_MS = 15_000; + +function getCacheKey(accountId: string): string { + return `identity_${accountId}`; +} + +function identitiesEqual(a: Identity | null, b: Identity | null): boolean { + if (a === b) return true; + if (a === null || b === null) return false; + if (a.fullUsername !== b.fullUsername) return false; + if (a.liteUsername !== b.liteUsername) return false; + if (a.credibility.type !== b.credibility.type) return false; + if (a.credibility.type === 'Person' && b.credibility.type === 'Person') { + if (a.credibility.alias !== b.credibility.alias) return false; + if (a.credibility.lastUpdate !== b.credibility.lastUpdate) return false; + } + return true; +} + export function createIdentityRepository({ adapter, storage, + initialEmissionTimeoutMs = WATCH_IDENTITY_INITIAL_TIMEOUT_MS, }: { adapter: IdentityAdapter; storage: StorageAdapter; + initialEmissionTimeoutMs?: number; }): IdentityRepository { - const cachedRequester = createCachedIdentityRequester(storage, accountId => `identity_${accountId}`); + const cachedRequester = createCachedIdentityRequester(storage, getCacheKey); return { getIdentity(accountId) { @@ -21,6 +50,28 @@ export function createIdentityRepository({ getIdentities(accounts) { return cachedRequester(accounts, adapter.readIdentities); }, + watchIdentity(accountId): Observable { + const source$ = adapter.watchIdentity(accountId).pipe( + distinctUntilChanged(identitiesEqual), + tap(identity => { + // Write-through: every distinct chain value refreshes the storage + // cache so non-watching readers see the same freshness. + if (identity === null) return; + // Best-effort. ResultAsync runs eagerly; a write failure must not + // surface on the live read so we don't subscribe to the result. + void storage.write(getCacheKey(accountId), JSON.stringify(identity)); + }), + // `takeUntil(source$)` in the fallback below subscribes a second time; + // without share() the tap would write twice per emission until the + // fallback is cancelled. + share(), + ); + const fallback$ = timer(initialEmissionTimeoutMs).pipe( + takeUntil(source$), + map(() => null), + ); + return merge(source$, fallback$); + }, }; } diff --git a/packages/host-papp/src/identity/rpcAdapter.ts b/packages/host-papp/src/identity/rpcAdapter.ts index a7a5be1e..354c09e4 100644 --- a/packages/host-papp/src/identity/rpcAdapter.ts +++ b/packages/host-papp/src/identity/rpcAdapter.ts @@ -2,6 +2,8 @@ import type { HexString } from '@novasamatech/scale'; import type { LazyClient } from '@novasamatech/statement-store'; import { errAsync, fromPromise, ok } from 'neverthrow'; import { AccountId } from 'polkadot-api'; +import type { Observable } from 'rxjs'; +import { map, throwError } from 'rxjs'; import type { People_lite } from '../../.papi/descriptors/dist/index.js'; import { toError } from '../helpers/utils.js'; @@ -9,17 +11,63 @@ import { zipWith } from '../helpers/zipWith.js'; import type { Credibility, Identity, IdentityAdapter } from './types.js'; +type RawConsumers = { + full_username?: Uint8Array; + fullUsername?: Uint8Array; + lite_username?: Uint8Array; + liteUsername?: Uint8Array; + credibility: + | { type: 'Lite' } + | { + type: 'Person'; + value: { + alias: unknown; + last_update?: unknown; + lastUpdate?: unknown; + }; + }; +}; + +function decodeRawIdentity(accountId: string, typedRaw: unknown, textDecoder: TextDecoder): Identity | null { + if (!typedRaw) return null; + + // Runtime metadata may expose fields in snake_case (V1) or camelCase (V2 + // multi-device). The .papi descriptor only types snake_case, so widen here + // and read defensively. + const raw = typedRaw as RawConsumers; + const fullUsername = raw.full_username ?? raw.fullUsername; + const liteUsername = raw.lite_username ?? raw.liteUsername; + + const credibility: Credibility = + raw.credibility.type === 'Lite' + ? { type: 'Lite' } + : { + type: 'Person', + alias: raw.credibility.value.alias as HexString, + lastUpdate: (raw.credibility.value.last_update ?? raw.credibility.value.lastUpdate)!.toString(), + }; + + return { + accountId, + fullUsername: fullUsername ? textDecoder.decode(fullUsername) : null, + liteUsername: liteUsername ? textDecoder.decode(liteUsername) : '', + credibility, + }; +} + export function createIdentityRpcAdapter(lazyClient: LazyClient): IdentityAdapter { const accCodec = AccountId(); + const textDecoder = new TextDecoder(); + + function getConsumersStorage() { + const client = lazyClient.getClient(); + const unsafeApi = client.getUnsafeApi(); + return unsafeApi.query.Resources.Consumers; + } return { readIdentities(accounts) { - const textDecoder = new TextDecoder(); - const client = lazyClient.getClient(); - const unsafeApi = client.getUnsafeApi(); - - const method = unsafeApi.query.Resources.Consumers; - + const method = getConsumersStorage(); if (!method) { return errAsync(new Error('Method Resources.Consumers not found')); } @@ -33,45 +81,23 @@ export function createIdentityRpcAdapter(lazyClient: LazyClient): IdentityAdapte return ok( Object.fromEntries( - zipWith([accounts, results], x => x).map<[string, Identity | null]>(([accountId, typedRaw]) => { - if (!typedRaw) { - return [accountId, null]; - } - - // Runtime metadata may expose fields in snake_case (V1) or - // camelCase (V2 multi-device). Read defensively. The .papi - // descriptor only types snake_case, so widen here. - const raw = typedRaw as unknown as Record & typeof typedRaw; - const fullUsername = - (raw.full_username as Uint8Array | undefined) ?? (raw.fullUsername as Uint8Array | undefined); - const liteUsername = - (raw.lite_username as Uint8Array | undefined) ?? (raw.liteUsername as Uint8Array | undefined); - - const credibility: Credibility = - raw.credibility.type == 'Lite' - ? { - type: 'Lite', - } - : { - type: 'Person', - alias: raw.credibility.value.alias as HexString, - lastUpdate: ((raw.credibility.value as Record).last_update ?? - (raw.credibility.value as Record).lastUpdate)!.toString(), - }; - - return [ - accountId, - { - accountId: accountId, - fullUsername: fullUsername ? textDecoder.decode(fullUsername) : null, - liteUsername: liteUsername ? textDecoder.decode(liteUsername) : '', - credibility, - }, - ]; - }), + zipWith([accounts, results], x => x).map<[string, Identity | null]>(([accountId, typedRaw]) => [ + accountId, + decodeRawIdentity(accountId, typedRaw, textDecoder), + ]), ), ); }); }, + + watchIdentity(accountId): Observable { + const method = getConsumersStorage(); + if (!method) { + return throwError(() => new Error('Method Resources.Consumers not found')); + } + return method + .watchValue(accCodec.dec(accountId)) + .pipe(map(emission => decodeRawIdentity(accountId, emission.value, textDecoder))); + }, }; } diff --git a/packages/host-papp/src/identity/types.ts b/packages/host-papp/src/identity/types.ts index 8d167c8f..2854b471 100644 --- a/packages/host-papp/src/identity/types.ts +++ b/packages/host-papp/src/identity/types.ts @@ -1,4 +1,5 @@ import type { ResultAsync } from 'neverthrow'; +import type { Observable } from 'rxjs'; export type Credibility = | { @@ -19,9 +20,25 @@ export type Identity = { export type IdentityAdapter = { readIdentities(accounts: string[]): ResultAsync, Error>; + /** + * Live subscription to a single account's on-chain identity. Each emission + * is the freshest value the source observed; emits `null` when no entry + * exists. Implementations should error the stream when the underlying + * storage/pallet is unavailable. + */ + watchIdentity(accountId: string): Observable; }; export type IdentityRepository = { getIdentity(accountId: string): ResultAsync; getIdentities(accounts: string[]): ResultAsync, Error>; + /** + * Live subscription to a single account's identity. Emits each distinct + * value the chain reports, plus a final-fallback `null` if the source + * doesn't emit within {@link WATCH_IDENTITY_INITIAL_TIMEOUT_MS}, so + * consumers don't hang on a dead WS. Every distinct non-null emission is + * written through to the storage cache so non-watching readers + * (`getIdentity`/`getIdentities`) see the freshest value. + */ + watchIdentity(accountId: string): Observable; }; From e076f372ff6d3f5da2641b20186b17fc33da2fe4 Mon Sep 17 00:00:00 2001 From: cuteWarmFrog Date: Wed, 27 May 2026 12:43:02 +0200 Subject: [PATCH 26/31] fix(host-papp): address code-review findings on watchIdentity Repository (impl.ts): - Replace share() with shareReplay({bufferSize:1, refCount:true}). Fixes a race where a synchronous first emission from the adapter would slip past the fallback's takeUntil subscriber, causing a spurious null to be emitted by the 15s timer after the real value. - Seed from the storage cache on subscribe: the first emission is the cached value (if any), so consumers exit pending instantly on a warm cache instead of waiting up to 15s when the WS is slow or unreachable. The seed is dropped if the live chain emits first; the outer distinctUntilChanged collapses any seed/chain duplicate. - Per-account de-dup: N concurrent watchIdentity(acc) calls share one upstream subscription (cached by accountId). refCount tears the upstream down when the last subscriber leaves so a stale Map entry doesn't hold an open WS subscription. - Structural equality (JSON.stringify) for distinctUntilChanged. Adding a new field to Identity no longer silently bypasses deduping / write-through, since identitiesEqual no longer enumerates fields by hand. Hook (host-papp-react-ui/hooks/identity.ts): - Hold papp in a ref so the effect's deps stay [hexAccountId] only. A consumer that recreates the adapter object per render no longer re-subscribes the chain on every render. - Clear identity state on accountId change so the UI doesn't briefly show the previous account's identity under the new account while waiting for the first emission. Tests: 4 new cases cover seed-from-cache, seed-dropped-when-live-first, per-account de-dup, and structural equality with a future widened Identity shape. 536/536 passing. Not addressed (intentional, out of scope for this PR): - No automatic retry on adapter errors. Adding silent retry would swallow legitimate errors; consumers can remount or wrap if they want recovery semantics. - Error channel typed as part of Observable; subscribers must attach an error handler. Documented on watchIdentity. --- .../host-papp-react-ui/src/hooks/identity.ts | 20 ++- .../__tests__/identityRepository.spec.ts | 74 +++++++++++ packages/host-papp/src/identity/impl.ts | 118 +++++++++++++----- 3 files changed, 175 insertions(+), 37 deletions(-) diff --git a/packages/host-papp-react-ui/src/hooks/identity.ts b/packages/host-papp-react-ui/src/hooks/identity.ts index 1ab9aa55..4877f880 100644 --- a/packages/host-papp-react-ui/src/hooks/identity.ts +++ b/packages/host-papp-react-ui/src/hooks/identity.ts @@ -1,7 +1,7 @@ import type { Identity, UserSession } from '@novasamatech/host-papp'; import type { AccountId } from '@novasamatech/statement-store'; import { toHex } from '@polkadot-api/utils'; -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { usePapp } from '../flow/PappProvider.js'; @@ -10,18 +10,30 @@ export function useIdentity(accountId: AccountId | null) { const [pending, setPending] = useState(false); const [identity, setIdentity] = useState(null); + // Held in a ref so the effect's deps stay `[hexAccountId]` only. The papp + // adapter is treated as stable for a provider's lifetime; if a consumer + // recreates it per render, this hook still only re-subscribes when the + // account changes — preventing chain-subscription churn on every parent + // re-render. + const pappRef = useRef(papp); + pappRef.current = papp; + const hexAccountId = accountId ? toHex(accountId) : null; useEffect(() => { + // Clear the previously displayed identity immediately on account change + // so the UI doesn't render the prior account's identity under the new + // account while we wait for the first emission. + setIdentity(null); + if (!hexAccountId) { setPending(false); - setIdentity(null); return; } setPending(true); - const subscription = papp.identity.watchIdentity(hexAccountId).subscribe({ + const subscription = pappRef.current.identity.watchIdentity(hexAccountId).subscribe({ next: value => { setIdentity(value); setPending(false); @@ -35,7 +47,7 @@ export function useIdentity(accountId: AccountId | null) { return () => { subscription.unsubscribe(); }; - }, [hexAccountId, papp]); + }, [hexAccountId]); return [identity, pending] as const; } diff --git a/packages/host-papp/__tests__/identityRepository.spec.ts b/packages/host-papp/__tests__/identityRepository.spec.ts index 64e8b290..8aee73b0 100644 --- a/packages/host-papp/__tests__/identityRepository.spec.ts +++ b/packages/host-papp/__tests__/identityRepository.spec.ts @@ -190,4 +190,78 @@ describe('createIdentityRepository.watchIdentity', () => { expect(errors).toHaveLength(1); expect(errors[0]?.message).toBe('pallet missing'); }); + + it('emits a cached identity as the first value when the chain is silent', async () => { + const cached = person('acc-1', 'cached-name'); + const storage = createMemoryAdapter({ 'identity_acc-1': JSON.stringify(cached) }); + const source = new Subject(); + const repo = createIdentityRepository({ + adapter: makeAdapter(source), + storage, + initialEmissionTimeoutMs: TIMEOUT_MS, + }); + + const emissions: (Identity | null)[] = []; + repo.watchIdentity('acc-1').subscribe(v => emissions.push(v)); + + // Flush microtasks the cache read sits on (defer → Promise → from). + for (let i = 0; i < 5; i++) await Promise.resolve(); + + expect(emissions).toEqual([cached]); + }); + + it('drops the cache seed if the live chain emits first', async () => { + const cached = person('acc-1', 'cached-name'); + const storage = createMemoryAdapter({ 'identity_acc-1': JSON.stringify(cached) }); + const source = new Subject(); + const repo = createIdentityRepository({ + adapter: makeAdapter(source), + storage, + initialEmissionTimeoutMs: TIMEOUT_MS, + }); + + const emissions: (Identity | null)[] = []; + repo.watchIdentity('acc-1').subscribe(v => emissions.push(v)); + + // Live beats the (async) cache read. + source.next(person('acc-1', 'chain-name')); + for (let i = 0; i < 5; i++) await Promise.resolve(); + + expect(emissions).toEqual([person('acc-1', 'chain-name')]); + }); + + it('returns the same Observable for repeated watchIdentity(acc) calls', () => { + const storage = createMemoryAdapter(); + const repo = createIdentityRepository({ + adapter: makeAdapter(new Subject()), + storage, + initialEmissionTimeoutMs: TIMEOUT_MS, + }); + + expect(repo.watchIdentity('acc-1')).toBe(repo.watchIdentity('acc-1')); + expect(repo.watchIdentity('acc-1')).not.toBe(repo.watchIdentity('acc-2')); + }); + + it('treats two structurally-equal Identity objects as equal even if a new field is added', () => { + const source = new Subject(); + const storage = createMemoryAdapter(); + const repo = createIdentityRepository({ + adapter: makeAdapter(source), + storage, + initialEmissionTimeoutMs: TIMEOUT_MS, + }); + + const emissions: (Identity | null)[] = []; + repo.watchIdentity('acc-1').subscribe(v => emissions.push(v)); + + // Simulate a future field on Identity by widening the value passed + // through the adapter. Structural equality must still dedupe identical + // payloads so a future schema extension doesn't silently bypass + // distinctUntilChanged. + const widened = { ...person(), avatarUrl: 'https://example/a.png' } as unknown as Identity; + source.next(widened); + source.next({ ...person(), avatarUrl: 'https://example/a.png' } as unknown as Identity); + + expect(emissions).toEqual([widened]); + }); }); diff --git a/packages/host-papp/src/identity/impl.ts b/packages/host-papp/src/identity/impl.ts index d06e56cb..63842731 100644 --- a/packages/host-papp/src/identity/impl.ts +++ b/packages/host-papp/src/identity/impl.ts @@ -1,17 +1,19 @@ import type { StorageAdapter } from '@novasamatech/storage-adapter'; import { Result, ResultAsync, err, ok, okAsync } from 'neverthrow'; import type { Observable } from 'rxjs'; -import { distinctUntilChanged, map, merge, share, takeUntil, tap, timer } from 'rxjs'; +import { catchError, defer, distinctUntilChanged, map, merge, of, shareReplay, takeUntil, tap, timer } from 'rxjs'; import { toError } from '../helpers/utils.js'; import type { Identity, IdentityAdapter, IdentityRepository } from './types.js'; /** - * Hard ceiling for `watchIdentity`'s first emission. Without this, a cold or - * unreachable WS would leave consumers spinning indefinitely. After the - * timeout the stream emits `null`; a real chain emission still arrives later - * and takes over, thanks to `distinctUntilChanged`. + * Hard ceiling for `watchIdentity`'s first emission when the cache is cold. + * Without this, a cold or unreachable WS would leave consumers spinning + * indefinitely. After the timeout the stream emits `null`; a real chain + * emission still arrives later and takes over, thanks to + * `distinctUntilChanged`. When the cache has a value it's emitted + * immediately and the timer is cancelled before it fires. */ export const WATCH_IDENTITY_INITIAL_TIMEOUT_MS = 15_000; @@ -19,17 +21,35 @@ function getCacheKey(accountId: string): string { return `identity_${accountId}`; } +/** + * Structural identity equality. Used by `distinctUntilChanged` so that adding + * a field to `Identity` doesn't silently bypass deduping / write-through. + * Identity values flow through `decodeRawIdentity` only, so key order is + * deterministic and `JSON.stringify` is safe here. + */ function identitiesEqual(a: Identity | null, b: Identity | null): boolean { if (a === b) return true; if (a === null || b === null) return false; - if (a.fullUsername !== b.fullUsername) return false; - if (a.liteUsername !== b.liteUsername) return false; - if (a.credibility.type !== b.credibility.type) return false; - if (a.credibility.type === 'Person' && b.credibility.type === 'Person') { - if (a.credibility.alias !== b.credibility.alias) return false; - if (a.credibility.lastUpdate !== b.credibility.lastUpdate) return false; - } - return true; + return JSON.stringify(a) === JSON.stringify(b); +} + +function readCachedIdentity(storage: StorageAdapter, accountId: string): Observable { + return defer(() => + storage + .read(getCacheKey(accountId)) + .match( + raw => { + if (!raw) return null; + try { + return JSON.parse(raw) as Identity; + } catch { + return null; + } + }, + () => null, + ) + .then(value => value), + ).pipe(catchError(() => of(null))); } export function createIdentityRepository({ @@ -43,6 +63,48 @@ export function createIdentityRepository({ }): IdentityRepository { const cachedRequester = createCachedIdentityRequester(storage, getCacheKey); + // Per-account de-dup: N concurrent `watchIdentity(acc)` calls share one + // chain subscription. `shareReplay` with `refCount` tears the upstream down + // when all subscribers leave so we don't leak WS subscriptions. + const watchCache = new Map>(); + + function buildWatch(accountId: string): Observable { + // Live chain reads, multicast so the seed/fallback `takeUntil` branches + // don't open extra upstream subscriptions and the tap doesn't run twice. + // `shareReplay({refCount: true})` tears the upstream down when the last + // subscriber leaves and rebuilds it on the next subscription, so a stale + // accountId entry in `watchCache` doesn't hold a chain subscription open. + const live$ = adapter.watchIdentity(accountId).pipe( + distinctUntilChanged(identitiesEqual), + tap(identity => { + // Write-through: every distinct chain value refreshes the storage + // cache so non-watching readers see the same freshness. + if (identity === null) return; + // Best-effort. ResultAsync runs eagerly; a write failure must not + // surface on the live read so we don't subscribe to the result. + void storage.write(getCacheKey(accountId), JSON.stringify(identity)); + }), + shareReplay({ bufferSize: 1, refCount: true }), + ); + + // Seed: surface the cached value (if any) as the first emission so + // consumers exit `pending` instantly on warm cache instead of waiting + // for `watchValue` to deliver its first block. Stale-OK: any divergent + // chain emission immediately overwrites via the outer + // `distinctUntilChanged`. Dropped if `live$` beats it. + const seed$ = readCachedIdentity(storage, accountId).pipe(takeUntil(live$)); + + // Cold-cache + silent-chain safety net: if neither seed nor live$ have + // delivered within the timeout, emit `null` so the UI doesn't hang on + // `pending=true` forever. + const fallback$ = timer(initialEmissionTimeoutMs).pipe( + takeUntil(merge(seed$, live$)), + map(() => null as Identity | null), + ); + + return merge(seed$, live$, fallback$).pipe(distinctUntilChanged(identitiesEqual)); + } + return { getIdentity(accountId) { return cachedRequester([accountId], adapter.readIdentities).map(map => map[accountId] ?? null); @@ -50,27 +112,17 @@ export function createIdentityRepository({ getIdentities(accounts) { return cachedRequester(accounts, adapter.readIdentities); }, + /** + * Subscribers MUST attach an `error` handler. Adapter errors propagate + * (no automatic retry); the consumer is responsible for re-subscribing + * if recovery is desired. + */ watchIdentity(accountId): Observable { - const source$ = adapter.watchIdentity(accountId).pipe( - distinctUntilChanged(identitiesEqual), - tap(identity => { - // Write-through: every distinct chain value refreshes the storage - // cache so non-watching readers see the same freshness. - if (identity === null) return; - // Best-effort. ResultAsync runs eagerly; a write failure must not - // surface on the live read so we don't subscribe to the result. - void storage.write(getCacheKey(accountId), JSON.stringify(identity)); - }), - // `takeUntil(source$)` in the fallback below subscribes a second time; - // without share() the tap would write twice per emission until the - // fallback is cancelled. - share(), - ); - const fallback$ = timer(initialEmissionTimeoutMs).pipe( - takeUntil(source$), - map(() => null), - ); - return merge(source$, fallback$); + const existing = watchCache.get(accountId); + if (existing) return existing; + const stream = buildWatch(accountId); + watchCache.set(accountId, stream); + return stream; }, }; } From 30591d4b00afcd5ef09c2f4c7bd6b25d4d5ba9be Mon Sep 17 00:00:00 2001 From: cuteWarmFrog Date: Wed, 27 May 2026 13:26:45 +0200 Subject: [PATCH 27/31] refactor(host-papp): simplify watchIdentity impl and tests - collapse readCachedIdentity to a single defer/match, drop redundant catchError - trim essay-style comments across impl, types, hook - factor makeRepo/flushMicrotasks helpers and use createMemoryAdapter with vi.spyOn in tests - fix typecheck: align storage mocks with current StorageAdapter shape (clear/subscribe) --- .../host-papp-react-ui/src/hooks/identity.ts | 11 +- .../__tests__/identityRepository.spec.ts | 126 +++++------------- packages/host-papp/src/identity/impl.ts | 77 +++-------- packages/host-papp/src/identity/types.ts | 18 +-- 4 files changed, 60 insertions(+), 172 deletions(-) diff --git a/packages/host-papp-react-ui/src/hooks/identity.ts b/packages/host-papp-react-ui/src/hooks/identity.ts index 4877f880..952d326d 100644 --- a/packages/host-papp-react-ui/src/hooks/identity.ts +++ b/packages/host-papp-react-ui/src/hooks/identity.ts @@ -10,20 +10,15 @@ export function useIdentity(accountId: AccountId | null) { const [pending, setPending] = useState(false); const [identity, setIdentity] = useState(null); - // Held in a ref so the effect's deps stay `[hexAccountId]` only. The papp - // adapter is treated as stable for a provider's lifetime; if a consumer - // recreates it per render, this hook still only re-subscribes when the - // account changes — preventing chain-subscription churn on every parent - // re-render. + // Ref keeps the effect deps to `[hexAccountId]` so a re-rendered papp + // adapter doesn't trigger chain-subscription churn. const pappRef = useRef(papp); pappRef.current = papp; const hexAccountId = accountId ? toHex(accountId) : null; useEffect(() => { - // Clear the previously displayed identity immediately on account change - // so the UI doesn't render the prior account's identity under the new - // account while we wait for the first emission. + // Clear stale identity so the new account doesn't briefly show the old one. setIdentity(null); if (!hexAccountId) { diff --git a/packages/host-papp/__tests__/identityRepository.spec.ts b/packages/host-papp/__tests__/identityRepository.spec.ts index 8aee73b0..e1671906 100644 --- a/packages/host-papp/__tests__/identityRepository.spec.ts +++ b/packages/host-papp/__tests__/identityRepository.spec.ts @@ -1,3 +1,4 @@ +import type { StorageAdapter } from '@novasamatech/storage-adapter'; import { createMemoryAdapter } from '@novasamatech/storage-adapter'; import { errAsync, okAsync } from 'neverthrow'; import { Subject, throwError } from 'rxjs'; @@ -28,6 +29,14 @@ function makeAdapter(stream: Subject): IdentityAdapter { }; } +function makeRepo(adapter: IdentityAdapter, storage: StorageAdapter = createMemoryAdapter()) { + return createIdentityRepository({ adapter, storage, initialEmissionTimeoutMs: TIMEOUT_MS }); +} + +async function flushMicrotasks() { + for (let i = 0; i < 5; i++) await Promise.resolve(); +} + describe('createIdentityRepository.watchIdentity', () => { beforeEach(() => { vi.useFakeTimers(); @@ -38,12 +47,7 @@ describe('createIdentityRepository.watchIdentity', () => { it('forwards each chain emission to the subscriber', () => { const source = new Subject(); - const storage = createMemoryAdapter(); - const repo = createIdentityRepository({ - adapter: makeAdapter(source), - storage, - initialEmissionTimeoutMs: TIMEOUT_MS, - }); + const repo = makeRepo(makeAdapter(source)); const emissions: (Identity | null)[] = []; repo.watchIdentity('acc-1').subscribe(v => emissions.push(v)); @@ -56,12 +60,7 @@ describe('createIdentityRepository.watchIdentity', () => { it('collapses identical consecutive emissions via distinctUntilChanged', () => { const source = new Subject(); - const storage = createMemoryAdapter(); - const repo = createIdentityRepository({ - adapter: makeAdapter(source), - storage, - initialEmissionTimeoutMs: TIMEOUT_MS, - }); + const repo = makeRepo(makeAdapter(source)); const emissions: (Identity | null)[] = []; repo.watchIdentity('acc-1').subscribe(v => emissions.push(v)); @@ -73,59 +72,36 @@ describe('createIdentityRepository.watchIdentity', () => { expect(emissions).toEqual([lite(), person()]); }); - it('writes each distinct non-null emission through to storage', async () => { + it('writes each distinct non-null emission through to storage', () => { const source = new Subject(); - const writes: Record = {}; - const storage = { - read: vi.fn(() => okAsync(null)), - write: vi.fn((key: string, value: string) => { - writes[key] = value; - return okAsync(undefined); - }), - remove: vi.fn(() => okAsync(undefined)), - }; - const repo = createIdentityRepository({ - adapter: makeAdapter(source), - storage, - initialEmissionTimeoutMs: TIMEOUT_MS, - }); + const storage = createMemoryAdapter(); + const writeSpy = vi.spyOn(storage, 'write'); + const repo = makeRepo(makeAdapter(source), storage); repo.watchIdentity('acc-1').subscribe(); source.next(lite()); source.next(person()); - expect(storage.write).toHaveBeenCalledTimes(2); - expect(JSON.parse(writes['identity_acc-1']!)).toEqual(person()); + expect(writeSpy).toHaveBeenCalledTimes(2); + expect(writeSpy).toHaveBeenLastCalledWith('identity_acc-1', JSON.stringify(person())); }); it('does not write a null emission through to storage', () => { const source = new Subject(); - const storage = { - read: vi.fn(() => okAsync(null)), - write: vi.fn(() => okAsync(undefined)), - remove: vi.fn(() => okAsync(undefined)), - }; - const repo = createIdentityRepository({ - adapter: makeAdapter(source), - storage, - initialEmissionTimeoutMs: TIMEOUT_MS, - }); + const storage = createMemoryAdapter(); + const writeSpy = vi.spyOn(storage, 'write'); + const repo = makeRepo(makeAdapter(source), storage); repo.watchIdentity('acc-1').subscribe(); source.next(null); - expect(storage.write).not.toHaveBeenCalled(); + expect(writeSpy).not.toHaveBeenCalled(); }); it('emits null after the initial-emission timeout when the source is silent', () => { const source = new Subject(); - const storage = createMemoryAdapter(); - const repo = createIdentityRepository({ - adapter: makeAdapter(source), - storage, - initialEmissionTimeoutMs: TIMEOUT_MS, - }); + const repo = makeRepo(makeAdapter(source)); const emissions: (Identity | null)[] = []; repo.watchIdentity('acc-1').subscribe(v => emissions.push(v)); @@ -137,12 +113,7 @@ describe('createIdentityRepository.watchIdentity', () => { it('cancels the fallback once the source emits first', () => { const source = new Subject(); - const storage = createMemoryAdapter(); - const repo = createIdentityRepository({ - adapter: makeAdapter(source), - storage, - initialEmissionTimeoutMs: TIMEOUT_MS, - }); + const repo = makeRepo(makeAdapter(source)); const emissions: (Identity | null)[] = []; repo.watchIdentity('acc-1').subscribe(v => emissions.push(v)); @@ -155,12 +126,7 @@ describe('createIdentityRepository.watchIdentity', () => { it('still emits real chain values that arrive after the fallback null', () => { const source = new Subject(); - const storage = createMemoryAdapter(); - const repo = createIdentityRepository({ - adapter: makeAdapter(source), - storage, - initialEmissionTimeoutMs: TIMEOUT_MS, - }); + const repo = makeRepo(makeAdapter(source)); const emissions: (Identity | null)[] = []; repo.watchIdentity('acc-1').subscribe(v => emissions.push(v)); @@ -176,11 +142,7 @@ describe('createIdentityRepository.watchIdentity', () => { readIdentities: vi.fn(() => errAsync(new Error('rpc'))), watchIdentity: vi.fn(() => throwError(() => new Error('pallet missing'))), }; - const repo = createIdentityRepository({ - adapter, - storage: createMemoryAdapter(), - initialEmissionTimeoutMs: TIMEOUT_MS, - }); + const repo = makeRepo(adapter); const errors: Error[] = []; repo.watchIdentity('acc-1').subscribe({ @@ -194,18 +156,12 @@ describe('createIdentityRepository.watchIdentity', () => { it('emits a cached identity as the first value when the chain is silent', async () => { const cached = person('acc-1', 'cached-name'); const storage = createMemoryAdapter({ 'identity_acc-1': JSON.stringify(cached) }); - const source = new Subject(); - const repo = createIdentityRepository({ - adapter: makeAdapter(source), - storage, - initialEmissionTimeoutMs: TIMEOUT_MS, - }); + const repo = makeRepo(makeAdapter(new Subject()), storage); const emissions: (Identity | null)[] = []; repo.watchIdentity('acc-1').subscribe(v => emissions.push(v)); - // Flush microtasks the cache read sits on (defer → Promise → from). - for (let i = 0; i < 5; i++) await Promise.resolve(); + await flushMicrotasks(); expect(emissions).toEqual([cached]); }); @@ -214,29 +170,19 @@ describe('createIdentityRepository.watchIdentity', () => { const cached = person('acc-1', 'cached-name'); const storage = createMemoryAdapter({ 'identity_acc-1': JSON.stringify(cached) }); const source = new Subject(); - const repo = createIdentityRepository({ - adapter: makeAdapter(source), - storage, - initialEmissionTimeoutMs: TIMEOUT_MS, - }); + const repo = makeRepo(makeAdapter(source), storage); const emissions: (Identity | null)[] = []; repo.watchIdentity('acc-1').subscribe(v => emissions.push(v)); - // Live beats the (async) cache read. source.next(person('acc-1', 'chain-name')); - for (let i = 0; i < 5; i++) await Promise.resolve(); + await flushMicrotasks(); expect(emissions).toEqual([person('acc-1', 'chain-name')]); }); it('returns the same Observable for repeated watchIdentity(acc) calls', () => { - const storage = createMemoryAdapter(); - const repo = createIdentityRepository({ - adapter: makeAdapter(new Subject()), - storage, - initialEmissionTimeoutMs: TIMEOUT_MS, - }); + const repo = makeRepo(makeAdapter(new Subject())); expect(repo.watchIdentity('acc-1')).toBe(repo.watchIdentity('acc-1')); expect(repo.watchIdentity('acc-1')).not.toBe(repo.watchIdentity('acc-2')); @@ -244,20 +190,12 @@ describe('createIdentityRepository.watchIdentity', () => { it('treats two structurally-equal Identity objects as equal even if a new field is added', () => { const source = new Subject(); - const storage = createMemoryAdapter(); - const repo = createIdentityRepository({ - adapter: makeAdapter(source), - storage, - initialEmissionTimeoutMs: TIMEOUT_MS, - }); + const repo = makeRepo(makeAdapter(source)); const emissions: (Identity | null)[] = []; repo.watchIdentity('acc-1').subscribe(v => emissions.push(v)); - // Simulate a future field on Identity by widening the value passed - // through the adapter. Structural equality must still dedupe identical - // payloads so a future schema extension doesn't silently bypass - // distinctUntilChanged. + // Future Identity field must not bypass distinctUntilChanged. const widened = { ...person(), avatarUrl: 'https://example/a.png' } as unknown as Identity; source.next(widened); source.next({ ...person(), avatarUrl: 'https://example/a.png' } as unknown as Identity); diff --git a/packages/host-papp/src/identity/impl.ts b/packages/host-papp/src/identity/impl.ts index 63842731..788b5b8e 100644 --- a/packages/host-papp/src/identity/impl.ts +++ b/packages/host-papp/src/identity/impl.ts @@ -1,32 +1,29 @@ import type { StorageAdapter } from '@novasamatech/storage-adapter'; import { Result, ResultAsync, err, ok, okAsync } from 'neverthrow'; import type { Observable } from 'rxjs'; -import { catchError, defer, distinctUntilChanged, map, merge, of, shareReplay, takeUntil, tap, timer } from 'rxjs'; +import { defer, distinctUntilChanged, map, merge, shareReplay, takeUntil, tap, timer } from 'rxjs'; import { toError } from '../helpers/utils.js'; import type { Identity, IdentityAdapter, IdentityRepository } from './types.js'; -/** - * Hard ceiling for `watchIdentity`'s first emission when the cache is cold. - * Without this, a cold or unreachable WS would leave consumers spinning - * indefinitely. After the timeout the stream emits `null`; a real chain - * emission still arrives later and takes over, thanks to - * `distinctUntilChanged`. When the cache has a value it's emitted - * immediately and the timer is cancelled before it fires. - */ export const WATCH_IDENTITY_INITIAL_TIMEOUT_MS = 15_000; function getCacheKey(accountId: string): string { return `identity_${accountId}`; } -/** - * Structural identity equality. Used by `distinctUntilChanged` so that adding - * a field to `Identity` doesn't silently bypass deduping / write-through. - * Identity values flow through `decodeRawIdentity` only, so key order is - * deterministic and `JSON.stringify` is safe here. - */ +function parseIdentity(raw: string | null): Identity | null { + if (!raw) return null; + try { + return JSON.parse(raw) as Identity; + } catch { + return null; + } +} + +// Identity values are produced by `decodeRawIdentity` only, so key order is +// deterministic and JSON.stringify is a safe structural equality probe. function identitiesEqual(a: Identity | null, b: Identity | null): boolean { if (a === b) return true; if (a === null || b === null) return false; @@ -34,22 +31,7 @@ function identitiesEqual(a: Identity | null, b: Identity | null): boolean { } function readCachedIdentity(storage: StorageAdapter, accountId: string): Observable { - return defer(() => - storage - .read(getCacheKey(accountId)) - .match( - raw => { - if (!raw) return null; - try { - return JSON.parse(raw) as Identity; - } catch { - return null; - } - }, - () => null, - ) - .then(value => value), - ).pipe(catchError(() => of(null))); + return defer(() => storage.read(getCacheKey(accountId)).match(parseIdentity, () => null)); } export function createIdentityRepository({ @@ -63,40 +45,26 @@ export function createIdentityRepository({ }): IdentityRepository { const cachedRequester = createCachedIdentityRequester(storage, getCacheKey); - // Per-account de-dup: N concurrent `watchIdentity(acc)` calls share one - // chain subscription. `shareReplay` with `refCount` tears the upstream down - // when all subscribers leave so we don't leak WS subscriptions. + // Per-account de-dup: N concurrent watchIdentity(acc) calls share one + // chain subscription. refCount tears the upstream down when the last + // subscriber leaves, so stale map entries don't hold a WS open. const watchCache = new Map>(); function buildWatch(accountId: string): Observable { - // Live chain reads, multicast so the seed/fallback `takeUntil` branches - // don't open extra upstream subscriptions and the tap doesn't run twice. - // `shareReplay({refCount: true})` tears the upstream down when the last - // subscriber leaves and rebuilds it on the next subscription, so a stale - // accountId entry in `watchCache` doesn't hold a chain subscription open. const live$ = adapter.watchIdentity(accountId).pipe( distinctUntilChanged(identitiesEqual), tap(identity => { - // Write-through: every distinct chain value refreshes the storage - // cache so non-watching readers see the same freshness. if (identity === null) return; - // Best-effort. ResultAsync runs eagerly; a write failure must not - // surface on the live read so we don't subscribe to the result. + // Best-effort write-through; failures must not surface on the read. void storage.write(getCacheKey(accountId), JSON.stringify(identity)); }), shareReplay({ bufferSize: 1, refCount: true }), ); - // Seed: surface the cached value (if any) as the first emission so - // consumers exit `pending` instantly on warm cache instead of waiting - // for `watchValue` to deliver its first block. Stale-OK: any divergent - // chain emission immediately overwrites via the outer - // `distinctUntilChanged`. Dropped if `live$` beats it. + // Seed from cache so warm consumers exit `pending` before the first chain block. const seed$ = readCachedIdentity(storage, accountId).pipe(takeUntil(live$)); - // Cold-cache + silent-chain safety net: if neither seed nor live$ have - // delivered within the timeout, emit `null` so the UI doesn't hang on - // `pending=true` forever. + // Cold-cache + silent-chain safety net: emit `null` so the UI doesn't hang. const fallback$ = timer(initialEmissionTimeoutMs).pipe( takeUntil(merge(seed$, live$)), map(() => null as Identity | null), @@ -112,11 +80,8 @@ export function createIdentityRepository({ getIdentities(accounts) { return cachedRequester(accounts, adapter.readIdentities); }, - /** - * Subscribers MUST attach an `error` handler. Adapter errors propagate - * (no automatic retry); the consumer is responsible for re-subscribing - * if recovery is desired. - */ + // Adapter errors propagate as-is; callers must attach an error handler + // and re-subscribe to recover (no automatic retry). watchIdentity(accountId): Observable { const existing = watchCache.get(accountId); if (existing) return existing; diff --git a/packages/host-papp/src/identity/types.ts b/packages/host-papp/src/identity/types.ts index 2854b471..d9470aff 100644 --- a/packages/host-papp/src/identity/types.ts +++ b/packages/host-papp/src/identity/types.ts @@ -20,25 +20,15 @@ export type Identity = { export type IdentityAdapter = { readIdentities(accounts: string[]): ResultAsync, Error>; - /** - * Live subscription to a single account's on-chain identity. Each emission - * is the freshest value the source observed; emits `null` when no entry - * exists. Implementations should error the stream when the underlying - * storage/pallet is unavailable. - */ + // Errors the stream when the underlying storage/pallet is unavailable. watchIdentity(accountId: string): Observable; }; export type IdentityRepository = { getIdentity(accountId: string): ResultAsync; getIdentities(accounts: string[]): ResultAsync, Error>; - /** - * Live subscription to a single account's identity. Emits each distinct - * value the chain reports, plus a final-fallback `null` if the source - * doesn't emit within {@link WATCH_IDENTITY_INITIAL_TIMEOUT_MS}, so - * consumers don't hang on a dead WS. Every distinct non-null emission is - * written through to the storage cache so non-watching readers - * (`getIdentity`/`getIdentities`) see the freshest value. - */ + // Emits cached seed (if any), then distinct chain values; falls back to + // `null` after WATCH_IDENTITY_INITIAL_TIMEOUT_MS if the source is silent. + // Each distinct non-null value is written through to storage. watchIdentity(accountId: string): Observable; }; From 03b5a32efb46dc0abc2423619c13e93b71593d2b Mon Sep 17 00:00:00 2001 From: cuteWarmFrog Date: Wed, 27 May 2026 14:06:18 +0200 Subject: [PATCH 28/31] fix(host-papp): address review on watchIdentity - rpcAdapter: derive the raw Consumers value type from the papi descriptor (People_liteQueries) instead of a hand-written RawConsumers type; drop the speculative snake_case/camelCase widening (the descriptor only types snake_case and no camelCase variant exists on-chain). - impl: clear the per-account watchCache entry via finalize when the last subscriber unsubscribes, so the map can't grow unbounded. The previous comment claimed refCount handled this, but refCount only tears down the chain subscription, not the map entry. - add a test asserting the cache entry is dropped after the last unsubscribe. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../__tests__/identityRepository.spec.ts | 14 +++++++ packages/host-papp/src/identity/impl.ts | 19 ++++++--- packages/host-papp/src/identity/rpcAdapter.ts | 42 ++++++------------- 3 files changed, 41 insertions(+), 34 deletions(-) diff --git a/packages/host-papp/__tests__/identityRepository.spec.ts b/packages/host-papp/__tests__/identityRepository.spec.ts index e1671906..2713c444 100644 --- a/packages/host-papp/__tests__/identityRepository.spec.ts +++ b/packages/host-papp/__tests__/identityRepository.spec.ts @@ -188,6 +188,20 @@ describe('createIdentityRepository.watchIdentity', () => { expect(repo.watchIdentity('acc-1')).not.toBe(repo.watchIdentity('acc-2')); }); + it('drops the cache entry once the last subscriber unsubscribes', () => { + const repo = makeRepo(makeAdapter(new Subject())); + + const first = repo.watchIdentity('acc-1'); + const sub = first.subscribe(); + // Shared while a subscriber is live. + expect(repo.watchIdentity('acc-1')).toBe(first); + + sub.unsubscribe(); + // refCount → 0 tears the stream down and finalize removes the map entry, + // so the next watch builds a fresh stream rather than reusing a dead one. + expect(repo.watchIdentity('acc-1')).not.toBe(first); + }); + it('treats two structurally-equal Identity objects as equal even if a new field is added', () => { const source = new Subject(); const repo = makeRepo(makeAdapter(source)); diff --git a/packages/host-papp/src/identity/impl.ts b/packages/host-papp/src/identity/impl.ts index 788b5b8e..304fff3e 100644 --- a/packages/host-papp/src/identity/impl.ts +++ b/packages/host-papp/src/identity/impl.ts @@ -1,7 +1,7 @@ import type { StorageAdapter } from '@novasamatech/storage-adapter'; import { Result, ResultAsync, err, ok, okAsync } from 'neverthrow'; import type { Observable } from 'rxjs'; -import { defer, distinctUntilChanged, map, merge, shareReplay, takeUntil, tap, timer } from 'rxjs'; +import { defer, distinctUntilChanged, finalize, map, merge, shareReplay, takeUntil, tap, timer } from 'rxjs'; import { toError } from '../helpers/utils.js'; @@ -45,9 +45,10 @@ export function createIdentityRepository({ }): IdentityRepository { const cachedRequester = createCachedIdentityRequester(storage, getCacheKey); - // Per-account de-dup: N concurrent watchIdentity(acc) calls share one - // chain subscription. refCount tears the upstream down when the last - // subscriber leaves, so stale map entries don't hold a WS open. + // Per-account de-dup: concurrent watchIdentity(acc) calls share one chain + // subscription via the shared stream built below. The entry clears itself — + // see the `finalize` in `buildWatch` — so the map can't accumulate dead + // streams across distinct accounts. const watchCache = new Map>(); function buildWatch(accountId: string): Observable { @@ -70,7 +71,15 @@ export function createIdentityRepository({ map(() => null as Identity | null), ); - return merge(seed$, live$, fallback$).pipe(distinctUntilChanged(identitiesEqual)); + return merge(seed$, live$, fallback$).pipe( + distinctUntilChanged(identitiesEqual), + // refCount tears down the chain subscription when the last subscriber + // leaves; `finalize` then drops the map entry so a later watch rebuilds + // a fresh stream instead of reusing a dead one. The shared `shareReplay` + // guarantees this fires once, on the final unsubscribe — not per caller. + finalize(() => watchCache.delete(accountId)), + shareReplay({ bufferSize: 1, refCount: true }), + ); } return { diff --git a/packages/host-papp/src/identity/rpcAdapter.ts b/packages/host-papp/src/identity/rpcAdapter.ts index 354c09e4..d23aaedb 100644 --- a/packages/host-papp/src/identity/rpcAdapter.ts +++ b/packages/host-papp/src/identity/rpcAdapter.ts @@ -5,38 +5,22 @@ import { AccountId } from 'polkadot-api'; import type { Observable } from 'rxjs'; import { map, throwError } from 'rxjs'; -import type { People_lite } from '../../.papi/descriptors/dist/index.js'; +import type { People_lite, People_liteQueries } from '../../.papi/descriptors/dist/index.js'; import { toError } from '../helpers/utils.js'; import { zipWith } from '../helpers/zipWith.js'; import type { Credibility, Identity, IdentityAdapter } from './types.js'; -type RawConsumers = { - full_username?: Uint8Array; - fullUsername?: Uint8Array; - lite_username?: Uint8Array; - liteUsername?: Uint8Array; - credibility: - | { type: 'Lite' } - | { - type: 'Person'; - value: { - alias: unknown; - last_update?: unknown; - lastUpdate?: unknown; - }; - }; -}; - -function decodeRawIdentity(accountId: string, typedRaw: unknown, textDecoder: TextDecoder): Identity | null { - if (!typedRaw) return null; +// The raw value type is owned by the papi descriptor; derive it from the +// `Resources.Consumers` storage entry rather than restating the shape here. +type RawConsumers = NonNullable; - // Runtime metadata may expose fields in snake_case (V1) or camelCase (V2 - // multi-device). The .papi descriptor only types snake_case, so widen here - // and read defensively. - const raw = typedRaw as RawConsumers; - const fullUsername = raw.full_username ?? raw.fullUsername; - const liteUsername = raw.lite_username ?? raw.liteUsername; +function decodeRawIdentity( + accountId: string, + raw: RawConsumers | undefined, + textDecoder: TextDecoder, +): Identity | null { + if (!raw) return null; const credibility: Credibility = raw.credibility.type === 'Lite' @@ -44,13 +28,13 @@ function decodeRawIdentity(accountId: string, typedRaw: unknown, textDecoder: Te : { type: 'Person', alias: raw.credibility.value.alias as HexString, - lastUpdate: (raw.credibility.value.last_update ?? raw.credibility.value.lastUpdate)!.toString(), + lastUpdate: raw.credibility.value.last_update.toString(), }; return { accountId, - fullUsername: fullUsername ? textDecoder.decode(fullUsername) : null, - liteUsername: liteUsername ? textDecoder.decode(liteUsername) : '', + fullUsername: raw.full_username ? textDecoder.decode(raw.full_username) : null, + liteUsername: textDecoder.decode(raw.lite_username), credibility, }; } From 59f994eca58de911c3c365888632cd56c53cd4a3 Mon Sep 17 00:00:00 2001 From: cuteWarmFrog Date: Wed, 27 May 2026 14:21:41 +0200 Subject: [PATCH 29/31] fix(host-papp): deep-review fixes for watchIdentity stream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - impl: the cache seed emitted `null` for a cold cache, which (a) cancelled the fallback timer before it could fire — making the documented 15s silent-chain safety net dead code — and (b) was subscribed twice (once in the merge, once in the fallback's takeUntil), doubling storage.read. Filter the seed to non-null and shareReplay it: a cold cache now falls through to the live read / fallback as intended, and the seed storage read runs once. - rpcAdapter: wrap watchIdentity in `defer` so client resolution and key decoding run on subscribe and any failure surfaces as a stream error rather than a synchronous throw at the call site (the React hook only attaches its error handler via `.subscribe`). - tests: lock in both fixes (no premature null from an empty cache before the fallback; storage read once per watch). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../__tests__/identityRepository.spec.ts | 28 +++++++++++++++++++ packages/host-papp/src/identity/impl.ts | 14 ++++++++-- packages/host-papp/src/identity/rpcAdapter.ts | 21 ++++++++------ 3 files changed, 52 insertions(+), 11 deletions(-) diff --git a/packages/host-papp/__tests__/identityRepository.spec.ts b/packages/host-papp/__tests__/identityRepository.spec.ts index 2713c444..3884d3c7 100644 --- a/packages/host-papp/__tests__/identityRepository.spec.ts +++ b/packages/host-papp/__tests__/identityRepository.spec.ts @@ -166,6 +166,34 @@ describe('createIdentityRepository.watchIdentity', () => { expect(emissions).toEqual([cached]); }); + it('does not emit a premature null from an empty cache before the fallback fires', async () => { + const storage = createMemoryAdapter(); // cold cache + const repo = makeRepo(makeAdapter(new Subject()), storage); + + const emissions: (Identity | null)[] = []; + repo.watchIdentity('acc-1').subscribe(v => emissions.push(v)); + await flushMicrotasks(); + + // The empty-cache seed is filtered out, so nothing is emitted yet — the + // fallback timer stays armed instead of being cancelled by a stray null. + expect(emissions).toEqual([]); + + vi.advanceTimersByTime(TIMEOUT_MS); + expect(emissions).toEqual([null]); + }); + + it('reads storage only once per watch subscription', async () => { + const storage = createMemoryAdapter({ 'identity_acc-1': JSON.stringify(person()) }); + const readSpy = vi.spyOn(storage, 'read'); + const repo = makeRepo(makeAdapter(new Subject()), storage); + + repo.watchIdentity('acc-1').subscribe(); + await flushMicrotasks(); + + // The seed read is shared, not duplicated by the fallback's takeUntil. + expect(readSpy).toHaveBeenCalledTimes(1); + }); + it('drops the cache seed if the live chain emits first', async () => { const cached = person('acc-1', 'cached-name'); const storage = createMemoryAdapter({ 'identity_acc-1': JSON.stringify(cached) }); diff --git a/packages/host-papp/src/identity/impl.ts b/packages/host-papp/src/identity/impl.ts index 304fff3e..5307e5b3 100644 --- a/packages/host-papp/src/identity/impl.ts +++ b/packages/host-papp/src/identity/impl.ts @@ -1,7 +1,7 @@ import type { StorageAdapter } from '@novasamatech/storage-adapter'; import { Result, ResultAsync, err, ok, okAsync } from 'neverthrow'; import type { Observable } from 'rxjs'; -import { defer, distinctUntilChanged, finalize, map, merge, shareReplay, takeUntil, tap, timer } from 'rxjs'; +import { defer, distinctUntilChanged, filter, finalize, map, merge, shareReplay, takeUntil, tap, timer } from 'rxjs'; import { toError } from '../helpers/utils.js'; @@ -62,8 +62,16 @@ export function createIdentityRepository({ shareReplay({ bufferSize: 1, refCount: true }), ); - // Seed from cache so warm consumers exit `pending` before the first chain block. - const seed$ = readCachedIdentity(storage, accountId).pipe(takeUntil(live$)); + // Seed from cache so warm consumers paint a value before the first chain + // block. Only a non-null seed is emitted: a cold cache must fall through to + // the live read / fallback rather than surface a premature `null` (that + // also keeps the fallback timer below reachable). `shareReplay` keeps the + // single storage read from being duplicated by the fallback's `takeUntil`. + const seed$ = readCachedIdentity(storage, accountId).pipe( + filter((identity): identity is Identity => identity !== null), + takeUntil(live$), + shareReplay({ bufferSize: 1, refCount: true }), + ); // Cold-cache + silent-chain safety net: emit `null` so the UI doesn't hang. const fallback$ = timer(initialEmissionTimeoutMs).pipe( diff --git a/packages/host-papp/src/identity/rpcAdapter.ts b/packages/host-papp/src/identity/rpcAdapter.ts index d23aaedb..b4a11186 100644 --- a/packages/host-papp/src/identity/rpcAdapter.ts +++ b/packages/host-papp/src/identity/rpcAdapter.ts @@ -3,7 +3,7 @@ import type { LazyClient } from '@novasamatech/statement-store'; import { errAsync, fromPromise, ok } from 'neverthrow'; import { AccountId } from 'polkadot-api'; import type { Observable } from 'rxjs'; -import { map, throwError } from 'rxjs'; +import { defer, map, throwError } from 'rxjs'; import type { People_lite, People_liteQueries } from '../../.papi/descriptors/dist/index.js'; import { toError } from '../helpers/utils.js'; @@ -75,13 +75,18 @@ export function createIdentityRpcAdapter(lazyClient: LazyClient): IdentityAdapte }, watchIdentity(accountId): Observable { - const method = getConsumersStorage(); - if (!method) { - return throwError(() => new Error('Method Resources.Consumers not found')); - } - return method - .watchValue(accCodec.dec(accountId)) - .pipe(map(emission => decodeRawIdentity(accountId, emission.value, textDecoder))); + // `defer` so client resolution and key decoding run on subscribe and any + // failure surfaces as a stream error, not a synchronous throw at the call + // site (the consumer only attaches its error handler via `.subscribe`). + return defer(() => { + const method = getConsumersStorage(); + if (!method) { + return throwError(() => new Error('Method Resources.Consumers not found')); + } + return method + .watchValue(accCodec.dec(accountId)) + .pipe(map(emission => decodeRawIdentity(accountId, emission.value, textDecoder))); + }); }, }; } From 4869c1005c9550cc4e58b795deeb99ff333b96d2 Mon Sep 17 00:00:00 2001 From: cuteWarmFrog Date: Wed, 27 May 2026 15:45:28 +0200 Subject: [PATCH 30/31] fix(host-papp): harden watchIdentity cache eviction; drop unused export - impl: the finalize eviction did an unconditional watchCache.delete(accountId). A late re-subscribe to an already torn-down stream would then evict a newer entry another caller built for the same account, silently breaking dedup and spawning duplicate chain subscriptions. Guard the delete on stream identity so it only removes its own entry. Added a test that fails without the guard. - impl: WATCH_IDENTITY_INITIAL_TIMEOUT_MS was exported but used only as an internal default and never re-exported from the package index (knip flagged it as an unused export). Make it module-private. Verified against the live runtime: `papi update people_lite` re-fetched metadata byte-identical to the pinned blob; Resources.Consumers is snake_case only, so the earlier removal of the speculative camelCase decode is correct, not a regression. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../__tests__/identityRepository.spec.ts | 16 ++++++++++++++++ packages/host-papp/src/identity/impl.ts | 12 +++++++++--- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/packages/host-papp/__tests__/identityRepository.spec.ts b/packages/host-papp/__tests__/identityRepository.spec.ts index 3884d3c7..eb1770fe 100644 --- a/packages/host-papp/__tests__/identityRepository.spec.ts +++ b/packages/host-papp/__tests__/identityRepository.spec.ts @@ -230,6 +230,22 @@ describe('createIdentityRepository.watchIdentity', () => { expect(repo.watchIdentity('acc-1')).not.toBe(first); }); + it('does not evict a fresh entry when a torn-down stream is re-subscribed', () => { + const repo = makeRepo(makeAdapter(new Subject())); + + const streamA = repo.watchIdentity('acc-1'); + streamA.subscribe().unsubscribe(); // refCount → 0 evicts streamA + + const streamB = repo.watchIdentity('acc-1'); // fresh entry for the same account + expect(streamB).not.toBe(streamA); + + // Re-subscribe + tear down the STALE streamA; its finalize must not delete + // streamB's entry just because it shares the account key. + streamA.subscribe().unsubscribe(); + + expect(repo.watchIdentity('acc-1')).toBe(streamB); + }); + it('treats two structurally-equal Identity objects as equal even if a new field is added', () => { const source = new Subject(); const repo = makeRepo(makeAdapter(source)); diff --git a/packages/host-papp/src/identity/impl.ts b/packages/host-papp/src/identity/impl.ts index 5307e5b3..d48144dd 100644 --- a/packages/host-papp/src/identity/impl.ts +++ b/packages/host-papp/src/identity/impl.ts @@ -7,7 +7,7 @@ import { toError } from '../helpers/utils.js'; import type { Identity, IdentityAdapter, IdentityRepository } from './types.js'; -export const WATCH_IDENTITY_INITIAL_TIMEOUT_MS = 15_000; +const WATCH_IDENTITY_INITIAL_TIMEOUT_MS = 15_000; function getCacheKey(accountId: string): string { return `identity_${accountId}`; @@ -79,15 +79,21 @@ export function createIdentityRepository({ map(() => null as Identity | null), ); - return merge(seed$, live$, fallback$).pipe( + const stream: Observable = merge(seed$, live$, fallback$).pipe( distinctUntilChanged(identitiesEqual), // refCount tears down the chain subscription when the last subscriber // leaves; `finalize` then drops the map entry so a later watch rebuilds // a fresh stream instead of reusing a dead one. The shared `shareReplay` // guarantees this fires once, on the final unsubscribe — not per caller. - finalize(() => watchCache.delete(accountId)), + // Guard on identity: a late re-subscribe to an already torn-down stream + // must not evict a newer entry that another caller built for this account. + finalize(() => { + if (watchCache.get(accountId) === stream) watchCache.delete(accountId); + }), shareReplay({ bufferSize: 1, refCount: true }), ); + + return stream; } return { From 3ee7e03222d4d6750cff6e48da7f1ce3fb9653ab Mon Sep 17 00:00:00 2001 From: Ilya Date: Mon, 1 Jun 2026 14:19:54 -0600 Subject: [PATCH 31/31] fix(host-papp): decode Resources.Consumers defensively (snake_case + camelCase) watchIdentity's decodeRawIdentity read snake_case fields only; the V2 multi-device runtime exposes camelCase, so it decoded usernames to empty (permanent "Unknown user"). Restore the defensive snake_case ?? camelCase read that existed before the watchIdentity rewrite. --- packages/host-papp/src/identity/rpcAdapter.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/host-papp/src/identity/rpcAdapter.ts b/packages/host-papp/src/identity/rpcAdapter.ts index b4a11186..d8b99aa5 100644 --- a/packages/host-papp/src/identity/rpcAdapter.ts +++ b/packages/host-papp/src/identity/rpcAdapter.ts @@ -22,19 +22,28 @@ function decodeRawIdentity( ): Identity | null { if (!raw) return null; + // Runtime metadata may expose fields in snake_case (V1) or camelCase (V2 + // multi-device). The .papi descriptor only types snake_case, so widen here + // and read defensively — otherwise the V2 multi-device runtime decodes to an + // empty username (the "Unknown user" regression). + const wide = raw as unknown as Record & typeof raw; + const fullUsername = (wide.full_username as Uint8Array | undefined) ?? (wide.fullUsername as Uint8Array | undefined); + const liteUsername = (wide.lite_username as Uint8Array | undefined) ?? (wide.liteUsername as Uint8Array | undefined); + const credibility: Credibility = raw.credibility.type === 'Lite' ? { type: 'Lite' } : { type: 'Person', alias: raw.credibility.value.alias as HexString, - lastUpdate: raw.credibility.value.last_update.toString(), + lastUpdate: ((raw.credibility.value as Record).last_update ?? + (raw.credibility.value as Record).lastUpdate)!.toString(), }; return { accountId, - fullUsername: raw.full_username ? textDecoder.decode(raw.full_username) : null, - liteUsername: textDecoder.decode(raw.lite_username), + fullUsername: fullUsername ? textDecoder.decode(fullUsername) : null, + liteUsername: liteUsername ? textDecoder.decode(liteUsername) : '', credibility, }; }