Merge: Client class (OO API)
Client.login() performs full SRP + key unwrap and returns a ready object. toJSON/fromJSON for consumer-managed persistence. listCollections, listFiles (with pagination), downloadFile, downloadThumbnail, whoami, logout. 8 new tests in a literate tutorial-as-test format. 86 total tests, all green.
This commit is contained in:
15
README.md
15
README.md
@@ -649,12 +649,17 @@ Phase 6: download
|
||||
- [x] Live integration test: logs in, decrypts collections and files, downloads
|
||||
a real JPEG from the dev account and verifies it on disk
|
||||
|
||||
Phase 7: session persistence
|
||||
Phase 7: Client class
|
||||
|
||||
- [ ] `SessionStore` writing an encrypted session blob with a key from the OS
|
||||
keychain (`keytar`) or a `0600` keyfile fallback
|
||||
- [ ] `Client.fromSavedSession()` and `Client.saveSession()`
|
||||
- [ ] `quack logout` deletes the session and the keychain entry
|
||||
- [x] `Client.login({ email, password, totp?, emailOTP? })` performs the full
|
||||
SRP handshake, key unwrap, returns a ready Client
|
||||
- [x] `Client.fromJSON(snapshot)` restores from a serialized snapshot
|
||||
- [x] `client.toJSON()` produces a `ClientSnapshot` the consumer can persist
|
||||
- [x] `client.whoami()`, `client.logout()`
|
||||
- [x] `client.listCollections()` with decryption
|
||||
- [x] `client.listFiles(collectionID, collectionKey)` with pagination
|
||||
- [x] `client.downloadFile(file, outPath?)` and `client.downloadThumbnail()`
|
||||
- [x] Literate test/client/usage.test.ts tutorial covering the entire API
|
||||
|
||||
Phase 8: CLI
|
||||
|
||||
|
||||
199
src/client.ts
Normal file
199
src/client.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { ApiClient, type ApiClientOptions } from "./api/client.js";
|
||||
import {
|
||||
beginLogin,
|
||||
submitTOTP,
|
||||
requestEmailOTP,
|
||||
submitEmailOTP,
|
||||
} from "./auth/login.js";
|
||||
import { unwrapAuth } from "./auth/unwrap.js";
|
||||
import { init, fromBase64, toBase64 } from "./crypto/index.js";
|
||||
import { decryptCollection, decryptFile } from "./model/index.js";
|
||||
import {
|
||||
downloadFile as dlFile,
|
||||
downloadThumbnail as dlThumb,
|
||||
} from "./download/index.js";
|
||||
import type {
|
||||
Collection,
|
||||
EnteFile,
|
||||
RawCollection,
|
||||
RawEnteFile,
|
||||
} from "./model/types.js";
|
||||
import type { DownloadResult } from "./download/index.js";
|
||||
|
||||
export interface LoginOptions {
|
||||
email: string;
|
||||
password: string;
|
||||
totp?: () => Promise<string>;
|
||||
emailOTP?: () => Promise<string>;
|
||||
apiOptions?: ApiClientOptions;
|
||||
}
|
||||
|
||||
export interface ClientSnapshot {
|
||||
email: string;
|
||||
userID: number;
|
||||
token: string;
|
||||
masterKey: string;
|
||||
secretKey: string;
|
||||
publicKey: string;
|
||||
}
|
||||
|
||||
export class Client {
|
||||
private readonly api: ApiClient;
|
||||
private readonly email: string;
|
||||
private readonly userID: number;
|
||||
private readonly masterKey: Uint8Array;
|
||||
private readonly secretKey: Uint8Array;
|
||||
private readonly publicKey: Uint8Array;
|
||||
private loggedOut = false;
|
||||
|
||||
private constructor(
|
||||
api: ApiClient,
|
||||
email: string,
|
||||
userID: number,
|
||||
masterKey: Uint8Array,
|
||||
secretKey: Uint8Array,
|
||||
publicKey: Uint8Array,
|
||||
) {
|
||||
this.api = api;
|
||||
this.email = email;
|
||||
this.userID = userID;
|
||||
this.masterKey = masterKey;
|
||||
this.secretKey = secretKey;
|
||||
this.publicKey = publicKey;
|
||||
}
|
||||
|
||||
static async login(opts: LoginOptions): Promise<Client> {
|
||||
await init();
|
||||
const api = new ApiClient(opts.apiOptions);
|
||||
const challenge = await beginLogin(api, opts.email, opts.password);
|
||||
|
||||
let response;
|
||||
if (challenge.kind === "complete") {
|
||||
response = challenge.response;
|
||||
} else if (challenge.kind === "totp") {
|
||||
if (!opts.totp)
|
||||
throw new Error(
|
||||
"Account requires TOTP but no totp callback provided",
|
||||
);
|
||||
const code = await opts.totp();
|
||||
response = await submitTOTP(api, challenge.sessionID, code);
|
||||
} else if (challenge.kind === "emailOTP") {
|
||||
if (!opts.emailOTP)
|
||||
throw new Error(
|
||||
"Account requires email OTP but no emailOTP callback provided",
|
||||
);
|
||||
await requestEmailOTP(api, opts.email);
|
||||
const code = await opts.emailOTP();
|
||||
response = await submitEmailOTP(api, opts.email, code);
|
||||
} else if (challenge.kind === "passkey") {
|
||||
throw new Error("Passkey authentication is not supported by quack");
|
||||
} else {
|
||||
throw new Error(`Unknown login challenge kind`);
|
||||
}
|
||||
|
||||
const unwrapped = await unwrapAuth(response, opts.password);
|
||||
api.setAuthToken(unwrapped.token);
|
||||
|
||||
return new Client(
|
||||
api,
|
||||
opts.email,
|
||||
response.id,
|
||||
unwrapped.masterKey,
|
||||
unwrapped.secretKey,
|
||||
unwrapped.publicKey,
|
||||
);
|
||||
}
|
||||
|
||||
static fromJSON(
|
||||
snapshot: ClientSnapshot,
|
||||
apiOptions?: ApiClientOptions,
|
||||
): Client {
|
||||
const api = new ApiClient({ ...apiOptions, authToken: snapshot.token });
|
||||
return new Client(
|
||||
api,
|
||||
snapshot.email,
|
||||
snapshot.userID,
|
||||
fromBase64(snapshot.masterKey),
|
||||
fromBase64(snapshot.secretKey),
|
||||
fromBase64(snapshot.publicKey),
|
||||
);
|
||||
}
|
||||
|
||||
private assertLoggedIn(): void {
|
||||
if (this.loggedOut) throw new Error("Client has been logged out");
|
||||
}
|
||||
|
||||
whoami(): { email: string; userID: number } {
|
||||
this.assertLoggedIn();
|
||||
return { email: this.email, userID: this.userID };
|
||||
}
|
||||
|
||||
toJSON(): ClientSnapshot {
|
||||
this.assertLoggedIn();
|
||||
return {
|
||||
email: this.email,
|
||||
userID: this.userID,
|
||||
token: this.api["token"]!,
|
||||
masterKey: toBase64(this.masterKey),
|
||||
secretKey: toBase64(this.secretKey),
|
||||
publicKey: toBase64(this.publicKey),
|
||||
};
|
||||
}
|
||||
|
||||
logout(): void {
|
||||
this.loggedOut = true;
|
||||
this.api.clearAuthToken();
|
||||
}
|
||||
|
||||
async listCollections(): Promise<Collection[]> {
|
||||
this.assertLoggedIn();
|
||||
const { collections } = await this.api.getJSON<{
|
||||
collections: RawCollection[];
|
||||
}>("/collections/v2", { sinceTime: 0 });
|
||||
return collections.map((raw) =>
|
||||
decryptCollection(raw, this.masterKey, this.userID),
|
||||
);
|
||||
}
|
||||
|
||||
async listFiles(
|
||||
collectionID: number,
|
||||
collectionKey: Uint8Array,
|
||||
): Promise<EnteFile[]> {
|
||||
this.assertLoggedIn();
|
||||
const allFiles: EnteFile[] = [];
|
||||
let sinceTime = 0;
|
||||
for (;;) {
|
||||
const { diff, hasMore } = await this.api.getJSON<{
|
||||
diff: RawEnteFile[];
|
||||
hasMore: boolean;
|
||||
}>("/collections/v2/diff", { collectionID, sinceTime });
|
||||
|
||||
for (const raw of diff) {
|
||||
if (!raw.isDeleted) {
|
||||
allFiles.push(decryptFile(raw, collectionKey));
|
||||
}
|
||||
if (raw.updationTime > sinceTime) {
|
||||
sinceTime = raw.updationTime;
|
||||
}
|
||||
}
|
||||
if (!hasMore) break;
|
||||
}
|
||||
return allFiles;
|
||||
}
|
||||
|
||||
async downloadFile(
|
||||
file: EnteFile,
|
||||
outPath?: string,
|
||||
): Promise<DownloadResult> {
|
||||
this.assertLoggedIn();
|
||||
return dlFile(this.api, file, outPath);
|
||||
}
|
||||
|
||||
async downloadThumbnail(
|
||||
file: EnteFile,
|
||||
outPath?: string,
|
||||
): Promise<DownloadResult> {
|
||||
this.assertLoggedIn();
|
||||
return dlThumb(this.api, file, outPath);
|
||||
}
|
||||
}
|
||||
22
src/index.ts
22
src/index.ts
@@ -1,9 +1,31 @@
|
||||
export const VERSION = "0.0.0";
|
||||
|
||||
export { Client, type LoginOptions, type ClientSnapshot } from "./client.js";
|
||||
export { ApiClient, ApiError, type ApiClientOptions } from "./api/client.js";
|
||||
export { unwrapAuth, type UnwrapResult } from "./auth/unwrap.js";
|
||||
export {
|
||||
beginLogin,
|
||||
submitTOTP,
|
||||
requestEmailOTP,
|
||||
submitEmailOTP,
|
||||
} from "./auth/login.js";
|
||||
export { decryptCollection, decryptFile } from "./model/index.js";
|
||||
export { downloadFile, downloadThumbnail } from "./download/index.js";
|
||||
export type {
|
||||
AuthorizationResponse,
|
||||
KeyAttributes,
|
||||
LoginChallenge,
|
||||
SRPAttributes,
|
||||
} from "./auth/types.js";
|
||||
export type {
|
||||
Collection,
|
||||
CollectionType,
|
||||
EnteFile,
|
||||
FileBlob,
|
||||
FileMetadata,
|
||||
FileType,
|
||||
Microseconds,
|
||||
RawCollection,
|
||||
RawEnteFile,
|
||||
} from "./model/types.js";
|
||||
export type { DownloadResult } from "./download/index.js";
|
||||
|
||||
620
test/client/usage.test.ts
Normal file
620
test/client/usage.test.ts
Normal file
@@ -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<ServerState> => {
|
||||
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<string, unknown>).__mockRawCollection = rawCollection;
|
||||
(globalThis as Record<string, unknown>).__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<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: 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<string, unknown>)
|
||||
.__mockRawCollection;
|
||||
return json({ collections: [raw] });
|
||||
}
|
||||
if (path === "/collections/v2/diff") {
|
||||
const raw = (globalThis as Record<string, unknown>).__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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user