-
Notifications
You must be signed in to change notification settings - Fork 4
docs: extend CLAUDE.md with migrated agent notes #190
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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` | ||
| 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. | ||
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.