Skip to content

chore: bump bincode-next 3.0.0-rc.13 to 3.0.0-rc.14 - #191

Merged
rubenhensen merged 1 commit into
mainfrom
chore/bincode-next-rc14
May 18, 2026
Merged

chore: bump bincode-next 3.0.0-rc.13 to 3.0.0-rc.14#191
rubenhensen merged 1 commit into
mainfrom
chore/bincode-next-rc14

Conversation

@dobby-coder

@dobby-coder dobby-coder Bot commented May 17, 2026

Copy link
Copy Markdown
Contributor

Patch bump for the bincode-next rc. From the dep-update scan in #190.

Changes

  • pg-core/Cargo.toml: bincode-next 3.0.0-rc.13 → 3.0.0-rc.14
  • pg-pkg/Cargo.toml: same
  • Cargo.lock refreshed via cargo update -p bincode-next

Verification

  • cargo build --workspace — clean (3m03s, host toolchain)
  • cargo test --manifest-path pg-core/Cargo.toml --features test,rust,stream — 51 unit tests + 3 doctests pass, including bincode_compat::tests::legacy_config_matches_v1_default_bytes which pins the v1-default byte layout used for on-disk IBS keys in pg-pkg → wire format is unchanged
  • cargo test --manifest-path pg-{ffi,pkg,cli}/Cargo.toml --all-features — 1 / 30 / 7 tests pass respectively
  • cargo fmt --all -- --check — clean
  • cargo audit — clean (444 crates scanned, 0 vulnerabilities)
  • bincode-next 3.0.0-rc.14 declares rust-version = 1.91.1, same as rc.13 → no Dockerfile/toolchain change needed

Trust caveat (unchanged)

bincode-next remains the third-party fork by Apich-Organization / panayang, not the original bincode-org. Same caveat as PR #182. If maintainer prefers the audit-allowlist or postcard route, this PR can be closed without harm.

Major bumps deferred

Per #190 the rand 0.8 → 0.10, inquire 0.6 → 0.9, and reqwest 0.11 → 0.13 bumps are out of scope here; a tracker comment will go on #190 once this PR is open.

Reviewer quickstart

git fetch origin && git checkout chore/bincode-next-rc14 && cargo build --workspace && cargo test --manifest-path pg-core/Cargo.toml --features test,rust,stream

Closes #190

Patch bump in pg-core and pg-pkg. MSRV stays at 1.91.1 so the Docker
toolchain pin in Dockerfile is unchanged. The pg-core wire-compat
regression test pg_core::bincode_compat::tests::legacy_config_matches_v1_default_bytes
still passes, confirming on-disk IBS key format is byte-identical.
@dobby-coder
dobby-coder Bot requested a review from rubenhensen May 17, 2026 22:24
@dobby-coder
dobby-coder Bot marked this pull request as ready for review May 17, 2026 22:31
@dobby-coder dobby-coder Bot mentioned this pull request May 17, 2026

@dobby-coder dobby-coder Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Self-review (bot cannot self-approve):

Patch bump only (rc.13 → rc.14) across pg-core, pg-pkg, and Cargo.lock. Diff matches PR body exactly, no other surface touched.

  • All CI green across both arches, all workspace test/format jobs, and wasm browsers.
  • Wire-format pin (bincode_compat::tests::legacy_config_matches_v1_default_bytes) passes, so on-disk IBS key compatibility is preserved.
  • Same upstream trust caveat as #182 — third-party fork — unchanged here.

LGTM from this agent's perspective; needs a human reviewer to actually approve.

@rubenhensen
rubenhensen merged commit afb683e into main May 18, 2026
21 checks passed
@rubenhensen
rubenhensen deleted the chore/bincode-next-rc14 branch May 18, 2026 07:26
@github-actions github-actions Bot mentioned this pull request May 18, 2026
rubenhensen added a commit that referenced this pull request Jul 31, 2026
* Add dockerignore to cut build context

* Rename cryptify-backend to cryptify

* Change email url

* Fix CI deployment

* Add better error msg for pkg fetch

* Change error to properly print url

* Add 10s timeout to PKG fetch to prevent silent startup hang

* Add SMTP logging and connection timeout to email sending

* Change url send confirmation

* Change email template to match design

* Add poll watching for claude code

* Split Docker build into native amd64/arm64 jobs, add cargo-chef caching

Replace single QEMU multi-platform job with parallel native runner jobs
that merge into a multi-arch manifest. Add cargo-chef to Dockerfile for
dependency layer caching. Add release/edge Cargo profiles (lto=true for
published releases, lto=false for all other builds).

* Move imdage name from  cryptify-backend to cryptify

* ci: upgrade anchore/scan-action to v7.3.2 and codeql-action to v4

anchore/scan-action@v3 was pinning grype v0.74.4 (Nov 2023). v7.3.2
brings grype v0.104+ with an up-to-date vulnerability database.

github/codeql-action/upload-sarif@v3 was showing a deprecation warning
and will be removed in December 2026; upgrade to v4.

Both actions are now pinned to their commit SHA per security best practice.

* chore(deps): bump keccak from 0.1.5 to 0.1.6 in /cryptify

