/** * A fake of the Ente HTTP API, for tests that run quak's real `Client` against * a server. * * `buildMetaFetch` is a `fetch` that answers the requests quak makes, from a * `MetaMockState` the test builds: the SRP login, `/collections/v2`, each * collection's `/collections/v2/diff`, the ML data of `/files/data/fetch`, and * each file's content from the files CDN, a 404 for a file it holds none for. * The test encrypts everything in that state with libsodium, as Ente's clients * do, so quak decrypts it with its own code. Nothing hits the network. * * `buildAccount` builds what the server holds to log an account in, and the * keys a test encrypts that account's collections with. Call `init()` from * `src/crypto` and await `sodium.ready` before using either. */ import sodium from "libsodium-wrappers-sumo"; import { SRP, SrpServer } from "fast-srp-hap"; import { toBase64, deriveKEK, deriveLoginSubkey } from "../src/crypto/index.js"; import type { KeyAttributes } from "../src/auth/types.js"; const TEST_OPS = 2; const TEST_MEM = 64 * 1024 * 1024; export 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; } export 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 }; }; export 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 }; }; // An account whose password is `password`. The first four fields are those of // `MetaMockState`; with the master key the test encrypts the keys of the // account's own collections, and to the public key it seals the keys of the // collections other accounts share with it. export const buildAccount = async (password: string) => { const kekSalt = sodium.randombytes_buf(sodium.crypto_pwhash_SALTBYTES); const kek = await deriveKEK(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, }; return { verifier, srpAttributes: { srpUserID, srpSalt: toBase64(srpSalt), memLimit: TEST_MEM, opsLimit: TEST_OPS, kekSalt: toBase64(kekSalt), isEmailMFAEnabled: false, }, keyAttributes, encryptedToken: toBase64(encToken), masterKey, publicKey: kp.publicKey, }; }; // `failMLDataFor`: answer 500 to every ML data request that asks for this file. export const buildMetaFetch = (m: MetaMockState, failMLDataFor?: number) => { 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); if ((body.fileIDs as number[]).includes(failMLDataFor!)) return new Response("server error", { status: 500 }); 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; };