From 0a5d34d610ebec3737efdd7084cbda14d427bd13 Mon Sep 17 00:00:00 2001 From: Ilya Date: Wed, 20 May 2026 12:55:52 -0600 Subject: [PATCH 01/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] =?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/16] 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/16] 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/16] 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/16] 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 094e4ce06b7803057e276fca5d40d087258c76ac Mon Sep 17 00:00:00 2001 From: Ilya Date: Fri, 22 May 2026 06:40:32 -0600 Subject: [PATCH 13/16] 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 107c7fb91c049cdab8a082df4fc8376e21564730 Mon Sep 17 00:00:00 2001 From: Ilya Date: Fri, 22 May 2026 07:11:54 -0600 Subject: [PATCH 14/16] 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 15/16] 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 16/16] 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