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).
This commit is contained in:
2026-05-09 12:43:52 -07:00
parent 64a3ace33a
commit 676d42c5eb
12 changed files with 695 additions and 0 deletions

160
test/crypto/box.test.ts Normal file
View File

@@ -0,0 +1,160 @@
/**
* Tests for `crypto.decryptBox` and `crypto.decryptSealed`.
*
* These cover the two asymmetric-and-symmetric "box" primitives quack uses
* to unwrap key material from Ente:
*
* - `decryptBox`: secretbox decryption. Used everywhere a small payload
* is sealed under a single shared key. Specifically:
*
* master key = decryptBox(encryptedKey, keyDecryptionNonce, kek)
* secret key = decryptBox(encryptedSecretKey, secretKeyDecryptionNonce, masterKey)
* collection key = decryptBox(coll.encryptedKey, coll.keyDecryptionNonce, masterKey)
* file key = decryptBox(file.encryptedKey, file.keyDecryptionNonce, collectionKey)
* file metadata = decryptBox(metadata.encryptedData, metadata.decryptionHeader, fileKey)
*
* - `decryptSealed`: anonymous sealed-box decryption. Used exactly once,
* to recover the auth token returned by login:
*
* authToken = decryptSealed(encryptedToken, publicKey, secretKey)
*
* Encryption is server-side; quack only ever decrypts.
*/
import sodium from "libsodium-wrappers-sumo";
import { beforeAll, describe, expect, it } from "vitest";
import { decryptBox, decryptSealed, init } from "../../src/crypto/index.js";
describe("crypto.decryptBox (XSalsa20-Poly1305 secretbox)", () => {
beforeAll(async () => {
await init();
await sodium.ready;
});
it("decrypts ciphertext produced by sodium.crypto_secretbox_easy", () => {
const key = sodium.crypto_secretbox_keygen();
const nonce = sodium.randombytes_buf(
sodium.crypto_secretbox_NONCEBYTES,
);
const plaintext = new TextEncoder().encode("hello, ente");
const ciphertext = sodium.crypto_secretbox_easy(plaintext, nonce, key);
const got = decryptBox(ciphertext, nonce, key);
expect(new TextDecoder().decode(got)).toBe("hello, ente");
});
it("decrypts a zero-byte plaintext", () => {
// Edge case: zero-length plaintext still produces a 16-byte
// Poly1305 tag, so the ciphertext is 16 bytes and decryption must
// succeed.
const key = sodium.crypto_secretbox_keygen();
const nonce = sodium.randombytes_buf(
sodium.crypto_secretbox_NONCEBYTES,
);
const ciphertext = sodium.crypto_secretbox_easy(
new Uint8Array(0),
nonce,
key,
);
const got = decryptBox(ciphertext, nonce, key);
expect(got.length).toBe(0);
});
it("throws when the ciphertext has been tampered with", () => {
// Authentication is the whole point. A single-bit flip must
// reject. If this test ever passes silently, the wrapper has lost
// the Poly1305 check and we have a security regression.
const key = sodium.crypto_secretbox_keygen();
const nonce = sodium.randombytes_buf(
sodium.crypto_secretbox_NONCEBYTES,
);
const ciphertext = sodium.crypto_secretbox_easy(
new Uint8Array([1, 2, 3, 4, 5]),
nonce,
key,
);
ciphertext[0] = ciphertext[0]! ^ 0x01;
expect(() => decryptBox(ciphertext, nonce, key)).toThrow();
});
it("throws when the wrong key is supplied", () => {
const key = sodium.crypto_secretbox_keygen();
const wrongKey = sodium.crypto_secretbox_keygen();
const nonce = sodium.randombytes_buf(
sodium.crypto_secretbox_NONCEBYTES,
);
const ciphertext = sodium.crypto_secretbox_easy(
new Uint8Array([9, 9, 9]),
nonce,
key,
);
expect(() => decryptBox(ciphertext, nonce, wrongKey)).toThrow();
});
it("throws when the nonce is wrong", () => {
const key = sodium.crypto_secretbox_keygen();
const nonce = sodium.randombytes_buf(
sodium.crypto_secretbox_NONCEBYTES,
);
const wrongNonce = sodium.randombytes_buf(
sodium.crypto_secretbox_NONCEBYTES,
);
const ciphertext = sodium.crypto_secretbox_easy(
new Uint8Array([1, 2, 3]),
nonce,
key,
);
expect(() => decryptBox(ciphertext, wrongNonce, key)).toThrow();
});
});
describe("crypto.decryptSealed (anonymous box)", () => {
beforeAll(async () => {
await init();
await sodium.ready;
});
/**
* Sealed-box (`crypto_box_seal`) is anonymous public-key encryption: a
* sender encrypts to a recipient public key without authenticating its
* own identity. The recipient decrypts using both halves of its own
* X25519 keypair.
*
* Ente's server uses this to deliver the auth token after login: the
* server seals the token to the user's published public key. The user
* recovers the secret key from a secretbox under the master key (see
* decryptBox above), then opens the sealed token.
*/
it("decrypts a sealed box produced by sodium.crypto_box_seal", () => {
const kp = sodium.crypto_box_keypair();
const message = new TextEncoder().encode("auth-token-payload");
const sealed = sodium.crypto_box_seal(message, kp.publicKey);
const got = decryptSealed(sealed, kp.publicKey, kp.privateKey);
expect(new TextDecoder().decode(got)).toBe("auth-token-payload");
});
it("throws when given the wrong keypair", () => {
const kp = sodium.crypto_box_keypair();
const otherKp = sodium.crypto_box_keypair();
const sealed = sodium.crypto_box_seal(
new Uint8Array([1, 2, 3]),
kp.publicKey,
);
expect(() =>
decryptSealed(sealed, otherKp.publicKey, otherKp.privateKey),
).toThrow();
});
it("throws when the ciphertext has been tampered with", () => {
const kp = sodium.crypto_box_keypair();
const sealed = sodium.crypto_box_seal(
new Uint8Array([1, 2, 3]),
kp.publicKey,
);
sealed[sealed.length - 1] = sealed[sealed.length - 1]! ^ 0x01;
expect(() =>
decryptSealed(sealed, kp.publicKey, kp.privateKey),
).toThrow();
});
});