Bumps [keccak](https://github.com/RustCrypto/sponges) from 0.1.5 to 0.1.6.
- [Commits](https://github.com/RustCrypto/sponges/compare/keccak-v0.1.5...keccak-v0.1.6)

---
updated-dependencies:
- dependency-name: keccak
  dependency-version: 0.1.6
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore(deps): bump bytes from 1.10.1 to 1.11.1 in /cryptify

Bumps [bytes](https://github.com/tokio-rs/bytes) from 1.10.1 to 1.11.1.
- [Release notes](https://github.com/tokio-rs/bytes/releases)
- [Changelog](https://github.com/tokio-rs/bytes/blob/master/CHANGELOG.md)
- [Commits](https://github.com/tokio-rs/bytes/compare/v1.10.1...v1.11.1)

---
updated-dependencies:
- dependency-name: bytes
  dependency-version: 1.11.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore(deps): bump time from 0.3.44 to 0.3.47 in /cryptify

Bumps [time](https://github.com/time-rs/time) from 0.3.44 to 0.3.47.
- [Release notes](https://github.com/time-rs/time/releases)
- [Changelog](https://github.com/time-rs/time/blob/main/CHANGELOG.md)
- [Commits](https://github.com/time-rs/time/compare/v0.3.44...v0.3.47)

---
updated-dependencies:
- dependency-name: time
  dependency-version: 0.3.47
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* fix: replace SVG with PNG in email template (#29)

Many email clients strip out SVGs for security and anti-tracking
reasons, causing the PostGuard logo to not render for a large portion
of recipients. Replace the inline SVG logo with a hosted PNG image
from postguard.eu.

Closes encryption4all/postguard-website#29

* fix: replace checkmark SVG with HTML/unicode equivalent in email (#29)

* chore(deps): bump rustls-webpki from 0.103.8 to 0.103.10 in /cryptify

Bumps [rustls-webpki](https://github.com/rustls/webpki) from 0.103.8 to 0.103.10.
- [Release notes](https://github.com/rustls/webpki/releases)
- [Commits](https://github.com/rustls/webpki/compare/v/0.103.8...v/0.103.10)

---
updated-dependencies:
- dependency-name: rustls-webpki
  dependency-version: 0.103.10
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* Split smtp credentials into username password

* Add release-plz

* Update pipeline action versions

* chore: add repository and license metadata to Cargo.toml

* chore: update Rust edition from 2018 to 2021

* chore: add package description to Cargo.toml

* fix: remove invalid command value from release-plz workflow

The action expects 'release-pr', 'release', or omitted (runs both).
'release-plz' matched neither, so no commands were executed.

* chore: release v0.1.1

* fix: trigger delivery on tag push so semver Docker tags are applied

The semver patterns in docker/metadata-action use event=tag, which
requires a tag push event. The previous release: [published] trigger
had event_name=release, so semver tags were never generated.

* chore: release v0.1.2

* chore: release v0.1.3

* fix: trigger delivery on tag push so semver Docker tags are applied

The semver patterns in docker/metadata-action use event=tag, which
requires a tag push event. The previous release: [published] trigger
had event_name=release, so semver tags were never generated.

* fix: add initial v0.1.0 changelog entry to prevent release-plz from including all history

* chore: release v0.1.4

* refactor: move Rust crate from cryptify/ subdirectory to repo root

Eliminates the redundant cryptify/cryptify/ nesting by moving Cargo.toml,
Cargo.lock, src/, templates/, and CHANGELOG.md to the repository root.

Updates Dockerfile, dev.Dockerfile, docker-compose.yml, docker-compose.dev.yml,
and the release-plz workflow to reference the new paths.

* chore: release v0.1.5

* fix: scope config.toml gitignore pattern to repo root only

The unscoped `config.toml` pattern also matched `conf/config.toml`,
which is a tracked file. This caused release-plz to fail with
"uncommitted changes" because git sees a conflict between a tracked
file and a gitignore rule. Prefixing with `/` restricts the pattern
to the repository root.

* chore: release v0.1.6

* Reset release-plz to defaults

* chore: release v0.1.7

* Add id-token write

* Disable release-plz cargo publishing

* fix: semver version on release

* chore: release v0.1.8

* chore: release v0.1.9

* Add rlsplz dependency

* chore: release v0.1.10

* Try other tag based flow

* chore: release v0.1.11

* Run on release-plz completion

* chore: release v0.1.12

* Run on tag push

* chore: release v0.1.13

* use one workflow file

* chore: release v0.1.14

* try the rlsplz way

* chore: release v0.1.15

* Disable main build on push

* chore: release v0.1.16

* Try 100 version of automated release

* Disable arm/amd build publish

* Move anchore scan

* Switch to docker specified workflow

* Upload to ghcr instead of docker

* chore: release v0.1.17

* Try 24375738 to make the pipeline work

* Use native runners

* chore: release v0.1.18

* add CI pipeline explanation

* Update build pipeline versions

* chore: release v0.1.19

* Use high-res PostGuard logo with text in email template

Replace the low-resolution (39x46px) logo-only image with the full
PostGuard logo including text, backed by a 600x326px PNG for crisp
rendering on retina displays.

* chore: release v0.1.20

* Fix release-plz creating PRs when nothing changed

Add `git_only = true` for the cryptify package to prevent a feedback
loop where release-plz's own merge commits trigger new release PRs.

* Standardize README

Point to docs.postguard.eu for full documentation and include
development quickstart and release instructions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Add PostGuard logo to README

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: cargo update to refresh dependencies and resolve advisories

Resolves the following advisories via transitive bumps in Cargo.lock:
- rustls-webpki < 0.103.12: name constraint parsing issues
  ([RUSTSEC-2026-0098], [RUSTSEC-2026-0099]).
- rand unsound with custom logger ([RUSTSEC-2026-0097]).

Remaining known warnings (not fixable without upstream changes):
- bincode 1.3.3 unmaintained ([RUSTSEC-2025-0141]) via pg-core 0.3.1.
- rustls-pemfile 1.0.4 unmaintained ([RUSTSEC-2025-0134]) via
  irma 0.2.1 -> reqwest 0.11.

Build: clean (release). Tests: 0 tests (unit-test-free crate).

* chore: release v0.1.21

* docs: remove outdated cryptify-frontend/backend references

* feat: enforce server-side upload limits (5 GiB/upload, 15 GiB rolling/email)

Adds server-side enforcement for the upload quotas described in cryptify#100.

- Rejects any chunk that would push the current upload past 5 GiB, returning
  413 with a JSON body identifying the per_upload limit, used_bytes, and
  limit_bytes.
- At finalize time, once the sender email is known from the postguard
  attributes, checks the sender's usage in the last 14 days. If the upload
  would push the sender past 15 GiB, returns 413 with limit="rolling_window"
  and cleans up both the FileState and the on-disk file.
- Records successful finalizations against the sender email so the rolling
  window is actually tracked.
- Exposes GET /usage?email=... so the frontend can show warnings before
  hitting the limit. Response includes used_bytes, limit_bytes, window_days,
  per_upload_limit_bytes, and resets_at (RFC-3339, when the oldest recorded
  upload falls out of the window).

Also documents the new endpoint and 413 responses in api-description.yaml.

Refs #100

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat: tiered upload limits for API key users, 10 MiB chunks, resets_at in 413

- API key users (X-Api-Key header): 100 GiB per upload, 100 GiB rolling
- Normal users: 5 GiB per upload, 5 GiB rolling (was 15 GiB)
- Chunk size bumped to 10 MiB
- 413 responses now include resets_at timestamp
- Rolling limit check logged for debugging
- Dev config: enable usage_db for persistent tracking

* fix: validate cryptifytoken header in upload_finalize

Adds the same CryptifyToken validation to upload_finalize that
upload_chunk already performs: the header is extracted via
FinalizeHeaders and checked against state.cryptify_token before
any file read. Without this, anyone who knew a valid upload UUID
could finalize an upload without possessing the correct token,
bypassing the token chain established during init/upload_chunk.

Refactors the match against state.cryptify_token into a small
check_cryptify_token helper shared by upload_chunk and
upload_finalize so the two call-sites can't drift.

Tests (15 new, 5 existing):
- finalize_headers_* (4): FinalizeHeaders extractor regression
  tests — reject missing CryptifyToken, missing Content-Range,
  malformed range; echo both on success.
- content_range_* (5): ContentRange parser coverage.
- check_cryptify_token_* (4): match/mismatch/empty/case-sensitive.
- compute_hash_* (2): determinism and token-sensitivity.

Closes #45

* fix(upload): raise Rocket data limits to match CHUNK_SIZE

Closes #38.

Rocket's default body-size limit for \`bytes\`, \`data-form\`, and \`file\`
is 1 MiB. When a client sends a chunk larger than 1 MiB (CHUNK_SIZE is
10 MiB as of #104), Rocket rejects the request body with "Data limit
reached while reading the request body" before \`data.open(...)\` in
\`upload_chunk\` has a chance to apply its own per-request cap.

Raises all three framework-level limits to CHUNK_SIZE + 1 MiB headroom
via \`rocket::Config::figment().merge(("limits", ...))\` so existing
Rocket.toml / \`ROCKET_CONFIG\` settings still compose with it.

The per-request \`(end - start).bytes()\` cap in \`upload_chunk\` remains
the authoritative limit — this change only lifts the framework floor
that was clipping legitimate chunks.

* feat(email): add X-PostGuard header to outgoing notifications

Closes #52.

The Outlook add-in's OnMessageRead launch event (Mailbox 1.15) filters
on HeaderName="x-postguard" so it only fires for PostGuard mail.
Without this header the launch event never triggers and the add-in
cannot auto-decrypt on message open.

Adds an X-PostGuard: 0.1.0 header via a small custom lettre Header
impl, set on both the recipient notification and the sender
confirmation email. Version string is a single const so it can be
bumped centrally.

Tests: header name matches the Outlook add-in's filter exactly,
round-trips through parse(), and appears in the formatted SMTP
message body.

* fix(email): avoid 'NaN B' file size for zero-byte uploads

Refs #37.

format_file_size(0) computed log10(0) = -inf, which cast to i32 saturates
to i32::MIN, then 1024.powi(i32::MIN) is 0, so the division produces NaN
and the email showed "NaN B". Extremely large sizes (past TB) would also
index out of bounds into UNITS.

Short-circuit size == 0 to "0 B" and clamp the computed index to the
last unit so u64::MAX stays in-range. Adds 7 unit tests covering 0, sub-
KB, KB/MB/GB/TB round numbers, and u64::MAX.

This does not address the root cause reported in #37 — whatever code
path produces size == 0 when S3 storage is in use — but it turns the
visible NaN into a benign "0 B" so recipients at least see something
sensible if the upstream issue recurs.

* Add end-to-end upload-pipeline integration test harness

Refactor `rocket()` to extract a `build_rocket(figment, vk)` builder so
tests can inject a `VerifyingKey` (via `pg_core::test::TestSetup`)
without needing a live PKG at startup. Add `email_stub` config flag so
the finalize happy path does not require an SMTP server.

New integration tests in `src/main.rs` (mod `integration`) drive the
full init → chunk(s) → finalize flow against a real Rocket client,
using payloads sealed with pg-core's streaming Sealer. Covers the
happy path (single chunk + multi-chunk), plus cross-handler edge cases
(invalid email, wrong cryptify_token on chunk/finalize, unknown uuid,
size mismatch, content-range misalignment).

Refs #108.

* fix: use GB instead of GiB in API description

* fix: align upload limits to round GB values (5 GB / 100 GB)

* feat: make chunk size configurable via TOML, default 5 MB

* chore: release v0.1.22

* docs(api): align upload limit descriptions with current constants

The OpenAPI description listed a stale 15 GB rolling limit and did not
mention the API-key tier. Bring the human-readable response and schema
descriptions in line with the constants in src/store.rs:

- PER_UPLOAD_LIMIT          = 5 GB
- ROLLING_LIMIT             = 5 GB
- API_KEY_PER_UPLOAD_LIMIT  = 100 GB
- API_KEY_ROLLING_LIMIT     = 100 GB

* chore: release v0.1.23

* chore(deps): bump strum and strum_macros to 0.28

Refs encryption4all/cryptify#117

* chore(deps): bump askama to 0.15

askama 0.14 -> 0.15.6 builds clean (no template-syntax changes
needed in templates/email/*.html or subject.txt) and all 30 tests
pass.

Refs encryption4all/dobby#25, encryption4all/cryptify#117

* chore(deps): bump reqwest to 0.13

reqwest 0.12 -> 0.13.3 builds clean, all 30 tests pass. reqwest is
only listed in Cargo.toml (no direct usage in src/), so the bump is
mechanical from this crate's perspective.

Refs encryption4all/dobby#25, encryption4all/cryptify#117

* chore(deps): bump sha2 to 0.11 and rand to 0.10

- sha2 0.10 -> 0.11 changes the digest output type so it no longer
  implements LowerHex; switch compute_hash to use the existing
  bytes_to_hex helper (same lower-hex output, byte-identical).
- rand 0.9 -> 0.10 removes rand::rng().random::<[u8; N]>(); use the
  top-level rand::random::<[u8; N]>() instead. Drops the now-unused
  use rand::Rng import.

cargo build + cargo test (30 passed) green.

Refs encryption4all/dobby#25, encryption4all/cryptify#117

* chore(deps): bump pg-core to 0.5

pg-core 0.3.0-rc.0 -> 0.5.10 builds clean and all 30 tests pass.
The Unsealer + UnsealerStreamConfig + Parameters + VerifyingKey API
surface used in src/main.rs is unchanged in 0.5.

Refs encryption4all/dobby#25, encryption4all/cryptify#117

* Bump openssl from 0.10.76 to 0.10.78

Bumps [openssl](https://github.com/rust-openssl/rust-openssl) from 0.10.76 to 0.10.78.
- [Release notes](https://github.com/rust-openssl/rust-openssl/releases)
- [Commits](https://github.com/rust-openssl/rust-openssl/compare/openssl-v0.10.76...openssl-v0.10.78)

---
updated-dependencies:
- dependency-name: openssl
  dependency-version: 0.10.78
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* Bump rustls-webpki from 0.103.12 to 0.103.13

Bumps [rustls-webpki](https://github.com/rustls/webpki) from 0.103.12 to 0.103.13.
- [Release notes](https://github.com/rustls/webpki/releases)
- [Commits](https://github.com/rustls/webpki/compare/v/0.103.12...v/0.103.13)

---
updated-dependencies:
- dependency-name: rustls-webpki
  dependency-version: 0.103.13
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore: release v0.1.24

* chore: fix clippy useless_format warning in email.rs

`format!("{}", &sender)` allocated a String just to borrow it as
&str into url::form_urlencoded::Serializer::append_pair, which
takes &str. Pass &sender directly.

Step 2 of the CI bring-up sequence in #126: clippy must pass
cleanly before a clippy job can be added in CI.

Refs: #126

* fix: parse recipient before creating file in upload_init

Closes #125

Reorders `upload_init` so the request's recipient is parsed first; only
on success do we generate a UUID and create the on-disk file. Previously
a malformed recipient returned 400 but left an empty UUID-named file
behind in `data_dir` that the in-memory `purge_task` could never reap
(no `FileState` was inserted on the rejection path).

Adds two regression tests against a minimal Rocket harness:
- bad recipient: 400 returned and `data_dir` stays empty.
- good recipient: 200 returned and exactly one file appears.

* fix(store): extend upload session deadline on each chunk PUT

The in-memory `Store::create` schedules a hard 15-minute eviction
regardless of activity, so any upload whose end-to-end duration
exceeds 15 minutes (slow client, large file on a thin link) ends
with a 404 on the next chunk PUT — even on a single, never-restarted
process.

This patch:
- bumps the constant to a 60-minute idle window and renames it to
  `UPLOAD_SESSION_IDLE_TIMEOUT_SECS` so its meaning is unambiguous;
- adds `Store::touch(id)`, called after each successful chunk PUT,
  which resets the deadline to "now + idle timeout";
- maintains a reverse `expiration_keys` index so `touch` and `remove`
  can locate the existing `(deadline, removal_id)` entry without
  scanning the BTreeMap.

`remove` now also clears the matching `expirations` entry, so an
explicit removal does not leave an orphan deadline pointing at a
missing file id.

This is one of the two issues called out in cryptify#116. The
broader persistence gap (process restart wipes all sessions) is
deliberately left for a follow-up — that one needs disk persistence
and is a much larger change.

Refs: encryption4all/cryptify#116

* feat(upload): add notifyRecipients toggle on /fileupload/init

The recipient notification email was sent unconditionally on every
upload — there was no way for a client to upload silently. That's a
problem when the encrypted payload is delivered to recipients through
another channel (e.g. an email add-in delivering the message from the
sender's own mailbox) and the Cryptify mail would be a duplicate.

Adds an optional `notifyRecipients` field to the InitBody. Defaults to
`true` to preserve the long-standing behaviour for clients that don't
know about it. When `false`, the per-recipient SMTP delivery loop in
`send_email` is skipped; the recipient list is still parsed and stored
(so the existing validation behaviour is unchanged) and the sender
confirmation, gated separately by `confirm`, is sent regardless.

* chore: release v0.1.25

* ci: add Rust quality job (fmt, clippy, test)

Adds a `quality` job that runs `cargo fmt --all -- --check`,
`cargo clippy --all-targets -- -D warnings`, and
`cargo test --all-targets` on every pull request and on push to main.

Runs in parallel to the existing release-plz/Docker pipeline and does
not block it.

Refs #126.

* fix: validate API key against pg-pkg, not a local allowlist (closes #123)

The original `ApiKeyPresent` request guard only checked that an `X-Api-Key`
header was present, admitting any non-empty value to the higher-quota tier
(100 GB per upload, 100 GB rolling vs. the default 5 GB). Anyone could
defeat the storage/bandwidth abuse controls with `X-Api-Key: anything`.

Supersedes the closed PR #130, which validated against a hashed allowlist
in cryptify's config — that approach drifted from the source of truth in
postguard-business (`business_api_keys`) on every key rotation/revocation.

Now: cryptify reads `Authorization: Bearer PG-…`, calls pg-pkg's new
`GET /v2/api-key/validate`, and uses the returned tenant id (`organizations.id`)
both for limit selection and as the rolling-window accounting key
(`api-key:<tenant>`). The bespoke `X-Api-Key` header is gone — clients now
use the same `Authorization: Bearer PG-…` shape pg-pkg already accepts.

Failure modes per the rule "small uploads degrade silently, large uploads
get an explicit error":

- pg-pkg returns 401/403  → caller is degraded to default tier (their
  expired/fake key just doesn't earn the higher tier).
- pg-pkg unreachable      → retry up to 30s with exponential backoff. If
  still down, mark `api_key_validation_failed=true` on `FileState`. Chunks
  within the default 5 GB cap continue (with a warning logged); chunks
  exceeding it return 503 (cannot apply the higher tier without pg-pkg).
- pg-pkg validates the key → tenant id stored on `FileState`,
  100 GB tier applied, rolling window accounted per tenant.

Tests: 7 new unit tests pin the bearer-extract rules (PG-prefix required,
scheme `Bearer`/`bearer`, rejects missing/empty/wrong-scheme/non-PG
tokens). Full suite: 37 pass.

Depends on the pg-pkg `/v2/api-key/validate` endpoint being deployed first.

* build(deps): bump openssl from 0.10.78 to 0.10.79

Bumps [openssl](https://github.com/rust-openssl/rust-openssl) from 0.10.78 to 0.10.79.
- [Release notes](https://github.com/rust-openssl/rust-openssl/releases)
- [Commits](https://github.com/rust-openssl/rust-openssl/compare/openssl-v0.10.78...openssl-v0.10.79)

---
updated-dependencies:
- dependency-name: openssl
  dependency-version: 0.10.79
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore: update dependencies

- bump askama 0.15 → 0.16 (direct, no API changes needed)
- bump tokio 1.52.1 → 1.52.2 (transitive patch via cargo update)

Closes #142

* feat(upload): structured 404 body, configurable session TTL

Layered on top of the per-chunk-touch fix to make client-side retry
viable. Three changes:

1. New `Error::UploadSessionNotFound` variant returning 404 with a
   stable JSON body `{error, uuid, reason}`. Replaces the bare 404 that
   previously came from `Ok(None)` in `upload_chunk` and
   `upload_finalize`. The `reason` field distinguishes
   `expired_or_unknown`, `invalid_uuid`, and `file_missing` so clients
   can decide whether a retry is even meaningful (it isn't — start a
   new upload).

2. The session idle TTL is now driven by `CryptifyConfig::session_ttl_secs`
   (default 3600). The hardcoded constant only survives in #[cfg(test)].
   Production wires it through `Store::with_idle_ttl(...)`. This avoids
   another constant-change PR when the Postgres-backed sessions land.

3. `api-description.yaml` documents the new 404 response shape and the
   `UploadSessionNotFound` schema for both `PUT /fileupload/{uuid}` and
   `POST /fileupload/finalize/{uuid}`.

Refs encryption4all/postguard-website#117 (the upload-robustness issue
this work was scoped against).

* feat(upload): idempotent retry of the last committed chunk

When a chunk PUT response is lost in flight the client retains the old
CryptifyToken (the one it sent) but the server has already advanced the
rolling-token chain. Without server-side help, retry is impossible: the
naïve resend hits 'token mismatch' or 'wrong offset' depending on what
the client tries.

Server now caches the just-committed chunk's
(prev_token, prev_uploaded, chunk_len, chunk_sha256, response_token) on
the FileState. The chunk handler is restructured around a
`classify_chunk_request` helper with three outcomes:

- NormalNext: state.uploaded == start && header_token == current_token
  → write, advance, refresh last_chunk
- ReplayLastChunk: every cached field matches AND body sha256 matches
  → return cached response_token, no write, no double-count
- Reject: regular 400s. Stricter 400s when the request looks like a
  retry but the body or length differ — never accept different bytes
  for the same offset.

The body is now read once at the top of the handler so the same bytes
feed both the replay-detection hash and the normal write path. Per-
upload limit and file-open are still checked only on the normal-write
path; on retry the original PUT already passed those.

api-description.yaml documents the contract on PUT /fileupload/{uuid}.

Tests: seven new unit tests cover NormalNext, ReplayLastChunk match,
length mismatch, body-hash mismatch, offset mismatch with no replay
record, token mismatch at correct offset, and prev_token mismatch with
a replay record present (falls through to regular Content-Range 400).

Refs encryption4all/cryptify#136 (resume protocol design from
@dobby-coder's audit), encryption4all/postguard-website#117.

* refactor(upload): apply review feedback from #145

Four small follow-ups from @dobby-coder's review:

1. Cheap pre-check before reading the chunk body. Mirrors the structural
   part of `classify_chunk_request` (normal-next OR replay-candidate)
   and rejects mismatches before consuming up to `chunk_size` bytes,
   restoring the prior security profile against UUIDs leaked to an
   attacker.

2. Drop `chunk_sha256` and `chunk_len` from `LastChunkRecord`. Body
   identity falls out of the rolling-token chain itself: recompute
   `compute_hash(prev_token, body)` and compare to `response_token`.
   Length divergence surfaces as a hash mismatch — one less invariant
   to keep in sync, smaller `FileState`, simpler Phase 2 schema. The
   length-specific test folds into the body-mismatch test.

3. Promote the duplicated "Cryptify Token header does not match"
   string to a `TOKEN_MISMATCH_MSG` const referenced by both
   `check_cryptify_token` (finalize path) and the chunk classifier so
   the wording can't drift silently between call sites.

4. One fewer `shasum.clone()` — the final responder takes the moved
   value while `state.cryptify_token` and `last_chunk.response_token`
   share a single clone.

`sha256_of` is removed (sole caller is gone). Tests gain a
`last_chunk_for(prev_token, offset, body)` helper that builds a
`LastChunkRecord` whose `response_token` is the actual rolling hash,
matching the production handler's invariant.

Refs #145 review.

* chore: release v0.1.26

* feat(upload): GET /fileupload/{uuid}/status for cross-refresh resume

Adds the server side of the cross-refresh-resume flow proposed in #146.
A client that loses track of an in-flight upload (page refresh, tab
crash, navigate-away-and-back) can rehydrate by calling this endpoint
with the recovery token issued at `upload_init`, then feed the next
chunk PUT through the idempotent-retry path from #145.

Wire-level shape (incorporates the design feedback from @dobby-coder's
review on #146):

- New `recovery_token` field in the `upload_init` response — hex-encoded
  32-byte random, stored on the client (typically IndexedDB) alongside
  the UUID.
- `GET /fileupload/{uuid}/status` reads
  `(uploaded, cryptify_token, prev_token, prev_offset)`. `prev_*` are
  omitted until at least one chunk has been committed; in that case the
  client just resumes from offset 0 with `cryptify_token`.
- Authentication via a dedicated `X-Recovery-Token` header — *not*
  `Authorization: Bearer`, which is already used for the API-key tier
  flow. Keeps the two credential channels disjoint.
- Constant-time comparison of the recovery token (`subtle` crate).
- 401 only on missing/empty header; **a wrong token returns 404 with
  the same `upload_session_not_found` body** as an evicted/unknown
  session, deliberately, so attackers cannot probe for live UUIDs by
  varying the token.
- Successful `/status` calls `Store::touch(uuid)` to reset the idle
  eviction deadline so the rehydrate window doesn't age out between
  the GET and the next chunk PUT.
- CORS `allowed_headers` now explicitly enumerates `Authorization`,
  `Content-Type`, `Content-Range`, `CryptifyToken`, and
  `X-Recovery-Token` so browser preflights pass for the new route.

In-memory backing:
This ships against the existing in-memory `FileState`. That covers the
common cases (browser refresh, tab crash, navigate-away-and-back) but
**does not** survive a server restart — same limitation as everything
else today. The full robustness story comes when Phase 2 (#116/#134)
moves session state into Postgres; that work needs to persist
`recovery_token` alongside the rest of the row, otherwise this
mechanism silently breaks across restarts.

Tests:
- `status_returns_initial_state_after_init` — 200 with `uploaded=0`,
  `cryptify_token` present, `prev_*` absent.
- `status_returns_401_when_recovery_header_missing` — bare GET → 401.
- `status_returns_401_when_recovery_header_blank` — whitespace-only
  header → 401.
- `status_returns_404_for_token_mismatch_same_as_unknown_uuid` —
  pins the info-hiding behaviour: real UUID + wrong token and
  unknown UUID + any token must return identical `error` strings.
- `recovery_tokens_match_constant_time_helper` — value-equality
  semantics, so a future refactor can't silently turn the constant-time
  compare into `presented == expected`.

54 tests pass; cargo fmt + clippy --all-targets -- -D warnings clean.

Closes #146.
Refs #117, #136, #145, #116, #134.

* test(upload): cover /status preflight + deadline-extension AC

Closes the two test-coverage gaps @dobby-coder flagged in the review
on #148 — both were in the design AC for #146, both were correct by
inspection but not pinned by a test. Now they are.

1. **CORS preflight smoke test.** New
   `status_preflight_advertises_x_recovery_token` test sends an
   `OPTIONS /fileupload/{uuid}/status` preflight and asserts the
   response advertises `X-Recovery-Token` among
   `Access-Control-Allow-Headers`. Pins the allow-list against silent
   regression. Helper `status_client_with_cors` mirrors the production
   cors fairing on a per-test rocket.

2. **Deadline-extension assertion.** New
   `status_extends_eviction_deadline` test compares the session's
   deadline before and after a successful `/status` call via a new
   `#[cfg(test)] Store::deadline_for(id)` accessor. Negative
   complement `status_does_not_extend_deadline_on_token_mismatch`
   pins that a failed-auth `/status` does NOT extend the deadline —
   otherwise an attacker with a known UUID could keep a session alive
   indefinitely with bogus tokens.

The timing side-channel observation @dobby-coder raised
(unknown-UUID returns 404 immediately vs. wrong-token returns 404
after taking the mutex + ct_eq) is deliberately accepted — the
UUIDv4 search space makes that channel useless to a real attacker,
and adding a dummy `ct_eq` on the unknown-UUID path costs more than
it earns. dobby's review explicitly leaned toward the same "accept"
position.

57 tests pass; cargo fmt + clippy --all-targets -- -D warnings clean.

Refs #146, #148 review.

* chore: bump tokio 1.52.2 → 1.52.3

* feat: staging_mode config that logs instead of sending email

When staging_mode = true, finalize logs a [STAGING] line containing
recipients, sender, sender_attributes, expiry, download URL, and uuid
instead of contacting SMTP. Returns Ok so the upload flow completes.

Refs encryption4all/postguard#174.

* style: apply cargo fmt

* fix: add HTTP Range support to /filedownload

Rocket's FileServer returns 200 + full body for every request, so
browsers disable the resume button on interrupted multi-GB downloads.
Replace the FileServer mount with a custom range-aware handler that
advertises Accept-Ranges, parses single-range Range headers (bytes=N-M,
bytes=N-, bytes=-N), and returns 206 + Content-Range for the requested
slice. Invalid ranges return 416 with bytes */N.

Closes #153

* chore: bump pg-core 0.5 → 0.6

Bumps pg-core requirement from 0.5.0 to 0.6.0 (resolves 0.5.10 → 0.6.0
in the lockfile) in both [dependencies] and [dev-dependencies].

Upstream 0.6.0 (encryption4all/postguard pg-core-v0.6.0) bundles a
ciphertext bounds-check security fix and migrates from bincode 1.3.3
to bincode-next 3.0.0-rc.13, which clears RUSTSEC-2025-0141 in the
transitive graph.

No cryptify source changes required; the public API surface used by
this crate (Parameters, VerifyingKey, Unsealer, UnsealerStreamConfig,
Sealer, SealerStreamConfig, TestSetup) is unchanged.

Closes #155

* fix: address dead UUID check and copy-paste error in upload_chunk

- Move uuid::Uuid::parse_str above the store.get lookup so the
  'invalid_uuid' reason code is actually reachable when a malformed
  UUID is supplied in the URL. Previously the store.get HashMap lookup
  always returned None first, so the invalid_uuid branch was dead.
- Fix copy-paste error: the missing-end error message said
  'Could not read Content-Range start'; corrected to 'end'.

Closes #157

* test: add regression test for invalid_uuid reason on upload_chunk

The previous PR moved the UUID parse check above the store.get lookup
so the 'invalid_uuid' reason code is actually emitted. Add a regression
test that asserts the response body for a malformed UUID contains
reason="invalid_uuid" (pre-fix it would have been
"expired_or_unknown").

* chore: release v0.1.27

* chore: bump lettre 0.11.21 → 0.11.22 (RUSTSEC-2026-0141)

Addresses critical advisory (CVSS 9.1): TLS hostname verification
was disabled when using the Boring TLS backend. cryptify uses the
default backend; bump is precautionary and aligns with the advisory
fix range.

Closes #159

* ci: add semantic PR title check workflow (#162)

* chore: bump pg-core 0.6.0 → 0.6.1 (#164)

Co-authored-by: dobby-yivi-agent[bot] <275734547+dobby-yivi-agent[bot]@users.noreply.github.com>

* feat: expose Prometheus /metrics endpoint for usage dashboards (#102)

* feat: expose Prometheus /metrics endpoint for usage dashboards

Adds a Prometheus text-format `GET /metrics` endpoint covering the metrics
requested in encryption4all/cryptify#101:

  * cryptify_uploads_total{channel}
  * cryptify_upload_bytes_total{channel}
  * cryptify_storage_bytes (gauge, sampled periodically from data_dir)
  * cryptify_active_files (gauge, same source)
  * cryptify_expired_files_total (counter, purged-before-finalized)

The channel label is derived from request headers:
  1. X-Cryptify-Source explicit header
  2. Authorization: Bearer / X-Api-Key -> "api"
  3. Origin -> "staging-website" / "website"
  4. User-Agent substring -> "outlook" / "thunderbird"
  5. fallback "unknown"

Values are sanitized (lower-case [a-z0-9_-], max 32 chars) to prevent
label-injection and cardinality blowup.

Storage gauges are sampled by a background task that walks data_dir every
`metrics_scan_interval_secs` (default 60, configurable). Dashboard JSON
ready for import into the Scaleway Grafana instance is shipped under
`docs/grafana/`, alongside a Prometheus scrape-config example.

No authentication on /metrics; restrict via firewall / proxy allow-list
(documented in README and docs/grafana/README.md).

Refs #101

* chore: cargo fmt + drop non-standard Monitoring README section

Rule compliance pass:
- cargo fmt --all (rust-run-cargo-fmt-before-push)
- README "Monitoring" section removed; docs already live in
  docs/grafana/README.md (standardized-readmes)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: correct indentation of production target in Grafana scrape config

The production target's labels block was indented under targets instead
of being a sibling, which would fail YAML parsing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* chore: drop docs/grafana from metrics PR

Remove the Grafana dashboard JSON and scrape-config README per
maintainer feedback — keep only the /metrics endpoint exposure.

---------

Co-authored-by: dobby-yivi-agent[bot] <275734547+dobby-yivi-agent[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* feat(metrics): pre-seed known channels at zero on startup (#165)

* feat(metrics): pre-seed known channels at zero on startup

Without this, Prometheus only creates a series for cryptify_uploads_total
{channel="X"} when the first upload from channel X lands. PromQL
increase(...[1h]) over that series returns nothing because the earliest
sample is already non-zero — dashboards under-report low-volume channels
for an hour after each pod restart.

Pre-seeds website, staging-website, outlook, thunderbird, api, unknown
to 0 in Metrics::new(), so the full label set is visible from the first
scrape and increase() can compute deltas honestly.

The previous render-time "if empty, emit unknown=0" fallback is now
effectively dead (the map is never empty after new()), but left in place
defensively against any future code path that constructs Metrics via
Default rather than new().

* refactor(metrics): route Default through new() so they can't diverge

Address dobby's non-blocking observation on #165: with #[derive(Default)]
still in place, Metrics::default() produced an empty-channels object
while Metrics::new() pre-seeded — a footgun for a future contributor
who reaches for ::default() in a generic context or test helper.

Drops the derive, refactors new() to build the maps directly (no
internal lock dance), and adds a manual `impl Default for Metrics`
that just calls Self::new(). The two construction paths now produce
identical objects.

All 13 metrics tests still pass.

* fix(email): address postguard#197 deliverability + show disclosed signer name (#170)

* fix(email): address postguard#197 deliverability + show disclosed signer name

- X-PostGuard header now tracks the pg-core version via a new build.rs
  that parses Cargo.lock, instead of the stale hardcoded "0.1.0". When
  pg-core releases 1.0, the header auto-bumps on the next build.
- Add Reply-To header pointing to the disclosed sender on recipient mail
  so replies reach the human sender, not the noreply From-address.
- Add Auto-Submitted: auto-generated (RFC 3834) on both notification and
  confirmation mail to suppress responder loops and signal "transactional"
  to receiving MTAs.
- Send multipart/alternative with a hand-authored plain-text branch in
  addition to HTML (new templates/email/email.txt).
- Embed the PostGuard logo as a multipart/related CID inline part instead
  of fetching https://postguard.eu/pg_logo.png at render time. Kills the
  HTML-only + remote-image spam fingerprint and keeps the logo rendering
  even with images-blocked-by-default.
- Show the sender's disclosed full name (pbdf.gemeente.personalData.fullname)
  wherever the bare email used to appear in the body; filter it from the
  attribute pill list so it doesn't render twice.

Deliberately not added: List-Unsubscribe — wrong semantic fit for
person-to-person mail, would misrepresent the sender.

Refs encryption4all/postguard#197

* build: copy build.rs in Dockerfile and apply rustfmt

CI fallout from the previous commit:

- The Docker build couldn't run build.rs because it wasn't COPY'd into
  the planner / builder stages, so env!("PG_CORE_VERSION") had nothing
  to resolve and the release build failed. Add `COPY build.rs ./build.rs`
  to both stages.
- rustfmt wanted `build_body(...)`'s signature on a single line. Apply.

* fix(email): address dobby review on disclosed-name flow

- Treat empty disclosed fullname as not disclosed; fall through to the
  email instead of rendering a blank where the sender appears.
- Match the fullname attribute by `.gemeente.personalData.fullname`
  suffix so the `irma-demo` scheme used in staging also routes through
  this path. (Previously only `pbdf.gemeente.personalData.fullname`
  matched.)
- Log a warning when `state.sender` is `Some(...)` but doesn't parse as
  a `Mailbox` — the Reply-To deliverability fix shouldn't silently
  drop.
- Move the `sender_display` docstring from above `build_body` to above
  `sender_display`.
- Extend the `pg-core not found in Cargo.lock` panic in build.rs to
  mention that PG_CORE_VERSION feeds the X-PostGuard header consumed
  by the Outlook add-in's OnMessageRead filter.
- New tests: demo-scheme name detection, empty-name fallback, and the
  `Someone` fallback (sender=None, no name).

* feat(email): render firstName+lastName from passport/idcard/drivinglicence

Extend `sender_display` so the recipient mail can show a real sender
name when the signer's Yivi disclosure came from an ID credential
instead of the Dutch municipality credential.

`FULLNAME_ATYPE_SUFFIX` still wins when present. If absent, the new
`take_firstname_lastname_pair` helper scans the disclosed attributes
for a `<cred>.firstName` + `<cred>.lastName` pair from one of
`pbdf.pbdf.{passport,idcard,drivinglicence}` (and matching
`irma-demo.*` variants), and concatenates them. Both attributes are
removed from the pill list once consumed, so the recipient doesn't see
them rendered twice. Empty values fall through to the bare email, as
they already did for the gemeente fullname path.

Companion to the postguard-website condiscon change: senders can now
satisfy the mandatory name disclosure from any of four credentials,
not just gemeente. Without this rendering update, non-Dutch senders
would still appear as their bare email in the recipient mail.

Tests: 7 new `sender_display_*` cases (per-credential, demo-scheme,
preference-order, firstname-only fallback, empty-value fallback). All
24 email tests pass, full `cargo test`: 113/113.

* fix(email): use 'PostGuard' as sender display when no name is disclosed

When the signer discloses no name, the recipient email now reads
'PostGuard sent you files' instead of showing the raw sender email.
Also drop 'via PostGuard' from the subject — it would be redundant when
the sender is already 'PostGuard', and is cleaner for named senders too.

Renames the two tests that previously asserted the email fallback.

* style: cargo fmt

* feat(email): staging-only /staging/preview/<uuid> + shared render API (#171)

* feat(email): expose render fns + staging-only /staging/preview/<uuid>

Factor `send_email` so the per-recipient and confirmation email bodies
are produced by `render_recipient_email` / `render_confirmation_email`
(public, pure, no SMTP). `send_email` consumes those and adds the
SMTP/MIME plumbing — keeping the email layout in one place.

Add `GET /staging/preview/<uuid>`, gated on `config.staging_mode()` and
returning 404 otherwise. Response is JSON `{ recipients: [...],
confirmation: ... }` with each entry carrying `recipient`, `subject`,
`from`, `reply_to`, `html`, `text`. The staging website calls this so
developers can read what cryptify *would* have sent without scraping
log lines.

* review: restore ByteRange doc, observable confirm-skip, route tests

- Move the "Parsed byte range…" doc back above `struct ByteRange`; it
  had been displaced onto `StagingPreviewResponse` by the previous edit.
- `send_email`'s confirmation arm now logs at error level when
  `state.confirm` is set but `render_confirmation_email` returns `None`,
  instead of silently dropping the sender's copy if the invariant ever
  loosens.
- Three Rocket local-client tests for `/staging/preview/<uuid>`: 404
  when `staging_mode = false`, 404 on unknown uuid, and a happy-path
  case asserting the JSON shape (recipients + confirmation).

* feat(email): replace unicode checkmark with inline PNG image (#172)

The signer-verified circle previously rendered a unicode `&#10003;`
glyph which is styled inconsistently across mail clients. Embed a
PNG of the PostGuard checkmark via `cid:pg-check` to match the
shape used in the download page UI.

* feat(email): remove circle around signer-verified checkmark (#173)

* feat(email): replace unicode checkmark with inline PNG image

The signer-verified circle previously rendered a unicode `&#10003;`
glyph which is styled inconsistently across mail clients. Embed a
PNG of the PostGuard checkmark via `cid:pg-check` to match the
shape used in the download page UI.

* feat(email): remove circle around signer-verified checkmark

* feat(metrics): capture X-POSTGUARD-CLIENT-VERSION client identity (#174)

Parse the X-POSTGUARD-CLIENT-VERSION header (host,host_version,app,app_version,
reused from pg-pkg) on uploads. Log the full raw value at init and finalize so
exact client versions are greppable, and expose a cardinality-safe
cryptify_uploads_by_app_total{app} counter (app field only; full version stays
in logs). Add X-POSTGUARD-CLIENT-VERSION to the CORS allowlist so browser
pg-js can send it.

* chore: replace pkg_url with https://pkg.postguard.eu/ (#178)

Replace the production PKG URL https://postguard-main.cs.ru.nl/pkg with
https://pkg.postguard.eu/ in conf/config.toml and the commented example
in conf/config.dev.toml.

Since the new URL carries a trailing slash, harden the startup parameter
fetch in rocket() to trim a trailing slash before appending
/v2/sign/parameters (mirroring the existing trim in
PkgClient::validate_api_key), avoiding a // in the request path.

Co-authored-by: dobby-yivi-agent[bot] <275734547+dobby-yivi-agent[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* chore(deps): cargo update lockfile (#168)

Refreshes transitive deps to latest compatible versions including
aws-lc-rs, bincode-next, rustls, tower-http, wasm-bindgen and others.

Closes #167

Co-authored-by: dobby-yivi-agent[bot] <275734547+dobby-yivi-agent[bot]@users.noreply.github.com>

* fix(cors): allow Office add-in origins (addin.postguard.eu + localhost:3000) and DELETE method (#179)

* fix(cors): allow Office add-in origins + DELETE method

Browser callers from the Office add-in were blocked by CORS when reaching
/fileupload/* and /filedownload/*: the preflight returned no
Access-Control-Allow-Origin because the production allowed_origins regex
only matched the postguard.eu/nl website.

- conf/config.toml: extend allowed_origins to also match
  https://addin.postguard.eu (Outlook prod) and https://localhost:3000
  (Office add-in dev), keeping the existing postguard.(eu|nl) origins.
- build_rocket CORS: add DELETE to allowed_methods so the preflight
  advertises GET, POST, PUT, DELETE. Content-Type and Authorization are
  already in the allowed-headers list.
- Add integration tests over the real build_rocket CORS fairing asserting
  the preflight succeeds (echoes Allow-Origin, advertises all four methods
  and the required headers) for both add-in origins, and is rejected for an
  unlisted origin.

Refs encryption4all/postguard#154

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(test): correct misleading PROD_ALLOWED_ORIGINS comment

The comment claimed a config typo would fail the suite, but the CORS
tests use this hand-maintained copy and never read conf/config.toml.
Reword to state it is a copy that must be kept in sync.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: dobby-yivi-agent[bot] <275734547+dobby-yivi-agent[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* chore: update dependencies (#181)

Resolve dependency CVEs and refresh outdated crates.

Security fixes:
- lettre 0.11.19 -> 0.11.22 (RUSTSEC-2026-0141: inverted-boolean bug
  disabling TLS hostname verification on the boring-tls backend)
- rand 0.10.0 -> 0.10.1 (RUSTSEC-2026-0097: aliased mutable reference /
  UB when a custom log logger calls rand::rng() during reseeding)

minor/patch bumps:
- tokio 1.48.0 -> 1.52.3
- uuid 1.18.1 -> 1.23.4
- reqwest 0.13.0 -> 0.13.4
- serde_json 1.0.145 -> 1.0.150
- chrono 0.4.42 -> 0.4.45
- log 0.4.28 -> 0.4.33
- url 2.5.7 -> 2.5.8
- tokio-util 0.7.17 -> 0.7.18

major bump:
- minreq 2.14.1 -> 3.0.0: renamed the https-native feature to
  https-native-tls (3.0 feature rename); call site in rocket() uses only
  get/with_timeout/send/json which are unchanged.

cargo audit reports no vulnerabilities. cargo fmt/clippy/test all pass
(127 tests).

Co-authored-by: dobby-yivi-agent[bot] <275734547+dobby-yivi-agent[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* fix: require validated API key on GET /usage (GHSA-5rhx-xgvv-h78h) (#183)

The `/usage` route used the `ApiKey` guard, whose `FromRequest` always
succeeds (yielding the anonymous default tier when no key is present).
The handler's default-tier branch then looked up usage by the
caller-supplied `email` query parameter with no ownership check, so any
unauthenticated caller could probe usage for an arbitrary address.

Introduce a `ValidatedApiKey` request guard that fails the request
(401 on NoCredentials/Rejected, 503 on PkgUnreachable) so the
"authenticated" intent is enforced by the type system. Switch `/usage`
to that guard and key the lookup on the validated tenant; the `email`
query param is now optional and only echoed back.

Also make the `cryptify_token` comparisons constant-time via
`subtle::ConstantTimeEq` (new `cryptify_tokens_match` helper), mirroring
`recovery_tokens_match`, in `check_cryptify_token`, `upload_chunk`, and
`classify_chunk_request`.

Adds a regression test asserting unauthenticated `/usage` returns 401,
and updates the OpenAPI description.

Co-authored-by: dobby-yivi-agent[bot] <275734547+dobby-yivi-agent[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* feat: add GET /email-template endpoint keyed on API key (#175)

Add an endpoint that returns the email template pg-pkg has linked to the
caller's API key (issue #54, requirement 10 of postguard#81). The key is
validated through the existing `ApiKey` request guard, so the same
`Authorization: Bearer PG-…` flow the upload endpoints use applies here.

- Extend the pg-pkg `/v2/api-key/validate` response and `ApiKey` guard to
  carry an optional `email_template`.
- `GET /email-template` returns 200 + template for a valid key, 401 for a
  missing/invalid key, 404 when the key is valid but has no template, and
  503 when pg-pkg is unreachable during validation.
- Branch mapping extracted into a pure `resolve_email_template` helper for
  unit testing; full route also covered end-to-end through the real guard
  against a mock pg-pkg.
- Document the endpoint and a bearer security scheme in api-description.yaml.

Closes #54

Co-authored-by: dobby-yivi-agent[bot] <275734547+dobby-yivi-agent[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* fix: return generic body from 5xx responses, log detail server-side (#185)

The InternalServerError(Some(..)) and ServiceUnavailable(Some(..)) call
sites in src/main.rs returned internal diagnostic strings ("could not open
file", "couldn't read postguard file", "pg-pkg was unreachable while
validating the API key", etc.) directly to HTTP clients, leaking
implementation detail (GHSA-r95f-qf3j-xccw, low severity).

Each site now returns the generic constant GENERIC_INTERNAL_ERROR_MSG
("an internal error occurred") and writes the detailed diagnostic to a
server-side log::error!() call, so operators keep observability without
exposing internals. Where the error closures previously discarded the
underlying error (|_|), they now capture and log it, improving
diagnosability over the prior behaviour.

Adds integration test finalize_internal_error_body_is_generic asserting a
500 body is the generic message and contains none of the old leaky detail.

Co-authored-by: dobby-yivi-agent[bot] <275734547+dobby-yivi-agent[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* feat: persist rolling-quota usage to SQLite (usage_db) (#176)

The config schema already accepts `usage_db = "<path>"`, but
`CryptifyConfig` never parsed it and the rolling-quota state lived only
in the in-memory `Store.shared.state.usage` map, so any pod restart or
redeploy wiped everyone's rolling quota.

- Parse `usage_db: Option<String>` in `RawCryptifyConfig` / `CryptifyConfig`
  with a `usage_db()` accessor.
- Back the usage map with SQLite (`rusqlite`, bundled) at that path. The
  DB is the source of truth; the in-memory map is a cache. On startup
  existing usage is loaded into the cache; every accounted upload is
  written through and stale rows outside the rolling window are pruned.
- `usage_db` unset → in-memory only, preserving prior behaviour. A
  configured-but-unopenable DB panics at startup (loud config error).
- Tests: persistence across a simulated restart, continued accumulation
  after restart, rolling-window eviction persisted to the DB, lazy
  in-memory eviction after reload, and config parsing.

Closes #134

Co-authored-by: dobby-yivi-agent[bot] <275734547+dobby-yivi-agent[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* fix(email): larger, selectable download-link code block in recipient email (#187)

* fix(email): make recipient download link a prominent selectable code block

The notification email's download-link block ({{link_str}}) rendered at
13px — smaller than the 16px primary button above it — and used a low
weight/contrast style that was hard to read and select. July 2026 user
test (postguard-website#296) flagged it as small and hard to copy.

Restyle the {{link_str}} URL as a full-width monospace code block:
- font-size 16px (no smaller than the primary button)
- monospace family, higher contrast (dark #030E17 text)
- more padding, subtle background + border so it reads as a code block
- display:block + word-break for easy full selection

No inline copy buttons (unreliable across email clients). Plain-text
template already renders the label + URL on their own lines and stays
consistent. Adds a render assertion guarding the >=16px monospace style.

Closes #186

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(email): pin download-link assertion to restyled <a>

The prior font-size:16px substring check was satisfied by the primary
download button regardless, so it failed to guard the link block. Assert
a contiguous substring unique to the restyled <a> (display:block +
monospace + font-size:16px) so a font-size regression genuinely fails.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: dobby-yivi-agent[bot] <275734547+dobby-yivi-agent[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* feat(metrics): require a bearer token on /metrics when configured (#177)

Add an optional `metrics_token` config (also settable via ROCKET_METRICS_TOKEN).
When set, /metrics requires `Authorization: Bearer <token>` (constant-time
compared via subtle); when unset it stays open and logs a startup warning, so
rolling this out doesn't break existing scrapers. The auth lives in the app,
not the ingress, so it travels with cryptify to external hosts that can't be
firewalled. /health stays public.

Co-authored-by: dobby-yivi-agent[bot] <275734547+dobby-yivi-agent[bot]@users.noreply.github.com>

* fix(cors): allow X-Cryptify-Source in browser preflights (#189)

The website tags its uploads with X-Cryptify-Source for per-channel
metrics (postguard-website#228), but the header was never added to the
CORS allow-list. Browsers include it in the preflight's
Access-Control-Request-Headers, rocket_cors rejects the preflight with
a 403 carrying no Access-Control-Allow-Origin, and uploads from the
website fail before they start. Staging hits this directly; production
only works because its nginx answers preflights itself.

Add the header to the allow-list, and extract build_cors() so the
preflight smoke tests exercise the production CORS config instead of a
test-local copy — the duplicated config is why the existing smoke test
couldn't catch this regression.

* docs: extend CLAUDE.md with migrated agent notes (#190)

* docs: add agent & contributor notes (migrated from dobby memory)

* docs: correct purge timer notes in CLAUDE.md

The Purge timer block described a hard 15-minute eviction from creation
at src/store.rs:80. It is actually a resettable 1-hour idle timeout:
Store::create (src/store.rs:292) seeds the deadline from idle_ttl
(DEFAULT_UPLOAD_SESSION_IDLE_TIMEOUT_SECS = 60 * 60) and Store::touch
re-inserts it on each chunk PUT / status check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: dobby-yivi-agent[bot] <275734547+dobby-yivi-agent[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* fix: retry the startup verifying-key fetch instead of panicking (#192)

* fix: retry the startup verifying-key fetch instead of panicking

Cryptify fetched the IBS verifying key from pg-pkg once at startup and
panicked if the PKG was unreachable or the response unparsable, creating a
hard restart-order dependency (cryptify -> pg-pkg -> irma server): a brief PKG
unavailability window during a rolling deploy took cryptify down with it.

The fetch now retries transient failures with exponential backoff (500ms..10s)
for a 120s budget, mirroring the existing /api-key/validate retry pattern, and
runs the blocking minreq call via spawn_blocking. If the budget is exhausted
the process still exits with a clear error so a misconfigured pkg_url does not
fail silently.

Fixes encryption4all/postguard#235

* fix: log non-2xx PKG responses distinctly from parse failures in the startup retry

* feat: configurable email attribute type for the finalize sender check (#193)

Part 2 of encryption4all/postguard#236 (the cryptify leg). Finalize required
exactly pbdf.sidn-pbdf.email.email in the sender's signing identity; test
environments cannot issue pbdf credentials, blocking real end-to-end flows.
The `email_attribute` config key (default: the production value, so deployed
configs are unaffected) now names the attribute type carrying the sender's
email. Companion changes: pg-pkg (encryption4all/postguard#244) and pg-js.

* chore: remove unused qrcode, strum and strum_macros deps (#195)

These three dependencies were declared in Cargo.toml but never used
anywhere in the crate. Removing them prunes qrcode's image subtree
(image, moxcms, pxfm, byteorder-lite) and strum's heck from Cargo.lock.

Closes #191

Co-authored-by: dobby-yivi-agent[bot] <275734547+dobby-yivi-agent[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* chore: remove unused irma dependency (#199)

* chore: remove unused irma dependency

The `irma` crate was declared in Cargo.toml but never imported anywhere in
the crate. Attributes reach cryptify already signed inside the
PostGuard-sealed file and are read back through pg-core's Unsealer, so
there is no IRMA client here to migrate.

`irma` stays in Cargo.lock as a transitive dependency of pg-core.

Closes #194

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: say irma is still compiled in via pg-core, not just in the lockfile

`cargo tree -i irma` shows irma 0.2.1 on the normal dependency edge under
pg-core, so it is built into the binary, not lockfile residue. It also brings
reqwest 0.11.27 alongside cryptify's own reqwest 0.13.4. The old wording could
lead the postguard-e2e knownDrift follow-up to record the pair as resolved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: dobby-yivi-agent[bot] <275734547+dobby-yivi-agent[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* docs: true up api-description.yaml with the mounted routes (#200)

* docs: true up api-description.yaml with the mounted routes

The spec had drifted from the service in ways a client reading it would hit:

- `/staging/preview/{uuid}` was mounted but undocumented.
- `PUT /fileupload/{uuid}` and `POST /fileupload/finalize/{uuid}` documented a
  409 for a `cryptifytoken` mismatch; both return 400, and no route in the
  service returns 409.
- Finalize validates a `cryptifytoken` header, which the spec did not list, so
  a client following the spec got a 400 from the extractor.
- 400 bodies are plain-text messages, not `{"message": …}` JSON.
- `/metrics` requires `Authorization: Bearer <metrics_token>` when the
  deployment configures one; the spec said firewall-only and listed no 401.
- `/filedownload/{uuid}` answers `Range` requests with 206 or 416 and never
  returns the documented 400, and it sets no `Content-Type` at all.
- Missing responses: 400/422/500 on init, 500/503 on chunk PUT, 500 on
  finalize, 503 on `/usage`.
- `PayloadTooLarge` gained `resets_at` on the rolling-window 413.

`api_routes()` is now the single mount list, and `mod api_description_tests`
compares it against the spec so a route can no longer be added, removed, or
renamed without the spec following.

Refs encryption4all/postguard#247

* docs: correct four spec statements and document the exclusive Content-Range end

Follow-up on the review of this PR. All five were verified against the
handlers, not inferred from the spec:

- `PUT /fileupload/{uuid}`: the `Content-Range` end byte is exclusive
  (`upload_chunk` uses `end - start`), which no version of the spec has ever
  said. An external client following RFC 7233 sends one byte too many and gets
  a 400. Documented on the parameter, since the spec is the contract pg-js and
  pg-dotnet are written against.
- `GET /filedownload/{uuid}`: `Accept-Ranges: bytes` is set on the response
  builder after both 404 early returns, so it is not "always" advertised.
  Reworded, and added the header to the 416, which does carry it.
- `GET /filedownload/{uuid}`: added the 500 from a failed `file.seek` on the
  range path.
- `POST /fileupload/init`: the 400 also covers a syntactically invalid JSON
  body, which Rocket answers with its default error page rather than the
  plain-text message. Same caveat the chunk and finalize 400s already carry.
- `StagingPreview.confirmation`: null whenever `state.sender` is unset, i.e.
  when previewing before finalize — the normal staging flow, and the case the
  old wording excluded.

cargo fmt/clippy/test all pass (154 tests); the spec still validates as
OpenAPI 3.0.3 with all $refs resolving.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: dobby-yivi-agent[bot] <275734547+dobby-yivi-agent[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* ci: add the oasdiff breaking-change gate (#201)

(cherry picked from commit b2a0cda35e4646797ab2e3e3bc952685236647fb)

* ci: tune the oasdiff gate's severity settings and pin them with a test (#203)

* ci: pin the oasdiff gate's settings with a mutation test

`fail-on: ERR` with no `include-checks`, the setting the gate landed with in
#201, fails open on ten of the changes the spec's contract forbids. Measured
against oasdiff v1.26.1 by mutating the real spec one way per rule: a `401`
that becomes a `403` and a dropped response enum value rate ERR but are
opt-in, and a removed or renamed optional response property, a removed request
parameter, a removed request property and the constraint-narrowing `*-set`
family all rate WARN. Cryptify's routes are unversioned, so each of those
reaches every pinned client the moment it deploys.

Adds `mod api_gate_tests` in `src/main.rs`, which runs the engine with the
flags the action's entrypoint builds and asserts stop-or-pass per mutation. It
fails on the shipped settings and names every gap; it passes on `fail-on: WARN`
plus the two opt-in checks. The engine is not on any runner, so it skips in CI
while `every_api_gate_mutation_still_applies` still runs there.

The workflow itself is in a PR comment: the App has no `workflows: write`.

Refs #202. Part of encryption4all/postguard#247 (workstream C).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ci: assert the workflow runs the settings the gate test pins

Review on #203: `FAIL_ON` and `INCLUDE_CHECKS` described settings nothing
tied to the committed workflow, which still says `fail-on: ERR` with no
`include-checks`, and `cargo test` was green anyway. So the branch shipped a
passing test certifying a configuration the gate does not use, which is the
fail-open the module exists to catch.

`the_workflow_uses_the_settings_this_module_pins` reads
`.github/workflows/api-diff.yml` and asserts its two step inputs are the two
constants. It needs no `oasdiff`, so it runs on every runner. It is red until
a maintainer applies the workflow patch from the PR comment, which makes the
merge order enforced rather than documented.

Also from the review: CLAUDE.md now says the settings are pending rather than
present-tense fact and names where the patch lives; the tier counts are the
`-f json` ones (WARN adds 30 on top of ERR's 212, not 31 on 213 — the table
output has a header row and a trailing blank); the versioned-route mutation
goes through `once` like every other anchor, so a renamed `getUsage` fails
loudly instead of no-op'ing; and a root `.gitattributes` marks the spec `-text`
so the multi-line anchors survive a Windows checkout.

Refs #202.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test: assert the api gate still runs, not only what it runs with

`the_workflow_uses_the_settings_this_module_pins` pinned the gate's two
inputs but not whether the job is ever triggered, which is the one
fail-open it could not see: settings that read correctly on a gate that
never fires. The workflow's own header comment names the foreseeable edit
("There is deliberately no `on: paths:` filte…
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

chore: update dependencies

1 participant