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
|
- [x] Live integration test: logs in, decrypts collections and files, downloads
|
||||||
a real JPEG from the dev account and verifies it on disk
|
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
|
- [x] `Client.login({ email, password, totp?, emailOTP? })` performs the full
|
||||||
keychain (`keytar`) or a `0600` keyfile fallback
|
SRP handshake, key unwrap, returns a ready Client
|
||||||
- [ ] `Client.fromSavedSession()` and `Client.saveSession()`
|
- [x] `Client.fromJSON(snapshot)` restores from a serialized snapshot
|
||||||
- [ ] `quack logout` deletes the session and the keychain entry
|
- [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
|
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 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 { 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 {
|
export type {
|
||||||
AuthorizationResponse,
|
AuthorizationResponse,
|
||||||
KeyAttributes,
|
KeyAttributes,
|
||||||
LoginChallenge,
|
LoginChallenge,
|
||||||
SRPAttributes,
|
SRPAttributes,
|
||||||
} from "./auth/types.js";
|
} 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