Skip to content

feat: pre-generated monthly notary schedule for ghost keys - #192

Merged
sanity merged 16 commits into
mainfrom
worktree-notary-schedule
Sep 29, 2026
Merged

sanity merged 16 commits into
mainfrom
worktree-notary-schedule

Conversation

@sanity

@sanity sanity commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

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 DIR writes one complete per-amount set per month to DIR/YYYY-MM/.

  • Dating:
    • $1 and $5 are dated monthly (the 1st, 00:00 UTC).
    • $20 and up are dated quarterly (1 January, April, July or October), with one keypair per quarter shared by its three months.
  • Why quarterly for $20 and up: the date is public in every ghost key, so it splits each tier's anonymity set. Over the last month gkapi issued roughly 118 × $5, 22 × $1, 4–5 × $20, 2 × $50 and ≤1 × $100, so a monthly date would often single out one donor on the higher tiers. Quarterly rather than yearly keeps the date usable by an application that wants a recent key: a key is never shown more than three months older than it is.
  • Only ever adds: a run never replaces an existing month. Each month is built in a hidden directory and renamed in when complete. Extending a schedule reuses the quarter's existing pairs, verified as pairs. .schedule-policy locks which tiers are monthly and which quarterly for the schedule's lifetime.
  • Checks before writing: each pair must verify against the master verifying key (the compiled-in Freenet key by default). Its info string must be exactly what was requested, and a ghost key issued from it must verify end to end.

API (rust/api):

  • Quoting: /create-donation and /update-donation quote from the newest YYYY-MM/ that is not in the future, or from the flat files if there is none. They record which one on the PaymentIntent as notary_period metadata.
  • Quoted certificate: GET /notary-certificate/{payment_intent_id} returns the certificate that PaymentIntent was quoted. The success page blinds against it rather than localStorage, which every tab shares.
  • Signing: /sign-certificate signs 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.
  • Mismatched pairs: a pair whose signing key isn't the certificate's is refused when it's loaded, which fails the quote before any charge.
  • Existing PaymentIntents: those without notary_period (all current ones) keep using the flat files.

Install (rust/api/publish_notary_window.sh plus a daily systemd timer, documented in rust/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:

  • stages each month where gkapi can't reach and renames it in with mv --no-copy;
  • never replaces a published month;
  • fails if there is a gap in the schedule;
  • warns when 12 or fewer months remain.

Docs: rust/api/README.md covers:

  • the deploy on nova;
  • the schedule, and why it's dated the way it is;
  • the rollout order: binary first, then the timer;
  • why rolling back to a pre-schedule binary after a month is live breaks in-flight donations, and how to recover.

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.
    • Generator: year and quarter boundaries, extending mid-quarter, a tampered reused pair, a policy change refused in both directions, validation, a wrong master key.
    • Publisher: the window, idempotence, never replacing a published month, a symlinked or cross-filesystem notary dir, a non-private schedule or parent, mode normalisation, non-regular files.
  • Mutation checks: breaking period loading fails 3 API tests; disabling reuse fails the reuse tests; disabling the same-filesystem check and the mode normalisation each fail their tests.

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]

…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 sanity left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  1. The success page blinds against shared localStorage, not against the certificate its PaymentIntent was quoted (payment-flow lens, skeptical lens; verified at hugo-site/static/js/donation-success.js:176-187). Blinding happens after the charge. localStorage is 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").
  2. The README install glob 20?? never matches YYYY-MM directories (big-picture, code-first, skeptical; reproduced).
  3. 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_year reset on every run.

Should Fix

  1. 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).
  2. Installing all 120 months on nova would expose ten years of notary keys to a gkapi compromise, as opposed to the current one (skeptical).
  3. Non-atomic months, and overwriting: an aborted run leaves a partial month that looks complete, and cp -a or --overwrite into live months breaks in-flight donations (skeptical, payment-flow).
  4. 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.html comment 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 08 is parsed as octal": false positive. The script validates --months with ^[1-9][0-9]*$ and rejects 08 before 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: new GET /notary-certificate/{payment_intent_id}, which the success page now blinds against; /sign-certificate refuses 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.sh publishes 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 sanity left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.lock not committed, so CI re-resolves dependencies (code-first, Info): this predates the PR (Cargo.lock is gitignored repo-wide). Out of scope.
  • /notary-certificate reads 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-certificate doesn't check donation_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-donation rewrite notary_period on a succeeded PI? At most to the current period. /notary-certificate and /sign-certificate read 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 sanity left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 sanity left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-status also 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 sanity left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_queued failed. See "Blocking" below.

Round-4 findings

Resolved. The skeptical lens found nothing material in them:

  • mv --no-copy strace-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::drop decides whether to remove the map entry by Arc::strong_count == 2. But tokio's OwnedMutexGuard releases the mutex before dropping its Arc. 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 -u rejects a numeric uid with no passwd entry, and :group-only owners (nit): fails closed; the documented owner is gkapi:gkapi.
  • The publisher tests fail if $TMPDIR has 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 sanity left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_pair key/cert check: the DER comparison is sound (same type and encoder on both sides) and fails closed. It runs before PaymentIntent::create/update and before the certificate_signed mark. Legacy delegate_* files still load, and the unit test fails without the check.
  • Generator reuse check: exit inside $(...) propagates under set -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, -P is the default throughout, and it runs before chown. 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 sanity left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 of 7d26a37e.

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 in errors.rs and routes.rs.
    • NotaryMismatch, InvalidPaymentIntentId and NotFound are all kept.
    • update_donation keeps main's id check and adds this branch's notary quote.
    • /notary-certificate uses the shared payment_claim::is_payment_intent_id.
    • #194's new test gained the notary_certificate_base64 field.

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-certificate validates 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-certificate has 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]

@sanity
sanity marked this pull request as ready for review September 29, 2026 14:05
@sanity
sanity merged commit f2658e6 into main Sep 29, 2026
3 checks passed
@sanity
sanity deleted the worktree-notary-schedule branch September 29, 2026 14:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant