test/crypto/encrypt-blob.test.ts (8 tests): Round-trip with decryptBlob, zero-length payload, ciphertext overhead check, header size check, different keys produce different output, same key produces different output each call (random nonce), wrong-key rejection, tamper detection. test/api/upload.test.ts (4 tests): putJSON sends PUT with auth headers and JSON body. putFile sends PUT to the exact presigned URL with Content-Type octet-stream and does NOT send X-Auth-Token or X-Client-Package (S3 would reject them). getUploadURL POSTs with contentLength and contentMD5. updateThumbnail PUTs to /files/thumbnail with correct body shape. test/thumbnails/thumbnails.test.ts (8 tests): listMissingThumbnails identifies empty (0 byte) and 404 thumbnails while ignoring working ones; deduplicates across collections. fixMissingThumbnails verifies the full pipeline: download original, generate JPEG via sharp, encrypt with encryptBlob, upload via presigned URL, register via PUT /files/thumbnail. The test decrypts the uploaded ciphertext and verifies it starts with JPEG magic bytes (FF D8 FF). Also tests: nonexistent file ID reports failure without crashing; mixed success/failure across multiple files; Client.getApiClient() works when logged in, throws after logout.
100 lines
3.9 KiB
TypeScript
100 lines
3.9 KiB
TypeScript
/**
|
|
* 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();
|
|
});
|
|
});
|