sneak 30a13eeeaf CLI red: backup tests, commander + env-paths deps, stub
4 tests for runBackup: full download into collection-named dirs,
incremental skip of existing files, resilient continuation after
single-file HTTP 500, and metadata.json output.

Adds commander 14.0.3 and env-paths 4.0.0 as runtime deps.
2026-05-13 18:36:07 -07:00
2026-05-13 18:04:35 -07:00
2026-05-09 21:27:03 +02:00
2026-05-13 18:02:55 -07:00
2026-05-13 18:02:55 -07:00

quak

quak is a WTFPL-licensed TypeScript client library and CLI by @sneak for the Ente end-to-end encrypted photo hosting service. It logs in, enumerates collections and files, and downloads individual images while decrypting them on the way to disk.

Getting Started

git clone https://git.eeqj.de/sneak/quak.git
cd quak
yarn install
yarn build

# Log in (prompts for email, password, and OTP/TOTP if required).
# Stores an encrypted session under $XDG_CONFIG_HOME/quak/.
yarn quak login

# List the user's collections (albums).
yarn quak collections

# List files in a collection.
yarn quak files --collection 12345

# Download and decrypt a single file to ./out/.
yarn quak get 67890 --out ./out/

For library use:

import { Client } from "quak";

const client = await Client.fromSavedSession();
for (const c of await client.listCollections()) {
    console.log(c.id, c.name);
}
const file = await client.getFile(67890);
await client.downloadFile(file, "./out/");

Rationale

Ente is one of very few photo services with a credible end-to-end encryption story. The shipping clients (mobile Flutter, web React, desktop Electron, and Go CLI) work, but they are slow, buggy, and difficult to script against. The Flutter app fails to sync reliably. The web app is heavy. The desktop app is the web app inside a slow Electron wrapper. The Go CLI is the closest thing to a usable tool, but it is awkward to integrate from anything that is not a shell.

quak is the first step in fixing that. This repo ships a small, correct, well-tested implementation of Ente's cryptographic protocol and its read-only API surface, plus a CLI that proves the library is enough to do real work without a UI.

The longer-term goal of this project is a simple desktop client for Ente, built on this library in Electron (or a comparable runtime), with two priorities above everything else: correctness and stability. Performance and simplicity follow from those. Features will be added only after the protocol layer is correct, the local cache is reliable, and the UI is responsive on a five-year-old laptop.

This first release is deliberately scoped to read operations: log in, walk the account, decrypt and save files. Upload, sharing, deletion, and bidirectional sync are out of scope. Adding them later is straightforward; doing them right requires the protocol layer to be correct first.

Development workflow

All work on quak is test-driven. No exceptions.

  1. Every change starts on a feature branch off main.
  2. The first commit on the branch is the test suite for what is being added or changed. Those tests must fail at that commit; the branch is red until the implementation lands.
  3. Subsequent commits add the implementation and any refactors needed to make the tests pass.
  4. A feature branch can only be merged into main when make check is green. main is always green. The Dockerfile runs make check, so a red branch cannot pass CI.
  5. Tests are the canonical API documentation for this library. Every test file is commented thoroughly enough that a reader who has never seen quak can learn how to use it from the tests alone. Comments explain why a behavior matters, not just what the assertion checks.
  6. Test fixtures (cryptographic vectors, recorded HTTP responses, sample files) are committed alongside their tests. Where possible they are generated by deterministic helpers in the test/ tree so any reviewer can reproduce them by running the helper.
  7. git rebase -i is allowed on a feature branch before merge to clean up the test-then-implementation sequence into reviewable commits, but the final history must still show tests landing before (or with) the matching implementation.
  8. The pre-commit hook installed by make hooks runs make lint && make fmt-check, not the full make check. This is deliberate so the TDD red-phase commit (failing tests, no implementation yet) can land. The full make check runs as part of docker build ., which is what CI executes, so a red branch still cannot reach main.

Design

quak is a TypeScript library with a thin CLI wrapper. The library does the work; the CLI is for humans.

