fix(api): stop concurrent requests minting several Ghost Keys from one donation - #94
Conversation
ReviewFour lenses, run sequentially in-session (not as independent blind subagents), each committed to in writing before the next. Full tier: this is a payment path. One finding, fixed in 57db256. Lens 1 — concurrency correctness (the failure mode specific to this change)The mechanism itself checked out on every point I could find to attack:
Stripe semantics for the new release path, verified against the docs rather than assumed: setting a metadata key to an empty string deletes the key (it does not store Lens 2 — adversarialAttempts to break it that came up empty: submitting distinct blinded keys sequentially against one PaymentIntent (second is correctly rejected), exploiting the window while the mark is briefly released (the claim is still held, and a released mark is the correct state since nothing was issued), and holding the lock to block a legitimate donor (needs the victim's PaymentIntent id, and only affects that one donation). One thing worth recording: the donation routes have no rate limiting. Lens 3 — test integrityThe finding. Two tests asserted on the size of the global Replaced Re-ran the mutation after changing the assertions, since edited assertions have to be re-proven non-vacuous: Restored, 47 pass, and 5 consecutive runs of the Lens 4 — scopeWhat this does not do, stated so it isn't mistaken for more: the guard is per-process. It closes the entire exposure as deployed today (single axum process, no orchestration), and it does not span multiple instances behind a load balancer. The Stripe flag remains the durable record, so it still blocks a retry arriving after the first finished and still survives a restart. This is in the module docs, not just here. Verification
[AI-assisted - Claude] |
) Deploying this crate was undocumented tribal knowledge with one step that is invisible until it bites: the service runs unprivileged and binds port 80 for the ACME challenge server, which needs CAP_NET_BIND_SERVICE as a file capability. `cp` does not preserve file capabilities, so replacing the binary without re-running setcap makes it bind 443, log a healthy-looking "Listening on 0.0.0.0:443", then panic with PermissionDenied and restart-loop. The same trap catches the rollback, which is what makes it nasty: copying a backup back into place also drops the capability, so the service keeps panicking and it reads as "the new build is broken" rather than "the copy dropped a capability". `mv` keeps the inode and therefore the capability. This bit during the deploy of #94 and took the API down for about 100 seconds on 2026-08-16. The larger risk it exposed is quieter: without a restart to surface it, a missing capability leaves HTTPS looking perfectly healthy while certificate renewal fails at the next attempt. Also records that merging a change to rust/api ships nothing, because deploy.yml only publishes the Hugo site — #94 sat merged and undeployed for 18 days for exactly that reason and nothing in the repo said so. Adversarial review of the procedure found two ways the runbook could itself cause a worse outage, both fixed before merge: - Backing up with `mv` and installing with `cp` as separate steps left a window with no binary at the live path, where a restart fails 203/EXEC and stays down. Now stages the new binary at its final location, verifies the capability while the live binary is untouched, and swaps with two adjacent renames. - `getcap` was called without sudo while its neighbours had it. It lives in /usr/sbin, off a normal user's PATH, so the one verification gate could fail as "command not found" and be skipped rather than enforced. The real fix is CI deployment for this crate so the manual procedure stops mattering; that is a larger change and is flagged rather than attempted here.
Problem
sign_certificateguards against reusing a PaymentIntent with acertificate_signedflag in Stripe metadata, but the guard is a read-check-write across three separate Stripe API calls with no atomicity between them:Two requests carrying the same
payment_intent_idcan both retrieve the PaymentIntent before either writes, both observe an unset flag, and both go on to sign.This is worse than an ordinary double-submit. Ghost Keys are sold on the claim that an identity costs real money, so Sybil attacks get expensive. An attacker who donates $1 once and then fires N concurrent
/sign-certrequests with N different blinded keys gets N identities for $1, and the scarcity property the whole scheme rests on collapses. The page I just merged in #93 states that property to donors in as many words.Found while reviewing #93; flagged there as out of scope for that PR.
Approach
A per-PaymentIntent async lock (
payment_claim), taken before the PaymentIntent is retrieved and held past the metadata write, so the check and the write cannot interleave.Scoped honestly: this closes the window within the process, which is the entire exposure today since the API is a single axum process with no orchestration. If it is ever run as more than one instance behind a load balancer the claim has to move to shared storage. The Stripe flag remains the durable record either way, so it still blocks a retry arriving after the first finished, and still survives a restart. That limitation is written into the module docs rather than left implicit.
Two details worth review attention:
Arc::strong_count(lock) == 2check inDropmeans "the map plus this guard, so nobody is waiting". A queued waiter has already cloned the Arc, making it 3, and the entry stays so the waiter keeps contending on the same mutex.Two adjacent donor-facing fixes the lock enables
Both were reachable before and both leave a donor charged with no key and no way to retry. Neither is safe to do without the lock, because each briefly widens the window the lock now closes:
BlindedMessage::from_base64on caller-supplied input ran after the mark was written, so a bad request permanently consumed a real payment. Parsing moved before the mark.sign_with_notary_keyorget_notaryfails (for example the amount has no notary keypair, the exact hazard documented on the tier list in feat(ghostkey): fix donation funnel conversion and suppress Stripe Link #93), the mark is cleared so the donor can retry. Safe only because the claim is still held, so nothing can slip into the window while the flag is briefly clear.Testing
payment_claimunit tests, plus source-scrape pins inhandle_sign_certfollowing the pattern used elsewhere in the Freenet codebase.Mutation-tested, so these are not vacuous. Making
claim()hand out a fresh unshared mutex per call (i.e. reintroducing the bug):with the intended message: "two requests were inside the claim for one PaymentIntent at once, so both could sign and one donation would mint two Ghost Keys". Restored, all 47 pass.
The pins exist because the dangerous regressions here are invisible to behavioural tests: moving the claim after the flag read, or writing
let _ = claim(..)instead oflet _claim = claim(..), both leave the happy path returning a perfectly valid certificate while the exclusion silently does nothing.cargo fmt --checkclean,cargo build,cargo test -p ghostkey-api47 passed.Not addressed
Two pre-existing clippy warnings (
unnecessary_get_then_checkin this file,type_complexityintor.rs) are left alone to keep a security-path diff tight. CI gates fmt/build/test, not clippy.[AI-assisted - Claude]