What to build
The cryptify half of the upload proof: mint a challenge at init, verify the answer at finalize, and reduce the result to a SenderClaim. This ticket deliberately stops before the email — the template split, the wording and the kill switch are the sibling ticket, which is blocked by this one.
Every decision below is made. This body is self-contained.
Why
upload_init requires nothing of the uploader; upload_finalize reads a sender identity out of the container (main.rs:942-963) and treats it as theirs. Nothing connects the two, so anyone holding a container someone else sealed can have PostGuard mail strangers in that person's name. cryptify holds no decryption key and never decrypts, so no check inside the container can help — the fix has to bind the uploader to the identity the container claims.
1. Mint and store the challenge
In upload_init, generate 32 random bytes with the idiom already used for the two tokens at main.rs:448 (bytes_to_hex(&rand::random::<[u8; 32]>())) and:
- store it on
FileState,
- return it in
InitResponse as challenge (hex string), beside max_chunk_size_bytes.
Adding it to the response is additive; existing clients ignore the field.
2. Verify at finalize
upload_finalize accepts an optional X-PostGuard-Proof request header carrying a base64 signature. After the container's pub_id is read (:942):
let claim = match proof_header {
Some(sig) => {
let ok = pg_core::verify_challenge(
&vk.public_key, &pub_id, uuid, challenge.as_bytes(), &sig);
if ok { SenderClaim::Proven { .. } } else { SenderClaim::Unproven }
}
None => SenderClaim::Unproven,
};
verify_challenge derives the identity from the container's own pub_id and verifies under it. That is deliberate and is what removes any identity-comparison step: either the uploader holds the key for the identity the container claims, or they do not. Do not add a separate claimed-identity field to the request and compare it.
A present-but-invalid proof and an absent proof both resolve to Unproven in this ticket. Do not reject either — the rollout policy is decided in the sibling ticket, and rejecting here would break every client that has not shipped the header.
verify_challenge and its domain separator come from the pg-core ticket this one is blocked by. Do not reimplement the message construction locally.
3. The type
pub enum SenderClaim {
Proven { email: String, attrs: Vec<(String, String)> },
Unproven,
}
Written exactly once, as the return value of the verification. Do not assign a default and patch it afterwards — the point is that no code path produces a Proven except the verifying branch. Keep the existing state.sender / state.sender_attributes fields as they are for now; the sibling ticket is what stops the templates reading them.
4. Persistence
FileState gains the challenge and the claim, so both must survive a restart — #302/#303 made these sessions durable and a session that reboots mid-upload must still be finalizable.
store.rs needs: new columns on upload_sessions (:389-406), the INSERT/upsert column lists (:431-479), and the positional read (:357, row.get(N) — indices shift, check every one). CREATE TABLE IF NOT EXISTS will not migrate a deployed database, so add a real migration that ALTER TABLEs the columns onto an existing table and is a no-op on a fresh one.
Tests
init returns a challenge, and it is stored on the session.
- A finalize carrying a correct signature yields
Proven with the container's identity.
- A signature made with a different identity's key yields
Unproven — not an error, not Proven.
- A signature over a different challenge yields
Unproven.
- No header at all yields
Unproven.
- Round-trip: persist a session carrying a challenge and a claim, reload it from SQLite, and get the same values back.
- Migration: open a database created without the new columns, run the migration, and confirm existing rows survive and load.
Scope fence
- Do not touch
templates/email/*, src/email.rs, EN_STRINGS/NL_STRINGS, or sender_display. The email is the sibling ticket.
- Do not change the accounting key — a separate ticket canonicalizes it.
- Do not reject any upload that fails or omits the proof.
- Do not add a config flag here.
- Touch no file under
.github/workflows/.
Acceptance check
cargo test -p cryptify --all-targets
cargo fmt --all -- --check
cargo clippy -p cryptify --all-targets -- -D warnings
All green. Also show test 3 red: make the verification ignore the derived identity, confirm a wrong-key signature starts reporting Proven, then revert. Put that in the PR description.
Part of #338, itself part of #247. Decided in #358.
What to build
The cryptify half of the upload proof: mint a challenge at
init, verify the answer atfinalize, and reduce the result to aSenderClaim. This ticket deliberately stops before the email — the template split, the wording and the kill switch are the sibling ticket, which is blocked by this one.Every decision below is made. This body is self-contained.
Why
upload_initrequires nothing of the uploader;upload_finalizereads a sender identity out of the container (main.rs:942-963) and treats it as theirs. Nothing connects the two, so anyone holding a container someone else sealed can have PostGuard mail strangers in that person's name. cryptify holds no decryption key and never decrypts, so no check inside the container can help — the fix has to bind the uploader to the identity the container claims.1. Mint and store the challenge
In
upload_init, generate 32 random bytes with the idiom already used for the two tokens atmain.rs:448(bytes_to_hex(&rand::random::<[u8; 32]>())) and:FileState,InitResponseaschallenge(hex string), besidemax_chunk_size_bytes.Adding it to the response is additive; existing clients ignore the field.
2. Verify at finalize
upload_finalizeaccepts an optionalX-PostGuard-Proofrequest header carrying a base64 signature. After the container'spub_idis read (:942):verify_challengederives the identity from the container's ownpub_idand verifies under it. That is deliberate and is what removes any identity-comparison step: either the uploader holds the key for the identity the container claims, or they do not. Do not add a separate claimed-identity field to the request and compare it.A present-but-invalid proof and an absent proof both resolve to
Unprovenin this ticket. Do not reject either — the rollout policy is decided in the sibling ticket, and rejecting here would break every client that has not shipped the header.verify_challengeand its domain separator come from the pg-core ticket this one is blocked by. Do not reimplement the message construction locally.3. The type
Written exactly once, as the return value of the verification. Do not assign a default and patch it afterwards — the point is that no code path produces a
Provenexcept the verifying branch. Keep the existingstate.sender/state.sender_attributesfields as they are for now; the sibling ticket is what stops the templates reading them.4. Persistence
FileStategains the challenge and the claim, so both must survive a restart — #302/#303 made these sessions durable and a session that reboots mid-upload must still be finalizable.store.rsneeds: new columns onupload_sessions(:389-406), the INSERT/upsert column lists (:431-479), and the positional read (:357,row.get(N)— indices shift, check every one).CREATE TABLE IF NOT EXISTSwill not migrate a deployed database, so add a real migration thatALTER TABLEs the columns onto an existing table and is a no-op on a fresh one.Tests
initreturns a challenge, and it is stored on the session.Provenwith the container's identity.Unproven— not an error, notProven.Unproven.Unproven.Scope fence
templates/email/*,src/email.rs,EN_STRINGS/NL_STRINGS, orsender_display. The email is the sibling ticket..github/workflows/.Acceptance check
All green. Also show test 3 red: make the verification ignore the derived identity, confirm a wrong-key signature starts reporting
Proven, then revert. Put that in the PR description.Part of #338, itself part of #247. Decided in #358.