eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

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.
17import {
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").
33export 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).
39export 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.
52export 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.
61export 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.
65export async function wrapVaultPriv(privB64, kek) {
66 return bytesToB64url(await encryptBytes(kek, b64urlToBytes(privB64)));
67}
68
69export 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.
78export 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").
84export 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.
103export 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.
112export 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.
133export 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.
161export async function assertVaultPasskey(credIdsB64) {
162 const { prf } = await assertVaultPasskeyFull(credIdsB64);
163 return prf;
164}
165
166export 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