From e9aa89c73af321131c60d94d1e62e479be7b84e7 Mon Sep 17 00:00:00 2001 From: NotASithLord <48842926+NotASithLord@users.noreply.github.com> Date: Sat, 8 Aug 2026 21:22:33 +0000 Subject: [PATCH 1/5] feat(identity): freeze the credential-orphaning surface with passkey-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. --- .../portable-identity/02-recovery-record.md | 5 +- .../portable-identity/04-canonical-rp.md | 62 +++- .../identity/credential-wrapper.js | 137 +++++++- .../peerd-distributed/identity/errors.js | 3 + .../peerd-distributed/identity/handoff.js | 301 ++++++++++++++++++ .../identity/recovery-record.js | 44 ++- extension/peerd-distributed/index.js | 17 +- packaging/check-tscheck.ts | 2 +- .../identity-handoff.test.ts | 103 ++++++ .../identity-prf-vectors.test.ts | 68 ++++ web-identity/README.md | 44 +++ web-identity/handoff.js | 301 ++++++++++++++++++ web-identity/identity-rp.js | 178 +++++++++++ web-identity/index.html | 45 +++ 14 files changed, 1268 insertions(+), 42 deletions(-) create mode 100644 extension/peerd-distributed/identity/handoff.js create mode 100644 tests/peerd-distributed/identity-handoff.test.ts create mode 100644 tests/peerd-distributed/identity-prf-vectors.test.ts create mode 100644 web-identity/README.md create mode 100644 web-identity/handoff.js create mode 100644 web-identity/identity-rp.js create mode 100644 web-identity/index.html diff --git a/docs/design/portable-identity/02-recovery-record.md b/docs/design/portable-identity/02-recovery-record.md index fd9474a7..15ff9c06 100644 --- a/docs/design/portable-identity/02-recovery-record.md +++ b/docs/design/portable-identity/02-recovery-record.md @@ -1,6 +1,9 @@ # Recovery record and manual transfer -Status: implemented for preview backup files. +Status: implemented for preview backup files. The record format now also +accepts `passkey-prf` wrappers (no offline oracle - the KEK needs the +authenticator; frozen derivation constants and vectors per doc 04); the +ceremony that produces the PRF output is doc 04's canonical-RP handoff. The recovery record contains an advertised did, an encrypted capsule, credential wrappers, and version metadata. It can be carried as ciphertext, but it is still diff --git a/docs/design/portable-identity/04-canonical-rp.md b/docs/design/portable-identity/04-canonical-rp.md index 243ac938..c846cd10 100644 --- a/docs/design/portable-identity/04-canonical-rp.md +++ b/docs/design/portable-identity/04-canonical-rp.md @@ -1,16 +1,62 @@ # Canonical relying party -Status: proposal; blocked on an owner decision and work in the separate site -repository. +Status: RP decided and frozen; ceremony page + handoff protocol landed +in-repo; production deployment and the extension UI flow are the +remaining work. The extension-origin vault passkey cannot be a portable website credential: WebAuthn binds it to the extension origin, and an extension cannot claim `peerd.ai` as its relying-party ID. -A future portable passkey therefore needs a stable HTTPS relying party, such as -`id.peerd.ai`, with a small auditable ceremony page. Choosing that RP is -effectively permanent because changing it orphans credentials. Hosting, -deployment integrity, recovery-record storage, challenge binding, and the -extension-to-page transport all require their own threat model and live tests. +## Decided (owner, 2026-08 - the pre-1.0 orphaning surface) -None of that surface is part of the manual backup implementation. +Everything a portable passkey binds to permanently is now fixed and +locked by CI: + +- **RP ID: `peerd.ai`** - the anchor every identity passkey is minted + against. Changing it after the first production mint orphans every + credential. The ceremony origin (`IDENTITY_RP_ORIGIN`, working value + `https://id.peerd.ai`) may still move between `peerd.ai` subdomains + until that first mint; the RP ID may not. +- **The PRF input and KEK derivation** - constants in + `identity/handoff.js` + `identity/credential-wrapper.js`, frozen by + known-answer vectors (`tests/peerd-distributed/identity-prf-vectors.test.ts`). + A failing vector means the change orphans credentials: revert the + derivation, never update the vector. + +## Architecture (narrower than this doc's original sketch) + +The ceremony page (`web-identity/`) is a **pure PRF oracle**. It parses +the extension's request off a URL fragment, takes one explicit user +gesture, runs the WebAuthn ceremony with the frozen PRF input, and +returns the 32-byte PRF output sealed to the request's ephemeral ECDH +key - via a fragment redirect the extension watches on the tab. It +never sees a seed, capsule, recovery record, or capsule key; all +capsule crypto stays in the extension (`credential-wrapper.js`). So the +page compromise blast radius is one credential's PRF output as +ciphertext bound to one live request - not the identity root, and not a +passphrase oracle. + +why fragments + AEAD instead of postMessage: fragments never reach a +server, the ciphertext left in tab history is useless without the +extension-held ephemeral key, and nothing depends on cross-scheme +postMessage targetOrigin semantics (identical on Chrome and Firefox). + +The protocol is single-sourced in `identity/handoff.js` (deliberately +import-free); the page runs a byte-identical copy and CI fails on +drift. + +## Remaining before this is usable end to end + +1. Deploy `web-identity/` at the canonical origin (site repo vendors + the directory; hosting requirements in `web-identity/README.md`). +2. The extension flow: backup/restore UI grows "protect with a passkey" + (register) and "unlock with passkey" (get) - open the ceremony tab, + watch for the return fragment, open the sealed response, then + wrap/unwrap CapK locally. The module surface for this is exported + from the dweb index (`createHandoffRequest` … `openHandoffResponse`). +3. Live cross-browser ceremony tests against the deployed origin, and a + first REAL mint - after which the origin, too, is effectively frozen. + +Hosted recovery-record storage remains out of scope (README decision +D-B): the record still travels only in the explicit backup file. diff --git a/extension/peerd-distributed/identity/credential-wrapper.js b/extension/peerd-distributed/identity/credential-wrapper.js index 85aadd59..de9ebfd8 100644 --- a/extension/peerd-distributed/identity/credential-wrapper.js +++ b/extension/peerd-distributed/identity/credential-wrapper.js @@ -1,17 +1,30 @@ // @ts-check -// peerd-distributed/identity/credential-wrapper.js — the passphrase wrapper -// used by manual portable-identity backup and restore. +// peerd-distributed/identity/credential-wrapper.js - per-credential unlock +// onto the identity capsule: the passphrase wrapper (manual backup) and the +// passkey-PRF wrapper (the id.peerd.ai ceremony - web-identity/ hosts the +// page source; docs/design/portable-identity/ 04 records the decided RP). // // A wrapper is ciphertext, but it is also a sensitive offline verifier: // AES-KW integrity tells an attacker when a guessed passphrase re-derives the -// correct KEK. The shipped kind is Argon2id(passphrase, salt, bounded -// parameters). Passkey PRF and hosted lookup remain design proposals until -// their ceremonies and relying-party boundary are implemented end to end. +// correct KEK. Passphrase kind: Argon2id(passphrase, salt, bounded +// parameters). Passkey kind: HKDF over the authenticator's PRF output - no +// stretch needed (the credential secret is uniform; presence + user +// verification is the work factor). Hosted lookup remains a proposal. // -// why Argon2id: a carried record gives an attacker an offline correctness -// oracle against a permanent signing root. Reuse the vault's audited, -// vendored memory-hard implementation instead of treating this like a -// low-value settings file. +// why Argon2id for passphrases: a carried record gives an attacker an offline +// correctness oracle against a permanent signing root. Reuse the vault's +// audited, vendored memory-hard implementation instead of treating this like +// a low-value settings file. +// +// FROZEN protocol constants (orphaning surface - changing any of these after +// credentials exist makes every passkey wrapper unopenable; the KAT vectors +// in tests/peerd-distributed/identity-prf-vectors.test.ts lock them in CI): +// the PRF input tag, the zero HKDF salt, and the HKDF info string below. +// why a protocol-FIXED PRF input (unlike the vault's random per-enrollment +// salt): a portable credential must be evaluable on a machine holding no +// local state yet - the input has to be knowable from the protocol alone. +// Uniqueness comes from the authenticator's per-credential secret; purpose +// separation happens AFTER the PRF via HKDF info strings. import { toBase64, fromBase64 } from '/shared/bundle/bytes.js'; import { @@ -21,15 +34,28 @@ import { import { IdentityCredentialError } from './errors.js'; export const WRAPPER_KIND_PASSPHRASE = 'passphrase'; +export const WRAPPER_KIND_PRF = 'passkey-prf'; const WRAPPED_KEY_BYTES = 40; +const WRAPPED_KEY_B64_LENGTH = 56; const BASE64_PATTERN = /^[A-Za-z0-9+/]+={0,2}$/; +// The frozen KEK-derivation constant (see the file header). Its sibling - +// the PRF INPUT the ceremony evaluates - lives in handoff.js, the +// self-contained module the ceremony page runs a byte-copy of. +const HKDF_INFO_WRAPPER = 'peerd/capsule-wrapper/v1'; +const PRF_OUTPUT_BYTES = 32; +// WebAuthn credential IDs are at most 1023 bytes; base64 of that is 1368 +// chars. Bound with headroom, exact charset. +const CREDENTIAL_ID_B64_MAX = 2048; +const TRANSPORTS_MAX = 8; +const TRANSPORT_NAME_MAX = 32; + /** @param {string} message @param {string} [code] @param {unknown} [cause] */ const credentialFailure = (message, code = 'malformed-wrapper', cause) => new IdentityCredentialError(message, code, cause === undefined ? {} : { cause }); -/** @param {Uint8Array} bytes exactly 32 — imported as a non-extractable AES-KW KEK */ +/** @param {Uint8Array} bytes exactly 32 - imported as a non-extractable AES-KW KEK */ const importKek = (bytes) => crypto.subtle.importKey( 'raw', /** @type {BufferSource} */ (bytes), { name: 'AES-KW', length: 256 }, false, ['wrapKey', 'unwrapKey'], @@ -64,12 +90,43 @@ const kekFromPassphrase = async (passphrase, salt, kdf) => { } }; +/** + * HKDF-SHA256 → 32 bytes. Zero salt by design: the PRF output is already + * uniform, and a fixed salt keeps the derivation reproducible from the + * protocol constants alone (a random salt here would just be one more + * piece of local state a fresh install doesn't have). + * @param {Uint8Array} ikm @param {string} info + */ +const hkdf32 = async (ikm, info) => { + const key = await crypto.subtle.importKey( + 'raw', /** @type {BufferSource} */ (ikm), 'HKDF', false, ['deriveBits'], + ); + return new Uint8Array(await crypto.subtle.deriveBits( + { name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(32), info: new TextEncoder().encode(info) }, + key, + 256, + )); +}; + +/** @param {Uint8Array} prfOutput the authenticator's 32-byte PRF result */ +const kekFromPrf = async (prfOutput) => { + if (!(prfOutput instanceof Uint8Array) || prfOutput.byteLength !== PRF_OUTPUT_BYTES) { + throw credentialFailure('PRF output must be exactly 32 bytes', 'bad-prf-output'); + } + const raw = await hkdf32(prfOutput, HKDF_INFO_WRAPPER); + try { + return await importKek(raw); + } finally { + raw.fill(0); + } +}; + /** @param {CryptoKey} capsuleKey @param {CryptoKey} kek */ const wrapCapK = async (capsuleKey, kek) => toBase64(new Uint8Array(await crypto.subtle.wrapKey('raw', capsuleKey, kek, { name: 'AES-KW' }))); /** - * Unwrap a wrapped CapK into a non-extractable AES-GCM handle — enough + * Unwrap a wrapped CapK into a non-extractable AES-GCM handle - enough * to open (or re-seal) the capsule, never to export the key bytes. * @param {string} wrappedB64 @param {CryptoKey} kek */ @@ -93,9 +150,46 @@ const unwrapCapK = (wrappedB64, kek) => { * kind: string, * wrappedKey: string, * kdf?: { name: string, memKiB: number, iters: number, parallelism: number, salt: string }, + * credentialId?: string | null, + * transports?: string[] | null, * }} CredentialWrapper */ +/** + * Wrap CapK for a passkey's PRF output (evaluated over identityPrfInput() + * at the canonical RP - the ceremony runs at id.peerd.ai, never in an + * extension context, so the credential is portable across installs). + * + * @param {CryptoKey} capsuleKey + * @param {Uint8Array} prfOutput + * @param {{ credentialId?: string | null, transports?: string[] | null }} [meta] + * enrollment metadata (base64 credential ID + transport hints) so a + * later unlock can route straight to the right authenticator - the + * same role the vault's PrfContext plays locally. + * @returns {Promise} + */ +export const makePrfWrapper = async (capsuleKey, prfOutput, { credentialId = null, transports = null } = {}) => { + const kek = await kekFromPrf(prfOutput); + const wrapper = { + kind: WRAPPER_KIND_PRF, + wrappedKey: await wrapCapK(capsuleKey, kek), + credentialId: credentialId ?? null, + transports: transports ?? null, + }; + const defect = validateCredentialWrapper(wrapper); + if (defect) throw credentialFailure(`refusing to emit an invalid passkey wrapper: ${defect}`); + return wrapper; +}; + +/** @param {CredentialWrapper} wrapper @param {Uint8Array} prfOutput */ +export const openPrfWrapper = async (wrapper, prfOutput) => { + const defect = validateCredentialWrapper(wrapper); + if (defect || wrapper.kind !== WRAPPER_KIND_PRF) { + throw credentialFailure(`invalid passkey wrapper: ${defect ?? 'wrong-kind'}`); + } + return unwrapCapK(wrapper.wrappedKey, await kekFromPrf(prfOutput)); +}; + /** * Wrap CapK under a passphrase. * @param {CryptoKey} capsuleKey @param {string} passphrase @@ -141,9 +235,9 @@ export const openPassphraseWrapper = async (wrapper, passphrase) => { export const validateCredentialWrapper = (wrapper) => { if (!wrapper || typeof wrapper !== 'object') return 'not-an-object'; if (typeof wrapper.kind !== 'string' || wrapper.kind.length === 0 || wrapper.kind.length > 64) return 'bad-kind'; - const known = wrapper.kind === WRAPPER_KIND_PASSPHRASE; + const known = wrapper.kind === WRAPPER_KIND_PASSPHRASE || wrapper.kind === WRAPPER_KIND_PRF; if (typeof wrapper.wrappedKey !== 'string' || wrapper.wrappedKey.length === 0 - || (known ? wrapper.wrappedKey.length !== 56 : wrapper.wrappedKey.length > 4096) + || (known ? wrapper.wrappedKey.length !== WRAPPED_KEY_B64_LENGTH : wrapper.wrappedKey.length > 4096) || (known && !BASE64_PATTERN.test(wrapper.wrappedKey))) return 'bad-wrapped-key'; if (wrapper.kind === WRAPPER_KIND_PASSPHRASE) { const kdf = wrapper.kdf; @@ -154,5 +248,22 @@ export const validateCredentialWrapper = (wrapper) => { if (typeof kdf.salt !== 'string' || kdf.salt.length !== 24 || !BASE64_PATTERN.test(kdf.salt)) return 'bad-salt'; } + if (wrapper.kind === WRAPPER_KIND_PRF) { + // No KDF descriptor: the derivation is the frozen protocol constant, so + // an untrusted record has no work-factor knob here at all. + if (wrapper.kdf !== undefined) return 'unexpected-kdf'; + if (wrapper.credentialId != null + && (typeof wrapper.credentialId !== 'string' + || wrapper.credentialId.length === 0 + || wrapper.credentialId.length > CREDENTIAL_ID_B64_MAX + || !BASE64_PATTERN.test(wrapper.credentialId))) return 'bad-credential-id'; + if (wrapper.transports != null) { + if (!Array.isArray(wrapper.transports) || wrapper.transports.length > TRANSPORTS_MAX) return 'bad-transports'; + for (const transport of wrapper.transports) { + if (typeof transport !== 'string' || transport.length === 0 + || transport.length > TRANSPORT_NAME_MAX) return 'bad-transports'; + } + } + } return null; }; diff --git a/extension/peerd-distributed/identity/errors.js b/extension/peerd-distributed/identity/errors.js index 3b1ccdee..672237de 100644 --- a/extension/peerd-distributed/identity/errors.js +++ b/extension/peerd-distributed/identity/errors.js @@ -15,3 +15,6 @@ class PortableIdentityError extends Error { export class IdentityCapsuleError extends PortableIdentityError {} export class IdentityCredentialError extends PortableIdentityError {} export class IdentityRecordError extends PortableIdentityError {} +// IdentityHandoffError lives in ./handoff.js, not here: that module must +// stay import-free so its byte-identical copy runs on the static ceremony +// page (web-identity/), where this file does not exist. diff --git a/extension/peerd-distributed/identity/handoff.js b/extension/peerd-distributed/identity/handoff.js new file mode 100644 index 00000000..bcff74d9 --- /dev/null +++ b/extension/peerd-distributed/identity/handoff.js @@ -0,0 +1,301 @@ +// @ts-check +// peerd-distributed/identity/handoff.js - the extension ↔ id.peerd.ai +// ceremony handoff (docs/design/portable-identity/ 04). +// +// The canonical RP page is a PURE PRF ORACLE: it runs the WebAuthn +// ceremony for the peerd.ai credential and returns the 32-byte PRF +// output - nothing else. It never sees the seed, the capsule, the +// recovery record, or the capsule key; all capsule crypto stays in the +// extension. The one secret that crosses (the PRF output) crosses only +// as AEAD ciphertext bound to a single live request: +// +// extension id.peerd.ai (web-identity/) +// ────────── ─────────────────────────── +// mint ephemeral ECDH P-256 (epk) +// mint 32-byte challenge +// open {origin}/#req=b64(request) ─▶ parse + show consent +// WebAuthn create()/get() with the +// frozen PRF input (credential- +// wrapper.js constants) +// ECDH(pageEph, epk) → HKDF(salt= +// challenge) → AES-GCM key +// read #res=… off the tab URL ◀─ location.replace('#res=b64(env)') +// ECDH-decrypt, check challenge +// derive KEK locally, wrap/unwrap CapK +// +// why fragment + AEAD instead of postMessage: fragments never reach a +// server, and the ciphertext in tab history is useless without the +// ephemeral private key, which lives only in the extension context that +// minted it and dies with the flow. No reliance on cross-scheme +// postMessage targetOrigin semantics, identical on Chrome and Firefox. +// +// SELF-CONTAINED ON PURPOSE: no imports. A byte-identical copy of this +// file ships on the static ceremony page (web-identity/handoff.js), +// where the extension's /shared/ helpers do not exist - CI asserts the +// two copies match (tests/peerd-distributed/identity-handoff.test.ts). + +// FROZEN alongside the PRF constants (see credential-wrapper.js header): +// the RP ID is the anchor every passkey is minted against - changing it +// after first production mint orphans every credential. The ORIGIN may +// move between subdomains of the RP ID before launch; the RP ID may not. +export const IDENTITY_RP_ID = 'peerd.ai'; +export const IDENTITY_RP_ORIGIN = 'https://id.peerd.ai'; + +// The PRF input every portable-identity credential is evaluated with - +// hashing the tag pins the input to exactly 32 bytes on every +// authenticator. Deterministic protocol state, never per-install; FROZEN +// (an input change orphans every passkey wrapper ever minted). +const PRF_INPUT_TAG = 'peerd.identity.credential.v1'; +/** @returns {Promise} */ +export const identityPrfInput = async () => + new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(PRF_INPUT_TAG))); + +export const HANDOFF_VERSION = 1; +const HANDOFF_HKDF_INFO = 'peerd/identity-handoff/v1'; +const CHALLENGE_BYTES = 32; +const IV_BYTES = 12; +const FRAGMENT_MAX = 8192; +const FLOWS = Object.freeze(['register', 'get']); + +export class IdentityHandoffError extends Error { + /** @param {string} message @param {string} code @param {{ cause?: unknown }} [options] */ + constructor(message, code, options = {}) { + super(message, options); + this.name = 'IdentityHandoffError'; + this.code = code; + } +} + +/** @param {Uint8Array} bytes */ +const toB64 = (bytes) => btoa(String.fromCharCode(...bytes)); +/** @param {string} b64 */ +const fromB64 = (b64) => Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)); +const utf8 = (/** @type {string} */ s) => new TextEncoder().encode(s); + +/** @param {Record} value */ +const encodeEnvelope = (value) => toB64(utf8(JSON.stringify(value))); +/** @param {string} b64 @param {string} what */ +const decodeEnvelope = (b64, what) => { + if (typeof b64 !== 'string' || b64.length === 0 || b64.length > FRAGMENT_MAX) { + throw new IdentityHandoffError(`${what} is missing or oversized`, 'bad-envelope'); + } + try { + return JSON.parse(new TextDecoder().decode(fromB64(b64))); + } catch (cause) { + throw new IdentityHandoffError(`${what} is not decodable`, 'bad-envelope', { cause }); + } +}; + +// P-256 everywhere: universal WebCrypto support (X25519 is not there yet +// on every target this page must serve). +const ECDH_PARAMS = Object.freeze({ name: 'ECDH', namedCurve: 'P-256' }); + +/** @param {any} jwk bounded public-key JWK check before importKey sees it */ +const validatePublicJwk = (jwk) => { + if (!jwk || typeof jwk !== 'object') return 'not-an-object'; + if (jwk.kty !== 'EC' || jwk.crv !== 'P-256') return 'wrong-curve'; + if (typeof jwk.x !== 'string' || jwk.x.length === 0 || jwk.x.length > 64) return 'bad-x'; + if (typeof jwk.y !== 'string' || jwk.y.length === 0 || jwk.y.length > 64) return 'bad-y'; + if (jwk.d !== undefined) return 'private-material'; + return null; +}; + +/** @param {any} jwk @param {string} what */ +const importPeerPublicKey = async (jwk, what) => { + const defect = validatePublicJwk(jwk); + if (defect) throw new IdentityHandoffError(`${what} public key rejected: ${defect}`, 'bad-public-key'); + // Strip to exactly the fields a public EC JWK needs - nothing an + // untrusted envelope smuggles alongside survives. + const { kty, crv, x, y } = jwk; + try { + return await crypto.subtle.importKey('jwk', { kty, crv, x, y }, ECDH_PARAMS, false, []); + } catch (cause) { + throw new IdentityHandoffError(`${what} public key is not a valid P-256 point`, 'bad-public-key', { cause }); + } +}; + +/** + * ECDH → HKDF(salt=challenge, info=fixed) → one-shot AES-GCM key. The + * challenge in the salt binds the key to this request; a replayed + * response against a fresh request derives a different key and fails + * authentication. + * + * @param {CryptoKey} privateKey this side's ECDH private key + * @param {CryptoKey} publicKey the peer's imported public key + * @param {Uint8Array} challenge + */ +const deriveHandoffKey = async (privateKey, publicKey, challenge) => { + const shared = await crypto.subtle.deriveBits({ name: 'ECDH', public: publicKey }, privateKey, 256); + const ikm = await crypto.subtle.importKey('raw', shared, 'HKDF', false, ['deriveKey']); + return crypto.subtle.deriveKey( + { + name: 'HKDF', hash: 'SHA-256', + salt: /** @type {BufferSource} */ (challenge), + info: utf8(HANDOFF_HKDF_INFO), + }, + ikm, + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt', 'decrypt'], + ); +}; + +/** + * EXTENSION SIDE - mint a ceremony request. The private key never leaves + * the calling context; hold it in memory for the life of the flow and + * drop it (it is non-extractable, and single-use by contract). + * + * @param {Object} args + * @param {'register' | 'get'} args.flow + * @param {string | null} [args.credentialId] routes a 'get' straight to + * the enrolled credential; omitted → discoverable-credential picker + * @param {string[] | null} [args.transports] + * @returns {Promise<{ request: any, privateKey: CryptoKey, challenge: Uint8Array }>} + */ +export const createHandoffRequest = async ({ flow, credentialId = null, transports = null }) => { + if (!FLOWS.includes(flow)) throw new IdentityHandoffError(`unknown flow ${flow}`, 'bad-flow'); + const keyPair = /** @type {CryptoKeyPair} */ ( + await crypto.subtle.generateKey(ECDH_PARAMS, false, ['deriveBits', 'deriveKey'])); + const challenge = crypto.getRandomValues(new Uint8Array(CHALLENGE_BYTES)); + const epk = await crypto.subtle.exportKey('jwk', keyPair.publicKey); + delete epk.key_ops; + delete epk.ext; + return { + request: { + v: HANDOFF_VERSION, + flow, + challenge: toB64(challenge), + epk, + credentialId, + transports, + }, + privateKey: keyPair.privateKey, + challenge, + }; +}; + +/** + * Both sides - the ceremony URL the extension opens, and the request the + * page parses back out of its own fragment. + * @param {string} origin @param {any} request + */ +export const buildCeremonyUrl = (origin, request) => + `${origin}/#req=${encodeURIComponent(encodeEnvelope(request))}`; + +/** @param {string} fragment location.hash with or without the leading '#' */ +export const parseCeremonyRequest = (fragment) => { + const raw = /** @type {string} */ (fragment ?? '').replace(/^#/, ''); + const match = /^req=(.+)$/.exec(raw); + if (!match) return null; + const request = decodeEnvelope(decodeURIComponent(match[1]), 'ceremony request'); + if (request?.v !== HANDOFF_VERSION) throw new IdentityHandoffError(`unsupported handoff version ${request?.v}`, 'bad-version'); + if (!FLOWS.includes(request.flow)) throw new IdentityHandoffError(`unknown flow ${request.flow}`, 'bad-flow'); + const challenge = (() => { + try { return fromB64(request.challenge); } catch { return new Uint8Array(0); } + })(); + if (challenge.length !== CHALLENGE_BYTES) throw new IdentityHandoffError('bad challenge', 'bad-challenge'); + const epkDefect = validatePublicJwk(request.epk); + if (epkDefect) throw new IdentityHandoffError(`bad request key: ${epkDefect}`, 'bad-public-key'); + if (request.credentialId != null + && (typeof request.credentialId !== 'string' || request.credentialId.length > 2048)) { + throw new IdentityHandoffError('bad credentialId', 'bad-credential-id'); + } + return { ...request, challengeBytes: challenge }; +}; + +/** + * PAGE SIDE - seal the ceremony result to the requesting extension. + * @param {Object} args + * @param {any} args.request the parsed ceremony request + * @param {Uint8Array} args.prfOutput + * @param {string | null} [args.credentialId] + * @param {string[] | null} [args.transports] + * @returns {Promise} the value for `#res=` + */ +export const sealHandoffResponse = async ({ request, prfOutput, credentialId = null, transports = null }) => { + if (!(prfOutput instanceof Uint8Array) || prfOutput.byteLength !== 32) { + throw new IdentityHandoffError('PRF output must be exactly 32 bytes', 'bad-prf-output'); + } + const extensionKey = await importPeerPublicKey(request.epk, 'request'); + const challenge = fromB64(request.challenge); + const pageKeys = /** @type {CryptoKeyPair} */ ( + await crypto.subtle.generateKey(ECDH_PARAMS, false, ['deriveBits', 'deriveKey'])); + const key = await deriveHandoffKey(pageKeys.privateKey, extensionKey, challenge); + const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES)); + const plaintext = utf8(JSON.stringify({ + v: HANDOFF_VERSION, + challenge: request.challenge, + prfOutput: toB64(prfOutput), + credentialId, + transports, + })); + let ct; + try { + ct = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, plaintext)); + } finally { + plaintext.fill(0); + } + const epk = await crypto.subtle.exportKey('jwk', pageKeys.publicKey); + delete epk.key_ops; + delete epk.ext; + return encodeEnvelope({ v: HANDOFF_VERSION, epk, iv: toB64(iv), ct: toB64(ct) }); +}; + +/** The page navigates here when done; the extension watches the tab URL. */ +/** @param {string} origin @param {string} sealedResponse */ +export const buildReturnUrl = (origin, sealedResponse) => + `${origin}/#res=${encodeURIComponent(sealedResponse)}`; + +/** + * EXTENSION SIDE - pull `#res=` off a watched tab URL. Returns null while + * the ceremony is still in progress (no res fragment yet). + * @param {string} url + */ +export const extractSealedResponse = (url) => { + let hash; + try { hash = new URL(url).hash; } catch { return null; } + const match = /^#res=(.+)$/.exec(hash); + return match ? decodeURIComponent(match[1]) : null; +}; + +/** + * EXTENSION SIDE - open the sealed response. Verifies the echoed + * challenge before anything else is trusted. + * + * @param {Object} args + * @param {string} args.sealedResponse + * @param {CryptoKey} args.privateKey from createHandoffRequest + * @param {Uint8Array} args.challenge from createHandoffRequest + * @returns {Promise<{ prfOutput: Uint8Array, credentialId: string | null, transports: string[] | null }>} + */ +export const openHandoffResponse = async ({ sealedResponse, privateKey, challenge }) => { + const envelope = decodeEnvelope(sealedResponse, 'ceremony response'); + if (envelope?.v !== HANDOFF_VERSION) throw new IdentityHandoffError(`unsupported handoff version ${envelope?.v}`, 'bad-version'); + const pageKey = await importPeerPublicKey(envelope.epk, 'response'); + const key = await deriveHandoffKey(privateKey, pageKey, challenge); + let parsed; + try { + const iv = fromB64(envelope.iv); + if (iv.length !== IV_BYTES) throw new Error('bad iv length'); + const plaintext = new Uint8Array( + await crypto.subtle.decrypt({ name: 'AES-GCM', iv, }, key, fromB64(envelope.ct))); + parsed = JSON.parse(new TextDecoder().decode(plaintext)); + plaintext.fill(0); + } catch (cause) { + throw new IdentityHandoffError('ceremony response could not be authenticated', 'open-failed', { cause }); + } + if (parsed?.challenge !== toB64(challenge)) { + throw new IdentityHandoffError('ceremony response answers a different request', 'challenge-mismatch'); + } + const prfOutput = (() => { + try { return fromB64(parsed.prfOutput); } catch { return new Uint8Array(0); } + })(); + if (prfOutput.length !== 32) throw new IdentityHandoffError('response carries no PRF output', 'bad-prf-output'); + return { + prfOutput, + credentialId: typeof parsed.credentialId === 'string' ? parsed.credentialId : null, + transports: Array.isArray(parsed.transports) + ? parsed.transports.filter((/** @type {unknown} */ t) => typeof t === 'string').slice(0, 8) + : null, + }; +}; diff --git a/extension/peerd-distributed/identity/recovery-record.js b/extension/peerd-distributed/identity/recovery-record.js index 533c9f1f..e25ad0bb 100644 --- a/extension/peerd-distributed/identity/recovery-record.js +++ b/extension/peerd-distributed/identity/recovery-record.js @@ -1,15 +1,16 @@ // @ts-check -// peerd-distributed/identity/recovery-record.js — the portable identity +// peerd-distributed/identity/recovery-record.js - the portable identity // record: everything a fresh install needs to become this did EXCEPT the // credential itself (docs/design/portable-identity/ 02). // // Sensitive recovery ciphertext: the capsule is AES-GCM ciphertext and each // wrapper is AES-KW ciphertext under a credential-derived KEK, but a stolen -// record still permits offline passphrase guessing. This implementation carries -// the record only in the explicit backup file; hosted lookup and passkey -// ceremonies remain proposals. +// record still permits offline passphrase guessing (a passkey-prf wrapper has +// no offline oracle - its KEK needs the authenticator). The record travels +// only in the explicit backup file; hosted lookup remains a proposal. The +// passkey ceremony runs at the canonical RP (web-identity/, handoff.js). // -// This file also owns the ADOPTION decision — what an import does when a +// This file also owns the ADOPTION decision - what an import does when a // record meets whatever identity the receiving install already has. The // rule is explicit: the same did is accepted without a write, a different did // is refused by default, and replacement requires an explicit user decision. @@ -20,8 +21,9 @@ import { generateCapsuleKey, sealCapsule, openCapsule, MAX_CAPSULE_B64_LENGTH } import { IdentityRecordError } from './errors.js'; import { makePassphraseWrapper, openPassphraseWrapper, + makePrfWrapper, openPrfWrapper, validateCredentialWrapper, - WRAPPER_KIND_PASSPHRASE, + WRAPPER_KIND_PASSPHRASE, WRAPPER_KIND_PRF, } from './credential-wrapper.js'; export const RECORD_FORMAT = 'peerd-identity-record'; @@ -41,9 +43,14 @@ const MAX_WRAPPERS = 8; */ /** - * A wrapper spec for buildIdentityRecord — the credential material to - * enroll, by kind. - * @typedef {{ kind: 'passphrase', passphrase: string }} WrapperSpec + * A wrapper spec for buildIdentityRecord - the credential material to + * enroll, by kind. The PRF output comes from a ceremony at the canonical + * RP (web-identity/), never from an extension-origin credential. + * @typedef {( + * { kind: 'passphrase', passphrase: string } | + * { kind: 'passkey-prf', prfOutput: Uint8Array, + * credentialId?: string | null, transports?: string[] | null } + * )} WrapperSpec */ /** @@ -78,6 +85,8 @@ export const buildIdentityRecord = async ({ material, wrappers, now }) => { for (const spec of wrappers) { if (spec.kind === WRAPPER_KIND_PASSPHRASE) { built.push(await makePassphraseWrapper(capsuleKey, spec.passphrase)); + } else if (spec.kind === WRAPPER_KIND_PRF) { + built.push(await makePrfWrapper(capsuleKey, spec.prfOutput, spec)); } else { throw new IdentityRecordError(`unknown wrapper kind ${/** @type {any} */ (spec).kind}`, 'unknown-wrapper'); } @@ -93,7 +102,7 @@ export const buildIdentityRecord = async ({ material, wrappers, now }) => { }; /** - * Structural validation — cheap, pure, no crypto. Returns an error + * Structural validation - cheap, pure, no crypto. Returns an error * string (for the import notice) or null when the record is usable. * * @param {any} record @@ -122,7 +131,7 @@ export const validateIdentityRecord = (record) => { /** * Open a record with whatever credentials the caller holds. Tries every * wrapper whose kind matches a supplied credential; unknown kinds are - * skipped (forward compatibility — a future record may carry wrapper + * skipped (forward compatibility - a future record may carry wrapper * kinds this build can't open, beside ones it can). * * why the did re-check after decryption: the did in the record is @@ -132,10 +141,10 @@ export const validateIdentityRecord = (record) => { * caller trusts it. * * @param {IdentityRecord} record - * @param {{ passphrase?: string }} credentials + * @param {{ passphrase?: string, prfOutput?: Uint8Array }} credentials * @returns {Promise<{ seed: string, pub: string, did: string }>} */ -export const openIdentityRecord = async (record, { passphrase } = {}) => { +export const openIdentityRecord = async (record, { passphrase, prfOutput } = {}) => { const invalid = validateIdentityRecord(record); if (invalid) throw new IdentityRecordError(`identity record is invalid: ${invalid}`, invalid); /** @type {Error | null} */ @@ -145,6 +154,8 @@ export const openIdentityRecord = async (record, { passphrase } = {}) => { let capsuleKey = null; if (wrapper.kind === WRAPPER_KIND_PASSPHRASE && typeof passphrase === 'string' && passphrase.length > 0) { capsuleKey = await openPassphraseWrapper(wrapper, passphrase); + } else if (wrapper.kind === WRAPPER_KIND_PRF && prfOutput instanceof Uint8Array) { + capsuleKey = await openPrfWrapper(wrapper, prfOutput); } if (!capsuleKey) continue; const material = await openCapsule(record.capsule, capsuleKey); @@ -172,6 +183,9 @@ export const openIdentityRecord = async (record, { passphrase } = {}) => { * @param {Object} args * @param {any} args.record the record arriving from an export payload * @param {string} [args.passphrase] + * @param {Uint8Array} [args.prfOutput] a passkey ceremony's PRF result + * (the id.peerd.ai handoff), usable instead of - or beside - the + * passphrase * @param {string | null} [args.existingMaterial] the receiving install's * current identity secret value (JSON string), or null * @param {boolean} [args.replaceExisting] explicit user-approved replacement @@ -183,14 +197,14 @@ export const openIdentityRecord = async (record, { passphrase } = {}) => { * the record supplied a new identity; null when nothing changes. */ export const adoptIdentityRecord = async ({ - record, passphrase, existingMaterial = null, replaceExisting = false, + record, passphrase, prfOutput, existingMaterial = null, replaceExisting = false, }) => { const invalid = validateIdentityRecord(record); if (invalid) return { adopted: false, did: null, material: null, reason: invalid }; let recovered; try { - recovered = await openIdentityRecord(record, { passphrase }); + recovered = await openIdentityRecord(record, { passphrase, prfOutput }); } catch { return { adopted: false, did: null, material: null, reason: 'no-openable-wrapper' }; } diff --git a/extension/peerd-distributed/index.js b/extension/peerd-distributed/index.js index fba997f1..b102c201 100644 --- a/extension/peerd-distributed/index.js +++ b/extension/peerd-distributed/index.js @@ -1,5 +1,5 @@ // @ts-check -// peerd-distributed — public surface. +// peerd-distributed - public surface. // // The decentralized web (dweb) between separate peerd instances: // identity, transport, content addressing, discovery, messaging. See @@ -11,7 +11,7 @@ // module, files reach for siblings via relative paths. // // PHASE 0 (the V1-launch wedge): two peers exchange a signed dwapp bundle -// over WebRTC with manual paste-code pairing — no DHT, no async +// over WebRTC with manual paste-code pairing - no DHT, no async // messaging, no discovery. The surface grows per ROADMAP phase. Sub-areas // not yet built (identity subkeys, DHT, messaging, curation) live in // docs/distributed and land in later phases. @@ -20,6 +20,15 @@ export { generateIdentity, createPersistentIdentity, importIdentity, verifySignature } from './identity/keypair.js'; export { encodeDidKey, decodeDidKey } from './identity/did.js'; +// --- identity: the id.peerd.ai ceremony handoff -------------------------- +// The extension side of the passkey ceremony (request mint, tab-return +// parsing, response opening) - what the backup/restore UI drives when it +// grows a passkey path. The page side runs a byte-copy of handoff.js. +export { + createHandoffRequest, buildCeremonyUrl, extractSealedResponse, openHandoffResponse, + identityPrfInput, IDENTITY_RP_ID, IDENTITY_RP_ORIGIN, +} from './identity/handoff.js'; + // --- content addressing (peerd://, signed manifests, chunked bundles) ---- export { parsePeerdUri, formatPeerdUri } from './content/uri.js'; export { buildManifest, verifyManifest, manifestHash } from './content/manifest.js'; @@ -47,7 +56,7 @@ export { createPeer, localDescriptionComplete, DEFAULT_ICE_SERVERS } from './tra // --- transport: cold-start rendezvous (signaling) ----------------------- // The pure signaling reducer (shared by the browser client and the server -// shells — Bun host, CF Worker) and the client adapter that turns a room +// shells - Bun host, CF Worker) and the client adapter that turns a room // code into a Channel over the WebRTC transport. export { signalingStep, initialSignalingState, ROOM_CAP, WEBSITE_CAP } from './transport/signaling.js'; export { connectViaSignaling, openRendezvous, DEFAULT_SIGNALING } from './transport/signaling-client.js'; @@ -74,7 +83,7 @@ export { installAppBundle, BundleRejectedError } from './apps/loader.js'; export { createDwebBridge } from './apps/bridge.js'; export { loadSeedApp, COMMONS_SEED } from './apps/seed.js'; -// A2A Agent Card validation/caps — exposed so the offscreen base host (dweb-base.js, +// A2A Agent Card validation/caps - exposed so the offscreen base host (dweb-base.js, // which reaches this module only via loadDweb, never a static import) can enforce // them on the card-set (validate my own card, reject/strip before it hits the mesh) // and card-get (clamp an untrusted peer card before handing it to the actor) paths. diff --git a/packaging/check-tscheck.ts b/packaging/check-tscheck.ts index 6338f1fc..9e8a183d 100644 --- a/packaging/check-tscheck.ts +++ b/packaging/check-tscheck.ts @@ -158,7 +158,7 @@ import { computeCoverage } from './tscheck-coverage.ts'; // route, Options surface, and rendered side-panel coverage. // 673 → 676: the Actor Fabric adds its pure topology model, SW live projection, // and rendered browser contract while replacing the checked async-task bar. -const COVERED_FLOOR = 687; +const COVERED_FLOOR = 688; // The scan (walk + // @ts-check detection + the ES5-injected exemption set) // lives in tscheck-coverage.ts so the badge generator reports the same number. diff --git a/tests/peerd-distributed/identity-handoff.test.ts b/tests/peerd-distributed/identity-handoff.test.ts new file mode 100644 index 00000000..60db692b --- /dev/null +++ b/tests/peerd-distributed/identity-handoff.test.ts @@ -0,0 +1,103 @@ +// The id.peerd.ai ceremony handoff (docs/design/portable-identity/ 04): +// request mint → page-side seal → extension-side open, with the +// challenge/tamper/replay refusals that make the fragment transport +// safe, the PRF-wrapper integration (the response unlocks a record), +// and the byte-equality lock on the page's protocol copy. + +import { describe, test, expect } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { + createHandoffRequest, buildCeremonyUrl, parseCeremonyRequest, + sealHandoffResponse, buildReturnUrl, extractSealedResponse, openHandoffResponse, + IDENTITY_RP_ORIGIN, IDENTITY_RP_ID, +} from '../../extension/peerd-distributed/identity/handoff.js'; +import { buildIdentityRecord, openIdentityRecord } from '../../extension/peerd-distributed/identity/recovery-record.js'; +import { mintKeypairMaterial } from '../../extension/peerd-distributed/identity/keypair.js'; + +const roundTrip = async (flow: 'register' | 'get') => { + const { request, privateKey, challenge } = await createHandoffRequest({ flow }); + // The page parses the request back off the URL the extension opened. + const url = buildCeremonyUrl(IDENTITY_RP_ORIGIN, request); + const parsed = parseCeremonyRequest(new URL(url).hash); + if (!parsed) throw new Error('expected a parsed request'); + // The page runs the ceremony (faked PRF result) and seals it. + const prfOutput = crypto.getRandomValues(new Uint8Array(32)); + const sealed = await sealHandoffResponse({ + request: parsed, prfOutput, credentialId: 'Y3JlZA==', transports: ['internal'], + }); + // The extension pulls it off the watched tab URL and opens it. + const returned = extractSealedResponse(buildReturnUrl(IDENTITY_RP_ORIGIN, sealed)); + if (!returned) throw new Error('expected a sealed response on the return url'); + const opened = await openHandoffResponse({ sealedResponse: returned, privateKey, challenge }); + return { prfOutput, opened, parsed, privateKey, challenge }; +}; + +describe('ceremony handoff', () => { + test('register flow round-trips the PRF output end to end', async () => { + const { prfOutput, opened } = await roundTrip('register'); + expect(Buffer.from(opened.prfOutput).equals(Buffer.from(prfOutput))).toBe(true); + expect(opened.credentialId).toBe('Y3JlZA=='); + expect(opened.transports).toEqual(['internal']); + }); + + test('the RP anchors are the decided constants', () => { + expect(IDENTITY_RP_ID).toBe('peerd.ai'); + expect(new URL(IDENTITY_RP_ORIGIN).hostname.endsWith(IDENTITY_RP_ID)).toBe(true); + }); + + test('a response sealed for one request cannot answer another (challenge binding)', async () => { + const a = await createHandoffRequest({ flow: 'get' }); + const b = await createHandoffRequest({ flow: 'get' }); + const sealed = await sealHandoffResponse({ + request: parseCeremonyRequest(new URL(buildCeremonyUrl(IDENTITY_RP_ORIGIN, a.request)).hash), + prfOutput: new Uint8Array(32).fill(1), + }); + // Wrong ephemeral key + wrong challenge → authentication fails outright. + await expect(openHandoffResponse({ + sealedResponse: sealed, privateKey: b.privateKey, challenge: b.challenge, + })).rejects.toMatchObject({ code: 'open-failed' }); + }); + + test('a tampered ciphertext is refused', async () => { + const { privateKey, challenge, parsed } = await roundTrip('get'); + const sealed = await sealHandoffResponse({ request: parsed, prfOutput: new Uint8Array(32).fill(9) }); + const envelope = JSON.parse(new TextDecoder().decode(Uint8Array.from(atob(sealed), (c) => c.charCodeAt(0)))); + envelope.ct = `${envelope.ct.slice(0, -4)}AAAA`; + const tampered = btoa(JSON.stringify(envelope)); + await expect(openHandoffResponse({ sealedResponse: tampered, privateKey, challenge })) + .rejects.toMatchObject({ code: 'open-failed' }); + }); + + test('oversized or malformed fragments are refused before crypto', async () => { + expect(() => parseCeremonyRequest(`#req=${'A'.repeat(10_000)}`)).toThrow(); + expect(parseCeremonyRequest('#nonsense')).toBeNull(); + expect(extractSealedResponse('not a url')).toBeNull(); + expect(extractSealedResponse(`${IDENTITY_RP_ORIGIN}/#req=abc`)).toBeNull(); + }); + + test('the ceremony PRF output opens a passkey-wrapped record (the recovery path)', async () => { + const material = await mintKeypairMaterial(); + const { prfOutput } = await roundTrip('register'); + const record = await buildIdentityRecord({ + material, + wrappers: [ + { kind: 'passphrase', passphrase: 'paper-backup' }, + { kind: 'passkey-prf', prfOutput, credentialId: 'Y3JlZA==', transports: ['internal'] }, + ], + }); + const viaPasskey = await openIdentityRecord(record, { prfOutput }); + expect(viaPasskey.did).toBe(material.did); + // …and a wrong passphrase still opens nothing on the same record. + await expect(openIdentityRecord(record, { passphrase: 'guess' })).rejects.toThrow(); + }); +}); + +describe('the page copy is the module, byte for byte', () => { + test('web-identity/handoff.js === extension/peerd-distributed/identity/handoff.js', () => { + const root = join(import.meta.dir, '..', '..'); + const module_ = readFileSync(join(root, 'extension/peerd-distributed/identity/handoff.js'), 'utf8'); + const copy = readFileSync(join(root, 'web-identity/handoff.js'), 'utf8'); + expect(copy).toBe(module_); + }); +}); diff --git a/tests/peerd-distributed/identity-prf-vectors.test.ts b/tests/peerd-distributed/identity-prf-vectors.test.ts new file mode 100644 index 00000000..4488c8a4 --- /dev/null +++ b/tests/peerd-distributed/identity-prf-vectors.test.ts @@ -0,0 +1,68 @@ +// FROZEN derivation vectors for the passkey-PRF wrapper - the +// credential-orphaning lock. These constants are permanent protocol +// state: a change to the PRF input tag, the HKDF salt/info, or the +// AES-KW wrap makes every passkey wrapper ever minted unopenable. If +// one of these assertions fails, the change orphans credentials - do +// not update the expected values; revert the derivation. + +import { describe, test, expect } from 'bun:test'; +import { identityPrfInput } from '../../extension/peerd-distributed/identity/handoff.js'; +import { makePrfWrapper, openPrfWrapper, validateCredentialWrapper } from '../../extension/peerd-distributed/identity/credential-wrapper.js'; + +const hex = (bytes: Uint8Array) => Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); + +// SHA-256("peerd.identity.credential.v1") - the input every portable +// credential's PRF is evaluated with, on every machine, forever. +const FROZEN_PRF_INPUT_HEX = '0621b47c1726c9a97a2d213d7122fa68d858d48181c0d60c43ace37fe0cae45b'; + +// makePrfWrapper(CapK = 32×0x24, prfOutput = 32×0x42) - locks the whole +// chain: HKDF(zero salt, info "peerd/capsule-wrapper/v1") → AES-KW KEK → +// deterministic RFC 3394 wrap of the raw CapK bytes. +const FROZEN_WRAPPED_CAPK_B64 = 'ojw8GNfkkOL8asuk5WIkEtS73Vr10LAq7riNVhqr50eXkvruhwRBMA=='; + +describe('frozen passkey-PRF derivation vectors', () => { + test('the PRF input is the frozen 32-byte digest', async () => { + expect(hex(await identityPrfInput())).toBe(FROZEN_PRF_INPUT_HEX); + }); + + test('PRF output → KEK → wrapped CapK matches the frozen vector exactly', async () => { + const capK = await crypto.subtle.importKey( + 'raw', new Uint8Array(32).fill(0x24), { name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt'], + ); + const wrapper = await makePrfWrapper(capK, new Uint8Array(32).fill(0x42)); + expect(wrapper.wrappedKey).toBe(FROZEN_WRAPPED_CAPK_B64); + }); + + test('and the frozen wrapper still opens (the vector is live, not a fossil)', async () => { + const wrapper = { + kind: 'passkey-prf', + wrappedKey: FROZEN_WRAPPED_CAPK_B64, + credentialId: null, + transports: null, + }; + const capK = await openPrfWrapper(wrapper, new Uint8Array(32).fill(0x42)); + expect(capK.type).toBe('secret'); + await expect(openPrfWrapper(wrapper, new Uint8Array(32).fill(0x43))).rejects.toThrow(); + }); +}); + +// Untrusted-wrapper bounds for the new kind - same posture as the +// passphrase wrapper's pinned KDF: a hostile record gets no knobs. +describe('passkey-prf wrapper validation', () => { + const good = { + kind: 'passkey-prf', + wrappedKey: FROZEN_WRAPPED_CAPK_B64, + credentialId: 'Y3JlZA==', + transports: ['internal', 'hybrid'], + }; + + test('a well-formed wrapper passes; hostile shapes are named', () => { + expect(validateCredentialWrapper(good)).toBeNull(); + expect(validateCredentialWrapper({ ...good, kdf: { name: 'Argon2id' } })).toBe('unexpected-kdf'); + expect(validateCredentialWrapper({ ...good, wrappedKey: 'AAAA' })).toBe('bad-wrapped-key'); + expect(validateCredentialWrapper({ ...good, credentialId: 'not base64!!' })).toBe('bad-credential-id'); + expect(validateCredentialWrapper({ ...good, credentialId: 'A'.repeat(4096) })).toBe('bad-credential-id'); + expect(validateCredentialWrapper({ ...good, transports: Array(20).fill('usb') })).toBe('bad-transports'); + expect(validateCredentialWrapper({ ...good, transports: [42] })).toBe('bad-transports'); + }); +}); diff --git a/web-identity/README.md b/web-identity/README.md new file mode 100644 index 00000000..282a0367 --- /dev/null +++ b/web-identity/README.md @@ -0,0 +1,44 @@ +# web-identity - the canonical relying-party ceremony page + +The source for `https://id.peerd.ai`: the ONE origin where portable +peerd-identity passkeys are created and evaluated (RP ID `peerd.ai` - +decided; see `docs/design/portable-identity/04-canonical-rp.md`, which +also records what is frozen and why). + +Three files, no dependencies, no build step: + +- `index.html` - the consent shell (monochrome; a consent surface gets + no accent color). +- `identity-rp.js` - the ceremony driver. A pure PRF oracle: it runs + WebAuthn `create()`/`get()` with the frozen PRF input and returns the + 32-byte PRF output sealed to the requesting extension's ephemeral + key. It never sees a seed, capsule, record, or capsule key, and + stores nothing. +- `handoff.js` - a byte-identical copy of + `extension/peerd-distributed/identity/handoff.js` (the protocol is + single-sourced there; `tests/peerd-distributed/identity-handoff.test.ts` + fails CI if the copies drift). To change the protocol, edit the + extension module and re-copy. + +## Deploying (site repo) + +The peerd.ai site repo vendors this directory verbatim, same as the +other snapshots it carries. Requirements at the host: + +- Serve at the exact origin in `IDENTITY_RP_ORIGIN` (`handoff.js`). + Subdomain moves under `peerd.ai` are safe **until first production + mint**; the RP ID is what credentials bind to. +- Static files only; no third-party scripts, analytics, or fonts - a + compromise of this page at ceremony time is scoped to one PRF output + as ciphertext, and keeping the page dependency-free keeps it that way + (and keeps it auditable). +- Suggested headers: a CSP of `default-src 'none'; script-src 'self'; + style-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'`, + plus `Referrer-Policy: no-referrer`. + +## Local development + +`localhost` is a WebAuthn-blessed dev RP: serve this directory +(`bunx serve web-identity` or any static server) and the page pins +`rpId: 'localhost'`. Dev credentials are scoped to localhost and can +never collide with production `peerd.ai` credentials. diff --git a/web-identity/handoff.js b/web-identity/handoff.js new file mode 100644 index 00000000..bcff74d9 --- /dev/null +++ b/web-identity/handoff.js @@ -0,0 +1,301 @@ +// @ts-check +// peerd-distributed/identity/handoff.js - the extension ↔ id.peerd.ai +// ceremony handoff (docs/design/portable-identity/ 04). +// +// The canonical RP page is a PURE PRF ORACLE: it runs the WebAuthn +// ceremony for the peerd.ai credential and returns the 32-byte PRF +// output - nothing else. It never sees the seed, the capsule, the +// recovery record, or the capsule key; all capsule crypto stays in the +// extension. The one secret that crosses (the PRF output) crosses only +// as AEAD ciphertext bound to a single live request: +// +// extension id.peerd.ai (web-identity/) +// ────────── ─────────────────────────── +// mint ephemeral ECDH P-256 (epk) +// mint 32-byte challenge +// open {origin}/#req=b64(request) ─▶ parse + show consent +// WebAuthn create()/get() with the +// frozen PRF input (credential- +// wrapper.js constants) +// ECDH(pageEph, epk) → HKDF(salt= +// challenge) → AES-GCM key +// read #res=… off the tab URL ◀─ location.replace('#res=b64(env)') +// ECDH-decrypt, check challenge +// derive KEK locally, wrap/unwrap CapK +// +// why fragment + AEAD instead of postMessage: fragments never reach a +// server, and the ciphertext in tab history is useless without the +// ephemeral private key, which lives only in the extension context that +// minted it and dies with the flow. No reliance on cross-scheme +// postMessage targetOrigin semantics, identical on Chrome and Firefox. +// +// SELF-CONTAINED ON PURPOSE: no imports. A byte-identical copy of this +// file ships on the static ceremony page (web-identity/handoff.js), +// where the extension's /shared/ helpers do not exist - CI asserts the +// two copies match (tests/peerd-distributed/identity-handoff.test.ts). + +// FROZEN alongside the PRF constants (see credential-wrapper.js header): +// the RP ID is the anchor every passkey is minted against - changing it +// after first production mint orphans every credential. The ORIGIN may +// move between subdomains of the RP ID before launch; the RP ID may not. +export const IDENTITY_RP_ID = 'peerd.ai'; +export const IDENTITY_RP_ORIGIN = 'https://id.peerd.ai'; + +// The PRF input every portable-identity credential is evaluated with - +// hashing the tag pins the input to exactly 32 bytes on every +// authenticator. Deterministic protocol state, never per-install; FROZEN +// (an input change orphans every passkey wrapper ever minted). +const PRF_INPUT_TAG = 'peerd.identity.credential.v1'; +/** @returns {Promise} */ +export const identityPrfInput = async () => + new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(PRF_INPUT_TAG))); + +export const HANDOFF_VERSION = 1; +const HANDOFF_HKDF_INFO = 'peerd/identity-handoff/v1'; +const CHALLENGE_BYTES = 32; +const IV_BYTES = 12; +const FRAGMENT_MAX = 8192; +const FLOWS = Object.freeze(['register', 'get']); + +export class IdentityHandoffError extends Error { + /** @param {string} message @param {string} code @param {{ cause?: unknown }} [options] */ + constructor(message, code, options = {}) { + super(message, options); + this.name = 'IdentityHandoffError'; + this.code = code; + } +} + +/** @param {Uint8Array} bytes */ +const toB64 = (bytes) => btoa(String.fromCharCode(...bytes)); +/** @param {string} b64 */ +const fromB64 = (b64) => Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)); +const utf8 = (/** @type {string} */ s) => new TextEncoder().encode(s); + +/** @param {Record} value */ +const encodeEnvelope = (value) => toB64(utf8(JSON.stringify(value))); +/** @param {string} b64 @param {string} what */ +const decodeEnvelope = (b64, what) => { + if (typeof b64 !== 'string' || b64.length === 0 || b64.length > FRAGMENT_MAX) { + throw new IdentityHandoffError(`${what} is missing or oversized`, 'bad-envelope'); + } + try { + return JSON.parse(new TextDecoder().decode(fromB64(b64))); + } catch (cause) { + throw new IdentityHandoffError(`${what} is not decodable`, 'bad-envelope', { cause }); + } +}; + +// P-256 everywhere: universal WebCrypto support (X25519 is not there yet +// on every target this page must serve). +const ECDH_PARAMS = Object.freeze({ name: 'ECDH', namedCurve: 'P-256' }); + +/** @param {any} jwk bounded public-key JWK check before importKey sees it */ +const validatePublicJwk = (jwk) => { + if (!jwk || typeof jwk !== 'object') return 'not-an-object'; + if (jwk.kty !== 'EC' || jwk.crv !== 'P-256') return 'wrong-curve'; + if (typeof jwk.x !== 'string' || jwk.x.length === 0 || jwk.x.length > 64) return 'bad-x'; + if (typeof jwk.y !== 'string' || jwk.y.length === 0 || jwk.y.length > 64) return 'bad-y'; + if (jwk.d !== undefined) return 'private-material'; + return null; +}; + +/** @param {any} jwk @param {string} what */ +const importPeerPublicKey = async (jwk, what) => { + const defect = validatePublicJwk(jwk); + if (defect) throw new IdentityHandoffError(`${what} public key rejected: ${defect}`, 'bad-public-key'); + // Strip to exactly the fields a public EC JWK needs - nothing an + // untrusted envelope smuggles alongside survives. + const { kty, crv, x, y } = jwk; + try { + return await crypto.subtle.importKey('jwk', { kty, crv, x, y }, ECDH_PARAMS, false, []); + } catch (cause) { + throw new IdentityHandoffError(`${what} public key is not a valid P-256 point`, 'bad-public-key', { cause }); + } +}; + +/** + * ECDH → HKDF(salt=challenge, info=fixed) → one-shot AES-GCM key. The + * challenge in the salt binds the key to this request; a replayed + * response against a fresh request derives a different key and fails + * authentication. + * + * @param {CryptoKey} privateKey this side's ECDH private key + * @param {CryptoKey} publicKey the peer's imported public key + * @param {Uint8Array} challenge + */ +const deriveHandoffKey = async (privateKey, publicKey, challenge) => { + const shared = await crypto.subtle.deriveBits({ name: 'ECDH', public: publicKey }, privateKey, 256); + const ikm = await crypto.subtle.importKey('raw', shared, 'HKDF', false, ['deriveKey']); + return crypto.subtle.deriveKey( + { + name: 'HKDF', hash: 'SHA-256', + salt: /** @type {BufferSource} */ (challenge), + info: utf8(HANDOFF_HKDF_INFO), + }, + ikm, + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt', 'decrypt'], + ); +}; + +/** + * EXTENSION SIDE - mint a ceremony request. The private key never leaves + * the calling context; hold it in memory for the life of the flow and + * drop it (it is non-extractable, and single-use by contract). + * + * @param {Object} args + * @param {'register' | 'get'} args.flow + * @param {string | null} [args.credentialId] routes a 'get' straight to + * the enrolled credential; omitted → discoverable-credential picker + * @param {string[] | null} [args.transports] + * @returns {Promise<{ request: any, privateKey: CryptoKey, challenge: Uint8Array }>} + */ +export const createHandoffRequest = async ({ flow, credentialId = null, transports = null }) => { + if (!FLOWS.includes(flow)) throw new IdentityHandoffError(`unknown flow ${flow}`, 'bad-flow'); + const keyPair = /** @type {CryptoKeyPair} */ ( + await crypto.subtle.generateKey(ECDH_PARAMS, false, ['deriveBits', 'deriveKey'])); + const challenge = crypto.getRandomValues(new Uint8Array(CHALLENGE_BYTES)); + const epk = await crypto.subtle.exportKey('jwk', keyPair.publicKey); + delete epk.key_ops; + delete epk.ext; + return { + request: { + v: HANDOFF_VERSION, + flow, + challenge: toB64(challenge), + epk, + credentialId, + transports, + }, + privateKey: keyPair.privateKey, + challenge, + }; +}; + +/** + * Both sides - the ceremony URL the extension opens, and the request the + * page parses back out of its own fragment. + * @param {string} origin @param {any} request + */ +export const buildCeremonyUrl = (origin, request) => + `${origin}/#req=${encodeURIComponent(encodeEnvelope(request))}`; + +/** @param {string} fragment location.hash with or without the leading '#' */ +export const parseCeremonyRequest = (fragment) => { + const raw = /** @type {string} */ (fragment ?? '').replace(/^#/, ''); + const match = /^req=(.+)$/.exec(raw); + if (!match) return null; + const request = decodeEnvelope(decodeURIComponent(match[1]), 'ceremony request'); + if (request?.v !== HANDOFF_VERSION) throw new IdentityHandoffError(`unsupported handoff version ${request?.v}`, 'bad-version'); + if (!FLOWS.includes(request.flow)) throw new IdentityHandoffError(`unknown flow ${request.flow}`, 'bad-flow'); + const challenge = (() => { + try { return fromB64(request.challenge); } catch { return new Uint8Array(0); } + })(); + if (challenge.length !== CHALLENGE_BYTES) throw new IdentityHandoffError('bad challenge', 'bad-challenge'); + const epkDefect = validatePublicJwk(request.epk); + if (epkDefect) throw new IdentityHandoffError(`bad request key: ${epkDefect}`, 'bad-public-key'); + if (request.credentialId != null + && (typeof request.credentialId !== 'string' || request.credentialId.length > 2048)) { + throw new IdentityHandoffError('bad credentialId', 'bad-credential-id'); + } + return { ...request, challengeBytes: challenge }; +}; + +/** + * PAGE SIDE - seal the ceremony result to the requesting extension. + * @param {Object} args + * @param {any} args.request the parsed ceremony request + * @param {Uint8Array} args.prfOutput + * @param {string | null} [args.credentialId] + * @param {string[] | null} [args.transports] + * @returns {Promise} the value for `#res=` + */ +export const sealHandoffResponse = async ({ request, prfOutput, credentialId = null, transports = null }) => { + if (!(prfOutput instanceof Uint8Array) || prfOutput.byteLength !== 32) { + throw new IdentityHandoffError('PRF output must be exactly 32 bytes', 'bad-prf-output'); + } + const extensionKey = await importPeerPublicKey(request.epk, 'request'); + const challenge = fromB64(request.challenge); + const pageKeys = /** @type {CryptoKeyPair} */ ( + await crypto.subtle.generateKey(ECDH_PARAMS, false, ['deriveBits', 'deriveKey'])); + const key = await deriveHandoffKey(pageKeys.privateKey, extensionKey, challenge); + const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES)); + const plaintext = utf8(JSON.stringify({ + v: HANDOFF_VERSION, + challenge: request.challenge, + prfOutput: toB64(prfOutput), + credentialId, + transports, + })); + let ct; + try { + ct = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, plaintext)); + } finally { + plaintext.fill(0); + } + const epk = await crypto.subtle.exportKey('jwk', pageKeys.publicKey); + delete epk.key_ops; + delete epk.ext; + return encodeEnvelope({ v: HANDOFF_VERSION, epk, iv: toB64(iv), ct: toB64(ct) }); +}; + +/** The page navigates here when done; the extension watches the tab URL. */ +/** @param {string} origin @param {string} sealedResponse */ +export const buildReturnUrl = (origin, sealedResponse) => + `${origin}/#res=${encodeURIComponent(sealedResponse)}`; + +/** + * EXTENSION SIDE - pull `#res=` off a watched tab URL. Returns null while + * the ceremony is still in progress (no res fragment yet). + * @param {string} url + */ +export const extractSealedResponse = (url) => { + let hash; + try { hash = new URL(url).hash; } catch { return null; } + const match = /^#res=(.+)$/.exec(hash); + return match ? decodeURIComponent(match[1]) : null; +}; + +/** + * EXTENSION SIDE - open the sealed response. Verifies the echoed + * challenge before anything else is trusted. + * + * @param {Object} args + * @param {string} args.sealedResponse + * @param {CryptoKey} args.privateKey from createHandoffRequest + * @param {Uint8Array} args.challenge from createHandoffRequest + * @returns {Promise<{ prfOutput: Uint8Array, credentialId: string | null, transports: string[] | null }>} + */ +export const openHandoffResponse = async ({ sealedResponse, privateKey, challenge }) => { + const envelope = decodeEnvelope(sealedResponse, 'ceremony response'); + if (envelope?.v !== HANDOFF_VERSION) throw new IdentityHandoffError(`unsupported handoff version ${envelope?.v}`, 'bad-version'); + const pageKey = await importPeerPublicKey(envelope.epk, 'response'); + const key = await deriveHandoffKey(privateKey, pageKey, challenge); + let parsed; + try { + const iv = fromB64(envelope.iv); + if (iv.length !== IV_BYTES) throw new Error('bad iv length'); + const plaintext = new Uint8Array( + await crypto.subtle.decrypt({ name: 'AES-GCM', iv, }, key, fromB64(envelope.ct))); + parsed = JSON.parse(new TextDecoder().decode(plaintext)); + plaintext.fill(0); + } catch (cause) { + throw new IdentityHandoffError('ceremony response could not be authenticated', 'open-failed', { cause }); + } + if (parsed?.challenge !== toB64(challenge)) { + throw new IdentityHandoffError('ceremony response answers a different request', 'challenge-mismatch'); + } + const prfOutput = (() => { + try { return fromB64(parsed.prfOutput); } catch { return new Uint8Array(0); } + })(); + if (prfOutput.length !== 32) throw new IdentityHandoffError('response carries no PRF output', 'bad-prf-output'); + return { + prfOutput, + credentialId: typeof parsed.credentialId === 'string' ? parsed.credentialId : null, + transports: Array.isArray(parsed.transports) + ? parsed.transports.filter((/** @type {unknown} */ t) => typeof t === 'string').slice(0, 8) + : null, + }; +}; diff --git a/web-identity/identity-rp.js b/web-identity/identity-rp.js new file mode 100644 index 00000000..c1da5715 --- /dev/null +++ b/web-identity/identity-rp.js @@ -0,0 +1,178 @@ +// @ts-check +// web-identity/identity-rp.js - the canonical relying-party ceremony page. +// +// A PURE PRF ORACLE (docs/design/portable-identity/ 04): parse the +// extension's request off the fragment, take one explicit user gesture, +// run the WebAuthn ceremony for the peerd.ai credential with the frozen +// PRF input, seal the 32-byte PRF output to the request's ephemeral key, +// and navigate to the return fragment. This page never sees a seed, a +// capsule, a recovery record, or a capsule key - compromise of this page +// at ceremony time yields one credential's PRF output as ciphertext +// bound to one live request, not the identity root. +// +// Deployment (site repo vendors this directory verbatim): serve at +// IDENTITY_RP_ORIGIN, static files only, no third-party scripts. The +// origin check below fails closed anywhere else except localhost dev. + +import { + IDENTITY_RP_ID, IDENTITY_RP_ORIGIN, identityPrfInput, + parseCeremonyRequest, sealHandoffResponse, buildReturnUrl, + IdentityHandoffError, +} from './handoff.js'; + +const statusEl = /** @type {HTMLElement} */ (document.getElementById('status')); +const actionEl = /** @type {HTMLButtonElement} */ (document.getElementById('action')); +const detailEl = /** @type {HTMLElement} */ (document.getElementById('detail')); + +/** @param {string} text @param {boolean} [isError] */ +const show = (text, isError = false) => { + statusEl.textContent = text; + statusEl.classList.toggle('err', isError); +}; + +// why localhost is allowed: the ceremony must be testable before (and +// independently of) the production deployment; a localhost RP ID is what +// WebAuthn itself blesses for development. Credentials minted on +// localhost are dev-scoped and can never collide with peerd.ai ones. +const devHost = ['localhost', '127.0.0.1'].includes(location.hostname); +const rpId = devHost ? 'localhost' : IDENTITY_RP_ID; + +/** @param {ArrayBuffer | Uint8Array} bytes */ +const toB64 = (bytes) => btoa(String.fromCharCode(...new Uint8Array(bytes))); +/** @param {string} b64 */ +const fromB64 = (b64) => Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)); + +/** @param {PublicKeyCredential} credential */ +const readTransports = (credential) => { + try { + const response = /** @type {AuthenticatorAttestationResponse} */ (credential.response); + const transports = response?.getTransports?.(); + return Array.isArray(transports) ? transports.slice(0, 8) : null; + } catch { return null; } +}; + +/** @param {any} extensionResults getClientExtensionResults() */ +const prfFrom = (extensionResults) => { + const first = extensionResults?.prf?.results?.first; + return first ? new Uint8Array(first) : null; +}; + +/** @param {any} request */ +const runRegister = async (request) => { + const prfInput = await identityPrfInput(); + const credential = /** @type {PublicKeyCredential | null} */ (await navigator.credentials.create({ + publicKey: { + rp: { id: rpId, name: 'peerd' }, + user: { + id: crypto.getRandomValues(new Uint8Array(16)), + name: 'peerd identity', + displayName: 'peerd identity', + }, + challenge: crypto.getRandomValues(new Uint8Array(32)), + pubKeyCredParams: [ + { type: 'public-key', alg: -8 }, // Ed25519 + { type: 'public-key', alg: -7 }, // ES256 + { type: 'public-key', alg: -257 }, // RS256 + ], + // why discoverable-preferred: a portable identity credential must be + // findable on a fresh machine with zero local state - that is the + // whole point of minting it here rather than in the extension. + authenticatorSelection: { residentKey: 'preferred', userVerification: 'required' }, + timeout: 120_000, + attestation: 'none', + extensions: { prf: { eval: { first: prfInput } } }, + }, + })); + if (!credential) throw new IdentityHandoffError('ceremony was cancelled', 'cancelled'); + const credentialId = toB64(credential.rawId); + const transports = readTransports(credential); + const direct = prfFrom(credential.getClientExtensionResults?.()); + // Same honesty as the vault's enrollment path: some authenticators only + // materialise PRF on a follow-up get(); a still-missing PRF means THIS + // authenticator cannot protect the identity, and enrollment must fail + // rather than hand back a credential that can never unlock anything. + const prfOutput = direct ?? await runGet({ credentialId }, prfInput); + return { prfOutput, credentialId, transports }; +}; + +/** @param {{ credentialId?: string | null }} request @param {Uint8Array} [prfInputBytes] */ +const runGet = async (request, prfInputBytes) => { + const prfInput = prfInputBytes ?? await identityPrfInput(); + const assertion = /** @type {PublicKeyCredential | null} */ (await navigator.credentials.get({ + publicKey: { + rpId, + challenge: crypto.getRandomValues(new Uint8Array(32)), + // With a known credential, route straight to it; without one, the + // discoverable-credential picker is exactly the recovery UX. + allowCredentials: request.credentialId + ? [{ type: 'public-key', id: fromB64(request.credentialId) }] + : [], + userVerification: 'required', + timeout: 120_000, + extensions: { prf: { eval: { first: /** @type {BufferSource} */ (prfInput) } } }, + }, + })); + if (!assertion) throw new IdentityHandoffError('ceremony was cancelled', 'cancelled'); + const prfOutput = prfFrom(assertion.getClientExtensionResults?.()); + if (!prfOutput) { + throw new IdentityHandoffError( + 'This authenticator does not support the PRF extension and cannot protect a peerd identity.', + 'prf-unsupported', + ); + } + return prfOutput; +}; + +const main = () => { + if (!devHost && location.origin !== IDENTITY_RP_ORIGIN) { + show(`This page only operates at ${IDENTITY_RP_ORIGIN}.`, true); + return; + } + let request; + try { + request = parseCeremonyRequest(location.hash); + } catch (e) { + show(`Invalid ceremony request: ${/** @type {{ message?: string }} */ (e)?.message ?? e}`, true); + return; + } + if (!request) { + show('This is the peerd identity ceremony page. Open it from peerd (Settings → Backup) - it does nothing on its own, stores nothing, and never sees your identity key.'); + return; + } + + const verb = request.flow === 'register' ? 'Create a peerd identity passkey' : 'Unlock with your peerd passkey'; + show(`peerd is asking to ${request.flow === 'register' + ? 'protect your identity backup with a new passkey.' + : 'unlock your identity backup with your passkey.'}`); + detailEl.textContent = 'Your identity key never touches this page; the passkey result returns encrypted to the peerd extension that opened it.'; + actionEl.textContent = verb; + actionEl.hidden = false; + + actionEl.addEventListener('click', async () => { + actionEl.disabled = true; + show('Waiting for your authenticator…'); + try { + const result = request.flow === 'register' + ? await runRegister(request) + : { + prfOutput: await runGet(request), + credentialId: request.credentialId ?? null, + transports: request.transports ?? null, + }; + const sealed = await sealHandoffResponse({ request, ...result }); + result.prfOutput.fill(0); + show('Done - returning to peerd. You can close this tab.'); + // why replace (not assign): the request fragment never enters + // history; only the sealed, single-request ciphertext does. + location.replace(buildReturnUrl(location.origin, sealed)); + } catch (e) { + actionEl.disabled = false; + const err = /** @type {{ name?: string, message?: string }} */ (e); + show(err?.name === 'NotAllowedError' + ? 'The ceremony was cancelled or timed out. You can try again.' + : `Ceremony failed: ${err?.message ?? e}`, true); + } + }); +}; + +main(); diff --git a/web-identity/index.html b/web-identity/index.html new file mode 100644 index 00000000..ab393756 --- /dev/null +++ b/web-identity/index.html @@ -0,0 +1,45 @@ + + + + + + + peerd identity + + + + +
+

