Files
AutistMask/tests/vault.test.js
clawbot 16a3d3cefc
All checks were successful
check / check (push) Successful in 32s
test: known-answer coverage for HD derivation and the vault (closes #159)
wallet.js and vault.js — the two modules that hold user funds — had no
derivation or encryption tests. Add them, pinned to published vectors
rather than to whatever the implementation returns today.

wallet.js: hdWalletFromMnemonic, hdWalletFromXprv, deriveAddressFromXpub
and getSignerForAddress are pinned to the standard development recovery
phrase's first three accounts at m/44'/60'/0'/0/n and to the BIP-39
all-zero-entropy phrase's first address; addressFromPrivateKey is pinned
to the published key/address pairs, so the HD path and the bare-key path
must meet at the same address from two directions. isValidMnemonic and
isValidXprv cover bad checksum, wrong word count, wrong key type and
empty/garbage input. The absolute-vs-relative path asymmetry between
hdWalletFromMnemonic and hdWalletFromXprv is proven harmless: for the
same master key both reach the same xpub and the same addresses.

vault.js: round trip (including non-ASCII and an empty password), wrong
password rejected as a rejected promise with no partial plaintext,
tampered ciphertext / auth tag / nonce / salt rejected, truncated and
spliced blobs rejected, missing fields rejected, fresh salt and nonce per
encryption, the documented { salt, nonce, ciphertext } shape, and no
trace of the plaintext or password anywhere in the serialized blob. The
production Argon2id parameters are not weakened; the tamper cases share
one encrypted fixture to stay inside script/test's 30-second budget.

One test is skipped: isValidXprv accepts an extended private key with a
one-character typo, because ethers skips base58 checksum verification for
the usual 82-byte payload. That is a defect to be filed separately, not
fixed here; the skipped test asserts the correct behaviour and names the
reason.

Suite: 211 passed, 1 skipped, 7.2s inside the container build.
2026-08-11 12:28:20 +00:00

264 lines
9.0 KiB
JavaScript

// 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();
}
});
});