/** * 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 * * How a collection's key is encrypted depends on who owns it: * * OWNED collections (owner == current user): the collection key is a * secretbox under the owner's master key, and `keyDecryptionNonce` * carries the nonce. * * SHARED collections (owned by someone else, shared with us): the owner * does not have our master key, so the server instead carries the * collection key as an anonymous SEALED BOX (crypto_box_seal) to our * X25519 public key, and `keyDecryptionNonce` is ABSENT from the wire * (sealed boxes embed an ephemeral public key instead of a nonce). * * `decryptCollection` therefore takes the full key material (master key * plus keypair) and dispatches on the presence of `keyDecryptionNonce`: * * 1a. nonce present: decryptBox(encryptedKey, keyDecryptionNonce, * masterKey) -> collectionKey * 1b. nonce absent: decryptSealed(encryptedKey, publicKey, secretKey) * -> 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 { KeyMaterial, RawCollection, RawEnteFile, } from "../../src/model/index.js"; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- // The full set of key material a logged-in client holds: the master key // (decrypts owned collection keys) and the X25519 keypair (unseals shared // collection keys and the auth token). const buildKeys = (): KeyMaterial => { const kp = sodium.crypto_box_keypair(); return { masterKey: sodium.crypto_secretbox_keygen(), publicKey: kp.publicKey, secretKey: kp.privateKey, }; }; 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 }; }; // A collection shared with us by another user. The wire format differs // from owned collections in two ways, both verified against the live // api.ente.io: `encryptedKey` is an anonymous sealed box to OUR public // key (80 bytes for a 32-byte key, vs 48 for a secretbox), and // `keyDecryptionNonce` is entirely ABSENT from the JSON. const buildSharedRawCollection = ( recipientPublicKey: Uint8Array, opts?: { name?: string; ownerID?: number }, ): { raw: RawCollection; collectionKey: Uint8Array } => { const collectionKey = sodium.crypto_secretbox_keygen(); const sealedKey = sodium.crypto_box_seal(collectionKey, recipientPublicKey); const name = opts?.name ?? "Shared Album"; const { ciphertext: encName, nonce: nameNonce } = secretboxEncrypt( new TextEncoder().encode(name), collectionKey, ); const raw: RawCollection = { id: 101, owner: { id: opts?.ownerID ?? 99 }, encryptedKey: toBase64(sealedKey), // NOTE: no keyDecryptionNonce. Do not "fix" this fixture by adding // one: its absence is exactly what the real server sends, and an // unfaithful fixture here previously masked a crash on every // account with an incoming shared album. encryptedName: toBase64(encName), nameDecryptionNonce: toBase64(nameNonce), type: "album", updationTime: 1700000000000000, }; return { raw, collectionKey }; }; const buildRawFile = ( collectionKey: Uint8Array, opts?: { // Any JSON value; `undefined` leaves the title out of the metadata. title?: unknown; fileType?: number; creationTime?: number; info?: { fileSize?: number; thumbSize?: number }; // Replaces the whole metadata JSON value. metadata?: unknown; }, ): RawEnteFile => { const fileKey = sodium.crypto_secretbox_keygen(); const { ciphertext: encFileKey, nonce: fileKeyNonce } = secretboxEncrypt( fileKey, collectionKey, ); const defaultMetadata = { title: opts && "title" in opts ? opts.title : "IMG_0001.jpg", fileType: opts?.fileType ?? 0, creationTime: opts?.creationTime ?? 1700000000000000, modificationTime: 1700000000000000, latitude: 48.8566, longitude: 2.3522, hash: "abcdef1234567890", }; const metadata = opts && "metadata" in opts ? opts.metadata : defaultMetadata; // 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)) }, info: opts?.info, updationTime: 1700000000000000, }; }; // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe("model.decryptCollection", () => { beforeAll(async () => { await init(); await sodium.ready; }); it("decrypts an owned collection key and name from a raw server response", () => { const keys = buildKeys(); const { raw, collectionKey } = buildRawCollection(keys.masterKey, { name: "Summer Photos", }); const col = decryptCollection(raw, keys, 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("decrypts a shared collection via sealed box when keyDecryptionNonce is absent", () => { // A collection shared TO us is not encrypted with our master key: // the sharer only knows our public key, so the collection key // arrives as crypto_box_seal(collectionKey, ourPublicKey) and the // response has NO keyDecryptionNonce. decryptCollection must // recover the key with the keypair, then decrypt the name with it // as usual. Accounts with any incoming shared album hit this path // on every listCollections call. const keys = buildKeys(); const { raw, collectionKey } = buildSharedRawCollection( keys.publicKey, { name: "Friend's Wedding", ownerID: 99 }, ); const col = decryptCollection(raw, keys, 1); expect(col.key).toEqual(collectionKey); expect(col.name).toBe("Friend's Wedding"); expect(col.ownerID).toBe(99); expect(col.isShared).toBe(true); }); it("maps known type strings to CollectionType", () => { const keys = buildKeys(); for (const type of ["album", "folder", "favorites", "uncategorized"]) { const { raw } = buildRawCollection(keys.masterKey, { type }); const col = decryptCollection(raw, keys, 1); expect(col.type).toBe(type); } }); it("maps unrecognised type strings to 'unknown'", () => { const keys = buildKeys(); const { raw } = buildRawCollection(keys.masterKey, { type: "someFutureType", }); const col = decryptCollection(raw, keys, 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 keys = buildKeys(); const { raw } = buildRawCollection(keys.masterKey); delete raw.encryptedName; delete raw.nameDecryptionNonce; const col = decryptCollection(raw, keys, 1); expect(col.name).toBe(""); }); it("throws when the master key is wrong for an owned collection", () => { const keys = buildKeys(); const { raw } = buildRawCollection(keys.masterKey); // buildKeys() generates fresh random key material, so this is a // client holding the wrong master key. expect(() => decryptCollection(raw, buildKeys(), 1)).toThrow(); }); it("throws when the keypair is wrong for a shared collection", () => { const keys = buildKeys(); const { raw } = buildSharedRawCollection(keys.publicKey); // A different keypair must not be able to unseal the key. const wrongKeys = buildKeys(); expect(() => decryptCollection(raw, wrongKeys, 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("reads a missing or non-string title as an empty string", () => { // The server controls the metadata JSON. A title that is not a // string must not reach code that builds file names from it. const masterKey = sodium.crypto_secretbox_keygen(); const { collectionKey } = buildRawCollection(masterKey); for (const title of [undefined, null, 42, ["a"], { x: "../y" }]) { const file = decryptFile( buildRawFile(collectionKey, { title }), collectionKey, ); expect(file.metadata.title).toBe(""); } }); it("rejects metadata that is not a JSON object", () => { const masterKey = sodium.crypto_secretbox_keygen(); const { collectionKey } = buildRawCollection(masterKey); for (const metadata of [null, "IMG_0001.jpg", 7, []]) { const raw = buildRawFile(collectionKey, { metadata }); expect(() => decryptFile(raw, collectionKey)).toThrow( "file 200: metadata is not a JSON object", ); } }); it("reads the recorded content hash, joining an older live photo's two parts", () => { const masterKey = sodium.crypto_secretbox_keygen(); const { collectionKey } = buildRawCollection(masterKey); const hashOf = (metadata: Record) => decryptFile( buildRawFile(collectionKey, { metadata: { title: "x", ...metadata }, }), collectionKey, ).metadata.hash; expect(hashOf({ fileType: 0, hash: "H" })).toBe("H"); expect( hashOf({ fileType: 2, hash: "H", imageHash: "I", videoHash: "V" }), ).toBe("H"); expect(hashOf({ fileType: 2, imageHash: "I", videoHash: "V" })).toBe( "I:V", ); expect(hashOf({ fileType: 2, imageHash: "I" })).toBeUndefined(); expect( hashOf({ fileType: 0, imageHash: "I", videoHash: "V" }), ).toBeUndefined(); expect(hashOf({ fileType: 0 })).toBeUndefined(); expect(hashOf({ fileType: 0, hash: 42 })).toBeUndefined(); expect( hashOf({ fileType: 2, imageHash: "I", videoHash: 7 }), ).toBeUndefined(); // An empty string counts as absent, not as a hash to match. expect(hashOf({ fileType: 0, hash: "" })).toBeUndefined(); expect( hashOf({ fileType: 2, hash: "", imageHash: "I", videoHash: "V" }), ).toBe("I:V"); expect( hashOf({ fileType: 2, imageHash: "", videoHash: "V" }), ).toBeUndefined(); expect( hashOf({ fileType: 2, imageHash: "I", videoHash: "" }), ).toBeUndefined(); }); 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(); }); it("carries the file and thumbnail byte sizes from info", () => { // The server reports the encrypted-blob sizes in `info`; the cache // needs them without a HEAD request, so decryptFile must copy them // onto the file and thumbnail blobs. const masterKey = sodium.crypto_secretbox_keygen(); const { collectionKey } = buildRawCollection(masterKey); const raw = buildRawFile(collectionKey, { info: { fileSize: 4096, thumbSize: 512 }, }); const file = decryptFile(raw, collectionKey); expect(file.file.size).toBe(4096); expect(file.thumbnail.size).toBe(512); }); it("leaves the sizes undefined when the server omits info", () => { // Older files predate the info field; the sizes must stay undefined // rather than become 0, so callers can tell "unknown" from "empty". const masterKey = sodium.crypto_secretbox_keygen(); const { collectionKey } = buildRawCollection(masterKey); const raw = buildRawFile(collectionKey); expect(raw.info).toBeUndefined(); const file = decryptFile(raw, collectionKey); expect(file.file.size).toBeUndefined(); expect(file.thumbnail.size).toBeUndefined(); }); it("carries the deletion flag from the diff row", () => { // The diff marks a deleted row with isDeleted; decryptFile copies it // onto the file so a caller can tell a deleted row from a live one. const masterKey = sodium.crypto_secretbox_keygen(); const { collectionKey } = buildRawCollection(masterKey); const raw = buildRawFile(collectionKey); raw.isDeleted = true; const file = decryptFile(raw, collectionKey); expect(file.isDeleted).toBe(true); }); });