Layout

quak/
    src/
        crypto/         libsodium primitives (boxes, secretstreams, KDF, SRP)
        api/            HTTP client + typed endpoint wrappers
        auth/           login flow (SRP + email OTP + TOTP), key unwrap
        model/          decrypted Collection, File, Metadata types
        session/        on-disk session persistence (token + master key)
        client.ts       high-level Client class assembled from the above
        index.ts        public library exports
    bin/
        quak.ts        CLI entrypoint (commander.js)
    test/               unit + integration tests (vitest)
    Makefile
    Dockerfile
    package.json
    tsconfig.json

Cryptography

All cryptography is done by libsodium-wrappers-sumo (the "sumo" build is required for crypto_pwhash / Argon2id). No hand-rolled crypto.

The key hierarchy, derived during login, is:

  1. The user enters their password.
  2. Argon2id (crypto_pwhash) over the password and a server-issued kekSalt, with server-issued memLimit and opsLimit, produces a 32-byte Key Encryption Key (KEK).
  3. SRP login: a 16-byte SRP login subkey is derived from the KEK using crypto_kdf_derive_from_key (BLAKE2b) with subkey id 1 and context loginctx. That 16-byte value is the SRP password.
  4. After SRP completes (or after email-OTP fallback), the server returns a blob of "key attributes" plus an encrypted auth token.
  5. crypto_secretbox_open_easy over the encrypted master key with the KEK yields the 32-byte master key.
  6. crypto_secretbox_open_easy over the encrypted secret key with the master key yields the user's X25519 private key. The matching public key is delivered in cleartext.
  7. crypto_box_seal_open over the encrypted token with the user's keypair yields the URL-safe base64 auth token used in X-Auth-Token for all subsequent calls.

Per-collection keys are decrypted with crypto_secretbox_open_easy using the master key (for owned collections). Per-file keys are decrypted with crypto_secretbox_open_easy using the collection key. File metadata is a secretbox under the file key. File content is a chunked crypto_secretstream_xchacha20poly1305 stream under the file key, with a 4 MiB plaintext chunk size and a 17-byte authentication overhead per chunk.

HTTP API

Production endpoints:

  • API: https://api.ente.io
  • File download CDN: https://files.ente.io/?fileID=<id>
  • Thumbnail CDN: https://thumbnails.ente.io/?fileID=<id>

A custom API endpoint is configurable for self-hosted servers via the ENTE_API_ENDPOINT environment variable. When set, file downloads route through <endpoint>/files/download/<id> instead of the dedicated CDN host.

Required request headers on every authenticated call:

  • X-Auth-Token: the decrypted auth token from login.
  • X-Client-Package: identifies the client. quak uses berlin.sneak.quak.

Endpoints used:

  • GET /users/srp/attributes?email=<email>: fetch SRP and KDF parameters.
  • POST /users/srp/create-session: begin SRP handshake.
  • POST /users/srp/verify-session: complete SRP, receive 2FA challenge or the encrypted token plus key attributes.
  • POST /users/ott and POST /users/verify-email: email OTP fallback path.
  • POST /users/two-factor/verify: TOTP second factor.
  • GET /collections/v2?sinceTime=<usec>: list collections changed since microsecond timestamp; pass 0 for a full enumeration.
  • GET /collections/v2/diff?collectionID=<id>&sinceTime=<usec>: list files in a collection; paginate while hasMore is true.
  • GET https://files.ente.io/?fileID=<id>: download encrypted file bytes.

Session persistence

After login, quak writes an encrypted session blob to $XDG_CONFIG_HOME/quak/session.json (default ~/.config/quak/session.json) containing the auth token, the user's master key, the user's secret key, and the user's email. The session file is itself encrypted with a key derived from a per-machine random value stored in the OS keychain when available, falling back to a key file at mode 0600 in the same config directory. The master key and secret key are never written to disk in cleartext.

