Skip to content

Portable identity passkey PRF and canonical RP ceremony - #360

Draft
NotASithLord wants to merge 10 commits into
mainfrom
claude/peerd-cross-origin-identity-x7f662
Draft

Portable identity passkey PRF and canonical RP ceremony#360
NotASithLord wants to merge 10 commits into
mainfrom
claude/peerd-cross-origin-identity-x7f662

Conversation

@NotASithLord

@NotASithLord NotASithLord commented Aug 8, 2026

Copy link
Copy Markdown
Owner

What changed

  • fixes the portable identity RP ID as peerd.ai and the ceremony origin as id.peerd.ai
  • fixes the PRF input and key derivation with known-answer vectors
  • adds the passkey-prf credential wrapper and strict recovery-record validation
  • adds an import-free handoff protocol and a byte-identical static ceremony page
  • records the remaining deployment, UI, and cross-browser work

Why

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

  • deploy web-identity/ through the site repository
  • connect the backup and restore UI to the handoff surface
  • add live Chrome and Firefox ceremony tests
  • complete security, cryptography, human UX, and model UX review
  • link the implementation to the correct pre-1.0 tracking issue

Validation on the current draft head

  • frozen derivation vectors
  • handoff round-trip, tamper, challenge, and replay tests
  • wrapper validation and size bounds
  • static-page copy equality check

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 with peerd.ai credentials.

@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown

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.
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
@NotASithLord NotASithLord changed the title feat(identity): freeze the credential-orphaning surface — passkey-PRF wrappers + the canonical-RP ceremony Portable identity passkey PRF and canonical RP ceremony Aug 8, 2026
…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
@NotASithLord

Copy link
Copy Markdown
Owner Author

CI note on 9565704: the Firefox Store smoke + Gecko suite failure is pre-existing on the base branch, not caused by this PR.

  • Failing assertion: the accessible Run control becomes Stop, keeps focus, terminates, and recovers (scripts/firefox/run-runtime-tests.mjs:3376) — a Firefox Notebook UI control test.
  • It arrived with Add first-class Firefox Notebook modules #370 ("Add first-class Firefox Notebook modules"), and 5de5c9a fails the same job on main itself, with this branch not involved.
  • This branch touches no Firefox, Notebook, or engine code (git diff origin/main...HEAD -- scripts/firefox/ extension/engine-tabs/notebook-tab/ extension/peerd-engine/ is empty), and its copy of run-runtime-tests.mjs is byte-identical to main's.

The head commit here is only the recurring packaging/check-tscheck.ts floor merge (695 -> 699, resolved to the true post-merge count). Everything else on this head is green, and the portable-identity gates pass locally: typecheck, check:tscheck, lint (extension + web + web-identity), check:boundary, copy-hygiene, and the full bun suite.

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

jonybur added a commit that referenced this pull request Aug 9, 2026
… 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>
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.
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.

1 participant