Bring README and TODO.md in line with next after the milestone merge (closes #132)
check / check (push) Successful in 40s
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:
@@ -122,9 +122,9 @@ alpine. We provide:
|
||||
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 .`;
|
||||
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
|
||||
no host lint or test path: docker is required, and that also works where the
|
||||
docker daemon is remote and bind mounts are impossible.
|
||||
each build one phase with `docker build --no-cache --target <phase>`. Neither
|
||||
script runs the tools on the host: docker is required, and that also works where
|
||||
the docker daemon is remote and bind mounts are impossible.
|
||||
|
||||
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
|
||||
@@ -209,7 +209,7 @@ the CLI is for humans.
|
||||
```
|
||||
quak/
|
||||
src/
|
||||
crypto/ libsodium primitives (boxes, secretstreams, KDF, SRP)
|
||||
crypto/ libsodium primitives (boxes, secretstreams, KDF, hash)
|
||||
api/ HTTP client (ApiClient class)
|
||||
auth/ login flow (SRP + email OTP + TOTP), key unwrap
|
||||
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
|
||||
|
||||
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:
|
||||
|
||||
@@ -258,19 +259,20 @@ The key hierarchy, derived during login, is:
|
||||
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.
|
||||
3. SRP login: `crypto_kdf_derive_from_key` (BLAKE2b) derives a 32-byte subkey
|
||||
from the KEK with subkey id 1 and context `loginctx`. Its first 16 bytes are
|
||||
the SRP password.
|
||||
4. After SRP completes, or after the email OTP that replaces SRP when the
|
||||
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
|
||||
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.
|
||||
yields the auth token's bytes. Encoded as URL-safe base64 with padding, they
|
||||
are the `X-Auth-Token` value 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
|
||||
@@ -306,7 +308,8 @@ Endpoints used:
|
||||
- `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/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/logout`: end the calling token's session (`quak logout`).
|
||||
- `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
|
||||
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.
|
||||
atomic write is part of the retried unit: each attempt writes its own temporary
|
||||
file and removes it if the attempt fails, and only the attempt that completes
|
||||
renames its file into place, so a download that needed three attempts still
|
||||
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
|
||||
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
|
||||
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.
|
||||
It does not delete the cache: it prints the account's cache directory and says
|
||||
it still holds decrypted data (file keys in `metadata.json`, cached originals
|
||||
and thumbnails), for the user to delete if they want it gone.
|
||||
It does not delete the cache. When it knows the cache directory, from
|
||||
`--cache-dir` or from a session file it could read, it prints it and says it
|
||||
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
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
Every command runs on the same cache-backed library. The read commands —
|
||||
`collections`, `files`, `get`, `get-thumb`, `backup-metadata`,
|
||||
`helper list-missing-thumbnails` and `helper fix-missing-thumbnails` — force a
|
||||
fresh server round-trip before they answer, so they report current account state
|
||||
rather than whatever the cache last held. If that round-trip fails, the command
|
||||
prints the error on one line and exits 1. `--cache-dir` overrides where the
|
||||
cache lives; without it each account gets its own directory under the per-user
|
||||
cache path.
|
||||
Every command except `login`, `whoami` and `logout` runs on the cache-backed
|
||||
library. The read commands — `collections`, `files`, `get`, `get-thumb`,
|
||||
`backup-metadata`, `helper list-missing-thumbnails` and
|
||||
`helper fix-missing-thumbnails` — force a fresh server round-trip before they
|
||||
answer, so they report current account state rather than whatever the cache last
|
||||
held. If that round-trip fails, the command prints the error on one line and
|
||||
exits 1. `--cache-dir` overrides where the cache lives; without it each account
|
||||
gets its own directory under the per-user cache path.
|
||||
|
||||
`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
|
||||
@@ -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
|
||||
`--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
|
||||
refused. `backup-metadata --exif` (alias `--all`) additionally downloads each
|
||||
file to extract full EXIF/IPTC/XMP metadata. The listing and backup commands
|
||||
support `--json` for machine-readable output.
|
||||
refused. `backup-metadata --exif` (alias `--all`) additionally fetches each
|
||||
file's original through the cache and records its XMP metadata and, for a JPEG,
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
|
||||
- [x] Retry policy: no retry on 4xx, exponential backoff on 5xx and network
|
||||
errors
|
||||
- [x] Retry policy: no retry on 4xx (except `408` and `429`), exponential
|
||||
backoff on 5xx and network errors
|
||||
- [x] Update the API reference section below to match the current implementation
|
||||
- [x] `make docker` green
|
||||
- [x] Store live photos in a form a photo viewer can open
|
||||
@@ -604,21 +613,21 @@ background, so an unreachable server does not block opening.
|
||||
|
||||
`LibraryOptions`:
|
||||
|
||||
| Option | Default | Meaning |
|
||||
| ------------------------ | --------------------------- | --------------------------------------------------------------------- |
|
||||
| `client` | required | the account client (a `Client`, or any `LibraryClient`) |
|
||||
| `cacheDirectory` | `<XDG cache>/quak/<userID>` | where `metadata.json` and the content cache live |
|
||||
| `downloadDirectory` | none | backup destination; an original already stored there counts as cached |
|
||||
| `refreshIntervalSeconds` | `3` | background refresh cadence |
|
||||
| `precacheThumbnails` | `true` | prefetch every thumbnail, newest first |
|
||||
| `precacheOriginals` | `true` | prefetch the favorites album and the latest-window originals |
|
||||
| `precacheOriginalsDays` | `7` | length in days of that latest window |
|
||||
| `cacheOriginalsMaxBytes` | 100 GiB | hard ceiling on the originals cache |
|
||||
| `freeBelowBytes` | 50 GiB | free space to protect on the volume; the effective limit adapts down |
|
||||
| `isOriginalPinned` | none | extra predicate for originals that must never be evicted |
|
||||
| `pools` | fresh `RequestPools` | the bounded request pools (sets concurrency) |
|
||||
| `onProgress` | none | refresh/ML/precache progress callback (`RefreshEvent`) |
|
||||
| `contentSource` | the client's own | override the byte source (mainly for tests) |
|
||||
| Option | Default | Meaning |
|
||||
| ------------------------ | -------------------------------------------------- | --------------------------------------------------------------------- |
|
||||
| `client` | required | the account client (a `Client`, or any `LibraryClient`) |
|
||||
| `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 |
|
||||
| `refreshIntervalSeconds` | `3` | background refresh cadence |
|
||||
| `precacheThumbnails` | `true` | prefetch every thumbnail, newest first |
|
||||
| `precacheOriginals` | `true` | prefetch the favorites album and the latest-window originals |
|
||||
| `precacheOriginalsDays` | `7` | length in days of that latest window |
|
||||
| `cacheOriginalsMaxBytes` | 100 GiB | hard ceiling on the originals cache |
|
||||
| `freeBelowBytes` | 50 GiB | free space to protect on the volume; the effective limit adapts down |
|
||||
| `isOriginalPinned` | none | extra predicate for originals that must never be evicted |
|
||||
| `pools` | fresh `RequestPools` | the bounded request pools (sets concurrency) |
|
||||
| `onProgress` | none | refresh/ML/precache progress callback (`RefreshEvent`) |
|
||||
| `contentSource` | the client's own | override the byte source (mainly for tests) |
|
||||
|
||||
Concurrency is set through `pools`: construct
|
||||
`new RequestPools({ metadataConcurrency, contentConcurrency, thumbnailConcurrency })`
|
||||
|
||||
@@ -14,13 +14,27 @@ pre-1.0
|
||||
|
||||
# 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
|
||||
declares one.
|
||||
|
||||
# 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
|
||||
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`
|
||||
|
||||
Reference in New Issue
Block a user