diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..44cde76 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,205 @@ + +--- + +## Agent notes (migrated from the dobby memory repo) + +## Overview +`encryption4all/cryptify` is a Rocket/Rust file-upload service: a sender uploads a +file, cryptify PostGuard-seals it for a signed recipient, and emails a notification. +Backend only. `pdf-signature` is a fork sharing the same README, with a divergent +frontend and a slightly different config shape (see that repo's own notes). + +## Config +Backend config lives in `conf/config.toml` (prod) and `conf/config.dev.toml` (dev). +Keys: `server_url`, `address`, `data_dir`, `email_from`, `smtp_*`, `allowed_origins`, +`pkg_url`. The backend reads `ROCKET_CONFIG=config.toml` baked into the Dockerfile, +`[global]` profile in `conf/config.toml`. Compose bind-mounts `./conf/config.toml` +to `/app/config.toml:ro`; mutating the bind-mounted file requires a container +restart to take effect. + +## Release process +Release-plz automation. + +## Build / test +- `cargo check`, `cargo build --release`, `cargo test`, `cargo clippy --all-targets` + all work from repo root. +- No library target: tests live in `src/**` under `#[cfg(test)] mod tests`. +- CI (`.github/workflows/ci.yml`, `quality` job) runs `cargo fmt --all -- --check`, + `cargo clippy --all-targets -- -D warnings`, and `cargo test --all-targets` on + every PR. Run all three locally before pushing; a fmt/clippy failure blocks the PR. +- **Docker build Rust version can lag behind CI's stable toolchain.** The CI + `Rust quality` job uses `dtolnay/rust-toolchain@stable` (always latest), but the + Docker `Build (amd64/arm64)` jobs use the pinned `FROM rust:-slim-trixie` in + the `Dockerfile`. These can diverge enough that a dependency's build script needs + a newer Rust than the pinned Docker image ships (happened when `rusqlite`'s + `bundled` feature started needing `cfg_select`, stabilized in Rust 1.94, while + Docker was pinned to 1.93). When adding a dep, check whether it needs a newer Rust + than the Dockerfile's pin and bump the Dockerfile if so. `rust:*-slim-trixie` + already ships gcc, so `bundled` C compilation works without extra apt installs. +- **Tests that touch `Store` need a tokio runtime.** `Store::new()` spawns a purge + task via `rocket::tokio::spawn`; under plain `#[test]` it panics with "no reactor + running". Use `#[rocket::async_test]` and `async fn`, even when the body never + awaits. + +## Running the binary +- Needs a reachable PKG server (`pkg_url`) at startup or it panics on + `/v2/sign/parameters`. For config tests, prefer a serde-roundtrip unit test over + booting the server. +- SMTP, `data_dir`, `pkg_url` are all required. + +## Request pipeline (build_rocket layering) +- Two seams in `src/main.rs`: `default_figment()` returns the bare + `rocket::Config::figment()`; `build_rocket(figment, vk)` extracts + `CryptifyConfig`, computes body-size limits from `config.chunk_size()`, merges + them, then constructs `rocket::custom(...)`. +- **Do NOT extract config inside `default_figment()`.** Integration tests layer + config on top with `default_figment().merge(...)`. Extracting too early panics + with `MissingField`. +- Body-size headroom is `chunk_size + 1 MiB` on `bytes`, `data-form`, `file`. + Per-request reads are still capped by `data.open((end - start).bytes())` in + `upload_chunk`. + +## Upload flow and state lifetime +- `POST /fileupload/init`: in-memory `FileState` keyed by UUID. Sender unknown at + this point. +- `PUT /fileupload/`: write a chunk (<= 1 MiB), advance `state.uploaded`. The + cryptify token rolls per chunk as `SHA256(prev_token || chunk)`. +- `POST /fileupload/finalize/`: run the postguard Unsealer over the whole + file to extract attributes; `sender` (`pbdf.sidn-pbdf.email.email`) becomes + known. +- **Purge timer:** `state.expirations` (a `BTreeMap` populated in `Store::create` + at `src/store.rs:292` with `Instant::now() + self.shared.idle_ttl`) is what + `purge_task` walks. `idle_ttl` defaults to `DEFAULT_UPLOAD_SESSION_IDLE_TIMEOUT_SECS` + (`60 * 60`, 1 hour); this is a resettable idle timeout, not a hard deadline from + creation. `Store::touch` (`src/store.rs:318`) removes the old `expirations` key and + re-inserts `Instant::now() + idle_ttl` on each chunk PUT and status check, so the + hour counts from the last activity (see the `touch_extends_eviction_deadline` test + at `src/store.rs:545`). `FileState.expires` (current_time + 14d) is NOT what drives + eviction; it's a different field, never read by the purge loop. When tracing + eviction, follow `state.expirations`. +- Purge does not delete the on-disk file. Rejecting at finalize must manually + `tokio::fs::remove_file` and `store.remove(uuid)`. +- In-memory only, no persistence. Process restart wipes all upload sessions and + orphans on-disk files in `data_dir/` (tracked as cryptify#116). +- Per-sender usage tracking is a `HashMap` in `StoreState.usage`, optionally backed + by SQLite when config `usage_db = ""` is set: `UsageDb` (rusqlite, + `bundled`) is the source of truth, the map is a cache loaded on startup and + written through on each `record_upload` (which also prunes rows outside the 14d + rolling window). `usage_db` unset means in-memory only (old behaviour). A + configured-but-unopenable DB panics at startup. + +## Token chain must be checked on every route touching a FileState +The upload token chain (`SHA256(prev || chunk)`) must be validated on every route +that operates on an existing `FileState`, not just `PUT`. An earlier version only +checked it on `PUT`, letting anyone who guessed a live UUID finalize another user's +upload (fixed). When adding new routes, mirror the token check `upload_chunk` uses; +don't trust UUID knowledge alone as authorization. + +## CORS +`allowed_origins` is a single regex string in `rocket_cors` 0.6.0. +`AllowedOrigins::some_regex` compiles via `regex::RegexSet`; standard alternation +works fine. The regex is anchored (`^...$`), so there's no subdomain/wildcard +bypass. + +## Metrics +- `GET /metrics`: Prometheus text format, unauthenticated by design. Lock down at + the firewall, not the endpoint. +- Channel label derived in priority: `X-Cryptify-Source`, then + `Authorization: Bearer` / `X-Api-Key` (-> `api`), then `Origin` (-> `website` / + `staging-website`), then `User-Agent` (-> `outlook` / `thunderbird`), then + `unknown`. Sanitized to `[a-z0-9_-]`, max 32 chars. +- Storage gauges are sampled from `data_dir` on a background task (default 60s, + `metrics_scan_interval_secs`). +- `FileState.source_channel` is populated at `upload_init` from request headers; + populate it in any new test fixtures too. + +## Integration test harness +- `build_rocket(figment, vk)` is the injection point. `#[launch] rocket()` wraps it + and fetches vk via `minreq` for production. +- `CryptifyConfig.email_stub: bool` (default false) short-circuits `send_email`; + set true in test figments. +- `pg_core::test::TestSetup` provides `VerifyingKey` plus an encryption policy and + signing keys. The test policy includes `pbdf.sidn-pbdf.email.email = + "bob@example.com"`; seal with `signing_keys[2]` (Bob) for finalize to succeed. +- pg-core's Sealer API uses rand 0.8; cryptify uses rand 0.9. Dev-deps alias + `rand08 = { package = "rand", version = "0.8" }`; use `rand08::thread_rng()` only + in test code calling pg-core directly. +- Integration tests live inline in `src/main.rs` under `mod integration`, not in + `tests/` (that would require a library target). +- Each test gets a per-test temp `data_dir` under `std::env::temp_dir()` with a + uuid suffix for parallel safety. + +For handler-level tests that need `State` and `State` +without the full `build_rocket` injection point: + +```rust +use rocket::figment::{providers::Serialized, Figment}; +use rocket::local::asynchronous::Client; + +let figment = Figment::from(rocket::Config::default()).merge(Serialized::defaults( + serde_json::json!({ + "server_url": "http://localhost", + "data_dir": data_dir.to_str().unwrap(), + "email_from": "Test ", + "smtp_url": "localhost", + "smtp_port": 1025u16, + "allowed_origins": ".*", + "pkg_url": "http://localhost", + }), +)); + +let rocket = rocket::custom(figment) + .mount("/", routes![upload_init]) + .attach(AdHoc::config::()) + .manage(Store::new()); +let client = Client::tracked(rocket).await.unwrap(); +``` + +Gotchas: `#[rocket::async_test]` is required (Store spawns purge_task, needs a +reactor). `InitBody` is camelCase; send `mailContent` and `mailLang` (not +snake_case) or you get a 422. `email::Language` serializes uppercase (`"EN"`, +`"NL"`). This minimal harness only works for routes that don't need the verifying +key (`upload_init`, `health`, `usage`); routes needing vk need the full +`build_rocket(figment, vk)`. + +## X-PostGuard header convention +Cryptify's notification emails set an `X-PostGuard` header using the `pg-core` +crate version as the value (e.g. `X-PostGuard: 0.6.1`), wired at build time via +`build.rs` reading `Cargo.lock`. This gives operational visibility into which +postguard version processed a given email, and it advances automatically as +`pg-core` is bumped, no manual updates needed. This supersedes an earlier +preference for a semantic token like `notification` (cryptify#170). + +For reference, the tb-addon (Thunderbird) implementation sets +`x-postguard: 0.1.0` via `customHeaders` on `onBeforeSend`; detection of "is this a +PostGuard message" elsewhere uses the `postguard.encrypted` attachment or the +inline `-----BEGIN POSTGUARD MESSAGE-----` marker, not this header. +`X-PostGuard-Client-Version` is a separate, unrelated HTTP header sent on PKG +requests (not a MIME header on the email). + +## Security: reviewed and confirmed clean, don't re-report +From the 2026-07-02 in-depth security audit (the one confirmed finding from that +audit, unauthenticated `/usage` enumeration, was fixed and merged in PR #183): +- **Path traversal on `GET /filedownload/`**: guarded by + `is_safe_download_segment` (rejects empty, len>128, `/`, `\`, NUL, `.`, `..`). + On-disk files are named by random UUIDv4, so the download key is an unguessable + capability. +- **HTML injection / XSS in notification emails**: `mail_content` is + attacker-controlled (init is unauthenticated) but rendered via Askama as + `{{html_content}}` without `|safe`, so it's auto-HTML-escaped. The `.txt` + template uses `escape="none"` correctly for plaintext. +- **Email header injection**: `recipient` is parsed via lettre `Mailboxes`; + `reply_to` comes from the signed IRMA email attribute. lettre validates both. +- **Upload/finalize auth**: chunk PUT and finalize are gated by the rolling + `cryptify_token` (`SHA256(prev||chunk)`); finalize checks it too. The status + endpoint is gated by a constant-time `X-Recovery-Token` comparison, with + 401-vs-404 collapsed to avoid leaking session existence. +- **Secrets in git history**: none; `conf/config.toml` and `config.dev.toml` only + ever had commented-out placeholders. +- **`/metrics` unauthenticated**: known and by design, locked down at the + firewall. +- **`/staging/preview/`**: 404s unless `staging_mode` is on; safe in prod. + +## Test-runner quirk + +`Store` tests need a tokio runtime: use `#[rocket::async_test]` on `async fn`, even when the body never awaits.