View File

@@ -0,0 +1,89 @@
/**
* Tests for `crypto.fromBase64`, `crypto.toBase64`, and
* `crypto.toBase64URL`.
*
* Ente delivers most binary fields as standard base64 strings (with `+`,
* `/`, and `=` padding). A few fields, notably the auth token returned by
* the login flow, are URL-safe base64 (with `-` and `_` instead of `+` and
* `/`, and stripped padding). quack must accept both forms on input and
* produce the right form on output.
*
* These tests pin the contract:
* - `toBase64(b)` produces standard base64 with padding.
* - `toBase64URL(b)` produces URL-safe base64 without padding.
* - `fromBase64(s)` accepts both forms transparently and round-trips.
*/
import { beforeAll, describe, expect, it } from "vitest";
import {
fromBase64,
init,
toBase64,
toBase64URL,
} from "../../src/crypto/index.js";
describe("crypto encoding helpers", () => {
beforeAll(async () => {
await init();
});
/**
* The test input contains bytes that produce `+`, `/`, and `=` in
* standard base64. Specifically the bytes `0xFB 0xFF 0xBF` encode to
* `+/+/` in standard base64 and `-_-_` in URL-safe base64. This makes
* the alphabet difference observable.
*/
const BYTES_WITH_AMBIGUOUS_CHARS = new Uint8Array([0xfb, 0xff, 0xbf]);
it("round-trips arbitrary bytes through standard base64", () => {
const original = new Uint8Array([0, 1, 2, 127, 128, 250, 255]);
const encoded = toBase64(original);
expect(typeof encoded).toBe("string");
expect(fromBase64(encoded)).toEqual(original);
});
it("toBase64 produces a standard-alphabet string", () => {
// Standard base64 may contain `+`, `/`, and trailing `=`. URL-safe
// base64 is forbidden from containing those characters. We test
// that toBase64 chose the standard alphabet.
const encoded = toBase64(BYTES_WITH_AMBIGUOUS_CHARS);
// For these specific bytes the encoding contains both `+` and `/`,
// proving the alphabet is the standard one.
expect(encoded).toMatch(/[+/]/);
});
it("toBase64URL produces a URL-safe string with no padding", () => {
const encoded = toBase64URL(BYTES_WITH_AMBIGUOUS_CHARS);
// URL-safe alphabet: no `+`, `/`, or `=`.
expect(encoded).not.toMatch(/[+/=]/);
// The substitutions `-` and `_` should appear.
expect(encoded).toMatch(/[-_]/);
});
it("fromBase64 accepts standard input", () => {
const standard = toBase64(BYTES_WITH_AMBIGUOUS_CHARS);
expect(fromBase64(standard)).toEqual(BYTES_WITH_AMBIGUOUS_CHARS);
});
it("fromBase64 accepts URL-safe input", () => {
const urlSafe = toBase64URL(BYTES_WITH_AMBIGUOUS_CHARS);
expect(fromBase64(urlSafe)).toEqual(BYTES_WITH_AMBIGUOUS_CHARS);
});
it("fromBase64 accepts URL-safe input even without padding", () => {
// Construct a URL-safe form with the padding stripped, as Ente
// delivers it. `fromBase64` must still decode it correctly.
const stripped = toBase64URL(BYTES_WITH_AMBIGUOUS_CHARS).replace(
/=+$/,
"",
);
expect(fromBase64(stripped)).toEqual(BYTES_WITH_AMBIGUOUS_CHARS);
});
it("fromBase64 rejects garbage", () => {
// Non-base64 characters should not silently decode to something. We
// do not commit to the exact error type but we do commit that the
// call cannot return data successfully.
expect(() => fromBase64("!!! not base64 !!!")).toThrow();
});
});

