/** * Tests for `quak backup-metadata `. * * This command dumps all decrypted account metadata into a directory * tree of plain JSON files, without downloading any file content. It * is fast (no multi-megabyte downloads) and produces a complete * plaintext record of every collection name, file title, creation * date, GPS coordinate, camera model, caption, face label, and any * other metadata the Ente clients have attached. * * Layout: * * / * account.json { email, userID } * collections/ * -/ * _collection.json { id, name, type, magicMetadata?, ... } * .json { id, metadata, magicMetadata?, pubMagicMetadata? } * * The test builds a mock server with two collections, each with files * that have different combinations of metadata layers, and verifies * the output tree is correct and complete. */ import { gzipSync } from "node:zlib"; import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import sodium from "libsodium-wrappers-sumo"; import { SRP, SrpServer } from "fast-srp-hap"; import { beforeAll, afterAll, describe, expect, it } from "vitest"; import { init, toBase64, deriveKEK, deriveLoginSubkey, encryptBlob, } from "../../src/crypto/index.js"; import sharp from "sharp"; import { Client } from "../../src/client.js"; import { runMetadataBackup } from "../../src/metadata-backup.js"; import type { KeyAttributes } from "../../src/auth/types.js"; const TEST_EMAIL = "metabackup@example.com"; const TEST_PASSWORD = "metapass"; const TEST_OPS = 2; const TEST_MEM = 64 * 1024 * 1024; interface MetaMockState { verifier: Buffer; srpAttributes: Record; keyAttributes: KeyAttributes; encryptedToken: string; collections: Record[]; filesByCollection: Record[]>; // For ML data and EXIF tests encryptedMLData: Record< number, { encryptedData: string; decryptionHeader: string } >; fileCiphertexts: Record; } let mock: MetaMockState; let testDir: string; const encryptSecretbox = (plaintext: Uint8Array, key: Uint8Array) => { const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES); const ciphertext = sodium.crypto_secretbox_easy(plaintext, nonce, key); return { ciphertext, nonce }; }; const encryptStreamBlob = (plaintext: Uint8Array, key: Uint8Array) => { const push = sodium.crypto_secretstream_xchacha20poly1305_init_push(key); const ciphertext = sodium.crypto_secretstream_xchacha20poly1305_push( push.state, plaintext, null, sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL, ); return { ciphertext, header: push.header }; }; const buildMetaMock = async (): Promise => { const kekSalt = sodium.randombytes_buf(sodium.crypto_pwhash_SALTBYTES); const kek = await deriveKEK(TEST_PASSWORD, kekSalt, TEST_OPS, TEST_MEM); const loginSubKeyBytes = deriveLoginSubkey(kek); const srpUserID = "meta-srp"; const srpSalt = sodium.randombytes_buf(16); const verifier = SRP.computeVerifier( SRP.params["4096"], Buffer.from(srpSalt), Buffer.from(srpUserID), Buffer.from(loginSubKeyBytes), ); const masterKey = sodium.randombytes_buf(32); const { ciphertext: encMK, nonce: mkNonce } = encryptSecretbox( masterKey, kek, ); const kp = sodium.crypto_box_keypair(); const { ciphertext: encSK, nonce: skNonce } = encryptSecretbox( kp.privateKey, masterKey, ); const tokenBytes = sodium.randombytes_buf(32); const encToken = sodium.crypto_box_seal(tokenBytes, kp.publicKey); const keyAttributes: KeyAttributes = { kekSalt: toBase64(kekSalt), encryptedKey: toBase64(encMK), keyDecryptionNonce: toBase64(mkNonce), publicKey: toBase64(kp.publicKey), encryptedSecretKey: toBase64(encSK), secretKeyDecryptionNonce: toBase64(skNonce), memLimit: TEST_MEM, opsLimit: TEST_OPS, }; // Collection 1: "Vacation" with collection-level pubMagicMetadata const ck1 = sodium.crypto_secretbox_keygen(); const { ciphertext: encCK1, nonce: ck1N } = encryptSecretbox( ck1, masterKey, ); const { ciphertext: encCN1, nonce: cn1N } = encryptSecretbox( new TextEncoder().encode("Vacation"), ck1, ); const collPubMagic = JSON.stringify({ coverID: 999, sortBy: "date" }); const { ciphertext: encCollPM, header: collPMHeader } = encryptStreamBlob( new TextEncoder().encode(collPubMagic), ck1, ); const rawColl1 = { id: 10, owner: { id: 42 }, encryptedKey: toBase64(encCK1), keyDecryptionNonce: toBase64(ck1N), encryptedName: toBase64(encCN1), nameDecryptionNonce: toBase64(cn1N), type: "album", updationTime: 1700000000000000, pubMagicMetadata: { version: 1, count: 1, data: toBase64(encCollPM), header: toBase64(collPMHeader), }, }; // Collection 2: "Work" with no magic metadata const ck2 = sodium.crypto_secretbox_keygen(); const { ciphertext: encCK2, nonce: ck2N } = encryptSecretbox( ck2, masterKey, ); const { ciphertext: encCN2, nonce: cn2N } = encryptSecretbox( new TextEncoder().encode("Work"), ck2, ); const rawColl2 = { id: 20, owner: { id: 42 }, encryptedKey: toBase64(encCK2), keyDecryptionNonce: toBase64(ck2N), encryptedName: toBase64(encCN2), nameDecryptionNonce: toBase64(cn2N), type: "folder", updationTime: 1700000000000000, }; // File 100 in coll 10: has metadata + pubMagicMetadata const fk1 = sodium.crypto_secretstream_xchacha20poly1305_keygen(); const { ciphertext: encFK1, nonce: fk1N } = encryptSecretbox(fk1, ck1); const meta1 = JSON.stringify({ title: "beach.jpg", fileType: 0, creationTime: 1700000000000000, modificationTime: 1700000000000000, latitude: 35.6762, longitude: 139.6503, }); const { ciphertext: encMeta1, header: meta1Header } = encryptStreamBlob( new TextEncoder().encode(meta1), fk1, ); const pubMagic1 = JSON.stringify({ w: 3000, h: 2000, cameraMake: "SONY", cameraModel: "DSC-RX1RM3", }); const { ciphertext: encPM1, header: pm1Header } = encryptStreamBlob( new TextEncoder().encode(pubMagic1), fk1, ); const rawFile1 = { id: 100, collectionID: 10, ownerID: 42, encryptedKey: toBase64(encFK1), keyDecryptionNonce: toBase64(fk1N), metadata: { encryptedData: toBase64(encMeta1), decryptionHeader: toBase64(meta1Header), }, pubMagicMetadata: { version: 1, count: 1, data: toBase64(encPM1), header: toBase64(pm1Header), }, file: { decryptionHeader: toBase64(sodium.randombytes_buf(24)) }, thumbnail: { decryptionHeader: toBase64(sodium.randombytes_buf(24)) }, updationTime: 1700000000000000, }; // File 200 in coll 20: metadata only, no magic metadata const fk2 = sodium.crypto_secretstream_xchacha20poly1305_keygen(); const { ciphertext: encFK2, nonce: fk2N } = encryptSecretbox(fk2, ck2); const meta2 = JSON.stringify({ title: "diagram.png", fileType: 0, creationTime: 1710000000000000, modificationTime: 1710000000000000, }); const { ciphertext: encMeta2, header: meta2Header } = encryptStreamBlob( new TextEncoder().encode(meta2), fk2, ); const rawFile2 = { id: 200, collectionID: 20, ownerID: 42, encryptedKey: toBase64(encFK2), keyDecryptionNonce: toBase64(fk2N), metadata: { encryptedData: toBase64(encMeta2), decryptionHeader: toBase64(meta2Header), }, file: { decryptionHeader: toBase64(sodium.randombytes_buf(24)) }, thumbnail: { decryptionHeader: toBase64(sodium.randombytes_buf(24)) }, updationTime: 1710000000000000, }; // Encrypt ML data for file 100 (gzipped JSON, encrypted with file key) const mlPayload = JSON.stringify({ face: { version: 1, client: "test", width: 3000, height: 2000, faces: [ { faceID: "face-abc", detection: { box: { x: 0.1, y: 0.2, width: 0.3, height: 0.4 }, landmarks: [ { x: 0.15, y: 0.25 }, { x: 0.25, y: 0.25 }, ], }, score: 0.98, blur: 12.5, embedding: [0.1, 0.2, 0.3], }, ], }, clip: { version: 1, client: "test", embedding: [0.5, 0.6, 0.7], }, }); const gzipped = gzipSync(Buffer.from(mlPayload)); const { header: mlHeader, ciphertext: mlCiphertext } = encryptBlob( new Uint8Array(gzipped), fk1, ); // Generate a real JPEG for EXIF extraction tests const tinyJpeg = await sharp({ create: { width: 100, height: 80, channels: 3, background: "red" }, }) .jpeg({ quality: 80 }) .toBuffer(); const filePush1 = sodium.crypto_secretstream_xchacha20poly1305_init_push(fk1); const encFileBody1 = sodium.crypto_secretstream_xchacha20poly1305_push( filePush1.state, new Uint8Array(tinyJpeg), null, sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL, ); // Patch rawFile1's file.decryptionHeader to match the push header rawFile1.file.decryptionHeader = toBase64(filePush1.header); return { verifier, srpAttributes: { srpUserID, srpSalt: toBase64(srpSalt), memLimit: TEST_MEM, opsLimit: TEST_OPS, kekSalt: toBase64(kekSalt), isEmailMFAEnabled: false, }, keyAttributes, encryptedToken: toBase64(encToken), collections: [rawColl1, rawColl2], filesByCollection: { 10: [rawFile1], 20: [rawFile2] }, encryptedMLData: { 100: { encryptedData: toBase64(mlCiphertext), decryptionHeader: toBase64(mlHeader), }, }, fileCiphertexts: { 100: encFileBody1 }, }; }; const buildMetaFetch = (m: MetaMockState) => { let srpServer: SrpServer; return (async ( input: RequestInfo | URL, init?: RequestInit, ): Promise => { const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; const path = new URL(url).pathname; const json = (body: unknown) => new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" }, }); if (path === "/users/srp/attributes") return json({ attributes: m.srpAttributes }); if (path === "/users/srp/create-session") { const body = JSON.parse(init?.body as string); const serverKey = await SRP.genKey(); srpServer = new SrpServer( SRP.params["4096"], m.verifier, serverKey, ); const B = srpServer.computeB(); srpServer.setA(Buffer.from(body.srpA, "base64")); return json({ sessionID: "s1", srpB: B.toString("base64") }); } if (path === "/users/srp/verify-session") { const body = JSON.parse(init?.body as string); srpServer.checkM1(Buffer.from(body.srpM1, "base64")); return json({ srpM2: srpServer.computeM2().toString("base64"), id: 42, keyAttributes: m.keyAttributes, encryptedToken: m.encryptedToken, }); } if (path === "/collections/v2") return json({ collections: m.collections }); if (path === "/collections/v2/diff") { const collID = Number( new URL(url).searchParams.get("collectionID"), ); return json({ diff: m.filesByCollection[collID] ?? [], hasMore: false, }); } if (path === "/files/data/fetch") { const body = JSON.parse(init?.body as string); const data = (body.fileIDs as number[]) .filter((id: number) => m.encryptedMLData[id]) .map((id: number) => ({ fileID: id, ...m.encryptedMLData[id], updatedAt: 1700000000000000, })); return json({ data }); } if ( url.includes("files.ente.io") || path.startsWith("/files/download/") ) { const parsed = new URL(url); const fileID = Number( parsed.searchParams.get("fileID") ?? path.split("/").pop(), ); const ct = m.fileCiphertexts[fileID]; if (ct) return new Response(ct, { status: 200 }); return new Response("not found", { status: 404 }); } return new Response("not found", { status: 404 }); }) as typeof globalThis.fetch; }; beforeAll(async () => { await init(); await sodium.ready; mock = await buildMetaMock(); testDir = mkdtempSync(join(tmpdir(), "quak-meta-backup-test-")); }); afterAll(() => { if (testDir && existsSync(testDir)) rmSync(testDir, { recursive: true, force: true }); }); describe("quak backup-metadata", () => { it("writes account.json with email and userID", async () => { const outDir = join(testDir, "full"); const client = await Client.login({ email: TEST_EMAIL, password: TEST_PASSWORD, apiOptions: { fetch: buildMetaFetch(mock) }, }); await runMetadataBackup(client, outDir); const account = JSON.parse( readFileSync(join(outDir, "account.json"), "utf-8"), ); expect(account.email).toBe(TEST_EMAIL); expect(account.userID).toBe(42); }); it("creates per-collection directories with _collection.json", async () => { const outDir = join(testDir, "collections"); const client = await Client.login({ email: TEST_EMAIL, password: TEST_PASSWORD, apiOptions: { fetch: buildMetaFetch(mock) }, }); await runMetadataBackup(client, outDir); const collDirs = readdirSync(join(outDir, "collections")); expect(collDirs.length).toBe(2); // Find the Vacation collection dir (prefixed with ID) const vacDir = collDirs.find((d) => d.includes("Vacation"))!; expect(vacDir).toBeDefined(); const collMeta = JSON.parse( readFileSync( join(outDir, "collections", vacDir, "_collection.json"), "utf-8", ), ); expect(collMeta.id).toBe(10); expect(collMeta.name).toBe("Vacation"); expect(collMeta.type).toBe("album"); }); it("decrypts collection-level pubMagicMetadata", async () => { const outDir = join(testDir, "coll-magic"); const client = await Client.login({ email: TEST_EMAIL, password: TEST_PASSWORD, apiOptions: { fetch: buildMetaFetch(mock) }, }); await runMetadataBackup(client, outDir); const collDirs = readdirSync(join(outDir, "collections")); const vacDir = collDirs.find((d) => d.includes("Vacation"))!; const collMeta = JSON.parse( readFileSync( join(outDir, "collections", vacDir, "_collection.json"), "utf-8", ), ); expect(collMeta.pubMagicMetadata).toBeDefined(); expect(collMeta.pubMagicMetadata.coverID).toBe(999); expect(collMeta.pubMagicMetadata.sortBy).toBe("date"); }); it("writes per-file JSON with all three metadata layers", async () => { const outDir = join(testDir, "file-meta"); const client = await Client.login({ email: TEST_EMAIL, password: TEST_PASSWORD, apiOptions: { fetch: buildMetaFetch(mock) }, }); await runMetadataBackup(client, outDir); const collDirs = readdirSync(join(outDir, "collections")); const vacDir = collDirs.find((d) => d.includes("Vacation"))!; const fileMeta = JSON.parse( readFileSync( join(outDir, "collections", vacDir, "100.json"), "utf-8", ), ); expect(fileMeta.id).toBe(100); expect(fileMeta.metadata.title).toBe("beach.jpg"); expect(fileMeta.metadata.latitude).toBeCloseTo(35.6762); expect(fileMeta.pubMagicMetadata.cameraMake).toBe("SONY"); expect(fileMeta.pubMagicMetadata.w).toBe(3000); }); it("handles files with no magic metadata gracefully", async () => { const outDir = join(testDir, "no-magic"); const client = await Client.login({ email: TEST_EMAIL, password: TEST_PASSWORD, apiOptions: { fetch: buildMetaFetch(mock) }, }); await runMetadataBackup(client, outDir); const collDirs = readdirSync(join(outDir, "collections")); const workDir = collDirs.find((d) => d.includes("Work"))!; const fileMeta = JSON.parse( readFileSync( join(outDir, "collections", workDir, "200.json"), "utf-8", ), ); expect(fileMeta.id).toBe(200); expect(fileMeta.metadata.title).toBe("diagram.png"); expect(fileMeta.pubMagicMetadata).toBeUndefined(); expect(fileMeta.magicMetadata).toBeUndefined(); }); it("is incremental: second run does not fail", async () => { const outDir = join(testDir, "incremental"); const client = await Client.login({ email: TEST_EMAIL, password: TEST_PASSWORD, apiOptions: { fetch: buildMetaFetch(mock) }, }); await runMetadataBackup(client, outDir); await runMetadataBackup(client, outDir); const account = JSON.parse( readFileSync(join(outDir, "account.json"), "utf-8"), ); expect(account.email).toBe(TEST_EMAIL); }); it("fetches and decrypts ML data when --ml is set", async () => { const outDir = join(testDir, "ml-data"); const client = await Client.login({ email: TEST_EMAIL, password: TEST_PASSWORD, apiOptions: { fetch: buildMetaFetch(mock) }, }); await runMetadataBackup(client, outDir, { mlData: true }); const collDirs = readdirSync(join(outDir, "collections")); const vacDir = collDirs.find((d) => d.includes("Vacation"))!; const fileMeta = JSON.parse( readFileSync( join(outDir, "collections", vacDir, "100.json"), "utf-8", ), ); // ML data should be present and decrypted expect(fileMeta.mlData).toBeDefined(); expect(fileMeta.mlData.face).toBeDefined(); expect(fileMeta.mlData.face.faces.length).toBe(1); expect(fileMeta.mlData.face.faces[0].faceID).toBe("face-abc"); expect(fileMeta.mlData.face.faces[0].score).toBeCloseTo(0.98); expect(fileMeta.mlData.face.faces[0].detection.box.x).toBeCloseTo(0.1); expect(fileMeta.mlData.clip).toBeDefined(); expect(fileMeta.mlData.clip.embedding).toEqual([0.5, 0.6, 0.7]); }); it("does not include ML data when --ml is not set", async () => { const outDir = join(testDir, "no-ml"); const client = await Client.login({ email: TEST_EMAIL, password: TEST_PASSWORD, apiOptions: { fetch: buildMetaFetch(mock) }, }); await runMetadataBackup(client, outDir); const collDirs = readdirSync(join(outDir, "collections")); const vacDir = collDirs.find((d) => d.includes("Vacation"))!; const fileMeta = JSON.parse( readFileSync( join(outDir, "collections", vacDir, "100.json"), "utf-8", ), ); expect(fileMeta.mlData).toBeUndefined(); }); it("extracts EXIF from downloaded files when --exif is set", async () => { const outDir = join(testDir, "exif-data"); const client = await Client.login({ email: TEST_EMAIL, password: TEST_PASSWORD, apiOptions: { fetch: buildMetaFetch(mock) }, }); await runMetadataBackup(client, outDir, { exif: true }); const collDirs = readdirSync(join(outDir, "collections")); const vacDir = collDirs.find((d) => d.includes("Vacation"))!; const fileMeta = JSON.parse( readFileSync( join(outDir, "collections", vacDir, "100.json"), "utf-8", ), ); // imageMetadata from sharp should be present expect(fileMeta.imageMetadata).toBeDefined(); expect(fileMeta.imageMetadata.format).toBe("jpeg"); expect(fileMeta.imageMetadata.width).toBe(100); expect(fileMeta.imageMetadata.height).toBe(80); expect(fileMeta.imageMetadata.channels).toBe(3); }); });