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.
191 lines
7.7 KiB
TypeScript
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,
|
|
);
|
|
});
|
|
});
|