feat(cryptify): mint an upload challenge and verify it at finalize - #373
feat(cryptify): mint an upload challenge and verify it at finalize#373dobby-coder[bot] wants to merge 2 commits into
Conversation
upload_init required nothing of the uploader and upload_finalize read a sender identity out of the container and treated it as theirs, so anyone holding a container someone else sealed could have PostGuard mail strangers in that person's name. cryptify holds no decryption key, so no check inside the container can help: the uploader has to be bound to the identity the container claims. upload_init now mints 32 random bytes, stores them on the session and returns them hex-encoded as `challenge`. upload_finalize reads an optional base64 `X-PostGuard-Proof` header and reduces it to a `SenderClaim` through `sender_claim`, the only place that type is constructed. Verification goes through pg-core's `verify_challenge` (#368) against the identity the container's own signing policy derives to, which is what removes any identity-comparison step. The signed bytes are the decoded challenge and the context is the uuid, matching postguard-js#239. An absent, malformed or failing proof all resolve to `Unproven` and none of them refuses the upload: the rollout policy is the sibling ticket, and rejecting here would break every client without the header. FileState gains both fields, so `upload_sessions` gains two columns. `CREATE TABLE IF NOT EXISTS` cannot add a column to a table a deployment already has, so `StateDb::migrate_sessions` ALTER TABLEs them on, driven off `pragma_table_info` rather than a schema version. It is a no-op on a fresh database and on a second boot. Part of #338. Closes #364.
There was a problem hiding this comment.
VERDICT: request-changes
Rules Dobby 2, cycle 1
One blocking issue, three non-blocking nits. I reproduced every finding on this HEAD before posting, and verified each suggested fix by applying it rather than by reading it.
What I verified
The blocking one reproduces. A scratch integration test driving two finalizes over real HTTP: the first, carrying a valid proof, returns 200 and stores Some(Proven { email: "bob@example.com", .. }). A second with the header omitted also returns 200 and leaves Some(Unproven). Details in the inline comment on main.rs:1216.
The suggested guard works and passes the gate. With it applied, the second finalize keeps its Proven claim, and the upgrade direction still works: an unproven finalize followed by one carrying a valid proof becomes Proven. On the fix alone, cargo fmt --manifest-path cryptify/Cargo.toml --all -- --check exits 0, cargo clippy -p cryptify --all-targets -- -D warnings is clean, and cargo test -p cryptify --all-targets is 206 passed. No existing test relied on the overwrite. The suggestion block below is what rustfmt emits.
The base64 alphabet nit reproduces too. Over 40 fresh signatures, the standard and base64url spellings differed in all 40 cases, decode_proof accepted all 40 standard ones and none of the 40 url ones. SIG_BYTES is 96, so the encoding is 128 characters with no padding, and unpadded standard base64 is accepted as well. Padding is not at issue; the alphabet is.
Both spec suggestions parse. I applied them to a scratch copy of api-description.yaml and loaded it: valid YAML, and both descriptions read correctly in context.
What the acceptance claims check out as
cargo test -p cryptify --all-targets is 206 passed on this HEAD, matching the PR body. Every CI check on the branch is green, including Format workspace (cryptify), Clippy workspace (cryptify), Test workspace (cryptify) and both API breaking changes jobs.
Rule sweep
Rules checked against this diff: the SQLite column-add and migration-test rules, the pre-existing cryptify Content-Range and synchronous-notification-mail conventions, the AEAD recipient-set and additive-security-fix rules, the one-way-security-switch rule, the Rust fmt/clippy gate, and the prose rules. All clean but one banned word, noted inline on CLAUDE.md:137.
Worth recording that the migration test is the shape the column-add rule asks for: a_sessions_table_without_the_proof_columns_gains_them builds the pre-change table explicitly, seeds a row with data in it, boots, and then checks all three things separately, including that a session written after the migration reaches the new columns. That is the case a fresh-schema test never sees.
The AEAD rule does not bite here. This change adds no copy of any value inside an AEAD plaintext and calls no multi-recipient encapsulation; it verifies an IBS signature over a server-minted random challenge, which is the sender-key binding that rule points at as the real remedy.
Not flipping out of draft, since this is a request for changes.
Nothing marks an upload session finalized, so `upload_finalize` stays
reachable for as long as the session lives. The claim was assigned
unconditionally, so a second finalize with no `X-PostGuard-Proof` header
recomputed `Unproven` and `persist_session` wrote that over a stored
`Proven`. Both paths that hit it are ordinary: a client retrying after a
lost response, and a client resuming after a refresh, which no longer
holds the challenge and so cannot rebuild the header at all.
Guard the assignment instead: `Unproven` to `Proven` still upgrades,
`Proven` never degrades. Only the verification produces a `Proven`, so
this does not widen what can prove a sender.
Also from review:
- pin the standard base64 alphabet on `X-PostGuard-Proof` in
`api-description.yaml`. `decode_proof` uses `base64ct::Base64`, so
base64url is rejected, and the client half is a separate ticket in
another repo. A new end-to-end test respells a valid signature in
base64url and asserts it proves nothing; a decoder that accepted both
alphabets turns it red.
- tell the client to persist the `challenge`, the way its
`max_chunk_size_bytes` sibling already does. `GET /fileupload/{uuid}/status`
does not repeat it, so a resumed upload otherwise finalizes unproven.
- drop a banned word from `cryptify/CLAUDE.md`, and record the
repeatable-finalize invariant there.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
VERDICT: request-changes
Rules + review sign-off — cycle 2
Re-swept at 3fa1253 (cycle 1 was 2a8a3bf). The cycle-1 blocking finding is fixed and load-bearing: the claim assignment at main.rs:1220 is now guarded, a_retried_finalize_does_not_erase_a_proof drives both directions over real HTTP, and the upgrade path (Unproven -> Proven) still works. The schema migration, the canonicalized claim, and the spec additions all check out.
Verified on HEAD myself: cargo test -p cryptify --all-targets -> 208 passed, 0 failed; cargo fmt --all -- --check clean; cargo clippy -p cryptify --all-targets -- -D warnings clean.
One blocking item remains, and it is the one the suite cannot show you by passing once.
Blocking
-
a_base64url_proof_leaves_the_sender_unprovenis flaky (main.rs:4375). Measured, not reasoned: 4 failures in 400 runs of this single test. ~1% is often enough to red CI on pushes that have nothing to do with this code. Fix + measurement in the inline comment; I verified the replacement at 0/400.Note for whoever applies it: the suggestion span in the previous review round was
4375-4378, which would have lefturl, standard, ... ); Some(url)dangling after the replacement. The block that has to go is 4375-4382, which is what the inline suggestion here covers.
Non-blocking
- Pre-existing, out of scope: a retried finalize also re-sends the recipient notification and double-charges the rolling limit (
main.rs:1229,main.rs:1248). Real bug, not this PR's, and worth its own ticket rather than a change here — details inline atmain.rs:1220. - Two small wording/precision nits (
api-description.yaml:381,main.rs:2736), both inline.
Rule sweep
Checked 17 rules selected for a Rust + SQLite + OpenAPI diff across four batches (security/correctness, tests, schema-migration/deps, spec-and-docs). Everything came back compliant except the items above. Specifically clean: a-recomputed-claim-downgrades-on-retry (the cycle-1 finding, now guarded on the assignment with the upgrade direction intact), automigrate-column-add-needs-a-nullable-field (both columns nullable, and a_sessions_table_without_the_proof_columns_gains_them builds the pre-migration schema explicitly, seeds a row with data, and asserts survival plus writability — which is exactly what that rule asks for), a-negative-test-behind-two-validators-pins-neither (the base64url test now uses a real signature respelled, not filler bytes), and rust-run-cargo-fmt-before-push.
Two Haiku candidates dropped as misapplied: the "AAAA" case in a_malformed_proof_header_leaves_the_sender_unproven asserts the end-to-end HTTP contract across three deliberately different junk shapes rather than one validator stage, and there is no AEAD binding change in this diff.
| let standard = proof_header(&setup.signing_keys[2], uuid, challenge); | ||
| let bytes = Base64::decode_vec(&standard).expect("the header is standard base64"); | ||
| let url = Base64Url::encode_string(&bytes); | ||
| assert_ne!( | ||
| url, standard, | ||
| "the two alphabets must disagree for this to test anything" | ||
| ); | ||
| Some(url) |
There was a problem hiding this comment.
Flaky test — reds CI on unrelated pushes. Confirmed independently: I ran this one test 400 times against the built test binary and got 4 failures (~1%), on the assert_ne! here:
assertion `left != right` failed: the two alphabets must disagree for this to test anything
left: "PBKrustAX2odSlUvW1rWxGAeNQS7zJtUAs00MGNLPF3dyYzudn2f5VUzxUVrP6UlbBrTYVGWmn9PtmROZlOhCLbm5HXiqh37LMtLsDtynXgQ8qx6hPSNV5IXLDulow0F"
A signature is 96 bytes, so its standard base64 is 128 characters; when none of them happens to be + or / the base64url spelling is byte-identical and the assertion fires. Signing is randomised, so redrawing until the two spellings differ removes the flake without weakening what the test pins.
I applied the suggestion below and measured it: 0 failures in 400 runs, cargo build --tests -p cryptify clean, cargo fmt --all -- --check clean.
| let standard = proof_header(&setup.signing_keys[2], uuid, challenge); | |
| let bytes = Base64::decode_vec(&standard).expect("the header is standard base64"); | |
| let url = Base64Url::encode_string(&bytes); | |
| assert_ne!( | |
| url, standard, | |
| "the two alphabets must disagree for this to test anything" | |
| ); | |
| Some(url) | |
| // Roughly one signature in fifty encodes with neither `+` nor `/`, | |
| // and then both alphabets spell it identically and there is nothing | |
| // left to test. Signing is randomised, so draw again until the two | |
| // spellings actually differ. | |
| let url = loop { | |
| let standard = proof_header(&setup.signing_keys[2], uuid, challenge); | |
| let bytes = Base64::decode_vec(&standard).expect("the header is standard base64"); | |
| let url = Base64Url::encode_string(&bytes); | |
| if url != standard { | |
| break url; | |
| } | |
| }; | |
| Some(url) |
| // may repeat the request without the header, and that must not erase what | ||
| // was proved. Only the verification above produces a `Proven`, so this | ||
| // keeps the claim written once in the direction that matters. | ||
| if !matches!(state.sender_claim, Some(SenderClaim::Proven { .. })) { |
There was a problem hiding this comment.
Pre-existing bug, not introduced here — out of scope, worth its own ticket (not a nit, and not a reason to hold this PR).
This PR promotes finalize's repeatability to a documented invariant (cryptify/CLAUDE.md: "Finalize is repeatable, so the claim only moves one way") and the guard on this line is correct. But two other things ride on that same repeatability and are not guarded:
send_email(main.rs:1229) runs on every finalize, so a client retrying after a lost response sends the recipient a second notification.store.record_upload(main.rs:1248) pushes a freshUploadRecordunconditionally — no per-uuid key (store.rs:1037) — andget_usagesums every record in the window (store.rs:1048), so a retried finalize charges the sender's 14-day rolling limit twice for one upload.
I verified both against HEAD. a_retried_finalize_does_not_erase_a_proof now drives exactly this path: both finalizes return 200 and run to the end of the handler. Neither function is touched by this diff, so neither is this PR's to fix — but the CLAUDE.md paragraph reads as though repeat finalizes are settled, and these two are the part that is not.
| "The answer to the `challenge` served at `/fileupload/init`: a | ||
| signature over the decoded challenge bytes, made with the uploader's | ||
| PostGuard signing key under the `uuid` as the context, encoded as | ||
| standard base64 (RFC 4648 section 4, `+` and `/`) — the base64url |
There was a problem hiding this comment.
Nit (wording). "the base64url alphabet is rejected" reads as an HTTP rejection, which is the one thing this endpoint never does — and the same description says so four lines later ("neither refuses the upload"). What actually happens is that the value fails to decode and the sender comes out Unproven, with a 200. Worth saying that directly, since this line is the client contract:
| standard base64 (RFC 4648 section 4, `+` and `/`) — the base64url | |
| standard base64 (RFC 4648 section 4, `+` and `/`); a value spelled in | |
| the base64url alphabet does not decode, and so proves nothing. |
| assert!(decode_proof("not base64 ~~").is_none()); | ||
| // Well-formed base64 of the wrong length, both ways: a truncated | ||
| // signature and a whole one with bytes hung off the end. | ||
| let short = Base64::encode_string(&[0u8; pg_core::ibs::gg::SIG_BYTES - 1]); |
There was a problem hiding this comment.
Nit, low value — take it or leave it. The doc comment on decode_proof says "a value decoding to anything longer or shorter is refused rather than truncated", but neither case here exercises the explicit bytes.len() != SIG_BYTES check:
longis caught earlier, byBase64::decoderefusing to write past theSIG_BYTESbuffer.shortis caught later, bybincode_compat::deserialize.
I confirmed it by mutation: deleting the if bytes.len() != pg_core::ibs::gg::SIG_BYTES { return None; } block leaves this test green. So the check is defence in depth rather than something the suite pins. The trailing-bytes property the comment cares about is genuinely covered (by the buffer size), so this is a comment-precision point, not a hole.
Closes #364. Part of #338.
What this does
upload_initmints 32 random bytes, stores them on the session and returns them hex-encoded aschallengebesidemax_chunk_size_bytes.upload_finalizereads an optional base64X-PostGuard-Proofheader and reduces it to aSenderClaim.Before this,
upload_initasked nothing of the uploader andupload_finalizeread a sender identity out of the container and treated it as theirs. cryptify holds no decryption key and never opens an upload, so nothing inside the container can say who uploaded it. The proof is what binds the uploader to the identity their container claims.Against the shipped pg-core API
Built on
pg_core::challenge::verify_challengefrom #368, not the sketch in the ticket body:&str;signChallenge(pubSignKey, uuid, challengeBytes)does in postguard-js#239;falseis theUnprovenarm;The signature bytes on the wire are
bincode_compat::serializeof the signature, exactlySIG_BYTESlong, which is whatpg-wasm'ssignChallengereturns.decode_proofinsists on that length, so extra bytes cannot ride along on a valid proof (the same checkjs_verify_challengemakes).Two decisions worth your eye
sender_claim: Option<SenderClaim>, not a defaultedSenderClaim. The ticket asks that the claim be written exactly once and that no code path other than the verification produce aProven.Nonemeans finalize has not run, exactly assenderalready works;Some(Unproven)means it ran and settled the question. Init writes noSenderClaimat all.Provencarries canonical values.derive_ibscanonicalizes, so a container spelling the addressAlice@Example.COMverifies under a key issued foralice@example.com. The raw spelling is therefore not what the proof pinned, and putting it insideProvenwould store an uploader-chosen string there. The claim is read offpub_id.canonical().state.senderkeeps the raw spelling, untouched, per the scope fence.Say the word if you would rather have the raw spelling in the claim and I will change it.
Persistence and the migration
FileStategainschallengeandsender_claim, soupload_sessionsgains two nullable columns.CREATE TABLE IF NOT EXISTSleaves an existing table alone, so editing the create statement would be a silent no-op on every deployed database.StateDb::migrate_sessionsALTER TABLEs the missing columns on at startup, driven offpragma_table_info('upload_sessions')rather than a stored schema version, so there is no version counter to fall out of step with the columns. Both columns are nullable becauseADD COLUMNcannot addNOT NULLwithout a default, and a row written before the column existed genuinely has no value: a restored session with no challenge finalizes asUnprovenhowever it is presented.Test 3 red, as asked
Making the verification ignore the derived identity (
if !verify_challenge(...)becomesif false) turns a wrong-key signature into aProvensender:The other two that went red with it are the wrong-challenge and wrong-context tests, which is what those two are for. Reverted.
Acceptance check
The oasdiff gate on
cryptify/api-description.yamlreports no breaking changes at the flags CI uses, and with the engine installed locally the mutation test that normally skips in CI runs and passes too:CI on the branch: PR Title, API diff, Continuous integration and Delivery all green.
New tests: init serves a fresh challenge and stores it; a retried finalize does not erase a proof and still upgrades an unproven one; a valid signature respelled in base64url proves nothing; a correct signature proves the sender and the claim names the container's identity; a signature from another identity, over another challenge, bound to another uuid, malformed, or absent each leave it
Unprovenand none refuses the upload; the challenge and both claim states survive a restart; aupload_sessionstable without the columns gains them and keeps its rows; the migration is a no-op on a second boot; plus unit tests forhex_to_bytesanddecode_proof.Also run against the real binary
Booted
target/debug/cryptifyagainst the production PKG parameters endpoint, so this is the shipped launch path rather than a test harness:POST /fileupload/initanswered"challenge": "3c9d749f…", 64 hex chars decoding to 32 bytes, and SQLite held that same value withsender_claimNULL.Added column challenge to upload_sessions,Added column sender_claim to upload_sessions, thenRestored 1 upload session(s). The old row came back withchallengeNULL and a fresh init wrote its challenge into the migrated column.X-PostGuard-Proofreached the unsealer, so the header extractor takes it and the proof path costs the request nothing.Review round 1
One bug and three nits, all addressed in
3fa1253.The bug: a retried finalize erased a proof. Nothing marks a session finalized, so
upload_finalizestays reachable while the session lives, and the claim was assigned unconditionally — a second finalize with no header recomputedUnprovenandpersist_sessionwrote that over a storedProven. Both ways in are ordinary: a client retrying after a lost response, and a client resuming after a refresh, which no longer holds the challenge and so cannot rebuild the header at all. The assignment is now guarded, soUnproventoProvenstill upgrades andProvennever degrades; only the verification produces aProven, so nothing new can prove a sender.a_retried_finalize_does_not_erase_a_proofcovers both directions, and was red before the guard:The alphabet is now in the wire contract.
decode_proofusesbase64ct::Base64, so base64url — the reflex in web crypto code — is rejected, and the client half is a separate ticket in another repo.api-description.yamlsays so, anda_base64url_proof_leaves_the_sender_unprovenpins it: a valid signature respelled in base64url proves nothing. Makingdecode_prooffall back toBase64Urlturns that test red withSome(Proven { .. }), so the alphabet is the only thing rejecting it.The
challengefield now tells the client to persist it, like itsmax_chunk_size_bytessibling.GET /fileupload/{uuid}/statusdoes not repeat it, so a client that lost it to a refresh finalizes unproven while still holding the signing key. Putting the challenge on the status response would be a wire-contract change for postguard-js#239 to agree to, so that is not in this PR.The fourth was a banned word in
cryptify/CLAUDE.md, cut. The pre-existing one at:200is in a bullet this PR does not touch, so it is left for its own sweep.Scope
No template,
email.rs,EN_STRINGS/NL_STRINGSorsender_displaychange; no accounting-key change; nothing rejected; no config flag; nothing under.github/workflows/.api-description.yamldocuments the new response field and header, andcryptify/CLAUDE.mdrecords the migration gotcha and the proof wire format.One dependency added:
base64ct, the crate pg-core already encodes its artifacts with, soCargo.lockgains only the edge and no new package.