Files
quak/test/crypto/kdf.test.ts
clawbot cb61582ae6
check / check (push) Successful in 1m45s
Make lint and test phases of the Dockerfile (closes #96)
Follows the template: Dockerfile.lint is gone; the Dockerfile has a lint
phase (eslint, prettier --check .) and a test phase (vitest, run as the
node user, which the not-writable-directory tests need), and its
last stage compiles and depends on both. script/lint and script/test
build one phase each with --no-cache; script/docker and script/cibuild
pass --no-cache, so CHECK_EPOCH and LINT_EPOCH are removed.
script/cibuild is the single image build, so CI runs lint and the tests
once each. The tests that checked the old layout are deleted,
REPO_POLICIES.md is re-copied and the README describes the new layout.

Model: opus-5-5
2026-09-23 07:18:46 +02:00

137 lines
5.0 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 90-second
* `timeout` in the `test` phase of the `Dockerfile`. 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);
});
});