14 Commits

Author SHA1 Message Date
2039608c07 Refresh vendored REPO_POLICIES.md from prompts repo
All checks were successful
check / check (push) Successful in 4s
2026-07-07 00:21:00 +02:00
4f506b0155 Adopt scripts-to-rule-them-all: script/ entrypoints, Makefile shims 2026-07-07 00:20:19 +02:00
dc0dd11f19 Format TODO.md with prettier (make fmt)
Some checks failed
check / check (push) Failing after 5s
2026-07-06 21:28:18 +02:00
84554a85ad Add standard Workflow section to TODO.md
Some checks failed
check / check (push) Failing after 6s
2026-07-06 21:06:40 +02:00
88510a3ff5 Add TODO.md
Some checks failed
check / check (push) Failing after 5s
2026-07-06 20:35:46 +02:00
6c26e3ccb7 Merge: skip deleted-collection tombstones in listCollections 2026-06-10 11:49:48 -07:00
d0b4ee979e Green: filter isDeleted tombstones out of listCollections 2026-06-10 11:49:45 -07:00
cb9ac29cb4 Red: listCollections must drop deleted-collection tombstones
/collections/v2 is a sync API: deleted collections remain in the
response forever with isDeleted: true, and their /collections/v2/diff
endpoint returns HTTP 404. A long-lived account accumulates hundreds
of tombstones, so any caller that iterates listCollections() output
(backup, backup-metadata) dies on the first one.
2026-06-10 11:49:11 -07:00
6a9e41a2ee Merge: decrypt collections shared by other users (sealed-box keys) 2026-06-10 11:46:54 -07:00
15d2effc2d Green: unseal shared collection keys with the account keypair
decryptCollection now takes the full key material {masterKey,
publicKey, secretKey} and dispatches on keyDecryptionNonce: present
means an owned collection (secretbox under the master key), absent
means a shared collection (sealed box to our public key). Client
already held the keypair for unsealing the auth token, so it just
passes it through.
2026-06-10 11:46:51 -07:00
59e0aa7d47 Red: shared collections arrive as sealed boxes with no keyDecryptionNonce
Collections shared with the account are not encrypted with the master
key: the sharer only knows the recipient's public key, so the server
delivers encryptedKey as crypto_box_seal to that key and omits
keyDecryptionNonce entirely. decryptCollection assumed the owned-only
wire format and crashed on fromBase64(undefined) for any account with
an incoming shared album, taking down listCollections and every
command built on it (backup, backup-metadata, ...).

The previous "shared" fixture was unfaithful (secretbox + nonce with a
foreign ownerID, a shape the server never sends), which is why the
suite stayed green. These tests model the real wire format and change
decryptCollection to take the full key material {masterKey, publicKey,
secretKey} so it can unseal shared collection keys.
2026-06-10 11:41:36 -07:00
b86ac2cd20 Merge: fix dual-2FA login against real server (empty-string fields) 2026-06-10 11:26:18 -07:00
68d8cfb7fe Green: treat empty-string 2FA session fields as absent
Use || instead of ?? when picking the TOTP session ID. The server
sends "" for unset 2FA fields (no omitempty), and "" ?? v2
short-circuits to "", which made dual-2FA accounts fall through to
the unsupported passkey branch.
2026-06-10 11:26:15 -07:00
2c51074294 Red: mock server must serialize empty 2FA fields like Go does
The museum server's EmailAuthorizationResponse declares
passkeySessionID, accountsUrl, twoFactorSessionID, and
twoFactorSessionIDV2 without `omitempty`, so Go always sends them,
as "" when unset. The previous mock omitted the unset fields
entirely, which let the ?? -based dispatch pass in tests while the
real server's "" defeated it and dual-2FA logins fell through to
the unsupported passkey branch.
2026-06-10 11:25:25 -07:00
29 changed files with 771 additions and 93 deletions

View File

@@ -6,4 +6,4 @@ jobs:
steps: steps:
# actions/checkout v4.2.2, 2026-02-22 # actions/checkout v4.2.2, 2026-02-22
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- run: docker build . - run: script/cibuild

View File

@@ -1,11 +1,10 @@
# node 22-alpine, 2026-02-22 # node 22-alpine, 2026-02-22
FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34 FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34
RUN apk add --no-cache make
WORKDIR /app WORKDIR /app
COPY script/ script/
COPY package.json yarn.lock ./ COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile RUN script/bootstrap
COPY . . COPY . .
RUN make check RUN make check

View File

@@ -1,27 +1,27 @@
.PHONY: test lint fmt fmt-check check build build-bin install dev clean docker hooks .PHONY: bootstrap setup test lint fmt fmt-check check build build-bin install dev clean docker hooks
# Use `timeout` (GNU coreutils) when available so `make test` is hard-capped.
# On macOS without coreutils this is empty and the cap is skipped.
TIMEOUT := $(shell command -v timeout 2>/dev/null || command -v gtimeout 2>/dev/null)
YARN := yarn run YARN := yarn run
bootstrap:
@script/bootstrap
setup:
@script/setup
test: test:
@$(TIMEOUT) $(if $(TIMEOUT),30s,) $(YARN) vitest run --reporter=dot || \ @script/test
{ echo "--- Rerunning with verbose for details ---"; \
$(YARN) vitest run --reporter=verbose; exit 1; }
lint: lint:
@$(YARN) eslint . @script/lint
@$(YARN) prettier --check .
fmt: fmt:
@$(YARN) prettier --write . @script/fmt
fmt-check: fmt-check:
@$(YARN) prettier --check . @script/fmt-check
check: test lint fmt-check check:
@script/check
build: build:
@$(YARN) tsc @$(YARN) tsc
@@ -41,13 +41,7 @@ clean:
@rm -rf dist coverage .vitest-cache *.tsbuildinfo @rm -rf dist coverage .vitest-cache *.tsbuildinfo
docker: docker:
docker build -t quak . @script/docker
hooks: hooks:
@printf '#!/bin/sh\nset -e\nmake lint\nmake fmt-check\n' > .git/hooks/pre-commit @script/install-precommit
@chmod +x .git/hooks/pre-commit
@echo "Installed pre-commit hook (runs make lint && make fmt-check)."
@echo "Note: tests are deliberately not in the pre-commit hook so the"
@echo "TDD red-phase commit (failing tests, no implementation yet)"
@echo "can land. CI runs make check via docker build, which catches"
@echo "any branch that ships red."

View File

