fix(cryptify): canonicalize the default-tier accounting key - #366
Conversation
The rolling limit on the default tier accounted against the sender email exactly as the uploaded container stored it. That value is caller-chosen even when the sender is genuine: `Policy::derive` canonicalizes before it derives the signer's identity, so one signing key for `bob@example.com` also verifies a container storing `Bob@Example.COM`, and the `Unsealer` hands the raw spelling back as `pub_id`. One identity could therefore mint a fresh 30-day quota bucket per capitalization. Route both the `get_usage` check and the `record_upload` write through a new `accounting_key` helper that applies `pg_core::identity::canonicalize(config.email_attribute(), …)` to the sender. The `api-key:<tenant>` branch is unchanged — a tenant id is not an identity attribute and carries no rule — and `state.sender` stays raw, since that is the confirmation-mail recipient and `Reply-To`. Tests: an end-to-end pair of finalizes whose containers store `Bob@Example.COM` and `bob@example.com` must share one bucket, and a table test pinning the key to `canonicalize()` of the container value across capitalization and surrounding whitespace. Closes #363 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The bucketing test's fixture rewrites the stored sender spelling in place. Read it back through the Unsealer before counting buckets, so a byte substitution that landed somewhere inert cannot leave the test passing over a canonical container and guarding nothing. Verifying the container is part of that read, which also pins the premise: the respelled container is still accepted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The rolling window is `ROLLING_WINDOW_SECS = 14 * 24 * 60 * 60` (cryptify/src/store.rs:17), and both cryptify/CLAUDE.md and api-description.yaml already say 14 days. The doc comment on `accounting_key` is where a reader lands to find out what a duplicate bucket costs, so the number there is the one that has to be right. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
VERDICT: approve
Rules Dobby 2 — cycle 1, sign-off
One finding, a nit, and it is already fixed on the branch. No bug-severity findings, so no fix loop.
The finding (fixed, not outstanding)
accounting_key's doc comment said the duplicate bucket mints "a fresh 30-day quota". The rolling window is 14 days — ROLLING_WINDOW_SECS = 14 * 24 * 60 * 60 (cryptify/src/store.rs:17), cryptify/CLAUDE.md:136 already said "14d rolling window" four lines above the bullet this PR adds, and cryptify/api-description.yaml:388 says "rolling 14-day upload limit". That comment is where a reader lands to find out what a duplicate bucket costs, so the number there is the one that has to be right.
The same sentence was in the PR description ("mint a fresh 30-day quota bucket per capitalization"), which is not in the diff and would have survived a code-only fix. Both surfaces are corrected:
cryptify/src/main.rs:927→bb9b05d(cargo fmt --manifest-path cryptify/Cargo.toml --all -- --checkclean; comment-only change)- the PR body → edited in place, no branch push, no CI rerun
What I checked and did not find
Rule sweep over the rules that plausibly bear on this diff — Rust fmt/clippy gating, regression-test policy, mutation-probe honesty, self-certifying guards, doc/PR claim accuracy, closing keywords, the AEAD/recipient-set family, and cryptify's own finalize and byte-range conventions. Verified against the tree rather than taken from the sweep:
- The fix covers both ends. The
get_usagecheck and therecord_uploadwrite now read one helper, so the check and the accrual cannot disagree — which was the shape of the original defect. canonicalizeis the right call. For an email attribute type it isvalue.trim().to_lowercase(), and it is total: an attribute type with no rule passes through unchanged, so theirma-demotype test deployments configure is covered by the same tail-keyed rule. The table test pins both types.- The integration test has teeth and is not self-certifying.
respell_stored_senderis read back through theUnsealerbefore any bucket is counted, so a byte substitution that landed somewhere inert cannot leave the test passing over a canonical container. The quoted "seen red" mutation (None => sender.map(|s| s.to_owned())) is the base branch's expression, and the quoted numbers line up with the test's own assertions (4999998525vsROLLING_LIMIT). - The out-of-scope list holds.
state.senderstaying raw is correct — it is the confirmation-mail recipient andReply-To. Theapi-key:<tenant>branch is unchanged and still wins over the sender. - No overclaim about already-stored rows. The body says plainly that rows written under a non-canonical spelling keep their bucket until the window clears them, and that no migration is included. That is the qualification this class of fix usually loses.
- Nothing embargoed here. #363 is a public, maintainer-filed issue, and none of the repo's draft advisories covers this topic — so flipping the PR out of draft discloses nothing.
CI on bb9b05d: 63 SUCCESS, 4 SKIPPED, 0 failures. Flipping to ready for review.
Closes #363.
What was wrong
upload_finalizeaccounted the default-tier rolling limit against the sender email exactly as the uploaded container stored it:senderis read verbatim out of the container's public signing policy, and that value is caller-chosen even when the sender is entirely genuine.Policy::derivecanonicalizes before it derives the signer's identity, so one signing key forbob@example.comalso verifies a container storingBob@Example.COM, and theUnsealerhands the raw spelling back aspub_id. One identity could therefore mint a fresh 14-day quota bucket per capitalization.pg-core's
Sealerdoes canonicalize the signing policy before writing it (canonical_signing_key), but that is the honest client's courtesy rather than a gate: a client that skips it produces a container that verifies just the same. The test below builds exactly that container.The change
Both the
get_usagecheck and therecord_uploadwrite now go through oneaccounting_keyhelper, so the check and the accrual cannot disagree:Usage rows already written under a non-canonical spelling keep their own bucket until they age out of the rolling window, which the window clears on its own. No migration is included: the issue did not ask for one, and a backfill would need to merge rows rather than rename them.
Out of scope, and untouched:
state.senderstays raw (it is the confirmation-mail recipient andReply-To), theapi-key:<tenant>branch stays as it was (a tenant id is not an identity attribute and has no rule), and no limit, template, locale string or pg-core file changed.Tests
two_spellings_of_one_sender_share_a_rolling_limit_bucketseeds the bucket to leave room for exactly one container, uploads the capitalized spelling, and requires the lowercase one to be refused. Building the non-canonical container needs a helper,respell_stored_sender, which rewrites the stored spelling inside the header-signature block; the replacement is the same byte length, so every length prefix stays valid and the signed header bytes are untouched. The test reads both containers back through theUnsealerbefore it counts buckets, so a substitution that landed somewhere inert cannot leave the guard passing over a canonical container. That read verifies the container too, which is the premise itself: the respelled one is still accepted.accounting_key_canonicalizes_the_sender_spellingis the regression pin #358 asked for. It asserts the key equalscanonicalize()of the container value for a capitalized local part, a capitalized domain, an all-caps address and surrounding whitespace, against both the production attribute type and theirma-demoone test deployments configure.Two smaller tests hold the
api-key:branch and the no-tenant-no-sender case in place.Seen red
With the canonicalization reverted (
None => sender.map(|s| s.to_owned())), re-run atfb9abce:The server log for that run shows the separate bucket directly:
Dropping that intermediate assertion to reach the end of the test shows the second spelling going through as well:
The table test is red on the same revert:
Acceptance check
cryptify/CLAUDE.mdgains a note on why the accounting key is not the sender value on the wire, so the next reader does not reach past the helper for the raw value.Part of #338, itself part of #247. Decided in #358.