/** * # Using the quak Client * * This test file is a tutorial. It walks through every operation the * library supports, in the order you would use them in a real program. * Each `it()` block is a self-contained example with commentary * explaining what is happening and why. If you are reading the quak * source for the first time, start here. * * The tests run against a mock Ente server built from the same SRP and * crypto primitives the real server uses, so the data flowing through * the Client is structurally identical to production data. Nothing hits * the network. * * * ## Table of contents * * 1. Logging in * 2. Inspecting account info * 3. Listing collections (albums) * 4. Listing files in a collection * 5. Downloading a file * 6. Downloading a thumbnail * 7. Serializing and restoring a session * 8. Logging out * * * ## Prerequisites * * All you need is the `Client` class: * * ```ts * import { Client } from "quak"; * ``` * * The Client wraps every lower-level module (crypto, auth, api, model, * download) into a single object. You create one by logging in, and * then call methods on it for the lifetime of your program. The session * (auth token, master key, secret key) lives in memory on the Client * instance. If you want to persist it across process restarts, call * `client.toJSON()` to get a serializable snapshot, store it however * you like, and later restore it with `Client.fromJSON(snapshot)`. */ import { mkdtempSync, readFileSync, rmSync, existsSync } 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, } from "../../src/crypto/index.js"; import { Client } from "../../src/client.js"; import type { KeyAttributes } from "../../src/auth/types.js"; // --------------------------------------------------------------------------- // Mock server // --------------------------------------------------------------------------- // // Everything below this line builds a fake Ente backend that the Client // talks to via an injected `fetch`. You would not write any of this in a // real program; it exists only so the tests are self-contained. const TEST_EMAIL = "user@example.com"; const TEST_PASSWORD = "hunter2"; const TEST_OPS = 2; const TEST_MEM = 64 * 1024 * 1024; interface ServerState { srpAttributes: { srpUserID: string; srpSalt: string; memLimit: number; opsLimit: number; kekSalt: string; isEmailMFAEnabled: boolean; }; verifier: Buffer; keyAttributes: KeyAttributes; encryptedToken: string; masterKey: Uint8Array; photoPlaintext: Uint8Array; photoKey: Uint8Array; photoHeader: Uint8Array; photoCiphertext: Uint8Array; thumbPlaintext: Uint8Array; thumbHeader: Uint8Array; thumbCiphertext: Uint8Array; collectionKey: Uint8Array; } let server: ServerState; let testDir: string; const buildServer = 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 = "mock-srp-user"; const srpSalt = sodium.randombytes_buf(16); const verifier = SRP.computeVerifier( SRP.params["4096"], Buffer.from(srpSalt), Buffer.from(srpUserID), Buffer.from(loginSubKeyBytes), ); // Master key, keypair, token const masterKey = sodium.randombytes_buf(32); const keyNonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES); const encryptedKey = sodium.crypto_secretbox_easy(masterKey, keyNonce, kek); const kp = sodium.crypto_box_keypair(); const skNonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES); const encryptedSecretKey = sodium.crypto_secretbox_easy( kp.privateKey, skNonce, masterKey, ); const tokenBytes = sodium.randombytes_buf(32); const encryptedToken = sodium.crypto_box_seal(tokenBytes, kp.publicKey); const keyAttributes: KeyAttributes = { kekSalt: toBase64(kekSalt), encryptedKey: toBase64(encryptedKey), keyDecryptionNonce: toBase64(keyNonce), publicKey: toBase64(kp.publicKey), encryptedSecretKey: toBase64(encryptedSecretKey), secretKeyDecryptionNonce: toBase64(skNonce), memLimit: TEST_MEM, opsLimit: TEST_OPS, }; // A collection with one photo const collectionKey = sodium.crypto_secretbox_keygen(); const ckNonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES); const encCollKey = sodium.crypto_secretbox_easy( collectionKey, ckNonce, masterKey, ); const collName = new TextEncoder().encode("Vacation"); const cnNonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES); const encCollName = sodium.crypto_secretbox_easy( collName, cnNonce, collectionKey, ); // A file inside that collection const photoKey = sodium.crypto_secretstream_xchacha20poly1305_keygen(); const fkNonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES); const encFileKey = sodium.crypto_secretbox_easy( photoKey, fkNonce, collectionKey, ); const metadata = JSON.stringify({ title: "beach.jpg", fileType: 0, creationTime: 1700000000000000, modificationTime: 1700000000000000, latitude: 35.6762, longitude: 139.6503, }); const metaPush = sodium.crypto_secretstream_xchacha20poly1305_init_push(photoKey); const encMeta = sodium.crypto_secretstream_xchacha20poly1305_push( metaPush.state, new TextEncoder().encode(metadata), null, sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL, ); // Encrypted file body (a small fake JPEG) const photoPlaintext = sodium.randombytes_buf(5000); const filePush = sodium.crypto_secretstream_xchacha20poly1305_init_push(photoKey); const photoCiphertext = sodium.crypto_secretstream_xchacha20poly1305_push( filePush.state, photoPlaintext, null, sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL, ); // Encrypted thumbnail const thumbPlaintext = sodium.randombytes_buf(800); const thumbPush = sodium.crypto_secretstream_xchacha20poly1305_init_push(photoKey); const thumbCiphertext = sodium.crypto_secretstream_xchacha20poly1305_push( thumbPush.state, thumbPlaintext, null, sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL, ); // Wire up the mock fetch responses const rawCollection = { id: 1, owner: { id: 42 }, encryptedKey: toBase64(encCollKey), keyDecryptionNonce: toBase64(ckNonce), encryptedName: toBase64(encCollName), nameDecryptionNonce: toBase64(cnNonce), type: "album", updationTime: 1700000000000000, }; const rawFile = { id: 100, collectionID: 1, ownerID: 42, encryptedKey: toBase64(encFileKey), keyDecryptionNonce: toBase64(fkNonce), metadata: { encryptedData: toBase64(encMeta), decryptionHeader: toBase64(metaPush.header), }, file: { decryptionHeader: toBase64(filePush.header) }, thumbnail: { decryptionHeader: toBase64(thumbPush.header) }, updationTime: 1700000000000000, }; // Store the raw JSON responses on the server state so the mock fetch // can return them. This is ugly plumbing; in a real program you // never see any of it. (globalThis as Record).__mockRawCollection = rawCollection; (globalThis as Record).__mockRawFile = rawFile; return { srpAttributes: { srpUserID, srpSalt: toBase64(srpSalt), memLimit: TEST_MEM, opsLimit: TEST_OPS, kekSalt: toBase64(kekSalt), isEmailMFAEnabled: false, }, verifier, keyAttributes, encryptedToken: toBase64(encryptedToken), masterKey, photoPlaintext, photoKey, photoHeader: filePush.header, photoCiphertext, thumbPlaintext, thumbHeader: thumbPush.header, thumbCiphertext, collectionKey, }; }; const buildMockFetch = (s: ServerState) => { 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: s.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"], s.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")); const M2 = srpServer.computeM2(); return json({ srpM2: M2.toString("base64"), id: 42, keyAttributes: s.keyAttributes, encryptedToken: s.encryptedToken, }); } if (path === "/collections/v2") { const raw = (globalThis as Record) .__mockRawCollection; return json({ collections: [raw] }); } if (path === "/collections/v2/diff") { const raw = (globalThis as Record).__mockRawFile; return json({ diff: [raw], hasMore: false }); } if (url.includes("files.ente.io") || path === "/files/download/100") { return new Response(s.photoCiphertext, { status: 200 }); } if ( url.includes("thumbnails.ente.io") || path === "/files/preview/100" ) { return new Response(s.thumbCiphertext, { status: 200 }); } return new Response("not found", { status: 404 }); }) as typeof globalThis.fetch; }; // --------------------------------------------------------------------------- // Setup / teardown // --------------------------------------------------------------------------- beforeAll(async () => { await init(); await sodium.ready; server = await buildServer(); testDir = mkdtempSync(join(tmpdir(), "quak-usage-test-")); }); afterAll(() => { if (testDir && existsSync(testDir)) rmSync(testDir, { recursive: true, force: true }); }); // --------------------------------------------------------------------------- // The tutorial // --------------------------------------------------------------------------- describe("quak Client usage guide", () => { /** * ## 1. Logging in * * Create a Client by calling `Client.login()`. This performs the * entire authentication handshake (SRP key exchange, key derivation, * key unwrapping) and returns a ready-to-use Client. The password * never leaves the process. * * If the account requires a second factor, `login()` accepts * optional callbacks for TOTP and email OTP. If neither is provided * and the server demands 2FA, `login()` throws. * * ```ts * const client = await Client.login({ * email: "you@example.com", * password: "your-password", * // Optional: supply these if the account has 2FA enabled. * // totp: async () => prompt("Enter TOTP code: "), * // emailOTP: async () => prompt("Enter email code: "), * }); * ``` */ it("1. log in with email and password", async () => { const client = await Client.login({ email: TEST_EMAIL, password: TEST_PASSWORD, apiOptions: { fetch: buildMockFetch(server) }, }); // The client is now authenticated. All subsequent calls use the // auth token internally. expect(client).toBeInstanceOf(Client); }); /** * ## 2. Inspecting account info * * After login, the client knows the user's email and numeric ID. * The master key and secret key are held in memory but not directly * exposed; they are used internally by collection/file decryption. * * ```ts * const { email, userID } = client.whoami(); * console.log(`Logged in as ${email} (id ${userID})`); * ``` */ it("2. inspect account info with whoami()", async () => { const client = await Client.login({ email: TEST_EMAIL, password: TEST_PASSWORD, apiOptions: { fetch: buildMockFetch(server) }, }); const info = client.whoami(); expect(info.email).toBe(TEST_EMAIL); expect(info.userID).toBe(42); }); /** * ## 3. Listing collections * * Collections are Ente's name for albums. Each collection has a * name, a type (album, folder, favorites, uncategorized), and an * encryption key. `listCollections()` fetches them from the server, * decrypts the keys and names, and returns typed objects. * * ```ts * const collections = await client.listCollections(); * for (const c of collections) { * console.log(`${c.name} [${c.type}] — ${c.id}`); * } * ``` */ it("3. list and decrypt collections", async () => { const client = await Client.login({ email: TEST_EMAIL, password: TEST_PASSWORD, apiOptions: { fetch: buildMockFetch(server) }, }); const collections = await client.listCollections(); expect(collections.length).toBe(1); expect(collections[0]!.name).toBe("Vacation"); expect(collections[0]!.type).toBe("album"); expect(collections[0]!.id).toBe(1); // The decrypted collection key is available for advanced use. expect(collections[0]!.key.length).toBe(32); }); /** * ## 4. Listing files in a collection * * Each collection contains files (photos, videos, live photos). * `listFiles()` fetches the file list, decrypts each file's key * and metadata (title, type, creation time, GPS coordinates), and * returns typed objects. * * ```ts * const files = await client.listFiles(collection.id, collection.key); * for (const f of files) { * console.log(`${f.metadata.title} [${f.metadata.fileType}]`); * if (f.metadata.latitude) { * console.log(` at ${f.metadata.latitude}, ${f.metadata.longitude}`); * } * } * ``` */ it("4. list and decrypt files", async () => { const client = await Client.login({ email: TEST_EMAIL, password: TEST_PASSWORD, apiOptions: { fetch: buildMockFetch(server) }, }); const collections = await client.listCollections(); const col = collections[0]!; const files = await client.listFiles(col.id, col.key); expect(files.length).toBe(1); expect(files[0]!.metadata.title).toBe("beach.jpg"); expect(files[0]!.metadata.fileType).toBe("image"); expect(files[0]!.metadata.latitude).toBeCloseTo(35.6762); expect(files[0]!.metadata.longitude).toBeCloseTo(139.6503); }); /** * ## 5. Downloading a file * * `downloadFile()` fetches the encrypted file body from the CDN, * decrypts it chunk by chunk using the file's secretstream key and * header, and writes the plaintext to disk. * * If you omit `outPath`, the file is written to the current * directory using the title from the decrypted metadata. * * ```ts * const result = await client.downloadFile(file, "/tmp/beach.jpg"); * console.log(`Wrote ${result.bytesWritten} bytes to ${result.path}`); * ``` */ it("5. download and decrypt a file to disk", async () => { const client = await Client.login({ email: TEST_EMAIL, password: TEST_PASSWORD, apiOptions: { fetch: buildMockFetch(server) }, }); const collections = await client.listCollections(); const files = await client.listFiles( collections[0]!.id, collections[0]!.key, ); const file = files[0]!; const outPath = join(testDir, "beach.jpg"); const result = await client.downloadFile(file, outPath); expect(result.path).toBe(outPath); expect(result.bytesWritten).toBe(server.photoPlaintext.length); expect(readFileSync(outPath)).toEqual( Buffer.from(server.photoPlaintext), ); }); /** * ## 6. Downloading a thumbnail * * Thumbnails are encrypted the same way as full files, just served * from a different CDN endpoint. The API is identical. * * ```ts * const result = await client.downloadThumbnail(file, "/tmp/thumb.jpg"); * ``` */ it("6. download and decrypt a thumbnail", async () => { const client = await Client.login({ email: TEST_EMAIL, password: TEST_PASSWORD, apiOptions: { fetch: buildMockFetch(server) }, }); const collections = await client.listCollections(); const files = await client.listFiles( collections[0]!.id, collections[0]!.key, ); const file = files[0]!; const outPath = join(testDir, "thumb.jpg"); const result = await client.downloadThumbnail(file, outPath); expect(result.bytesWritten).toBe(server.thumbPlaintext.length); expect(readFileSync(outPath)).toEqual( Buffer.from(server.thumbPlaintext), ); }); /** * ## 7. Serializing and restoring a session * * The Client holds the auth token and key material in memory. If * you need to persist a session across process restarts (for a CLI * that doesn't re-prompt for a password every time, or a long-lived * daemon), call `toJSON()` to get a plain object you can * JSON.stringify and store however you like. Later, pass it to * `Client.fromJSON()` to get back a working Client without logging * in again. * * The serialized form contains the master key and secret key. Treat * it as you would treat the password itself. Encrypt it at rest if * you store it on disk. * * ```ts * // Save * const snapshot = client.toJSON(); * fs.writeFileSync("session.json", JSON.stringify(snapshot)); * * // Restore (in a later process) * const data = JSON.parse(fs.readFileSync("session.json", "utf-8")); * const restored = Client.fromJSON(data); * const collections = await restored.listCollections(); // works * ``` */ it("7. serialize with toJSON() and restore with fromJSON()", async () => { const client = await Client.login({ email: TEST_EMAIL, password: TEST_PASSWORD, apiOptions: { fetch: buildMockFetch(server) }, }); // Serialize to a plain object. This is what you would persist. const snapshot = client.toJSON(); expect(snapshot.email).toBe(TEST_EMAIL); expect(snapshot.userID).toBe(42); expect(typeof snapshot.token).toBe("string"); expect(typeof snapshot.masterKey).toBe("string"); // base64 // Restore from the snapshot. The new client must be able to do // everything the original could, without logging in again. const restored = Client.fromJSON(snapshot, { fetch: buildMockFetch(server), }); expect(restored.whoami().email).toBe(TEST_EMAIL); const collections = await restored.listCollections(); expect(collections[0]!.name).toBe("Vacation"); }); /** * ## 8. Logging out * * `logout()` clears the in-memory session. After this call, every * method that requires authentication will throw. The Client object * is no longer usable. * * ```ts * client.logout(); * // client.listCollections() would now throw * ``` */ it("8. logout clears the session", async () => { const client = await Client.login({ email: TEST_EMAIL, password: TEST_PASSWORD, apiOptions: { fetch: buildMockFetch(server) }, }); client.logout(); expect(() => client.whoami()).toThrow(); await expect(client.listCollections()).rejects.toThrow(); }); });