/** * 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, ); }); });