From 676d42c5eb8b714cb0e2e024031f53f42587faf5 Mon Sep 17 00:00:00 2001 From: sneak Date: Sat, 9 May 2026 12:43:52 -0700 Subject: [PATCH] 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). --- eslint.config.mjs | 17 ++++ src/crypto/box.ts | 17 ++++ src/crypto/encoding.ts | 13 +++ src/crypto/index.ts | 11 +++ src/crypto/kdf.ts | 14 +++ src/crypto/sodium.ts | 6 ++ src/crypto/stream.ts | 22 +++++ test/crypto/box.test.ts | 160 ++++++++++++++++++++++++++++++++ test/crypto/encoding.test.ts | 89 ++++++++++++++++++ test/crypto/init.test.ts | 38 ++++++++ test/crypto/kdf.test.ts | 136 +++++++++++++++++++++++++++ test/crypto/stream.test.ts | 172 +++++++++++++++++++++++++++++++++++ 12 files changed, 695 insertions(+) create mode 100644 src/crypto/box.ts create mode 100644 src/crypto/encoding.ts create mode 100644 src/crypto/index.ts create mode 100644 src/crypto/kdf.ts create mode 100644 src/crypto/sodium.ts create mode 100644 src/crypto/stream.ts create mode 100644 test/crypto/box.test.ts create mode 100644 test/crypto/encoding.test.ts create mode 100644 test/crypto/init.test.ts create mode 100644 test/crypto/kdf.test.ts create mode 100644 test/crypto/stream.test.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index a09cc5f..99198d8 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -7,4 +7,21 @@ export default tseslint.config( }, js.configs.recommended, ...tseslint.configs.recommended, + { + rules: { + // Honour the leading-underscore convention for intentionally + // unused parameters and variables. Stub functions in + // src/crypto/ during the TDD red phase keep their parameter + // names (prefixed with _) so the signature is visible to the + // implementer. + "@typescript-eslint/no-unused-vars": [ + "error", + { + argsIgnorePattern: "^_", + varsIgnorePattern: "^_", + caughtErrorsIgnorePattern: "^_", + }, + ], + }, + }, ); diff --git a/src/crypto/box.ts b/src/crypto/box.ts new file mode 100644 index 0000000..484152b --- /dev/null +++ b/src/crypto/box.ts @@ -0,0 +1,17 @@ +// Stub: see the README "Development workflow" section for TDD policy. + +export const decryptBox = ( + _ciphertext: Uint8Array, + _nonce: Uint8Array, + _key: Uint8Array, +): Uint8Array => { + throw new Error("crypto.decryptBox not implemented"); +}; + +export const decryptSealed = ( + _ciphertext: Uint8Array, + _publicKey: Uint8Array, + _secretKey: Uint8Array, +): Uint8Array => { + throw new Error("crypto.decryptSealed not implemented"); +}; diff --git a/src/crypto/encoding.ts b/src/crypto/encoding.ts new file mode 100644 index 0000000..85cc6ef --- /dev/null +++ b/src/crypto/encoding.ts @@ -0,0 +1,13 @@ +// Stub: see the README "Development workflow" section for TDD policy. + +export const fromBase64 = (_s: string): Uint8Array => { + throw new Error("crypto.fromBase64 not implemented"); +}; + +export const toBase64 = (_b: Uint8Array): string => { + throw new Error("crypto.toBase64 not implemented"); +}; + +export const toBase64URL = (_b: Uint8Array): string => { + throw new Error("crypto.toBase64URL not implemented"); +}; diff --git a/src/crypto/index.ts b/src/crypto/index.ts new file mode 100644 index 0000000..0c77196 --- /dev/null +++ b/src/crypto/index.ts @@ -0,0 +1,11 @@ +export { init } from "./sodium.js"; +export { fromBase64, toBase64, toBase64URL } from "./encoding.js"; +export { deriveKEK, deriveLoginSubkey } from "./kdf.js"; +export { decryptBox, decryptSealed } from "./box.js"; +export { + initStreamPull, + pullStreamChunk, + STREAM_CHUNK_OVERHEAD, + STREAM_CHUNK_SIZE, + type StreamPullState, +} from "./stream.js"; diff --git a/src/crypto/kdf.ts b/src/crypto/kdf.ts new file mode 100644 index 0000000..3c39487 --- /dev/null +++ b/src/crypto/kdf.ts @@ -0,0 +1,14 @@ +// Stub: see the README "Development workflow" section for TDD policy. + +export const deriveKEK = async ( + _password: string, + _salt: Uint8Array, + _opsLimit: number, + _memLimit: number, +): Promise => { + throw new Error("crypto.deriveKEK not implemented"); +}; + +export const deriveLoginSubkey = (_kek: Uint8Array): Uint8Array => { + throw new Error("crypto.deriveLoginSubkey not implemented"); +}; diff --git a/src/crypto/sodium.ts b/src/crypto/sodium.ts new file mode 100644 index 0000000..f0c1c6b --- /dev/null +++ b/src/crypto/sodium.ts @@ -0,0 +1,6 @@ +// Stub: see the README "Development workflow" section for TDD policy. +// This module's real implementation lands in a follow-up commit. + +export const init = async (): Promise => { + throw new Error("crypto.init not implemented"); +}; diff --git a/src/crypto/stream.ts b/src/crypto/stream.ts new file mode 100644 index 0000000..477e8af --- /dev/null +++ b/src/crypto/stream.ts @@ -0,0 +1,22 @@ +// Stub: see the README "Development workflow" section for TDD policy. + +export const STREAM_CHUNK_SIZE = 4 * 1024 * 1024; +export const STREAM_CHUNK_OVERHEAD = 17; + +export interface StreamPullState { + readonly _opaque: unique symbol; +} + +export const initStreamPull = ( + _header: Uint8Array, + _key: Uint8Array, +): StreamPullState => { + throw new Error("crypto.initStreamPull not implemented"); +}; + +export const pullStreamChunk = ( + _state: StreamPullState, + _ciphertext: Uint8Array, +): { plaintext: Uint8Array; tag: number } => { + throw new Error("crypto.pullStreamChunk not implemented"); +}; diff --git a/test/crypto/box.test.ts b/test/crypto/box.test.ts new file mode 100644 index 0000000..39bfe59 --- /dev/null +++ b/test/crypto/box.test.ts @@ -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(); + }); +}); diff --git a/test/crypto/encoding.test.ts b/test/crypto/encoding.test.ts new file mode 100644 index 0000000..8ce4f71 --- /dev/null +++ b/test/crypto/encoding.test.ts @@ -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(); + }); +}); diff --git a/test/crypto/init.test.ts b/test/crypto/init.test.ts new file mode 100644 index 0000000..46f85d4 --- /dev/null +++ b/test/crypto/init.test.ts @@ -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(); + }); +}); diff --git a/test/crypto/kdf.test.ts b/test/crypto/kdf.test.ts new file mode 100644 index 0000000..670d80e --- /dev/null +++ b/test/crypto/kdf.test.ts @@ -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); + }); +}); diff --git a/test/crypto/stream.test.ts b/test/crypto/stream.test.ts new file mode 100644 index 0000000..db67bc6 --- /dev/null +++ b/test/crypto/stream.test.ts @@ -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, + ); + }); +});