| 1 | // The Vault lock layer's crypto (docs/locked-vaults.md). A locked vault holds a |
| 2 | // dedicated ECDH P-256 "vault keypair": every value is ECIES-sealed to the |
| 3 | // vault PUBLIC key (so adding a secret never needs an unlock — encrypting to a |
| 4 | // public key is free), while the PRIVATE half exists only inside keyring |
| 5 | // "wraps", each openable solely by a biometric-gated KEK — a passkey's WebAuthn |
| 6 | // PRF output in the browser (exactly home's kekFromPRF pattern), or a Touch |
| 7 | // ID-guarded keychain key in the eleven CLI. A signed-in session alone (thread |
| 8 | // key in hand, no biometric) can read names and the audit trail, and can add — |
| 9 | // but never read a value. |
| 10 | // |
| 11 | // Everything here reuses crypto.js primitives — sealForPub/openSealed both for |
| 12 | // the values AND for handing the vault key to a member (sealed to their roster |
| 13 | // key) — so the wire shapes stay the ones the Go sidecar and home already speak |
| 14 | // (ephPub(65)||iv(12)||ct, b64url iv||ct). Pure functions only; the two |
| 15 | // WebAuthn ceremonies touch `navigator` strictly inside their bodies so this |
| 16 | // module stays importable (and the pure parts testable) in Node. |
| 17 | import { |
| 18 | b64urlToBytes, |
| 19 | bytesToB64url, |
| 20 | encryptBytes, |
| 21 | decryptBytes, |
| 22 | generateUserKeypair, |
| 23 | sealForPub, |
| 24 | openSealed, |
| 25 | } from "./crypto.js"; |
| 26 | |
| 27 | // ---- PRF -> KEK (the browser end) ------------------------------------------ |
| 28 | |
| 29 | // The PRF eval input is a fixed public constant — the secrecy lives in the |
| 30 | // passkey (the authenticator mixes it with a per-credential secret). Versioned |
| 31 | // so a future KDF change can request both and migrate (same shape as home's |
| 32 | // "eleven-home-kek-v1"). |
| 33 | export function prfEvalInput() { |
| 34 | return new TextEncoder().encode("eleven-vault-kek-v1"); |
| 35 | } |
| 36 | |
| 37 | // kekFromPRF derives the AES-GCM key-encryption key from a PRF output |
| 38 | // (ArrayBuffer/Uint8Array from getClientExtensionResults().prf.results.first). |
| 39 | export async function kekFromPRF(prfOutput) { |
| 40 | const ikm = await crypto.subtle.importKey("raw", prfOutput, "HKDF", false, ["deriveKey"]); |
| 41 | return crypto.subtle.deriveKey( |
| 42 | { name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info: new TextEncoder().encode("eleven-vault-kek") }, |
| 43 | ikm, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"] |
| 44 | ); |
| 45 | } |
| 46 | |
| 47 | // importKEK turns 32 raw KEK bytes into an AES-GCM key — the CLI/keychain path |
| 48 | // (the Go side holds the same 32 bytes in the login keychain behind an |
| 49 | // in-process LocalAuthentication gate; see eleven/vaultkek_darwin.go — not an |
| 50 | // enclave-bound ACL). Also what tests use in place of a real authenticator's |
| 51 | // PRF output. |
| 52 | export async function importKEK(rawBytes) { |
| 53 | return crypto.subtle.importKey("raw", rawBytes, "AES-GCM", false, ["encrypt", "decrypt"]); |
| 54 | } |
| 55 | |
| 56 | // ---- the vault keypair ------------------------------------------------------- |
| 57 | |
| 58 | // generateVaultKeypair mints the vault's ECDH P-256 pair: { publicKey (raw |
| 59 | // point b64url), privateKey (pkcs8 b64url) } — the same shapes as a user |
| 60 | // keypair, so every existing wrap/seal helper applies unchanged. |
| 61 | export const generateVaultKeypair = generateUserKeypair; |
| 62 | |
| 63 | // wrapVaultPriv/unwrapVaultPriv: the private key's pkcs8 bytes under a KEK, as |
| 64 | // b64url(iv||ct). One of these per keyring wrap. |
| 65 | export async function wrapVaultPriv(privB64, kek) { |
| 66 | return bytesToB64url(await encryptBytes(kek, b64urlToBytes(privB64))); |
| 67 | } |
| 68 | |
| 69 | export async function unwrapVaultPriv(wrappedB64, kek) { |
| 70 | return bytesToB64url(await decryptBytes(kek, b64urlToBytes(wrappedB64))); |
| 71 | } |
| 72 | |
| 73 | // ---- sealing values ---------------------------------------------------------- |
| 74 | |
| 75 | // sealValue ECIES-seals one secret value to the vault public key. The value |
| 76 | // rides inside a tiny JSON envelope so future fields (e.g. a content type for |
| 77 | // file items) can join without a format break. |
| 78 | export async function sealValue(vaultPubB64, value) { |
| 79 | return sealForPub(vaultPubB64, { v: value }); |
| 80 | } |
| 81 | |
| 82 | // unsealValue recovers the plaintext value with the vault private key. Throws |
| 83 | // on a blob not sealed to this vault (surfaced by the caller as "can't read"). |
| 84 | export async function unsealValue(sealedB64, vaultPrivB64) { |
| 85 | const obj = await openSealed(sealedB64, vaultPrivB64); |
| 86 | if (typeof obj.v !== "string") throw new Error("bad sealed value"); |
| 87 | return obj.v; |
| 88 | } |
| 89 | |
| 90 | // ---- the handoff (handing the vault key to a member by membership) ----------- |
| 91 | |
| 92 | // Access follows THREAD MEMBERSHIP now, not a request/grant dance |
| 93 | // (docs/locked-vaults.md). When a vault is locked, the locker seals the vault |
| 94 | // PRIVATE key to each other member's ROSTER public key — a `member` handoff — |
| 95 | // so every member is entitled by being in the thread. A member opens their |
| 96 | // handoff with their own session private key (no biometric — it's sealed to |
| 97 | // their identity key), then re-wraps the vault key under their OWN passkey / |
| 98 | // keychain (self-enrol) and the handoff is dropped. An already-unlocked insider |
| 99 | // can also hand a newcomer a fresh handoff (vault.give). |
| 100 | // |
| 101 | // It's the same ECIES as a value seal (sealForPub/openSealed); the envelope |
| 102 | // just carries key bytes under `k` (vs a value's `v`), so the two never confuse. |
| 103 | export async function sealVaultKeyTo(memberPubB64, vaultPrivB64) { |
| 104 | return sealForPub(memberPubB64, { k: vaultPrivB64 }); |
| 105 | } |
| 106 | |
| 107 | // openVaultHandoff recovers the vault private key from a handoff sealed to me, |
| 108 | // using my session (roster) private key. Throws on a blob not sealed to me, or |
| 109 | // one that isn't a handoff. The web opens handoffs through the host bridge |
| 110 | // (shell.js holds the session key); this is the pure helper the CLI and tests |
| 111 | // use directly. |
| 112 | export async function openVaultHandoff(sealedB64, memberPrivB64) { |
| 113 | const obj = await openSealed(sealedB64, memberPrivB64); |
| 114 | if (typeof obj.k !== "string") throw new Error("bad handoff"); |
| 115 | return obj.k; |
| 116 | } |
| 117 | |
| 118 | // ---- WebAuthn ceremonies (browser only) -------------------------------------- |
| 119 | // |
| 120 | // Both are FULLY client-side: the challenge is local randomness because nothing |
| 121 | // is being authenticated to a server — the ceremony exists only to make the |
| 122 | // authenticator evaluate PRF (user verification = the biometric). No WebAuthn |
| 123 | // endpoints, no attestation, nothing stored server-side. Mirrors home's |
| 124 | // backup-core.js, including the authenticators that only evaluate PRF during an |
| 125 | // assertion (not at create) — enrolment verifies PRF END-TO-END before the |
| 126 | // caller posts any wrap, so a no-PRF authenticator fails here, loudly, never |
| 127 | // after a wrap is already in the keyring (the 1Password-on-macOS lesson). |
| 128 | |
| 129 | // registerVaultPasskey creates a resident passkey with PRF for this vault and |
| 130 | // returns { credId (b64url), prf (Uint8Array) }. Throws Error("no-prf") when |
| 131 | // the authenticator can't do PRF — callers show "try another password manager / |
| 132 | // browser" copy. `label` becomes the passkey's user.name in the picker. |
| 133 | export async function registerVaultPasskey(label) { |
| 134 | const cred = await navigator.credentials.create({ |
| 135 | publicKey: { |
| 136 | rp: { name: "Vault", id: location.hostname }, |
| 137 | user: { |
| 138 | id: crypto.getRandomValues(new Uint8Array(16)), |
| 139 | name: label || "Vault key", |
| 140 | displayName: label || "Vault key", |
| 141 | }, |
| 142 | challenge: crypto.getRandomValues(new Uint8Array(32)), |
| 143 | pubKeyCredParams: [{ type: "public-key", alg: -7 }, { type: "public-key", alg: -257 }], |
| 144 | authenticatorSelection: { residentKey: "required", userVerification: "required" }, |
| 145 | extensions: { prf: { eval: { first: prfEvalInput() } } }, |
| 146 | }, |
| 147 | }); |
| 148 | const ext = cred.getClientExtensionResults(); |
| 149 | if (!ext.prf?.enabled && !ext.prf?.results) throw new Error("no-prf"); |
| 150 | const credId = bytesToB64url(new Uint8Array(cred.rawId)); |
| 151 | // Some authenticators evaluate PRF at create; the rest only in an assertion. |
| 152 | let prf = ext.prf?.results?.first; |
| 153 | if (!prf) prf = await assertVaultPasskey([credId]); |
| 154 | return { credId, prf: new Uint8Array(prf) }; |
| 155 | } |
| 156 | |
| 157 | // assertVaultPasskey runs one assertion against the keyring's enrolled |
| 158 | // credentials and returns the PRF output (Uint8Array) — one biometric prompt. |
| 159 | // Also returns which credential answered via the out-param shape when the |
| 160 | // caller needs to pick the matching wrap: use assertVaultPasskeyFull. |
| 161 | export async function assertVaultPasskey(credIdsB64) { |
| 162 | const { prf } = await assertVaultPasskeyFull(credIdsB64); |
| 163 | return prf; |
| 164 | } |
| 165 | |
| 166 | export async function assertVaultPasskeyFull(credIdsB64) { |
| 167 | const cred = await navigator.credentials.get({ |
| 168 | publicKey: { |
| 169 | challenge: crypto.getRandomValues(new Uint8Array(32)), |
| 170 | allowCredentials: (credIdsB64 || []).map((id) => ({ type: "public-key", id: b64urlToBytes(id) })), |
| 171 | userVerification: "required", |
| 172 | extensions: { prf: { eval: { first: prfEvalInput() } } }, |
| 173 | }, |
| 174 | }); |
| 175 | const prf = cred.getClientExtensionResults().prf?.results?.first; |
| 176 | if (!prf) throw new Error("no-prf"); |
| 177 | return { prf: new Uint8Array(prf), credId: bytesToB64url(new Uint8Array(cred.rawId)) }; |
| 178 | } |
| 179 | |