Files
quak/test/crypto/kdf.test.ts
sneak 676d42c5eb Phase 2 red: crypto primitive tests and stub modules
Tests for the entire crypto/ public surface, written against the API
shape declared in the README. The accompanying src/crypto/ modules are
stubs that throw 'not implemented' so the test files compile and tests
fail with clear errors rather than module-not-found.

Tests cover:
  * init() resolves and is idempotent
  * fromBase64 / toBase64 / toBase64URL round-trips, including URL-safe
    input with stripped padding (the form Ente uses for auth tokens)
  * deriveKEK matches sodium.crypto_pwhash with Argon2id parameters
  * deriveLoginSubkey matches sodium.crypto_kdf_derive_from_key with
    subkey id 1 and ctx 'loginctx', truncated to 16 bytes
  * decryptBox round-trips, rejects tampering, wrong key, wrong nonce
  * decryptSealed round-trips, rejects wrong keypair and tampering
  * Secretstream pull decrypts multi-chunk streams in order, exposes
    per-chunk tags, rejects tampering, wrong key, and out-of-order chunks
  * Constants STREAM_CHUNK_SIZE (4 MiB) and STREAM_CHUNK_OVERHEAD (17)

Tests are commented to serve as the canonical API documentation per the
README development workflow policy. Verified: 29 tests fail (red), 3
trivial constant tests pass; lint and fmt-check are green.

eslint.config.mjs is updated to honour the leading-underscore convention
for intentionally unused parameters (the stubs).
2026-05-09 12:43:52 -07:00

137 lines
4.9 KiB
TypeScript

/**
* Tests for `crypto.deriveKEK` and `crypto.deriveLoginSubkey`.
*
* These two functions implement the password-side of Ente's authentication
* flow:
*
* 1. The user types a password.
* 2. `deriveKEK` runs Argon2id over the password using a server-issued
* 16-byte salt and server-issued mem/ops parameters. The result is a
* 32-byte Key Encryption Key (KEK).
* 3. `deriveLoginSubkey` runs libsodium's BLAKE2b-based KDF over the KEK
* with a fixed subkey id and context, takes the first 16 bytes, and
* uses that as the password input to SRP-6a. The user's password
* itself never touches the network.
*
* Both derivations must match the upstream Ente implementations bit for
* bit. If they drift, the SRP handshake fails and the user cannot log in.
* The tests below pin the exact algorithms and parameters.
*/
import sodium from "libsodium-wrappers-sumo";
import { beforeAll, describe, expect, it } from "vitest";
import { deriveKEK, deriveLoginSubkey, init } from "../../src/crypto/index.js";
describe("crypto.deriveKEK (Argon2id)", () => {
beforeAll(async () => {
await init();
await sodium.ready;
});
/**
* Cheap parameters used so the test suite stays under the 30-second
* budget. The real production parameters Ente uses are larger
* (memLimit up to 1 GiB, opsLimit 3-16). The algorithm is the same
* regardless of parameters.
*/
const TEST_OPS = 2;
const TEST_MEM = 64 * 1024 * 1024; // 64 MiB
it("matches sodium.crypto_pwhash with Argon2id, 32-byte output", async () => {
// The contract: deriveKEK is exactly
// crypto_pwhash(32, password, salt, opsLimit, memLimit, ARGON2ID13)
// No wrapper-side normalisation, no parameter mangling, nothing
// implicit. We compute the expected value with sodium directly and
// assert byte equality.
const password = "correct horse battery staple";
const salt = new Uint8Array(sodium.crypto_pwhash_SALTBYTES);
salt.fill(0x42); // any salt works, we just need it to be deterministic
const expected = sodium.crypto_pwhash(
32,
password,
salt,
TEST_OPS,
TEST_MEM,
sodium.crypto_pwhash_ALG_ARGON2ID13,
);
const got = await deriveKEK(password, salt, TEST_OPS, TEST_MEM);
expect(got).toEqual(expected);
expect(got.length).toBe(32);
});
it("produces different keys for different passwords with the same salt", async () => {
const salt = new Uint8Array(sodium.crypto_pwhash_SALTBYTES);
salt.fill(0x01);
const a = await deriveKEK("alpha", salt, TEST_OPS, TEST_MEM);
const b = await deriveKEK("beta", salt, TEST_OPS, TEST_MEM);
expect(a).not.toEqual(b);
});
it("produces different keys for the same password with different salts", async () => {
const password = "same password";
const saltA = new Uint8Array(sodium.crypto_pwhash_SALTBYTES);
saltA.fill(0x01);
const saltB = new Uint8Array(sodium.crypto_pwhash_SALTBYTES);
saltB.fill(0x02);
const a = await deriveKEK(password, saltA, TEST_OPS, TEST_MEM);
const b = await deriveKEK(password, saltB, TEST_OPS, TEST_MEM);
expect(a).not.toEqual(b);
});
});
describe("crypto.deriveLoginSubkey", () => {
beforeAll(async () => {
await init();
await sodium.ready;
});
/**
* The exact derivation Ente uses, taken from the web client and the Go
* CLI:
*
* subkey = crypto_kdf_derive_from_key(
* outputLen = 32,
* subkeyId = 1,
* context = "loginctx",
* key = kek,
* ).slice(0, 16)
*
* The 32-byte output is truncated to 16 bytes; that 16-byte value is
* the password input to SRP-6a. Any deviation in subkey id, context,
* or truncation length breaks login.
*/
it("derives 16 bytes via KDF subkey 1, ctx 'loginctx'", () => {
const kek = new Uint8Array(32);
for (let i = 0; i < 32; i++) kek[i] = i; // 0x00..0x1f
const expected32 = sodium.crypto_kdf_derive_from_key(
32,
1,
"loginctx",
kek,
);
const expected16 = expected32.slice(0, 16);
const got = deriveLoginSubkey(kek);
expect(got.length).toBe(16);
expect(got).toEqual(expected16);
});
it("returns a different subkey for a different KEK", () => {
const kekA = new Uint8Array(32).fill(0x11);
const kekB = new Uint8Array(32).fill(0x22);
const a = deriveLoginSubkey(kekA);
const b = deriveLoginSubkey(kekB);
expect(a).not.toEqual(b);
});
it("is deterministic for a given KEK", () => {
const kek = new Uint8Array(32).fill(0x37);
const a = deriveLoginSubkey(kek);
const b = deriveLoginSubkey(kek);
expect(a).toEqual(b);
});
});