Skip to content

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
mainfrom
feat/sso-spec-v0.2.2
Open

feat(sso): support Mobile SSO spec v0.2.2 (sso_encr_pub_key) + clean createSession sessionKey API#186
kalininilya wants to merge 33 commits into
mainfrom
feat/sso-spec-v0.2.2

Conversation

@kalininilya

Copy link
Copy Markdown
Contributor

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-store and host-papp to support the V2 SSO session transport on host applications per Mobile SSO spec v0.2.2.

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.

  • 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 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.

host-papp — Mobile SSO spec v0.2.2

  • HandshakeSuccessV2 codec gains ssoEncPubKey: PublicKeyCodec (= 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:

    Body length Spec rev Surfaces
    226 bytes v0.2.2 ssoEncPubKey: <bytes>
    161 bytes v0.2.1 ssoEncPubKey: null
    129 bytes v0.2 ssoEncPubKey: null, 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.

Backward compatibility

  • V1 SSO sessions: unchanged. host-papp passes the 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.

Test plan

  • Unit: 135 tests pass across host-papp + statement-store packages locally
  • Codec round-trips for v0.2.2 (226-byte) Success body
  • Length-dispatched decoder still accepts v0.2.1 (161) and v0.2 (129) Success bodies and surfaces nulls correctly
  • V1 SSO session: byte-identical to pre-refactor (verified via session.spec.ts mock using sessionKey = remoteAccount.publicKey)
  • End-to-end host-side verification via the coordinated polkadot-desktop PR

kalininilya and others added 30 commits May 20, 2026 12:55
…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>
cuteWarmFrog and others added 3 commits June 1, 2026 13:56
- 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants