From ca6857d3fe53144d5476b620cee45c12f3c978b6 Mon Sep 17 00:00:00 2001 From: sneak Date: Wed, 13 May 2026 17:59:18 -0700 Subject: [PATCH] Client class red: literate usage tests and stub test/client/usage.test.ts is a tutorial-as-test-suite that walks through the entire quack API in order: login, whoami, listCollections, listFiles, downloadFile, downloadThumbnail, toJSON/fromJSON, logout. Each it() block is a self-contained example with prose commentary explaining what the code does and why, with code samples showing the API as a consumer would use it. The mock server performs real SRP and crypto so the test data is structurally identical to production. 8 tests, all failing against the stub. --- src/client.ts | 72 +++++ test/client/usage.test.ts | 620 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 692 insertions(+) create mode 100644 src/client.ts create mode 100644 test/client/usage.test.ts diff --git a/src/client.ts b/src/client.ts new file mode 100644 index 0000000..1cca50f --- /dev/null +++ b/src/client.ts @@ -0,0 +1,72 @@ +// Stub: see the README "Development workflow" section for TDD policy. + +import type { ApiClientOptions } from "./api/client.js"; +import type { Collection, EnteFile } from "./model/types.js"; +import type { DownloadResult } from "./download/index.js"; + +export interface LoginOptions { + email: string; + password: string; + totp?: () => Promise; + emailOTP?: () => Promise; + apiOptions?: ApiClientOptions; +} + +export interface ClientSnapshot { + email: string; + userID: number; + token: string; + masterKey: string; + secretKey: string; + publicKey: string; +} + +export class Client { + static async login(_opts: LoginOptions): Promise { + throw new Error("Client.login not implemented"); + } + + static fromJSON( + _snapshot: ClientSnapshot, + _apiOptions?: ApiClientOptions, + ): Client { + throw new Error("Client.fromJSON not implemented"); + } + + whoami(): { email: string; userID: number } { + throw new Error("Client.whoami not implemented"); + } + + toJSON(): ClientSnapshot { + throw new Error("Client.toJSON not implemented"); + } + + logout(): void { + throw new Error("Client.logout not implemented"); + } + + async listCollections(): Promise { + throw new Error("Client.listCollections not implemented"); + } + + async listFiles( + _collectionID: number, + _collectionKey: Uint8Array, + ): Promise { + throw new Error("Client.listFiles not implemented"); + } + + async downloadFile( + _file: EnteFile, + _outPath?: string, + ): Promise { + throw new Error("Client.downloadFile not implemented"); + } + + async downloadThumbnail( + _file: EnteFile, + _outPath?: string, + ): Promise { + throw new Error("Client.downloadThumbnail not implemented"); + } +} diff --git a/test/client/usage.test.ts b/test/client/usage.test.ts new file mode 100644 index 0000000..77f5603 --- /dev/null +++ b/test/client/usage.test.ts @@ -0,0 +1,620 @@ +/** + * # Using the quack 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 quack + * 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 "quack"; + * ``` + * + * 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(), "quack-usage-test-")); +}); + +afterAll(() => { + if (testDir && existsSync(testDir)) + rmSync(testDir, { recursive: true, force: true }); +}); + +// --------------------------------------------------------------------------- +// The tutorial +// --------------------------------------------------------------------------- + +describe("quack 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(); + }); +});