CLI surface

  • quak login: interactive login, writes session.
  • quak logout: deletes the session.
  • quak whoami: prints the logged-in email.
  • quak collections: list collections (id, name, type, file count).
  • quak files --collection <id>: list files in a collection (id, name, type, creation time, size).
  • quak get <fileID> --out <dir>: download and decrypt a file.
  • quak get-thumb <fileID> --out <dir>: download and decrypt a thumbnail.

All commands accept --json for machine-readable output.

API reference

The complete public surface of the library, expressed as TypeScript declarations. Every exported name is listed here. Anything not listed is internal.

Type aliases

// src/model/types.ts
export type Bytes = Uint8Array;
export type Base64 = string; // standard base64 unless noted
export type Base64URL = string; // URL-safe base64
export type Microseconds = number; // unix epoch microseconds (int64-ish)

Crypto module

// src/crypto/index.ts

// Lazily initializes libsodium. Safe to call repeatedly; the first call
// performs the init, subsequent calls are no-ops.
export function init(): Promise<void>;

// Argon2id over a UTF-8 password and a 16-byte salt, producing a 32-byte
// key. memLimit is in bytes, opsLimit is the iteration count, both as
// returned by the server in SRP / key attributes.
export function deriveKEK(
    password: string,
    salt: Bytes,
    opsLimit: number,
    memLimit: number,
): Promise<Bytes>;

// crypto_kdf_derive_from_key with subkey id 1 and context "loginctx",
// returning the first 16 bytes. Used as the SRP password.
export function deriveLoginSubkey(kek: Bytes): Bytes;

// crypto_secretbox_open_easy. Returns plaintext or throws on auth failure.
export function decryptBox(ciphertext: Bytes, nonce: Bytes, key: Bytes): Bytes;

// crypto_box_seal_open. Used to recover the auth token after login.
export function decryptSealed(
    ciphertext: Bytes,
    publicKey: Bytes,
    secretKey: Bytes,
): Bytes;

// crypto_secretstream_xchacha20poly1305_init_pull. Returned state is opaque
// and threaded through pullStreamChunk.
export function initStreamPull(header: Bytes, key: Bytes): StreamPullState;

// crypto_secretstream_xchacha20poly1305_pull. Tag values follow libsodium's
// constants: 0=MESSAGE, 1=PUSH, 2=REKEY, 3=FINAL.
export function pullStreamChunk(
    state: StreamPullState,
    ciphertext: Bytes,
): { plaintext: Bytes; tag: number };

// Convenience helpers. fromBase64 accepts both standard and URL-safe.
export function fromBase64(s: Base64 | Base64URL): Bytes;
export function toBase64(b: Bytes): Base64;
export function toBase64URL(b: Bytes): Base64URL;

// Plaintext chunk size used by Ente for file content streams.
export const STREAM_CHUNK_SIZE: number; // 4 * 1024 * 1024

// Encrypted-chunk overhead: secretstream auth tag (16) + tag byte (1).
export const STREAM_CHUNK_OVERHEAD: number; // 17

export interface StreamPullState {
    /* opaque */
}

Auth module

// src/auth/types.ts

export interface KDFParams {
    kekSalt: Base64;
    memLimit: number;
    opsLimit: number;
}

export interface KeyAttributes {
    kekSalt: Base64;
    encryptedKey: Base64;
    keyDecryptionNonce: Base64;
    publicKey: Base64;
    encryptedSecretKey: Base64;
    secretKeyDecryptionNonce: Base64;
    memLimit: number;
    opsLimit: number;
    masterKeyEncryptedWithRecoveryKey?: Base64;
    masterKeyDecryptionNonce?: Base64;
    recoveryKeyEncryptedWithMasterKey?: Base64;
    recoveryKeyDecryptionNonce?: Base64;
}

export interface SRPAttributes {
    srpUserID: string;
    srpSalt: Base64;
    memLimit: number;
    opsLimit: number;
    kekSalt: Base64;
    isEmailMFAEnabled: boolean;
}

