Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .github/workflows/package-and-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion docs/design/portable-identity/02-recovery-record.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
74 changes: 66 additions & 8 deletions docs/design/portable-identity/04-canonical-rp.md
Original file line number Diff line number Diff line change
@@ -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.
137 changes: 124 additions & 13 deletions extension/peerd-distributed/identity/credential-wrapper.js
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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'],
Expand Down Expand Up @@ -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
*/
Expand All @@ -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<CredentialWrapper>}
*/
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
Expand Down Expand Up @@ -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;
Expand All @@ -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;
};
3 changes: 3 additions & 0 deletions extension/peerd-distributed/identity/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Loading