peerd identity

+

loading…

+

+ +
+ + + From bbe6da4d6206ecb6e1a11590885247e7ba2ffe6a Mon Sep 17 00:00:00 2001 From: NotASithLord <48842926+NotASithLord@users.noreply.github.com> Date: Sat, 8 Aug 2026 22:19:54 +0000 Subject: [PATCH 2/5] fix(identity): address swarm-review findings on the ceremony handoff 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. --- .../portable-identity/04-canonical-rp.md | 18 +++++++-- .../peerd-distributed/identity/handoff.js | 37 +++++++++++++++++-- package.json | 2 +- tsconfig.json | 2 +- web-identity/README.md | 13 +++++-- web-identity/handoff.js | 37 +++++++++++++++++-- web-identity/identity-rp.js | 22 +++++++++-- web-identity/index.html | 14 ++++--- 8 files changed, 121 insertions(+), 24 deletions(-) diff --git a/docs/design/portable-identity/04-canonical-rp.md b/docs/design/portable-identity/04-canonical-rp.md index c846cd10..dd1231a9 100644 --- a/docs/design/portable-identity/04-canonical-rp.md +++ b/docs/design/portable-identity/04-canonical-rp.md @@ -33,9 +33,21 @@ returns the 32-byte PRF output sealed to the request's ephemeral ECDH key - via a fragment redirect the extension watches on the tab. It never sees a seed, capsule, recovery record, or capsule key; all capsule crypto stays in the extension (`credential-wrapper.js`). So the -page compromise blast radius is one credential's PRF output as -ciphertext bound to one live request - not the identity root, and not a -passphrase oracle. +worst case never reaches the identity ROOT. + +Be precise about what page compromise DOES cost, because the hosting +requirements below exist to prevent it. A hostile script on this page +reads the PRF output in PLAINTEXT (the page necessarily has it, to seal +it), and because the PRF input is a frozen protocol constant, that +output is the PERMANENT wrapper-KEK source for that credential - not +scoped to one request. Exfiltration means every passkey wrapper minted +from that credential is attacker-openable given the record, until the +user enrolls a new credential and re-wraps; a compromised page can also +substitute a hostile PRF output. The AEAD sealing does NOT defend +against the page itself - it protects only the return leg from off-page +observers of the tab URL/history. That is the whole reason the page is +static, dependency-free, and CSP-locked, and why an id.peerd.ai +compromise is an incident that forces credential re-enrollment. why fragments + AEAD instead of postMessage: fragments never reach a server, the ciphertext left in tab history is useless without the diff --git a/extension/peerd-distributed/identity/handoff.js b/extension/peerd-distributed/identity/handoff.js index bcff74d9..20425559 100644 --- a/extension/peerd-distributed/identity/handoff.js +++ b/extension/peerd-distributed/identity/handoff.js @@ -127,6 +127,11 @@ const importPeerPublicKey = async (jwk, what) => { const deriveHandoffKey = async (privateKey, publicKey, challenge) => { const shared = await crypto.subtle.deriveBits({ name: 'ECDH', public: publicKey }, privateKey, 256); const ikm = await crypto.subtle.importKey('raw', shared, 'HKDF', false, ['deriveKey']); + // Wipe the raw ECDH secret now that it lives inside the non-extractable + // HKDF handle. Honesty note: the PRF output also travels base64-inside- + // JSON through this module, and JS strings cannot be wiped - buffer + // fills here reduce lifetime, they do not guarantee erasure. + new Uint8Array(shared).fill(0); return crypto.subtle.deriveKey( { name: 'HKDF', hash: 'SHA-256', @@ -182,12 +187,24 @@ export const createHandoffRequest = async ({ flow, credentialId = null, transpor export const buildCeremonyUrl = (origin, request) => `${origin}/#req=${encodeURIComponent(encodeEnvelope(request))}`; +/** + * Guarded percent-decoding: a malformed sequence in a fragment we do not + * control (any page can navigate a watched tab) must surface as this + * module's typed refusal or a calm null, never a bare URIError. + * @param {string} value + */ +const decodePercent = (value) => { + try { return decodeURIComponent(value); } catch { return null; } +}; + /** @param {string} fragment location.hash with or without the leading '#' */ export const parseCeremonyRequest = (fragment) => { const raw = /** @type {string} */ (fragment ?? '').replace(/^#/, ''); const match = /^req=(.+)$/.exec(raw); if (!match) return null; - const request = decodeEnvelope(decodeURIComponent(match[1]), 'ceremony request'); + const decoded = decodePercent(match[1]); + if (decoded === null) throw new IdentityHandoffError('ceremony request is not decodable', 'bad-envelope'); + const request = decodeEnvelope(decoded, 'ceremony request'); if (request?.v !== HANDOFF_VERSION) throw new IdentityHandoffError(`unsupported handoff version ${request?.v}`, 'bad-version'); if (!FLOWS.includes(request.flow)) throw new IdentityHandoffError(`unknown flow ${request.flow}`, 'bad-flow'); const challenge = (() => { @@ -200,7 +217,21 @@ export const parseCeremonyRequest = (fragment) => { && (typeof request.credentialId !== 'string' || request.credentialId.length > 2048)) { throw new IdentityHandoffError('bad credentialId', 'bad-credential-id'); } - return { ...request, challengeBytes: challenge }; + const transports = Array.isArray(request.transports) + ? request.transports.filter((/** @type {unknown} */ t) => typeof t === 'string' && t.length <= 32).slice(0, 8) + : null; + const { kty, crv, x, y } = request.epk; + // Rebuild from the validated fields only: nothing an untrusted fragment + // smuggles alongside survives into the parsed request. + return { + v: request.v, + flow: request.flow, + challenge: request.challenge, + epk: { kty, crv, x, y }, + credentialId: request.credentialId ?? null, + transports, + challengeBytes: challenge, + }; }; /** @@ -255,7 +286,7 @@ export const extractSealedResponse = (url) => { let hash; try { hash = new URL(url).hash; } catch { return null; } const match = /^#res=(.+)$/.exec(hash); - return match ? decodeURIComponent(match[1]) : null; + return match ? decodePercent(match[1]) : null; }; /** diff --git a/package.json b/package.json index 3fc79d3b..de609673 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "eval:actors": "bun scripts/cdp/run-actor-orchestration-eval.mjs", "eval:context": "bun scripts/cdp/run-eval-context.mjs", "typecheck": "tsc", - "lint": "eslint extension web", + "lint": "eslint extension web web-identity", "package": "bun packaging/package.ts", "package:all": "bun packaging/package.ts --all", "package:web": "bun packaging/package-web.ts", diff --git a/tsconfig.json b/tsconfig.json index 6e04bd63..5f1ee4fc 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -57,6 +57,6 @@ // injected-into-page bodies are never annotated, so checkJs:false // already leaves them untouched. web/public (the web target's smoke // shell) rides the same opt-in ratchet as the extension. - "include": ["tests/**/*.ts", "extension/**/*.js", "web/public/**/*.js"], + "include": ["tests/**/*.ts", "extension/**/*.js", "web/public/**/*.js", "web-identity/**/*.js"], "exclude": ["extension/vendor/**"] } diff --git a/web-identity/README.md b/web-identity/README.md index 282a0367..2dff17c5 100644 --- a/web-identity/README.md +++ b/web-identity/README.md @@ -28,10 +28,15 @@ other snapshots it carries. Requirements at the host: - Serve at the exact origin in `IDENTITY_RP_ORIGIN` (`handoff.js`). Subdomain moves under `peerd.ai` are safe **until first production mint**; the RP ID is what credentials bind to. -- Static files only; no third-party scripts, analytics, or fonts - a - compromise of this page at ceremony time is scoped to one PRF output - as ciphertext, and keeping the page dependency-free keeps it that way - (and keeps it auditable). +- Static files only; no third-party scripts, analytics, or fonts. This + is the load-bearing requirement, not a nicety: a hostile script on + this page reads the PRF output in plaintext (the page must, to seal + it), and because the PRF input is a frozen constant that output is the + permanent wrapper-KEK source for the credential - a compromise forces + the user to re-enroll a new credential. The AEAD sealing only protects + the return leg from off-page observers, never against the page itself. + Dependency-free is what keeps this page small enough to audit and + impossible to compromise via a supply chain. - Suggested headers: a CSP of `default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'`, plus `Referrer-Policy: no-referrer`. diff --git a/web-identity/handoff.js b/web-identity/handoff.js index bcff74d9..20425559 100644 --- a/web-identity/handoff.js +++ b/web-identity/handoff.js @@ -127,6 +127,11 @@ const importPeerPublicKey = async (jwk, what) => { const deriveHandoffKey = async (privateKey, publicKey, challenge) => { const shared = await crypto.subtle.deriveBits({ name: 'ECDH', public: publicKey }, privateKey, 256); const ikm = await crypto.subtle.importKey('raw', shared, 'HKDF', false, ['deriveKey']); + // Wipe the raw ECDH secret now that it lives inside the non-extractable + // HKDF handle. Honesty note: the PRF output also travels base64-inside- + // JSON through this module, and JS strings cannot be wiped - buffer + // fills here reduce lifetime, they do not guarantee erasure. + new Uint8Array(shared).fill(0); return crypto.subtle.deriveKey( { name: 'HKDF', hash: 'SHA-256', @@ -182,12 +187,24 @@ export const createHandoffRequest = async ({ flow, credentialId = null, transpor export const buildCeremonyUrl = (origin, request) => `${origin}/#req=${encodeURIComponent(encodeEnvelope(request))}`; +/** + * Guarded percent-decoding: a malformed sequence in a fragment we do not + * control (any page can navigate a watched tab) must surface as this + * module's typed refusal or a calm null, never a bare URIError. + * @param {string} value + */ +const decodePercent = (value) => { + try { return decodeURIComponent(value); } catch { return null; } +}; + /** @param {string} fragment location.hash with or without the leading '#' */ export const parseCeremonyRequest = (fragment) => { const raw = /** @type {string} */ (fragment ?? '').replace(/^#/, ''); const match = /^req=(.+)$/.exec(raw); if (!match) return null; - const request = decodeEnvelope(decodeURIComponent(match[1]), 'ceremony request'); + const decoded = decodePercent(match[1]); + if (decoded === null) throw new IdentityHandoffError('ceremony request is not decodable', 'bad-envelope'); + const request = decodeEnvelope(decoded, 'ceremony request'); if (request?.v !== HANDOFF_VERSION) throw new IdentityHandoffError(`unsupported handoff version ${request?.v}`, 'bad-version'); if (!FLOWS.includes(request.flow)) throw new IdentityHandoffError(`unknown flow ${request.flow}`, 'bad-flow'); const challenge = (() => { @@ -200,7 +217,21 @@ export const parseCeremonyRequest = (fragment) => { && (typeof request.credentialId !== 'string' || request.credentialId.length > 2048)) { throw new IdentityHandoffError('bad credentialId', 'bad-credential-id'); } - return { ...request, challengeBytes: challenge }; + const transports = Array.isArray(request.transports) + ? request.transports.filter((/** @type {unknown} */ t) => typeof t === 'string' && t.length <= 32).slice(0, 8) + : null; + const { kty, crv, x, y } = request.epk; + // Rebuild from the validated fields only: nothing an untrusted fragment + // smuggles alongside survives into the parsed request. + return { + v: request.v, + flow: request.flow, + challenge: request.challenge, + epk: { kty, crv, x, y }, + credentialId: request.credentialId ?? null, + transports, + challengeBytes: challenge, + }; }; /** @@ -255,7 +286,7 @@ export const extractSealedResponse = (url) => { let hash; try { hash = new URL(url).hash; } catch { return null; } const match = /^#res=(.+)$/.exec(hash); - return match ? decodeURIComponent(match[1]) : null; + return match ? decodePercent(match[1]) : null; }; /** diff --git a/web-identity/identity-rp.js b/web-identity/identity-rp.js index c1da5715..713bce23 100644 --- a/web-identity/identity-rp.js +++ b/web-identity/identity-rp.js @@ -6,9 +6,20 @@ // run the WebAuthn ceremony for the peerd.ai credential with the frozen // PRF input, seal the 32-byte PRF output to the request's ephemeral key, // and navigate to the return fragment. This page never sees a seed, a -// capsule, a recovery record, or a capsule key - compromise of this page -// at ceremony time yields one credential's PRF output as ciphertext -// bound to one live request, not the identity root. +// capsule, a recovery record, or a capsule key - so its worst case never +// reaches the identity ROOT. +// +// But be precise about what page compromise DOES cost, because the +// hosting rules below exist to prevent it: a hostile script on this page +// reads the PRF output in PLAINTEXT (the AEAD sealing only protects the +// return leg from off-page observers of the tab URL/history - see +// handoff.js), and because the PRF input is a frozen protocol constant, +// that output is the PERMANENT wrapper KEK source for that credential - +// not scoped to one request. Exfiltration means every passkey wrapper +// ever minted from that credential is attacker-openable given the +// record, until the user enrolls a new credential and re-wraps. A +// compromised page can also substitute a hostile PRF output. That is the +// whole reason this page is static, dependency-free, and CSP-locked. // // Deployment (site repo vendors this directory verbatim): serve at // IDENTITY_RP_ORIGIN, static files only, no third-party scripts. The @@ -80,7 +91,10 @@ const runRegister = async (request) => { authenticatorSelection: { residentKey: 'preferred', userVerification: 'required' }, timeout: 120_000, attestation: 'none', - extensions: { prf: { eval: { first: prfInput } } }, + // why the cast: prfInput is a plain Uint8Array; the DOM PRF input + // type is BufferSource, which the type system widens to admit an + // SAB-backed view this code never produces. + extensions: { prf: { eval: { first: /** @type {BufferSource} */ (prfInput) } } }, }, })); if (!credential) throw new IdentityHandoffError('ceremony was cancelled', 'cancelled'); diff --git a/web-identity/index.html b/web-identity/index.html index ab393756..25da83c5 100644 --- a/web-identity/index.html +++ b/web-identity/index.html @@ -16,11 +16,6 @@ margin: 0; min-height: 100vh; display: grid; place-items: center; padding: 1rem; box-sizing: border-box; } - @media (prefers-color-scheme: dark) { - body { color: #ddd; background: #111; } - main { border-color: #333; } - button { background: #ddd; color: #111; } - } main { max-width: 26rem; border: 1px solid #ccc; border-radius: 6px; padding: 1.5rem; } h1 { font-size: 1rem; margin: 0 0 1rem; letter-spacing: 0.02em; } #status { margin: 0 0 0.75rem; white-space: pre-wrap; } @@ -31,6 +26,15 @@ border: none; border-radius: 4px; background: #222; color: #fafafa; } button:disabled { opacity: 0.5; cursor: default; } + /* why AFTER the base rules: media queries add no specificity, so at + equal specificity source order decides - the dark overrides must + follow the light defaults or they never apply. */ + @media (prefers-color-scheme: dark) { + body { color: #ddd; background: #111; } + main { border-color: #333; } + button { background: #ddd; color: #111; } + .err { color: #ff6b6b; } + } From d3f32b920da679d476d4a2f53640c626715d0ca8 Mon Sep 17 00:00:00 2001 From: NotASithLord <48842926+NotASithLord@users.noreply.github.com> Date: Sun, 9 Aug 2026 06:10:55 +0000 Subject: [PATCH 3/5] test(identity): prove the restored did is the one the mesh joins with 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. --- .../identity-mesh-join.test.ts | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 tests/peerd-distributed/identity-mesh-join.test.ts diff --git a/tests/peerd-distributed/identity-mesh-join.test.ts b/tests/peerd-distributed/identity-mesh-join.test.ts new file mode 100644 index 00000000..af5d20e3 --- /dev/null +++ b/tests/peerd-distributed/identity-mesh-join.test.ts @@ -0,0 +1,116 @@ +// The load-bearing integration for portable identity: the DID that +// backup/restore round-trips MUST be the one the p2p mesh joins with. +// +// Restore and mesh-join meet at exactly one place - the vault secret +// distributed/identity/v1. Restore writes it (adoptIdentityRecord -> +// vault.setSecret), and the offscreen mesh host reads it to join +// (client.identityMaterial = loadIdentityMaterial -> identityFromMaterial +// -> joinBaseNetwork, in offscreen/dweb-base.js). This test drives that +// exact seam over a fake secret store: adopt a recovery record, then load +// it back the way the mesh does, and prove the identity signs a +// HELLO-style payload verifiably under the SAME did. If this breaks, a +// restored identity would fail to authenticate on the mesh - the peer +// would recover its key but not its place in the network. + +import { describe, test, expect } from 'bun:test'; +import { + buildIdentityRecord, adoptIdentityRecord, +} from '../../extension/peerd-distributed/identity/recovery-record.js'; +import { + mintKeypairMaterial, loadIdentityMaterial, identityFromMaterial, verifySignature, +} from '../../extension/peerd-distributed/identity/keypair.js'; + +// A fake vault-secret surface - the same get/setSecret shape the SW's +// identity custody exposes to both the transfer helper and the mesh host. +const fakeSecrets = (seed: Record = {}) => { + const m = new Map(Object.entries(seed)); + return { + getSecret: async (name: string) => m.get(name) ?? null, + setSecret: async (name: string, value: string) => { m.set(name, value); }, + map: m, + }; +}; + +const IDENTITY_SECRET = 'distributed/identity/v1'; + +// Exactly what offscreen/dweb-base.js does to obtain the mesh identity: +// client.identityMaterial(io) === loadIdentityMaterial(io), then +// client.identityFromMaterial(material). Reproduced here so the test +// breaks if that consumption path and the stored shape ever diverge. +const meshIdentityFrom = async (io: ReturnType) => { + const material = await loadIdentityMaterial(io); + return identityFromMaterial(material); +}; + +describe('restored identity joins the mesh as the same did', () => { + test('adopt on a fresh install → the mesh loads and authenticates as that did', async () => { + // A peer's identity, backed up to a passphrase recovery record. + const original = await mintKeypairMaterial(); + const record = await buildIdentityRecord({ + material: { seed: original.seed, pub: original.pub }, + wrappers: [{ kind: 'passphrase', passphrase: 'export-passphrase-xyz' }], + }); + + // Fresh install: empty secret store. Restore writes the recovered + // material to the identity secret, exactly as dwebTransfer.adoptRecord + // does (vault.setSecret(identitySecretName, outcome.material)). + const vault = fakeSecrets(); + const outcome = await adoptIdentityRecord({ + record, passphrase: 'export-passphrase-xyz', existingMaterial: null, + }); + expect(outcome.adopted).toBe(true); + expect(outcome.did).toBe(original.did); + await vault.setSecret(IDENTITY_SECRET, outcome.material as string); + + // Now the mesh host starts and reads that secret to join. It must + // come up as the ORIGINAL did - same place in the network. + const identity = await meshIdentityFrom(vault); + expect(identity.did).toBe(original.did); + + // And it must actually authenticate: the mesh HELLO handshake signs + // with identity.sign and peers verify via verifySignature(did, …). + const hello = new TextEncoder().encode(`peerd-hello:${identity.did}:room:lobby`); + const sig = await identity.sign(hello); + expect(await verifySignature(original.did, sig, hello)).toBe(true); + }); + + test('loading did not fork the identity: the secret is unchanged after a mesh join', async () => { + const original = await mintKeypairMaterial(); + const vault = fakeSecrets({ + [IDENTITY_SECRET]: JSON.stringify({ v: 1, seed: original.seed, pub: original.pub }), + }); + const before = vault.map.get(IDENTITY_SECRET); + const identity = await meshIdentityFrom(vault); + expect(identity.did).toBe(original.did); + // A pre-existing identity must be reused verbatim - loadIdentityMaterial + // must not re-mint and orphan the peer from its network identity. + expect(vault.map.get(IDENTITY_SECRET)).toBe(before); + expect(vault.map.size).toBe(1); + }); + + test('replace on a peer that already has a DIFFERENT did → mesh rejoins as the incoming did', async () => { + // The device is already on the mesh as `local`; the user restores a + // different identity `incoming` with explicit replace approval. + const local = await mintKeypairMaterial(); + const incoming = await mintKeypairMaterial(); + const record = await buildIdentityRecord({ + material: { seed: incoming.seed, pub: incoming.pub }, + wrappers: [{ kind: 'passphrase', passphrase: 'pw-replace-123' }], + }); + const existing = JSON.stringify({ v: 1, seed: local.seed, pub: local.pub }); + const vault = fakeSecrets({ [IDENTITY_SECRET]: existing }); + + const outcome = await adoptIdentityRecord({ + record, passphrase: 'pw-replace-123', existingMaterial: existing, replaceExisting: true, + }); + expect(outcome.adopted).toBe(true); + expect(outcome.did).toBe(incoming.did); + await vault.setSecret(IDENTITY_SECRET, outcome.material as string); + + // The runtime restart (stopIdentityRuntime → write → startIdentityRuntime, + // driven by dwebTransfer) means the NEXT mesh load sees the incoming did. + const identity = await meshIdentityFrom(vault); + expect(identity.did).toBe(incoming.did); + expect(identity.did).not.toBe(local.did); + }); +}); From 29e4806eda479ecff202dbc51bf359d3d6dee11d Mon Sep 17 00:00:00 2001 From: NotASithLord <48842926+NotASithLord@users.noreply.github.com> Date: Sun, 9 Aug 2026 07:38:44 +0000 Subject: [PATCH 4/5] test(dweb): prove a RESTORED identity joins the live mesh as its pre-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). --- .github/workflows/package-and-release.yml | 13 +++++ extension/tests/dweb-twopeer.js | 61 ++++++++++++++++++++++- package.json | 1 + scripts/cdp/run-dweb-twopeer.mjs | 23 ++++++--- 4 files changed, 90 insertions(+), 8 deletions(-) diff --git a/.github/workflows/package-and-release.yml b/.github/workflows/package-and-release.yml index 0fc42b95..a22c4d25 100644 --- a/.github/workflows/package-and-release.yml +++ b/.github/workflows/package-and-release.yml @@ -265,6 +265,19 @@ jobs: sleep 5 done exit 1 + - name: Run the two-peer RESTORED-identity join over CDP + env: + CHROME_PATH: ${{ steps.chrome.outputs.chrome-path }} + # Persistent identity on the live network: alice runs the full + # backup/adopt lifecycle in-page and must mesh as the pre-backup did. + # Same flake retry as the runs above. + run: | + for attempt in 1 2 3; do + bun run test:twopeer:restore && exit 0 + echo "::warning::two-peer restore attempt $attempt failed (headless-WebRTC flake) - retrying" + sleep 5 + done + exit 1 # Additive job — the multi-PROCESS dweb node test (PHASE1-TESTING §A.2), # previously run only by hand. Spawns a relay + N real node processes that diff --git a/extension/tests/dweb-twopeer.js b/extension/tests/dweb-twopeer.js index e16ad632..7e8be13c 100644 --- a/extension/tests/dweb-twopeer.js +++ b/extension/tests/dweb-twopeer.js @@ -18,6 +18,11 @@ import { generateIdentity, joinRoom } from '/peerd-distributed/index.js'; import { createBaseNetwork } from '/peerd-distributed/base-network.js'; +// restore=1 mode: run the full persistent-identity lifecycle before joining. +// Deep imports are fine here (tests/ is exempt from the no-deep-import rule, +// same as the a2a-dispatch import below). +import { loadIdentityMaterial, identityFromMaterial } from '/peerd-distributed/identity/keypair.js'; +import { buildIdentityRecord, adoptIdentityRecord } from '/peerd-distributed/identity/recovery-record.js'; // The PRODUCTION a2a ask/reply correlation core — driven here over the REAL mesh // direct channel so the live two-peer round-trip exercises the same code the // dweb actor's a2a_run uses (envelope tag + request/reply, the did-bound resolve). @@ -33,6 +38,14 @@ const roomId = params.get('room') ?? 'harness'; const url = params.get('url') ?? 'ws://localhost:8799/rendezvous'; const name = params.get('name') ?? 'peer'; const a2aOn = params.get('a2a') === '1'; // add the live ask/reply beat on top of gossip +// restore=1: this peer joins the mesh AS A RESTORED IDENTITY. It simulates the +// whole portable-identity lifecycle in-page (install A mints and exports a +// recovery record; a fresh install B adopts it and loads the identity exactly +// the way the offscreen mesh host does), then joins the live mesh with the +// result. The driver asserts the joined did equals the pre-backup did: the +// proof that a restored identity keeps its PLACE IN THE NETWORK, not just its +// key material. +const restoreOn = params.get('restore') === '1'; // The gossip topic the two peers exchange a hello on — proves the application // layer (gossip flood + dedup) works over the live mesh, not just that a data @@ -52,6 +65,10 @@ let base = null; let myDid = null; /** @type {any} */ let error = null; +// restore mode: the did minted on "install A" before backup. A green run +// requires the joined did to equal it. +/** @type {string | null} */ +let restoredFromDid = null; // a2a live round-trip state (only when a2aOn): did we send an ask and get the // peer's reply back, over the real mesh, via the production correlation core? let askReplied = false; @@ -70,9 +87,47 @@ const render = () => { ].filter(Boolean).join('\n'); }; +// The vault-secret shape both the transfer helper and the mesh host program +// against, reduced to a Map for the in-page lifecycle simulation. +const fakeSecretStore = () => { + /** @type {Map} */ + const secrets = new Map(); + return { + getSecret: async (/** @type {string} */ secretName) => secrets.get(secretName) ?? null, + setSecret: async (/** @type {string} */ secretName, /** @type {string} */ value) => { secrets.set(secretName, value); }, + }; +}; +const IDENTITY_SECRET = 'distributed/identity/v1'; +const RESTORE_PASSPHRASE = 'twopeer-harness-restore-passphrase'; + +// The persistent-identity lifecycle, end to end, yielding the identity the +// mesh will join with: install A mints via the production first-run path and +// exports a recovery record; install B (a fresh empty store) adopts it, +// persists the material under the identity secret, and loads it back through +// loadIdentityMaterial + identityFromMaterial, byte-for-byte the composition +// offscreen/dweb-base.js runs at mesh start. +const restoreIdentity = async () => { + const installA = fakeSecretStore(); + const original = await loadIdentityMaterial(installA); + restoredFromDid = original.did; + const record = await buildIdentityRecord({ + material: { seed: original.seed, pub: original.pub }, + wrappers: [{ kind: 'passphrase', passphrase: RESTORE_PASSPHRASE }], + }); + const outcome = await adoptIdentityRecord({ + record, passphrase: RESTORE_PASSPHRASE, existingMaterial: null, + }); + if (!outcome.adopted || typeof outcome.material !== 'string') { + throw new Error(`restore failed before join: ${outcome.reason}`); + } + const installB = fakeSecretStore(); + await installB.setSecret(IDENTITY_SECRET, outcome.material); + return identityFromMaterial(await loadIdentityMaterial(installB)); +}; + const boot = async () => { try { - const identity = await generateIdentity(); + const identity = restoreOn ? await restoreIdentity() : await generateIdentity(); myDid = identity.did; render(); @@ -175,6 +230,10 @@ const boot = async () => { heard: heardFrom.size, askReplied, askReply, + // restore mode: true only when the mesh identity IS the pre-backup + // did. The driver requires it for the restore-mode pass gate. + restored: restoreOn ? (restoredFromDid !== null && myDid === restoredFromDid) : null, + restoredFrom: restoredFromDid, peers: snap.peers.map((/** @type {any} */ p) => ({ did: p.did, name: p.name, linked: p.linked, path: p.path })), error, }; diff --git a/package.json b/package.json index de609673..670bbd53 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "test:twopeer": "bun scripts/cdp/run-dweb-twopeer.mjs", "test:twopeer:a2a": "A2A=1 bun scripts/cdp/run-dweb-twopeer.mjs", "test:twopeer:conv": "CONV=1 bun scripts/cdp/run-dweb-twopeer.mjs", + "test:twopeer:restore": "RESTORE=1 bun scripts/cdp/run-dweb-twopeer.mjs", "e2e:verify": "bun scripts/cdp/run-e2e-verify.mjs", "test:e2e": "bun scripts/cdp/run-e2e-verify.mjs --functional", "test:e2e:all": "bun scripts/cdp/run-e2e-verify.mjs --functional", diff --git a/scripts/cdp/run-dweb-twopeer.mjs b/scripts/cdp/run-dweb-twopeer.mjs index ed65ed63..42628e71 100644 --- a/scripts/cdp/run-dweb-twopeer.mjs +++ b/scripts/cdp/run-dweb-twopeer.mjs @@ -159,10 +159,15 @@ const main = async () => { // CONV=1 (implies A2A) proves a STANDING conversation: converse opens a thread, // say continues it, the convId survives real WebRTC end to end. const conv = process.env.CONV === '1'; + // RESTORE=1: alice joins as a RESTORED identity: the page runs the full + // backup/adopt lifecycle first and the pass gate requires the did she meshes + // with to equal the pre-backup did. Persistent identity, proven on the live + // network, not just at the values level. + const restore = process.env.RESTORE === '1'; const room = `harness-${Math.random().toString(36).slice(2, 8)}`; - const pageUrl = (who) => `http://localhost:${httpPort}/tests/dweb-twopeer.html` - + `?room=${room}&name=${who}&url=${encodeURIComponent(rendezvous)}${(a2a || conv) ? '&a2a=1' : ''}${conv ? '&conv=1' : ''}`; - const alice = await openPeer(cdpPort, pageUrl('alice')); + const pageUrl = (who, extra = '') => `http://localhost:${httpPort}/tests/dweb-twopeer.html` + + `?room=${room}&name=${who}&url=${encodeURIComponent(rendezvous)}${(a2a || conv) ? '&a2a=1' : ''}${conv ? '&conv=1' : ''}${extra}`; + const alice = await openPeer(cdpPort, pageUrl('alice', restore ? '&restore=1' : '')); const bob = await openPeer(cdpPort, pageUrl('bob')); console.log(`[twopeer] two contexts joining room "${room}"`); @@ -170,16 +175,20 @@ const main = async () => { const reportExpr = 'window.__DWEB__?.ready ? JSON.stringify(window.__DWEB__.report()) : ""'; let a = null; let b = null; const deadline = Date.now() + RESULT_BUDGET_MS; - // A2A mode additionally requires the live ask/reply round-trip on both peers. - const done = (r) => r && r.linked >= 1 && r.heard >= 1 && ((!a2a && !conv) || r.askReplied === true); + // A2A mode additionally requires the live ask/reply round-trip on both peers; + // RESTORE mode additionally requires alice's mesh did to be the restored one. + const done = (r, restoredRequired = false) => r && r.linked >= 1 && r.heard >= 1 + && ((!a2a && !conv) || r.askReplied === true) + && (!restoredRequired || r.restored === true); while (Date.now() < deadline) { const [ja, jb] = await Promise.all([alice.evaluate(reportExpr), bob.evaluate(reportExpr)]); a = ja ? JSON.parse(ja) : a; b = jb ? JSON.parse(jb) : b; if (a?.error || b?.error) break; - if (done(a) && done(b)) { + if (done(a, restore) && done(b)) { const a2aNote = (a2a || conv) ? ` · ${conv ? 'standing conversation' : 'a2a ask/reply'} ✓ (alice⇐"${a.askReply}", bob⇐"${b.askReply}")` : ''; - console.log(`[twopeer] ✅ PASS — alice⇄bob meshed (alice linked ${a.linked}/heard ${a.heard}, bob linked ${b.linked}/heard ${b.heard})${a2aNote}`); + const restoreNote = restore ? ` · restored identity ✓ (alice meshed as pre-backup did …${String(a.did).slice(-8)})` : ''; + console.log(`[twopeer] ✅ PASS - alice⇄bob meshed (alice linked ${a.linked}/heard ${a.heard}, bob linked ${b.linked}/heard ${b.heard})${a2aNote}${restoreNote}`); cleanup(); process.exit(0); } From 20a63ae81a42c6b2b758a71cbb70362b2dec151c Mon Sep 17 00:00:00 2001 From: NotASithLord <48842926+NotASithLord@users.noreply.github.com> Date: Sun, 9 Aug 2026 14:23:37 +0000 Subject: [PATCH 5/5] test: await floating rejects/resolves assertions 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. --- tests/engine-tabs/notebook-tab/notebook-wasi.test.ts | 10 +++++----- tests/peerd-distributed/identity-capsule.test.ts | 8 ++++---- tests/peerd-distributed/recovery-record.test.ts | 4 ++-- tests/peerd-engine/notebook-registry.test.ts | 2 +- tests/peerd-runtime/skills/install.test.ts | 2 +- tests/peerd-runtime/skills/registry.test.ts | 6 +++--- tests/peerd-runtime/transfer/transfer.test.ts | 4 ++-- 7 files changed, 18 insertions(+), 18 deletions(-) diff --git a/tests/engine-tabs/notebook-tab/notebook-wasi.test.ts b/tests/engine-tabs/notebook-tab/notebook-wasi.test.ts index 108808c3..64de6223 100644 --- a/tests/engine-tabs/notebook-tab/notebook-wasi.test.ts +++ b/tests/engine-tabs/notebook-tab/notebook-wasi.test.ts @@ -57,17 +57,17 @@ describe('runWasi', () => { }); test('refuses a module without _start (reactor / empty)', async () => { - expect(runWasi(buildEmptyModule())).rejects.toThrow(WasiRunError); - expect(runWasi(buildEmptyModule())).rejects.toThrow(/command module/); + await expect(runWasi(buildEmptyModule())).rejects.toThrow(WasiRunError); + await expect(runWasi(buildEmptyModule())).rejects.toThrow(/command module/); }); test('refuses a module that wants non-WASI imports, with an actionable message', async () => { - expect(runWasi(buildNonWasiModule())).rejects.toThrow(WasiRunError); - expect(runWasi(buildNonWasiModule())).rejects.toThrow(/WASI preview1/); + await expect(runWasi(buildNonWasiModule())).rejects.toThrow(WasiRunError); + await expect(runWasi(buildNonWasiModule())).rejects.toThrow(/WASI preview1/); }); test('rejects junk bytes at compile', async () => { - expect(runWasi(new Uint8Array([1, 2, 3, 4]))).rejects.toThrow(); + await expect(runWasi(new Uint8Array([1, 2, 3, 4]))).rejects.toThrow(); }); test('the shim debug logger stays OFF (no per-syscall console spam)', async () => { diff --git a/tests/peerd-distributed/identity-capsule.test.ts b/tests/peerd-distributed/identity-capsule.test.ts index bf6aa257..3789efd3 100644 --- a/tests/peerd-distributed/identity-capsule.test.ts +++ b/tests/peerd-distributed/identity-capsule.test.ts @@ -25,9 +25,9 @@ describe('capsule', () => { const material = await mintKeypairMaterial(); const capK = await generateCapsuleKey(); const capsule = await sealCapsule(material, capK); - expect(openCapsule(capsule, await generateCapsuleKey())).rejects.toThrow(); + await expect(openCapsule(capsule, await generateCapsuleKey())).rejects.toThrow(); const tampered = `${capsule.slice(0, -4)}AAAA`; - expect(openCapsule(tampered, capK)).rejects.toThrow(); + await expect(openCapsule(tampered, capK)).rejects.toThrow(); }); }); @@ -47,13 +47,13 @@ describe('credential wrappers', () => { const capK = await generateCapsuleKey(); const wrapper = await makePassphraseWrapper(capK, 'right'); // AES-KW integrity check rejects a wrong KEK deterministically. - expect(openPassphraseWrapper(wrapper, 'wrong')).rejects.toThrow(); + await expect(openPassphraseWrapper(wrapper, 'wrong')).rejects.toThrow(); }); test('untrusted KDF work factors are refused before crypto', async () => { const capK = await generateCapsuleKey(); const wrapper = await makePassphraseWrapper(capK, 'right'); - expect(openPassphraseWrapper({ + await expect(openPassphraseWrapper({ ...wrapper, kdf: { ...wrapper.kdf!, iters: 999_999_999 }, }, 'right')).rejects.toThrow(/unsupported-kdf/); diff --git a/tests/peerd-distributed/recovery-record.test.ts b/tests/peerd-distributed/recovery-record.test.ts index 3d360def..b945737b 100644 --- a/tests/peerd-distributed/recovery-record.test.ts +++ b/tests/peerd-distributed/recovery-record.test.ts @@ -48,7 +48,7 @@ describe('buildIdentityRecord / openIdentityRecord', () => { material, wrappers: [{ kind: 'passphrase', passphrase: 'pw' }], }); const forged = { ...record, did: other.did }; - expect(openIdentityRecord(forged, { passphrase: 'pw' })).rejects.toThrow(/does not match/); + await expect(openIdentityRecord(forged, { passphrase: 'pw' })).rejects.toThrow(/does not match/); }); test('a seed paired with another public key is refused before adoption', async () => { @@ -63,7 +63,7 @@ describe('buildIdentityRecord / openIdentityRecord', () => { wrappers: [await makePassphraseWrapper(capsuleKey, 'pw')], updatedAt: 1, }; - expect(openIdentityRecord(record, { passphrase: 'pw' })).rejects.toThrow(/valid keypair/); + await expect(openIdentityRecord(record, { passphrase: 'pw' })).rejects.toThrow(/valid keypair/); }); test('unknown wrapper kinds are skipped, not fatal (forward compat)', async () => { diff --git a/tests/peerd-engine/notebook-registry.test.ts b/tests/peerd-engine/notebook-registry.test.ts index 4ffd53c3..c0d02b2a 100644 --- a/tests/peerd-engine/notebook-registry.test.ts +++ b/tests/peerd-engine/notebook-registry.test.ts @@ -34,7 +34,7 @@ describe('createNotebookRegistry', () => { test('setDefaultForSession throws for unknown id', async () => { const reg = createNotebookRegistry({ storage: createStorageStub() }); - expect(reg.setDefaultForSession('chat-1', 'notebook-missing')) + await expect(reg.setDefaultForSession('chat-1', 'notebook-missing')) .rejects.toThrow('notebook not found'); }); }); diff --git a/tests/peerd-runtime/skills/install.test.ts b/tests/peerd-runtime/skills/install.test.ts index cf024f63..18fd4368 100644 --- a/tests/peerd-runtime/skills/install.test.ts +++ b/tests/peerd-runtime/skills/install.test.ts @@ -67,7 +67,7 @@ describe('install sources go through the injected webFetch (egress)', () => { test('a denied webFetch surfaces as a clean install failure', async () => { const registry = reg.createSkillRegistry({ store: store.createSkillStore() }); const webFetch = async () => { throw new Error('egress denied: raw.githubusercontent.com'); }; - expect(install.installFromGit({ registry, webFetch }, { url: 'https://github.com/u/r/SKILL.md' })) + await expect(install.installFromGit({ registry, webFetch }, { url: 'https://github.com/u/r/SKILL.md' })) .rejects.toThrow(install.SkillInstallError); }); diff --git a/tests/peerd-runtime/skills/registry.test.ts b/tests/peerd-runtime/skills/registry.test.ts index 54bfa8a9..ba5e98a9 100644 --- a/tests/peerd-runtime/skills/registry.test.ts +++ b/tests/peerd-runtime/skills/registry.test.ts @@ -80,9 +80,9 @@ describe('skill registry — progressive disclosure', () => { test('loadBody throws SkillNotFoundError for unknown / disabled skills', async () => { const r = make(); await r.install(SKILL_A, { source: 'local' }); - expect(r.loadBody('nope')).rejects.toThrow(reg.SkillNotFoundError); + await expect(r.loadBody('nope')).rejects.toThrow(reg.SkillNotFoundError); await r.setEnabled('alpha', false); - expect(r.loadBody('alpha')).rejects.toThrow(reg.SkillNotFoundError); + await expect(r.loadBody('alpha')).rejects.toThrow(reg.SkillNotFoundError); // Disabled skills also drop out of the prompt block. expect(await r.describeForPrompt()).toBe(''); }); @@ -90,7 +90,7 @@ describe('skill registry — progressive disclosure', () => { test('duplicate install throws unless replace is set', async () => { const r = make(); await r.install(SKILL_A, { source: 'local' }); - expect(r.install(SKILL_A, { source: 'local' })).rejects.toThrow(reg.SkillExistsError); + await expect(r.install(SKILL_A, { source: 'local' })).rejects.toThrow(reg.SkillExistsError); await r.install(SKILL_A.replace('Use for A tasks.', 'UPDATED.'), { source: 'local', replace: true }); const list = await r.list(); expect(list).toHaveLength(1); diff --git a/tests/peerd-runtime/transfer/transfer.test.ts b/tests/peerd-runtime/transfer/transfer.test.ts index 3a25a2c3..eb4a9603 100644 --- a/tests/peerd-runtime/transfer/transfer.test.ts +++ b/tests/peerd-runtime/transfer/transfer.test.ts @@ -63,7 +63,7 @@ describe('passphrase crypto', () => { test('wrong passphrase throws ExportPassphraseError', async () => { const box = await encryptWithPassphrase('right', { k: 'v' }); - expect(decryptWithPassphrase('wrong', box)).rejects.toBeInstanceOf(ExportPassphraseError); + await expect(decryptWithPassphrase('wrong', box)).rejects.toBeInstanceOf(ExportPassphraseError); }); test('rejects attacker-controlled KDF parameters before doing crypto', async () => { @@ -106,7 +106,7 @@ describe('buildExport', () => { }); test('refuses secrets without a passphrase', async () => { - expect(buildExport({ + await expect(buildExport({ channel: 'store', storedSettings: {}, providerEndpoints: null, secrets: { anthropic: 'sk-1' }, passphrase: '', memory: null, hooks: [], skills: [], })).rejects.toBeInstanceOf(ExportPassphraseError);