export interface AuthorizationResponse {
    id: number; // user ID
    keyAttributes?: KeyAttributes;
    encryptedToken?: Base64URL; // sealed-box-encrypted to user's pubkey
    twoFactorSessionID?: string;
    passkeySessionID?: string;
}

export type LoginChallenge =
    | { kind: "complete"; response: AuthorizationResponse }
    | { kind: "totp"; sessionID: string }
    | { kind: "passkey"; sessionID: string }
    | { kind: "emailOTP" };
// src/auth/index.ts

// Begin login. Returns a challenge that tells the caller what to do next:
// supply a TOTP code, supply an email OTP, follow a passkey URL, or stop
// because login is already complete.
export function beginLogin(
    api: ApiClient,
    email: string,
    password: string,
): Promise<LoginChallenge>;

// Submit a TOTP code from an authenticator app. Returns the final
// AuthorizationResponse on success.
export function submitTOTP(
    api: ApiClient,
    sessionID: string,
    code: string,
): Promise<AuthorizationResponse>;

// Request and submit an email-delivered one-time code. Two calls, because
// the first triggers email delivery and the second verifies it.
export function requestEmailOTP(api: ApiClient, email: string): Promise<void>;
export function submitEmailOTP(
    api: ApiClient,
    email: string,
    code: string,
): Promise<AuthorizationResponse>;

// Given an AuthorizationResponse and the user's password, decrypt the master
// key, secret key, and auth token. Throws on bad password or tampered data.
export function unwrapAuth(
    response: AuthorizationResponse,
    password: string,
): Promise<{
    masterKey: Bytes;
    secretKey: Bytes;
    publicKey: Bytes;
    token: string; // base64 URL-safe; goes into X-Auth-Token
}>;

Model module

// src/model/index.ts

export type CollectionType =
    | "album"
    | "folder"
    | "favorites"
    | "uncategorized"
    | "unknown";

export interface Collection {
    id: number;
    ownerID: number;
    key: Bytes; // decrypted
    name: string; // decrypted
    type: CollectionType;
    updationTime: Microseconds;
    isShared: boolean; // true if owner != current user
}

export type FileType = "image" | "video" | "livePhoto" | "unknown";

export interface FileMetadata {
    title: string;
    fileType: FileType;
    creationTime: Microseconds;
    modificationTime: Microseconds;
    latitude?: number;
    longitude?: number;
    hash?: string; // base64 of file SHA256
}

export interface FileBlob {
    decryptionHeader: Base64;
    size?: number; // size of the encrypted body, if known from server
}

export interface EnteFile {
    id: number;
    collectionID: number;
    ownerID: number;
    key: Bytes; // decrypted file key
    metadata: FileMetadata;
    file: FileBlob;
    thumbnail: FileBlob;
    updationTime: Microseconds;
}

HTTP client

// src/api/client.ts

export interface ApiClientOptions {
    apiOrigin?: string; // default https://api.ente.io
    filesOrigin?: string; // default https://files.ente.io
    thumbsOrigin?: string; // default https://thumbnails.ente.io
    authToken?: string;
    fetch?: typeof fetch; // injectable for tests
    userAgent?: string; // default "quak/<version>"
}

export class ApiError extends Error {
    readonly status: number;
    readonly code?: string;
    readonly requestID?: string;
    readonly body?: unknown;
}

export class ApiClient {
    constructor(opts?: ApiClientOptions);
    setAuthToken(token: string): void;
    clearAuthToken(): void;

    getJSON<T>(
        path: string,
        query?: Record<string, string | number | undefined>,
    ): Promise<T>;
    postJSON<T>(path: string, body: unknown): Promise<T>;

    // Streaming download from the file CDN. Caller is responsible for
    // consuming the stream.
    getFileStream(fileID: number): Promise<ReadableStream<Uint8Array>>;
    getThumbnailStream(fileID: number): Promise<ReadableStream<Uint8Array>>;
}

Session

// src/session/index.ts

