Skip to content

cryptify: mint an upload challenge and verify it at finalize, reducing to a SenderClaim #364

Description

@rubenhensen

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

  1. init returns a challenge, and it is stored on the session.
  2. A finalize carrying a correct signature yields Proven with the container's identity.
  3. A signature made with a different identity's key yields Unproven — not an error, not Proven.
  4. A signature over a different challenge yields Unproven.
  5. No header at all yields Unproven.
  6. Round-trip: persist a session carrying a challenge and a claim, reload it from SQLite, and get the same values back.
  7. 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.

Metadata

Metadata

Assignees

Labels

repo:cryptifyTouches the cryptify reposecuritySecurity-related issue (vulnerability, hardening, or risk)wayfinder:dispatchWayfinder ticket type: no decisions left, carryable by a coding agentwayfinder:in-flightDispatched to a coding agent; claim is an agent's, not a human's

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions