All checks were successful
check / check (push) Successful in 22s
Rework against review findings on the branch. Test runtime (B1): the suite's cost was never the 4 MiB fixture, it was filling that fixture from the CSPRNG. sodium.randombytes_buf goes through the wasm wrapper a byte at a time and takes ~20s for 4 MiB, against ~108ms to encrypt the same buffer. Fixture content is not load-bearing anywhere in the file — only length and tag are — so payloads now come from a seeded LCG instead, which also makes them deterministic and reproducible as the README asks. A seeded generator rather than a constant fill, so a downloader that reordered or repeated chunks would still be caught. make test goes from over the 30s cap in script/test (it was failing outright, then rerunning verbose) back to 8.96s, against 10.02s on main. Atomic write coverage (B2): every failure the suite injected originated in streamDecrypt, which runs before anything is written, so no test observed a temp file existing or being cleaned up and the catch block in writeAtomic was dead code. rename is now intercepted in the test file, which adds two cases per entry point: one asserting the staged file exists at rename time and is a sibling of the destination, and one failing the rename itself so the cleanup path runs with a temp file genuinely on disk. Deleting writeAtomic in favour of a plain writeFile now turns the suite red. TAG_FINAL (M1): STREAM_TAG_FINAL was a hardcoded 3 plus a test to detect drift. The premise was right — libsodium attaches its constants inside ready.then(...), so an eager module-level read binds undefined — but a lazy read works, and decryptBlob was doing exactly that before. Replaced with streamTagFinal(), which reads the library's own value at call time. The drift test is repurposed to pin the accessor against the tag observed on a real final chunk, which fails if it is ever made eager again. Partial trailing chunk (M2): a transfer that stopped mid-chunk surfaced as "authentication failed", which reads as corruption and sends the user after the wrong problem. A final chunk that arrived in full always authenticates, so trailing bytes that do not are reported as the truncation they almost always are, with the authentication failure kept as the error's cause. Poly1305 cannot separate a partial chunk from a corrupt one, so the message names both possibilities; a corrupt whole chunk mid-stream is still reported as an authentication failure, and both are now tested. The pre-existing test that wrote its output to the process working directory, i.e. the repo root, now writes into the test's temp directory.
209 lines
8.6 KiB
TypeScript
209 lines
8.6 KiB
TypeScript
/**
|
|
* 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, and the
|
|
* re-exported `streamTagFinal()` matches the tag a real final chunk
|
|
* carries.
|
|
* - 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,
|
|
streamTagFinal,
|
|
} 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);
|
|
});
|
|
|
|
/**
|
|
* `streamTagFinal()` is re-exported so callers can detect a truncated
|
|
* stream (a body that ended on a non-final chunk) without importing
|
|
* libsodium themselves.
|
|
*
|
|
* It is a function, not a constant, and that is load-bearing: libsodium
|
|
* attaches its own constants to the module object only after
|
|
* `sodium.ready` resolves, which is long after this library's modules are
|
|
* evaluated. Reading the value at call time yields libsodium's number;
|
|
* reading it at module scope would yield `undefined`, and every
|
|
* truncation check downstream would then compare against `undefined` and
|
|
* reject good downloads. This test pins the behaviour end to end — it
|
|
* compares against the tag observed on a real final chunk pulled back off
|
|
* the wire format, so it fails if the accessor is ever made eager.
|
|
*/
|
|
it("streamTagFinal() is the tag carried by a real final chunk", async () => {
|
|
await init();
|
|
await sodium.ready;
|
|
|
|
const key = sodium.crypto_secretstream_xchacha20poly1305_keygen();
|
|
const push =
|
|
sodium.crypto_secretstream_xchacha20poly1305_init_push(key);
|
|
const ciphertext = sodium.crypto_secretstream_xchacha20poly1305_push(
|
|
push.state,
|
|
new TextEncoder().encode("last chunk"),
|
|
null,
|
|
sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL,
|
|
);
|
|
|
|
const state = initStreamPull(push.header, key);
|
|
expect(pullStreamChunk(state, ciphertext).tag).toBe(streamTagFinal());
|
|
});
|
|
});
|
|
|
|
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,
|
|
);
|
|
});
|
|
});
|