// 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 — they are pinned by the "key // derivation cost" tests, since they are the vault's only defence against an // offline attack on a stolen blob. 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("key derivation cost", () => { // Argon2id's opslimit and memlimit are the whole of the vault's resistance // to an offline attack on a stolen blob, and lowering them breaks nothing // any other test here can see — the suite merely runs faster. So pin them // directly, both to libsodium's INTERACTIVE constants and to the absolute // values those constants must keep meaning. const INTERACTIVE_OPSLIMIT = 2; const INTERACTIVE_MEMLIMIT = 64 * 1024 * 1024; test("the interactive constants still mean 2 passes over 64 MiB", () => { expect(sodium.crypto_pwhash_OPSLIMIT_INTERACTIVE).toBe( INTERACTIVE_OPSLIMIT, ); expect(sodium.crypto_pwhash_MEMLIMIT_INTERACTIVE).toBe( INTERACTIVE_MEMLIMIT, ); // The floor these must never quietly be swapped for: _MIN is one pass // over 8 KiB, an 8192x reduction in memory cost. expect(sodium.crypto_pwhash_OPSLIMIT_MIN).toBeLessThan( INTERACTIVE_OPSLIMIT, ); expect(sodium.crypto_pwhash_MEMLIMIT_MIN).toBeLessThan( INTERACTIVE_MEMLIMIT, ); }); test("a key derived at the interactive parameters opens the vault", () => { // Independent of any spy, and of the module's own code path: derive // the key here from the vault's published salt at the interactive cost // and open its ciphertext directly. A vault whose key came from any // other opslimit, memlimit or Argon2id variant yields a different key // and cannot be opened this way. const key = sodium.crypto_pwhash( sodium.crypto_secretbox_KEYBYTES, PASSWORD, b64decode(vault.salt), INTERACTIVE_OPSLIMIT, INTERACTIVE_MEMLIMIT, sodium.crypto_pwhash_ALG_ARGON2ID13, ); const opened = sodium.crypto_secretbox_open_easy( b64decode(vault.ciphertext), b64decode(vault.nonce), key, ); expect(sodium.to_string(opened)).toBe(SECRET); }); test.each([ [ "encrypt", async () => { await encryptWithPassword(SECRET, PASSWORD); }, ], [ "decrypt", async () => { await decryptWithPassword(vault, PASSWORD); }, ], ])("%s derives exactly one key at the interactive cost", async (_, run) => { const spy = jest.spyOn(sodium, "crypto_pwhash"); try { await run(); expect(spy).toHaveBeenCalledTimes(1); const [keyBytes, , salt, opslimit, memlimit, alg] = spy.mock.calls[0]; expect(keyBytes).toBe(sodium.crypto_secretbox_KEYBYTES); expect(salt).toHaveLength(SALT_BYTES); expect(opslimit).toBe(sodium.crypto_pwhash_OPSLIMIT_INTERACTIVE); expect(memlimit).toBe(sodium.crypto_pwhash_MEMLIMIT_INTERACTIVE); expect(alg).toBe(sodium.crypto_pwhash_ALG_ARGON2ID13); } finally { spy.mockRestore(); } }); }); 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(); } }); });