check / check (push) Successful in 1m54s
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
137 lines
5.0 KiB
TypeScript
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);
|
|
});
|
|
});
|