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:
172
test/crypto/stream.test.ts
Normal file
172
test/crypto/stream.test.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user