/** * Tests for `model.decryptCollection` and `model.decryptFile`. * * These two functions turn the raw encrypted JSON blobs the Ente server * returns into the decrypted Collection and EnteFile objects that the * rest of quak works with. * * ## Collection decryption * * The server stores each collection's encryption key sealed under the * owner's master key (secretbox). The collection's name is then sealed * under that collection key (also secretbox). `decryptCollection`: * * 1. decryptBox(encryptedKey, keyDecryptionNonce, masterKey) -> collectionKey * 2. decryptBox(encryptedName, nameDecryptionNonce, collectionKey) -> name (UTF-8) * 3. Maps the string `type` field to a CollectionType union member * 4. Returns a Collection with decrypted key, name, and type * * If the collection is shared (owner != current user), `isShared` is true. * * ## File decryption * * Each file has its own encryption key, sealed under the collection key * (secretbox). File metadata (title, creation time, geolocation, etc.) * is a JSON blob sealed under the file key (also secretbox, where the * field called `decryptionHeader` is the nonce). `decryptFile`: * * 1. decryptBox(encryptedKey, keyDecryptionNonce, collectionKey) -> fileKey * 2. decryptBox(metadata.encryptedData, metadata.decryptionHeader, fileKey) -> JSON * 3. Parses the JSON into a typed FileMetadata * 4. Returns an EnteFile with decrypted key, metadata, and the raw * file/thumbnail decryption headers (used later for download) * * The tests build synthetic encrypted payloads using libsodium directly. */ import sodium from "libsodium-wrappers-sumo"; import { beforeAll, describe, expect, it } from "vitest"; import { init, toBase64 } from "../../src/crypto/index.js"; import { decryptCollection, decryptFile } from "../../src/model/index.js"; import type { RawCollection, RawEnteFile } from "../../src/model/index.js"; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- const secretboxEncrypt = ( plaintext: Uint8Array, key: Uint8Array, ): { ciphertext: Uint8Array; nonce: Uint8Array } => { const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES); const ciphertext = sodium.crypto_secretbox_easy(plaintext, nonce, key); return { ciphertext, nonce }; }; const buildRawCollection = ( masterKey: Uint8Array, opts?: { name?: string; type?: string; ownerID?: number }, ): { raw: RawCollection; collectionKey: Uint8Array } => { const collectionKey = sodium.crypto_secretbox_keygen(); const { ciphertext: encKey, nonce: keyNonce } = secretboxEncrypt( collectionKey, masterKey, ); const name = opts?.name ?? "Vacation 2025"; const { ciphertext: encName, nonce: nameNonce } = secretboxEncrypt( new TextEncoder().encode(name), collectionKey, ); const raw: RawCollection = { id: 100, owner: { id: opts?.ownerID ?? 1 }, encryptedKey: toBase64(encKey), keyDecryptionNonce: toBase64(keyNonce), encryptedName: toBase64(encName), nameDecryptionNonce: toBase64(nameNonce), type: opts?.type ?? "album", updationTime: 1700000000000000, }; return { raw, collectionKey }; }; const buildRawFile = ( collectionKey: Uint8Array, opts?: { title?: string; fileType?: number; creationTime?: number }, ): RawEnteFile => { const fileKey = sodium.crypto_secretbox_keygen(); const { ciphertext: encFileKey, nonce: fileKeyNonce } = secretboxEncrypt( fileKey, collectionKey, ); const metadata = { title: opts?.title ?? "IMG_0001.jpg", fileType: opts?.fileType ?? 0, creationTime: opts?.creationTime ?? 1700000000000000, modificationTime: 1700000000000000, latitude: 48.8566, longitude: 2.3522, hash: "abcdef1234567890", }; // File metadata is encrypted as a single-chunk secretstream blob // (not secretbox). The decryptionHeader is the secretstream init header. const metadataBytes = new TextEncoder().encode(JSON.stringify(metadata)); const push = sodium.crypto_secretstream_xchacha20poly1305_init_push(fileKey); const encMeta = sodium.crypto_secretstream_xchacha20poly1305_push( push.state, metadataBytes, null, sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL, ); return { id: 200, collectionID: 100, ownerID: 1, encryptedKey: toBase64(encFileKey), keyDecryptionNonce: toBase64(fileKeyNonce), metadata: { encryptedData: toBase64(encMeta), decryptionHeader: toBase64(push.header), }, file: { decryptionHeader: toBase64(sodium.randombytes_buf(24)) }, thumbnail: { decryptionHeader: toBase64(sodium.randombytes_buf(24)) }, updationTime: 1700000000000000, }; }; // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe("model.decryptCollection", () => { beforeAll(async () => { await init(); await sodium.ready; }); it("decrypts the collection key and name from a raw server response", () => { const masterKey = sodium.crypto_secretbox_keygen(); const { raw, collectionKey } = buildRawCollection(masterKey, { name: "Summer Photos", }); const col = decryptCollection(raw, masterKey, 1); expect(col.id).toBe(100); expect(col.key).toEqual(collectionKey); expect(col.name).toBe("Summer Photos"); expect(col.type).toBe("album"); expect(col.ownerID).toBe(1); expect(col.updationTime).toBe(1700000000000000); expect(col.isShared).toBe(false); }); it("sets isShared when owner != current user", () => { const masterKey = sodium.crypto_secretbox_keygen(); const { raw } = buildRawCollection(masterKey, { ownerID: 99 }); const col = decryptCollection(raw, masterKey, 1); expect(col.isShared).toBe(true); }); it("maps known type strings to CollectionType", () => { const masterKey = sodium.crypto_secretbox_keygen(); for (const type of ["album", "folder", "favorites", "uncategorized"]) { const { raw } = buildRawCollection(masterKey, { type }); const col = decryptCollection(raw, masterKey, 1); expect(col.type).toBe(type); } }); it("maps unrecognised type strings to 'unknown'", () => { const masterKey = sodium.crypto_secretbox_keygen(); const { raw } = buildRawCollection(masterKey, { type: "someFutureType", }); const col = decryptCollection(raw, masterKey, 1); expect(col.type).toBe("unknown"); }); it("handles a collection with no encrypted name gracefully", () => { // Some special collections (e.g. uncategorized) may have no name. const masterKey = sodium.crypto_secretbox_keygen(); const { raw } = buildRawCollection(masterKey); delete raw.encryptedName; delete raw.nameDecryptionNonce; const col = decryptCollection(raw, masterKey, 1); expect(col.name).toBe(""); }); it("throws when the master key is wrong", () => { const masterKey = sodium.crypto_secretbox_keygen(); const wrongKey = sodium.crypto_secretbox_keygen(); const { raw } = buildRawCollection(masterKey); expect(() => decryptCollection(raw, wrongKey, 1)).toThrow(); }); }); describe("model.decryptFile", () => { beforeAll(async () => { await init(); await sodium.ready; }); it("decrypts the file key and metadata from a raw server response", () => { const masterKey = sodium.crypto_secretbox_keygen(); const { collectionKey } = buildRawCollection(masterKey); const raw = buildRawFile(collectionKey, { title: "sunset.heic", fileType: 0, creationTime: 1710000000000000, }); const file = decryptFile(raw, collectionKey); expect(file.id).toBe(200); expect(file.collectionID).toBe(100); expect(file.ownerID).toBe(1); expect(file.key.length).toBe(32); expect(file.metadata.title).toBe("sunset.heic"); expect(file.metadata.fileType).toBe("image"); expect(file.metadata.creationTime).toBe(1710000000000000); expect(file.metadata.latitude).toBeCloseTo(48.8566); expect(file.metadata.longitude).toBeCloseTo(2.3522); }); it("maps fileType numbers to FileType strings", () => { // Ente uses: 0=image, 1=video, 2=livePhoto const masterKey = sodium.crypto_secretbox_keygen(); const { collectionKey } = buildRawCollection(masterKey); const mapping: [number, string][] = [ [0, "image"], [1, "video"], [2, "livePhoto"], [99, "unknown"], ]; for (const [num, expected] of mapping) { const raw = buildRawFile(collectionKey, { fileType: num }); const file = decryptFile(raw, collectionKey); expect(file.metadata.fileType).toBe(expected); } }); it("preserves the file and thumbnail decryption headers", () => { // These headers are passed to initStreamPull later when // downloading and decrypting the actual file content. The // decryptFile function must pass them through unchanged. const masterKey = sodium.crypto_secretbox_keygen(); const { collectionKey } = buildRawCollection(masterKey); const raw = buildRawFile(collectionKey); const file = decryptFile(raw, collectionKey); expect(file.file.decryptionHeader).toBe(raw.file.decryptionHeader); expect(file.thumbnail.decryptionHeader).toBe( raw.thumbnail.decryptionHeader, ); }); it("throws when the collection key is wrong", () => { const masterKey = sodium.crypto_secretbox_keygen(); const { collectionKey } = buildRawCollection(masterKey); const wrongKey = sodium.crypto_secretbox_keygen(); const raw = buildRawFile(collectionKey); expect(() => decryptFile(raw, wrongKey)).toThrow(); }); });