Skip to content

Portable identity passkey PRF and canonical RP ceremony - #360

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

Portable identity passkey PRF and canonical RP ceremony#360
NotASithLord wants to merge 6 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 — 50 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.
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