quack
quack 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/quack.git
cd quack
yarn install
yarn build
# Log in (prompts for email, password, and OTP/TOTP if required).
# Stores an encrypted session under $XDG_CONFIG_HOME/quack/.
yarn quack login
# List the user's collections (albums).
yarn quack collections
# List files in a collection.
yarn quack files --collection 12345
# Download and decrypt a single file to ./out/.
yarn quack get 67890 --out ./out/
For library use:
import { Client } from "quack";
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 official clients (mobile Flutter app, web React app, and Go CLI) are high quality but each is bound up with a UI or with sync semantics that aren't useful for scripted access. Pulling individual photos out of an Ente account from a script (for backup, migration, archival, or programmatic processing) is awkward without a small TypeScript library that does just the cryptography and nothing else.
This project exists to provide that library. It is deliberately scoped to read operations: log in, walk the account, decrypt and save files. Upload, sharing, deletion, and sync state management are out of scope for the first release.
Design
quack is a TypeScript library with a thin CLI wrapper. The library does the work; the CLI is for humans.
Layout
quack/
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/
quack.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, looks like this:
- 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
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. quack usesberlin.sneak.quack.
Endpoints used:
GET /users/srp/attributes?email=<email>— fetch SRP + KDF parameters.POST /users/srp/create-session— begin SRP handshake.POST /users/srp/verify-session— complete SRP, receive 2FA challenge or the encrypted token + 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.
Session persistence
After login, quack writes an encrypted session blob to
$XDG_CONFIG_HOME/quack/session.json (default
~/.config/quack/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
quack login— interactive login, writes session.quack logout— deletes the session.quack whoami— prints the logged-in email.quack collections— list collections (id, name, type, file count).quack files --collection <id>— list files in a collection (id, name, type, creation time, size).quack get <fileID> --out <dir>— download and decrypt a file.quack get-thumb <fileID> --out <dir>— download and decrypt a thumbnail.
All commands accept --json for machine-readable output.
TODO
Phase 1: scaffolding (this commit and the next)
git init, write README- Create
initial-scaffoldingfeature branch - Add
LICENSE(WTFPL),REPO_POLICIES.md,.gitignore,.editorconfig,.prettierrc,.prettierignore,.dockerignore - Add
Makefilewithtest,lint,fmt,fmt-check,check,docker,hooks, plusbuild,dev,clean - Add
Dockerfilerunningmake checkagainst pinned node image - Add
.gitea/workflows/check.ymlrunningdocker build . - Add
package.json,tsconfig.json, install pinned versions oftypescript,libsodium-wrappers-sumo,secure-remote-password,commander,vitest,prettier,eslint,@types/node - Smoke test:
make checkandmake dockerboth 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, contextloginctx, 16 bytes)decryptBox(ciphertext, nonce, key)for secretboxdecryptSealed(ciphertext, publicKey, secretKey)for sealed boxdecryptStream(reader, header, key, writer)for chunked secretstream (4 MiB plaintext chunks, 17-byte overhead)- Round-trip tests against vectors generated by libsodium directly
Phase 3: SRP + auth
- SRP-6a client using
secure-remote-passwordwith the same group as the server loginViaSRP(email, password)returning either a 2FA challenge orKeyAttributes + encryptedTokenloginViaEmailOTP(email)for accounts without SRP enabledsubmitTOTP(sessionID, code)unwrapMasterKey(keyAttributes, password)returning master key, secret key, public key, and decrypted token- Tests against recorded HTTP fixtures
Phase 4: HTTP client + endpoints
- Tiny fetch wrapper that attaches
X-Auth-TokenandX-Client-Package - Typed wrappers for the endpoints listed above
- Retry policy: no retry on 4xx, exponential backoff on 5xx and network errors
- Error type that surfaces the server's error code and request id
Phase 5: collections and files
listCollections()paginating onsinceTimeuntil empty- Decrypt per-collection key with master key
- Decrypt collection name with collection key
listFiles(collectionID)paginating onsinceTimewhilehasMore- Decrypt per-file key with collection key
- Decrypt file metadata blob with file key, expose typed
FileMetadata
Phase 6: download
downloadFile(fileID, outPath)streams the encrypted body, decrypts it chunk by chunk, writes plaintext tooutPath. Resolves the filename from the decrypted metadata title when nooutPathis supplied.downloadThumbnail(fileID, outPath)for the thumbnail CDN- Live integration test against a throwaway Ente account if one is available
Phase 7: session persistence
- Write session blob encrypted with a key from the OS keychain
(
keytar) or a0600keyfile fallback Client.fromSavedSession()andClient.saveSession()quack logoutdeletes the session and the keychain entry
Phase 8: CLI
commander-based CLI that matches the surface in the Design section--jsonoutput 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
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.