38
test/crypto/init.test.ts Normal file
View File

@@ -0,0 +1,38 @@
/**
* Tests for `crypto.init()`.
*
* libsodium ships as WebAssembly. The bindings (`libsodium-wrappers-sumo`)
* load asynchronously: the runtime must `await sodium.ready` once before any
* crypto call is safe. quack hides that detail behind a single
* `init()` function.
*
* Every other test in `test/crypto/**` calls `init()` in `beforeAll`. New
* code that needs crypto should do the same. Calling it more than once is
* cheap and intentional; the second call returns the same already-resolved
* promise.
*/
import { beforeAll, describe, expect, it } from "vitest";
import { init } from "../../src/crypto/index.js";
describe("crypto.init", () => {
beforeAll(async () => {
await init();
});
it("resolves without throwing", async () => {
// The success criterion is simply that the promise resolves. If
// libsodium's WASM fails to load, `init()` rejects and this test
// fails.
await expect(init()).resolves.toBeUndefined();
});
it("is idempotent: repeated calls are safe", async () => {
// Code paths in this library may call `init()` defensively (e.g. a
// `Client` constructor that doesn't know whether the caller already
// initialised). Repeated calls must therefore be harmless.
await init();
await init();
await init();
});
});

136
test/crypto/kdf.test.ts Normal file
View File