@@ -66,6 +66,40 @@ const snapshot = client.toJSON();
const restored = Client.fromJSON(snapshot); const restored = Client.fromJSON(snapshot);
``` ```
## Entrypoints
This repository adheres to the
[Scripts to Rule Them All](https://github.com/github/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, then
`yarn install --frozen-lockfile`)
- `script/setup` — set up the repo for development after a fresh clone: runs
`script/bootstrap`, then `script/install-precommit`
- `script/projectname` — output the project name (our own extension); used by
`script/docker` for the image tag
- `script/test` — run the test suite (vitest, hard-capped at 30s where `timeout`
is available, verbose rerun on failure)
- `script/lint` — run eslint and a prettier check
- `script/fmt` — format all files with prettier (writes)
- `script/fmt-check` — check formatting (read-only)
- `script/check` — run all checks: `test`, `lint`, `fmt-check` (our own
extension)
- `script/docker` — build the Docker image, tagged via `script/projectname`
(byte-identical across repos)
- `script/cibuild` — cd to the repo root and `docker build .` (what CI runs; the
image build runs `make check`)
- `script/precommit` — run by the git pre-commit hook (our own extension); runs
`script/lint` and `script/fmt-check` but deliberately not the tests, so the
TDD red-phase commit can land
- `script/install-precommit` — installs the git pre-commit hook (our own
extension); `make hooks` shims to it
`make hooks` installs the pre-commit hook that runs `script/precommit`.
## Rationale ## Rationale
Ente is one of very few photo services with a credible end-to-end encryption Ente is one of very few photo services with a credible end-to-end encryption
@@ -113,11 +147,11 @@ All work on quak is test-driven. No exceptions.
test-then-implementation sequence into reviewable commits, but the final test-then-implementation sequence into reviewable commits, but the final
history must still show tests landing before (or with) the matching history must still show tests landing before (or with) the matching
implementation. implementation.
8. The pre-commit hook installed by `make hooks` runs 8. The pre-commit hook installed by `make hooks` runs `script/precommit`, which
`make lint && make fmt-check`, not the full `make check`. This is deliberate runs the lint and format checks but not the full `make check`. This is
so the TDD red-phase commit (failing tests, no implementation yet) can land. deliberate so the TDD red-phase commit (failing tests, no implementation yet)
The full `make check` runs as part of `docker build .`, which is what CI can land. The full `make check` runs as part of `docker build .`, which is
executes, so a red branch still cannot reach `main`. what CI executes, so a red branch still cannot reach `main`.
## Design ## Design

View File

@@ -1,6 +1,6 @@
--- ---
title: Repository Policies title: Repository Policies
last_modified: 2026-03-18 last_modified: 2026-07-06
--- ---
This document covers repository structure, tooling, and workflow standards. Code This document covers repository structure, tooling, and workflow standards. Code
@@ -34,10 +34,46 @@ style conventions are in separate documents:
every file before committing. There are zero exceptions to this rule. every file before committing. There are zero exceptions to this rule.
- Every repo with software must have a root `Makefile` with these targets: - Every repo with software must have a root `Makefile` with these targets:
`make test`, `make lint`, `make fmt` (writes), `make fmt-check` (read-only), `make bootstrap`, `make setup`, `make test`, `make lint`, `make fmt` (writes),
`make check` (prereqs: `test`, `lint`, `fmt-check`), `make docker`, and `make fmt-check` (read-only), `make check` (runs `test`, `lint`, `fmt-check`),
`make hooks` (installs pre-commit hook). A model Makefile is at `make docker`, and `make hooks` (installs pre-commit hook). A model Makefile
`https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`. is at `https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`.
- Repos follow the
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
pattern: the implementation of each Makefile target lives in an executable
script in `script/` (`script/bootstrap`, `script/setup`, `script/test`,
`script/lint`, `script/fmt`, `script/fmt-check`, `script/check`,
`script/docker`), and the Makefile targets are thin shims that call them. The
scripts must be POSIX sh (`#!/bin/sh`, `set -eu`, no bashisms) so they run in
minimal containers (e.g. alpine images have no bash); locate the repo root
with `$(cd "$(dirname "$0")/.." && pwd -P)` and `cd` there before acting. From
the standard's canonical set we use `bootstrap`, `setup` (make the repo ready
for development after a fresh clone: runs `bootstrap`, then
`install-precommit`, plus any repo-specific initialization), `test`, and
`cibuild`. `script/bootstrap` installs all dependencies idempotently and
assumes nothing is present: base tools come from nix, apt, brew, or apk
(detected in that order; apt runs noninteractive). For node it uses the
installed node if present; otherwise it installs a PINNED node version via
nvm, first installing nvm itself if missing — from a hash-verified GitHub
release archive (never `curl | sh`), with bash installed as an explicit
prerequisite since nvm requires bash. yarn is then pinned via
`corepack prepare yarn@<version> --activate`. Never install "latest" or "lts";
always exact versions. `script/cibuild` runs the CI build: it changes to the
repo root and runs `docker build .`; the Gitea workflow calls it. Four further
scripts are our own extensions to the standard: `script/check` runs
`script/test`, `script/lint`, and `script/fmt-check`; `script/precommit` is
what the git pre-commit hook runs, and it calls `script/check`;
`script/install-precommit` installs the git pre-commit hook (the `make hooks`
target shims to it); and `script/projectname` (literally that filename) simply
outputs the project's name. Scripts that need the name call
`script/projectname` — e.g. `script/docker` assembles its image tag from it —
so those scripts stay byte-identical across all repos. Repo-type-specific
pre-commit extras (e.g. `go mod tidy` verification in Go repos) belong in
`script/precommit`, not in the hook itself. Model scripts are at
`https://git.eeqj.de/sneak/prompts/raw/branch/main/script/<name>`. The README
must document the provided scripts in an **Entrypoints** section (see the
README requirements below).
- Always use Makefile targets (`make fmt`, `make test`, `make lint`, etc.) - Always use Makefile targets (`make fmt`, `make test`, `make lint`, etc.)
instead of invoking the underlying tools directly. The Makefile is the single instead of invoking the underlying tools directly. The Makefile is the single
@@ -57,7 +93,11 @@ style conventions are in separate documents:
as a build step so the build fails if the branch is not green. For non-server as a build step so the build fails if the branch is not green. For non-server
repos, the Dockerfile should bring up a development environment and run repos, the Dockerfile should bring up a development environment and run
`make check`. For server repos, `make check` should run as an early build `make check`. For server repos, `make check` should run as an early build
stage before the final image is assembled. stage before the final image is assembled. Dockerfiles install development
prerequisites by running `script/bootstrap` rather than duplicating installs
inline; COPY `script/` and the dependency manifests (`package.json` +
`yarn.lock`, `go.mod` + `go.sum`, etc.) before running it so the bootstrap
layer stays cached until dependencies change.
- **Dockerfiles must use a separate lint stage for fail-fast feedback.** Go - **Dockerfiles must use a separate lint stage for fail-fast feedback.** Go
repos use a multistage build where linting runs in an independent stage based repos use a multistage build where linting runs in an independent stage based
@@ -127,8 +167,9 @@ style conventions are in separate documents:
artifacts or heavier dependencies. artifacts or heavier dependencies.
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that - Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
runs `docker build .` on push. Since the Dockerfile already runs `make check`, runs `script/cibuild` (which runs `docker build .`) on push. Since the
a successful build implies all checks pass. Dockerfile already runs `make check`, a successful build implies all checks
pass.
- Use platform-standard formatters: `black` for Python, `prettier` for - Use platform-standard formatters: `black` for Python, `prettier` for
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
@@ -136,9 +177,11 @@ style conventions are in separate documents:
Markdown (hard-wrap at 80 columns). Documentation and writing repos (Markdown, Markdown (hard-wrap at 80 columns). Documentation and writing repos (Markdown,
HTML, CSS) should also have `.prettierrc` and `.prettierignore`. HTML, CSS) should also have `.prettierrc` and `.prettierignore`.
- Pre-commit hook: `make check` if local testing is possible, otherwise - Pre-commit hook: runs `script/precommit`, which calls `script/check`. If local
`make lint && make fmt-check`. The Makefile should provide a `make hooks` testing is not possible in the repo, `script/precommit` may skip `script/test`
target to install the pre-commit hook. and run only `script/lint` and `script/fmt-check`. The hook is installed by
`script/install-precommit`; the Makefile must provide a `make hooks` target
that shims to it.
- All repos with software must have tests that run via the platform-standard - All repos with software must have tests that run via the platform-standard
test framework (`go test`, `pytest`, `jest`/`vitest`, etc.). If no meaningful test framework (`go test`, `pytest`, `jest`/`vitest`, etc.). If no meaningful
@@ -297,6 +340,10 @@ style conventions are in separate documents:
"µPaaS is an MIT-licensed Go web application by @sneak that receives "µPaaS is an MIT-licensed Go web application by @sneak that receives
git-frontend webhooks and deploys applications via Docker in realtime." git-frontend webhooks and deploys applications via Docker in realtime."
- **Getting Started**: Copy-pasteable install/usage code block. - **Getting Started**: Copy-pasteable install/usage code block.
- **Entrypoints**: Opens by stating that the repo adheres to the
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
standard (with that link), then documents each provided `script/`
entrypoint and its purpose.
- **Rationale**: Why does this exist? - **Rationale**: Why does this exist?
- **Design**: How is the program structured? - **Design**: How is the program structured?
- **TODO**: Update meticulously, even between commits. When planning, put - **TODO**: Update meticulously, even between commits. When planning, put
@@ -351,6 +398,9 @@ style conventions are in separate documents:
- `README.md`, `.git`, `.gitignore`, `.editorconfig` - `README.md`, `.git`, `.gitignore`, `.editorconfig`
- `LICENSE`, `REPO_POLICIES.md` (copy from the `prompts` repo) - `LICENSE`, `REPO_POLICIES.md` (copy from the `prompts` repo)
- `Makefile` - `Makefile`
- `script/` entrypoints (`bootstrap`, `setup`, `projectname`, `test`,
`lint`, `fmt`, `fmt-check`, `check`, `docker`, `cibuild`, `precommit`,
`install-precommit`)
- `Dockerfile`, `.dockerignore` - `Dockerfile`, `.dockerignore`
- `.gitea/workflows/check.yml` - `.gitea/workflows/check.yml`
- Go: `go.mod`, `go.sum`, `.golangci.yml` - Go: `go.mod`, `go.sum`, `.golangci.yml`

