// Tests for src/shared/vault.js: the Argon2id + XSalsa20-Poly1305 encryption // that protects recovery phrases and private keys at rest. // // The properties that matter here are the ones whose failure is silent. A // vault that decrypts under the wrong password, that hands back plaintext from // a ciphertext an attacker edited, that reuses a nonce, or that leaves the // recovery phrase readable somewhere in the stored blob all look exactly like // a working vault from the UI. So each test below asserts a negative: the // thing that must not happen. // // Cost: every encrypt and decrypt runs one Argon2id pwhash at the production // interactive parameters, which the module hardcodes. The parameters are not // weakened or overridden anywhere in this file; the suite is kept inside // script/test's 30-second budget by sharing one encrypted fixture across the // tamper cases instead of re-encrypting per test. const sodium = require("libsodium-wrappers-sumo"); const { encryptWithPassword, decryptWithPassword, } = require("../src/shared/vault"); // A publicly known development phrase. Never fund it. const SECRET = "test test test test test test test test test test test junk"; const PASSWORD = "correct horse battery staple"; const WRONG_PASSWORD = "correct horse battery stapl"; const SALT_BYTES = 16; const NONCE_BYTES = 24; const POLY1305_TAG_BYTES = 16; const BASE64 = /^[A-Za-z0-9+/_-]+={0,2}$/; function b64decode(s) { return sodium.from_base64(s); } // A shallow copy with one field replaced, so the shared fixture is never // mutated by a tamper test. function withField(blob, field, value) { return { ...blob, [field]: value }; } // Flip the low bit of one byte of a base64-encoded field. function flipByte(b64, index) { const bytes = b64decode(b64); bytes[index] ^= 0x01; return sodium.to_base64(bytes); } let vault; beforeAll(async () => { await sodium.ready; vault = await encryptWithPassword(SECRET, PASSWORD); }); describe("stored blob shape", () => { test("is exactly the documented { salt, nonce, ciphertext }", () => { expect(Object.keys(vault).sort()).toEqual([ "ciphertext", "nonce", "salt", ]); }); test("every field is a base64 string", () => { for (const field of ["salt", "nonce", "ciphertext"]) { expect(typeof vault[field]).toBe("string"); expect(vault[field]).toMatch(BASE64); } }); test("salt and nonce are full length", () => { expect(b64decode(vault.salt)).toHaveLength(SALT_BYTES); expect(b64decode(vault.nonce)).toHaveLength(NONCE_BYTES); }); test("ciphertext carries a Poly1305 authentication tag", () => { expect(b64decode(vault.ciphertext)).toHaveLength( SECRET.length + POLY1305_TAG_BYTES, ); }); test("the blob survives JSON storage unchanged", async () => { const stored = JSON.parse(JSON.stringify(vault)); await expect(decryptWithPassword(stored, PASSWORD)).resolves.toBe( SECRET, ); }); }); describe("no plaintext leakage", () => { test("the secret does not appear in the serialized vault", () => { const serialized = JSON.stringify(vault); expect(serialized).not.toContain(SECRET); for (const word of new Set(SECRET.split(" "))) { expect(serialized).not.toContain(word); } }); test("the ciphertext bytes do not contain the secret bytes", () => { const bytes = Buffer.from(b64decode(vault.ciphertext)); expect(bytes.includes(Buffer.from(SECRET, "utf8"))).toBe(false); // Not even the first word, which would betray an unencrypted prefix. expect(bytes.includes(Buffer.from("test test", "utf8"))).toBe(false); }); test("the password does not appear in the serialized vault", () => { expect(JSON.stringify(vault)).not.toContain(PASSWORD); }); }); describe("round trip", () => { test("decrypts back to the original secret", async () => { await expect(decryptWithPassword(vault, PASSWORD)).resolves.toBe( SECRET, ); }); test("survives a non-ASCII plaintext byte for byte", async () => { const unicode = "recovery phrase é中文\u{1f600}"; const blob = await encryptWithPassword(unicode, PASSWORD); await expect(decryptWithPassword(blob, PASSWORD)).resolves.toBe( unicode, ); }); test("an empty password still round-trips and is not a bypass", async () => { const blob = await encryptWithPassword(SECRET, ""); await expect(decryptWithPassword(blob, "")).resolves.toBe(SECRET); // An empty password must not act as a skeleton key on other vaults, // nor may a real password open an empty-password vault. await expect(decryptWithPassword(vault, "")).rejects.toThrow(); await expect(decryptWithPassword(blob, PASSWORD)).rejects.toThrow(); }); }); describe("fresh salt and nonce", () => { test("two encryptions of the same plaintext differ in all three fields", async () => { const second = await encryptWithPassword(SECRET, PASSWORD); expect(second.salt).not.toBe(vault.salt); expect(second.nonce).not.toBe(vault.nonce); expect(second.ciphertext).not.toBe(vault.ciphertext); await expect(decryptWithPassword(second, PASSWORD)).resolves.toBe( SECRET, ); }); }); describe("wrong password", () => { test("is rejected, and rejects cleanly", async () => { // rejects.toThrow asserts a rejected promise, not a synchronous throw // and not an unhandled rejection: the caller can catch this. await expect( decryptWithPassword(vault, WRONG_PASSWORD), ).rejects.toThrow(); }); test("returns no plaintext, not even partially", async () => { const result = await decryptWithPassword(vault, WRONG_PASSWORD).catch( (err) => err, ); expect(result).toBeInstanceOf(Error); expect(String(result)).not.toContain("test"); }); test("the empty password is rejected on a password-protected vault", async () => { await expect(decryptWithPassword(vault, "")).rejects.toThrow(); }); }); describe("tampering", () => { test("a flipped ciphertext bit is rejected by the auth tag", async () => { const tampered = withField( vault, "ciphertext", flipByte(vault.ciphertext, 0), ); await expect(decryptWithPassword(tampered, PASSWORD)).rejects.toThrow(); }); test("a flipped bit in the authentication tag itself is rejected", async () => { const tagStart = b64decode(vault.ciphertext).length - 1; const tampered = withField( vault, "ciphertext", flipByte(vault.ciphertext, tagStart), ); await expect(decryptWithPassword(tampered, PASSWORD)).rejects.toThrow(); }); test("a flipped nonce bit is rejected", async () => { const tampered = withField(vault, "nonce", flipByte(vault.nonce, 0)); await expect(decryptWithPassword(tampered, PASSWORD)).rejects.toThrow(); }); test("a flipped salt bit is rejected", async () => { const tampered = withField(vault, "salt", flipByte(vault.salt, 0)); await expect(decryptWithPassword(tampered, PASSWORD)).rejects.toThrow(); }); test("a truncated ciphertext is rejected", async () => { const bytes = b64decode(vault.ciphertext); const tampered = withField( vault, "ciphertext", sodium.to_base64(bytes.slice(0, bytes.length - 4)), ); await expect(decryptWithPassword(tampered, PASSWORD)).rejects.toThrow(); }); test("a ciphertext shorter than the auth tag is rejected", async () => { const tampered = withField( vault, "ciphertext", sodium.to_base64(b64decode(vault.ciphertext).slice(0, 4)), ); await expect(decryptWithPassword(tampered, PASSWORD)).rejects.toThrow(); }); test("a truncated nonce is rejected", async () => { const tampered = withField( vault, "nonce", sodium.to_base64(b64decode(vault.nonce).slice(0, NONCE_BYTES - 1)), ); await expect(decryptWithPassword(tampered, PASSWORD)).rejects.toThrow(); }); test("a ciphertext from another vault is rejected", async () => { const other = await encryptWithPassword("a different secret", PASSWORD); const spliced = withField(vault, "ciphertext", other.ciphertext); await expect(decryptWithPassword(spliced, PASSWORD)).rejects.toThrow(); }); test("a missing field is rejected rather than decrypted", async () => { for (const field of ["salt", "nonce", "ciphertext"]) { const broken = { ...vault }; delete broken[field]; await expect( decryptWithPassword(broken, PASSWORD), ).rejects.toThrow(); } }); });