check / check (push) Waiting to run
test/cli/backup-end-to-end.test.ts runs backupCommand through run, as bin/quak.ts does, with the real loadSession reading a session saved after logging in, and every request answered by a fake of the Ente HTTP API serving data encrypted with libsodium. It covers the backup tree of an account with two albums of its own and one shared with it, a second run that downloads nothing, a download that succeeds after two 503s, a 404 recorded in failures.json with exit 1, a refresh answered 401 with exit 3, and --verify putting back an original changed on disk. The fake server and the account it logs in moved from the backup-metadata test into test/ente-server.ts, unchanged, so both tests use them. Model: opus-5-5
198 lines
7.4 KiB
TypeScript
198 lines
7.4 KiB
TypeScript
/**
|
|
* 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<string, unknown>;
|
|
keyAttributes: KeyAttributes;
|
|
encryptedToken: string;
|
|
collections: Record<string, unknown>[];
|
|
filesByCollection: Record<number, Record<string, unknown>[]>;
|
|
// For ML data and EXIF tests
|
|
encryptedMLData: Record<
|
|
number,
|
|
{ encryptedData: string; decryptionHeader: string }
|
|
>;
|
|
fileCiphertexts: Record<number, Uint8Array>;
|
|
}
|
|
|
|
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<Response> => {
|
|
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;
|
|
};
|