Repository navigation
feat: pre-generated monthly notary schedule for ghost keys - #192
Conversation
…er-key session generate_notary_keys.sh gains --start-month YYYY-MM --months N, writing one per-amount set per month to <notary-dir>/YYYY-MM/, dated the 1st at 00:00 UTC, so the master key can stay offline for years. Every pair is now checked before it is written: the cert verifies against the master verifying key (the compiled-in Freenet key by default, or --master-verifying-key), its info string is exactly what was requested, and a ghost key issued from it verifies end to end. Also: default amounts now match the donation form (adds 500/2500/10000), the CLI is built once instead of cargo run per amount, and set -euo pipefail. Claude-Session: https://claude.ai/code/session_01C7ph5zmqbaZdYnnuuNjGnv
In schedule mode, --amounts tiers (default $1, $5) are dated monthly and --yearly-amounts tiers (default $20 and up) are dated 1 January, one keypair per year copied into each month directory. The date is public in every ghost key, and at current volumes the $50/$100 tiers see one or two donors a month, so a monthly date would let whoever holds the payment records link a ghost key to its donor. Claude-Session: https://claude.ai/code/session_01C7ph5zmqbaZdYnnuuNjGnv
…hat was quoted The notary directory may now hold YYYY-MM/ subdirectories, each a complete set of per-amount pairs. /create-donation and /update-donation quote from the newest month that is not in the future (flat files if none) and record it on the PaymentIntent as notary_period. /sign-certificate signs with exactly that pair, loaded once, instead of re-reading whatever is current. The browser blinds against the certificate it was quoted before the card is charged, so rotating the pair under an in-flight checkout would charge the donor for a signature that does not unblind. Loading the pair once also removes the two separate reads in sign_marked_payment, and the pair is now loaded before the PaymentIntent is marked spent. PaymentIntents without notary_period (all existing ones) keep signing from the flat files. A missing current month logs an error and keeps issuing from the newest earlier month rather than failing. README: deploy procedure rewritten for nova (the vega/setcap steps no longer apply since the 2026-08-28 migration) and the notary schedule documented. Claude-Session: https://claude.ai/code/session_01C7ph5zmqbaZdYnnuuNjGnv
…ificate Review of #192 found the success page blinds against whatever notary certificate localStorage holds. Every tab shares it, so a second checkout for the same tier across a month boundary (now that tiers rotate) would leave the first donor charged with a signature that cannot unblind. - GET /notary-certificate/{payment_intent_id} returns the certificate that PaymentIntent was quoted; the success page uses it, localStorage is only a fallback for an API without the endpoint. - /sign-certificate accepts the certificate the client blinded against and refuses a mismatch (409) before marking the PaymentIntent spent, so any remaining mismatch is retryable instead of burning the donation. - Correct the comments that said blinding happens before the charge. - Tests: quote-then-sign across the exact UTC rollover, same_notary, and a pin that the load and the mismatch check precede the certificate_signed mark. Claude-Session: https://claude.ai/code/session_01C7ph5zmqbaZdYnnuuNjGnv
… install Generator (generate_notary_keys.sh): - a later run extending a year reuses that year's yearly pairs instead of minting a second set (which split the $20+ anonymity sets); a month generated under a different dating policy is an error, not shared - each month is built in a hidden directory and renamed in when complete - schedule mode only adds months: existing months and --overwrite refused - amounts validated as plain integers, duplicates and empty lists refused - compiler errors are shown; empty-array expansion safe on older bash - rust/cli/test_notary_schedule.sh covers all of it, run in CI Install: publish_notary_window.sh publishes only the current and next month from a root-only copy of the schedule, staged then renamed, never replacing a published month, failing loudly on a gap or exhaustion. A compromise of the API process then exposes two months of notary keys, not ten years. README: fix the install glob (20?? never matched YYYY-MM), document the rolling window and its timer, first-rollout order, and that rolling back to a pre-schedule binary after a month is live breaks in-flight donations and how to recover them. Donation form comment: a missing pair now fails at quote time, and a new tier needs a pair in every scheduled month. Claude-Session: https://claude.ai/code/session_01C7ph5zmqbaZdYnnuuNjGnv
sanity
left a comment
There was a problem hiding this comment.
Comprehensive PR Review: #192 (round 1)
Summary
- PR Title: feat: pre-generated monthly notary schedule for ghost keys
- Type: feat
- CI Status: pending at the reviewed commit
- Linked Issues: none (follow-up filed: #193)
- Review tier: Full (crypto signing and the payment path)
- Reviewers run: code-first, testing, skeptical, big-picture, plus a payment-flow failure lens ("charged donor gets no valid key"); Codex (
codex review --base origin/main); Gemini 3.1 Pro via Vercel AI Gateway ($0.56). All ran. - HEAD SHA reviewed:
3bd17ea2
Bottom line
No reviewer found a bug in the core API logic. Each quote reads the notary pair once, and the recorded notary_period, the returned certificate and the signing key all come from that one read. The amount and the period go in one Stripe update, and signing loads the pair before marking the PaymentIntent spent. Every other lens and model independently confirmed the Stripe metadata semantics (create never sends an empty value, an update with "" deletes the key, a client can't write metadata) and the path safety of is_period.
The review did find one real way for a donor to be charged and get no key, which this PR would have made much more likely, plus several operational problems.
Must Fix
- The success page blinds against shared
localStorage, not against the certificate its PaymentIntent was quoted (payment-flow lens, skeptical lens; verified athugo-site/static/js/donation-success.js:176-187). Blinding happens after the charge.localStorageis shared by every tab, so a second same-tier checkout across a month boundary overwrites the stored certificate. The first donor is then signed with the September pair, while their browser blinded against October's. The failure comes after the mark, and a reload blinds against the same wrong certificate. Monthly rotation makes this reachable where it used to be harmless. The PR's own comments also stated the wrong invariant ("blinds before the card is charged"). - The README install glob
20??never matchesYYYY-MMdirectories (big-picture, code-first, skeptical; reproduced). - A later generator run that extends a year mid-year mints a second "yearly" keypair, which splits the $20+ anonymity set the yearly dating exists to protect (code-first, skeptical, Gemini).
yearly_yearreset on every run.
Should Fix
- Rollback hazard: a pre-schedule binary ignores
notary_period, so rolling back after a month is live breaks unsigned month-quoted donations after the charge (payment-flow, skeptical). - Installing all 120 months on nova would expose ten years of notary keys to a gkapi compromise, as opposed to the current one (skeptical).
- Non-atomic months, and overwriting: an aborted run leaves a partial month that looks complete, and
cp -aor--overwriteinto live months breaks in-flight donations (skeptical, payment-flow). - Tests:
- Load-before-mark was not pinned.
- There was no quote → rollover → sign round trip, and no test at the exact UTC boundary.
- The generator was not tested in CI (testing lens).
Consider
- Validate amounts as plain integers; reject empty tier lists; show compiler errors hidden by
--message-format=json; make empty-array expansion safe on bash < 4.4 (skeptical). stripe-donation-form.htmlcomment is stale: a missing pair now fails at quote time, before the charge (skeptical).- Extract the create/update metadata construction into small helpers and unit test them (testing lens).
- An
error!is logged on every request once the schedule runs out (code-first).
Dismissed, with reasons
- Codex [P2] "zero-padded
--months 08is parsed as octal": false positive. The script validates--monthswith^[1-9][0-9]*$and rejects08before any arithmetic (verified:Error: --start-month YYYY-MM and --months N (N >= 1) must be given together.). Codex's repro replaced that validation with a stand-in. There's now a regression test for it. - Metadata helper extraction: each branch is 3 lines, and since the fixes below, a period mismatch is caught by construction (the success page fetches the quoted cert and the server refuses a mismatch before the mark). A helper test would re-state the code.
- Error log on every request after exhaustion: intentional. That degraded state should be noisy. The publisher timer also fails and warns ahead of time.
- Skeptical Q2, is the date a trust signal? The Vault delegate ranks keys by amount only (
ghostkeys/delegates/ghostkey-delegate/src/handlers.rs), and Harvest only displays it. Backdating yearly tiers to 1 January is no worse than today's flat 2024 date. - Pre-existing, no graceful shutdown: a restart between the mark and the response strands a donation. Out of scope here; filed #193.
Addressed in follow-up commits
-
f6011004: newGET /notary-certificate/{payment_intent_id}, which the success page now blinds against;/sign-certificaterefuses a mismatched client certificate with 409 before the mark; invariant comments corrected; tests for the rollover round trip at the exact UTC boundary,same_notary, and a pin that the load and the mismatch check precede the mark. (Must Fix 1, part of 7.) -
a75b4776:- The generator reuses a year's yearly pairs across runs, and a policy conflict is an error.
- Months are built hidden and renamed in; schedule mode only adds months (no
--overwrite). - Amounts are validated.
rust/cli/test_notary_schedule.sh(37 checks) runs in CI.publish_notary_window.shpublishes only the current and next month from a root-only schedule, without ever replacing one.- README: fixed glob, rolling-window install, first-rollout order, rollback warning and recovery.
- Form comment updated.
(Must Fix 2 and 3; Should Fix 4, 5, 6 and the rest of 7; Consider items.)
Verdict
State: Needs Changes — Re-review Required After Fix. The fixes touch the signing path and exceed 30 lines, so a fresh Full-tier pass will run on the new HEAD.
HEAD SHA reviewed: 3bd17ea2
[AI-assisted - Claude]
- /sign-certificate refuses a scheduled quote when the client does not state the certificate it blinded against (success-page JS cached from before the check, which blinds against shared localStorage). Refused before the mark, so a reload with current JS recovers. Flat-file quotes keep accepting it. (Codex P1) - generate_notary_keys.sh records the schedule's monthly/yearly split in .schedule-policy and refuses a later run with a different one, in either direction. (Codex P2) - README rollback recovery: clear certificate_signed only for donors who report a failure; clearing in bulk would let working donors mint twice. - Success page: the quoted-certificate fetch times out after 20s and falls back instead of spinning. Claude-Session: https://claude.ai/code/session_01C7ph5zmqbaZdYnnuuNjGnv
- publish_notary_window.sh (runs as root) no longer stages inside the gkapi-writable notary directory or runs chmod, which follows symlinks: a compromised gkapi could race it into chmod 600 on arbitrary root paths. Months are copied and chowned in <schedule>/.staging (root-only), then renamed in with mv -T; same filesystem is enforced; flock prevents overlapping runs; a planted symlink is skipped (tested). - Schedule mode requires an explicit --notary-dir: the default is dated, so an extension run would start empty and mint second yearly keys. - GET /notary-certificate returns 404 for a malformed or unknown id (was 500 with an error log, on an unauthenticated endpoint). - /sign-certificate no longer echoes key-load details (server paths, metadata) to the client; they are still logged. - An unparsable client certificate is a mismatch (409, retryable), not 500. - README: tell recovered donors to reload their success page. Claude-Session: https://claude.ai/code/session_01C7ph5zmqbaZdYnnuuNjGnv
sanity
left a comment
There was a problem hiding this comment.
Comprehensive PR Review: #192 (round 2, re-review after fixes)
Summary
- Review tier: Full, re-run on the whole diff after the round-1 fixes (signing path changed, more than 30 lines)
- Reviewers run: skeptical, code-first, payment-flow failure lens ("charged donor gets no valid key, or two keys"); Codex; Gemini 3.1 Pro via AI Gateway (effort medium, $0.45). All ran.
- HEAD SHA reviewed:
a75b4776(CI green: build, test including 37/37 generator checks, CLA)
Round-1 findings
All confirmed resolved by the code-first and payment-flow lenses. Each claimed fix is implemented as described, and both test suites were re-run independently: 37/37 shell checks, 62 Rust tests.
New findings, and what was done
| # | Finding | Source | Severity | Resolution |
|---|---|---|---|---|
| 1 | publish_notary_window.sh (runs as root) staged inside the gkapi-writable notary dir and ran chmod after chown. chmod follows symlinks, so a compromised gkapi could race it into chmod 600 on arbitrary root paths. |
skeptical (reproduced) | Medium | 9b7c699b: stage and chown in <schedule>/.staging (root-only), then mv -T into place; no chmod (cp -a keeps 700/600); same filesystem enforced; flock; a planted symlink is skipped (tested) |
| 2 | Old cached success-page JS sends no certificate, so the mismatch check was skipped even for a scheduled quote. A wrong localStorage cert then failed after the mark. | Codex P1 | Medium | e3d8fdf5: a scheduled quote without a stated cert is refused with 409 before the mark; flat quotes unchanged. client_blinded_against unit-tested for all four cases |
| 3 | Moving a tier yearly → monthly in a later generator run went undetected. | Codex P2 | Medium | e3d8fdf5: .schedule-policy records the split; a different split is refused in either direction (tested) |
| 4 | The rollback recovery ("clear the flag on every PI with notary_period") lets a donor whose key worked mint a second one. |
payment-flow | Low | README: clear only for donors who report a failure, and tell them to reload |
| 5 | The generator's default --notary-dir is dated, so an extension run could start an empty schedule and mint second yearly keys. |
skeptical | Low | Schedule mode requires --notary-dir (tested) |
| 6 | /notary-certificate returned 500 (logged as an error) for a malformed or unknown id, on an unauthenticated endpoint. |
skeptical | Low | Now 404, logged at warn |
| 7 | Key-load error text (server paths, metadata) was echoed to the client. The pattern predates this PR; the PR added new messages that reach it. | skeptical | Low | Generic client message; detail still logged |
| 8 | An unparsable client certificate returned 500. | skeptical | Low | Treated as a mismatch: 409, retryable |
| 9 | The success-page fetch had no timeout. | payment-flow | Info | 20s AbortSignal.timeout, then fallback |
| 10 | The publisher's "≤12 months left" warning was untested. | code-first | Low | Test added |
| 11 | No lock against concurrent publisher runs. | skeptical | Low | flock (in #1) |
Dismissed, with reasons
- "The source-scrape ordering pin can be silently disabled by a rename" (code-first): it can't. Each needle is
.find(...).expect(...), so a renamed anchor panics and the test fails. The pin also strips the test module, so it can't match itself. rust/cli/Cargo.locknot committed, so CI re-resolves dependencies (code-first, Info): this predates the PR (Cargo.lockis gitignored repo-wide). Out of scope./notary-certificatereads the signing key file to return a public cert (skeptical): one small file read per success-page load. Not worth a second code path./sign-certificatedoesn't checkdonation_type(payment-flow, Info, pre-existing): only matters if the Stripe account takes other payments, and the payer would still have paid a tier amount. Out of scope.- Skeptical's question, can
/update-donationrewritenotary_periodon a succeeded PI? At most to the current period./notary-certificateand/sign-certificateread the same metadata, so a reload blinds and signs consistently. The worst case is one 409 and a reload.
Verdict
State: Needs Changes — Re-review Required After Fix. Finding 1 is in a root-run script, so a round-3 pass runs on 9b7c699b.
HEAD SHA reviewed: a75b4776
[AI-assisted - Claude]
- publish_notary_window.sh: the same-filesystem check stat'ed a path the notary dir's owner can swap for a symlink onto another filesystem, where mv falls back to a copy that chmods through attacker-controlled paths as root. The notary dir is now pinned with cd -P and checked (device, owner) as ".", and only addressed relatively afterwards, so the rename cannot leave the filesystem. The schedule dir must be private to the caller (owner + 700). Tested, including that disabling the check fails the test. - /notary-certificate: 404 only when Stripe reports no such PaymentIntent; other Stripe failures stay 500 and logged. - Success page: AbortController + setTimeout instead of AbortSignal.timeout, which older browsers lack. - .schedule-policy is recorded once the first month is in place, so a run that fails outright does not pin its amounts. Claude-Session: https://claude.ai/code/session_01C7ph5zmqbaZdYnnuuNjGnv
sanity
left a comment
There was a problem hiding this comment.
Comprehensive PR Review: #192 (round 3, re-review after fixes)
- Review tier: Full, on the whole diff. The round-2 fixes touched a root-run script and the signing path.
- Reviewers run: skeptical (focused on the root publisher's threat model and the changed API paths); Codex ("No actionable regressions"); Gemini 3.1 Pro via AI Gateway ("No findings", $0.46).
- HEAD SHA reviewed:
9b7c699b(CI green: build, test, CLA)
Round-2 findings
All resolved. The skeptical lens re-verified them, including mv -T against pre-created empty dirs, symlinks and files on the month path, the staging/flock/cleanup order, client_blinded_against, and every KeyError source failing before the mark.
New findings, and what was done (b9b13f8e)
| # | Finding | Severity | Resolution |
|---|---|---|---|
| 1 | The publisher's same-filesystem check stat'ed $notary by path. The notary dir's owner can swap that path for a symlink onto another filesystem (no -L, and the path can be swapped again later), so mv -T hits EXDEV and falls back to a copy that chmods and utimensats through attacker-controllable paths as root. Reproduced with strace. Only reachable with gkapi code execution outside the sandboxed unit (ProtectHome=read-only), but the script claims to defend against exactly that. |
Medium | The notary dir is pinned with cd -P, device and owner are checked on ., and it's only addressed as ./$month afterwards, so the rename can't leave the filesystem. Test: a notary dir symlinked into /dev/shm is refused and nothing is written there. Disabling the check makes that test fail. |
| 2 | AbortSignal.timeout is missing on older browsers, so the quoted-cert fetch silently always fell back. |
Low | AbortController + setTimeout |
| 3 | /notary-certificate mapped every Stripe error to 404, hiding outages. |
Low | 404 only when Stripe returns 404; everything else stays 500 and is logged |
| 4 | .schedule-policy was written before any month succeeded, so a failed first run pinned its amounts. |
Low | Written after the first month is renamed in. Tested |
| 5 | The publisher didn't check that the schedule really is private. | Low | Requires the caller as owner and mode 700 (tested); clear error if either dir is missing |
Dismissed, with reasons
- Old cached JS gets a 409 on a scheduled quote until the unfingerprinted JS expires (Info): the documented rollout (site deploys on merge, then the binary, then the timer) means no scheduled quote exists while old JS can still be cached.
- Question: can gkapi execute code outside the unit? Treated as yes for design purposes, and fixed above regardless.
Verdict
State: Needs Changes — Re-review Required After Fix. Finding 1 is in a root-run script; a focused round-4 pass runs on b9b13f8e.
HEAD SHA reviewed: 9b7c699b
[AI-assisted - Claude]
- publish_notary_window.sh: mv --no-copy, so a rename that cannot be done in one step (across bind mounts of one filesystem too, which a sandboxed unit would create) fails instead of falling back to a path-following copy. Every directory above the schedule must also be private (or sticky), not just the schedule itself. Modes are normalised inside the root-only staging (cp -R --preserve=timestamps, chmod -R u=rwX,go=) instead of inherited from the drive. Owner compared by uid, so a numeric owner works. CDPATH unset. - /notary-certificate accepts only pi_ + alphanumerics: the id lands in a Stripe API path and PaymentIntentId::from_str checks only the prefix. - Success page: the fetch timeout also covers reading the body. Claude-Session: https://claude.ai/code/session_01C7ph5zmqbaZdYnnuuNjGnv
sanity
left a comment
There was a problem hiding this comment.
Comprehensive PR Review: #192 (round 4, re-review after fixes)
- Review tier: Full, re-run on the whole diff; the round-3 fix was in a root-run script.
- Reviewers run: skeptical (threat model: a compromised gkapi user racing the root publisher); Codex ("No actionable regressions").
- HEAD SHA reviewed:
b9b13f8e(CI green: build, and test including 48/48 generator checks, CLA)
Round-3 findings
Resolved. The pinned notary directory can't be moved to another mount, and a FUSE mount gets its own device number. Racing a symlink, file, FIFO or non-empty directory in at ./$month makes mv refuse. Staging, the lock and cleanup are unreachable to gkapi (strace-verified).
New findings, and what was done (1c5d801f)
| # | Finding | Severity | Resolution |
|---|---|---|---|
| 1 | The same device number doesn't mean rename(2) works: across two bind mounts of one filesystem it still returns EXDEV, and mv falls back to a path-following copy (reproduced under unshare). This is latent on nova, but a hardened unit (ProtectSystem=strict plus ReadWritePaths) would create exactly that layout. |
Medium (latent) | mv --no-copy -T: fails instead of copying (coreutils 9.4 on nova and CI) |
| 2 | Only the schedule dir itself was checked, not its parents. | Low | Every ancestor must be owned by root or the caller and not group/other-writable unless sticky. Tested |
| 3 | cp -a inherited modes, ACLs and xattrs from the drive into the live dir. |
Low | cp -R --preserve=timestamps plus chmod -R u=rwX,go= inside the root-only staging |
| 4 | A numeric owner was always refused. | Low | Compared by uid. Tested |
| 5 | The JS timeout no longer covered the response body. | Low | Timer cleared after response.json() |
| 6 | PaymentIntentId::from_str only checks the prefix, and the id lands in a Stripe API path. |
Low | /notary-certificate accepts only pi_ followed by alphanumerics. Unit-tested |
| 7 | An inherited CDPATH could redirect cd. |
Low | unset CDPATH |
Not applicable
- Legacy schedule without
.schedule-policy: no schedule has been generated yet; the first real run will use the current script. /check-payment-statusalso checks only the prefix: pre-existing and not changed here. Its only effect is a 500 for a malformed id.
Verdict
State: Needs Changes — Re-review Required After Fix. Finding 1 is in the root publisher; a focused round-5 pass runs on 1c5d801f.
HEAD SHA reviewed: b9b13f8e
[AI-assisted - Claude]
- The API refuses a notary pair whose signing key is not the certificate's (read_pair). Quoting reads the same pair, so a mismatched pair on disk now fails before any charge instead of producing a signature the donor cannot unblind after it. (Codex P2) - The generator checks a reused yearly pair end to end (issue and verify a ghost key), not just its certificate, before copying it into more months. (Codex P2) Tested by swapping in another valid key. - Publisher sets 700 on directories and 600 on files explicitly; tested with loose source modes (and the test fails without the chmods). - Comment: a rejected PaymentIntent id is not logged. Claude-Session: https://claude.ai/code/session_01C7ph5zmqbaZdYnnuuNjGnv
sanity
left a comment
There was a problem hiding this comment.
Comprehensive PR Review: #192 (round 5, re-review after fixes)
- Reviewers run: skeptical (root publisher threat model, plus the round-4 fixes); Codex.
- HEAD SHA reviewed:
1c5d801f - CI at that SHA: red.
payment_claim::tests::entry_survives_while_a_waiter_is_queuedfailed. See "Blocking" below.
Round-4 findings
Resolved. The skeptical lens found nothing material in them:
mv --no-copystrace-verified: a cross-device move is refused with no copy, and a raced-in symlink is refused.- The octal mode arithmetic checked across 15 modes.
- The ancestor walk mutation-checked.
- The uid-based owner check,
unset CDPATH,is_payment_intent_id, and the JS timeout now covering the body.
New findings, and what was done (55918c1e)
| # | Finding | Source | Severity | Resolution |
|---|---|---|---|---|
| 1 | When extending a year, the generator checked the reused yearly certificate but copied its signing key without proving they form a pair. A mismatched pair would sign after the charge with a signature that can't unblind. | Codex P2 | Medium | The generator now checks a reused pair end to end (issue and verify a ghost key); tested by swapping in another valid key. The API's read_pair also refuses a pair whose signing key isn't the certificate's. Quoting reads the same pair, so any mismatched pair on disk now fails before the charge. Unit-tested for flat files and months. |
| 2 | Nothing tested that published modes are normalised. | skeptical | Low | A test with loose source modes (777/755) checks the live result is 700/600, and it fails if the chmods are removed. Files and directories now get 600/700 explicitly (u=rwX could leave a key at 700). |
| 3 | A comment said "logged quietly"; that path doesn't log. | skeptical | Nit | Fixed |
Blocking: a pre-existing race in payment_claim.rs, not changed by this PR
The red CI is a real race from #94, reproduced independently by the skeptical lens (26/500 runs under taskset -c 0,1).
- The mechanism:
ClaimGuard::dropdecides whether to remove the map entry byArc::strong_count == 2. But tokio'sOwnedMutexGuardreleases the mutex before dropping itsArc. In that gap a waiter can acquire, finish and drop, sees a count of 3, and leaves the entry in the map for good. - The effect: mutual exclusion is unaffected; the bound on the map is not.
- The fix: this is a concurrency change to the double-mint guard, so it goes in its own PR with its own review, landing before this one. This PR will be rebased onto it and re-checked. It won't be retried past.
Dismissed
id -urejects a numeric uid with no passwd entry, and:group-only owners (nit): fails closed; the documented owner isgkapi:gkapi.- The publisher tests fail if
$TMPDIRhas a group-writable ancestor (nit): that's the check working as intended. CI and nova use/tmp.
Verdict
State: Needs Changes — Re-review Required After Fix. Finding 1 touches the signing-key load path, and CI must be green once the payment_claim fix has landed.
HEAD SHA reviewed: 1c5d801f
[AI-assisted - Claude]
- Publisher refuses a month containing anything but directories and regular files, rather than handing a symlink or device node to the owner. - Test pins the reused-pair failure message exactly. Claude-Session: https://claude.ai/code/session_01C7ph5zmqbaZdYnnuuNjGnv
sanity
left a comment
There was a problem hiding this comment.
Comprehensive PR Review: #192 (round 6, re-review after fixes)
- Reviewers run: skeptical; Codex. Both found nothing material. Codex: "no actionable regressions were identified."
- HEAD SHA reviewed:
55918c1e
Round-5 findings
All resolved and verified:
read_pairkey/cert check: the DER comparison is sound (same type and encoder on both sides) and fails closed. It runs beforePaymentIntent::create/update and before thecertificate_signedmark. Legacydelegate_*files still load, and the unit test fails without the check.- Generator reuse check:
exitinside$(...)propagates underset -e, and the trap cleans up the key copy. A mutation check with the check removed failed 4 tests. - Publisher
find/chmod: it follows nothing the owner controls,-Pis the default throughout, and it runs beforechown. The modes test catches a missing chmod.
Lows from this round, fixed in dd3de995 (light re-check: purely additive, tests 53/53)
- The publisher refuses a month containing anything other than directories and regular files. Tested with a planted symlink.
- A test grep now pins the exact reused-pair failure message.
Blocking
CI is red only because of the pre-existing payment_claim race. The fix is #194, under its own Full-tier review. After #194 merges, this branch will be rebased and CI confirmed green on the new HEAD. A conflict-free rebase needs no re-review; any conflict resolution gets one.
Verdict
State: Needs Changes — Light Re-Check Sufficient. The code review is clean; what remains is green CI after #194.
HEAD SHA reviewed: 55918c1e (plus light re-check of dd3de995)
[AI-assisted - Claude]
Yearly dates backdate a key by up to 11 months (a December purchase shows 1 January), which defeats an application that wants a recent key. Quarterly dates (1 Jan/Apr/Jul/Oct) backdate by at most 3 months while keeping several times the monthly anonymity set on the low-volume tiers. --yearly-amounts becomes --quarterly-amounts; reuse, policy recording and pair verification apply per quarter. Tests updated (55 checks), including an extension into a partly generated quarter and a tampered reused pair. Claude-Session: https://claude.ai/code/session_01C7ph5zmqbaZdYnnuuNjGnv
Conflicts: errors.rs/routes.rs error variants (keep NotaryMismatch, InvalidPaymentIntentId and NotFound; InvalidCurrency was removed on main); update_donation (main's id check + this branch's notary quote). The /notary-certificate route now uses payment_claim::is_payment_intent_id from main instead of a local copy (local copy and its test removed). #194's new sign_certificate test gains the notary_certificate_base64 field. Claude-Session: https://claude.ai/code/session_01C7ph5zmqbaZdYnnuuNjGnv
- /notary-certificate validates the id before reading the Stripe key. - Test for July-September and a mid-year quarter boundary (and the base-10 month arithmetic, which fails the test when removed). - The 'at most three months old' claim holds while the schedule covers the current month; say so. Reuse log wording. A misplaced test doc comment. Claude-Session: https://claude.ai/code/session_01C7ph5zmqbaZdYnnuuNjGnv
sanity
left a comment
There was a problem hiding this comment.
Comprehensive PR Review: #192 (round 7, re-review after the quarterly change and the merge of main)
- Reviewers run: skeptical; Codex; Gemini 3.1 Pro via AI Gateway ($0.52). None found anything material.
- HEAD SHA reviewed:
d775d69f(CI green: build, test), plus a light re-check of7d26a37e.
What changed since round 6
8ec19555: $20+ tiers are dated quarterly (1 Jan/Apr/Jul/Oct) instead of yearly. Yearly dates backdated a key by up to 11 months, which defeats any application that wants a recent key. Reuse, policy locking and pair verification now apply per quarter.d775d69f: merge of main (#194), with conflicts resolved inerrors.rsandroutes.rs.NotaryMismatch,InvalidPaymentIntentIdandNotFoundare all kept.update_donationkeeps main's id check and adds this branch's notary quote./notary-certificateuses the sharedpayment_claim::is_payment_intent_id.- #194's new test gained the
notary_certificate_base64field.
Verified
- Merged check order in
sign_certificate: format check → claim → retrieve → id equality → status → signed flag → quoted notary → client-certificate check → mark. Both PRs' ordering pins pass. - No check lost on either side: a diff against main shows only this branch's own additions.
- Quarter arithmetic: base-10 month parsing, quarter starts, no year crossing, and reuse across all three months, including backfilling from a later month.
Lows, fixed in 7d26a37e
/notary-certificatevalidates the id before reading the Stripe key.- A generator test for Jul–Sep and a mid-year quarter boundary. Removing the base-10 parsing fails 3 checks.
- The "never more than three months old" claim is qualified: it holds while the schedule covers the current month.
- Reuse log wording, and a misplaced test doc comment.
Noted, not changed
- Anonymity on the top tiers: quarterly dating gives the $2,500/$10,000 tiers a quarter of the yearly anonymity set. Donors at those tiers are already near-unique by amount alone, so the date adds little there. This is an explicit product decision.
/notary-certificatehas no retrieved-id equality check: with the strict validator,retrieve(pi_X)can't return another object, and the endpoint only returns a public certificate.
Verdict
State: Ready to Merge once CI is green on 7d26a37e (light re-check done: 60/60 generator checks, 75 API tests).
HEAD SHA reviewed: d775d69f (plus light re-check of 7d26a37e)
[AI-assisted - Claude]
Why
Every ghost key shows the date of the notary certificate that signed it, and those are still 2024-08-13 for $1–$100 (2026-08-03 for $500+). Refreshing them means taking the master key out of its encrypted drive each time. This PR lets us generate years of notary keys in one master-key session and have the API roll onto each month by itself.
What
Generator (
rust/cli/generate_notary_keys.sh):--start-month YYYY-MM --months N --notary-dir DIRwrites one complete per-amount set per month toDIR/YYYY-MM/..schedule-policylocks which tiers are monthly and which quarterly for the schedule's lifetime.API (
rust/api):/create-donationand/update-donationquote from the newestYYYY-MM/that is not in the future, or from the flat files if there is none. They record which one on the PaymentIntent asnotary_periodmetadata.GET /notary-certificate/{payment_intent_id}returns the certificate that PaymentIntent was quoted. The success page blinds against it rather thanlocalStorage, which every tab shares./sign-certificatesigns with exactly the quoted pair. If the client's stated certificate doesn't match, or a scheduled quote gets no certificate at all, it refuses with 409 before the PaymentIntent is marked, so the donation stays retryable.notary_period(all current ones) keep using the flat files.Install (
rust/api/publish_notary_window.shplus a daily systemd timer, documented inrust/api/README.md): the full schedule stays root-only on nova, and only the current and next month are published into gkapi's directory. The publisher:mv --no-copy;Docs:
rust/api/README.mdcovers:Testing
cargo test -p ghostkey-api: 75 tests, merged with fix(api): stop contended PaymentIntent claims leaking their map entry #194.rust/cli/test_notary_schedule.sh: 55 checks, also run in CI.Not yet exercised: a real Stripe checkout against a schedule month. That's planned as a post-deploy check.
Related: #193 (pre-existing, no graceful shutdown).
https://claude.ai/code/session_01C7ph5zmqbaZdYnnuuNjGnv
[AI-assisted - Claude]