Files
quak/test/crypto/stream.test.ts
sneak 1f894bad0e Add failing tests for download truncation detection and atomic writes
Covers, for both downloadFile and downloadThumbnail:

- a multi-chunk body whose TAG_FINAL chunk never arrived is rejected
  with a truncation error;
- an empty body is rejected as truncation rather than written as a
  zero-byte file;
- after a truncation or chunk-authentication failure the destination
  path does not exist and no temporary scratch file is left behind;
- an existing file at the destination survives a failed download
  byte for byte, and is replaced atomically by a successful one;
- the existing success cases still produce identical bytes and an
  identical DownloadResult.

Adds an encryptMultiChunkBody helper that frames leading chunks at
exactly STREAM_CHUNK_SIZE so the downloader's fixed-size re-splitting
lines up, plus a multi-chunk success case as the positive control.

Also pins the new STREAM_TAG_FINAL crypto export against libsodium's
own constant, since it must be declared as a literal: libsodium
attaches its constants only after sodium.ready resolves, well after
this library's modules are evaluated.

These fail until the implementation lands, per the repo's TDD workflow.
2026-08-09 01:53:43 +00:00

191 lines
7.7 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 `STREAM_TAG_FINAL` matches libsodium's own constant.
* - 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,
STREAM_TAG_FINAL,
} 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);
});
/**
* `STREAM_TAG_FINAL` is re-exported so callers can detect a truncated
* stream (a body that ended on a non-final chunk) without importing
* libsodium themselves. It has to be declared as a literal, because
* libsodium only attaches its own constants to the module object after
* `sodium.ready` resolves — long after this library's modules are
* evaluated. This test is what keeps the literal honest.
*/
it("STREAM_TAG_FINAL equals libsodium's TAG_FINAL", async () => {
await init();
await sodium.ready;
expect(STREAM_TAG_FINAL).toBe(
sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL,
);
});
});
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,
);
});
});