export interface Session {
    email: string;
    userID: number;
    token: string; // base64 URL-safe
    masterKey: Bytes; // 32 bytes, never serialized in cleartext
    secretKey: Bytes; // 32 bytes, never serialized in cleartext
    publicKey: Bytes; // 32 bytes
}

export interface SessionStoreOptions {
    path?: string; // default $XDG_CONFIG_HOME/quak/session.json
    keychainService?: string; // default "berlin.sneak.quak"
}

export class SessionStore {
    constructor(opts?: SessionStoreOptions);
    load(): Promise<Session | null>;
    save(s: Session): Promise<void>;
    clear(): Promise<void>;
}

Client

// src/client.ts

export interface LoginPrompt {
    password: () => Promise<string>;
    emailOTP?: () => Promise<string>; // called when account uses email OTP
    totp?: () => Promise<string>; // called when TOTP is required
}

export interface ClientOptions extends ApiClientOptions {
    sessionStore?: SessionStore;
}

export interface DownloadResult {
    path: string;
    bytesWritten: number;
}

export class Client {
    // Static constructors. Both end with a Client that has a populated
    // session and a ready ApiClient.
    static login(
        email: string,
        prompt: LoginPrompt,
        opts?: ClientOptions,
    ): Promise<Client>;
    static fromSavedSession(opts?: ClientOptions): Promise<Client>;

    readonly api: ApiClient;
    readonly session: Readonly<Session>;

    // Account.
    whoami(): { email: string; userID: number };
    saveSession(): Promise<void>;
    logout(): Promise<void>; // clears session on disk and in memory

    // Collections.
    listCollections(opts?: { sinceTime?: Microseconds }): Promise<Collection[]>;
    getCollection(id: number): Promise<Collection>;

    // Files.
    listFiles(
        collectionID: number,
        opts?: { sinceTime?: Microseconds },
    ): Promise<EnteFile[]>;
    getFile(fileID: number): Promise<EnteFile>;

    // Downloads. If outPath is omitted, a path is constructed from the
    // decrypted metadata title in the current working directory. If outPath
    // is a directory, the filename is taken from the metadata title. If
    // outPath is a file path, that file is written.
    downloadFile(
        file: EnteFile | number,
        outPath?: string,
    ): Promise<DownloadResult>;
    downloadThumbnail(
        file: EnteFile | number,
        outPath?: string,
    ): Promise<DownloadResult>;
}

Public exports (src/index.ts)

export { Client } from "./client";
export { ApiClient, ApiError } from "./api/client";
export { SessionStore } from "./session";
export * from "./model";
export type {
    Session,
    LoginPrompt,
    ClientOptions,
    DownloadResult,
} from "./client";

TODO

Phase 1: scaffolding

  • git init, write README
  • Create initial-scaffolding feature branch
  • Add LICENSE (WTFPL), REPO_POLICIES.md, .gitignore, .editorconfig, .prettierrc, .prettierignore, .dockerignore
  • Add Makefile with test, lint, fmt, fmt-check, check, docker, hooks, plus build, dev, clean
  • Add Dockerfile running make check against pinned node image
  • Add .gitea/workflows/check.yml running docker build .
  • Add package.json, tsconfig.json, pinned dev versions of typescript, prettier, eslint, typescript-eslint, vitest, @types/node (the runtime deps libsodium-wrappers-sumo, secure-remote-password, commander, etc. land with their respective implementation phases)
  • Smoke test: make check and make docker both pass

Phase 2: crypto primitives

  • Wrap libsodium init as an awaitable singleton
  • deriveKEK(password, kekSalt, memLimit, opsLimit) (Argon2id)
  • deriveLoginSubkey(kek) (KDF with subkey id 1, context loginctx, 16 bytes)
  • decryptBox(ciphertext, nonce, key) for secretbox
  • decryptSealed(ciphertext, publicKey, secretKey) for sealed box
  • initStreamPull and pullStreamChunk for chunked secretstream (4 MiB plaintext chunks, 17-byte overhead)
  • Round-trip tests against vectors generated by libsodium directly
  • Base64 helpers (fromBase64, toBase64, toBase64URL) accepting all four sodium variants on input