54
TODO.md Normal file
View File

@@ -0,0 +1,54 @@
# Workflow
- branch (from `main`)
- do the work in Next Step
- move Next Step to the top of Completed Steps
- move the top item of Future Steps into Next Step
- commit (`TODO.md` changes in the same commit as the work)
- merge to `main` if the branch is not protected, otherwise open a PR
- push
# Status
pre-1.0
# Next Step
Implement the download retry policy from the README TODO: no retry on 4xx,
exponential backoff on 5xx and network errors. Apply it to file and thumbnail
downloads, cover it with mock-server tests, and update the README TODO checkbox.
# Completed Steps
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, Makefile
shims, README Entrypoints section
- 2026-06-10: Decrypted collections shared by other users (sealed-box keys);
listCollections drops deleted-collection tombstones.
- 2026-06-10: Login hardening: dual-2FA empty-string fields handled, TOTP
preferred when a passkey is also enrolled, interactive input via
@inquirer/prompts.
- 2026-06-10: Replaced sharp with pure JS (jpeg-js + exif-reader); added
single-binary bun build and make install.
- 2026-06-09: Added backup-metadata command (ML data always included, --exif
opt-in); rewrote README to match the implementation; added thumbnail helper
tests.
- 2026-05-13: Full CLI surface: login, backup with dedup symlink layout,
collections, files, get, get-thumb, thumbnail repair helpers.
- 2026-05-13: Client OO API with literate usage tests; file download and
decryption; all three metadata layers decrypted and persisted; renamed quack
to quak.
- 2026-05-11: SRP login flow (email OTP + TOTP) and ApiClient.
# Future Steps
- Retry policy: no retry on 4xx, exponential backoff on 5xx and network errors
(the Next Step).
- Update the README API reference section to match the current implementation.
- Make `make docker` green.
- Tag v1.0.0.
- Future desktop client, separate repo:
- Electron app skeleton consuming this library.
- Local SQLite cache keyed on (collectionID, fileID, updationTime).
- Background sync worker streaming new files into the cache.
- Gallery UI: thumbnails, full-image view, basic search.
- Upload, delete, and share operations in the library.

136
script/bootstrap Executable file
View File

