diff --git a/.github/workflows/rust-api-tests.yml b/.github/workflows/rust-api-tests.yml index 0fb48fea..3207c2b6 100644 --- a/.github/workflows/rust-api-tests.yml +++ b/.github/workflows/rust-api-tests.yml @@ -43,7 +43,9 @@ jobs: - name: Cache cargo uses: Swatinem/rust-cache@v2 with: - workspaces: rust + workspaces: | + rust + rust/cli - name: Formatting working-directory: rust @@ -63,3 +65,10 @@ jobs: - name: Test working-directory: rust run: cargo test -p ghostkey-api + + # generate_notary_keys.sh mints years of notary keys in one master-key + # session, and a mistake in it (say, a second "quarterly" key for a quarter) + # is only found after the keys are in use. Runs against a throwaway + # master key. + - name: Notary schedule generator + run: bash rust/cli/test_notary_schedule.sh diff --git a/hugo-site/static/js/donation-success.js b/hugo-site/static/js/donation-success.js index 71cf1b22..ff78c93f 100644 --- a/hugo-site/static/js/donation-success.js +++ b/hugo-site/static/js/donation-success.js @@ -165,15 +165,52 @@ function generateTestCertificate() { displayCertificate(publicKey, privateKey, unblindedSignature); } +// The notary certificate this payment was quoted. It must be the one we blind +// against: the server signs with its pair, and notary keys rotate monthly. +// localStorage is only a fallback (an API without this endpoint), because every +// tab shares it and another checkout may have overwritten it with a different +// month's certificate for the same amount. +async function fetchQuotedNotaryCertificate(paymentIntentId) { + try { + const apiUrl = window.ghostkeyApiUrl; + // Bounded, so a hung request falls back instead of leaving the page + // spinning. AbortController rather than AbortSignal.timeout, which older + // browsers lack (it would throw and silently skip this fetch every time). + // The timer covers the body as well as the headers. + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 20000); + try { + const response = await fetch( + `${apiUrl}/notary-certificate/${encodeURIComponent(paymentIntentId)}`, + { signal: controller.signal } + ); + if (!response.ok) { + console.warn("Could not fetch quoted notary certificate:", response.status); + return null; + } + const data = await response.json(); + return data.notary_certificate_base64 || null; + } finally { + clearTimeout(timer); + } + } catch (error) { + console.warn("Could not fetch quoted notary certificate:", error); + return null; + } +} + async function generateAndSignCertificate(paymentIntentId) { console.log("Starting generateAndSignCertificate"); try { - // Read the canonical notary key first, fall back to the legacy + let notaryCertificateBase64 = await fetchQuotedNotaryCertificate(paymentIntentId); + // Fallback: read the canonical notary key first, then the legacy // delegate key for sessions opened before the rename. We write the // canonical key on migration so future reads are clean, but we do // NOT delete the legacy key for one release in case an old cached // tab reads it. Removal tracked for a future release in freenet/web#24. - let notaryCertificateBase64 = localStorage.getItem('notary_certificate_base64'); + if (!notaryCertificateBase64) { + notaryCertificateBase64 = localStorage.getItem('notary_certificate_base64'); + } if (!notaryCertificateBase64) { notaryCertificateBase64 = localStorage.getItem('delegate_certificate_base64'); if (notaryCertificateBase64) { @@ -209,9 +246,11 @@ async function generateAndSignCertificate(paymentIntentId) { headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify({ - payment_intent_id: paymentIntentId, - blinded_ghost_key_base64: blindedPublicKey + body: JSON.stringify({ + payment_intent_id: paymentIntentId, + blinded_ghost_key_base64: blindedPublicKey, + // Lets the server refuse a mismatch before spending the donation. + notary_certificate_base64: notaryCertificateBase64 }), credentials: 'same-origin' }); diff --git a/hugo-site/themes/freenet/layouts/shortcodes/stripe-donation-form.html b/hugo-site/themes/freenet/layouts/shortcodes/stripe-donation-form.html index 8081e78c..6bcecd8b 100644 --- a/hugo-site/themes/freenet/layouts/shortcodes/stripe-donation-form.html +++ b/hugo-site/themes/freenet/layouts/shortcodes/stripe-donation-form.html @@ -2,9 +2,10 @@
{{/* These tiers are NOT free to change. Each one needs a matching per-amount notary keypair on the API host (notary_certificate_{amount}.pem and - notary_signing_key_{amount}.pem, see rust/api/src/delegates.rs). An - amount with no keypair fails certificate signing AFTER the card has been - charged, leaving the donor paid up with no key. + notary_signing_key_{amount}.pem, see rust/api/src/delegates.rs), in the + flat files AND in every month of the notary schedule, so adding a tier + means a master-key session (rust/api/README.md). An amount with no + keypair fails when the donation is quoted, before any charge. Fixed tiers are also load-bearing for anonymity: the amount is written into the certificate and is visible to anyone who verifies it, so a diff --git a/rust/api/README.md b/rust/api/README.md index 6ef95c05..2e6bc65b 100644 --- a/rust/api/README.md +++ b/rust/api/README.md @@ -2,94 +2,47 @@ WARNING: This file is publicly readable, do NOT put anything secret in here. +## Where it runs + +gkapi runs on **nova** as `gkapi.service` (unit: `/etc/systemd/system/gkapi.service`, +binary: `/home/gkapi/bin/ghostkey-api`, user `gkapi`). It was migrated from vega on +2026-08-28. Caddy terminates TLS for `gkapi.freenet.org` and owns ACME (see +`/etc/caddy/gkapi.caddy`), and proxies to the API on `:8081`. So the binary binds an +unprivileged port and needs no file capability. The unit file's comments explain why the +TLS flags and `setcap` are gone and why the ufw `deny 8081` rule must stay. + ## Deploying gkapi There is **no CI deployment for this crate**. `deploy.yml` builds the Hugo site and publishes it to GitHub Pages; it never touches the API. `rust-api-tests.yml` only runs fmt, build and test. Merging a change to `rust/api` therefore ships nothing: the binary -on vega has to be replaced by hand, and has in the past sat months behind main. - -There is also no Rust toolchain on vega (`~gkapi/.cargo` exists but `bin/` is empty), so -the binary is built elsewhere and copied. vega and nova are both Ubuntu 24.04 on the same -glibc, so a release build from nova runs there as-is. Check with `ldd --version` on both -before relying on that. - -### The step that is easy to miss - -The service runs as the unprivileged `gkapi` user and binds **port 80** for the HTTP-01 -ACME challenge server, which needs `CAP_NET_BIND_SERVICE`. That capability lives on the -binary as a file capability, and **`cp` does not preserve it**. Replace the binary without -re-running `setcap` and the service binds 443, logs `Listening on 0.0.0.0:443`, then panics -at `main.rs` with `PermissionDenied` on the port 80 bind and systemd restart-loops until it -gives up. - -The same trap catches the rollback: copying a backup *back* into place also produces a file -with no capability, so a panicking service stays panicking and it looks like the new build -is at fault. `mv` preserves the capability because it keeps the inode; `cp` does not. - -### Procedure +has to be replaced by hand, and has in the past sat months behind main. ```bash # 1. build on nova, from main, and prove the build contains what you expect cd ~/code/freenet/web/main/rust && cargo build --release -p ghostkey-api strings target/release/ghostkey-api | grep -c payment_claim # sanity: expect non-zero -# 2. upload and re-check after transfer -scp target/release/ghostkey-api vega:/tmp/ghostkey-api.new -ssh vega 'strings /tmp/ghostkey-api.new | grep -q payment_claim && echo ok' - -# 3. stage it at its final location, fully prepared, WITHOUT displacing the live binary -ssh vega ' - sudo cp /tmp/ghostkey-api.new /home/gkapi/bin/ghostkey-api.staged - sudo chown gkapi:gkapi /home/gkapi/bin/ghostkey-api.staged - sudo chmod 775 /home/gkapi/bin/ghostkey-api.staged - sudo setcap cap_net_bind_service+ep /home/gkapi/bin/ghostkey-api.staged - sudo getcap /home/gkapi/bin/ghostkey-api.staged -' -# Must print: /home/gkapi/bin/ghostkey-api.staged cap_net_bind_service=ep -# If it does not, STOP. Nothing has changed yet and the live binary is untouched. - -# 4. swap with two adjacent renames, then restart -ssh vega ' - set -e - STAMP=$(date +%Y%m%d-%H%M%S) - sudo mv /home/gkapi/bin/ghostkey-api /home/gkapi/bin/ghostkey-api.rollback-$STAMP - sudo mv /home/gkapi/bin/ghostkey-api.staged /home/gkapi/bin/ghostkey-api - echo "rollback binary: /home/gkapi/bin/ghostkey-api.rollback-$STAMP" - sudo systemctl reset-failed gkapi - sudo systemctl restart gkapi -' +# 2. stage it next to the live binary, then swap with two adjacent renames +sudo install -o gkapi -g gkapi -m 755 target/release/ghostkey-api /home/gkapi/bin/ghostkey-api.staged +STAMP=$(date +%Y%m%d-%H%M%S) +sudo mv /home/gkapi/bin/ghostkey-api /home/gkapi/bin/ghostkey-api.rollback-$STAMP +sudo mv /home/gkapi/bin/ghostkey-api.staged /home/gkapi/bin/ghostkey-api +echo "rollback binary: /home/gkapi/bin/ghostkey-api.rollback-$STAMP" +sudo systemctl restart gkapi ``` -Two things about that shape are deliberate, and both exist to avoid a worse failure than -the one being fixed: - -- **Prepare the capability on the staged file, and verify it, before anything is - displaced.** The `getcap` gate in step 3 is the whole point of splitting the steps: if it - fails you stop with the running service completely untouched. Do not restart to see what - happens. -- **Do not `mv` the old binary away in one command and `cp` the new one in with another.** - Between those two the path does not exist, and anything that restarts the unit in that - window (a crash, an OOM, a reboot, someone running `systemctl restart` out of order) fails - with `status=203/EXEC` and stays down. Step 4 closes that to two adjacent renames. - `mv` is also what carries the capability across, since it keeps the inode. - -`sudo` on `getcap` is not decoration: it lives in `/usr/sbin`, which is not on a normal -user's `PATH`, so a bare `getcap` can fail as "command not found" and skip the check -entirely. +Do not `mv` the old binary away in one command and copy the new one in with a later one. +Between those two the path does not exist, and anything that restarts the unit in that +window fails with `status=203/EXEC` and stays down. ### Verifying a deploy ```bash -ssh vega 'systemctl is-active gkapi; strings /home/gkapi/bin/ghostkey-api | grep -c payment_claim' +systemctl is-active gkapi curl -s https://gkapi.freenet.org/ # {"message":"Hello, world!"} -curl -s -o /dev/null -w '%{http_code}\n' http://gkapi.freenet.org/.well-known/acme-challenge/probe ``` -That last one matters: a 404 means the port 80 challenge listener is up. Connection refused -means the capability is missing and certificate renewal will fail at the next attempt even -if HTTPS looks healthy. - Then confirm every donation tier still resolves its notary keypair, which is a separate failure mode from the binary (see the tier comment in `hugo-site/themes/freenet/layouts/shortcodes/stripe-donation-form.html`). @@ -109,39 +62,135 @@ done ### Rollback -Substitute `` with the timestamp step 4 printed. +Substitute `` with the timestamp the deploy printed. + +```bash +sudo mv /home/gkapi/bin/ghostkey-api.rollback- /home/gkapi/bin/ghostkey-api +sudo systemctl restart gkapi +``` + +**Once a notary schedule month is live, do not roll back to a binary that predates the +schedule** (anything before freenet/web#192). That binary ignores `notary_period` and signs +with the flat files, so every donation quoted from a month directory and not yet signed is +charged and gets a signature that does not unblind. The signing call succeeds, so the +PaymentIntent stays marked `certificate_signed` and the donor cannot retry. If it happens +anyway, roll forward, then clear `certificate_signed` only on the PaymentIntents of donors +who report that key generation failed (the success page shows the error; the PaymentIntent +id is in its URL), then tell each of them to reload that success page. Do not clear it in bulk on every PaymentIntent +with `notary_period`: the server cannot tell which signatures failed to unblind, and a +donor whose key worked would be able to mint a second one. + +## Notary keys and the monthly schedule + +The notary directory (`--notary-dir`, `/home/gkapi/delegate-keys` on nova) holds one +keypair per donation tier: `notary_certificate_.pem` and +`notary_signing_key_.pem` (older deployments use `delegate_*` names, which are +still read). The certificate's info string carries the amount and a date, +`delegate-key-created`, which every ghost key issued from it shows publicly. + +To keep that date current without taking the master key out of storage every month, the +directory can also hold a schedule: one subdirectory per month, `YYYY-MM/`, each a complete +set of tiers. + +### How the API uses it + +- **Quoting** (`/create-donation`, `/update-donation`) uses the newest `YYYY-MM/` that is not + in the future, or the flat files if there is none, and records which on the PaymentIntent + as `notary_period` metadata. If the current month is missing it logs an error and keeps + issuing from the newest earlier month, so a schedule that runs out degrades to a stale + date, not an outage. If the chosen month lacks a tier, quoting that tier fails, before + any charge. +- **Blinding** happens on the success page after the charge, possibly in a later month. + The page fetches the certificate its PaymentIntent was quoted + (`GET /notary-certificate/{payment_intent_id}`) rather than trusting localStorage, which + every tab shares. +- **Signing** (`/sign-certificate`) uses exactly the pair the PaymentIntent was quoted + from, never whatever is current by then, and refuses (409, before marking the payment + spent) if the client says it blinded against a different certificate. A PaymentIntent + with no `notary_period` (quoted from the flat files, including everything before the + schedule existed) signs with the flat files. + +So a month directory, and the flat files, must never be **deleted or replaced** while a +donation quoted from it could still be in checkout: that breaks the donation after the +charge. Keep past months; they are small. Never regenerate into a live directory. + +### Generating (master key mounted, one session) + +```bash +rust/cli/generate_notary_keys.sh --master-key \ + --notary-dir /notary-schedule --start-month 2026-10 --months 120 +``` + +By default $1 and $5 are dated monthly, and $20 and up are dated quarterly (1 January, +April, July or October). +- **Why not monthly for everything:** the date partitions each tier's anonymity set, and the + higher tiers see too few donors a month for a monthly date to be safe (see the script + header). +- **Why not yearly:** an application that wants a *recent* ghost key can still use the date. + While the schedule covers the current month, a key is never shown more than three months + older than it is. + +The script verifies every pair against the compiled-in Freenet master key. It builds each +month in a hidden directory before renaming it into place, and refuses to touch an existing +month. When extending a schedule mid-quarter, it reuses that quarter's pairs, after +checking them. `rust/cli/test_notary_schedule.sh` tests it (CI runs it). +To extend later, run it again with `--start-month` just after the last month. + +Keep the output on the encrypted master-key drive. It is the backup. + +### Installing on nova: a rolling window + +Do not copy the whole schedule into the API's directory. If the API process were +compromised, every future month's notary key would leak with it, and forged ghost keys +dated years ahead would be indistinguishable from real ones. Instead keep the schedule +root-only and let a daily timer publish the current and next month: ```bash -ssh vega ' - set -e - sudo cp /home/gkapi/bin/ghostkey-api.rollback- /home/gkapi/bin/ghostkey-api.staged - sudo chown gkapi:gkapi /home/gkapi/bin/ghostkey-api.staged - sudo chmod 775 /home/gkapi/bin/ghostkey-api.staged - sudo setcap cap_net_bind_service+ep /home/gkapi/bin/ghostkey-api.staged - sudo getcap /home/gkapi/bin/ghostkey-api.staged - sudo mv /home/gkapi/bin/ghostkey-api.staged /home/gkapi/bin/ghostkey-api - sudo systemctl reset-failed gkapi && sudo systemctl restart gkapi -' +# once, from the mounted drive +sudo install -d -m 700 -o root -g root /var/lib/gkapi-notary-schedule +sudo cp -a /notary-schedule/20??-?? /var/lib/gkapi-notary-schedule/ +sudo chown -R root:root /var/lib/gkapi-notary-schedule +sudo install -m 755 rust/api/publish_notary_window.sh /usr/local/sbin/publish-notary-window ``` -The `setcap` line is required on the way back too, and this is the part that is genuinely -easy to get wrong under pressure: copying a backup *into place* produces a file with no -capability, so the service carries on panicking and it reads as "the new build is broken" -rather than "the copy dropped a capability". If a rollback does not fix the panic, run -`sudo getcap` on the live binary before concluding anything about the build. +`/etc/systemd/system/gkapi-notary-window.service`: -## letsencrypt +```ini +[Unit] +Description=Publish the next months of the ghost key notary schedule to gkapi -Verify that certificate was automatically renewed by root cron job on vega by looking at write times -of cert files: +[Service] +Type=oneshot +ExecStart=/usr/local/sbin/publish-notary-window /var/lib/gkapi-notary-schedule /home/gkapi/delegate-keys gkapi:gkapi 1 +``` +`/etc/systemd/system/gkapi-notary-window.timer`: + +```ini +[Unit] +Description=Daily ghost key notary schedule publish + +[Timer] +OnCalendar=daily +Persistent=true + +[Install] +WantedBy=timers.target ``` -root@vega:/home/gkapi# cd /etc/letsencrypt/live/gkapi.freenet.org/ -root@vega:/etc/letsencrypt/live/gkapi.freenet.org# ls -l -total 4 --rw-r--r-- 1 root ssl-cert 692 Aug 5 00:40 README -lrwxrwxrwx 1 root root 41 Oct 4 05:31 cert.pem -> ../../archive/gkapi.freenet.org/cert2.pem -lrwxrwxrwx 1 root root 42 Oct 4 05:31 chain.pem -> ../../archive/gkapi.freenet.org/chain2.pem -lrwxrwxrwx 1 root root 46 Oct 4 05:31 fullchain.pem -> ../../archive/gkapi.freenet.org/fullchain2.pem -lrwxrwxrwx 1 root root 44 Oct 4 05:31 privkey.pem -> ../../archive/gkapi.freenet.org/privkey2.pem + +```bash +sudo systemctl daemon-reload +sudo systemctl start gkapi-notary-window.service # publish now; check its output +sudo systemctl enable --now gkapi-notary-window.timer ``` + +The publisher never replaces a published month, and it stages each month in a hidden +directory, so the API never sees a partial one. It exits non-zero if the schedule has no +directory for the current month, and warns when 12 or fewer months remain; both show in +`systemctl status gkapi-notary-window` and the journal. No gkapi restart is needed; the +files are read per request. + +**Order matters on first rollout:** deploy the binary from #192 first and let it run on the +flat files, and only then enable the timer. A binary that predates the schedule never reads +the month directories, and rolling back to one after a month is live breaks in-flight +donations (see Rollback). diff --git a/rust/api/publish_notary_window.sh b/rust/api/publish_notary_window.sh new file mode 100644 index 00000000..7172d138 --- /dev/null +++ b/rust/api/publish_notary_window.sh @@ -0,0 +1,134 @@ +#!/bin/bash +# +# Publish the next few months of a notary schedule to the API's notary +# directory. Run as root from a daily timer (see rust/api/README.md). +# +# publish_notary_window.sh [months-ahead] +# +# holds the full schedule from generate_notary_keys.sh and must +# be private to the caller, down a path nobody else can rewrite. Only months up +# to (default 1) past the current UTC month are published, so a +# compromise of the API process exposes the current and next month's notary +# keys, not the whole schedule. +# +# The API's user owns and can rename or replace anything on the +# path to it, so: +# - nothing is staged there: each month is copied, given private modes and +# handed to inside /.staging, which that user cannot +# reach; +# - is pinned once (cd -P) and afterwards addressed only as "."; +# - the month is renamed in with mv --no-copy, which fails rather than fall +# back to a copy (and its path-following syscalls) if the rename cannot be +# done in one step, e.g. across filesystems or bind mounts. +# A month that is already published is never touched: donations quoted from it +# may still be in checkout, and replacing its keys would break them after the +# charge. +# +# NOTARY_WINDOW_NOW=YYYY-MM overrides the current month, for tests. + +set -euo pipefail +unset CDPATH + +if [ $# -lt 3 ] || [ $# -gt 4 ]; then + echo "Usage: $0 [months-ahead]" >&2 + exit 1 +fi +schedule="$1" +notary="$2" +owner="$3" +ahead="${4:-1}" +if ! [[ "$ahead" =~ ^[0-9]+$ ]]; then + echo "Error: months-ahead must be a whole number" >&2 + exit 1 +fi +owner_uid=$(id -u "${owner%%:*}") + +for d in "$schedule" "$notary"; do + if [ ! -d "$d" ]; then + echo "Error: $d is not a directory" >&2 + exit 1 + fi +done + +# The schedule holds every future key, and the lock and staging live in it, so +# it must be private to the caller (root, in production), and so must every +# directory above it: otherwise its owner could swap the path out from under us. +schedule=$(cd -P -- "$schedule" && pwd) +if [ "$(stat -c '%u %a' "$schedule")" != "$(id -u) 700" ]; then + echo "Error: $schedule must be owned by $(id -un) with mode 700" >&2 + exit 1 +fi +d="$schedule" +while [ "$d" != / ]; do + d=$(dirname "$d") + read -r uid mode <<<"$(stat -c '%u %a' "$d")" + # Writable by group or others is only acceptable with the sticky bit + # (like /tmp), where nobody else can rename our entries. + if { [ "$uid" != 0 ] && [ "$uid" != "$(id -u)" ]; } || + { (((8#$mode & 8#022) != 0)) && (((8#$mode & 8#1000) == 0)); }; then + echo "Error: $d (above $schedule) is owned or writable by someone else" >&2 + exit 1 + fi +done + +cd -P -- "$notary" +if [ "$(stat -c %d .)" != "$(stat -c %d "$schedule")" ]; then + echo "Error: $notary and $schedule must be on the same filesystem, so a month can be renamed into place" >&2 + exit 1 +fi +if [ "$(stat -c %u .)" != "$owner_uid" ]; then + echo "Error: $notary is not owned by ${owner%%:*}" >&2 + exit 1 +fi + +# One run at a time: an overlapping manual run and timer run would share staging. +exec 9>"$schedule/.lock" +flock -n 9 || { echo "Error: another publish is running" >&2; exit 1; } + +staging="$schedule/.staging" +[ -d "$staging" ] || mkdir -m 700 "$staging" + +now="${NOTARY_WINDOW_NOW:-$(date -u +%Y-%m)}" +last=$(date -u -d "$now-01 +$ahead month" +%Y-%m) + +published=0 +for src in "$schedule"/[0-9][0-9][0-9][0-9]-[0-9][0-9]; do + [ -d "$src" ] || continue + month=$(basename "$src") + # YYYY-MM compares correctly as a string. + if [[ "$month" > "$last" ]] || [ -e "./$month" ] || [ -L "./$month" ]; then + continue + fi + # The generator writes only directories and regular files; anything else + # (a symlink, a device node) would be handed to as is. + if [ -n "$(find "$src" ! -type f ! -type d -print -quit)" ]; then + echo "Error: $src contains something other than files and directories" >&2 + exit 1 + fi + rm -rf "${staging:?}/$month" + # Contents only, not modes, ACLs or xattrs from wherever the schedule has + # been; staging is unreachable to , so setting modes here is safe. + cp -R --preserve=timestamps "$src" "$staging/$month" + find "$staging/$month" -type d -exec chmod 700 {} + + find "$staging/$month" -type f -exec chmod 600 {} + + chown -R "$owner" "$staging/$month" + mv --no-copy -T "$staging/$month" "./$month" + echo "published $month" + published=$((published + 1)) +done + +rmdir "$staging" 2>/dev/null || true + +remaining=$(find "$schedule" -mindepth 1 -maxdepth 1 -type d -name '[0-9][0-9][0-9][0-9]-[0-9][0-9]' -printf '%f\n' | + awk -v now="$now" '$0 >= now' | wc -l) +first=$(find "$schedule" -mindepth 1 -maxdepth 1 -type d -name '[0-9][0-9][0-9][0-9]-[0-9][0-9]' -printf '%f\n' | + sort | head -n1) +if [ -n "$first" ] && [[ "$first" > "$now" ]]; then + echo "schedule starts at $first; the API issues from the flat files until then" +elif [ ! -d "$schedule/$now" ]; then + echo "ERROR: the schedule has no $now; the API is issuing from an older month. Generate more months." >&2 + exit 1 +elif [ "$remaining" -le 12 ]; then + echo "WARNING: the schedule has $remaining months left from $now. Generate more months." >&2 +fi +echo "$published published; schedule covers $remaining months from $now" diff --git a/rust/api/src/delegates.rs b/rust/api/src/delegates.rs index 16f19b01..7c79df3a 100644 --- a/rust/api/src/delegates.rs +++ b/rust/api/src/delegates.rs @@ -98,11 +98,121 @@ fn pick_scheme(dir: &Path, amount: u64) -> NamingScheme { NamingScheme::Notary } -pub(crate) fn get_notary( +/// PaymentIntent metadata key recording which notary period a donation was +/// quoted from: `YYYY-MM`, or absent for the flat per-amount files. +/// +/// A donation's certificate is fixed when it is quoted (`/create-donation`, +/// `/update-donation`), but the ghost key is blinded and signed only after the +/// card is charged, possibly in another month. The success page blinds against +/// this quoted certificate (fetched from `/notary-certificate/{id}`), so +/// `/sign-certificate` must sign with the same pair, not whichever is current by +/// then, or the donor is charged for a signature that does not unblind. +pub(crate) const NOTARY_PERIOD_METADATA_KEY: &str = "notary_period"; + +/// A notary pair, and the period directory it came from (`None` for the flat +/// per-amount files directly under the notary directory). +pub(crate) struct Notary { + pub certificate: NotaryCertificateV1, + pub signing_key: RSASigningKey, + pub period: Option, +} + +/// `YYYY-MM` with a real month. Also what keeps a period read back from +/// metadata from naming a path outside the notary directory. +fn is_period(name: &str) -> bool { + let b = name.as_bytes(); + b.len() == 7 + && b[4] == b'-' + && b[..4].iter().all(u8::is_ascii_digit) + && b[5..].iter().all(u8::is_ascii_digit) + && matches!( + &name[5..], + "01" | "02" | "03" | "04" | "05" | "06" | "07" | "08" | "09" | "10" | "11" | "12" + ) +} + +/// The period to quote new donations from: the newest `YYYY-MM` directory that +/// is not in the future (generate_notary_keys.sh --start-month writes one per +/// month). `None` means there is no schedule yet, so use the flat files. +fn current_period(dir: &Path, now: chrono::DateTime) -> Option { + let this_month = now.format("%Y-%m").to_string(); + let newest = std::fs::read_dir(dir) + .ok()? + .filter_map(|e| e.ok()) + .filter(|e| e.path().is_dir()) + .filter_map(|e| e.file_name().into_string().ok()) + .filter(|name| is_period(name) && *name <= this_month) + .max()?; + if newest != this_month { + log::error!( + "notary schedule has no directory for {}; still issuing from {}. \ + Generate more months with generate_notary_keys.sh --start-month.", + this_month, + newest + ); + } + Some(newest) +} + +/// The notary pair to quote a new donation from. +pub(crate) fn current_notary(amount: u64) -> Result { + current_notary_in(¬ary_dir()?, amount, chrono::Utc::now()) +} + +fn current_notary_in( + dir: &Path, + amount: u64, + now: chrono::DateTime, +) -> Result { + load_notary(dir, amount, current_period(dir, now)) +} + +/// The notary pair a PaymentIntent was quoted from, given its +/// [`NOTARY_PERIOD_METADATA_KEY`] value. A PaymentIntent without one was quoted +/// from the flat files, including every one created before the schedule. +pub(crate) fn quoted_notary(amount: u64, period: Option<&str>) -> Result { + quoted_notary_in(¬ary_dir()?, amount, period) +} + +fn quoted_notary_in( + dir: &Path, + amount: u64, + period: Option<&str>, +) -> Result { + let period = match period { + None | Some("") => None, + Some(p) if is_period(p) => Some(p.to_string()), + Some(p) => { + return Err(CertificateError::KeyError(format!( + "invalid {} on PaymentIntent: {:?}", + NOTARY_PERIOD_METADATA_KEY, p + ))) + } + }; + load_notary(dir, amount, period) +} + +fn load_notary( + dir: &Path, + amount: u64, + period: Option, +) -> Result { + let (certificate, signing_key) = match &period { + Some(p) => read_pair(&dir.join(p), amount)?, + None => read_pair(dir, amount)?, + }; + Ok(Notary { + certificate, + signing_key, + period, + }) +} + +fn read_pair( + dir: &Path, amount: u64, ) -> Result<(NotaryCertificateV1, RSASigningKey), CertificateError> { - let dir = notary_dir()?; - let scheme = pick_scheme(&dir, amount); + let scheme = pick_scheme(dir, amount); let cert_path = dir.join(scheme.cert_filename(amount)); let cert = NotaryCertificateV1::from_file(&cert_path).map_err(|e| { @@ -121,15 +231,31 @@ pub(crate) fn get_notary( e )) })?; + + // A key that is not the certificate's would still produce a signature, + // just one the donor cannot unblind, and signing happens after the charge. + // Quoting reads the same pair, so refusing it here fails before any charge. + let matches = match ( + signing_key.public_key().and_then(|k| k.to_der()), + cert.payload.notary_verifying_key.to_der(), + ) { + (Ok(key), Ok(cert_key)) => key == cert_key, + _ => false, + }; + if !matches { + return Err(CertificateError::KeyError(format!( + "{} is not the signing key for {}", + signing_key_path.display(), + cert_path.display() + ))); + } Ok((cert, signing_key)) } pub(crate) fn sign_with_notary_key( blinded_ghostkey: &BlindedMessage, - amount_dollars: u64, + notary_signing_key: &RSASigningKey, ) -> Result { - let (_, notary_signing_key) = get_notary(amount_dollars)?; - let options = Options::default(); let blind_sig = notary_signing_key @@ -142,8 +268,204 @@ pub(crate) fn sign_with_notary_key( #[cfg(test)] mod tests { use super::*; + use chrono::TimeZone; use tempfile::tempdir; + fn at(year: i32, month: u32) -> chrono::DateTime { + chrono::Utc + .with_ymd_and_hms(year, month, 15, 12, 0, 0) + .unwrap() + } + + /// Write a real notary pair for `amount` into `dir`, with `info` as its + /// info string so a test can tell which pair it got back. + fn write_pair(dir: &Path, amount: u64, info: &str) { + std::fs::create_dir_all(dir).unwrap(); + let master = ed25519_dalek::SigningKey::generate(&mut OsRng); + let (cert, key) = NotaryCertificateV1::new(&master, &info.to_string()).unwrap(); + cert.to_file(&dir.join(NamingScheme::Notary.cert_filename(amount))) + .unwrap(); + key.to_file(&dir.join(NamingScheme::Notary.signing_key_filename(amount))) + .unwrap(); + } + + fn assert_pair_matches(notary: &Notary) { + assert_eq!( + notary.signing_key.public_key().unwrap().to_der().unwrap(), + notary + .certificate + .payload + .notary_verifying_key + .to_der() + .unwrap(), + "signing key does not belong to the certificate it was loaded with" + ); + } + + #[test] + fn is_period_accepts_only_real_months() { + assert!(is_period("2026-10")); + assert!(is_period("2036-12")); + for bad in [ + "2026-13", "2026-00", "2026-1", "26-10", "2026_10", "../2026", "2026-10/", "", + ] { + assert!(!is_period(bad), "{bad:?} accepted"); + } + } + + #[test] + fn current_period_is_newest_month_not_in_the_future() { + let dir = tempdir().unwrap(); + for m in ["2026-09", "2026-10", "2026-11", "notes"] { + std::fs::create_dir(dir.path().join(m)).unwrap(); + } + // A file, not a directory, must not count. + touch(&dir.path().join("2026-12")); + + assert_eq!( + current_period(dir.path(), at(2026, 10)).as_deref(), + Some("2026-10") + ); + // Schedule ran out: keep issuing from the newest month rather than fail. + assert_eq!( + current_period(dir.path(), at(2027, 3)).as_deref(), + Some("2026-11") + ); + // Only future months: no schedule in force yet, use the flat files. + assert_eq!(current_period(dir.path(), at(2026, 8)), None); + } + + #[test] + fn current_period_without_schedule_is_flat() { + let dir = tempdir().unwrap(); + assert_eq!(current_period(dir.path(), at(2026, 10)), None); + assert_eq!( + current_period(&dir.path().join("missing"), at(2026, 10)), + None + ); + } + + #[test] + fn current_notary_loads_this_months_pair() { + let dir = tempdir().unwrap(); + write_pair(dir.path(), 20, "flat"); + write_pair(&dir.path().join("2026-10"), 20, "2026-10"); + + let n = current_notary_in(dir.path(), 20, at(2026, 10)).unwrap(); + assert_eq!(n.period.as_deref(), Some("2026-10")); + assert_eq!(n.certificate.payload.info, "2026-10"); + assert_pair_matches(&n); + + let n = current_notary_in(dir.path(), 20, at(2026, 9)).unwrap(); + assert_eq!(n.period, None); + assert_eq!(n.certificate.payload.info, "flat"); + assert_pair_matches(&n); + } + + #[test] + fn current_notary_fails_rather_than_mixing_periods() { + // This month exists but lacks the tier: an error, not an older pair, + // so a broken schedule is loud and the quote never names a period + // whose files it did not come from. + let dir = tempdir().unwrap(); + write_pair(&dir.path().join("2026-09"), 20, "2026-09"); + std::fs::create_dir(dir.path().join("2026-10")).unwrap(); + assert!(current_notary_in(dir.path(), 20, at(2026, 10)).is_err()); + } + + #[test] + fn quoted_notary_uses_the_recorded_period_whatever_the_date() { + // Quoted in October, signed after the schedule has moved on: must + // still be October's pair, the one the browser blinded against. + let dir = tempdir().unwrap(); + write_pair(dir.path(), 5, "flat"); + write_pair(&dir.path().join("2026-10"), 5, "2026-10"); + write_pair(&dir.path().join("2026-11"), 5, "2026-11"); + + let n = quoted_notary_in(dir.path(), 5, Some("2026-10")).unwrap(); + assert_eq!(n.certificate.payload.info, "2026-10"); + assert_pair_matches(&n); + + // No period recorded (quoted from the flat files, or before the + // schedule existed): the flat pair, even though months exist. + for none in [None, Some("")] { + let n = quoted_notary_in(dir.path(), 5, none).unwrap(); + assert_eq!(n.certificate.payload.info, "flat"); + assert_pair_matches(&n); + } + } + + #[test] + fn quote_then_sign_after_rollover_uses_the_same_pair() { + // The protocol invariant end to end: the period current_notary records + // at quote time, fed back to quoted_notary after the month has turned, + // yields the very certificate the donor was quoted. + let dir = tempdir().unwrap(); + write_pair(&dir.path().join("2026-10"), 5, "2026-10"); + write_pair(&dir.path().join("2026-11"), 5, "2026-11"); + + let last_second = chrono::Utc + .with_ymd_and_hms(2026, 10, 31, 23, 59, 59) + .unwrap(); + let quoted = current_notary_in(dir.path(), 5, last_second).unwrap(); + assert_eq!(quoted.period.as_deref(), Some("2026-10")); + + let first_second = chrono::Utc.with_ymd_and_hms(2026, 11, 1, 0, 0, 0).unwrap(); + assert_eq!( + current_notary_in(dir.path(), 5, first_second) + .unwrap() + .period + .as_deref(), + Some("2026-11") + ); + + let signed = quoted_notary_in(dir.path(), 5, quoted.period.as_deref()).unwrap(); + assert_eq!( + signed.certificate.to_base64().unwrap(), + quoted.certificate.to_base64().unwrap() + ); + assert_pair_matches(&signed); + } + + #[test] + fn a_signing_key_that_is_not_the_certificates_is_refused() { + let dir = tempdir().unwrap(); + write_pair(&dir.path().join("a"), 5, "a"); + write_pair(&dir.path().join("b"), 5, "b"); + write_pair(&dir.path().join("2026-10"), 5, "2026-10"); + // Pair a's certificate with b's key in the flat files and in a month. + for target in [dir.path().to_path_buf(), dir.path().join("2026-10")] { + std::fs::copy( + dir.path() + .join("a") + .join(NamingScheme::Notary.cert_filename(5)), + target.join(NamingScheme::Notary.cert_filename(5)), + ) + .unwrap(); + std::fs::copy( + dir.path() + .join("b") + .join(NamingScheme::Notary.signing_key_filename(5)), + target.join(NamingScheme::Notary.signing_key_filename(5)), + ) + .unwrap(); + } + // Refused when quoting (before any charge) and when signing. + assert!(current_notary_in(dir.path(), 5, at(2026, 10)).is_err()); + assert!(current_notary_in(dir.path(), 5, at(2026, 9)).is_err()); + assert!(quoted_notary_in(dir.path(), 5, Some("2026-10")).is_err()); + } + + #[test] + fn quoted_notary_rejects_bad_or_missing_periods() { + let dir = tempdir().unwrap(); + write_pair(dir.path(), 5, "flat"); + assert!(quoted_notary_in(dir.path(), 5, Some("../5")).is_err()); + assert!(quoted_notary_in(dir.path(), 5, Some("2026-13")).is_err()); + // Well-formed but never generated or since deleted. + assert!(quoted_notary_in(dir.path(), 5, Some("2026-10")).is_err()); + } + fn touch(path: &Path) { std::fs::write(path, b"placeholder").unwrap(); } diff --git a/rust/api/src/errors.rs b/rust/api/src/errors.rs index 39ccc5b4..47e88d1d 100644 --- a/rust/api/src/errors.rs +++ b/rust/api/src/errors.rs @@ -7,6 +7,9 @@ pub enum CertificateError { PaymentNotSuccessful, PaymentMethodMissing, CertificateAlreadySigned, + /// The client blinded against a different notary certificate than the one + /// its PaymentIntent was quoted, so a signature would not unblind. + NotaryMismatch, /// The request's PaymentIntent id is not well-formed, or the PaymentIntent /// Stripe returned is not the one requested. InvalidPaymentIntentId, @@ -24,6 +27,9 @@ impl std::fmt::Display for CertificateError { CertificateError::PaymentNotSuccessful => write!(f, "Payment not successful"), CertificateError::PaymentMethodMissing => write!(f, "Payment method is missing"), CertificateError::CertificateAlreadySigned => write!(f, "Certificate already signed"), + CertificateError::NotaryMismatch => { + write!(f, "Notary certificate does not match this payment") + } CertificateError::InvalidPaymentIntentId => write!(f, "Invalid payment reference"), CertificateError::Base64Error(e) => write!(f, "Base64 decoding error: {}", e), CertificateError::KeyError(e) => write!(f, "Key error: {}", e), diff --git a/rust/api/src/handle_sign_cert.rs b/rust/api/src/handle_sign_cert.rs index a9d61717..f792780d 100644 --- a/rust/api/src/handle_sign_cert.rs +++ b/rust/api/src/handle_sign_cert.rs @@ -6,14 +6,19 @@ use serde::{Deserialize, Serialize}; use stripe::{Client, PaymentIntent, PaymentIntentStatus}; use ghostkey_lib::armorable::Armorable; +use ghostkey_lib::notary_certificate::NotaryCertificateV1; -use crate::delegates::sign_with_notary_key; +use crate::delegates::{quoted_notary, sign_with_notary_key, Notary, NOTARY_PERIOD_METADATA_KEY}; pub use crate::errors::CertificateError; #[derive(Debug, Deserialize)] pub struct SignCertificateRequest { payment_intent_id: String, blinded_ghost_key_base64: String, + /// The notary certificate the client blinded against. Optional because + /// browser JS cached from before this field existed does not send it. + #[serde(default)] + notary_certificate_base64: Option, } /// HTTP response for successful certificate signing. @@ -124,6 +129,30 @@ pub async fn sign_certificate( CertificateError::MiscError(e.to_string()) })?; + let amount_cents = pi.amount as u64; + let amount_dollars = amount_cents / 100; + + // Sign with the pair this donation was quoted from (see + // NOTARY_PERIOD_METADATA_KEY), not whichever is current now. Loaded before + // marking the PaymentIntent spent, so a missing key cannot consume it. + let notary = quoted_notary( + amount_dollars, + pi.metadata + .get(NOTARY_PERIOD_METADATA_KEY) + .map(String::as_str), + )?; + + // Refuse a mismatch now, while the donation can still be retried. Signing + // anyway would spend it on a signature that cannot unblind. + if !client_blinded_against(request.notary_certificate_base64.as_deref(), ¬ary)? { + log::error!( + "PaymentIntent {} was blinded against a different or unstated notary \ + certificate than it was quoted; refusing before marking it spent", + pi.id + ); + return Err(CertificateError::NotaryMismatch); + } + // Mark the payment intent as used for certificate signing let mut metadata = HashMap::new(); metadata.insert("certificate_signed".to_string(), "true".to_string()); @@ -136,10 +165,7 @@ pub async fn sign_certificate( // Sign the certificate log::info!("Payment intent verified successfully"); - let amount_cents = pi.amount as u64; - let amount_dollars = amount_cents / 100; - - match sign_marked_payment(&blinded_ghostkey, amount_dollars, amount_cents) { + match sign_marked_payment(&blinded_ghostkey, ¬ary, amount_cents) { Ok(response) => Ok(response), Err(e) => { // The PaymentIntent is marked spent but no certificate came out of @@ -153,6 +179,39 @@ pub async fn sign_certificate( } } +/// Whether the client's blinded key can be signed with `notary`, given the +/// certificate the client says it blinded against. +/// +/// A client that does not say is success-page JS cached from before this check, +/// which blinds against shared localStorage. That is tolerated for a flat-file +/// quote (one certificate per tier, as it always was), but a scheduled quote +/// rotates, so such a client must reload first. +fn client_blinded_against( + client_cert: Option<&str>, + notary: &Notary, +) -> Result { + match client_cert { + Some(cert) => same_notary(cert, notary), + None => Ok(notary.period.is_none()), + } +} + +/// Whether `client_cert` (base64, as the API handed it out) is `notary`'s +/// certificate. Compares the notary verifying key, which is what the client +/// blinds against. Something that does not parse is not it: the answer is the +/// same retryable refusal as any other mismatch. +fn same_notary(client_cert: &str, notary: &Notary) -> Result { + let Ok(client_cert) = NotaryCertificateV1::from_base64(client_cert) else { + return Ok(false); + }; + let der = |k: &blind_rsa_signatures::PublicKey| { + k.to_der() + .map_err(|e| CertificateError::MiscError(e.to_string())) + }; + Ok(der(&client_cert.payload.notary_verifying_key)? + == der(¬ary.certificate.payload.notary_verifying_key)?) +} + /// Produce the signed certificate for a PaymentIntent that has already been /// marked as spent. /// @@ -160,17 +219,17 @@ pub async fn sign_certificate( /// validation steps and undo the mark for exactly that case. fn sign_marked_payment( blinded_ghostkey: &BlindedMessage, - amount_dollars: u64, + notary: &Notary, amount_cents: u64, ) -> Result { - let blind_signature = sign_with_notary_key(blinded_ghostkey, amount_dollars).map_err(|e| { - log::error!("Error in sign_with_notary_key: {:?}", e); - e - })?; - - let (notary_certificate, _) = crate::delegates::get_notary(amount_dollars)?; + let blind_signature = + sign_with_notary_key(blinded_ghostkey, ¬ary.signing_key).map_err(|e| { + log::error!("Error in sign_with_notary_key: {:?}", e); + e + })?; - let cert_base64 = notary_certificate + let cert_base64 = notary + .certificate .to_base64() .map_err(|e| CertificateError::MiscError(e.to_string()))?; @@ -237,6 +296,31 @@ mod tests { squeeze(production) } + /// A missing notary pair or a mismatched client certificate has to fail + /// before the payment is marked, while the donor can still retry. + #[test] + fn notary_is_loaded_and_checked_before_the_payment_is_marked_spent() { + let source = production_source(); + + let load_at = source + .find(&squeeze("let notary = quoted_notary(")) + .expect("sign_certificate no longer loads the quoted notary"); + let check_at = source + .find(&squeeze("return Err(CertificateError::NotaryMismatch);")) + .expect("sign_certificate no longer refuses a notary mismatch"); + let mark_at = source + .find(&squeeze( + r#"metadata.insert("certificate_signed".to_string(), "true".to_string());"#, + )) + .expect("the certificate_signed mark has moved or been renamed"); + + assert!( + load_at < mark_at && check_at < mark_at, + "a missing notary pair or a mismatched client certificate must fail \ + BEFORE certificate_signed is set, while the donor can still retry" + ); + } + /// The claim has to be taken before the flag is read, not after. Taking it /// afterwards leaves exactly the read-check-write window it exists to /// close, and nothing else in the test suite would notice: the happy path @@ -337,6 +421,7 @@ mod tests { let result = super::sign_certificate(super::SignCertificateRequest { payment_intent_id: id.to_string(), blinded_ghost_key_base64: String::new(), + notary_certificate_base64: None, }) .await; assert!( @@ -358,4 +443,46 @@ mod tests { transient failure burns the donation" ); } + + fn notary(info: &str) -> super::Notary { + let master = ed25519_dalek::SigningKey::generate(&mut rand_core::OsRng); + let (certificate, signing_key) = + super::NotaryCertificateV1::new(&master, &info.to_string()).unwrap(); + super::Notary { + certificate, + signing_key, + period: None, + } + } + + #[test] + fn same_notary_compares_the_key_the_client_blinded_against() { + use super::Armorable; + let quoted = notary("quoted"); + let other = notary("other"); + let quoted_b64 = quoted.certificate.to_base64().unwrap(); + let other_b64 = other.certificate.to_base64().unwrap(); + + assert!(super::same_notary("ed_b64, "ed).unwrap()); + assert!(!super::same_notary(&other_b64, "ed).unwrap()); + assert!(!super::same_notary("not a certificate", "ed).unwrap()); + } + + #[test] + fn unstated_certificate_is_only_tolerated_for_flat_quotes() { + use super::Armorable; + let flat = notary("flat"); + let mut scheduled = notary("2026-10"); + scheduled.period = Some("2026-10".to_string()); + let scheduled_b64 = scheduled.certificate.to_base64().unwrap(); + let flat_b64 = flat.certificate.to_base64().unwrap(); + + // Old cached JS sends nothing: fine on a flat quote, refused (before + // the mark, so retryable) on a scheduled one. + assert!(super::client_blinded_against(None, &flat).unwrap()); + assert!(!super::client_blinded_against(None, &scheduled).unwrap()); + // New JS states it: accepted exactly when it matches. + assert!(super::client_blinded_against(Some(&scheduled_b64), &scheduled).unwrap()); + assert!(!super::client_blinded_against(Some(&flat_b64), &scheduled).unwrap()); + } } diff --git a/rust/api/src/main.rs b/rust/api/src/main.rs index 5fd36266..ae61bf50 100644 --- a/rust/api/src/main.rs +++ b/rust/api/src/main.rs @@ -230,8 +230,9 @@ async fn main() { .env("NOTARY_DIR") .value_name("DIR") .help( - "Directory containing per-amount notary certificates and signing keys. \ - Falls back to the NOTARY_DIR env var and then to the legacy \ + "Directory containing per-amount notary certificates and signing keys, \ + optionally with a monthly schedule of YYYY-MM/ subdirectories \ + (see rust/api/README.md). Falls back to the NOTARY_DIR env var and then to the legacy \ DELEGATE_DIR env var for backward compatibility.", ) .required(true), diff --git a/rust/api/src/routes.rs b/rust/api/src/routes.rs index 7ece1fac..c245e89c 100644 --- a/rust/api/src/routes.rs +++ b/rust/api/src/routes.rs @@ -18,7 +18,7 @@ use log::{error, info, warn}; use serde::{Deserialize, Serialize}; use stripe::{Client, Currency, PaymentIntent, PaymentIntentId}; -use crate::delegates::get_notary; +use crate::delegates::{current_notary, quoted_notary, NOTARY_PERIOD_METADATA_KEY}; use crate::handle_sign_cert::{ sign_certificate, CertificateError, SignCertificateRequest, SignCertificateResponse, }; @@ -152,6 +152,12 @@ async fn sign_certificate_route( status: StatusCode::BAD_REQUEST.as_u16(), }))) }, + CertificateError::NotaryMismatch => { + Err((StatusCode::CONFLICT, Json(ErrorResponse { + error: "This page prepared your key against a different notary certificate than your payment was quoted. Reload the page to try again; your donation has not been used.".to_string(), + status: StatusCode::CONFLICT.as_u16(), + }))) + }, CertificateError::InvalidPaymentIntentId => { Err((StatusCode::BAD_REQUEST, Json(ErrorResponse { error: "Invalid payment reference".to_string(), @@ -164,9 +170,11 @@ async fn sign_certificate_route( status: StatusCode::CONFLICT.as_u16(), }))) }, - CertificateError::KeyError(msg) => { + CertificateError::KeyError(_) => { + // The detail (server paths, metadata values) is logged + // above; the client only needs to know it was ours. Err((StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { - error: format!("Key error: {}", msg), + error: "The server could not load its signing key. Your donation has not been used; please try again later.".to_string(), status: StatusCode::INTERNAL_SERVER_ERROR.as_u16(), }))) }, @@ -185,6 +193,7 @@ async fn sign_certificate_route( pub enum DonationError { /// The request's PaymentIntent id is not well-formed. InvalidPaymentIntentId, + NotFound(&'static str), StripeError(stripe::StripeError), EnvError(std::env::VarError), OtherError(String), @@ -196,6 +205,7 @@ impl IntoResponse for DonationError { DonationError::InvalidPaymentIntentId => { (StatusCode::BAD_REQUEST, "Invalid payment reference") } + DonationError::NotFound(what) => (StatusCode::NOT_FOUND, what), DonationError::StripeError(e) => { error!("Stripe error: {:?}", e); (StatusCode::INTERNAL_SERVER_ERROR, "Stripe error occurred") @@ -235,8 +245,20 @@ async fn create_donation( let currency = Currency::USD; + let amount_dollars = request.amount / 100; + + // Quote the current notary and record which one on the PaymentIntent, so + // /sign-certificate signs with the pair this certificate belongs to. + let notary = current_notary(amount_dollars as u64).map_err(|e| { + error!("Error getting notary: {:?}", e); + DonationError::OtherError("Error getting notary".to_string()) + })?; + let mut metadata = HashMap::new(); metadata.insert("donation_type".to_string(), "freenet".to_string()); + if let Some(period) = ¬ary.period { + metadata.insert(NOTARY_PERIOD_METADATA_KEY.to_string(), period.clone()); + } let params = stripe::CreatePaymentIntent { amount: request.amount, @@ -284,14 +306,7 @@ async fn create_donation( info!("Payment intent created successfully"); - let amount_dollars = request.amount / 100; - - let (notary_certificate, _) = get_notary(amount_dollars as u64).map_err(|e| { - error!("Error getting notary: {:?}", e); - DonationError::OtherError("Error getting notary".to_string()) - })?; - - let cert_base64 = notary_certificate.to_base64().unwrap(); + let cert_base64 = notary.certificate.to_base64().unwrap(); match intent.client_secret { Some(secret) => Ok(Json(DonationResponse { @@ -330,8 +345,25 @@ async fn update_donation( let payment_intent_id = PaymentIntentId::from_str(&request.payment_intent_id) .map_err(|_| DonationError::InvalidPaymentIntentId)?; + + let amount_dollars = request.amount / 100; + + // The browser replaces its stored certificate with the one returned here, + // so re-quote and re-record the period in the same update as the amount. + // An empty value deletes the key, i.e. the flat files. + let notary = current_notary(amount_dollars as u64).map_err(|e| { + error!("Error getting notary: {:?}", e); + DonationError::OtherError("Error getting notary".to_string()) + })?; + let mut metadata = HashMap::new(); + metadata.insert( + NOTARY_PERIOD_METADATA_KEY.to_string(), + notary.period.clone().unwrap_or_default(), + ); + let params = stripe::UpdatePaymentIntent { amount: Some(request.amount), + metadata: Some(metadata), ..Default::default() }; @@ -341,14 +373,7 @@ async fn update_donation( info!("Payment intent updated successfully"); - let amount_dollars = request.amount / 100; - - let (notary_certificate, _) = get_notary(amount_dollars as u64).map_err(|e| { - error!("Error getting notary: {:?}", e); - DonationError::OtherError("Error getting notary".to_string()) - })?; - - let cert_base64 = notary_certificate.to_base64().unwrap(); + let cert_base64 = notary.certificate.to_base64().unwrap(); Ok(Json(DonationResponse { client_secret: updated_intent.client_secret.unwrap_or_default(), @@ -392,6 +417,57 @@ async fn check_payment_status_route( } } +#[derive(Serialize)] +pub struct NotaryCertificateResponse { + pub notary_certificate_base64: String, +} + +/// The notary certificate a PaymentIntent was quoted, which is the one the +/// donor must blind against. The success page asks for it instead of trusting +/// localStorage, which every tab shares and which may hold a later quote, from +/// another checkout, for a different month's key. +async fn notary_certificate_route( + Path(payment_intent_id): Path, +) -> Result, DonationError> { + // Accept only well-formed PaymentIntent ids, before any Stripe call. A + // malformed or unknown id is the caller's problem, not a server error, and + // this endpoint is unauthenticated: 404, not an error log. + if !is_payment_intent_id(&payment_intent_id) { + return Err(DonationError::NotFound("Payment not found")); + } + + let secret_key = std::env::var("STRIPE_SECRET_KEY").map_err(DonationError::EnvError)?; + let client = Client::new(&secret_key); + let payment_intent_id = PaymentIntentId::from_str(&payment_intent_id) + .map_err(|_| DonationError::NotFound("Payment not found"))?; + // Only Stripe saying "no such PaymentIntent" is a 404; an outage or a bad + // key stays a logged server error. + let intent = match stripe::PaymentIntent::retrieve(&client, &payment_intent_id, &[]).await { + Ok(intent) => intent, + Err(stripe::StripeError::Stripe(e)) if e.http_status == 404 => { + warn!("notary-certificate: no PaymentIntent {}", payment_intent_id); + return Err(DonationError::NotFound("Payment not found")); + } + Err(e) => return Err(DonationError::StripeError(e)), + }; + + let notary = quoted_notary( + (intent.amount / 100) as u64, + intent + .metadata + .get(NOTARY_PERIOD_METADATA_KEY) + .map(String::as_str), + ) + .map_err(|e| { + error!("Error getting quoted notary for {}: {:?}", intent.id, e); + DonationError::OtherError("Error getting notary".to_string()) + })?; + + Ok(Json(NotaryCertificateResponse { + notary_certificate_base64: notary.certificate.to_base64().unwrap(), + })) +} + // ============================================================================ // River Room Invite Endpoint // ============================================================================ @@ -694,6 +770,10 @@ pub fn get_routes() -> Router { "/check-payment-status/:payment_intent_id", get(check_payment_status_route), ) + .route( + "/notary-certificate/:payment_intent_id", + get(notary_certificate_route), + ) .layer(CorsLayer::permissive()) } diff --git a/rust/cli/generate_notary_keys.sh b/rust/cli/generate_notary_keys.sh index 0d5cb57f..37e13baf 100755 --- a/rust/cli/generate_notary_keys.sh +++ b/rust/cli/generate_notary_keys.sh @@ -3,26 +3,76 @@ # Generate a set of per-amount notary certificates and signing keys for the # Freenet donation API. # +# Two modes: +# +# Single set (default): one keypair per amount, dated now, written flat into +# --notary-dir as notary_{certificate,signing_key}_{amount}.pem. +# +# Schedule (--start-month YYYY-MM --months N): one complete set per calendar +# month, written to --notary-dir/YYYY-MM/. This lets the master key stay +# offline for years: generate the schedule once, and the API picks the +# current month's directory (see rust/api/README.md for installing it). +# +# In a schedule, --amounts tiers are dated monthly (the 1st of the month, +# 00:00:00 UTC) and --quarterly-amounts tiers are dated quarterly (1 January, +# April, July or October): one keypair per quarter, shared by that quarter's +# three month directories. The date is visible to anyone who verifies a ghost +# key, so it partitions each tier's anonymity set. Only tiers with plenty of +# donors per month can afford a monthly date; on a tier with one or two +# donors a month, the date would let whoever holds the payment records link a +# ghost key to its donor. Quarterly rather than yearly keeps the date useful +# to an application that wants a recent key: while the schedule covers the +# current month, a key is never shown more than three months older than it +# is. +# +# A schedule run only ever adds months. An existing month is an error (it may +# be live, and replacing its keys would break donations quoted from it), and +# a quarter that already has a month in --notary-dir reuses that month's +# quarterly keypairs instead of minting a second set, so a schedule can be +# extended later without splitting a quarter's anonymity set. Each month is built in a +# hidden directory and renamed into place only when complete. +# +# Every generated pair is checked before the script moves on: the certificate +# must verify against the master verifying key (by default the Freenet master +# key compiled into the CLI, so a real run also proves the master signing key +# is the real one), its info string must be exactly what was requested, and a +# ghost key issued from the pair must verify end to end, which proves the +# signing key belongs to the certificate. +# # Renamed from generate_delegate_keys.sh in 0.2.0 (issue freenet/web#24). # The old name is preserved as a stub that execs this script with a # deprecation warning. The --delegate-dir flag is accepted as a legacy # alias for --notary-dir. -set -e +set -euo pipefail -# Default values -DEFAULT_AMOUNTS=(1 5 20 50 100) +# Together these must cover the tiers in +# hugo-site/themes/freenet/layouts/shortcodes/stripe-donation-form.html +DEFAULT_AMOUNTS=(1 5 20 50 100 500 2500 10000) +DEFAULT_SCHEDULE_MONTHLY_AMOUNTS=(1 5) +DEFAULT_SCHEDULE_QUARTERLY_AMOUNTS=(20 50 100 500 2500 10000) TODAYS_DATE=$(date +%Y%m%d) DEFAULT_NOTARY_DIR="$HOME/code/freenet/keys/mnt/ghostkey-${TODAYS_DATE}/notaries" OVERWRITE=false usage() { - echo "Usage: $0 --master-key [--notary-dir ] [--amounts ...] [--overwrite]" >&2 + echo "Usage: $0 --master-key [--notary-dir ]" >&2 + echo " [--amounts ...] [--overwrite]" >&2 + echo " [--start-month YYYY-MM --months N [--quarterly-amounts ...]]" >&2 + echo " [--master-verifying-key ]" >&2 exit 1 } MASTER_KEY_FILE="" +MASTER_VERIFYING_KEY_FILE="" NOTARY_DIR="$DEFAULT_NOTARY_DIR" +NOTARY_DIR_SET=false +AMOUNTS=() +AMOUNTS_SET=false +QUARTERLY_AMOUNTS=() +QUARTERLY_AMOUNTS_SET=false +START_MONTH="" +MONTHS="" while [ $# -gt 0 ]; do case "$1" in @@ -30,23 +80,45 @@ while [ $# -gt 0 ]; do MASTER_KEY_FILE="$2" shift 2 ;; + --master-verifying-key) + MASTER_VERIFYING_KEY_FILE="$2" + shift 2 + ;; --notary-dir) NOTARY_DIR="$2" + NOTARY_DIR_SET=true shift 2 ;; --delegate-dir) echo "warning: --delegate-dir is deprecated, use --notary-dir (freenet/web#24)" >&2 NOTARY_DIR="$2" + NOTARY_DIR_SET=true shift 2 ;; --amounts) shift - AMOUNTS=() + AMOUNTS_SET=true while [[ $# -gt 0 && ! "$1" =~ ^-- ]]; do AMOUNTS+=("$1") shift done ;; + --quarterly-amounts) + shift + QUARTERLY_AMOUNTS_SET=true + while [[ $# -gt 0 && ! "$1" =~ ^-- ]]; do + QUARTERLY_AMOUNTS+=("$1") + shift + done + ;; + --start-month) + START_MONTH="$2" + shift 2 + ;; + --months) + MONTHS="$2" + shift 2 + ;; --overwrite) OVERWRITE=true shift @@ -63,62 +135,255 @@ if [ -z "$MASTER_KEY_FILE" ]; then usage fi -if [ ${#AMOUNTS[@]} -eq 0 ]; then - AMOUNTS=("${DEFAULT_AMOUNTS[@]}") -fi - if [ ! -f "$MASTER_KEY_FILE" ]; then echo "Error: Master signing key file not found: $MASTER_KEY_FILE" >&2 exit 1 fi +SCHEDULE=false +if [ -n "$START_MONTH" ] || [ -n "$MONTHS" ]; then + SCHEDULE=true + if ! [[ "$START_MONTH" =~ ^[0-9]{4}-(0[1-9]|1[0-2])$ ]] || ! [[ "$MONTHS" =~ ^[1-9][0-9]*$ ]]; then + echo "Error: --start-month YYYY-MM and --months N (N >= 1) must be given together." >&2 + usage + fi + # The default directory is named after today's date, so a later extension + # run would start an empty schedule and mint a second set of quarterly keys. + if [ "$NOTARY_DIR_SET" = false ]; then + echo "Error: a schedule needs an explicit --notary-dir (the same one every run)." >&2 + exit 1 + fi + if [ "$OVERWRITE" = true ]; then + echo "Error: --overwrite is not supported with a schedule; months are only ever added." >&2 + exit 1 + fi + if [ "$AMOUNTS_SET" = false ]; then + AMOUNTS=("${DEFAULT_SCHEDULE_MONTHLY_AMOUNTS[@]}") + fi + if [ "$QUARTERLY_AMOUNTS_SET" = false ]; then + QUARTERLY_AMOUNTS=("${DEFAULT_SCHEDULE_QUARTERLY_AMOUNTS[@]}") + fi +else + if [ "$QUARTERLY_AMOUNTS_SET" = true ]; then + echo "Error: --quarterly-amounts only applies with --start-month/--months." >&2 + usage + fi + if [ "$AMOUNTS_SET" = false ]; then + AMOUNTS=("${DEFAULT_AMOUNTS[@]}") + fi +fi + +# The API looks pairs up by the amount formatted as an integer, and the amount +# is interpolated into the certificate's JSON, so only plain positive integers. +ALL_AMOUNTS=(${AMOUNTS[@]+"${AMOUNTS[@]}"} ${QUARTERLY_AMOUNTS[@]+"${QUARTERLY_AMOUNTS[@]}"}) +if [ ${#ALL_AMOUNTS[@]} -eq 0 ]; then + echo "Error: no amounts to generate." >&2 + exit 1 +fi +for a in "${ALL_AMOUNTS[@]}"; do + if ! [[ "$a" =~ ^[1-9][0-9]*$ ]]; then + echo "Error: invalid amount '$a' (whole dollars, no leading zeros)." >&2 + exit 1 + fi +done +if [ "$(printf '%s\n' "${ALL_AMOUNTS[@]}" | sort | uniq -d)" != "" ]; then + echo "Error: an amount is listed more than once (across --amounts and --quarterly-amounts)." >&2 + exit 1 +fi + +VERIFY_ARGS=() +if [ -n "$MASTER_VERIFYING_KEY_FILE" ]; then + VERIFY_ARGS=(--master-verifying-key "$MASTER_VERIFYING_KEY_FILE") +fi + +# Build once and call the binary directly: a schedule is thousands of calls. +# The first build shows any compiler errors; the second is a no-op that reports +# the binary's path. +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cargo build --release --quiet --manifest-path "$script_dir/Cargo.toml" --bin ghostkey +GHOSTKEY=$(cargo build --release --quiet --manifest-path "$script_dir/Cargo.toml" --bin ghostkey \ + --message-format=json | jq -r 'select(.executable != null) | .executable') +if [ ! -x "$GHOSTKEY" ]; then + echo "Error: failed to build the ghostkey CLI" >&2 + exit 1 +fi +export NO_COLOR=1 + +ghostkey_verify() { + "$GHOSTKEY" "$1" ${VERIFY_ARGS[@]+"${VERIFY_ARGS[@]}"} "${@:2}" +} + mkdir -p "$NOTARY_DIR" chmod 700 "$NOTARY_DIR" -for amount in "${AMOUNTS[@]}"; do - current_date=$(date -u +"%Y-%m-%d %H:%M:%S") - # NOTE: the JSON key "delegate-key-created" is baked into the cert `info` - # field of every donation ever minted and is parsed by River's UI. DO NOT - # rename it or we lose backward compatibility with every historical ghost - # key in the wild. See freenet/web#24. - info="{\"action\":\"freenet-donation\",\"amount\":$amount,\"delegate-key-created\":\"$current_date\"}" +# A schedule keeps one dating policy for its whole life. Moving a tier between +# monthly and quarterly part-way would split that tier's anonymity set, so the +# first run records the policy and later runs must match it. +if [ "$SCHEDULE" = true ]; then + sorted() { printf '%s\n' "$@" | sort -n | tr '\n' ' '; } + policy="monthly: $(sorted ${AMOUNTS[@]+"${AMOUNTS[@]}"})| quarterly: $(sorted ${QUARTERLY_AMOUNTS[@]+"${QUARTERLY_AMOUNTS[@]}"})" + policy_file="$NOTARY_DIR/.schedule-policy" + if [ -f "$policy_file" ] && [ "$(cat "$policy_file")" != "$policy" ]; then + echo "Error: this schedule was generated with policy '$(cat "$policy_file")';" >&2 + echo " this run asks for '$policy'. Pass the same --amounts/--quarterly-amounts." >&2 + exit 1 + fi + # Recorded once the first month is in place (below), so a run that fails + # outright does not pin whatever it was given. +fi - signing_key_file="$NOTARY_DIR/notary_signing_key_$amount.pem" - cert_file="$NOTARY_DIR/notary_certificate_$amount.pem" +# Holds copies of signing keys during the pair check, so keep it inside the +# (protected) output directory rather than /tmp. +scratch=$(mktemp -d -p "$NOTARY_DIR" .verify.XXXXXX) +partial="" +trap 'rm -rf "$scratch" ${partial:+"$partial"}' EXIT - if [ -f "$signing_key_file" ] || [ -f "$cert_file" ]; then +info_for() { + local amount="$1" created="$2" + # NOTE: the JSON key "delegate-key-created" is baked into the cert `info` + # field of every donation ever minted and is parsed by the ghostkeys Vault + # UI (and Harvest) as "YYYY-MM-DD HH:MM:SS". DO NOT rename it or we lose + # backward compatibility with every historical ghost key in the wild. + # See freenet/web#24. + echo "{\"action\":\"freenet-donation\",\"amount\":$amount,\"delegate-key-created\":\"$created\"}" +} + +# Refuse to clobber an existing pair unless --overwrite. +check_target() { + local dir="$1" amount="$2" + if [ -f "$dir/notary_signing_key_$amount.pem" ] || [ -f "$dir/notary_certificate_$amount.pem" ]; then if [ "$OVERWRITE" = false ]; then - echo "Error: Output files already exist for amount $amount. Use --overwrite to replace." >&2 + echo "Error: Output files already exist for amount $amount in $dir. Use --overwrite to replace." >&2 exit 1 fi fi +} - script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - if ! cargo run --quiet --manifest-path "$script_dir/Cargo.toml" -- generate-notary \ - --master-signing-key "$MASTER_KEY_FILE" \ - --info "$info" \ - --output-dir "$NOTARY_DIR" \ - --ignore-permissions >/dev/null 2>&1; then - echo "Error: Failed to generate notary key for amount $amount" >&2 +# Fail unless verifies against the master key with exactly . +verify_cert() { + local cert="$1" info="$2" verified + if ! verified=$(ghostkey_verify verify-notary --notary-certificate "$cert" 2>&1); then + echo "Error: $cert does not verify against the master verifying key:" >&2 + echo "$verified" >&2 exit 1 fi - - # generate-notary writes unsuffixed canonical filenames; rename them to - # the per-amount names the API expects. - if [ ! -f "$NOTARY_DIR/notary_signing_key.pem" ] || [ ! -f "$NOTARY_DIR/notary_certificate.pem" ]; then - echo "Error: Expected files were not generated for amount $amount" >&2 + if ! grep -qxF "Info: $info" <<<"$verified"; then + echo "Error: $cert has unexpected info (wanted $info):" >&2 + echo "$verified" >&2 exit 1 fi +} - mv "$NOTARY_DIR/notary_signing_key.pem" "$signing_key_file" || { - echo "Error: Failed to rename notary signing key for amount $amount" >&2 +# Fail unless a ghost key issued from the pair in (canonical +# notary_{certificate,signing_key}.pem names) verifies end to end, which proves +# the signing key belongs to the certificate. +verify_pair() { + local dir="$1" what="$2" + rm -rf "$scratch/ghost" + if ! "$GHOSTKEY" generate-ghost-key --notary-dir "$dir" \ + --output-dir "$scratch/ghost" >/dev/null 2>&1 \ + || ! ghostkey_verify verify-ghost-key \ + --ghost-certificate "$scratch/ghost/ghost_key_certificate.pem" >/dev/null 2>&1; then + echo "Error: a ghost key issued by $what does not verify" >&2 exit 1 - } - mv "$NOTARY_DIR/notary_certificate.pem" "$cert_file" || { - echo "Error: Failed to rename notary certificate for amount $amount" >&2 + fi +} + +# generate_pair +generate_pair() { + local dir="$1" amount="$2" created="$3" + local info + info=$(info_for "$amount" "$created") + + check_target "$dir" "$amount" + + rm -rf "$scratch/notary" "$scratch/ghost" + if ! "$GHOSTKEY" generate-notary \ + --master-signing-key "$MASTER_KEY_FILE" \ + --info "$info" \ + --output-dir "$scratch/notary" \ + --ignore-permissions >/dev/null 2>&1; then + echo "Error: Failed to generate notary key for amount $amount ($dir)" >&2 exit 1 - } + fi - chmod 600 "$signing_key_file" - chmod 600 "$cert_file" -done + verify_cert "$scratch/notary/notary_certificate.pem" "$info" + + verify_pair "$scratch/notary" "the new notary for amount $amount" + + mv "$scratch/notary/notary_signing_key.pem" "$dir/notary_signing_key_$amount.pem" + mv "$scratch/notary/notary_certificate.pem" "$dir/notary_certificate_$amount.pem" + chmod 600 "$dir/notary_signing_key_$amount.pem" "$dir/notary_certificate_$amount.pem" +} + +# The first month (YYYY-MM) of the quarter is in. +quarter_start() { + local month="$1" m + m=$((10#${month#*-})) + printf '%s-%02d' "${month%-*}" $(((m - 1) / 3 * 3 + 1)) +} + +# The completed month directory of the quarter starting already +# holding the quarterly pair for , if any. Verified, so a month +# generated under a different policy (say, this amount dated monthly) is an +# error rather than silently shared. +existing_quarterly_dir() { + local qstart="$1" amount="$2" d k + for k in 0 1 2; do + d="$NOTARY_DIR/$(date -u -d "$qstart-01 +$k month" +%Y-%m)" + if [ -f "$d/notary_certificate_$amount.pem" ]; then + verify_cert "$d/notary_certificate_$amount.pem" "$(info_for "$amount" "$qstart-01 00:00:00")" + # And that its signing key really is that certificate's, before + # copying the pair into more months. + rm -rf "$scratch/reuse" + mkdir -m 700 "$scratch/reuse" + cp "$d/notary_certificate_$amount.pem" "$scratch/reuse/notary_certificate.pem" + cp "$d/notary_signing_key_$amount.pem" "$scratch/reuse/notary_signing_key.pem" + verify_pair "$scratch/reuse" "the existing $qstart quarter pair for amount $amount in $d" + rm -rf "$scratch/reuse" + echo "$d" + return + fi + done +} + +if [ "$SCHEDULE" = true ]; then + for ((i = 0; i < MONTHS; i++)); do + month=$(date -u -d "$START_MONTH-01 +$i month" +%Y-%m) + qstart=$(quarter_start "$month") + dir="$NOTARY_DIR/$month" + if [ -e "$dir" ]; then + echo "Error: $dir already exists. A schedule run only adds months; start after the last one." >&2 + exit 1 + fi + + partial="$NOTARY_DIR/.$month.partial" + rm -rf "$partial" + mkdir -m 700 "$partial" + + for amount in ${AMOUNTS[@]+"${AMOUNTS[@]}"}; do + generate_pair "$partial" "$amount" "$month-01 00:00:00" + done + + reused=0 + for amount in ${QUARTERLY_AMOUNTS[@]+"${QUARTERLY_AMOUNTS[@]}"}; do + src=$(existing_quarterly_dir "$qstart" "$amount") + if [ -n "$src" ]; then + cp -p "$src/notary_signing_key_$amount.pem" "$src/notary_certificate_$amount.pem" "$partial/" + reused=$((reused + 1)) + else + generate_pair "$partial" "$amount" "$qstart-01 00:00:00" + fi + done + + mv "$partial" "$dir" + partial="" + [ -f "$policy_file" ] || echo "$policy" >"$policy_file" + echo "$month: ${#AMOUNTS[@]} monthly and ${#QUARTERLY_AMOUNTS[@]} quarterly notary keypairs ($reused reused from elsewhere in the quarter)" + done +else + for amount in "${AMOUNTS[@]}"; do + generate_pair "$NOTARY_DIR" "$amount" "$(date -u +"%Y-%m-%d %H:%M:%S")" + done + echo "${#AMOUNTS[@]} notary keypairs generated and verified in $NOTARY_DIR" +fi diff --git a/rust/cli/test_notary_schedule.sh b/rust/cli/test_notary_schedule.sh new file mode 100644 index 00000000..c5ac604d --- /dev/null +++ b/rust/cli/test_notary_schedule.sh @@ -0,0 +1,184 @@ +#!/bin/bash +# +# Tests for generate_notary_keys.sh's schedule mode, run against a throwaway +# master key (never a real one). Needs cargo and jq. + +set -uo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +GEN="$script_dir/generate_notary_keys.sh" + +pass=0 +fail=0 +ok() { echo "ok - $1"; pass=$((pass + 1)); } +bad() { echo "FAIL - $1"; fail=$((fail + 1)); } +check() { if eval "$2"; then ok "$1"; else bad "$1"; fi; } +same() { cmp -s "$1" "$2"; } + +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT + +cargo build --release --quiet --manifest-path "$script_dir/Cargo.toml" --bin ghostkey +GHOSTKEY=$(cargo build --release --quiet --manifest-path "$script_dir/Cargo.toml" --bin ghostkey \ + --message-format=json | jq -r 'select(.executable != null) | .executable') +"$GHOSTKEY" generate-master-key --output-dir "$tmp/master" >/dev/null +MASTER=(--master-key "$tmp/master/master_signing_key.pem" + --master-verifying-key "$tmp/master/master_verifying_key.pem") +D="$tmp/notaries" + +run() { bash "$GEN" "${MASTER[@]}" --notary-dir "$D" "$@" >"$tmp/out" 2>&1; } +info() { + NO_COLOR=1 "$GHOSTKEY" verify-notary --master-verifying-key "$tmp/master/master_verifying_key.pem" \ + --notary-certificate "$1" | sed -n 's/^Info: //p' +} + +# A schedule across a year (and quarter) boundary. +check "3-month schedule succeeds" "run --start-month 2026-11 --months 3" +for m in 2026-11 2026-12 2027-01; do + check "$m has all 8 tiers" "[ \$(ls $D/$m/notary_certificate_*.pem | wc -l) -eq 8 ]" +done +check "monthly tier changes every month" \ + "! same $D/2026-11/notary_certificate_5.pem $D/2026-12/notary_certificate_5.pem" +check "monthly tier dated the 1st" \ + "[ \"\$(info $D/2026-12/notary_certificate_5.pem)\" = '{\"action\":\"freenet-donation\",\"amount\":5,\"delegate-key-created\":\"2026-12-01 00:00:00\"}' ]" +check "quarterly tier shared within a quarter" \ + "same $D/2026-11/notary_certificate_20.pem $D/2026-12/notary_certificate_20.pem && same $D/2026-11/notary_signing_key_20.pem $D/2026-12/notary_signing_key_20.pem" +check "quarterly tier changes with the quarter" \ + "! same $D/2026-12/notary_certificate_20.pem $D/2027-01/notary_certificate_20.pem" +check "quarterly tier dated the first day of its quarter" \ + "[ \"\$(info $D/2026-11/notary_certificate_10000.pem)\" = '{\"action\":\"freenet-donation\",\"amount\":10000,\"delegate-key-created\":\"2026-10-01 00:00:00\"}' ] && [ \"\$(info $D/2027-01/notary_certificate_10000.pem)\" = '{\"action\":\"freenet-donation\",\"amount\":10000,\"delegate-key-created\":\"2027-01-01 00:00:00\"}' ]" + +# Extending mid-quarter in a later run must reuse that quarter's pairs. +check "extension run succeeds" "run --start-month 2027-02 --months 1" +check "extension reuses the quarter's pair" \ + "same $D/2027-01/notary_certificate_100.pem $D/2027-02/notary_certificate_100.pem && same $D/2027-01/notary_signing_key_100.pem $D/2027-02/notary_signing_key_100.pem" +check "extension still mints new monthly pairs" \ + "! same $D/2027-01/notary_certificate_1.pem $D/2027-02/notary_certificate_1.pem" + +# A reused quarterly pair is checked as a pair, not just its certificate: swap +# in another valid signing key and an extension into that quarter must refuse it. +cp -p "$D/2027-01/notary_signing_key_100.pem" "$tmp/key100.bak" +cp "$D/2027-01/notary_signing_key_5.pem" "$D/2027-01/notary_signing_key_100.pem" +cp "$D/2027-01/notary_signing_key_5.pem" "$D/2027-02/notary_signing_key_100.pem" +check "a reused quarterly pair whose key does not match is refused" \ + "! run --start-month 2027-03 --months 1 && grep -q 'a ghost key issued by the existing 2027-01 quarter pair' $tmp/out && [ ! -e $D/2027-03 ]" +for m in 2027-01 2027-02; do cp -p "$tmp/key100.bak" "$D/$m/notary_signing_key_100.pem"; done +check "with the pair restored, the quarter can be completed" "run --start-month 2027-03 --months 1" +check "the completed quarter shares one pair" \ + "same $D/2027-01/notary_certificate_100.pem $D/2027-03/notary_certificate_100.pem" + +# Months are only ever added. +# shellcheck disable=SC2034 # read inside check's eval +before=$(find "$D" -type f -exec sha256sum {} + | sort) +check "existing month is refused" "! run --start-month 2027-03 --months 1" +check "--overwrite is refused with a schedule" "! run --start-month 2027-04 --months 1 --overwrite" +check "refusals changed nothing" "[ \"\$(find $D -type f -exec sha256sum {} + | sort)\" = \"\$before\" ]" + +# Input validation. +check "zero-padded --months is refused" "! run --start-month 2027-04 --months 08" +check "invalid amount is refused" "! run --start-month 2027-04 --months 1 --amounts 05" +check "duplicate amount is refused" "! run --start-month 2027-04 --months 1 --amounts 5 --quarterly-amounts 5" +check "invalid month is refused" "! run --start-month 2027-13 --months 1" +check "schedule without --notary-dir is refused" \ + "! bash $GEN --master-key $tmp/master/master_signing_key.pem --start-month 2027-04 --months 1 >$tmp/out 2>&1 && grep -q 'explicit --notary-dir' $tmp/out" + +# Wrong master key (no --master-verifying-key: checked against the real +# Freenet key) fails and leaves no month behind. +check "wrong master key is refused" \ + "! bash $GEN --master-key $tmp/master/master_signing_key.pem --notary-dir $D --start-month 2027-04 --months 1 >$tmp/out 2>&1" +check "failed run leaves no month directory" "[ ! -e $D/2027-04 ]" + +# Changing the dating policy of an existing schedule is refused, both ways. +check "quarterly to monthly is refused" "! run --start-month 2027-04 --months 1 --amounts 1 5 20 --quarterly-amounts 50 100 500 2500 10000" +check "monthly to quarterly is refused" "! run --start-month 2027-04 --months 1 --amounts 1 --quarterly-amounts 5 20 50 100 500 2500 10000" +check "refused policy change leaves no month directory" "[ ! -e $D/2027-04 ]" + +# A first run that fails outright does not pin its policy. +F="$tmp/failed-first" +check "failed first run" \ + "! bash $GEN --master-key $tmp/master/master_signing_key.pem --notary-dir $F --start-month 2027-01 --months 1 --amounts 1 >$tmp/out 2>&1" +check "failed first run records no policy" "[ ! -e $F/.schedule-policy ]" +check "a corrected retry with other amounts is accepted" \ + "bash $GEN ${MASTER[*]} --notary-dir $F --start-month 2027-01 --months 1 >$tmp/out 2>&1 && [ -f $F/.schedule-policy ]" + +# Belt and braces for a schedule without a policy file: a quarter whose +# existing month dated a quarterly tier some other way is an error, not silently +# shared. (February: a monthly date on a quarter's first month would equal the +# quarterly one.) +P="$tmp/nopolicy" +runp() { bash "$GEN" "${MASTER[@]}" --notary-dir "$P" "$@" >"$tmp/out" 2>&1; } +check "tier dated monthly in 2028-02" "runp --start-month 2028-02 --months 1 --amounts 20 --quarterly-amounts" +rm -f "$P/.schedule-policy" +check "conflicting existing month is refused" "! runp --start-month 2028-03 --months 1" +check "conflict leaves no month directory" "[ ! -e $P/2028-03 ]" + +# publish_notary_window.sh: the rolling window the API actually reads. +PUB="$script_dir/../api/publish_notary_window.sh" +L="$tmp/live" +mkdir -m 700 "$L" +me="$(id -un):$(id -gn)" +publish() { NOTARY_WINDOW_NOW="$1" bash "$PUB" "$D" "$L" "$me" >"$tmp/pub" 2>&1; } +check "before the schedule starts: publishes the first month, no error" \ + "publish 2026-10 && [ -d $L/2026-11 ] && [ ! -e $L/2026-12 ] && grep -q 'starts at 2026-11' $tmp/pub" +check "publish succeeds" "publish 2026-12" +check "publishes past, current and next month" "[ -d $L/2026-11 ] && [ -d $L/2026-12 ] && [ -d $L/2027-01 ]" +check "does not publish beyond the window" "[ ! -e $L/2027-02 ]" +check "published month is complete" "[ \$(ls $L/2027-01 | wc -l) -eq 16 ]" +check "published keys are private" "[ -z \"\$(find $L -name 'notary_signing_key_*' ! -perm 600)\" ]" +touch -d '2000-01-01' "$L/2027-01/notary_certificate_5.pem" +check "rerun is a no-op" "publish 2026-12 && grep -q '^0 published' $tmp/pub" +check "published month is never replaced" \ + "[ \"\$(stat -c %Y $L/2027-01/notary_certificate_5.pem)\" = \"\$(date -d 2000-01-01 +%s)\" ]" +check "window advances with the month" "publish 2027-01 && [ -d $L/2027-02 ] && [ ! -e $L/2027-03 ]" +ln -s /nonexistent "$L/2027-03" +check "a planted symlink is neither followed nor replaced" "publish 2027-02 && [ -L $L/2027-03 ]" +# The notary directory's owner can swap its path for a symlink onto another +# filesystem, where mv would fall back to a copy that follows symlinks. +shm=$(mktemp -d -p /dev/shm) +ln -s "$shm" "$tmp/live-elsewhere" +check "notary dir on another filesystem via symlink is refused" \ + "! NOTARY_WINDOW_NOW=2027-01 bash $PUB $D $tmp/live-elsewhere $me >$tmp/pub 2>&1 && grep -q 'same filesystem' $tmp/pub && [ -z \"\$(ls -A $shm)\" ]" +rm -rf "$shm" +chmod 755 "$D" +check "a schedule that is not private is refused" "! publish 2027-01 && grep -q 'mode 700' $tmp/pub" +chmod 700 "$D" +mkdir -m 777 "$tmp/open" && chmod 777 "$tmp/open" && mkdir -m 700 "$tmp/open/schedule" +check "a schedule under a directory others can rewrite is refused" \ + "! NOTARY_WINDOW_NOW=2027-01 bash $PUB $tmp/open/schedule $L $me >$tmp/pub 2>&1 && grep -q 'writable by someone else' $tmp/pub" +check "numeric owner is accepted" "NOTARY_WINDOW_NOW=2027-01 bash $PUB $D $L $(id -u):$(id -g) >$tmp/pub 2>&1" +# Modes are set on the way in, not inherited from the source. +S="$tmp/loose" +L2="$tmp/live2" +mkdir -m 700 "$S" "$L2" +cp -R "$D/2027-01" "$S/2027-01" +chmod 777 "$S/2027-01" +chmod 755 "$S"/2027-01/* +check "loose source modes are normalised" \ + "NOTARY_WINDOW_NOW=2027-01 bash $PUB $S $L2 $me >$tmp/pub 2>&1 && [ \"\$(stat -c %a $L2/2027-01)\" = 700 ] && [ -z \"\$(find $L2/2027-01 -type f ! -perm 600)\" ]" +ln -s /etc/passwd "$S/2027-01/notary_extra.pem" +rm -rf "$L2/2027-01" +check "a month containing a symlink is refused" \ + "! NOTARY_WINDOW_NOW=2027-01 bash $PUB $S $L2 $me >$tmp/pub 2>&1 && grep -q 'other than files' $tmp/pub && [ ! -e $L2/2027-01 ]" +check "warns when 12 or fewer months remain" "publish 2027-01 && grep -q 'WARNING: the schedule has 3 months left' $tmp/pub" +check "month missing from the schedule fails loudly" "! publish 2029-06 && grep -q 'no 2029-06' $tmp/pub" +check "no publishing directories left" "[ -z \"\$(find $L -maxdepth 1 -name '.*' ! -name . )\" ]" + +# Quarters in the middle of the year (and the 10# base in quarter_start: 08 +# and 09 are not octal). +Q="$tmp/midyear" +runq() { bash "$GEN" "${MASTER[@]}" --notary-dir "$Q" "$@" >"$tmp/out" 2>&1; } +check "mid-year schedule succeeds" "runq --start-month 2029-06 --months 4" +check "June belongs to the April quarter" \ + "[ \"\$(info $Q/2029-06/notary_certificate_50.pem)\" = '{\"action\":\"freenet-donation\",\"amount\":50,\"delegate-key-created\":\"2029-04-01 00:00:00\"}' ]" +check "a new quarter starts in July" \ + "! same $Q/2029-06/notary_certificate_50.pem $Q/2029-07/notary_certificate_50.pem" +check "August and September share July's pair" \ + "same $Q/2029-07/notary_certificate_50.pem $Q/2029-08/notary_certificate_50.pem && same $Q/2029-07/notary_certificate_50.pem $Q/2029-09/notary_certificate_50.pem" +check "September's pair is dated 1 July" \ + "[ \"\$(info $Q/2029-09/notary_certificate_50.pem)\" = '{\"action\":\"freenet-donation\",\"amount\":50,\"delegate-key-created\":\"2029-07-01 00:00:00\"}' ]" + +check "no scratch or partial directories left" "[ -z \"\$(find $D $P -maxdepth 1 -type d -name '.*')\" ]" +check "signing keys are private" "[ -z \"\$(find $D -name 'notary_signing_key_*' ! -perm 600)\" ]" + +echo "$pass passed, $fail failed" +[ "$fail" -eq 0 ]