Portable identity passkey PRF and canonical RP ceremony - #360
Portable identity passkey PRF and canonical RP ceremony#360NotASithLord wants to merge 10 commits into
Conversation
Visual regression✅ No visual drift. Every state renders identically to the committed baselines. 📸 Browse the visual gallery — 54 baseline screens (light + dark) as of this commit. |
…PRF wrappers and the canonical-RP ceremony Before 1.0, every constant a portable passkey binds to permanently must be decided and locked. This lands all of them: - RP decided (docs/design/portable-identity/04): RP ID peerd.ai, the ceremony origin id.peerd.ai. The origin may move within peerd.ai until the first production mint; the RP ID may not. - passkey-prf wrapper kind (credential-wrapper.js): HKDF over the authenticator's PRF output feeding AES-KW around CapK. No KDF descriptor, so an untrusted record has no work-factor knobs. Bounded credentialId and transports metadata; record build/open/adopt accept prfOutput beside the passphrase. Unlike the passphrase wrapper, a stolen record gives no offline oracle against this kind: the KEK requires the authenticator. - Frozen derivation vectors (tests/peerd-distributed/ identity-prf-vectors.test.ts): the PRF input digest and a known-answer wrap lock the input tag, zero HKDF salt, info string, and RFC 3394 wrap in CI. A failing vector means credentials would be orphaned: revert the derivation, never the vector. - identity/handoff.js, the ceremony handoff. The id.peerd.ai page is a pure PRF oracle: the request rides a URL fragment out (ephemeral P-256 key plus challenge), the PRF output rides a fragment back sealed AEAD to that key (ECDH, then HKDF with the challenge as salt, then AES-GCM). The page never sees a seed, capsule, record, or CapK; fragments never reach a server; the history residue is single-request ciphertext. Import-free on purpose: web-identity/handoff.js is a byte-identical copy and CI fails on drift. - web-identity/: the static, dependency-free ceremony page source (register/get flows, frozen PRF input, localhost dev RP), vendored by the site repo per its README. Still ahead (doc 04): deploy the page, grow the backup/restore UI's passkey path over the exported handoff surface, live cross-browser ceremony tests, then the first real mint freezes the origin too.
efd11df to
e9aa89c
Compare
Verified findings from an adversarial multi-reviewer pass over the diff: - web-identity/ escaped every gate (medium): // @ts-check was inert, the dir was unlinted and unchecked. Add web-identity to the lint scope (package.json) and the tsconfig include; the now-live typecheck caught a missing BufferSource cast on the PRF input, now fixed. - Threat-model prose overstated the sealing (low): a compromised RP page reads the PRF output in PLAINTEXT (it must, to seal it), and because the PRF input is a frozen constant that output is the credential's PERMANENT wrapper-KEK source, not 'ciphertext bound to one request'. The AEAD protects only the return leg from off-page observers. Corrected in identity-rp.js, docs 04, and web-identity/README - and stated as the reason page compromise forces credential re-enrollment. - Unguarded decodeURIComponent (low): parseCeremonyRequest and extractSealedResponse threw a bare URIError on a malformed percent sequence in a fragment we do not control, breaking the module's typed- error taxonomy and the null-on-garbage contract. Add a guarded decodePercent: typed bad-envelope on the request path, null on the response path. - Unvalidated field spread (low): parseCeremonyRequest spread the untrusted fragment into the parsed request. Rebuild from validated fields only (v, flow, challenge, epk stripped to kty/crv/x/y, bounded credentialId + transports). - Partial zeroization (low): wipe the raw ECDH shared secret after it enters the non-extractable HKDF handle; comment the honest residual (base64-in-JSON string copies of the PRF output cannot be wiped). - Dead dark-mode CSS (low): the @media block preceded the base rules at equal specificity, so the overrides never applied. Move it after, add an .err dark variant, comment the ordering rule. handoff.js and its byte-identical web-identity/ copy stay in sync (the copy-equality test still passes). Refuted findings (forged/unauthenticated response, residentKey downgrade, oracle framing) were checked against the code and left as-is.
…igin-identity-x7f662 # Conflicts: # packaging/check-tscheck.ts
…igin-identity-x7f662 # Conflicts: # packaging/check-tscheck.ts
…igin-identity-x7f662 # Conflicts: # packaging/check-tscheck.ts
The essential integration guarantee for portable identity: a recovered did must not just verify in isolation, it must be the identity the p2p mesh authenticates as. Restore and mesh-join meet at one vault secret (distributed/identity/v1); this test drives that exact seam over a fake secret store, reproducing what offscreen/dweb-base.js does: - fresh install: adopt a recovery record, write the material to the secret (as dwebTransfer.adoptRecord does), then load it back through loadIdentityMaterial + identityFromMaterial (the mesh's own path) and prove the identity signs a HELLO-style payload verifiable under the ORIGINAL did. - a plain load does not re-mint or fork the stored identity. - replace-on-different-did yields the incoming did on the next mesh load (the runtime restart around the custody write is what makes a live mesh rejoin as the restored identity). Values-level coverage of the same wiring the live two-peer job exercises with a freshly minted identity; no production change.
…backup did The live tier of the persistent-identity guarantee. RESTORE=1 makes alice run the whole portable-identity lifecycle in-page before joining: install A mints through the production first-run path and exports a passphrase recovery record; a fresh install B adopts it, persists the material under distributed/identity/v1, and loads it back through loadIdentityMaterial + identityFromMaterial, byte-for-byte the composition the offscreen mesh host runs at start. She then joins the real WebRTC mesh, and the pass gate requires her meshed did to equal the pre-backup did on top of the usual link + gossip checks. Local runs: restore mode passes (alice meshes as the pre-backup did), and the default + conv modes still pass unchanged. Wiring: test:twopeer:restore script, a fourth retry-wrapped CI step in the two-peer job, and a restored/restoredFrom field on the harness report (null outside restore mode, so existing gates are untouched). Together with tests/peerd-distributed/identity-mesh-join.test.ts this closes both tiers: values-level (the stored bytes are the mesh's bytes) and live (a restored peer authenticates on a real network).
…igin-identity-x7f662 # Conflicts: # packaging/check-tscheck.ts
|
CI note on
The head commit here is only the recurring Not fixing it from this branch since it is base-branch breakage; I will re-run the job once main is green again. Generated by Claude Code |
… flush The Run-control probe read notebook.js the instant the button flipped to Stop. The flip happens at run-reserve time, BEFORE js/eval's awaited mirror of the agent code into notebook.js lands in OPFS, so on a slow runner the single-shot read saw the previous run's code and failed the suite (branch runs of #372 and #360 hit this; the product ordering is correct - reserve first prevents double-runs, and the mirror completes before the run starts). The read now polls until the mirrored content appears, bounded at 50 attempts, so a slow flush is a wait instead of a false mismatch. Signed-off-by: Jonathan Bursztyn <jobur93@gmail.com>
…igin-identity-x7f662
Eighteen async assertions across seven files were written as bare expect(promise).rejects.toThrow(...) with no await. Two effects, both real: 1. They asserted nothing. The statement returns a promise that nobody inspects, so the assertion could not fail the test it lives in. All eighteen pass once awaited, so no behavior was actually broken, but the coverage they claimed was not there. 2. They leave the work running after their own test finishes. When a straggler settles it is attributed to whatever test happens to be running at that moment, in a different file. Effect 2 matches the CI failure seen on this branch: bun blamed recovery-record.test.ts's first test at 7ms, far too fast to have reached its own Argon2id work. identity-capsule.test.ts runs four files earlier and had four floating assertions, two of them carrying an Argon2id derivation of roughly 150ms, with only fast files in between. That is the right shape and the right window to land on recovery-record's opening test. Not reproduced locally in twelve full suite runs, so this is the best supported explanation rather than a confirmed one; the fix stands on its own merits either way. Found by walking back from each .rejects./.resolves. occurrence to its statement start, so multi-line awaited forms are not counted.
What changed
peerd.aiand the ceremony origin asid.peerd.aipasskey-prfcredential wrapper and strict recovery-record validationWhy
Portable identity credentials bind permanently to protocol constants. Those constants must be reviewed and fixed before the first production credential is created. Changing them later would orphan existing credentials.
The ceremony page acts only as a PRF oracle. It receives a public key and challenge in the URL fragment, then returns the PRF output encrypted to that request. It never receives the identity seed, capsule, record, or capsule key.
Still required before this can be ready
web-identity/through the site repositoryValidation on the current draft head
Review notes
This touches portable identity and the dweb identity module. It does not change service-worker routes, vault custody, transfer paths, or tool gates.
web-identity/allows localhost only for development credentials; those credentials cannot collide withpeerd.aicredentials.