| 1 | // Tests for the vault lock layer's crypto (web/static/vaultcrypto.js), run |
| 2 | // in Node (Node 22 has Web Crypto). node test/vaultcrypto.test.mjs |
| 3 | // |
| 4 | // The WebAuthn ceremonies need an authenticator, so — like home's crypto tests — |
| 5 | // PRF outputs here are synthetic 32-byte secrets: kekFromPRF only ever sees the |
| 6 | // bytes, so the pipeline from PRF output to unsealed value is exercised end to |
| 7 | // end, with only the authenticator itself stubbed out. |
| 8 | import assert from "node:assert/strict"; |
| 9 | import { |
| 10 | prfEvalInput, |
| 11 | kekFromPRF, |
| 12 | importKEK, |
| 13 | generateVaultKeypair, |
| 14 | wrapVaultPriv, |
| 15 | unwrapVaultPriv, |
| 16 | sealValue, |
| 17 | unsealValue, |
| 18 | sealVaultKeyTo, |
| 19 | openVaultHandoff, |
| 20 | } from "../web/static/vaultcrypto.js"; |
| 21 | |
| 22 | let failures = 0; |
| 23 | async function test(name, fn) { |
| 24 | try { |
| 25 | await fn(); |
| 26 | console.log("PASS " + name); |
| 27 | } catch (e) { |
| 28 | console.log("FAIL " + name + " — " + (e && e.message)); |
| 29 | failures++; |
| 30 | } |
| 31 | } |
| 32 | |
| 33 | const prf = () => crypto.getRandomValues(new Uint8Array(32)); |
| 34 | |
| 35 | await test("prfEvalInput is the fixed versioned constant", () => { |
| 36 | assert.equal(new TextDecoder().decode(prfEvalInput()), "eleven-vault-kek-v1"); |
| 37 | }); |
| 38 | |
| 39 | await test("kekFromPRF is deterministic per PRF output", async () => { |
| 40 | const out = prf(); |
| 41 | const kek1 = await kekFromPRF(out); |
| 42 | const kek2 = await kekFromPRF(out); |
| 43 | const kp = await generateVaultKeypair(); |
| 44 | // Same PRF bytes → interchangeable KEKs (wrap with one, unwrap with the other). |
| 45 | const wrapped = await wrapVaultPriv(kp.privateKey, kek1); |
| 46 | assert.equal(await unwrapVaultPriv(wrapped, kek2), kp.privateKey); |
| 47 | }); |
| 48 | |
| 49 | await test("seal to the vault public key → unseal with the private key (incl. unicode + '=' values)", async () => { |
| 50 | const kp = await generateVaultKeypair(); |
| 51 | for (const v of ["hunter2", "pässwörd 🌍", "BASE64==", "KEY=VALUE\nOTHER=x", " leading and trailing "]) { |
| 52 | const sealed = await sealValue(kp.publicKey, v); |
| 53 | assert.equal(await unsealValue(sealed, kp.privateKey), v); |
| 54 | } |
| 55 | }); |
| 56 | |
| 57 | await test("unseal with the wrong private key throws (never a wrong value)", async () => { |
| 58 | const kp = await generateVaultKeypair(); |
| 59 | const other = await generateVaultKeypair(); |
| 60 | const sealed = await sealValue(kp.publicKey, "secret"); |
| 61 | await assert.rejects(() => unsealValue(sealed, other.privateKey)); |
| 62 | }); |
| 63 | |
| 64 | await test("wrapVaultPriv under one KEK never opens under another", async () => { |
| 65 | const kp = await generateVaultKeypair(); |
| 66 | const wrapped = await wrapVaultPriv(kp.privateKey, await kekFromPRF(prf())); |
| 67 | await assert.rejects(async () => unwrapVaultPriv(wrapped, await kekFromPRF(prf()))); |
| 68 | }); |
| 69 | |
| 70 | await test("importKEK: raw 32 bytes (the CLI keychain shape) drive the same wrap format", async () => { |
| 71 | const kp = await generateVaultKeypair(); |
| 72 | const raw = crypto.getRandomValues(new Uint8Array(32)); |
| 73 | const wrapped = await wrapVaultPriv(kp.privateKey, await importKEK(raw)); |
| 74 | assert.equal(await unwrapVaultPriv(wrapped, await importKEK(raw)), kp.privateKey); |
| 75 | }); |
| 76 | |
| 77 | await test("the membership handoff: lock hands the key to a member's roster key → they self-enrol → both unseal", async () => { |
| 78 | // Paul locks: vault keypair, his wrap under his (synthetic) PRF KEK. |
| 79 | const paulKek = await kekFromPRF(prf()); |
| 80 | const vault = await generateVaultKeypair(); |
| 81 | const paulWrap = await wrapVaultPriv(vault.privateKey, paulKek); |
| 82 | const sealed = await sealValue(vault.publicKey, "LCHAT_ADMIN_TOKEN=abc123"); |
| 83 | |
| 84 | // The handoff: Paul seals the vault PRIVATE key to James's ROSTER public key |
| 85 | // (this is the `member` wrap's `wrapped`, ridden by vault.lock's handoffs / a |
| 86 | // vault.give). James holds a normal user keypair — his identity, not a vault key. |
| 87 | const james = await generateVaultKeypair(); |
| 88 | const handoff = await sealVaultKeyTo(james.publicKey, vault.privateKey); |
| 89 | |
| 90 | // James self-enrols: open the handoff with HIS session private key (no |
| 91 | // biometric — it's sealed to his identity), then wrap the vault key under his |
| 92 | // OWN passkey KEK. From here on he needs his passkey. |
| 93 | const vaultPrivForJames = await openVaultHandoff(handoff, james.privateKey); |
| 94 | const jamesKek = await kekFromPRF(prf()); |
| 95 | const jamesWrap = await wrapVaultPriv(vaultPrivForJames, jamesKek); |
| 96 | |
| 97 | // Both members now unseal the same value through their own biometric-gated wrap. |
| 98 | assert.equal(await unsealValue(sealed, await unwrapVaultPriv(paulWrap, paulKek)), "LCHAT_ADMIN_TOKEN=abc123"); |
| 99 | assert.equal(await unsealValue(sealed, await unwrapVaultPriv(jamesWrap, jamesKek)), "LCHAT_ADMIN_TOKEN=abc123"); |
| 100 | // …and a third party (not the handoff's recipient) can't open it. |
| 101 | const stranger = await generateVaultKeypair(); |
| 102 | await assert.rejects(() => openVaultHandoff(handoff, stranger.privateKey)); |
| 103 | }); |
| 104 | |
| 105 | console.log(failures === 0 ? "\nALL PASSED" : `\n${failures} FAILED`); |
| 106 | process.exit(failures === 0 ? 0 : 1); |
| 107 | |