/** * Tests for `crypto.encryptBlob`. * * `encryptBlob` is the push-side counterpart to `decryptBlob`. It * encrypts a small payload as a single secretstream chunk with * TAG_FINAL and returns the header + ciphertext. Used for encrypting * thumbnails before upload to the Ente server. * * The critical invariant is that `decryptBlob(encryptBlob(...))` is the * identity function. If either side drifts, uploaded thumbnails become * unreadable. */ import sodium from "libsodium-wrappers-sumo"; import { beforeAll, describe, expect, it } from "vitest"; import { decryptBlob, encryptBlob, init, STREAM_CHUNK_OVERHEAD, } from "../../src/crypto/index.js"; describe("crypto.encryptBlob", () => { beforeAll(async () => { await init(); await sodium.ready; }); it("round-trips with decryptBlob for arbitrary data", () => { const key = sodium.crypto_secretstream_xchacha20poly1305_keygen(); const plaintext = sodium.randombytes_buf(500); const { header, ciphertext } = encryptBlob(plaintext, key); const recovered = decryptBlob(ciphertext, header, key); expect(recovered).toEqual(plaintext); }); it("round-trips a zero-length payload", () => { const key = sodium.crypto_secretstream_xchacha20poly1305_keygen(); const { header, ciphertext } = encryptBlob(new Uint8Array(0), key); const recovered = decryptBlob(ciphertext, header, key); expect(recovered.length).toBe(0); }); it("ciphertext is exactly STREAM_CHUNK_OVERHEAD longer than plaintext", () => { const key = sodium.crypto_secretstream_xchacha20poly1305_keygen(); const plaintext = sodium.randombytes_buf(1234); const { ciphertext } = encryptBlob(plaintext, key); expect(ciphertext.length).toBe( plaintext.length + STREAM_CHUNK_OVERHEAD, ); }); it("header is 24 bytes (secretstream XChaCha20 header size)", () => { const key = sodium.crypto_secretstream_xchacha20poly1305_keygen(); const { header } = encryptBlob(new Uint8Array([1, 2, 3]), key); expect(header.length).toBe( sodium.crypto_secretstream_xchacha20poly1305_HEADERBYTES, ); }); it("produces different ciphertext for different keys", () => { const k1 = sodium.crypto_secretstream_xchacha20poly1305_keygen(); const k2 = sodium.crypto_secretstream_xchacha20poly1305_keygen(); const plaintext = new Uint8Array([1, 2, 3, 4, 5]); const enc1 = encryptBlob(plaintext, k1); const enc2 = encryptBlob(plaintext, k2); expect(enc1.ciphertext).not.toEqual(enc2.ciphertext); }); it("produces different ciphertext on each call (random nonce in header)", () => { const key = sodium.crypto_secretstream_xchacha20poly1305_keygen(); const plaintext = new Uint8Array([9, 9, 9]); const a = encryptBlob(plaintext, key); const b = encryptBlob(plaintext, key); expect(a.header).not.toEqual(b.header); expect(a.ciphertext).not.toEqual(b.ciphertext); }); it("decryptBlob rejects ciphertext encrypted with a different key", () => { const k1 = sodium.crypto_secretstream_xchacha20poly1305_keygen(); const k2 = sodium.crypto_secretstream_xchacha20poly1305_keygen(); const { header, ciphertext } = encryptBlob( new Uint8Array([1, 2, 3]), k1, ); expect(() => decryptBlob(ciphertext, header, k2)).toThrow(); }); it("decryptBlob rejects tampered ciphertext from encryptBlob", () => { const key = sodium.crypto_secretstream_xchacha20poly1305_keygen(); const { header, ciphertext } = encryptBlob( sodium.randombytes_buf(100), key, ); ciphertext[ciphertext.length - 1] = ciphertext[ciphertext.length - 1]! ^ 0x01; expect(() => decryptBlob(ciphertext, header, key)).toThrow(); }); });