Bring README and TODO.md in line with next after the milestone merge (closes #132)
check / check (push) Successful in 40s

Docs only. TODO.md's Next Step no longer says open issues wait on
`next`: no implementation work is open, and the cache design waits on
sneak's review. The README is corrected where the code contradicts it:
where SRP lives and how the login subkey is derived, when email OTP is
used, how the auth token is encoded, the download retry's temporary
files, which CLI commands open a library, what `--exif` records, the ML
data fetch during `quak backup`, the default cache directory, and the
`408`/`429` retries.

Model: opus-5-5
This commit is contained in:
2026-09-29 01:37:26 +00:00
parent 9e6e038545
commit 55738a5107
2 changed files with 73 additions and 50 deletions
+45 -36
View File
@@ -122,9 +122,9 @@ alpine. We provide:
Linting and testing are phases of the `Dockerfile`. The `lint` phase copies the Linting and testing are phases of the `Dockerfile`. The `lint` phase copies the
repo into a digest-pinned node image and runs eslint and `prettier --check .`; repo into a digest-pinned node image and runs eslint and `prettier --check .`;
the `test` phase does the same with the suite. `script/lint` and `script/test` the `test` phase does the same with the suite. `script/lint` and `script/test`
each build one phase with `docker build --no-cache --target <phase>`. There is each build one phase with `docker build --no-cache --target <phase>`. Neither
no host lint or test path: docker is required, and that also works where the script runs the tools on the host: docker is required, and that also works where
docker daemon is remote and bind mounts are impossible. the docker daemon is remote and bind mounts are impossible.
The last stage of the `Dockerfile` compiles the package, and it copies a file The last stage of the `Dockerfile` compiles the package, and it copies a file
from each phase, so it cannot be built unless lint and the tests pass. That is from each phase, so it cannot be built unless lint and the tests pass. That is
@@ -209,7 +209,7 @@ the CLI is for humans.
``` ```
quak/ quak/
src/ src/
crypto/ libsodium primitives (boxes, secretstreams, KDF, SRP) crypto/ libsodium primitives (boxes, secretstreams, KDF, hash)
api/ HTTP client (ApiClient class) api/ HTTP client (ApiClient class)
auth/ login flow (SRP + email OTP + TOTP), key unwrap auth/ login flow (SRP + email OTP + TOTP), key unwrap
model/ decrypted Collection, File, Metadata types + decrypt fns model/ decrypted Collection, File, Metadata types + decrypt fns
@@ -250,7 +250,8 @@ the repository root rather than `src/`, because `bin/` is compiled too and
### Cryptography ### Cryptography
All cryptography is done by `libsodium-wrappers-sumo` (the "sumo" build is All cryptography is done by `libsodium-wrappers-sumo` (the "sumo" build is
required for `crypto_pwhash` / Argon2id). No hand-rolled crypto. required for `crypto_pwhash` / Argon2id), except the SRP handshake, which uses
`fast-srp-hap`. No hand-rolled crypto.
The key hierarchy, derived during login, is: The key hierarchy, derived during login, is:
@@ -258,19 +259,20 @@ The key hierarchy, derived during login, is:
2. Argon2id (`crypto_pwhash`) over the password and a server-issued `kekSalt`, 2. Argon2id (`crypto_pwhash`) over the password and a server-issued `kekSalt`,
with server-issued `memLimit` and `opsLimit`, produces a 32-byte Key with server-issued `memLimit` and `opsLimit`, produces a 32-byte Key
Encryption Key (KEK). Encryption Key (KEK).
3. SRP login: a 16-byte SRP login subkey is derived from the KEK using 3. SRP login: `crypto_kdf_derive_from_key` (BLAKE2b) derives a 32-byte subkey
`crypto_kdf_derive_from_key` (BLAKE2b) with subkey id 1 and context from the KEK with subkey id 1 and context `loginctx`. Its first 16 bytes are
`loginctx`. That 16-byte value is the SRP password. the SRP password.
4. After SRP completes (or after email-OTP fallback), the server returns a blob 4. After SRP completes, or after the email OTP that replaces SRP when the
of "key attributes" plus an encrypted auth token. account has email MFA on (`isEmailMFAEnabled`), 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 5. `crypto_secretbox_open_easy` over the encrypted master key with the KEK
yields the 32-byte master key. yields the 32-byte master key.
6. `crypto_secretbox_open_easy` over the encrypted secret key with the master 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 key yields the user's X25519 private key. The matching public key is
delivered in cleartext. delivered in cleartext.
7. `crypto_box_seal_open` over the encrypted token with the user's keypair 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 yields the auth token's bytes. Encoded as URL-safe base64 with padding, they
subsequent calls. are the `X-Auth-Token` value for all subsequent calls.
Per-collection keys are decrypted with `crypto_secretbox_open_easy` using the Per-collection keys are decrypted with `crypto_secretbox_open_easy` using the
master key (for owned collections). Per-file keys are decrypted with master key (for owned collections). Per-file keys are decrypted with
@@ -306,7 +308,8 @@ Endpoints used:
- `POST /users/srp/create-session`: begin SRP handshake. - `POST /users/srp/create-session`: begin SRP handshake.
- `POST /users/srp/verify-session`: complete SRP, receive 2FA challenge or the - `POST /users/srp/verify-session`: complete SRP, receive 2FA challenge or the
encrypted token plus key attributes. encrypted token plus key attributes.
- `POST /users/ott` and `POST /users/verify-email`: email OTP fallback path. - `POST /users/ott` and `POST /users/verify-email`: email OTP, used instead of
SRP when the SRP attributes have `isEmailMFAEnabled` set.
- `POST /users/two-factor/verify`: TOTP second factor. - `POST /users/two-factor/verify`: TOTP second factor.
- `POST /users/logout`: end the calling token's session (`quak logout`). - `POST /users/logout`: end the calling token's session (`quak logout`).
- `GET /collections/v2?sinceTime=<usec>`: list collections changed since - `GET /collections/v2?sinceTime=<usec>`: list collections changed since
@@ -395,10 +398,12 @@ 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 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 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 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 atomic write is part of the retried unit: each attempt writes its own temporary
still performs exactly one write and one rename. `runBackup` and file and removes it if the attempt fails, and only the attempt that completes
`runMetadataBackup` are unchanged: the retry sits below them, and a file that renames its file into place, so a download that needed three attempts still
fails after exhausting it is still logged, counted, and stepped over. performs exactly 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 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 through a secretstream chunk, Poly1305 fails and carries no framing signal, so a
@@ -439,9 +444,10 @@ field. Both exit with status 1.
stops working even in a copy of the file, and then deletes the file. If the stops working even in a copy of the file, and then deletes the file. If the
server call fails (or the file is corrupt), the file is still deleted, the server call fails (or the file is corrupt), the file is still deleted, the
command says the server session could not be ended, and it exits with status 1. command says the server session could not be ended, and it exits with status 1.
It does not delete the cache: it prints the account's cache directory and says It does not delete the cache. When it knows the cache directory, from
it still holds decrypted data (file keys in `metadata.json`, cached originals `--cache-dir` or from a session file it could read, it prints it and says it
and thumbnails), for the user to delete if they want it gone. still holds decrypted data (file keys in `metadata.json`, cached originals and
thumbnails), for the user to delete if they want it gone.
### CLI surface ### CLI surface
@@ -460,14 +466,14 @@ quak helper list-missing-thumbnails [--json] find files with missing thumbnai
quak helper fix-missing-thumbnails [--file ids] generate + upload missing thumbnails quak helper fix-missing-thumbnails [--file ids] generate + upload missing thumbnails
``` ```
Every command runs on the same cache-backed library. The read commands — Every command except `login`, `whoami` and `logout` runs on the cache-backed
`collections`, `files`, `get`, `get-thumb`, `backup-metadata`, library. The read commands — `collections`, `files`, `get`, `get-thumb`,
`helper list-missing-thumbnails` and `helper fix-missing-thumbnails` — force a `backup-metadata`, `helper list-missing-thumbnails` and
fresh server round-trip before they answer, so they report current account state `helper fix-missing-thumbnails` — force a fresh server round-trip before they
rather than whatever the cache last held. If that round-trip fails, the command answer, so they report current account state rather than whatever the cache last
prints the error on one line and exits 1. `--cache-dir` overrides where the held. If that round-trip fails, the command prints the error on one line and
cache lives; without it each account gets its own directory under the per-user exits 1. `--cache-dir` overrides where the cache lives; without it each account
cache path. gets its own directory under the per-user cache path.
`get` and `get-thumb` resolve the file by ID directly, so `--collection` is `get` and `get-thumb` resolve the file by ID directly, so `--collection` is
accepted for backward compatibility but ignored. For a live photo, `get` writes accepted for backward compatibility but ignored. For a live photo, `get` writes
@@ -475,9 +481,10 @@ its image and its video, each named after the title with its own extension, as
Ente's clients name them (`IMG_0001.heic` and `IMG_0001.mov`). With Ente's clients name them (`IMG_0001.heic` and `IMG_0001.mov`). With
`--out PATH`, the image is written to `PATH` and the video beside it, with `--out PATH`, the image is written to `PATH` and the video beside it, with
`PATH`'s name and the video's extension; a `PATH` with the video's extension is `PATH`'s name and the video's extension; a `PATH` with the video's extension is
refused. `backup-metadata --exif` (alias `--all`) additionally downloads each refused. `backup-metadata --exif` (alias `--all`) additionally fetches each
file to extract full EXIF/IPTC/XMP metadata. The listing and backup commands file's original through the cache and records its XMP metadata and, for a JPEG,
support `--json` for machine-readable output. its EXIF metadata and dimensions. The listing and backup commands support
`--json` for machine-readable output.
`backup-metadata` fetches ML data in requests of up to 200 files. When a request `backup-metadata` fetches ML data in requests of up to 200 files. When a request
still fails after its retries, the error is logged, each of its files is written still fails after its retries, the error is logged, each of its files is written
@@ -550,7 +557,9 @@ the cache. An original the cache already held is copied from there instead. On
subsequent runs, existing originals are skipped. If a download fails, the error 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 is logged and the backup continues with the next file. The exit code is non-zero
if any files failed. `quak backup` opens its library with the thumbnail and if any files failed. `quak backup` opens its library with the thumbnail and
originals precache off, so it fetches only what the backup stores. originals precache off, so the only file content it fetches is the originals the
backup stores. The library's ML data fetch still runs and fills the cache's
`mldata/`.
Each original is written to a temporary file in the same directory, synced to Each original is written to a temporary file in the same directory, synced to
disk, and renamed into place, so an original is either complete or absent, even disk, and renamed into place, so an original is either complete or absent, even
@@ -567,8 +576,8 @@ temporary file's permissions, not those of the file it replaced.
## TODO ## TODO
- [x] Retry policy: no retry on 4xx, exponential backoff on 5xx and network - [x] Retry policy: no retry on 4xx (except `408` and `429`), exponential
errors backoff on 5xx and network errors
- [x] Update the API reference section below to match the current implementation - [x] Update the API reference section below to match the current implementation
- [x] `make docker` green - [x] `make docker` green
- [x] Store live photos in a form a photo viewer can open - [x] Store live photos in a form a photo viewer can open
@@ -605,9 +614,9 @@ background, so an unreachable server does not block opening.
`LibraryOptions`: `LibraryOptions`:
| Option | Default | Meaning | | Option | Default | Meaning |
| ------------------------ | --------------------------- | --------------------------------------------------------------------- | | ------------------------ | -------------------------------------------------- | --------------------------------------------------------------------- |
| `client` | required | the account client (a `Client`, or any `LibraryClient`) | | `client` | required | the account client (a `Client`, or any `LibraryClient`) |
| `cacheDirectory` | `<XDG cache>/quak/<userID>` | where `metadata.json` and the content cache live | | `cacheDirectory` | `quak/<userID>` under the per-user cache directory | where `metadata.json` and the content cache live |
| `downloadDirectory` | none | backup destination; an original already stored there counts as cached | | `downloadDirectory` | none | backup destination; an original already stored there counts as cached |
| `refreshIntervalSeconds` | `3` | background refresh cadence | | `refreshIntervalSeconds` | `3` | background refresh cadence |
| `precacheThumbnails` | `true` | prefetch every thumbnail, newest first | | `precacheThumbnails` | `true` | prefetch every thumbnail, newest first |
+15 -1
View File
@@ -14,13 +14,27 @@ pre-1.0
# Next Step # Next Step
None: every issue still open is done on `next` and waits for it to reach `main`. None: no implementation work is open. The cache design,
https://git.eeqj.de/sneak/quak/issues/36, waits on sneak's review.
Tagging and releases are decided by sneak alone, and happen only when he Tagging and releases are decided by sneak alone, and happen only when he
declares one. declares one.
# Completed Steps # Completed Steps
- 2026-09-29: Brought the README and this file in line with `next` after the
milestone merge (issue 132). The Next Step says no implementation work is open
and the cache design (issue 36) waits on sneak's review. README corrections:
`script/lint` and `script/test` have no host path, though `yarn test` does;
the SRP handshake uses `fast-srp-hap`, outside `crypto/`; the SRP password is
the first 16 bytes of a 32-byte subkey; email OTP replaces SRP when the
account has email MFA on; the auth token is sent as URL-safe base64 with
padding; each download attempt writes its own temporary file; `login`,
`whoami` and `logout` open no library; `--exif` records XMP and, for a JPEG,
EXIF, and no IPTC; `quak backup` still fetches ML data; the default cache
directory is the per-user one, not an XDG path on macOS; and `408` and `429`
are retried.
- 2026-09-28: Tested the live-photo writer's fsyncs (issue 130). A test checks - 2026-09-28: Tested the live-photo writer's fsyncs (issue 130). A test checks
that the image's and the video's temp files are fsynced before either is that the image's and the video's temp files are fsynced before either is
renamed into place, and the directory after both renames, as the `writeAtomic` renamed into place, and the directory after both renames, as the `writeAtomic`