Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
205 changes: 205 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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:<ver>-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/<uuid>`: write a chunk (<= 1 MiB), advance `state.uploaded`. The
cryptify token rolls per chunk as `SHA256(prev_token || chunk)`.
- `POST /fileupload/finalize/<uuid>`: 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`
Comment thread
dobby-coder[bot] marked this conversation as resolved.
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 = "<path>"` 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<CryptifyConfig>` and `State<Store>`
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 <test@example.com>",
"smtp_url": "localhost",
"smtp_port": 1025u16,
"allowed_origins": ".*",
"pkg_url": "http://localhost",
}),
));

let rocket = rocket::custom(figment)
.mount("/", routes![upload_init])
.attach(AdHoc::config::<CryptifyConfig>())
.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/<filename>`**: 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/<uuid>`**: 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.