All checks were successful
check / check (push) Successful in 32s
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.
264 lines
9.0 KiB
JavaScript
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();
|
|
}
|
|
});
|
|
});
|