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/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..dd1231a9 100644 --- a/docs/design/portable-identity/04-canonical-rp.md +++ b/docs/design/portable-identity/04-canonical-rp.md @@ -1,16 +1,74 @@ # 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 +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 +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..20425559 --- /dev/null +++ b/extension/peerd-distributed/identity/handoff.js @@ -0,0 +1,332 @@ +// @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']); + // 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', + 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))}`; + +/** + * 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 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 = (() => { + 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'); + } + 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, + }; +}; + +/** + * 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 ? decodePercent(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/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 3fc79d3b..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", @@ -38,7 +39,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/packaging/check-tscheck.ts b/packaging/check-tscheck.ts index 81431fc3..225477b5 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 = 698; +const COVERED_FLOOR = 699; // 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/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); } 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-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); + }); +}); 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/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 new file mode 100644 index 00000000..2dff17c5 --- /dev/null +++ b/web-identity/README.md @@ -0,0 +1,49 @@ +# 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. 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`. + +## 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..20425559 --- /dev/null +++ b/web-identity/handoff.js @@ -0,0 +1,332 @@ +// @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']); + // 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', + 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))}`; + +/** + * 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 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 = (() => { + 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'); + } + 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, + }; +}; + +/** + * 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 ? decodePercent(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..713bce23 --- /dev/null +++ b/web-identity/identity-rp.js @@ -0,0 +1,192 @@ +// @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 - 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 +// 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', + // 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'); + 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..25da83c5 --- /dev/null +++ b/web-identity/index.html @@ -0,0 +1,49 @@ + + + + + + + peerd identity + + + + +
+

peerd identity

+

loading…

+

+ +
+ + +