feat(sso): support Mobile SSO spec v0.2.2 (sso_encr_pub_key) + clean createSession sessionKey API#186
Open
kalininilya wants to merge 33 commits into
Open
feat(sso): support Mobile SSO spec v0.2.2 (sso_encr_pub_key) + clean createSession sessionKey API#186kalininilya wants to merge 33 commits into
kalininilya wants to merge 33 commits into
Conversation
…ng service
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=<hex>`.
- 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.
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.
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.
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.
…vate_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)).
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.
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<string, unknown>` at the read site.
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<u8>` on the wire. DeviceInfoContent (used by deviceChatAccepted) stays fixed-size — Android's `DeviceInfoScale` declares `@FixedLength` explicitly.
`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.
`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<DeviceIdentityForPairing>`
- 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.
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.
…K; restore V1-shape auth surface
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.
V2 People chain endpoints — needed by desktop env constants that have been falling back to paseo-next while the V2 entry was missing.
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.
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.
# Conflicts: # CHANGELOG.md # package-lock.json # packages/host-chat/package.json # packages/host-papp/package.json # packages/host-papp/src/sso/auth/v2/proposal.ts
…createSession sessionKey API Two coordinated changes across statement-store and host-papp to support the V2 SSO session transport on host applications per Mobile SSO spec v0.2.2 (https://hackmd.io/F_GpT6GDT4aOvjrTDbRfKw). ## statement-store: explicit sessionKey on createSession `createSession` now takes a `sessionKey: Uint8Array` parameter for the SessionId khash derivation, instead of (mis)using `remoteAccount.publicKey` for that purpose. The conflation worked for V1 by accident — V1 enc pubkeys were 33-byte compressed P-256 and fit blake2b's 64-byte key limit. V2's 65-byte uncompressed P-256 broke it. With the param explicit: - V1 callers (host-papp's createUserSession) pass `sessionKey: userSession.remoteAccount.publicKey` and preserve byte-identical behaviour. - V2 callers (polkadot-desktop's V2SsoSession) pass the ECDH-derived shared_secret_session, conforming to the spec: SessionId(A, B) = khash(shared_secret_session, "session" : A.acct : B.acct : "/" : "/") Breaking change for direct callers of `createSession`: must now pass `sessionKey`. The only in-tree caller (host-papp/createUserSession) is updated. session.spec.ts mock updated to pass `sessionKey = remoteAccount.publicKey` (preserves expectations). ## host-papp: Mobile SSO spec v0.2.2 - `HandshakeSuccessV2` codec gains `ssoEncPubKey: PublicKeyCodec`, i.e. `papp_encr_pub` from spec §Encrypted response — V2. New encoded Success body length: 226 bytes (32 + 32 + 32 + 65 + 65). - `HandshakeSuccessV2_v021` retains the previous 161-byte shape for back-compat decoding. - Length-dispatched `decodeEncryptedHandshakeResponseV2` accepts all three Success body shapes seen in the wild: 226 bytes (spec v0.2.2) — includes ssoEncPubKey 161 bytes (spec v0.2.1) — surfaces ssoEncPubKey: null 129 bytes (spec v0.2) — surfaces ssoEncPubKey: null AND rootAccountId: null - `HandshakeSuccessV2Value` and `HandshakeSuccessState` carry `ssoEncPubKey: Uint8Array | null`. - `OnAuthSuccess` callback's event payload now includes `ssoEncPubKey: Uint8Array | null` alongside the existing `identityChatPrivateKey`. Host applications use this to drive the V2 SSO session transport — pre-v0.2.2 peers leave it null and the host leaves the transport inactive while chat continues to work via `identityChatPrivateKey`. `createUserSession` passes `sessionKey: userSession.remoteAccount.publicKey` to preserve V1 session behaviour after the statement-store API change. ## Backward compatibility - V1 SSO sessions: unchanged (host-papp passes V1 enc pubkey as sessionKey). - V2 SSO sessions: existed in name only before (host applications couldn't actually open them because the V2 enc pubkey was too long for blake2b's key limit); now openable per spec. - v0.2.1 / v0.2 handshake-response peers: handshake completes, ssoEncPubKey surfaces as null on `OnAuthSuccess` so host applications can leave the SSO transport inactive without breaking chat. Coordinated host-side PR (vendors the built dist of this branch under vendor/ for CI build verification): paritytech/polkadot-desktop#523
`P2PMixnetFile` now carries the sender-stamped hop endpoint and per-meta
blurhash thumbnail that Android (and iOS) stamp on outgoing attachments:
P2PMixnetFile = { identifier, claimTicket, nodeEndpoint, meta }
NodeEndpoint = wssUrl({ url }) // 0
ImageFileMeta = { ..., thumbnail: Option(Vec<u8>) }
VideoFileMeta = { ..., thumbnail: Option(Vec<u8>) }
Receivers use `nodeEndpoint` to validate the hop URL against their
bulletin-chain allowlist before opening a socket; `thumbnail` is the
UTF-8 bytes of a Wolt-spec blurhash string for a placeholder render
while the full file downloads.
`MessageContent` replaces five `_void` placeholders with the actual
payload-bearing variants Android emits, so a sync envelope (or a chat
message) that mixes them with text/richText decodes cleanly instead of
mis-advancing the cursor:
- 8 dataChannelOffer { sdp: Bytes, purpose: AUDIO_CALL|VIDEO_CALL }
- 9 dataChannelAnswer { offerMessageId, sdp }
- 10 dataChannelIceCandidate { offerMessageId, sdp } (renamed from `dataChannelCandidates`)
- 11 dataChannelClosed { offerMessageId }
- 16 coinagePayment { totalValue: compact, coinKeys: Vec<Vec<u8>> }
Indices 6 and 19 stay reserved (no Android counterpart). New codec
spec covers the data-channel + coinage round-trips and pins the enum
discriminants for 16, 11, and 8.
`hop_claim` was signing the raw 32-byte content hash with the ticket-
derived sr25519 key. The HOP server (per `substrate/client/hop/src/types.rs`)
validates the signature against `blake2b256("hop-claim-v1:" || hash)`,
identical to Android's `HopSigningPayloads.claim`. With the wrong message,
sigs fail verification and the server returns "Data not found" —
indistinguishable from a truly missing entry, which sent receivers down
the wrong debug path.
HopSigningPayloads.{claim, ack, submit}(...) — byte-identical to Android
`downloadFile` now signs `HopSigningPayloads.claim(hash)` for the metadata
claim and each chunk claim. Cross-verified that @scure/sr25519 sigs
produced this way verify under @polkadot/util-crypto's WASM
`sr25519Verify` (same schnorrkel substrate context Android uses).
`HopClient` gains `ack(hash, signature)` so callers can complete the
claim+ack pair the server expects for clean eviction. `downloadFile`
keeps the call best-effort and out of band — firing it on the same WSS
between claims stalls the next chunk's response, so the receive path
opts to rely on the server's claim-time eviction instead.
Append `ssoEncPubKey` (papp_encr_pub, Mobile SSO spec v0.2.2) as an Option field on storedUserSessionCodec and write it in persistAndNotify. This lets a host rebuild its SSO session transport (shared_secret_session = ECDH(host_encr_secret, ssoEncPubKey)) on a cold start without re-running the handshake. None for pre-v0.2.2 peers.
Today every identity read goes through getIdentity / getIdentities, which cache to localStorage and only refetch entries whose stored value is null. Once an account is observed as Lite, its on-chain promotion to Person is never picked up — the cache pins the stale value forever. Reframe the storage cache from authority to latency cache and make the chain the source of truth: - IdentityAdapter.watchIdentity(accountId): Observable<Identity | null> -- live subscription via Resources.Consumers.watchValue. - IdentityRepository.watchIdentity: wraps the adapter with distinctUntilChanged, write-through to StorageAdapter on every distinct non-null emission, and a 15s initial-emission fallback (emits null) so a cold or unreachable WS doesn't leave consumers spinning. share() in front of the tap so the fallback's takeUntil subscription doesn't double the writes. - host-papp-react-ui useIdentity now subscribes via watchIdentity. The hook auto-rerenders on chain changes and unsubscribes on unmount. - Refactored the inline decoder out of readIdentities into a shared decodeRawIdentity so the batch read and the subscription decode identically. getIdentity / getIdentities are unchanged in this PR. They still return the cached value; the difference is that the cache is now kept fresh by any active watcher, so existing call sites benefit without code changes. Tests: 8 new cases on the repository covering emission forwarding, distinctUntilChanged, write-through (including the null skip), the fallback timeout, fallback cancellation when the source emits first, and adapter-error propagation. 532/532 across the workspace pass.
Repository (impl.ts):
- Replace share() with shareReplay({bufferSize:1, refCount:true}). Fixes
a race where a synchronous first emission from the adapter would slip
past the fallback's takeUntil subscriber, causing a spurious null to
be emitted by the 15s timer after the real value.
- Seed from the storage cache on subscribe: the first emission is the
cached value (if any), so consumers exit pending instantly on a warm
cache instead of waiting up to 15s when the WS is slow or
unreachable. The seed is dropped if the live chain emits first; the
outer distinctUntilChanged collapses any seed/chain duplicate.
- Per-account de-dup: N concurrent watchIdentity(acc) calls share one
upstream subscription (cached by accountId). refCount tears the
upstream down when the last subscriber leaves so a stale Map entry
doesn't hold an open WS subscription.
- Structural equality (JSON.stringify) for distinctUntilChanged. Adding
a new field to Identity no longer silently bypasses deduping /
write-through, since identitiesEqual no longer enumerates fields by
hand.
Hook (host-papp-react-ui/hooks/identity.ts):
- Hold papp in a ref so the effect's deps stay [hexAccountId] only. A
consumer that recreates the adapter object per render no longer
re-subscribes the chain on every render.
- Clear identity state on accountId change so the UI doesn't briefly
show the previous account's identity under the new account while
waiting for the first emission.
Tests: 4 new cases cover seed-from-cache, seed-dropped-when-live-first,
per-account de-dup, and structural equality with a future widened
Identity shape. 536/536 passing.
Not addressed (intentional, out of scope for this PR):
- No automatic retry on adapter errors. Adding silent retry would
swallow legitimate errors; consumers can remount or wrap if they want
recovery semantics.
- Error channel typed as part of Observable; subscribers must attach an
error handler. Documented on watchIdentity.
- collapse readCachedIdentity to a single defer/match, drop redundant catchError - trim essay-style comments across impl, types, hook - factor makeRepo/flushMicrotasks helpers and use createMemoryAdapter with vi.spyOn in tests - fix typecheck: align storage mocks with current StorageAdapter shape (clear/subscribe)
- rpcAdapter: derive the raw Consumers value type from the papi descriptor (People_liteQueries) instead of a hand-written RawConsumers type; drop the speculative snake_case/camelCase widening (the descriptor only types snake_case and no camelCase variant exists on-chain). - impl: clear the per-account watchCache entry via finalize when the last subscriber unsubscribes, so the map can't grow unbounded. The previous comment claimed refCount handled this, but refCount only tears down the chain subscription, not the map entry. - add a test asserting the cache entry is dropped after the last unsubscribe. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- impl: the cache seed emitted `null` for a cold cache, which (a) cancelled the fallback timer before it could fire — making the documented 15s silent-chain safety net dead code — and (b) was subscribed twice (once in the merge, once in the fallback's takeUntil), doubling storage.read. Filter the seed to non-null and shareReplay it: a cold cache now falls through to the live read / fallback as intended, and the seed storage read runs once. - rpcAdapter: wrap watchIdentity in `defer` so client resolution and key decoding run on subscribe and any failure surfaces as a stream error rather than a synchronous throw at the call site (the React hook only attaches its error handler via `.subscribe`). - tests: lock in both fixes (no premature null from an empty cache before the fallback; storage read once per watch). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- impl: the finalize eviction did an unconditional watchCache.delete(accountId). A late re-subscribe to an already torn-down stream would then evict a newer entry another caller built for the same account, silently breaking dedup and spawning duplicate chain subscriptions. Guard the delete on stream identity so it only removes its own entry. Added a test that fails without the guard. - impl: WATCH_IDENTITY_INITIAL_TIMEOUT_MS was exported but used only as an internal default and never re-exported from the package index (knip flagged it as an unused export). Make it module-private. Verified against the live runtime: `papi update people_lite` re-fetched metadata byte-identical to the pinned blob; Resources.Consumers is snake_case only, so the earlier removal of the speculative camelCase decode is correct, not a regression. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…camelCase) watchIdentity's decodeRawIdentity read snake_case fields only; the V2 multi-device runtime exposes camelCase, so it decoded usernames to empty (permanent "Unknown user"). Restore the defensive snake_case ?? camelCase read that existed before the watchIdentity rewrite.
Main (0.8.7) had independently absorbed nearly all of this branch's work via PRs #178/#202/#205/#206/#212/#215/#216, with newer/cleaner versions. Conflict resolution (38 files): - package.json (x14), package-lock.json, CHANGELOG.md, migration doc: took main (newer versions, canonical release history). Dropped the unused `verifiablejs` dep. - All SSO V2, statement-store, handoff-service, and host-chat *impl* files: took main — strictly newer (ECDH session-key derivation #206, RFC-7 entropy #205, statement-store fix #215). The branch's parallel May implementations are superseded. - watchIdentity series: already identical in main (no real conflict). - Kept BRANCH version for two files where it is genuinely ahead: * identity/rpcAdapter.ts — defensive snake_case/camelCase Resources decode (fixes "Unknown user" regression on V2 runtime); a clean superset of main's version. * host-chat/codec/attachment.spec.ts — extra blurhash-thumbnail and NodeEndpoint round-trip tests against a byte-identical codec impl. Verified: build, typecheck, lint (0 errors), 679/679 tests pass.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Coordinated with paritytech/polkadot-desktop#523, which vendors the built dist of this branch for CI build verification on the host side.
Summary
Two coordinated changes across
statement-storeandhost-pappto support the V2 SSO session transport on host applications per Mobile SSO spec v0.2.2.statement-store — explicit
sessionKeyoncreateSessioncreateSessionnow takes asessionKey: Uint8Arrayparameter for the SessionId khash derivation, instead of (mis)usingremoteAccount.publicKeyfor that purpose.The conflation worked for V1 by accident — V1 enc pubkeys were 33-byte compressed P-256 and fit blake2b's 64-byte key limit. V2's 65-byte uncompressed P-256 broke it.
createUserSession) passsessionKey: userSession.remoteAccount.publicKeyand preserve byte-identical behaviour.shared_secret_session, conforming to spec:Breaking change for direct callers of
createSession: must now passsessionKey. The only in-tree caller (host-papp/createUserSession) is updated.host-papp — Mobile SSO spec v0.2.2
HandshakeSuccessV2codec gainsssoEncPubKey: PublicKeyCodec(=papp_encr_pubfrom spec §Encrypted response — V2). New encoded Success body length: 226 bytes (32 + 32 + 32 + 65 + 65).HandshakeSuccessV2_v021retains the previous 161-byte shape for back-compat decoding.Length-dispatched
decodeEncryptedHandshakeResponseV2accepts all three Success body shapes:ssoEncPubKey: <bytes>ssoEncPubKey: nullssoEncPubKey: null,rootAccountId: nullHandshakeSuccessV2ValueandHandshakeSuccessStatecarryssoEncPubKey: Uint8Array | null.OnAuthSuccesscallback's event payload now includesssoEncPubKey: Uint8Array | nullalongside the existingidentityChatPrivateKey. Host applications use this to drive the V2 SSO session transport — pre-v0.2.2 peers leave it null and the host leaves the transport inactive while chat continues to work viaidentityChatPrivateKey.Backward compatibility
sessionKey.ssoEncPubKeysurfaces asnullonOnAuthSuccessso host applications can leave the SSO transport inactive without breaking chat.Test plan
session.spec.tsmock usingsessionKey = remoteAccount.publicKey)