@@ -0,0 +1,136 @@
#!/bin/sh
# script/bootstrap: install all dependencies needed to build and develop
# this repo. Idempotent: every install is guarded by a check so already
# installed tools are skipped. Base tooling comes from nix, apt, brew,
# or apk (detected in that order); assumes nothing is present. Node is
# used directly if installed; otherwise it is installed at a pinned
# version via nvm (installing nvm itself first, from a hash-verified
# release archive, never curl | sh).
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Pinned versions, 2026-07-06
NODE_VERSION="22.17.0"
NVM_VERSION="0.40.3"
# sha256 of https://github.com/nvm-sh/nvm/archive/refs/tags/v0.40.3.tar.gz
NVM_SHA256="5f4d6aaa04a177dc93c985e31dbc411ab6b8c6e1e21d8015dbc1372625fcd1d0"
YARN_VERSION="1.22.22"
PKGMGR=""
SUDO=""
detect_pkgmgr() {
[ -n "$PKGMGR" ] && return 0
if command -v nix-env >/dev/null 2>&1; then
PKGMGR="nix"
elif command -v apt-get >/dev/null 2>&1; then
PKGMGR="apt"
elif command -v brew >/dev/null 2>&1; then
PKGMGR="brew"
elif command -v apk >/dev/null 2>&1; then
PKGMGR="apk"
else
echo "bootstrap: no supported package manager (nix, apt, brew, apk)" >&2
exit 1
fi
if [ "$PKGMGR" = "apt" ]; then
export DEBIAN_FRONTEND=noninteractive
if [ "$(id -u)" != "0" ]; then
SUDO="sudo"
fi
fi
}
# pkg_install <nix-attr> <apt-pkg> <brew-formula> <apk-pkg>
pkg_install() {
detect_pkgmgr
case "$PKGMGR" in
nix) nix-env -iA "nixpkgs.$1" ;;
apt) $SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y "$2" ;;
brew) brew install "$3" ;;
apk) apk add --no-cache "$4" ;;
esac
}
missing() {
! command -v "$1" >/dev/null 2>&1
}
# verify_sha256 <file> <expected-hash>
verify_sha256() {
if command -v sha256sum >/dev/null 2>&1; then
actual="$(sha256sum "$1" | cut -d' ' -f1)"
else
actual="$(shasum -a 256 "$1" | cut -d' ' -f1)"
fi
if [ "$actual" != "$2" ]; then
echo "bootstrap: sha256 mismatch for $1" >&2
echo " expected: $2" >&2
echo " actual: $actual" >&2
exit 1
fi
}
# nvm is a bash script; run a command in a bash with nvm loaded
nvm_sh() {
bash -c ". \"\$HOME/.nvm/nvm.sh\" && $*"
}
ensure_nvm() {
[ -s "$HOME/.nvm/nvm.sh" ] && return 0
# nvm prerequisites; nvm itself requires bash
if missing bash; then pkg_install bash bash bash bash; fi
if missing curl; then pkg_install curl curl curl curl; fi
if missing git; then pkg_install git git git git; fi
tmp="$(mktemp -d)"
curl -fsSL -o "$tmp/nvm.tar.gz" \
"https://github.com/nvm-sh/nvm/archive/refs/tags/v${NVM_VERSION}.tar.gz"
verify_sha256 "$tmp/nvm.tar.gz" "$NVM_SHA256"
mkdir -p "$HOME/.nvm"
tar -xzf "$tmp/nvm.tar.gz" -C "$HOME/.nvm" --strip-components=1
rm -rf "$tmp"
}
ensure_node() {
if ! missing node; then return 0; fi
ensure_nvm
nvm_sh "nvm install $NODE_VERSION"
}
ensure_yarn() {
if ! missing yarn; then return 0; fi
if ! missing corepack; then
corepack enable
corepack prepare "yarn@$YARN_VERSION" --activate
elif [ -s "$HOME/.nvm/nvm.sh" ]; then
nvm_sh "nvm use $NODE_VERSION >/dev/null && corepack enable && \
corepack prepare yarn@$YARN_VERSION --activate"
else
npm install -g "yarn@$YARN_VERSION"
fi
}
install_js_deps() {
if missing yarn && [ -s "$HOME/.nvm/nvm.sh" ]; then
nvm_sh "nvm use $NODE_VERSION >/dev/null && cd \"$ROOT\" && \
yarn install --frozen-lockfile"
else
yarn install --frozen-lockfile
fi
}
main() {
cd "$ROOT"
if missing make; then pkg_install gnumake make make make; fi
if missing git; then pkg_install git git git git; fi
ensure_node
ensure_yarn
install_js_deps
echo "bootstrap complete"
}
main "$@"

14
script/check Executable file
View File

@@ -0,0 +1,14 @@
#!/bin/sh
# script/check: run all checks (test, lint, fmt-check). Our own
# extension to scripts-to-rule-them-all. Must not modify any files.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
main() {
"$SCRIPT_DIR/test"
"$SCRIPT_DIR/lint"
"$SCRIPT_DIR/fmt-check"
}
main "$@"

13
script/cibuild Executable file
View File

@@ -0,0 +1,13 @@
#!/bin/sh
# script/cibuild: run the CI build. The Dockerfile runs script/check, so
# a successful build implies all checks pass.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
docker build .
}
main "$@"

14
script/docker Executable file
View File

@@ -0,0 +1,14 @@
#!/bin/sh
# script/docker: build the Docker image tagged with the project name.
# Identical in all repos; the tag comes from script/projectname.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() {
cd "$ROOT"
docker build -t "$("$SCRIPT_DIR/projectname")" .
}
main "$@"

12
script/fmt Executable file
View File

@@ -0,0 +1,12 @@
#!/bin/sh
# script/fmt: format all files (writes).
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
yarn run prettier --write .
}
main "$@"

12
script/fmt-check Executable file
View File

@@ -0,0 +1,12 @@
#!/bin/sh
# script/fmt-check: check formatting (read-only).
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
yarn run prettier --check .
}
main "$@"

16
script/install-precommit Executable file
View File

@@ -0,0 +1,16 @@
#!/bin/sh
# script/install-precommit: install the git pre-commit hook that runs
# script/precommit. Our own extension to scripts-to-rule-them-all.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
hook=".git/hooks/pre-commit"
printf '#!/bin/sh\nset -e\nscript/precommit\n' > .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit
echo "pre-commit hook installed: runs script/precommit"
}
main "$@"

13
script/lint Executable file
View File

@@ -0,0 +1,13 @@
#!/bin/sh
# script/lint: run the linter (eslint plus a prettier check).
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
yarn run eslint .
yarn run prettier --check .
}
main "$@"

18
script/precommit Executable file
View File

@@ -0,0 +1,18 @@
#!/bin/sh
# script/precommit: run by the git pre-commit hook; fails the commit if
# checks fail. Our own extension to scripts-to-rule-them-all.
#
# Runs lint and fmt-check but deliberately NOT the tests, so the TDD
# red-phase commit (failing tests, no implementation yet) can land. CI
# runs make check via docker build, which catches any branch that
# ships red.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
main() {
"$SCRIPT_DIR/lint"
"$SCRIPT_DIR/fmt-check"
}
main "$@"

12
script/projectname Executable file
View File

@@ -0,0 +1,12 @@
#!/bin/sh
# script/projectname: output the name of this project. Our own
# extension to scripts-to-rule-them-all. Other scripts that need the
# name (e.g. script/docker) call this, so they can stay identical
# across all repos.
set -eu
main() {
echo "quack"
}
main "$@"

13
script/setup Executable file
View File

@@ -0,0 +1,13 @@
#!/bin/sh
# script/setup: set up the repo for development after a fresh clone:
# installs dependencies and the git pre-commit hook.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
main() {
"$SCRIPT_DIR/bootstrap"
"$SCRIPT_DIR/install-precommit"
}
main "$@"

25
script/test Executable file
View File

