script/check ran script/test, script/lint and script/fmt-check. Since linting moved into Docker, script/lint is a build of Dockerfile.lint, which runs `prettier --check .` as a build step — so make check checked formatting twice over the same tree: once in the container and once on the host. script/precommit had the same pair. Drop the script/fmt-check call from both. The container keeps the check, because a successful Dockerfile.lint build is what CI treats as proof of a clean tree, and it is the stronger of the two verdicts: its prettier is digest-pinned and installed under --frozen-lockfile, while the host's is whatever the working tree happens to have. The pre-commit hook is unchanged in what it catches — script/lint still fails a badly formatted tree, and therefore the commit. script/fmt-check survives as a standalone entrypoint, as REPO_POLICIES.md requires, for asking the formatting question by itself without docker. Its verdict cannot drift from the container's: prettier is pinned to an exact version, installed from yarn.lock in both places, and reads .gitignore as its default ignore file, which is why .dockerignore keeps .gitignore in the build context. The count is asserted rather than promised. test/packaging/lint-once.test.ts walks the invocation graph from each entrypoint — through the Makefile shims, the script/ calls, the package.json scripts and the docker build into Dockerfile.lint's RUN steps — and counts prettier invocations: one per make check, one per script/precommit, and one each for make lint and make fmt-check alone, so neither can become a no-op that satisfies the count trivially. The walk also asserts which nodes it reached, so a restructure that defeats the resolver fails the test instead of quietly counting zero. Observed: 2 prettier invocations per make check before, 1 after.
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.
quak also includes a resilient backup command that downloads every file in the account into a deduplicated local directory tree, skipping files that already exist on disk and continuing past individual download failures instead of crashing. It decrypts and persists all three metadata layers (basic, private magic, public magic) per file, including camera info, GPS coordinates, captions, and any face/keyword labels the Ente clients have added. A helper subcommand can detect and regenerate missing thumbnails, encrypting and uploading them back to the server.
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).
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.
yarn quak get 67890 --out ./photo.jpg
# Back up every file in the account.
yarn quak backup ./my-backup
For library use:
import { Client } from "quak";
const client = await Client.login({
email: "you@example.com",
password: "your-password",
});
for (const c of await client.listCollections()) {
console.log(c.id, c.name);
const files = await client.listFiles(c.id, c.key);
for (const f of files) {
console.log(` ${f.metadata.title} [${f.metadata.fileType}]`);
}
}
// Download a file
const files = await client.listFiles(collectionID, collectionKey);
await client.downloadFile(files[0], "./photo.jpg");
// Serialize session for later (consumer handles persistence)
const snapshot = client.toJSON();
// ... later:
const restored = Client.fromJSON(snapshot);
Entrypoints
This repository adheres to the
Scripts to Rule Them All
standard: normalized scripts in script/ are the entrypoints for the
development workflow, and the Makefile targets are thin shims that call them.
The scripts are POSIX sh (not bash) so they run in minimal containers such as
alpine. We provide:
script/bootstrap— install all dependencies (node/yarn if missing, thenyarn install --frozen-lockfile)script/setup— set up the repo for development after a fresh clone: runsscript/bootstrap, thenscript/install-precommitscript/projectname— output the project name (our own extension); used byscript/dockerfor the image tagscript/build— compile the TypeScript sources intodist/, then verify that the entrypointspackage.jsondeclares (main,types,bin) are among the files the compiler wrote, and make the CLI executable (our own extension)script/test— run the test suite (vitest, hard-capped at 30s wheretimeoutis available, verbose rerun on failure)script/lint— run eslint and a prettier check, by buildingDockerfile.lint; requires docker (see Linting below)script/fmt— format all files with prettier (writes)script/fmt-check— check formatting on the host (read-only); standalone, and not called byscript/checkorscript/precommit, becausescript/lintalready checks formatting in the container (see Linting below)script/check— run all checks:test,lint(our own extension)script/docker— build the test and build image, tagged viascript/projectnamescript/cibuild— cd to the repo root and build both images (what CI runs):script/lintfirst, then theDockerfileimage, which runsmake testandmake buildscript/precommit— run by the git pre-commit hook (our own extension); runsscript/lint, which checks both lint and formatting, but deliberately not the tests, so the TDD red-phase commit can landscript/install-precommit— installs the git pre-commit hook (our own extension);make hooksshims to it
make hooks installs the pre-commit hook that runs script/precommit.
Linting
Linting runs in a container, one way, everywhere. script/lint builds
Dockerfile.lint, which copies the repo into a digest-pinned node image and
runs eslint and prettier as build steps, so a successful build is a clean lint.
There is no host lint path: docker is required to lint, and that also works
where the docker daemon is remote and bind mounts are impossible.
The formatting check is part of that, not a step beside it. script/check and
script/precommit therefore call script/lint and stop; neither calls
script/fmt-check as well, which would run prettier a second time over the same
tree for the same verdict — and the weaker of the two, since the host's prettier
is whatever the working tree has installed. So make check and the pre-commit
hook both still fail on a badly formatted tree, and prettier runs exactly once
in each. test/packaging/lint-once.test.ts asserts that count by walking the
invocation graph, so a second pass cannot creep back in unnoticed.
script/fmt-check remains as a standalone entrypoint for asking the formatting
question on its own, without docker and without the rest of lint. Its verdict
cannot drift from the container's: prettier is pinned to an exact version,
installed from yarn.lock under --frozen-lockfile in both places, and reads
.gitignore as its default ignore file — which is why .dockerignore
deliberately keeps .gitignore in the build context.
Lint happens in exactly one place, which constrains the rest of the build.
script/check calls script/lint, so make check cannot run inside a
container without asking for docker inside docker. The image built from
Dockerfile therefore runs make test and make build and does not lint;
script/cibuild builds Dockerfile.lint first and that image second, so CI
gets both verdicts.
Build epochs
script/lint passes --build-arg LINT_EPOCH="$(date +%s)", and script/docker
and script/cibuild pass --build-arg CHECK_EPOCH="$(date +%s)". Both
Dockerfiles refuse to build without their argument. This is deliberate: on an
unchanged tree Docker would otherwise serve the linter and test layers from
cache, so nothing would run and the build would still exit 0 — a lint build over
an untouched tree returns success in well under a second, having linted nothing.
A changing epoch invalidates every layer below the guard on every invocation
while leaving the dependency layers above them cached, and the missing-argument
guard means a bare docker build . fails loudly instead of quietly reporting a
green it did not earn: an unset build argument is the empty string, which is a
perfectly stable cache key.
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. The Go CLI's backup mode crashes entirely when a single file download fails, which makes it useless as an actual backup tool.
quak fixes these problems. This repo ships a correct, well-tested implementation of Ente's cryptographic protocol and API surface, plus a CLI that proves the library is enough to do real work without a UI. The backup command is resilient by design: per-file errors are logged and the run continues.
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.
Development workflow
All work on quak is test-driven. No exceptions.
- Every change starts on a feature branch off
main. - 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.
- Subsequent commits add the implementation and any refactors needed to make the tests pass.
- A feature branch can only be merged into
mainwhenmake checkis green.mainis always green. CI runsscript/cibuild, which lints viaDockerfile.lintand then runsmake testandmake buildin theDockerfileimage, so neither a red branch nor one that does not compile can pass CI. - 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.
- 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. git rebase -iis 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.- The pre-commit hook installed by
make hooksrunsscript/precommit, which runsscript/lint— eslint and the prettier check, in the container — but not the tests, and so not the fullmake check. This is deliberate so the TDD red-phase commit (failing tests, no implementation yet) can land. The suite runs as part of the image build, which is what CI executes viascript/cibuild, so a red branch still cannot reachmain.
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 (ApiClient class)
auth/ login flow (SRP + email OTP + TOTP), key unwrap
model/ decrypted Collection, File, Metadata types + decrypt fns
download/ streaming file/thumbnail download + decryption
backup.ts resilient full-account backup with dedup
errors.ts error types shared across layers
retry.ts retry classifier + exponential backoff with jitter
thumbnails.ts detect + regenerate missing thumbnails
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 test suite and compile
Dockerfile.lint eslint and prettier, as build steps
package.json
tsconfig.json
make build compiles that tree into dist/, preserving its shape: the library
lands in dist/src/ and the CLI in dist/bin/quak.js, which is what
package.json points main, types and bin at. The compiler's rootDir is
the repository root rather than src/, because bin/ is compiled too and
rootDir has to contain everything that is compiled.
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:
- The user enters their password.
- Argon2id (
crypto_pwhash) over the password and a server-issuedkekSalt, with server-issuedmemLimitandopsLimit, produces a 32-byte Key Encryption Key (KEK). - 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 contextloginctx. That 16-byte value is the SRP password. - After SRP completes (or after email-OTP fallback), the server returns a blob of "key attributes" plus an encrypted auth token.
crypto_secretbox_open_easyover the encrypted master key with the KEK yields the 32-byte master key.crypto_secretbox_open_easyover the encrypted secret key with the master key yields the user's X25519 private key. The matching public key is delivered in cleartext.crypto_box_seal_openover the encrypted token with the user's keypair yields the URL-safe base64 auth token used inX-Auth-Tokenfor 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
secretstream blob (single chunk, TAG_FINAL) 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. Thumbnails use the same secretstream blob format as metadata.
For upload (thumbnail repair), encryptBlob performs the push side: a single
secretstream chunk with TAG_FINAL, returning the header and ciphertext.
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
constructor option apiOrigin. When set, file downloads route through
<apiOrigin>/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 usesberlin.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/ottandPOST /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 whilehasMoreis true.GET https://files.ente.io/?fileID=<id>: download encrypted file bytes.POST /files/upload-url: mint a presigned upload URL (for thumbnail repair).PUT /files/thumbnail: register an uploaded thumbnail's object key.
Retries and timeouts
Every request in the library goes through one policy, in src/retry.ts. A
request is repeated only when repeating it could produce a different answer:
ApiErrorwith a 5xx status: retried. So are408and429, the two 4xx codes that are statements about timing rather than about the request.- Every other 4xx: not retried. A 404 in particular is an answer, and
listMissingThumbnailsdepends on getting it promptly and once. - Transport failures — a
fetchrejection,ECONNRESET,ETIMEDOUT, a DNS or TLS failure — and deadline aborts: retried. The errno is looked for in the error'scausechain, because that is where Node'sfetchputs it. - A truncated download: retried.
- Anything else, including a secretstream authentication failure that is not truncation: not retried. The default answer is no. For a backup tool, retrying a permanent failure spends round trips and delays every remaining file, while declining to retry a transient one costs a single file that the next run picks up.
Backoff is exponential with full jitter: the delay before retry n is
random() * min(maxDelayMs, baseDelayMs * 2 ** (n - 1)). The exponential term
is the ceiling and the wait is drawn below it, so a client that lost many
parallel downloads to one CDN blip does not send them all again at the same
instant. Defaults, configurable through ApiClientOptions.retry:
| Option | Default | Meaning |
|---|---|---|
attempts |
4 |
total calls, not retries |
baseDelayMs |
500 |
ceiling for the first retry's delay |
maxDelayMs |
10000 |
upper bound on that ceiling |
With those defaults a file that is going to fail gives up after at most three
and a half seconds of waiting. sleep and random are injectable through the
same option, which is how the test suite exercises the whole policy without
waiting.
Two deadlines, applied with AbortSignal.timeout() and renewed for each
attempt:
| Option | Default | Applies to |
|---|---|---|
requestTimeoutMs |
30000 |
getJSON, postJSON, putJSON, putFile |
downloadTimeoutMs |
600000 |
file and thumbnail body transfers |
They are separate because one number cannot serve both: a value short enough to
keep a hung API call from stalling a backup would cancel a legitimate
multi-gigabyte download. The download deadline covers the body, not just the
headers — getFileStream returns as soon as headers arrive, so a deadline that
only guarded the initial request would leave the same hang one layer down.
Non-idempotent requests are not blindly replayed. postJSON and putJSON
reach /users/srp/create-session, /users/two-factor/verify — which consumes
one of a small number of second-factor attempts — and /files/thumbnail. They
are retried only on the three failures that establish no TCP connection to the
server ever existed, so no request byte can have been transmitted: ENOTFOUND
and EAI_AGAIN (name resolution produced no address) and ECONNREFUSED (the
peer refused the connection). A 5xx, a mid-flight reset and a deadline are all
left to the caller, because each of them can happen after the server has already
acted. The routing errnos EHOSTUNREACH, ENETUNREACH and ENETDOWN are
excluded for the same reason, despite looking like connect-time failures: on
Linux an ICMP unreachable arriving mid-flight, or a local interface going down
after the request was written, delivers them on an already-established socket.
They stay retryable for the idempotent calls. putFile is exempt: a presigned
PUT stores one whole object at one key in one request, so replaying it has no
partial state to damage.
A download is retried as a whole — request, stream consumption, and decryption —
because a socket reset after the response headers have arrived surfaces in the
download layer rather than in ApiClient, and that is the common failure for
multi-megabyte photos over a CDN. The secretstream pull state is not resumable
and these endpoints have no Range support, so a retry starts the file over. The
atomic write stays outside the retry, so a download that needed three attempts
still performs exactly one write and one rename. runBackup and
runMetadataBackup are unchanged: the retry sits below them, and a file that
fails after exhausting it is still logged, counted, and stepped over.
One imprecision is deliberate and worth knowing about. When a body ends part-way through a secretstream chunk, Poly1305 fails and carries no framing signal, so a cut connection and genuinely corrupt bytes are indistinguishable. quak reports that as truncation, which means it is retried. For a body of more than one chunk the distinction is real — a chunk that failed while the stream carried on past it stays an authentication failure and is not retried — but for a single-chunk body, which is most thumbnails and every small file, a wrong key, server-side corruption and a mid-chunk cutoff all present alike and all get retried. The cost is bounded by the attempt count, and it buys never silently keeping a truncated file.
Session handling
The Client class holds the auth token, master key, secret key, and public key
in memory. There is no on-disk session store in the library; the consumer
decides how to persist sessions.
client.toJSON() returns a ClientSnapshot (a plain serializable object with
base64-encoded keys) that the consumer can write to disk, a database, or
whatever else fits their use case. Client.fromJSON(snapshot) restores a
working client from that snapshot without re-authenticating.
The CLI stores the snapshot at the platform-appropriate data directory via
env-paths: ~/Library/Application Support/quak/session.json on macOS,
$XDG_DATA_HOME/quak/session.json on Linux. The file is written with mode
0600. The key material is stored in cleartext in the JSON; treat this file as
you would treat the password itself.
CLI surface
quak login interactive or QUAK_EMAIL/QUAK_PASSWORD
quak whoami print logged-in account as JSON
quak logout delete saved session
quak collections [--json] list all collections
quak files --collection <id> [--json] list files in a collection
quak get <fileID> [--out path] [--collection] download and decrypt a file
quak get-thumb <fileID> [--out] [--collection] download and decrypt a thumbnail
quak backup <dir> [--json] full incremental backup
quak helper list-missing-thumbnails [--json] find files with missing thumbnails
quak helper fix-missing-thumbnails [--file ids] generate + upload missing thumbnails
get and get-thumb search all collections for the file ID when --collection
is not specified. All listing and backup commands support --json for
machine-readable output.
Backup layout
quak backup <dir> produces:
<dir>/
originals/
<fileID>.<ext> actual file content (one per unique file)
<fileID>.json all decrypted metadata for that file
collections/
<name>/
<title> -> ../../originals/<fileID>.<ext> (symlink)
<name>.json collection metadata + file list
Each file is downloaded exactly once regardless of how many collections it appears in. On subsequent runs, existing originals are skipped. If a download fails, the error is logged and the backup continues with the next file. The exit code is non-zero if any files failed.
TODO
- Retry policy: no retry on 4xx, exponential backoff on 5xx and network errors
- Update the API reference section below to match the current implementation
make dockergreen- Tag
v1.0.0
Future (desktop client, separate repo):
- Electron app skeleton consuming this library
- Local cache (SQLite) keyed on
(collectionID, fileID, updationTime) - Background sync worker that streams new files into the cache
- Gallery UI: thumbnails, full-image view, basic search
- Upload, delete, and share operations in the library
API reference
The API reference section below is from an earlier draft and does not fully
reflect the current implementation. The authoritative API documentation is in
the test files, particularly test/client/usage.test.ts which is a literate
tutorial walking through every operation. Run yarn test to verify the examples
are correct.
The key types and their actual signatures can be found in:
src/client.ts:Client,LoginOptions,ClientSnapshotsrc/api/client.ts:ApiClient,ApiClientOptions,ApiError,StreamOptionssrc/errors.ts:ApiError,TruncatedStreamErrorsrc/retry.ts:withRetry,isRetryable,isSafeToReplay,RetryOptionssrc/auth/types.ts:KeyAttributes,SRPAttributes,AuthorizationResponse,LoginChallengesrc/model/types.ts:Collection,EnteFile,FileMetadata,FileBlob,RawCollection,RawEnteFile,RawMagicMetadatasrc/download/index.ts:DownloadResultsrc/backup.ts:BackupResult,BackupErrorsrc/thumbnails.ts:MissingThumbnailInfo,ThumbnailFixResult
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/.
For LLMs
If you are an LLM agent working on this repository, read and follow these documents:
-
REPO_POLICIES.mdin the repo root. It is copied from https://git.eeqj.de/sneak/prompts and covers repository structure, tooling, Makefile targets, Dockerfile conventions, dependency pinning, and commit hygiene. All external dependencies must be pinned by cryptographic hash inyarn.lock. Nevergit add -A. Never force-push to main. -
The "Development workflow" section above. All changes go on feature branches. Tests are written first and committed in a failing state before the implementation. Tests are the canonical API documentation and must be commented thoroughly.
mainis always green. -
Required checks before every commit:
make lintmust pass — that is eslint plus the prettier check, and it buildsDockerfile.lint, so it needs docker. The pre-commit hook enforces exactly that.make check(which also runs the tests) must pass before merging tomain.make fmt-checkis available for a host-side formatting check on its own, but it is not a separate requirement:make lintalready covers it, and running both would check formatting twice. Never invoke eslint or prettier directly; linting runs in the container only. -
Formatting: prettier with 4-space indents and
proseWrap: alwaysfor markdown. Usemake fmtto format. Useyarnnotnpm. -
Testing: vitest. Tests go in
test/mirroring thesrc/structure.make testmust complete in under 20 seconds. UsemkdtempSyncfor temporary directories, never manual timestamp paths. -
Code style:
constfor everything,letif reassignment is needed, nevervar. Avoid unnecessary comments. No hand-rolled crypto. TheLLM_PROSE_TELLS.mddocument in the prompts repo applies to any prose written in this repository (README, comments, commit messages).
License
WTFPL. See LICENSE.