Phase 3: SRP + auth

  • SRP-6a client using fast-srp-hap with the 4096-bit group (matching the upstream Ente web client)
  • beginLogin(api, email, password) returning a LoginChallenge
  • requestEmailOTP and submitEmailOTP for accounts without SRP
  • submitTOTP(api, sessionID, code)
  • unwrapAuth(response, password) returning master key, secret key, public key, and decrypted token (URL-safe-no-padding base64)
  • src/auth/types.ts with KeyAttributes, SRPAttributes, AuthorizationResponse, and LoginChallenge
  • Tests with mock SRP server performing real 4096-bit math end-to-end

Phase 4: HTTP client + endpoints

  • ApiClient that attaches X-Auth-Token and X-Client-Package
  • ApiError that surfaces the server's error code and request id
  • getJSON / postJSON with query-param and JSON-body handling
  • getFileStream / getThumbnailStream with self-hosted routing
  • Typed wrappers for the endpoints listed above
  • Retry policy: no retry on 4xx, exponential backoff on 5xx and network errors

Phase 5: collections and files

  • decryptCollection(raw, masterKey) with key + name decryption, type mapping, isShared flag
  • decryptFile(raw, collectionKey) with key + metadata decryption (secretstream blob, not secretbox), fileType mapping, header passthrough
  • decryptBlob(ciphertext, header, key) convenience for single-chunk secretstream decryption (used by file metadata and magic metadata)
  • Model types: Collection, EnteFile, FileMetadata, RawCollection, RawEnteFile
  • Live-tested against real Ente API (collection names + file metadata)
  • Higher-level listCollections() / listFiles() with pagination

Phase 6: download

  • downloadFile(api, file, outPath?) streams encrypted body, buffers to 4 MiB chunk boundary, decrypts via secretstream pull, writes to disk. Falls back to metadata.title when outPath is omitted.
  • downloadThumbnail(api, file, outPath?) same for thumbnails
  • Live integration test: logs in, decrypts collections and files, downloads a real JPEG from the dev account and verifies it on disk

Phase 7: Client class

  • Client.login({ email, password, totp?, emailOTP? }) performs the full SRP handshake, key unwrap, returns a ready Client
  • Client.fromJSON(snapshot) restores from a serialized snapshot
  • client.toJSON() produces a ClientSnapshot the consumer can persist
  • client.whoami(), client.logout()
  • client.listCollections() with decryption
  • client.listFiles(collectionID, collectionKey) with pagination
  • client.downloadFile(file, outPath?) and client.downloadThumbnail()
  • Literate test/client/usage.test.ts tutorial covering the entire API

Phase 8: CLI

  • commander-based CLI that matches the surface in the Design section
  • --json output for every command
  • Reasonable progress output for long downloads (only when stdout is a TTY)

Phase 9: docs and 1.0

  • README usage examples for both library and CLI verified by hand
  • All TODO items above checked
  • Tag v1.0.0

Phase 10 and beyond: desktop client (separate repo)

  • Spike Electron app skeleton consuming this library
  • Local cache (SQLite) keyed on (collectionID, fileID, updationTime)
  • Background sync worker that streams new files into the cache
  • Read-only gallery UI: thumbnails, full-image view, basic search
  • Add upload, delete, and share back into the library before the desktop UI exposes them

Source attribution

The cryptographic protocol and wire format implemented here are Ente's, taken from the Ente open source clients at https://github.com/ente-io/ente. No code is imported or vendored from those projects; any reference code that is copied is rewritten in TypeScript in this repository. Protocol fidelity is verified against the upstream implementations in web/packages/base/, mobile/apps/photos/lib/, and cli/.

License

WTFPL. See LICENSE.

Author

@sneak

Description
No description provided
Readme 217 KiB
Languages
TypeScript 91.7%
JavaScript 7.4%
Makefile 0.7%
Dockerfile 0.2%