@@ -0,0 +1,25 @@
#!/bin/sh
# script/test: run the test suite. Uses `timeout` (GNU coreutils) when
# available so the run is hard-capped at 30s; on macOS without
# coreutils the cap is skipped.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
rerun_verbose() {
echo "--- Rerunning with verbose for details ---"
yarn run vitest run --reporter=verbose
exit 1
}
main() {
cd "$ROOT"
TIMEOUT="$(command -v timeout 2>/dev/null || command -v gtimeout 2>/dev/null || true)"
if [ -n "$TIMEOUT" ]; then
"$TIMEOUT" 30s yarn run vitest run --reporter=dot || rerun_verbose
else
yarn run vitest run --reporter=dot || rerun_verbose
fi
}
main "$@"

View File

@@ -76,8 +76,12 @@ const srpHandshake = async (
// twoFactorSessionIDV2 is set (instead of twoFactorSessionID) when the // twoFactorSessionIDV2 is set (instead of twoFactorSessionID) when the
// account has BOTH passkeys and TOTP. Prefer TOTP: a CLI cannot perform // account has BOTH passkeys and TOTP. Prefer TOTP: a CLI cannot perform
// a WebAuthn ceremony, and the user has a TOTP secret enrolled. // a WebAuthn ceremony, and the user has a TOTP secret enrolled.
//
// The server marshals these fields without `omitempty`, so unset fields
// arrive as "" rather than being absent. Use || (not ??) so empty
// strings are treated as not-set.
const totpSessionID = const totpSessionID =
verifyResponse.twoFactorSessionID ?? verifyResponse.twoFactorSessionID ||
verifyResponse.twoFactorSessionIDV2; verifyResponse.twoFactorSessionIDV2;
if (totpSessionID) { if (totpSessionID) {
return { kind: "totp", sessionID: totpSessionID }; return { kind: "totp", sessionID: totpSessionID };

View File

@@ -155,9 +155,21 @@ export class Client {
const { collections } = await this.api.getJSON<{ const { collections } = await this.api.getJSON<{
collections: RawCollection[]; collections: RawCollection[];
}>("/collections/v2", { sinceTime: 0 }); }>("/collections/v2", { sinceTime: 0 });
return collections.map((raw) => // The sync API keeps returning deleted collections as tombstones
decryptCollection(raw, this.masterKey, this.userID), // (isDeleted: true); their diff endpoint 404s, so drop them.
); return collections
.filter((raw) => !raw.isDeleted)
.map((raw) =>
decryptCollection(
raw,
{
masterKey: this.masterKey,
publicKey: this.publicKey,
secretKey: this.secretKey,
},
this.userID,
),
);
} }
async listFiles( async listFiles(

View File

@@ -24,6 +24,7 @@ export type {
FileBlob, FileBlob,
FileMetadata, FileMetadata,
FileType, FileType,
KeyMaterial,
Microseconds, Microseconds,
RawCollection, RawCollection,
RawEnteFile, RawEnteFile,

View File

@@ -1,10 +1,16 @@
import { decryptBlob, decryptBox, fromBase64 } from "../crypto/index.js"; import {
decryptBlob,
decryptBox,
decryptSealed,
fromBase64,
} from "../crypto/index.js";
import type { import type {
Collection, Collection,
CollectionType, CollectionType,
EnteFile, EnteFile,
FileMetadata, FileMetadata,
FileType, FileType,
KeyMaterial,
RawCollection, RawCollection,
RawEnteFile, RawEnteFile,
RawMagicMetadata, RawMagicMetadata,
@@ -30,14 +36,25 @@ const parseFileType = (n: number): FileType => FILE_TYPE_MAP[n] ?? "unknown";
export const decryptCollection = ( export const decryptCollection = (
raw: RawCollection, raw: RawCollection,
masterKey: Uint8Array, keys: KeyMaterial,
currentUserID?: number, currentUserID?: number,
): Collection => { ): Collection => {
const key = decryptBox( // Owned collections carry their key as a secretbox under our master
fromBase64(raw.encryptedKey), // key, with the nonce in keyDecryptionNonce. Collections shared with
fromBase64(raw.keyDecryptionNonce), // us carry it as an anonymous sealed box to our public key and have
masterKey, // no keyDecryptionNonce at all (sealed boxes embed an ephemeral
); // public key instead).
const key = raw.keyDecryptionNonce
? decryptBox(
fromBase64(raw.encryptedKey),
fromBase64(raw.keyDecryptionNonce),
keys.masterKey,
)
: decryptSealed(
fromBase64(raw.encryptedKey),
keys.publicKey,
keys.secretKey,
);
let name = ""; let name = "";
if (raw.encryptedName && raw.nameDecryptionNonce) { if (raw.encryptedName && raw.nameDecryptionNonce) {

View File

@@ -6,6 +6,7 @@ export type {
FileBlob, FileBlob,
FileMetadata, FileMetadata,
FileType, FileType,
KeyMaterial,
Microseconds, Microseconds,
RawCollection, RawCollection,
RawEnteFile, RawEnteFile,

View File

@@ -50,13 +50,25 @@ export interface EnteFile {
updationTime: Microseconds; updationTime: Microseconds;
} }
// The key material a logged-in client holds, everything needed to decrypt
// any collection: the master key (secretbox for owned collection keys) and
// the X25519 keypair (sealed box for collection keys shared with us).
export interface KeyMaterial {
masterKey: Uint8Array;
publicKey: Uint8Array;
secretKey: Uint8Array;
}
// Raw shapes as they arrive from the Ente API, before decryption. // Raw shapes as they arrive from the Ente API, before decryption.
export interface RawCollection { export interface RawCollection {
id: number; id: number;
owner: { id: number }; owner: { id: number };
encryptedKey: string; encryptedKey: string;
keyDecryptionNonce: string; // Absent for collections shared with us: their encryptedKey is a
// sealed box to our public key, which embeds an ephemeral public key
// instead of using a nonce.
keyDecryptionNonce?: string;
encryptedName?: string; encryptedName?: string;
nameDecryptionNonce?: string; nameDecryptionNonce?: string;
type: string; type: string;

View File

@@ -200,11 +200,25 @@ const buildMockFetch = (
srpServer.checkM1(Buffer.from(body.srpM1, "base64")); srpServer.checkM1(Buffer.from(body.srpM1, "base64"));
const M2 = srpServer.computeM2(); const M2 = srpServer.computeM2();
// IMPORTANT: the museum server's EmailAuthorizationResponse
// (server/ente/user.go) declares passkeySessionID, accountsUrl,
// twoFactorSessionID, and twoFactorSessionIDV2 WITHOUT the
// `omitempty` JSON tag. Go therefore always serializes them,
// sending "" (empty string, NOT null/absent) for any that do
// not apply. These mocks must reproduce that faithfully: a
// client that distinguishes fields with `??` instead of `||`
// passes against an omitting mock but breaks against the real
// server.
if (opts?.requireTOTP) { if (opts?.requireTOTP) {
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
id: 42,
srpM2: M2.toString("base64"), srpM2: M2.toString("base64"),
passkeySessionID: "",
accountsUrl: "",
twoFactorSessionID: "totp-session-999", twoFactorSessionID: "totp-session-999",
twoFactorSessionIDV2: "",
}), }),
{ {
status: 200, status: 200,
@@ -218,11 +232,14 @@ const buildMockFetch = (
// server sets passkeySessionID + twoFactorSessionIDV2 (not // server sets passkeySessionID + twoFactorSessionIDV2 (not
// twoFactorSessionID -- that's deliberate, so old clients // twoFactorSessionID -- that's deliberate, so old clients
// that only know the V1 field keep using the passkey flow). // that only know the V1 field keep using the passkey flow).
// The V1 field is still present on the wire as "".
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
id: 42,
srpM2: M2.toString("base64"), srpM2: M2.toString("base64"),
passkeySessionID: "passkey-session-123", passkeySessionID: "passkey-session-123",
accountsUrl: "https://accounts.ente.io", accountsUrl: "https://accounts.ente.io",
twoFactorSessionID: "",
twoFactorSessionIDV2: "totp-session-v2-456", twoFactorSessionIDV2: "totp-session-v2-456",
}), }),
{ {
@@ -238,6 +255,10 @@ const buildMockFetch = (
id: 42, id: 42,
keyAttributes: fixture.keyAttributes, keyAttributes: fixture.keyAttributes,
encryptedToken: fixture.encryptedToken, encryptedToken: fixture.encryptedToken,
passkeySessionID: "",
accountsUrl: "",
twoFactorSessionID: "",
twoFactorSessionIDV2: "",
}), }),
{ {
status: 200, status: 200,

View File

@@ -91,6 +91,7 @@ interface ServerState {
thumbHeader: Uint8Array; thumbHeader: Uint8Array;
thumbCiphertext: Uint8Array; thumbCiphertext: Uint8Array;
collectionKey: Uint8Array; collectionKey: Uint8Array;
sharedCollectionKey: Uint8Array;
} }
let server: ServerState; let server: ServerState;
@@ -211,6 +212,69 @@ const buildServer = async (): Promise<ServerState> => {
updationTime: 1700000000000000, updationTime: 1700000000000000,
}; };
// A collection another user shared WITH us. The sharer does not have
// our master key, only our public key, so the real server delivers the
// collection key as an anonymous sealed box (crypto_box_seal) to our
// public key and the response carries NO keyDecryptionNonce. Every
// account with an incoming shared album has one of these in its
// /collections/v2 response, so the mock must include one too.
const sharedCollectionKey = sodium.crypto_secretbox_keygen();
const sealedSharedKey = sodium.crypto_box_seal(
sharedCollectionKey,
kp.publicKey,
);
const sharedNameNonce = sodium.randombytes_buf(
sodium.crypto_secretbox_NONCEBYTES,
);
const encSharedName = sodium.crypto_secretbox_easy(
new TextEncoder().encode("Friend's Wedding"),
sharedNameNonce,
sharedCollectionKey,
);
const rawSharedCollection = {
id: 2,
owner: { id: 99 },
encryptedKey: toBase64(sealedSharedKey),
encryptedName: toBase64(encSharedName),
nameDecryptionNonce: toBase64(sharedNameNonce),
type: "album",
updationTime: 1700000000000000,
};
// A DELETED collection. /collections/v2 is a sync API: deleted
// collections stay in the response forever as tombstones with
// isDeleted: true (a long-lived real account accumulates hundreds).
// Their keys still decrypt, but /collections/v2/diff returns
// HTTP 404 for them, so they must never surface from
// listCollections(); a client that naively iterates them dies on
// the first deleted album.
const deletedKey = sodium.crypto_secretbox_keygen();
const dkNonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
const encDeletedKey = sodium.crypto_secretbox_easy(
deletedKey,
dkNonce,
masterKey,
);
const deletedNameNonce = sodium.randombytes_buf(
sodium.crypto_secretbox_NONCEBYTES,
);
const encDeletedName = sodium.crypto_secretbox_easy(
new TextEncoder().encode("Old Album"),
deletedNameNonce,
deletedKey,
);
const rawDeletedCollection = {
id: 3,
owner: { id: 42 },
encryptedKey: toBase64(encDeletedKey),
keyDecryptionNonce: toBase64(dkNonce),
encryptedName: toBase64(encDeletedName),
nameDecryptionNonce: toBase64(deletedNameNonce),
type: "album",
updationTime: 1700000000000000,
isDeleted: true,
};
const rawFile = { const rawFile = {
id: 100, id: 100,
collectionID: 1, collectionID: 1,
@@ -230,6 +294,10 @@ const buildServer = async (): Promise<ServerState> => {
// can return them. This is ugly plumbing; in a real program you // can return them. This is ugly plumbing; in a real program you
// never see any of it. // never see any of it.
(globalThis as Record<string, unknown>).__mockRawCollection = rawCollection; (globalThis as Record<string, unknown>).__mockRawCollection = rawCollection;
(globalThis as Record<string, unknown>).__mockRawSharedCollection =
rawSharedCollection;
(globalThis as Record<string, unknown>).__mockRawDeletedCollection =
rawDeletedCollection;
(globalThis as Record<string, unknown>).__mockRawFile = rawFile; (globalThis as Record<string, unknown>).__mockRawFile = rawFile;
return { return {
@@ -253,6 +321,7 @@ const buildServer = async (): Promise<ServerState> => {
thumbHeader: thumbPush.header, thumbHeader: thumbPush.header,
thumbCiphertext, thumbCiphertext,
collectionKey, collectionKey,
sharedCollectionKey,
}; };
}; };
@@ -303,9 +372,14 @@ const buildMockFetch = (s: ServerState) => {
}); });
} }
if (path === "/collections/v2") { if (path === "/collections/v2") {
const raw = (globalThis as Record<string, unknown>) const g = globalThis as Record<string, unknown>;
.__mockRawCollection; return json({
return json({ collections: [raw] }); collections: [
g.__mockRawCollection,
g.__mockRawSharedCollection,
g.__mockRawDeletedCollection,
],
});
} }
if (path === "/collections/v2/diff") { if (path === "/collections/v2/diff") {
const raw = (globalThis as Record<string, unknown>).__mockRawFile; const raw = (globalThis as Record<string, unknown>).__mockRawFile;
@@ -412,6 +486,10 @@ describe("quak Client usage guide", () => {
* encryption key. `listCollections()` fetches them from the server, * encryption key. `listCollections()` fetches them from the server,
* decrypts the keys and names, and returns typed objects. * decrypts the keys and names, and returns typed objects.
* *
* This works transparently for both kinds of collection: ones you
* own (key encrypted with your master key) and ones shared with you
* (key sealed to your public key, flagged with `isShared: true`).
*
* ```ts * ```ts
* const collections = await client.listCollections(); * const collections = await client.listCollections();
* for (const c of collections) { * for (const c of collections) {
@@ -419,7 +497,7 @@ describe("quak Client usage guide", () => {
* } * }
* ``` * ```
*/ */
it("3. list and decrypt collections", async () => { it("3. list and decrypt collections, owned and shared", async () => {
const client = await Client.login({ const client = await Client.login({
email: TEST_EMAIL, email: TEST_EMAIL,
password: TEST_PASSWORD, password: TEST_PASSWORD,
@@ -428,12 +506,25 @@ describe("quak Client usage guide", () => {
const collections = await client.listCollections(); const collections = await client.listCollections();
expect(collections.length).toBe(1); // The mock server also returns a deleted collection (id 3).
expect(collections[0]!.name).toBe("Vacation"); // Deleted collections are tombstones in the sync protocol: their
expect(collections[0]!.type).toBe("album"); // file diff endpoint 404s, so listCollections must drop them.
expect(collections[0]!.id).toBe(1); expect(collections.length).toBe(2);
expect(collections.find((c) => c.id === 3)).toBeUndefined();
const owned = collections.find((c) => c.id === 1)!;
expect(owned.name).toBe("Vacation");
expect(owned.type).toBe("album");
expect(owned.isShared).toBe(false);
// The decrypted collection key is available for advanced use. // The decrypted collection key is available for advanced use.
expect(collections[0]!.key.length).toBe(32); expect(owned.key.length).toBe(32);
const shared = collections.find((c) => c.id === 2)!;
expect(shared.name).toBe("Friend's Wedding");
expect(shared.isShared).toBe(true);
// The key was unsealed with our keypair; the decrypted name above
// already proves it round-trips, but check it exactly too.
expect(shared.key).toEqual(server.sharedCollectionKey);
}); });
/** /**

View File

@@ -25,7 +25,10 @@ const main = async () => {
process.exit(1); process.exit(1);
} }
const { masterKey, token } = await unwrapAuth(challenge.response, PASSWORD); const { masterKey, secretKey, publicKey, token } = await unwrapAuth(
challenge.response,
PASSWORD,
);
api.setAuthToken(token); api.setAuthToken(token);
console.log("Logged in, user ID:", challenge.response.id); console.log("Logged in, user ID:", challenge.response.id);
@@ -37,7 +40,7 @@ const main = async () => {
const userID = challenge.response.id; const userID = challenge.response.id;
const collections = rawCollections.map((raw) => const collections = rawCollections.map((raw) =>
decryptCollection(raw, masterKey, userID), decryptCollection(raw, { masterKey, publicKey, secretKey }, userID),
); );
console.log(`${collections.length} collection(s):`); console.log(`${collections.length} collection(s):`);

View File

@@ -7,12 +7,27 @@
* *
* ## Collection decryption * ## Collection decryption
* *
* The server stores each collection's encryption key sealed under the * How a collection's key is encrypted depends on who owns it:
* owner's master key (secretbox). The collection's name is then sealed
* under that collection key (also secretbox). `decryptCollection`:
* *
* 1. decryptBox(encryptedKey, keyDecryptionNonce, masterKey) -> collectionKey * OWNED collections (owner == current user): the collection key is a
* 2. decryptBox(encryptedName, nameDecryptionNonce, collectionKey) -> name (UTF-8) * secretbox under the owner's master key, and `keyDecryptionNonce`
* carries the nonce.
*
* SHARED collections (owned by someone else, shared with us): the owner
* does not have our master key, so the server instead carries the
* collection key as an anonymous SEALED BOX (crypto_box_seal) to our
* X25519 public key, and `keyDecryptionNonce` is ABSENT from the wire
* (sealed boxes embed an ephemeral public key instead of a nonce).
*
* `decryptCollection` therefore takes the full key material (master key
* plus keypair) and dispatches on the presence of `keyDecryptionNonce`:
*
* 1a. nonce present: decryptBox(encryptedKey, keyDecryptionNonce,
* masterKey) -> collectionKey
* 1b. nonce absent: decryptSealed(encryptedKey, publicKey, secretKey)
* -> collectionKey
* 2. decryptBox(encryptedName, nameDecryptionNonce, collectionKey)
* -> name (UTF-8)
* 3. Maps the string `type` field to a CollectionType union member * 3. Maps the string `type` field to a CollectionType union member
* 4. Returns a Collection with decrypted key, name, and type * 4. Returns a Collection with decrypted key, name, and type
* *
@@ -38,12 +53,28 @@ import sodium from "libsodium-wrappers-sumo";
import { beforeAll, describe, expect, it } from "vitest"; import { beforeAll, describe, expect, it } from "vitest";
import { init, toBase64 } from "../../src/crypto/index.js"; import { init, toBase64 } from "../../src/crypto/index.js";
import { decryptCollection, decryptFile } from "../../src/model/index.js"; import { decryptCollection, decryptFile } from "../../src/model/index.js";
import type { RawCollection, RawEnteFile } from "../../src/model/index.js"; import type {
KeyMaterial,
RawCollection,
RawEnteFile,
} from "../../src/model/index.js";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Helpers // Helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// The full set of key material a logged-in client holds: the master key
// (decrypts owned collection keys) and the X25519 keypair (unseals shared
// collection keys and the auth token).
const buildKeys = (): KeyMaterial => {
const kp = sodium.crypto_box_keypair();
return {
masterKey: sodium.crypto_secretbox_keygen(),
publicKey: kp.publicKey,
secretKey: kp.privateKey,
};
};
const secretboxEncrypt = ( const secretboxEncrypt = (
plaintext: Uint8Array, plaintext: Uint8Array,
key: Uint8Array, key: Uint8Array,
@@ -80,6 +111,38 @@ const buildRawCollection = (
return { raw, collectionKey }; return { raw, collectionKey };
}; };
// A collection shared with us by another user. The wire format differs
// from owned collections in two ways, both verified against the live
// api.ente.io: `encryptedKey` is an anonymous sealed box to OUR public
// key (80 bytes for a 32-byte key, vs 48 for a secretbox), and
// `keyDecryptionNonce` is entirely ABSENT from the JSON.
const buildSharedRawCollection = (
recipientPublicKey: Uint8Array,
opts?: { name?: string; ownerID?: number },
): { raw: RawCollection; collectionKey: Uint8Array } => {
const collectionKey = sodium.crypto_secretbox_keygen();
const sealedKey = sodium.crypto_box_seal(collectionKey, recipientPublicKey);
const name = opts?.name ?? "Shared Album";
const { ciphertext: encName, nonce: nameNonce } = secretboxEncrypt(
new TextEncoder().encode(name),
collectionKey,
);
const raw: RawCollection = {
id: 101,
owner: { id: opts?.ownerID ?? 99 },
encryptedKey: toBase64(sealedKey),
// NOTE: no keyDecryptionNonce. Do not "fix" this fixture by adding
// one: its absence is exactly what the real server sends, and an
// unfaithful fixture here previously masked a crash on every
// account with an incoming shared album.
encryptedName: toBase64(encName),
nameDecryptionNonce: toBase64(nameNonce),
type: "album",
updationTime: 1700000000000000,
};
return { raw, collectionKey };
};
const buildRawFile = ( const buildRawFile = (
collectionKey: Uint8Array, collectionKey: Uint8Array,
opts?: { title?: string; fileType?: number; creationTime?: number }, opts?: { title?: string; fileType?: number; creationTime?: number },
@@ -137,13 +200,13 @@ describe("model.decryptCollection", () => {
await sodium.ready; await sodium.ready;
}); });
it("decrypts the collection key and name from a raw server response", () => { it("decrypts an owned collection key and name from a raw server response", () => {
const masterKey = sodium.crypto_secretbox_keygen(); const keys = buildKeys();
const { raw, collectionKey } = buildRawCollection(masterKey, { const { raw, collectionKey } = buildRawCollection(keys.masterKey, {
name: "Summer Photos", name: "Summer Photos",
}); });
const col = decryptCollection(raw, masterKey, 1); const col = decryptCollection(raw, keys, 1);
expect(col.id).toBe(100); expect(col.id).toBe(100);
expect(col.key).toEqual(collectionKey); expect(col.key).toEqual(collectionKey);
@@ -154,49 +217,73 @@ describe("model.decryptCollection", () => {
expect(col.isShared).toBe(false); expect(col.isShared).toBe(false);
}); });
it("sets isShared when owner != current user", () => { it("decrypts a shared collection via sealed box when keyDecryptionNonce is absent", () => {
const masterKey = sodium.crypto_secretbox_keygen(); // A collection shared TO us is not encrypted with our master key:
const { raw } = buildRawCollection(masterKey, { ownerID: 99 }); // the sharer only knows our public key, so the collection key
// arrives as crypto_box_seal(collectionKey, ourPublicKey) and the
// response has NO keyDecryptionNonce. decryptCollection must
// recover the key with the keypair, then decrypt the name with it
// as usual. Accounts with any incoming shared album hit this path
// on every listCollections call.
const keys = buildKeys();
const { raw, collectionKey } = buildSharedRawCollection(
keys.publicKey,
{ name: "Friend's Wedding", ownerID: 99 },
);
const col = decryptCollection(raw, masterKey, 1); const col = decryptCollection(raw, keys, 1);
expect(col.key).toEqual(collectionKey);
expect(col.name).toBe("Friend's Wedding");
expect(col.ownerID).toBe(99);
expect(col.isShared).toBe(true); expect(col.isShared).toBe(true);
}); });
it("maps known type strings to CollectionType", () => { it("maps known type strings to CollectionType", () => {
const masterKey = sodium.crypto_secretbox_keygen(); const keys = buildKeys();
for (const type of ["album", "folder", "favorites", "uncategorized"]) { for (const type of ["album", "folder", "favorites", "uncategorized"]) {
const { raw } = buildRawCollection(masterKey, { type }); const { raw } = buildRawCollection(keys.masterKey, { type });
const col = decryptCollection(raw, masterKey, 1); const col = decryptCollection(raw, keys, 1);
expect(col.type).toBe(type); expect(col.type).toBe(type);
} }
}); });
it("maps unrecognised type strings to 'unknown'", () => { it("maps unrecognised type strings to 'unknown'", () => {
const masterKey = sodium.crypto_secretbox_keygen(); const keys = buildKeys();
const { raw } = buildRawCollection(masterKey, { const { raw } = buildRawCollection(keys.masterKey, {
type: "someFutureType", type: "someFutureType",
}); });
const col = decryptCollection(raw, masterKey, 1); const col = decryptCollection(raw, keys, 1);
expect(col.type).toBe("unknown"); expect(col.type).toBe("unknown");
}); });
it("handles a collection with no encrypted name gracefully", () => { it("handles a collection with no encrypted name gracefully", () => {
// Some special collections (e.g. uncategorized) may have no name. // Some special collections (e.g. uncategorized) may have no name.
const masterKey = sodium.crypto_secretbox_keygen(); const keys = buildKeys();
const { raw } = buildRawCollection(masterKey); const { raw } = buildRawCollection(keys.masterKey);
delete raw.encryptedName; delete raw.encryptedName;
delete raw.nameDecryptionNonce; delete raw.nameDecryptionNonce;
const col = decryptCollection(raw, masterKey, 1); const col = decryptCollection(raw, keys, 1);
expect(col.name).toBe(""); expect(col.name).toBe("");
}); });
it("throws when the master key is wrong", () => { it("throws when the master key is wrong for an owned collection", () => {
const masterKey = sodium.crypto_secretbox_keygen(); const keys = buildKeys();
const wrongKey = sodium.crypto_secretbox_keygen(); const { raw } = buildRawCollection(keys.masterKey);
const { raw } = buildRawCollection(masterKey);
expect(() => decryptCollection(raw, wrongKey, 1)).toThrow(); // buildKeys() generates fresh random key material, so this is a
// client holding the wrong master key.
expect(() => decryptCollection(raw, buildKeys(), 1)).toThrow();
});
it("throws when the keypair is wrong for a shared collection", () => {
const keys = buildKeys();
const { raw } = buildSharedRawCollection(keys.publicKey);
// A different keypair must not be able to unseal the key.
const wrongKeys = buildKeys();
expect(() => decryptCollection(raw, wrongKeys, 1)).toThrow();
}); });
}); });

View File

@@ -314,7 +314,7 @@
"@inquirer/core" "^11.2.1" "@inquirer/core" "^11.2.1"
"@inquirer/type" "^4.0.7" "@inquirer/type" "^4.0.7"
"@inquirer/prompts@^8.5.2": "@inquirer/prompts@8.5.2":
version "8.5.2" version "8.5.2"
resolved "https://registry.yarnpkg.com/@inquirer/prompts/-/prompts-8.5.2.tgz#09c0132ada2bbba94c91d341115e1e41cb3f1525" resolved "https://registry.yarnpkg.com/@inquirer/prompts/-/prompts-8.5.2.tgz#09c0132ada2bbba94c91d341115e1e41cb3f1525"
integrity sha512-IYR/3C/paEVVQYQvdDlFZVjRCJVYHHON0XXMH91KO9GSxs0TdKYWlUdvfQl2EfAHDxUaN3IBffkE/BDTh5nJ6g== integrity sha512-IYR/3C/paEVVQYQvdDlFZVjRCJVYHHON0XXMH91KO9GSxs0TdKYWlUdvfQl2EfAHDxUaN3IBffkE/BDTh5nJ6g==