// The Vault lock layer's crypto (docs/locked-vaults.md). A locked vault holds a // dedicated ECDH P-256 "vault keypair": every value is ECIES-sealed to the // vault PUBLIC key (so adding a secret never needs an unlock — encrypting to a // public key is free), while the PRIVATE half exists only inside keyring // "wraps", each openable solely by a biometric-gated KEK — a passkey's WebAuthn // PRF output in the browser (exactly home's kekFromPRF pattern), or a Touch // ID-guarded keychain key in the eleven CLI. A signed-in session alone (thread // key in hand, no biometric) can read names and the audit trail, and can add — // but never read a value. // // Everything here reuses crypto.js primitives — sealForPub/openSealed both for // the values AND for handing the vault key to a member (sealed to their roster // key) — so the wire shapes stay the ones the Go sidecar and home already speak // (ephPub(65)||iv(12)||ct, b64url iv||ct). Pure functions only; the two // WebAuthn ceremonies touch `navigator` strictly inside their bodies so this // module stays importable (and the pure parts testable) in Node. import { b64urlToBytes, bytesToB64url, encryptBytes, decryptBytes, generateUserKeypair, sealForPub, openSealed, } from "./crypto.js"; // ---- PRF -> KEK (the browser end) ------------------------------------------ // The PRF eval input is a fixed public constant — the secrecy lives in the // passkey (the authenticator mixes it with a per-credential secret). Versioned // so a future KDF change can request both and migrate (same shape as home's // "eleven-home-kek-v1"). export function prfEvalInput() { return new TextEncoder().encode("eleven-vault-kek-v1"); } // kekFromPRF derives the AES-GCM key-encryption key from a PRF output // (ArrayBuffer/Uint8Array from getClientExtensionResults().prf.results.first). export async function kekFromPRF(prfOutput) { const ikm = await crypto.subtle.importKey("raw", prfOutput, "HKDF", false, ["deriveKey"]); return crypto.subtle.deriveKey( { name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info: new TextEncoder().encode("eleven-vault-kek") }, ikm, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"] ); } // importKEK turns 32 raw KEK bytes into an AES-GCM key — the CLI/keychain path // (the Go side holds the same 32 bytes in the login keychain behind an // in-process LocalAuthentication gate; see eleven/vaultkek_darwin.go — not an // enclave-bound ACL). Also what tests use in place of a real authenticator's // PRF output. export async function importKEK(rawBytes) { return crypto.subtle.importKey("raw", rawBytes, "AES-GCM", false, ["encrypt", "decrypt"]); } // ---- the vault keypair ------------------------------------------------------- // generateVaultKeypair mints the vault's ECDH P-256 pair: { publicKey (raw // point b64url), privateKey (pkcs8 b64url) } — the same shapes as a user // keypair, so every existing wrap/seal helper applies unchanged. export const generateVaultKeypair = generateUserKeypair; // wrapVaultPriv/unwrapVaultPriv: the private key's pkcs8 bytes under a KEK, as // b64url(iv||ct). One of these per keyring wrap. export async function wrapVaultPriv(privB64, kek) { return bytesToB64url(await encryptBytes(kek, b64urlToBytes(privB64))); } export async function unwrapVaultPriv(wrappedB64, kek) { return bytesToB64url(await decryptBytes(kek, b64urlToBytes(wrappedB64))); } // ---- sealing values ---------------------------------------------------------- // sealValue ECIES-seals one secret value to the vault public key. The value // rides inside a tiny JSON envelope so future fields (e.g. a content type for // file items) can join without a format break. export async function sealValue(vaultPubB64, value) { return sealForPub(vaultPubB64, { v: value }); } // unsealValue recovers the plaintext value with the vault private key. Throws // on a blob not sealed to this vault (surfaced by the caller as "can't read"). export async function unsealValue(sealedB64, vaultPrivB64) { const obj = await openSealed(sealedB64, vaultPrivB64); if (typeof obj.v !== "string") throw new Error("bad sealed value"); return obj.v; } // ---- the handoff (handing the vault key to a member by membership) ----------- // Access follows THREAD MEMBERSHIP now, not a request/grant dance // (docs/locked-vaults.md). When a vault is locked, the locker seals the vault // PRIVATE key to each other member's ROSTER public key — a `member` handoff — // so every member is entitled by being in the thread. A member opens their // handoff with their own session private key (no biometric — it's sealed to // their identity key), then re-wraps the vault key under their OWN passkey / // keychain (self-enrol) and the handoff is dropped. An already-unlocked insider // can also hand a newcomer a fresh handoff (vault.give). // // It's the same ECIES as a value seal (sealForPub/openSealed); the envelope // just carries key bytes under `k` (vs a value's `v`), so the two never confuse. export async function sealVaultKeyTo(memberPubB64, vaultPrivB64) { return sealForPub(memberPubB64, { k: vaultPrivB64 }); } // openVaultHandoff recovers the vault private key from a handoff sealed to me, // using my session (roster) private key. Throws on a blob not sealed to me, or // one that isn't a handoff. The web opens handoffs through the host bridge // (shell.js holds the session key); this is the pure helper the CLI and tests // use directly. export async function openVaultHandoff(sealedB64, memberPrivB64) { const obj = await openSealed(sealedB64, memberPrivB64); if (typeof obj.k !== "string") throw new Error("bad handoff"); return obj.k; } // ---- WebAuthn ceremonies (browser only) -------------------------------------- // // Both are FULLY client-side: the challenge is local randomness because nothing // is being authenticated to a server — the ceremony exists only to make the // authenticator evaluate PRF (user verification = the biometric). No WebAuthn // endpoints, no attestation, nothing stored server-side. Mirrors home's // backup-core.js, including the authenticators that only evaluate PRF during an // assertion (not at create) — enrolment verifies PRF END-TO-END before the // caller posts any wrap, so a no-PRF authenticator fails here, loudly, never // after a wrap is already in the keyring (the 1Password-on-macOS lesson). // registerVaultPasskey creates a resident passkey with PRF for this vault and // returns { credId (b64url), prf (Uint8Array) }. Throws Error("no-prf") when // the authenticator can't do PRF — callers show "try another password manager / // browser" copy. `label` becomes the passkey's user.name in the picker. export async function registerVaultPasskey(label) { const cred = await navigator.credentials.create({ publicKey: { rp: { name: "Vault", id: location.hostname }, user: { id: crypto.getRandomValues(new Uint8Array(16)), name: label || "Vault key", displayName: label || "Vault key", }, challenge: crypto.getRandomValues(new Uint8Array(32)), pubKeyCredParams: [{ type: "public-key", alg: -7 }, { type: "public-key", alg: -257 }], authenticatorSelection: { residentKey: "required", userVerification: "required" }, extensions: { prf: { eval: { first: prfEvalInput() } } }, }, }); const ext = cred.getClientExtensionResults(); if (!ext.prf?.enabled && !ext.prf?.results) throw new Error("no-prf"); const credId = bytesToB64url(new Uint8Array(cred.rawId)); // Some authenticators evaluate PRF at create; the rest only in an assertion. let prf = ext.prf?.results?.first; if (!prf) prf = await assertVaultPasskey([credId]); return { credId, prf: new Uint8Array(prf) }; } // assertVaultPasskey runs one assertion against the keyring's enrolled // credentials and returns the PRF output (Uint8Array) — one biometric prompt. // Also returns which credential answered via the out-param shape when the // caller needs to pick the matching wrap: use assertVaultPasskeyFull. export async function assertVaultPasskey(credIdsB64) { const { prf } = await assertVaultPasskeyFull(credIdsB64); return prf; } export async function assertVaultPasskeyFull(credIdsB64) { const cred = await navigator.credentials.get({ publicKey: { challenge: crypto.getRandomValues(new Uint8Array(32)), allowCredentials: (credIdsB64 || []).map((id) => ({ type: "public-key", id: b64urlToBytes(id) })), userVerification: "required", extensions: { prf: { eval: { first: prfEvalInput() } } }, }, }); const prf = cred.getClientExtensionResults().prf?.results?.first; if (!prf) throw new Error("no-prf"); return { prf: new Uint8Array(prf), credId: bytesToB64url(new Uint8Array(cred.rawId)) }; }