@@ -0,0 +1,136 @@
/**
* 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);
});
});

172
test/crypto/stream.test.ts Normal file
View File

@@ -0,0 +1,172 @@
/**
* Tests for `crypto.initStreamPull` and `crypto.pullStreamChunk`.
*
* Ente encrypts file content with libsodium's secretstream construction
* (XChaCha20-Poly1305) in chunked mode. Each plaintext chunk is at most
* `STREAM_CHUNK_SIZE` bytes (4 MiB); each ciphertext chunk is exactly 17
* bytes longer than its plaintext (16-byte Poly1305 tag plus a 1-byte
* secretstream tag).
*
* The decryption header is delivered separately from the encrypted body in
* the file metadata (`file.file.decryptionHeader`). Once `initStreamPull`
* has consumed it, the body is read in order, one ciphertext chunk at a
* time, and each chunk is fed to `pullStreamChunk`. The library exposes
* the secretstream tag on each pulled chunk so the caller can verify the
* stream ended on a `TAG_FINAL` chunk and was therefore not truncated.
*
* These tests pin:
* - The chunk-size constants match Ente's expectations.
* - The pull state can decrypt a multi-chunk stream produced by
* sodium.crypto_secretstream_xchacha20poly1305_push, in order.
* - The tag byte is propagated to the caller.
* - Tampered or out-of-order ciphertext is rejected.
*/
import sodium from "libsodium-wrappers-sumo";
import { beforeAll, describe, expect, it } from "vitest";
import {
init,
initStreamPull,
pullStreamChunk,
STREAM_CHUNK_OVERHEAD,
STREAM_CHUNK_SIZE,
} from "../../src/crypto/index.js";
describe("crypto stream constants", () => {
/**
* These constants match the values hard-coded into Ente's web client
* and Go CLI. If Ente ever changes them server-side, every client
* must change in lockstep.
*/
it("STREAM_CHUNK_SIZE is 4 MiB", () => {
expect(STREAM_CHUNK_SIZE).toBe(4 * 1024 * 1024);
});
it("STREAM_CHUNK_OVERHEAD is 17 bytes", () => {
expect(STREAM_CHUNK_OVERHEAD).toBe(17);
});
});
describe("crypto.initStreamPull / pullStreamChunk", () => {
beforeAll(async () => {
await init();
await sodium.ready;
});
/**
* Helper: encrypt a sequence of plaintext chunks with sodium's push
* API and return the header plus the encrypted chunks. Marks the
* final chunk with `TAG_FINAL` (3); intermediate chunks use
* `TAG_MESSAGE` (0).
*/
const encryptChunks = (
key: Uint8Array,
chunks: Uint8Array[],
): { header: Uint8Array; encrypted: Uint8Array[] } => {
const push =
sodium.crypto_secretstream_xchacha20poly1305_init_push(key);
const encrypted: Uint8Array[] = [];
for (let i = 0; i < chunks.length; i++) {
const isLast = i === chunks.length - 1;
const tag = isLast
? sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL
: sodium.crypto_secretstream_xchacha20poly1305_TAG_MESSAGE;
encrypted.push(
sodium.crypto_secretstream_xchacha20poly1305_push(
push.state,
chunks[i]!,
null,
tag,
),
);
}
return { header: push.header, encrypted };
};
it("decrypts a single-chunk stream marked TAG_FINAL", () => {
const key = sodium.crypto_secretstream_xchacha20poly1305_keygen();
const plaintext = new TextEncoder().encode("a small file's contents");
const { header, encrypted } = encryptChunks(key, [plaintext]);
const state = initStreamPull(header, key);
const result = pullStreamChunk(state, encrypted[0]!);
expect(result.plaintext).toEqual(plaintext);
expect(result.tag).toBe(
sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL,
);
});
it("decrypts a multi-chunk stream in order, exposing tags per chunk", () => {
const key = sodium.crypto_secretstream_xchacha20poly1305_keygen();
const plaintexts = [
new Uint8Array([1, 2, 3]),
new Uint8Array([4, 5, 6, 7, 8]),
new Uint8Array([9, 10]),
];
const { header, encrypted } = encryptChunks(key, plaintexts);
const state = initStreamPull(header, key);
const results = encrypted.map((c) => pullStreamChunk(state, c));
// Plaintext is recovered chunk-for-chunk, in order.
expect(results.map((r) => r.plaintext)).toEqual(plaintexts);
// Intermediate chunks carry TAG_MESSAGE; the last carries TAG_FINAL.
// The caller can use this to detect a truncated stream: if the
// last chunk seen does not have TAG_FINAL, the body was cut off.
const TAG_MESSAGE =
sodium.crypto_secretstream_xchacha20poly1305_TAG_MESSAGE;
const TAG_FINAL =
sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL;
expect(results[0]!.tag).toBe(TAG_MESSAGE);
expect(results[1]!.tag).toBe(TAG_MESSAGE);
expect(results[2]!.tag).toBe(TAG_FINAL);
});
it("rejects a tampered ciphertext chunk", () => {
const key = sodium.crypto_secretstream_xchacha20poly1305_keygen();
const { header, encrypted } = encryptChunks(key, [
new Uint8Array([1, 2, 3]),
]);
encrypted[0]![0] = encrypted[0]![0]! ^ 0x01;
const state = initStreamPull(header, key);
expect(() => pullStreamChunk(state, encrypted[0]!)).toThrow();
});
it("rejects a chunk decrypted with the wrong key", () => {
const key = sodium.crypto_secretstream_xchacha20poly1305_keygen();
const wrongKey = sodium.crypto_secretstream_xchacha20poly1305_keygen();
const { header, encrypted } = encryptChunks(key, [
new Uint8Array([1, 2, 3]),
]);
const state = initStreamPull(header, wrongKey);
expect(() => pullStreamChunk(state, encrypted[0]!)).toThrow();
});
it("rejects chunks pulled out of order", () => {
// The secretstream construction binds each chunk to its position in
// the stream. Feeding chunk 1's ciphertext after chunk 0 was
// skipped, or in the wrong order, must fail authentication.
const key = sodium.crypto_secretstream_xchacha20poly1305_keygen();
const { header, encrypted } = encryptChunks(key, [
new Uint8Array([1, 2, 3]),
new Uint8Array([4, 5, 6]),
]);
const state = initStreamPull(header, key);
// Skip chunk 0 entirely and try to pull chunk 1 first.
expect(() => pullStreamChunk(state, encrypted[1]!)).toThrow();
});
it("ciphertext chunks are exactly STREAM_CHUNK_OVERHEAD longer than plaintext", () => {
// Sanity check on the overhead constant. If libsodium ever changes
// this (it won't), the constant in our crypto module must change
// with it.
const key = sodium.crypto_secretstream_xchacha20poly1305_keygen();
const plaintext = new Uint8Array(123).fill(0x55);
const { encrypted } = encryptChunks(key, [plaintext]);
expect(encrypted[0]!.length).toBe(
plaintext.length + STREAM_CHUNK_OVERHEAD,
);
});
});