From 468fd0f9d8f8a2673d9609b0e0619e98cdfe9d5b Mon Sep 17 00:00:00 2001 From: Ian Clarke Date: Mon, 28 Sep 2026 09:42:00 -0500 Subject: [PATCH 01/15] feat(cli): generate a monthly schedule of notary keypairs in one master-key session generate_notary_keys.sh gains --start-month YYYY-MM --months N, writing one per-amount set per month to /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 --- rust/cli/generate_notary_keys.sh | 174 +++++++++++++++++++++++-------- 1 file changed, 129 insertions(+), 45 deletions(-) diff --git a/rust/cli/generate_notary_keys.sh b/rust/cli/generate_notary_keys.sh index 0d5cb57f..76927135 100755 --- a/rust/cli/generate_notary_keys.sh +++ b/rust/cli/generate_notary_keys.sh @@ -3,26 +3,49 @@ # 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. +# +# Monthly schedule (--start-month YYYY-MM --months N): one set per calendar +# month, each dated the 1st of that month at 00:00:00 UTC and written to +# --notary-dir/YYYY-MM/. This lets the master key stay offline for years: +# generate the schedule once, then roll the API onto each month's set. +# +# 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) +# Must match the tiers in hugo-site/themes/freenet/layouts/shortcodes/stripe-donation-form.html +DEFAULT_AMOUNTS=(1 5 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] [--master-verifying-key ]" >&2 exit 1 } MASTER_KEY_FILE="" +MASTER_VERIFYING_KEY_FILE="" NOTARY_DIR="$DEFAULT_NOTARY_DIR" +AMOUNTS=() +START_MONTH="" +MONTHS="" while [ $# -gt 0 ]; do case "$1" in @@ -30,6 +53,10 @@ 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" shift 2 @@ -41,12 +68,19 @@ while [ $# -gt 0 ]; do ;; --amounts) shift - AMOUNTS=() while [[ $# -gt 0 && ! "$1" =~ ^-- ]]; do AMOUNTS+=("$1") shift done ;; + --start-month) + START_MONTH="$2" + shift 2 + ;; + --months) + MONTHS="$2" + shift 2 + ;; --overwrite) OVERWRITE=true shift @@ -72,53 +106,103 @@ if [ ! -f "$MASTER_KEY_FILE" ]; then exit 1 fi +if [ -n "$START_MONTH" ] || [ -n "$MONTHS" ]; then + 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 +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. +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +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 + 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\"}" +# 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) +trap 'rm -rf "$scratch"' EXIT + +# generate_set +generate_set() { + local dir="$1" created="$2" + mkdir -p "$dir" + chmod 700 "$dir" + + for amount in "${AMOUNTS[@]}"; do + # 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. + local info="{\"action\":\"freenet-donation\",\"amount\":$amount,\"delegate-key-created\":\"$created\"}" + + local signing_key_file="$dir/notary_signing_key_$amount.pem" + local cert_file="$dir/notary_certificate_$amount.pem" + + if [ -f "$signing_key_file" ] || [ -f "$cert_file" ]; then + if [ "$OVERWRITE" = false ]; then + echo "Error: Output files already exist for amount $amount in $dir. Use --overwrite to replace." >&2 + exit 1 + fi + fi - signing_key_file="$NOTARY_DIR/notary_signing_key_$amount.pem" - cert_file="$NOTARY_DIR/notary_certificate_$amount.pem" + 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 - if [ -f "$signing_key_file" ] || [ -f "$cert_file" ]; then - if [ "$OVERWRITE" = false ]; then - echo "Error: Output files already exist for amount $amount. Use --overwrite to replace." >&2 + local verified + if ! verified=$("$GHOSTKEY" verify-notary "${VERIFY_ARGS[@]}" \ + --notary-certificate "$scratch/notary/notary_certificate.pem" 2>&1); then + echo "Error: notary certificate for amount $amount does not verify against the master verifying key:" >&2 + echo "$verified" >&2 + exit 1 + fi + if ! grep -qxF "Info: $info" <<<"$verified"; then + echo "Error: notary certificate for amount $amount has unexpected info:" >&2 + echo "$verified" >&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 - exit 1 - fi + if ! "$GHOSTKEY" generate-ghost-key --notary-dir "$scratch/notary" \ + --output-dir "$scratch/ghost" >/dev/null 2>&1 \ + || ! "$GHOSTKEY" verify-ghost-key "${VERIFY_ARGS[@]}" \ + --ghost-certificate "$scratch/ghost/ghost_key_certificate.pem" >/dev/null 2>&1; then + echo "Error: a ghost key issued by the new notary for amount $amount does not verify" >&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 - exit 1 - fi + mv "$scratch/notary/notary_signing_key.pem" "$signing_key_file" + mv "$scratch/notary/notary_certificate.pem" "$cert_file" + chmod 600 "$signing_key_file" "$cert_file" + done +} - mv "$NOTARY_DIR/notary_signing_key.pem" "$signing_key_file" || { - echo "Error: Failed to rename notary signing key for amount $amount" >&2 - exit 1 - } - mv "$NOTARY_DIR/notary_certificate.pem" "$cert_file" || { - echo "Error: Failed to rename notary certificate for amount $amount" >&2 - exit 1 - } - - chmod 600 "$signing_key_file" - chmod 600 "$cert_file" -done +if [ -n "$START_MONTH" ]; then + for ((i = 0; i < MONTHS; i++)); do + month=$(date -u -d "$START_MONTH-01 +$i month" +%Y-%m) + generate_set "$NOTARY_DIR/$month" "$month-01 00:00:00" + echo "$month: ${#AMOUNTS[@]} notary keypairs generated and verified" + done +else + generate_set "$NOTARY_DIR" "$(date -u +"%Y-%m-%d %H:%M:%S")" + echo "${#AMOUNTS[@]} notary keypairs generated and verified in $NOTARY_DIR" +fi From cf04b641ac1581870eef114f70d3ee5cbf591434 Mon Sep 17 00:00:00 2001 From: Ian Clarke Date: Mon, 28 Sep 2026 09:43:39 -0500 Subject: [PATCH 02/15] docs(cli): delegate-key-created is parsed by the Vault UI and Harvest, not River Claude-Session: https://claude.ai/code/session_01C7ph5zmqbaZdYnnuuNjGnv --- rust/cli/generate_notary_keys.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/rust/cli/generate_notary_keys.sh b/rust/cli/generate_notary_keys.sh index 76927135..e303864e 100755 --- a/rust/cli/generate_notary_keys.sh +++ b/rust/cli/generate_notary_keys.sh @@ -144,7 +144,8 @@ generate_set() { for amount in "${AMOUNTS[@]}"; do # 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 + # 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. local info="{\"action\":\"freenet-donation\",\"amount\":$amount,\"delegate-key-created\":\"$created\"}" From cbe0b363f8bfc821891a1249cddaf4fc198092b3 Mon Sep 17 00:00:00 2001 From: Ian Clarke Date: Mon, 28 Sep 2026 19:22:23 -0500 Subject: [PATCH 03/15] feat(cli): date low-volume tiers yearly in the notary schedule 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 --- rust/cli/generate_notary_keys.sh | 209 +++++++++++++++++++++---------- 1 file changed, 143 insertions(+), 66 deletions(-) diff --git a/rust/cli/generate_notary_keys.sh b/rust/cli/generate_notary_keys.sh index e303864e..7d527e55 100755 --- a/rust/cli/generate_notary_keys.sh +++ b/rust/cli/generate_notary_keys.sh @@ -8,10 +8,18 @@ # Single set (default): one keypair per amount, dated now, written flat into # --notary-dir as notary_{certificate,signing_key}_{amount}.pem. # -# Monthly schedule (--start-month YYYY-MM --months N): one set per calendar -# month, each dated the 1st of that month at 00:00:00 UTC and written to -# --notary-dir/YYYY-MM/. This lets the master key stay offline for years: -# generate the schedule once, then roll the API onto each month's set. +# 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. +# +# In a schedule, --amounts tiers are dated monthly (the 1st of the month, +# 00:00:00 UTC) and --yearly-amounts tiers are dated yearly (1 January): one +# keypair per year, copied into each of that year's 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. # # Every generated pair is checked before the script moves on: the certificate # must verify against the master verifying key (by default the Freenet master @@ -27,8 +35,11 @@ set -euo pipefail -# Must match the tiers in hugo-site/themes/freenet/layouts/shortcodes/stripe-donation-form.html +# 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_YEARLY_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 @@ -36,7 +47,8 @@ OVERWRITE=false usage() { echo "Usage: $0 --master-key [--notary-dir ]" >&2 echo " [--amounts ...] [--overwrite]" >&2 - echo " [--start-month YYYY-MM --months N] [--master-verifying-key ]" >&2 + echo " [--start-month YYYY-MM --months N [--yearly-amounts ...]]" >&2 + echo " [--master-verifying-key ]" >&2 exit 1 } @@ -44,6 +56,9 @@ MASTER_KEY_FILE="" MASTER_VERIFYING_KEY_FILE="" NOTARY_DIR="$DEFAULT_NOTARY_DIR" AMOUNTS=() +AMOUNTS_SET=false +YEARLY_AMOUNTS=() +YEARLY_AMOUNTS_SET=false START_MONTH="" MONTHS="" @@ -68,11 +83,20 @@ while [ $# -gt 0 ]; do ;; --amounts) shift + AMOUNTS_SET=true while [[ $# -gt 0 && ! "$1" =~ ^-- ]]; do AMOUNTS+=("$1") shift done ;; + --yearly-amounts) + shift + YEARLY_AMOUNTS_SET=true + while [[ $# -gt 0 && ! "$1" =~ ^-- ]]; do + YEARLY_AMOUNTS+=("$1") + shift + done + ;; --start-month) START_MONTH="$2" shift 2 @@ -97,10 +121,6 @@ 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 @@ -111,6 +131,28 @@ if [ -n "$START_MONTH" ] || [ -n "$MONTHS" ]; then echo "Error: --start-month YYYY-MM and --months N (N >= 1) must be given together." >&2 usage fi + if [ "$AMOUNTS_SET" = false ]; then + AMOUNTS=("${DEFAULT_SCHEDULE_MONTHLY_AMOUNTS[@]}") + fi + if [ "$YEARLY_AMOUNTS_SET" = false ]; then + YEARLY_AMOUNTS=("${DEFAULT_SCHEDULE_YEARLY_AMOUNTS[@]}") + fi + for a in "${AMOUNTS[@]}"; do + for y in "${YEARLY_AMOUNTS[@]}"; do + if [ "$a" = "$y" ]; then + echo "Error: amount $a is in both --amounts and --yearly-amounts." >&2 + exit 1 + fi + done + done +else + if [ "$YEARLY_AMOUNTS_SET" = true ]; then + echo "Error: --yearly-amounts only applies with --start-month/--months." >&2 + usage + fi + if [ "$AMOUNTS_SET" = false ]; then + AMOUNTS=("${DEFAULT_AMOUNTS[@]}") + fi fi VERIFY_ARGS=() @@ -136,74 +178,109 @@ chmod 700 "$NOTARY_DIR" scratch=$(mktemp -d -p "$NOTARY_DIR" .verify.XXXXXX) trap 'rm -rf "$scratch"' EXIT -# generate_set -generate_set() { - local dir="$1" created="$2" - mkdir -p "$dir" - chmod 700 "$dir" - - for amount in "${AMOUNTS[@]}"; do - # 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. - local info="{\"action\":\"freenet-donation\",\"amount\":$amount,\"delegate-key-created\":\"$created\"}" - - local signing_key_file="$dir/notary_signing_key_$amount.pem" - local cert_file="$dir/notary_certificate_$amount.pem" - - if [ -f "$signing_key_file" ] || [ -f "$cert_file" ]; then - if [ "$OVERWRITE" = false ]; then - echo "Error: Output files already exist for amount $amount in $dir. Use --overwrite to replace." >&2 - exit 1 - fi - fi +make_dir() { + mkdir -p "$1" + chmod 700 "$1" +} - 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 +# 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 in $dir. Use --overwrite to replace." >&2 exit 1 fi + fi +} - local verified - if ! verified=$("$GHOSTKEY" verify-notary "${VERIFY_ARGS[@]}" \ - --notary-certificate "$scratch/notary/notary_certificate.pem" 2>&1); then - echo "Error: notary certificate for amount $amount does not verify against the master verifying key:" >&2 - echo "$verified" >&2 - exit 1 - fi - if ! grep -qxF "Info: $info" <<<"$verified"; then - echo "Error: notary certificate for amount $amount has unexpected info:" >&2 - echo "$verified" >&2 - exit 1 - fi +# generate_pair +generate_pair() { + local dir="$1" amount="$2" created="$3" + # 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. + local info="{\"action\":\"freenet-donation\",\"amount\":$amount,\"delegate-key-created\":\"$created\"}" - if ! "$GHOSTKEY" generate-ghost-key --notary-dir "$scratch/notary" \ - --output-dir "$scratch/ghost" >/dev/null 2>&1 \ - || ! "$GHOSTKEY" verify-ghost-key "${VERIFY_ARGS[@]}" \ - --ghost-certificate "$scratch/ghost/ghost_key_certificate.pem" >/dev/null 2>&1; then - echo "Error: a ghost key issued by the new notary for amount $amount does not verify" >&2 - exit 1 - fi + check_target "$dir" "$amount" - mv "$scratch/notary/notary_signing_key.pem" "$signing_key_file" - mv "$scratch/notary/notary_certificate.pem" "$cert_file" - chmod 600 "$signing_key_file" "$cert_file" - done + 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 + + local verified + if ! verified=$("$GHOSTKEY" verify-notary "${VERIFY_ARGS[@]}" \ + --notary-certificate "$scratch/notary/notary_certificate.pem" 2>&1); then + echo "Error: notary certificate for amount $amount does not verify against the master verifying key:" >&2 + echo "$verified" >&2 + exit 1 + fi + if ! grep -qxF "Info: $info" <<<"$verified"; then + echo "Error: notary certificate for amount $amount has unexpected info:" >&2 + echo "$verified" >&2 + exit 1 + fi + + if ! "$GHOSTKEY" generate-ghost-key --notary-dir "$scratch/notary" \ + --output-dir "$scratch/ghost" >/dev/null 2>&1 \ + || ! "$GHOSTKEY" verify-ghost-key "${VERIFY_ARGS[@]}" \ + --ghost-certificate "$scratch/ghost/ghost_key_certificate.pem" >/dev/null 2>&1; then + echo "Error: a ghost key issued by the new notary for amount $amount does not verify" >&2 + exit 1 + fi + + 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" +} + +# copy_pair +copy_pair() { + local from="$1" to="$2" amount="$3" + check_target "$to" "$amount" + cp -p "$from/notary_signing_key_$amount.pem" "$from/notary_certificate_$amount.pem" "$to/" } if [ -n "$START_MONTH" ]; then + yearly_year="" + yearly_dir="" for ((i = 0; i < MONTHS; i++)); do month=$(date -u -d "$START_MONTH-01 +$i month" +%Y-%m) - generate_set "$NOTARY_DIR/$month" "$month-01 00:00:00" - echo "$month: ${#AMOUNTS[@]} notary keypairs generated and verified" + year=${month%-*} + dir="$NOTARY_DIR/$month" + make_dir "$dir" + + for amount in "${AMOUNTS[@]}"; do + generate_pair "$dir" "$amount" "$month-01 00:00:00" + done + + # One yearly keypair, generated in the first month of the year that the + # schedule reaches and copied into the rest. + if [ "$year" != "$yearly_year" ]; then + for amount in "${YEARLY_AMOUNTS[@]}"; do + generate_pair "$dir" "$amount" "$year-01-01 00:00:00" + done + yearly_year="$year" + yearly_dir="$dir" + else + for amount in "${YEARLY_AMOUNTS[@]}"; do + copy_pair "$yearly_dir" "$dir" "$amount" + done + fi + + echo "$month: $(( ${#AMOUNTS[@]} + ${#YEARLY_AMOUNTS[@]} )) notary keypairs in place (${#AMOUNTS[@]} monthly, ${#YEARLY_AMOUNTS[@]} yearly)" done else - generate_set "$NOTARY_DIR" "$(date -u +"%Y-%m-%d %H:%M:%S")" + 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 From 3bd17ea2b432f1b8a60e617c9b2fbd154695c8eb Mon Sep 17 00:00:00 2001 From: Ian Clarke Date: Mon, 28 Sep 2026 19:32:01 -0500 Subject: [PATCH 04/15] feat(api): issue from a monthly notary schedule, sign with the pair that 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 --- rust/api/README.md | 161 ++++++++------------ rust/api/src/delegates.rs | 254 ++++++++++++++++++++++++++++++- rust/api/src/handle_sign_cert.rs | 36 +++-- rust/api/src/main.rs | 5 +- rust/api/src/routes.rs | 49 +++--- 5 files changed, 371 insertions(+), 134 deletions(-) diff --git a/rust/api/README.md b/rust/api/README.md index 6ef95c05..2da98843 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,55 @@ done ### Rollback -Substitute `` with the timestamp step 4 printed. +Substitute `` with the timestamp the deploy printed. ```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 -' +sudo mv /home/gkapi/bin/ghostkey-api.rollback- /home/gkapi/bin/ghostkey-api +sudo systemctl restart gkapi ``` -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. +## Notary keys and the monthly schedule -## letsencrypt +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. -Verify that certificate was automatically renewed by root cron job on vega by looking at write times -of cert files: +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. Generate it with the master key mounted, in one session: +```bash +rust/cli/generate_notary_keys.sh --master-key \ + --notary-dir --start-month 2026-10 --months 120 ``` -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 -``` + +By default $1 and $5 are dated monthly and $20 and up are dated yearly, because 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). The script verifies every pair against the +compiled-in Freenet master key before writing it. + +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, that tier fails. +- **Signing** (`/sign-certificate`) uses exactly the pair the PaymentIntent was quoted from, + never whatever is current by then. The browser blinds against the certificate it was + quoted, before the card is charged, so signing with any other pair would charge the donor + for a key that does not verify. A PaymentIntent with no `notary_period` (quoted from the + flat files, including everything before the schedule existed) signs with the flat files. + +Operationally, that means: + +- **Do not delete a month directory, or the flat files, while a donation quoted from it + could still be in checkout.** Removing one only breaks those in-flight donations, but it + breaks them after the charge. Keep past months; they are small. +- Installing a schedule is a copy into the notary directory + (`sudo cp -a /20?? /home/gkapi/delegate-keys/`, then + `sudo chown -R gkapi:gkapi` and keep modes `700`/`600`). No restart is needed; the files + are read per request. +- Keep the generated schedule on the encrypted master-key drive as well. It is the backup. diff --git a/rust/api/src/delegates.rs b/rust/api/src/delegates.rs index 16f19b01..ed14641f 100644 --- a/rust/api/src/delegates.rs +++ b/rust/api/src/delegates.rs @@ -98,11 +98,120 @@ 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. +/// +/// The browser blinds its ghost key against the notary certificate it was +/// handed at `/create-donation` (or `/update-donation`), before the card is +/// charged. `/sign-certificate` must sign with that same pair, not whichever is +/// current by then, or a donation straddling a month boundary is charged and +/// gets 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| { @@ -126,10 +235,8 @@ pub(crate) fn get_notary( 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 +249,143 @@ 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 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/handle_sign_cert.rs b/rust/api/src/handle_sign_cert.rs index 653d9ea4..87906184 100644 --- a/rust/api/src/handle_sign_cert.rs +++ b/rust/api/src/handle_sign_cert.rs @@ -7,7 +7,7 @@ use stripe::{Client, PaymentIntent, PaymentIntentStatus}; use ghostkey_lib::armorable::Armorable; -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)] @@ -108,6 +108,19 @@ 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, which is the one the + // browser blinded against, 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), + )?; + // Mark the payment intent as used for certificate signing let mut metadata = HashMap::new(); metadata.insert("certificate_signed".to_string(), "true".to_string()); @@ -120,10 +133,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 @@ -144,17 +154,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()))?; 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 8c107143..9e6c6102 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, NOTARY_PERIOD_METADATA_KEY}; use crate::handle_sign_cert::{ sign_certificate, CertificateError, SignCertificateRequest, SignCertificateResponse, }; @@ -225,8 +225,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, @@ -274,14 +286,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 { @@ -315,8 +320,25 @@ async fn update_donation( let payment_intent_id = PaymentIntentId::from_str(&request.payment_intent_id) .map_err(|_| DonationError::InvalidCurrency)?; + + 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() }; @@ -326,14 +348,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(), From f6011004eabb33879bfc466a1fb9f300869dd36f Mon Sep 17 00:00:00 2001 From: Ian Clarke Date: Mon, 28 Sep 2026 19:39:35 -0500 Subject: [PATCH 05/15] fix(api): success page blinds against the PaymentIntent's quoted certificate 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 --- hugo-site/static/js/donation-success.js | 36 +++++++++-- rust/api/src/delegates.rs | 43 +++++++++++-- rust/api/src/errors.rs | 6 ++ rust/api/src/handle_sign_cert.rs | 84 ++++++++++++++++++++++++- rust/api/src/routes.rs | 50 ++++++++++++++- 5 files changed, 206 insertions(+), 13 deletions(-) diff --git a/hugo-site/static/js/donation-success.js b/hugo-site/static/js/donation-success.js index 71cf1b22..d440824a 100644 --- a/hugo-site/static/js/donation-success.js +++ b/hugo-site/static/js/donation-success.js @@ -165,15 +165,39 @@ 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; + const response = await fetch(`${apiUrl}/notary-certificate/${encodeURIComponent(paymentIntentId)}`); + 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; + } 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 +233,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/rust/api/src/delegates.rs b/rust/api/src/delegates.rs index ed14641f..a752b80f 100644 --- a/rust/api/src/delegates.rs +++ b/rust/api/src/delegates.rs @@ -101,11 +101,12 @@ fn pick_scheme(dir: &Path, amount: u64) -> NamingScheme { /// PaymentIntent metadata key recording which notary period a donation was /// quoted from: `YYYY-MM`, or absent for the flat per-amount files. /// -/// The browser blinds its ghost key against the notary certificate it was -/// handed at `/create-donation` (or `/update-donation`), before the card is -/// charged. `/sign-certificate` must sign with that same pair, not whichever is -/// current by then, or a donation straddling a month boundary is charged and -/// gets a signature that does not unblind. +/// 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 @@ -376,6 +377,38 @@ mod tests { } } + #[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 quoted_notary_rejects_bad_or_missing_periods() { let dir = tempdir().unwrap(); diff --git a/rust/api/src/errors.rs b/rust/api/src/errors.rs index 3b80c608..dec048b4 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, Base64Error(base64::DecodeError), KeyError(String), ParseIdError(stripe::ParseIdError), @@ -21,6 +24,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::Base64Error(e) => write!(f, "Base64 decoding error: {}", e), CertificateError::KeyError(e) => write!(f, "Key error: {}", e), CertificateError::ParseIdError(e) => write!(f, "Parse ID error: {}", e), diff --git a/rust/api/src/handle_sign_cert.rs b/rust/api/src/handle_sign_cert.rs index 87906184..4b75f32e 100644 --- a/rust/api/src/handle_sign_cert.rs +++ b/rust/api/src/handle_sign_cert.rs @@ -6,6 +6,7 @@ use serde::{Deserialize, Serialize}; use stripe::{Client, PaymentIntent, PaymentIntentStatus}; use ghostkey_lib::armorable::Armorable; +use ghostkey_lib::notary_certificate::NotaryCertificateV1; use crate::delegates::{quoted_notary, sign_with_notary_key, Notary, NOTARY_PERIOD_METADATA_KEY}; pub use crate::errors::CertificateError; @@ -14,6 +15,10 @@ pub use crate::errors::CertificateError; 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. @@ -111,8 +116,8 @@ pub async fn sign_certificate( let amount_cents = pi.amount as u64; let amount_dollars = amount_cents / 100; - // Sign with the pair this donation was quoted from, which is the one the - // browser blinded against, not whichever is current now. Loaded before + // 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, @@ -121,6 +126,20 @@ pub async fn sign_certificate( .map(String::as_str), )?; + // If the client says which certificate it blinded against, refuse a + // mismatch now, while the donation can still be retried. Signing anyway + // would spend it on a signature that cannot unblind. + if let Some(client_cert) = &request.notary_certificate_base64 { + if !same_notary(client_cert, ¬ary)? { + log::error!( + "PaymentIntent {} was blinded against a different 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()); @@ -147,6 +166,20 @@ pub async fn sign_certificate( } } +/// 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. +fn same_notary(client_cert: &str, notary: &Notary) -> Result { + let client_cert = NotaryCertificateV1::from_base64(client_cert) + .map_err(|e| CertificateError::MiscError(format!("invalid notary certificate: {}", e)))?; + 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. /// @@ -235,6 +268,29 @@ mod tests { /// afterwards leaves exactly the read-check-write window it exists to /// close, and nothing else in the test suite would notice: the happy path /// still returns a valid certificate. + #[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" + ); + } + #[test] fn claim_is_taken_before_the_signed_flag_is_read() { let source = production_source(); @@ -285,4 +341,28 @@ 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).is_err()); + } } diff --git a/rust/api/src/routes.rs b/rust/api/src/routes.rs index 9e6c6102..3d5b9199 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::{current_notary, NOTARY_PERIOD_METADATA_KEY}; +use crate::delegates::{current_notary, quoted_notary, NOTARY_PERIOD_METADATA_KEY}; use crate::handle_sign_cert::{ sign_certificate, CertificateError, SignCertificateRequest, SignCertificateResponse, }; @@ -151,6 +151,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::CertificateAlreadySigned => { Err((StatusCode::CONFLICT, Json(ErrorResponse { error: "Certificate has already been signed for this payment.".to_string(), @@ -387,6 +393,44 @@ 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> { + 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::OtherError("invalid PaymentIntent id".to_string()))?; + let intent = stripe::PaymentIntent::retrieve(&client, &payment_intent_id, &[]) + .await + .map_err(DonationError::StripeError)?; + + 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 // ============================================================================ @@ -689,6 +733,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()) } From a75b47765d767881a131594d2592312009983cd1 Mon Sep 17 00:00:00 2001 From: Ian Clarke Date: Mon, 28 Sep 2026 19:43:31 -0500 Subject: [PATCH 06/15] fix: address #192 review: yearly reuse, atomic months, rolling window 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 --- .github/workflows/rust-api-tests.yml | 11 +- .../shortcodes/stripe-donation-form.html | 7 +- rust/api/README.md | 121 ++++++++++--- rust/api/publish_notary_window.sh | 69 +++++++ rust/cli/generate_notary_keys.sh | 171 +++++++++++------- rust/cli/test_notary_schedule.sh | 109 +++++++++++ 6 files changed, 397 insertions(+), 91 deletions(-) create mode 100644 rust/api/publish_notary_window.sh create mode 100644 rust/cli/test_notary_schedule.sh diff --git a/.github/workflows/rust-api-tests.yml b/.github/workflows/rust-api-tests.yml index 0fb48fea..b9c3c52a 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 "yearly" key for a year) + # 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/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 2da98843..c1fca2f7 100644 --- a/rust/api/README.md +++ b/rust/api/README.md @@ -69,6 +69,15 @@ sudo mv /home/gkapi/bin/ghostkey-api.rollback- /home/gkapi/bin/ghostkey-a 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 find the affected PaymentIntents in Stripe (metadata +`notary_period` set and `certificate_signed` = `true`, after the rollback time) and clear +`certificate_signed` on each so the donors can reload the success page. + ## Notary keys and the monthly schedule The notary directory (`--notary-dir`, `/home/gkapi/delegate-keys` on nova) holds one @@ -79,38 +88,100 @@ still read). The certificate's info string carries the amount and a date, 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. Generate it with the master key mounted, in one session: +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 --start-month 2026-10 --months 120 + --notary-dir /notary-schedule --start-month 2026-10 --months 120 ``` By default $1 and $5 are dated monthly and $20 and up are dated yearly, because 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). The script verifies every pair against the -compiled-in Freenet master key before writing it. +compiled-in Freenet master key, builds each month in a hidden directory before renaming it +into place, refuses to touch an existing month, and reuses a year's yearly pairs when +extending a schedule mid-year. `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. -How the API uses it: +Keep the output on the encrypted master-key drive. It is the backup. -- **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, that tier fails. -- **Signing** (`/sign-certificate`) uses exactly the pair the PaymentIntent was quoted from, - never whatever is current by then. The browser blinds against the certificate it was - quoted, before the card is charged, so signing with any other pair would charge the donor - for a key that does not verify. A PaymentIntent with no `notary_period` (quoted from the - flat files, including everything before the schedule existed) signs with the flat files. - -Operationally, that means: - -- **Do not delete a month directory, or the flat files, while a donation quoted from it - could still be in checkout.** Removing one only breaks those in-flight donations, but it - breaks them after the charge. Keep past months; they are small. -- Installing a schedule is a copy into the notary directory - (`sudo cp -a /20?? /home/gkapi/delegate-keys/`, then - `sudo chown -R gkapi:gkapi` and keep modes `700`/`600`). No restart is needed; the files - are read per request. -- Keep the generated schedule on the encrypted master-key drive as well. 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 +# 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 +``` + +`/etc/systemd/system/gkapi-notary-window.service`: + +```ini +[Unit] +Description=Publish the next months of the ghost key notary schedule to gkapi + +[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 +``` + +```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..81aae610 --- /dev/null +++ b/rust/api/publish_notary_window.sh @@ -0,0 +1,69 @@ +#!/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 +# should be readable by root only. 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. +# +# Each month is copied to a hidden directory in , given to , +# then renamed into place, so the API never sees a partial month. 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 + +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 + +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 "$notary/$month" ]; then + continue + fi + partial="$notary/.$month.publishing" + rm -rf "$partial" + cp -a "$src" "$partial" + chown -R "$owner" "$partial" + chmod 700 "$partial" + chmod 600 "$partial"/* + mv "$partial" "$notary/$month" + echo "published $month" + published=$((published + 1)) +done + +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/cli/generate_notary_keys.sh b/rust/cli/generate_notary_keys.sh index 7d527e55..0aa69860 100755 --- a/rust/cli/generate_notary_keys.sh +++ b/rust/cli/generate_notary_keys.sh @@ -11,15 +11,22 @@ # 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. +# 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 --yearly-amounts tiers are dated yearly (1 January): one -# keypair per year, copied into each of that year's 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. +# keypair per year, shared by all of that year's 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. +# +# 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 year that already has a month in --notary-dir reuses that month's yearly +# keypairs instead of minting a second set, so a schedule can be extended +# later without splitting a year'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 @@ -126,25 +133,23 @@ if [ ! -f "$MASTER_KEY_FILE" ]; then 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 + 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 [ "$YEARLY_AMOUNTS_SET" = false ]; then YEARLY_AMOUNTS=("${DEFAULT_SCHEDULE_YEARLY_AMOUNTS[@]}") fi - for a in "${AMOUNTS[@]}"; do - for y in "${YEARLY_AMOUNTS[@]}"; do - if [ "$a" = "$y" ]; then - echo "Error: amount $a is in both --amounts and --yearly-amounts." >&2 - exit 1 - fi - done - done else if [ "$YEARLY_AMOUNTS_SET" = true ]; then echo "Error: --yearly-amounts only applies with --start-month/--months." >&2 @@ -155,13 +160,34 @@ else 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[@]}"} ${YEARLY_AMOUNTS[@]+"${YEARLY_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 --yearly-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 @@ -170,17 +196,27 @@ if [ ! -x "$GHOSTKEY" ]; then fi export NO_COLOR=1 +ghostkey_verify() { + "$GHOSTKEY" "$1" ${VERIFY_ARGS[@]+"${VERIFY_ARGS[@]}"} "${@:2}" +} + mkdir -p "$NOTARY_DIR" chmod 700 "$NOTARY_DIR" # 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) -trap 'rm -rf "$scratch"' EXIT +partial="" +trap 'rm -rf "$scratch" ${partial:+"$partial"}' EXIT -make_dir() { - mkdir -p "$1" - chmod 700 "$1" +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. @@ -194,15 +230,26 @@ check_target() { fi } +# 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 + if ! grep -qxF "Info: $info" <<<"$verified"; then + echo "Error: $cert has unexpected info (wanted $info):" >&2 + echo "$verified" >&2 + exit 1 + fi +} + # generate_pair generate_pair() { local dir="$1" amount="$2" created="$3" - # 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. - local info="{\"action\":\"freenet-donation\",\"amount\":$amount,\"delegate-key-created\":\"$created\"}" + local info + info=$(info_for "$amount" "$created") check_target "$dir" "$amount" @@ -216,22 +263,11 @@ generate_pair() { exit 1 fi - local verified - if ! verified=$("$GHOSTKEY" verify-notary "${VERIFY_ARGS[@]}" \ - --notary-certificate "$scratch/notary/notary_certificate.pem" 2>&1); then - echo "Error: notary certificate for amount $amount does not verify against the master verifying key:" >&2 - echo "$verified" >&2 - exit 1 - fi - if ! grep -qxF "Info: $info" <<<"$verified"; then - echo "Error: notary certificate for amount $amount has unexpected info:" >&2 - echo "$verified" >&2 - exit 1 - fi + verify_cert "$scratch/notary/notary_certificate.pem" "$info" if ! "$GHOSTKEY" generate-ghost-key --notary-dir "$scratch/notary" \ --output-dir "$scratch/ghost" >/dev/null 2>&1 \ - || ! "$GHOSTKEY" verify-ghost-key "${VERIFY_ARGS[@]}" \ + || ! 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 the new notary for amount $amount does not verify" >&2 exit 1 @@ -242,41 +278,52 @@ generate_pair() { chmod 600 "$dir/notary_signing_key_$amount.pem" "$dir/notary_certificate_$amount.pem" } -# copy_pair -copy_pair() { - local from="$1" to="$2" amount="$3" - check_target "$to" "$amount" - cp -p "$from/notary_signing_key_$amount.pem" "$from/notary_certificate_$amount.pem" "$to/" +# The completed month directory of already holding the yearly 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_yearly_dir() { + local year="$1" amount="$2" d + for d in "$NOTARY_DIR/$year"-[0-9][0-9]; do + if [ -f "$d/notary_certificate_$amount.pem" ]; then + verify_cert "$d/notary_certificate_$amount.pem" "$(info_for "$amount" "$year-01-01 00:00:00")" + echo "$d" + return + fi + done } -if [ -n "$START_MONTH" ]; then - yearly_year="" - yearly_dir="" +if [ "$SCHEDULE" = true ]; then for ((i = 0; i < MONTHS; i++)); do month=$(date -u -d "$START_MONTH-01 +$i month" +%Y-%m) year=${month%-*} dir="$NOTARY_DIR/$month" - make_dir "$dir" + if [ -e "$dir" ]; then + echo "Error: $dir already exists. A schedule run only adds months; start after the last one." >&2 + exit 1 + fi - for amount in "${AMOUNTS[@]}"; do - generate_pair "$dir" "$amount" "$month-01 00:00:00" + 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 - # One yearly keypair, generated in the first month of the year that the - # schedule reaches and copied into the rest. - if [ "$year" != "$yearly_year" ]; then - for amount in "${YEARLY_AMOUNTS[@]}"; do - generate_pair "$dir" "$amount" "$year-01-01 00:00:00" - done - yearly_year="$year" - yearly_dir="$dir" - else - for amount in "${YEARLY_AMOUNTS[@]}"; do - copy_pair "$yearly_dir" "$dir" "$amount" - done - fi + reused=0 + for amount in ${YEARLY_AMOUNTS[@]+"${YEARLY_AMOUNTS[@]}"}; do + src=$(existing_yearly_dir "$year" "$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" "$year-01-01 00:00:00" + fi + done - echo "$month: $(( ${#AMOUNTS[@]} + ${#YEARLY_AMOUNTS[@]} )) notary keypairs in place (${#AMOUNTS[@]} monthly, ${#YEARLY_AMOUNTS[@]} yearly)" + mv "$partial" "$dir" + partial="" + echo "$month: ${#AMOUNTS[@]} monthly and ${#YEARLY_AMOUNTS[@]} yearly notary keypairs ($reused yearly reused from earlier in $year)" done else for amount in "${AMOUNTS[@]}"; do diff --git a/rust/cli/test_notary_schedule.sh b/rust/cli/test_notary_schedule.sh new file mode 100644 index 00000000..0d582986 --- /dev/null +++ b/rust/cli/test_notary_schedule.sh @@ -0,0 +1,109 @@ +#!/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 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 "yearly tier shared within a year" \ + "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 "yearly tier changes with the year" \ + "! same $D/2026-12/notary_certificate_20.pem $D/2027-01/notary_certificate_20.pem" +check "yearly tier dated 1 January" \ + "[ \"\$(info $D/2027-01/notary_certificate_10000.pem)\" = '{\"action\":\"freenet-donation\",\"amount\":10000,\"delegate-key-created\":\"2027-01-01 00:00:00\"}' ]" + +# Extending mid-year in a later run must reuse that year's yearly pairs. +check "extension run succeeds" "run --start-month 2027-02 --months 2" +check "extension reuses the year's yearly pair" \ + "same $D/2027-01/notary_certificate_100.pem $D/2027-03/notary_certificate_100.pem && same $D/2027-01/notary_signing_key_100.pem $D/2027-03/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" + +# 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 --yearly-amounts 5" +check "invalid month is refused" "! run --start-month 2027-13 --months 1" + +# 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 ]" + +# A year whose existing month dated a yearly tier some other way is an error, +# not silently shared. +# (February: a January monthly date would equal the yearly one.) +check "tier dated monthly in 2028-02" "run --start-month 2028-02 --months 1 --amounts 20 --yearly-amounts" +check "policy conflict is refused" "! run --start-month 2028-03 --months 1" +check "policy conflict leaves no month directory" "[ ! -e $D/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 ]" +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 . )\" ]" + +check "no scratch or partial directories left" "[ -z \"\$(find $D -maxdepth 1 -name '.*' ! -name . )\" ]" +check "signing keys are private" "[ -z \"\$(find $D -name 'notary_signing_key_*' ! -perm 600)\" ]" + +echo "$pass passed, $fail failed" +[ "$fail" -eq 0 ] From e3d8fdf529ef693520e6bffc8872152f89e0db72 Mon Sep 17 00:00:00 2001 From: Ian Clarke Date: Mon, 28 Sep 2026 19:50:50 -0500 Subject: [PATCH 07/15] fix: address #192 round-2 review - /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 --- hugo-site/static/js/donation-success.js | 6 ++- rust/api/README.md | 8 ++-- rust/api/src/handle_sign_cert.rs | 56 +++++++++++++++++++------ rust/cli/generate_notary_keys.sh | 18 ++++++++ rust/cli/test_notary_schedule.sh | 20 ++++++--- 5 files changed, 86 insertions(+), 22 deletions(-) diff --git a/hugo-site/static/js/donation-success.js b/hugo-site/static/js/donation-success.js index d440824a..44c20f51 100644 --- a/hugo-site/static/js/donation-success.js +++ b/hugo-site/static/js/donation-success.js @@ -173,7 +173,11 @@ function generateTestCertificate() { async function fetchQuotedNotaryCertificate(paymentIntentId) { try { const apiUrl = window.ghostkeyApiUrl; - const response = await fetch(`${apiUrl}/notary-certificate/${encodeURIComponent(paymentIntentId)}`); + // Bounded, so a hung request falls back instead of leaving the page spinning. + const response = await fetch( + `${apiUrl}/notary-certificate/${encodeURIComponent(paymentIntentId)}`, + { signal: AbortSignal.timeout(20000) } + ); if (!response.ok) { console.warn("Could not fetch quoted notary certificate:", response.status); return null; diff --git a/rust/api/README.md b/rust/api/README.md index c1fca2f7..bcaf91f8 100644 --- a/rust/api/README.md +++ b/rust/api/README.md @@ -74,9 +74,11 @@ schedule** (anything before freenet/web#192). That binary ignores `notary_period 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 find the affected PaymentIntents in Stripe (metadata -`notary_period` set and `certificate_signed` = `true`, after the rollback time) and clear -`certificate_signed` on each so the donors can reload the success page. +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), so they can reload it. 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 diff --git a/rust/api/src/handle_sign_cert.rs b/rust/api/src/handle_sign_cert.rs index 4b75f32e..34768c2f 100644 --- a/rust/api/src/handle_sign_cert.rs +++ b/rust/api/src/handle_sign_cert.rs @@ -126,18 +126,15 @@ pub async fn sign_certificate( .map(String::as_str), )?; - // If the client says which certificate it blinded against, refuse a - // mismatch now, while the donation can still be retried. Signing anyway - // would spend it on a signature that cannot unblind. - if let Some(client_cert) = &request.notary_certificate_base64 { - if !same_notary(client_cert, ¬ary)? { - log::error!( - "PaymentIntent {} was blinded against a different notary certificate \ - than it was quoted; refusing before marking it spent", - pi.id - ); - return Err(CertificateError::NotaryMismatch); - } + // 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 @@ -166,6 +163,23 @@ 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. @@ -365,4 +379,22 @@ mod tests { assert!(!super::same_notary(&other_b64, "ed).unwrap()); assert!(super::same_notary("not a certificate", "ed).is_err()); } + + #[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/cli/generate_notary_keys.sh b/rust/cli/generate_notary_keys.sh index 0aa69860..c4b63587 100755 --- a/rust/cli/generate_notary_keys.sh +++ b/rust/cli/generate_notary_keys.sh @@ -203,6 +203,24 @@ ghostkey_verify() { mkdir -p "$NOTARY_DIR" chmod 700 "$NOTARY_DIR" +# A schedule keeps one dating policy for its whole life. Moving a tier between +# monthly and yearly 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[@]}"})| yearly: $(sorted ${YEARLY_AMOUNTS[@]+"${YEARLY_AMOUNTS[@]}"})" + policy_file="$NOTARY_DIR/.schedule-policy" + if [ -f "$policy_file" ]; then + if [ "$(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/--yearly-amounts." >&2 + exit 1 + fi + else + echo "$policy" >"$policy_file" + fi +fi + # 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) diff --git a/rust/cli/test_notary_schedule.sh b/rust/cli/test_notary_schedule.sh index 0d582986..fccc4f1b 100644 --- a/rust/cli/test_notary_schedule.sh +++ b/rust/cli/test_notary_schedule.sh @@ -74,12 +74,20 @@ 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 ]" -# A year whose existing month dated a yearly tier some other way is an error, -# not silently shared. +# Changing the dating policy of an existing schedule is refused, both ways. +check "yearly to monthly is refused" "! run --start-month 2027-04 --months 1 --amounts 1 5 20 --yearly-amounts 50 100 500 2500 10000" +check "monthly to yearly is refused" "! run --start-month 2027-04 --months 1 --amounts 1 --yearly-amounts 5 20 50 100 500 2500 10000" +check "refused policy change leaves no month directory" "[ ! -e $D/2027-04 ]" + +# Belt and braces for a schedule without a policy file: a year whose existing +# month dated a yearly tier some other way is an error, not silently shared. # (February: a January monthly date would equal the yearly one.) -check "tier dated monthly in 2028-02" "run --start-month 2028-02 --months 1 --amounts 20 --yearly-amounts" -check "policy conflict is refused" "! run --start-month 2028-03 --months 1" -check "policy conflict leaves no month directory" "[ ! -e $D/2028-03 ]" +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 --yearly-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" @@ -102,7 +110,7 @@ check "window advances with the month" "publish 2027-01 && [ -d $L/2027-02 ] && 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 . )\" ]" -check "no scratch or partial directories left" "[ -z \"\$(find $D -maxdepth 1 -name '.*' ! -name . )\" ]" +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" From 9574c8479aa70cbd1139c01a0b9a622cbbdce63b Mon Sep 17 00:00:00 2001 From: Ian Clarke Date: Mon, 28 Sep 2026 19:51:49 -0500 Subject: [PATCH 08/15] test: cover the publisher's low-schedule warning Claude-Session: https://claude.ai/code/session_01C7ph5zmqbaZdYnnuuNjGnv --- rust/cli/test_notary_schedule.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/rust/cli/test_notary_schedule.sh b/rust/cli/test_notary_schedule.sh index fccc4f1b..bbd0dca7 100644 --- a/rust/cli/test_notary_schedule.sh +++ b/rust/cli/test_notary_schedule.sh @@ -107,6 +107,7 @@ 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 ]" +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 . )\" ]" From 9b7c699b331a35645908e2bdb91d9d6f7f378525 Mon Sep 17 00:00:00 2001 From: Ian Clarke Date: Mon, 28 Sep 2026 19:54:17 -0500 Subject: [PATCH 09/15] fix: address #192 round-2 skeptical review - 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 /.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 --- rust/api/README.md | 2 +- rust/api/publish_notary_window.sh | 46 ++++++++++++++++++++----------- rust/api/src/handle_sign_cert.rs | 10 ++++--- rust/api/src/routes.rs | 20 +++++++++++--- rust/cli/generate_notary_keys.sh | 9 ++++++ rust/cli/test_notary_schedule.sh | 4 +++ 6 files changed, 66 insertions(+), 25 deletions(-) diff --git a/rust/api/README.md b/rust/api/README.md index bcaf91f8..aeda374f 100644 --- a/rust/api/README.md +++ b/rust/api/README.md @@ -76,7 +76,7 @@ charged and gets a signature that does not unblind. The signing call succeeds, s 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), so they can reload it. Do not clear it in bulk on every 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. diff --git a/rust/api/publish_notary_window.sh b/rust/api/publish_notary_window.sh index 81aae610..9dbd6b46 100644 --- a/rust/api/publish_notary_window.sh +++ b/rust/api/publish_notary_window.sh @@ -5,15 +5,19 @@ # # publish_notary_window.sh [months-ahead] # -# holds the full schedule from generate_notary_keys.sh and -# should be readable by root only. 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. +# holds the full schedule from generate_notary_keys.sh and must +# be root-only. 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. # -# Each month is copied to a hidden directory in , given to , -# then renamed into place, so the API never sees a partial month. 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. +# The API's user owns and can rename anything in it, so nothing +# is staged there: each month is copied and given to inside +# /.staging, where that user cannot reach, then renamed into +# in one step. That is why both must be on one filesystem. No +# chmod is run (cp -a keeps the generator's 700/600 modes), because chmod +# follows symlinks. 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. @@ -31,6 +35,17 @@ if ! [[ "$ahead" =~ ^[0-9]+$ ]]; then echo "Error: months-ahead must be a whole number" >&2 exit 1 fi +if [ "$(stat -c %d "$schedule")" != "$(stat -c %d "$notary")" ]; then + echo "Error: $schedule and $notary must be on the same filesystem, so a month is renamed into place atomically" >&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) @@ -40,20 +55,19 @@ 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 "$notary/$month" ]; then + if [[ "$month" > "$last" ]] || [ -e "$notary/$month" ] || [ -L "$notary/$month" ]; then continue fi - partial="$notary/.$month.publishing" - rm -rf "$partial" - cp -a "$src" "$partial" - chown -R "$owner" "$partial" - chmod 700 "$partial" - chmod 600 "$partial"/* - mv "$partial" "$notary/$month" + rm -rf "${staging:?}/$month" + cp -a "$src" "$staging/$month" + chown -R "$owner" "$staging/$month" + mv -T "$staging/$month" "$notary/$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' | diff --git a/rust/api/src/handle_sign_cert.rs b/rust/api/src/handle_sign_cert.rs index 34768c2f..ccc8d7c9 100644 --- a/rust/api/src/handle_sign_cert.rs +++ b/rust/api/src/handle_sign_cert.rs @@ -182,10 +182,12 @@ fn client_blinded_against( /// 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. +/// 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 client_cert = NotaryCertificateV1::from_base64(client_cert) - .map_err(|e| CertificateError::MiscError(format!("invalid notary certificate: {}", e)))?; + 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())) @@ -377,7 +379,7 @@ mod tests { assert!(super::same_notary("ed_b64, "ed).unwrap()); assert!(!super::same_notary(&other_b64, "ed).unwrap()); - assert!(super::same_notary("not a certificate", "ed).is_err()); + assert!(!super::same_notary("not a certificate", "ed).unwrap()); } #[test] diff --git a/rust/api/src/routes.rs b/rust/api/src/routes.rs index 3d5b9199..1afdedc8 100644 --- a/rust/api/src/routes.rs +++ b/rust/api/src/routes.rs @@ -163,9 +163,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(), }))) }, @@ -183,6 +185,7 @@ async fn sign_certificate_route( #[derive(Debug)] pub enum DonationError { InvalidCurrency, + NotFound(&'static str), StripeError(stripe::StripeError), EnvError(std::env::VarError), OtherError(String), @@ -192,6 +195,7 @@ impl IntoResponse for DonationError { fn into_response(self) -> axum::response::Response { let (status, error_message) = match self { DonationError::InvalidCurrency => (StatusCode::BAD_REQUEST, "Invalid currency"), + DonationError::NotFound(what) => (StatusCode::NOT_FOUND, what), DonationError::StripeError(e) => { error!("Stripe error: {:?}", e); (StatusCode::INTERNAL_SERVER_ERROR, "Stripe error occurred") @@ -408,11 +412,19 @@ async fn notary_certificate_route( let secret_key = std::env::var("STRIPE_SECRET_KEY").map_err(DonationError::EnvError)?; let client = Client::new(&secret_key); + // A malformed or unknown id is the caller's problem, not a server error, + // and this endpoint is unauthenticated: 404, logged quietly. let payment_intent_id = PaymentIntentId::from_str(&payment_intent_id) - .map_err(|_| DonationError::OtherError("invalid PaymentIntent id".to_string()))?; + .map_err(|_| DonationError::NotFound("Payment not found"))?; let intent = stripe::PaymentIntent::retrieve(&client, &payment_intent_id, &[]) .await - .map_err(DonationError::StripeError)?; + .map_err(|e| { + warn!( + "notary-certificate: cannot retrieve {}: {:?}", + payment_intent_id, e + ); + DonationError::NotFound("Payment not found") + })?; let notary = quoted_notary( (intent.amount / 100) as u64, diff --git a/rust/cli/generate_notary_keys.sh b/rust/cli/generate_notary_keys.sh index c4b63587..4b246ac7 100755 --- a/rust/cli/generate_notary_keys.sh +++ b/rust/cli/generate_notary_keys.sh @@ -62,6 +62,7 @@ usage() { MASTER_KEY_FILE="" MASTER_VERIFYING_KEY_FILE="" NOTARY_DIR="$DEFAULT_NOTARY_DIR" +NOTARY_DIR_SET=false AMOUNTS=() AMOUNTS_SET=false YEARLY_AMOUNTS=() @@ -81,11 +82,13 @@ while [ $# -gt 0 ]; do ;; --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) @@ -140,6 +143,12 @@ if [ -n "$START_MONTH" ] || [ -n "$MONTHS" ]; 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 yearly 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 diff --git a/rust/cli/test_notary_schedule.sh b/rust/cli/test_notary_schedule.sh index bbd0dca7..b7c4c119 100644 --- a/rust/cli/test_notary_schedule.sh +++ b/rust/cli/test_notary_schedule.sh @@ -67,6 +67,8 @@ 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 --yearly-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. @@ -107,6 +109,8 @@ 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 ]" 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 . )\" ]" From b9b13f8e1795894ddee06afcd610b5fd30b54225 Mon Sep 17 00:00:00 2001 From: Ian Clarke Date: Mon, 28 Sep 2026 20:05:13 -0500 Subject: [PATCH 10/15] fix: address #192 round-3 review - 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 --- hugo-site/static/js/donation-success.js | 19 +++++++++++---- rust/api/publish_notary_window.sh | 32 +++++++++++++++++++++---- rust/api/src/routes.rs | 19 ++++++++------- rust/cli/generate_notary_keys.sh | 15 ++++++------ rust/cli/test_notary_schedule.sh | 18 ++++++++++++++ 5 files changed, 77 insertions(+), 26 deletions(-) diff --git a/hugo-site/static/js/donation-success.js b/hugo-site/static/js/donation-success.js index 44c20f51..d7ad2af1 100644 --- a/hugo-site/static/js/donation-success.js +++ b/hugo-site/static/js/donation-success.js @@ -173,11 +173,20 @@ function generateTestCertificate() { async function fetchQuotedNotaryCertificate(paymentIntentId) { try { const apiUrl = window.ghostkeyApiUrl; - // Bounded, so a hung request falls back instead of leaving the page spinning. - const response = await fetch( - `${apiUrl}/notary-certificate/${encodeURIComponent(paymentIntentId)}`, - { signal: AbortSignal.timeout(20000) } - ); + // 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). + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 20000); + let response; + try { + response = await fetch( + `${apiUrl}/notary-certificate/${encodeURIComponent(paymentIntentId)}`, + { signal: controller.signal } + ); + } finally { + clearTimeout(timer); + } if (!response.ok) { console.warn("Could not fetch quoted notary certificate:", response.status); return null; diff --git a/rust/api/publish_notary_window.sh b/rust/api/publish_notary_window.sh index 9dbd6b46..0d9f659e 100644 --- a/rust/api/publish_notary_window.sh +++ b/rust/api/publish_notary_window.sh @@ -35,8 +35,32 @@ if ! [[ "$ahead" =~ ^[0-9]+$ ]]; then echo "Error: months-ahead must be a whole number" >&2 exit 1 fi -if [ "$(stat -c %d "$schedule")" != "$(stat -c %d "$notary")" ]; then - echo "Error: $schedule and $notary must be on the same filesystem, so a month is renamed into place atomically" >&2 +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 really be private to whoever runs this (root, in production). +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 + +# The owner of can swap any path that leads to it, including for +# a symlink onto another filesystem, where mv would silently fall back to a +# copy that follows symlinks. So pin the directory itself once, check that +# pinned directory, and address it only as "." from here on. +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 is renamed into place atomically" >&2 + exit 1 +fi +if [ "$(stat -c %U .)" != "${owner%%:*}" ]; then + echo "Error: $notary is not owned by ${owner%%:*}" >&2 exit 1 fi @@ -55,13 +79,13 @@ 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 "$notary/$month" ] || [ -L "$notary/$month" ]; then + if [[ "$month" > "$last" ]] || [ -e "./$month" ] || [ -L "./$month" ]; then continue fi rm -rf "${staging:?}/$month" cp -a "$src" "$staging/$month" chown -R "$owner" "$staging/$month" - mv -T "$staging/$month" "$notary/$month" + mv -T "$staging/$month" "./$month" echo "published $month" published=$((published + 1)) done diff --git a/rust/api/src/routes.rs b/rust/api/src/routes.rs index 1afdedc8..845e8784 100644 --- a/rust/api/src/routes.rs +++ b/rust/api/src/routes.rs @@ -416,15 +416,16 @@ async fn notary_certificate_route( // and this endpoint is unauthenticated: 404, logged quietly. let payment_intent_id = PaymentIntentId::from_str(&payment_intent_id) .map_err(|_| DonationError::NotFound("Payment not found"))?; - let intent = stripe::PaymentIntent::retrieve(&client, &payment_intent_id, &[]) - .await - .map_err(|e| { - warn!( - "notary-certificate: cannot retrieve {}: {:?}", - payment_intent_id, e - ); - 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, diff --git a/rust/cli/generate_notary_keys.sh b/rust/cli/generate_notary_keys.sh index 4b246ac7..487a3dc6 100755 --- a/rust/cli/generate_notary_keys.sh +++ b/rust/cli/generate_notary_keys.sh @@ -219,15 +219,13 @@ if [ "$SCHEDULE" = true ]; then sorted() { printf '%s\n' "$@" | sort -n | tr '\n' ' '; } policy="monthly: $(sorted ${AMOUNTS[@]+"${AMOUNTS[@]}"})| yearly: $(sorted ${YEARLY_AMOUNTS[@]+"${YEARLY_AMOUNTS[@]}"})" policy_file="$NOTARY_DIR/.schedule-policy" - if [ -f "$policy_file" ]; then - if [ "$(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/--yearly-amounts." >&2 - exit 1 - fi - else - echo "$policy" >"$policy_file" + 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/--yearly-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 # Holds copies of signing keys during the pair check, so keep it inside the @@ -350,6 +348,7 @@ if [ "$SCHEDULE" = true ]; then mv "$partial" "$dir" partial="" + [ -f "$policy_file" ] || echo "$policy" >"$policy_file" echo "$month: ${#AMOUNTS[@]} monthly and ${#YEARLY_AMOUNTS[@]} yearly notary keypairs ($reused yearly reused from earlier in $year)" done else diff --git a/rust/cli/test_notary_schedule.sh b/rust/cli/test_notary_schedule.sh index b7c4c119..60485d2d 100644 --- a/rust/cli/test_notary_schedule.sh +++ b/rust/cli/test_notary_schedule.sh @@ -81,6 +81,14 @@ check "yearly to monthly is refused" "! run --start-month 2027-04 --months 1 --a check "monthly to yearly is refused" "! run --start-month 2027-04 --months 1 --amounts 1 --yearly-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 year whose existing # month dated a yearly tier some other way is an error, not silently shared. # (February: a January monthly date would equal the yearly one.) @@ -111,6 +119,16 @@ check "published month is never replaced" \ 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" 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 . )\" ]" From 1c5d801f065e1e2862a92094de91e3501793e80d Mon Sep 17 00:00:00 2001 From: Ian Clarke Date: Mon, 28 Sep 2026 20:14:11 -0500 Subject: [PATCH 11/15] fix: address #192 round-4 review - 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 --- hugo-site/static/js/donation-success.js | 16 +++---- rust/api/publish_notary_window.sh | 62 ++++++++++++++++--------- rust/api/src/routes.rs | 32 ++++++++++++- rust/cli/test_notary_schedule.sh | 4 ++ 4 files changed, 84 insertions(+), 30 deletions(-) diff --git a/hugo-site/static/js/donation-success.js b/hugo-site/static/js/donation-success.js index d7ad2af1..ff78c93f 100644 --- a/hugo-site/static/js/donation-success.js +++ b/hugo-site/static/js/donation-success.js @@ -176,23 +176,23 @@ async function fetchQuotedNotaryCertificate(paymentIntentId) { // 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); - let response; try { - response = await fetch( + 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); } - 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; } catch (error) { console.warn("Could not fetch quoted notary certificate:", error); return null; diff --git a/rust/api/publish_notary_window.sh b/rust/api/publish_notary_window.sh index 0d9f659e..e79503c2 100644 --- a/rust/api/publish_notary_window.sh +++ b/rust/api/publish_notary_window.sh @@ -6,22 +6,28 @@ # publish_notary_window.sh [months-ahead] # # holds the full schedule from generate_notary_keys.sh and must -# be root-only. 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. +# 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 anything in it, so nothing -# is staged there: each month is copied and given to inside -# /.staging, where that user cannot reach, then renamed into -# in one step. That is why both must be on one filesystem. No -# chmod is run (cp -a keeps the generator's 700/600 modes), because chmod -# follows symlinks. 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. +# 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 @@ -35,6 +41,8 @@ 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 @@ -42,24 +50,33 @@ for d in "$schedule" "$notary"; do fi done -# The schedule holds every future key and the lock and staging live in it, so -# it must really be private to whoever runs this (root, in production). +# 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 -# The owner of can swap any path that leads to it, including for -# a symlink onto another filesystem, where mv would silently fall back to a -# copy that follows symlinks. So pin the directory itself once, check that -# pinned directory, and address it only as "." from here on. 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 is renamed into place atomically" >&2 + 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%%:*}" ]; then +if [ "$(stat -c %u .)" != "$owner_uid" ]; then echo "Error: $notary is not owned by ${owner%%:*}" >&2 exit 1 fi @@ -83,9 +100,12 @@ for src in "$schedule"/[0-9][0-9][0-9][0-9]-[0-9][0-9]; do continue fi rm -rf "${staging:?}/$month" - cp -a "$src" "$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" + chmod -R u=rwX,go= "$staging/$month" chown -R "$owner" "$staging/$month" - mv -T "$staging/$month" "./$month" + mv --no-copy -T "$staging/$month" "./$month" echo "published $month" published=$((published + 1)) done diff --git a/rust/api/src/routes.rs b/rust/api/src/routes.rs index 845e8784..e1f66b01 100644 --- a/rust/api/src/routes.rs +++ b/rust/api/src/routes.rs @@ -402,6 +402,13 @@ pub struct NotaryCertificateResponse { pub notary_certificate_base64: String, } +/// `pi_` followed by one or more ASCII alphanumerics, the shape of a Stripe +/// PaymentIntent id. +fn is_payment_intent_id(id: &str) -> bool { + id.strip_prefix("pi_") + .is_some_and(|rest| !rest.is_empty() && rest.bytes().all(|b| b.is_ascii_alphanumeric())) +} + /// 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 @@ -413,7 +420,12 @@ async fn notary_certificate_route( let client = Client::new(&secret_key); // A malformed or unknown id is the caller's problem, not a server error, - // and this endpoint is unauthenticated: 404, logged quietly. + // and this endpoint is unauthenticated: 404, logged quietly. The id ends up + // in a Stripe API path and PaymentIntentId::from_str only checks the + // prefix, so accept nothing but pi_ followed by alphanumerics. + if !is_payment_intent_id(&payment_intent_id) { + return Err(DonationError::NotFound("Payment not found")); + } 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 @@ -771,6 +783,24 @@ pub fn get_invite_routes(state: InviteState) -> Router { .layer(cors) } +#[cfg(test)] +mod notary_certificate_route_tests { + #[test] + fn only_plain_payment_intent_ids_reach_stripe() { + assert!(super::is_payment_intent_id("pi_3PxYz0ABCdef123")); + for bad in [ + "pi_", + "pi_x/../../customers/cus_1", + "pi_x%2F..", + "pi_abc?expand=x", + "cus_123", + "", + ] { + assert!(!super::is_payment_intent_id(bad), "{bad:?} accepted"); + } + } +} + #[cfg(test)] mod invite_handler_tests { /// #4 client-IP keying behind the caddy reverse proxy (2026-08-28 regression). diff --git a/rust/cli/test_notary_schedule.sh b/rust/cli/test_notary_schedule.sh index 60485d2d..d78f7852 100644 --- a/rust/cli/test_notary_schedule.sh +++ b/rust/cli/test_notary_schedule.sh @@ -129,6 +129,10 @@ 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" 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 . )\" ]" From 55918c1eaceed86fc65f19e3b4cbc49af1da55b2 Mon Sep 17 00:00:00 2001 From: Ian Clarke Date: Mon, 28 Sep 2026 20:23:04 -0500 Subject: [PATCH 12/15] fix: address #192 round-5 review - 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 --- rust/api/publish_notary_window.sh | 3 +- rust/api/src/delegates.rs | 47 +++++++++++++++++++++++++++++++ rust/api/src/routes.rs | 2 +- rust/cli/generate_notary_keys.sh | 31 +++++++++++++++----- rust/cli/test_notary_schedule.sh | 19 +++++++++++++ 5 files changed, 93 insertions(+), 9 deletions(-) diff --git a/rust/api/publish_notary_window.sh b/rust/api/publish_notary_window.sh index e79503c2..13149bc2 100644 --- a/rust/api/publish_notary_window.sh +++ b/rust/api/publish_notary_window.sh @@ -103,7 +103,8 @@ for src in "$schedule"/[0-9][0-9][0-9][0-9]-[0-9][0-9]; do # 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" - chmod -R u=rwX,go= "$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" diff --git a/rust/api/src/delegates.rs b/rust/api/src/delegates.rs index a752b80f..7c79df3a 100644 --- a/rust/api/src/delegates.rs +++ b/rust/api/src/delegates.rs @@ -231,6 +231,24 @@ fn read_pair( 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)) } @@ -409,6 +427,35 @@ mod tests { 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(); diff --git a/rust/api/src/routes.rs b/rust/api/src/routes.rs index e1f66b01..a2b5a66b 100644 --- a/rust/api/src/routes.rs +++ b/rust/api/src/routes.rs @@ -420,7 +420,7 @@ async fn notary_certificate_route( let client = Client::new(&secret_key); // A malformed or unknown id is the caller's problem, not a server error, - // and this endpoint is unauthenticated: 404, logged quietly. The id ends up + // and this endpoint is unauthenticated: 404, not an error log. The id ends up // in a Stripe API path and PaymentIntentId::from_str only checks the // prefix, so accept nothing but pi_ followed by alphanumerics. if !is_payment_intent_id(&payment_intent_id) { diff --git a/rust/cli/generate_notary_keys.sh b/rust/cli/generate_notary_keys.sh index 487a3dc6..de3ed554 100755 --- a/rust/cli/generate_notary_keys.sh +++ b/rust/cli/generate_notary_keys.sh @@ -270,6 +270,21 @@ verify_cert() { fi } +# 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 + fi +} + # generate_pair generate_pair() { local dir="$1" amount="$2" created="$3" @@ -290,13 +305,7 @@ generate_pair() { verify_cert "$scratch/notary/notary_certificate.pem" "$info" - if ! "$GHOSTKEY" generate-ghost-key --notary-dir "$scratch/notary" \ - --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 the new notary for amount $amount does not verify" >&2 - exit 1 - fi + 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" @@ -311,6 +320,14 @@ existing_yearly_dir() { for d in "$NOTARY_DIR/$year"-[0-9][0-9]; do if [ -f "$d/notary_certificate_$amount.pem" ]; then verify_cert "$d/notary_certificate_$amount.pem" "$(info_for "$amount" "$year-01-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 $year pair for amount $amount in $d" + rm -rf "$scratch/reuse" echo "$d" return fi diff --git a/rust/cli/test_notary_schedule.sh b/rust/cli/test_notary_schedule.sh index d78f7852..e6758d31 100644 --- a/rust/cli/test_notary_schedule.sh +++ b/rust/cli/test_notary_schedule.sh @@ -55,6 +55,16 @@ check "extension reuses the year's yearly pair" \ check "extension still mints new monthly pairs" \ "! same $D/2027-01/notary_certificate_1.pem $D/2027-02/notary_certificate_1.pem" +# A reused yearly pair is checked as a pair, not just its certificate: swap in +# another valid signing key and an extension into that year 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" +cp "$D/2027-01/notary_signing_key_5.pem" "$D/2027-03/notary_signing_key_100.pem" +check "a reused yearly pair whose key does not match is refused" \ + "! run --start-month 2027-04 --months 1 && grep -q 'does not verify' $tmp/out && [ ! -e $D/2027-04 ]" +for m in 2027-01 2027-02 2027-03; do cp -p "$tmp/key100.bak" "$D/$m/notary_signing_key_100.pem"; done + # Months are only ever added. # shellcheck disable=SC2034 # read inside check's eval before=$(find "$D" -type f -exec sha256sum {} + | sort) @@ -133,6 +143,15 @@ mkdir -m 777 "$tmp/open" && chmod 777 "$tmp/open" && mkdir -m 700 "$tmp/open/sch 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)\" ]" 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 . )\" ]" From dd3de9953b1be3c4f7a482356a0fdfaf069119f3 Mon Sep 17 00:00:00 2001 From: Ian Clarke Date: Mon, 28 Sep 2026 20:28:31 -0500 Subject: [PATCH 13/15] fix: address #192 round-6 review (lows) - 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 --- rust/api/publish_notary_window.sh | 6 ++++++ rust/cli/test_notary_schedule.sh | 6 +++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/rust/api/publish_notary_window.sh b/rust/api/publish_notary_window.sh index 13149bc2..7172d138 100644 --- a/rust/api/publish_notary_window.sh +++ b/rust/api/publish_notary_window.sh @@ -99,6 +99,12 @@ for src in "$schedule"/[0-9][0-9][0-9][0-9]-[0-9][0-9]; do 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. diff --git a/rust/cli/test_notary_schedule.sh b/rust/cli/test_notary_schedule.sh index e6758d31..65b5575e 100644 --- a/rust/cli/test_notary_schedule.sh +++ b/rust/cli/test_notary_schedule.sh @@ -62,7 +62,7 @@ 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" cp "$D/2027-01/notary_signing_key_5.pem" "$D/2027-03/notary_signing_key_100.pem" check "a reused yearly pair whose key does not match is refused" \ - "! run --start-month 2027-04 --months 1 && grep -q 'does not verify' $tmp/out && [ ! -e $D/2027-04 ]" + "! run --start-month 2027-04 --months 1 && grep -q 'a ghost key issued by the existing 2027 pair' $tmp/out && [ ! -e $D/2027-04 ]" for m in 2027-01 2027-02 2027-03; do cp -p "$tmp/key100.bak" "$D/$m/notary_signing_key_100.pem"; done # Months are only ever added. @@ -152,6 +152,10 @@ 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 . )\" ]" From 8ec1955528ad7843370863647afa98b00289cc89 Mon Sep 17 00:00:00 2001 From: Ian Clarke Date: Mon, 28 Sep 2026 21:01:38 -0500 Subject: [PATCH 14/15] feat(cli): date $20+ tiers quarterly instead of yearly 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 --- .github/workflows/rust-api-tests.yml | 2 +- rust/api/README.md | 18 ++++-- rust/cli/generate_notary_keys.sh | 90 ++++++++++++++++------------ rust/cli/test_notary_schedule.sh | 47 ++++++++------- 4 files changed, 89 insertions(+), 68 deletions(-) diff --git a/.github/workflows/rust-api-tests.yml b/.github/workflows/rust-api-tests.yml index b9c3c52a..3207c2b6 100644 --- a/.github/workflows/rust-api-tests.yml +++ b/.github/workflows/rust-api-tests.yml @@ -67,7 +67,7 @@ jobs: 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 "yearly" key for a year) + # 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 diff --git a/rust/api/README.md b/rust/api/README.md index aeda374f..13cacabd 100644 --- a/rust/api/README.md +++ b/rust/api/README.md @@ -121,12 +121,18 @@ 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 yearly, because 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). The script verifies every pair against the -compiled-in Freenet master key, builds each month in a hidden directory before renaming it -into place, refuses to touch an existing month, and reuses a year's yearly pairs when -extending a schedule mid-year. `rust/cli/test_notary_schedule.sh` tests it (CI runs it). +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. + 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. diff --git a/rust/cli/generate_notary_keys.sh b/rust/cli/generate_notary_keys.sh index de3ed554..3fe532d7 100755 --- a/rust/cli/generate_notary_keys.sh +++ b/rust/cli/generate_notary_keys.sh @@ -14,18 +14,21 @@ # 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 --yearly-amounts tiers are dated yearly (1 January): one -# keypair per year, shared by all of that year's 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. +# 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: 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 year that already has a month in --notary-dir reuses that month's yearly -# keypairs instead of minting a second set, so a schedule can be extended -# later without splitting a year's anonymity set. Each month is built in a +# 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 @@ -46,7 +49,7 @@ set -euo pipefail # 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_YEARLY_AMOUNTS=(20 50 100 500 2500 10000) +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 @@ -54,7 +57,7 @@ OVERWRITE=false usage() { echo "Usage: $0 --master-key [--notary-dir ]" >&2 echo " [--amounts ...] [--overwrite]" >&2 - echo " [--start-month YYYY-MM --months N [--yearly-amounts ...]]" >&2 + echo " [--start-month YYYY-MM --months N [--quarterly-amounts ...]]" >&2 echo " [--master-verifying-key ]" >&2 exit 1 } @@ -65,8 +68,8 @@ NOTARY_DIR="$DEFAULT_NOTARY_DIR" NOTARY_DIR_SET=false AMOUNTS=() AMOUNTS_SET=false -YEARLY_AMOUNTS=() -YEARLY_AMOUNTS_SET=false +QUARTERLY_AMOUNTS=() +QUARTERLY_AMOUNTS_SET=false START_MONTH="" MONTHS="" @@ -99,11 +102,11 @@ while [ $# -gt 0 ]; do shift done ;; - --yearly-amounts) + --quarterly-amounts) shift - YEARLY_AMOUNTS_SET=true + QUARTERLY_AMOUNTS_SET=true while [[ $# -gt 0 && ! "$1" =~ ^-- ]]; do - YEARLY_AMOUNTS+=("$1") + QUARTERLY_AMOUNTS+=("$1") shift done ;; @@ -144,7 +147,7 @@ if [ -n "$START_MONTH" ] || [ -n "$MONTHS" ]; then 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 yearly keys. + # 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 @@ -156,12 +159,12 @@ if [ -n "$START_MONTH" ] || [ -n "$MONTHS" ]; then if [ "$AMOUNTS_SET" = false ]; then AMOUNTS=("${DEFAULT_SCHEDULE_MONTHLY_AMOUNTS[@]}") fi - if [ "$YEARLY_AMOUNTS_SET" = false ]; then - YEARLY_AMOUNTS=("${DEFAULT_SCHEDULE_YEARLY_AMOUNTS[@]}") + if [ "$QUARTERLY_AMOUNTS_SET" = false ]; then + QUARTERLY_AMOUNTS=("${DEFAULT_SCHEDULE_QUARTERLY_AMOUNTS[@]}") fi else - if [ "$YEARLY_AMOUNTS_SET" = true ]; then - echo "Error: --yearly-amounts only applies with --start-month/--months." >&2 + if [ "$QUARTERLY_AMOUNTS_SET" = true ]; then + echo "Error: --quarterly-amounts only applies with --start-month/--months." >&2 usage fi if [ "$AMOUNTS_SET" = false ]; then @@ -171,7 +174,7 @@ 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[@]}"} ${YEARLY_AMOUNTS[@]+"${YEARLY_AMOUNTS[@]}"}) +ALL_AMOUNTS=(${AMOUNTS[@]+"${AMOUNTS[@]}"} ${QUARTERLY_AMOUNTS[@]+"${QUARTERLY_AMOUNTS[@]}"}) if [ ${#ALL_AMOUNTS[@]} -eq 0 ]; then echo "Error: no amounts to generate." >&2 exit 1 @@ -183,7 +186,7 @@ for a in "${ALL_AMOUNTS[@]}"; do fi done if [ "$(printf '%s\n' "${ALL_AMOUNTS[@]}" | sort | uniq -d)" != "" ]; then - echo "Error: an amount is listed more than once (across --amounts and --yearly-amounts)." >&2 + echo "Error: an amount is listed more than once (across --amounts and --quarterly-amounts)." >&2 exit 1 fi @@ -213,15 +216,15 @@ mkdir -p "$NOTARY_DIR" chmod 700 "$NOTARY_DIR" # A schedule keeps one dating policy for its whole life. Moving a tier between -# monthly and yearly part-way would split that tier's anonymity set, so the +# 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[@]}"})| yearly: $(sorted ${YEARLY_AMOUNTS[@]+"${YEARLY_AMOUNTS[@]}"})" + 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/--yearly-amounts." >&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 @@ -312,21 +315,30 @@ generate_pair() { chmod 600 "$dir/notary_signing_key_$amount.pem" "$dir/notary_certificate_$amount.pem" } -# The completed month directory of already holding the yearly 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_yearly_dir() { - local year="$1" amount="$2" d - for d in "$NOTARY_DIR/$year"-[0-9][0-9]; do +# 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" "$year-01-01 00:00:00")" + 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 $year pair for amount $amount in $d" + verify_pair "$scratch/reuse" "the existing $qstart quarter pair for amount $amount in $d" rm -rf "$scratch/reuse" echo "$d" return @@ -337,7 +349,7 @@ existing_yearly_dir() { if [ "$SCHEDULE" = true ]; then for ((i = 0; i < MONTHS; i++)); do month=$(date -u -d "$START_MONTH-01 +$i month" +%Y-%m) - year=${month%-*} + 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 @@ -353,20 +365,20 @@ if [ "$SCHEDULE" = true ]; then done reused=0 - for amount in ${YEARLY_AMOUNTS[@]+"${YEARLY_AMOUNTS[@]}"}; do - src=$(existing_yearly_dir "$year" "$amount") + 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" "$year-01-01 00:00:00" + 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 ${#YEARLY_AMOUNTS[@]} yearly notary keypairs ($reused yearly reused from earlier in $year)" + echo "$month: ${#AMOUNTS[@]} monthly and ${#QUARTERLY_AMOUNTS[@]} quarterly notary keypairs ($reused reused from earlier in the quarter)" done else for amount in "${AMOUNTS[@]}"; do diff --git a/rust/cli/test_notary_schedule.sh b/rust/cli/test_notary_schedule.sh index 65b5575e..6cce4cc2 100644 --- a/rust/cli/test_notary_schedule.sh +++ b/rust/cli/test_notary_schedule.sh @@ -32,7 +32,7 @@ info() { --notary-certificate "$1" | sed -n 's/^Info: //p' } -# A schedule across a year boundary. +# 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 ]" @@ -41,29 +41,31 @@ 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 "yearly tier shared within a year" \ +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 "yearly tier changes with the year" \ +check "quarterly tier changes with the quarter" \ "! same $D/2026-12/notary_certificate_20.pem $D/2027-01/notary_certificate_20.pem" -check "yearly tier dated 1 January" \ - "[ \"\$(info $D/2027-01/notary_certificate_10000.pem)\" = '{\"action\":\"freenet-donation\",\"amount\":10000,\"delegate-key-created\":\"2027-01-01 00:00:00\"}' ]" +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-year in a later run must reuse that year's yearly pairs. -check "extension run succeeds" "run --start-month 2027-02 --months 2" -check "extension reuses the year's yearly pair" \ - "same $D/2027-01/notary_certificate_100.pem $D/2027-03/notary_certificate_100.pem && same $D/2027-01/notary_signing_key_100.pem $D/2027-03/notary_signing_key_100.pem" +# 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 yearly pair is checked as a pair, not just its certificate: swap in -# another valid signing key and an extension into that year must refuse it. +# 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" -cp "$D/2027-01/notary_signing_key_5.pem" "$D/2027-03/notary_signing_key_100.pem" -check "a reused yearly pair whose key does not match is refused" \ - "! run --start-month 2027-04 --months 1 && grep -q 'a ghost key issued by the existing 2027 pair' $tmp/out && [ ! -e $D/2027-04 ]" -for m in 2027-01 2027-02 2027-03; do cp -p "$tmp/key100.bak" "$D/$m/notary_signing_key_100.pem"; done +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 @@ -75,7 +77,7 @@ check "refusals changed nothing" "[ \"\$(find $D -type f -exec sha256sum {} + | # 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 --yearly-amounts 5" +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" @@ -87,8 +89,8 @@ check "wrong master key is refused" \ check "failed run leaves no month directory" "[ ! -e $D/2027-04 ]" # Changing the dating policy of an existing schedule is refused, both ways. -check "yearly to monthly is refused" "! run --start-month 2027-04 --months 1 --amounts 1 5 20 --yearly-amounts 50 100 500 2500 10000" -check "monthly to yearly is refused" "! run --start-month 2027-04 --months 1 --amounts 1 --yearly-amounts 5 20 50 100 500 2500 10000" +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. @@ -99,12 +101,13 @@ 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 year whose existing -# month dated a yearly tier some other way is an error, not silently shared. -# (February: a January monthly date would equal the yearly one.) +# 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 --yearly-amounts" +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 ]" From 7d26a37e6a56a4aae47df37be3cb55848dca8fe2 Mon Sep 17 00:00:00 2001 From: Ian Clarke Date: Mon, 28 Sep 2026 21:09:12 -0500 Subject: [PATCH 15/15] fix: address #192 round-7 review (lows) - /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 --- rust/api/README.md | 3 ++- rust/api/src/handle_sign_cert.rs | 10 ++++++---- rust/api/src/routes.rs | 6 +++--- rust/cli/generate_notary_keys.sh | 7 ++++--- rust/cli/test_notary_schedule.sh | 14 ++++++++++++++ 5 files changed, 29 insertions(+), 11 deletions(-) diff --git a/rust/api/README.md b/rust/api/README.md index 13cacabd..2e6bc65b 100644 --- a/rust/api/README.md +++ b/rust/api/README.md @@ -127,7 +127,8 @@ April, July or October). 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. - A key is never shown more than three months older than it is. + 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 diff --git a/rust/api/src/handle_sign_cert.rs b/rust/api/src/handle_sign_cert.rs index 2c97815b..f792780d 100644 --- a/rust/api/src/handle_sign_cert.rs +++ b/rust/api/src/handle_sign_cert.rs @@ -296,10 +296,8 @@ mod tests { squeeze(production) } - /// 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 - /// still returns a valid certificate. + /// 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(); @@ -323,6 +321,10 @@ mod tests { ); } + /// 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 + /// still returns a valid certificate. #[test] fn claim_is_taken_before_the_signed_flag_is_read() { let source = production_source(); diff --git a/rust/api/src/routes.rs b/rust/api/src/routes.rs index 5f106d45..c245e89c 100644 --- a/rust/api/src/routes.rs +++ b/rust/api/src/routes.rs @@ -429,15 +429,15 @@ pub struct NotaryCertificateResponse { async fn notary_certificate_route( Path(payment_intent_id): Path, ) -> Result, DonationError> { - let secret_key = std::env::var("STRIPE_SECRET_KEY").map_err(DonationError::EnvError)?; - let client = Client::new(&secret_key); - // 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 diff --git a/rust/cli/generate_notary_keys.sh b/rust/cli/generate_notary_keys.sh index 3fe532d7..37e13baf 100755 --- a/rust/cli/generate_notary_keys.sh +++ b/rust/cli/generate_notary_keys.sh @@ -21,8 +21,9 @@ # 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: a key is never shown more than -# three months older than it is. +# 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 @@ -378,7 +379,7 @@ if [ "$SCHEDULE" = true ]; then mv "$partial" "$dir" partial="" [ -f "$policy_file" ] || echo "$policy" >"$policy_file" - echo "$month: ${#AMOUNTS[@]} monthly and ${#QUARTERLY_AMOUNTS[@]} quarterly notary keypairs ($reused reused from earlier in the quarter)" + echo "$month: ${#AMOUNTS[@]} monthly and ${#QUARTERLY_AMOUNTS[@]} quarterly notary keypairs ($reused reused from elsewhere in the quarter)" done else for amount in "${AMOUNTS[@]}"; do diff --git a/rust/cli/test_notary_schedule.sh b/rust/cli/test_notary_schedule.sh index 6cce4cc2..c5ac604d 100644 --- a/rust/cli/test_notary_schedule.sh +++ b/rust/cli/test_notary_schedule.sh @@ -163,6 +163,20 @@ check "warns when 12 or fewer months remain" "publish 2027-01 && grep -q 'WARNIN 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)\" ]"