From 445ac0dddd123f34fcf76173361ed3b319f2e383 Mon Sep 17 00:00:00 2001 From: nisfeb <78994617+nisfeb@users.noreply.github.com> Date: Mon, 29 Jun 2026 15:29:05 -0400 Subject: [PATCH] feat: add GPG signing for release PDFs Add detached-signature tooling so anyone can confirm a GhostNet PDF is the genuine S2 Underground release, even when the file travels off GitHub by radio or sneakernet. Addresses issue #13. A detached .asc is committed beside each PDF and travels with it, which serves the real threat model (forged PDFs circulating offline) better than git commit or tag signing would. - sign-ghostnet.sh: signer produces a detached armored signature. - verify-ghostnet.sh: anyone imports the bundled key into a throwaway keyring, sees the fingerprint to cross-check, and verifies. - selftest.sh: proves the logic with an ephemeral key, no network. - hooks/pre-commit: optional auto-sign for the signer (opt-in). - README: trust model and out-of-band key publication guidance. No public key is bundled; verify fails clearly until S2 Underground publishes a key, so trust rests on an out-of-band fingerprint. --- hooks/pre-commit | 17 +++++++++ signing/README.md | 74 ++++++++++++++++++++++++++++++++++++++ signing/selftest.sh | 41 +++++++++++++++++++++ signing/sign-ghostnet.sh | 37 +++++++++++++++++++ signing/verify-ghostnet.sh | 60 +++++++++++++++++++++++++++++++ 5 files changed, 229 insertions(+) create mode 100755 hooks/pre-commit create mode 100644 signing/README.md create mode 100755 signing/selftest.sh create mode 100755 signing/sign-ghostnet.sh create mode 100755 signing/verify-ghostnet.sh diff --git a/hooks/pre-commit b/hooks/pre-commit new file mode 100755 index 0000000..d1fa1f1 --- /dev/null +++ b/hooks/pre-commit @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Optional convenience for the GhostNet signer: when a PDF is committed, re-sign +# it so its .asc never goes stale, and stage the signature in the same commit. +# +# Install (signer only): git config core.hooksPath hooks +# +# ponytail: convenience only, not enforcement. Git does not distribute hooks to +# people who clone, and this is bypassable with `git commit --no-verify`. The +# real mechanism is signing/sign-ghostnet.sh; this just runs it automatically. +set -euo pipefail + +cd "$(git rev-parse --show-toplevel)" +mapfile -t pdfs < <(git diff --cached --name-only --diff-filter=ACM -- '*.pdf') +[ "${#pdfs[@]}" -gt 0 ] || exit 0 + +signing/sign-ghostnet.sh "${pdfs[@]}" +git add "${pdfs[@]/%/.asc}" diff --git a/signing/README.md b/signing/README.md new file mode 100644 index 0000000..6f08e94 --- /dev/null +++ b/signing/README.md @@ -0,0 +1,74 @@ +GhostNet PDF Signing +==================== + +**Status: Proposed.** Opened in response to +[issue #13](https://github.com/s2underground/GhostNet/issues/13). Signing is not +active until S2 Underground publishes a signing key (see "For the signer"). + +Why +--- +The concern in issue #13 is forged copies of the GhostNet manual circulating +with altered information, especially once a PDF leaves GitHub and travels by +radio, sneakernet, or reposts. A detached GPG signature lets anyone confirm a +PDF is the genuine S2 Underground release, wherever they got it. + +The signature (`.pdf.asc`) is committed next to each PDF and travels with +the file. This is deliberately not git commit/tag signing: that would only help +people who obtained the file through GitHub, who are the ones least at risk. + +The trust model (read this) +--------------------------- +A valid signature only proves the PDF matches **the key in this repository**. +Anyone can fork the repo, swap in their own key, and re-sign a forged PDF. The +signature is therefore only meaningful if you verify the key's **fingerprint +against a copy published out of band**, somewhere an attacker cannot quietly +change: + +- read aloud / sent during a live net, +- printed inside the manual PDF itself, +- posted on QRZ and on more than one independent platform, +- anywhere the real S2 Underground identity is already established. + +If the fingerprint the verify script prints does not match a source you trust, +the signature means nothing. Do not skip this step. + +For anyone: verify a PDF +------------------------ +Requires `gpg`. + +```sh +signing/verify-ghostnet.sh # verify all signed PDFs +signing/verify-ghostnet.sh GhostNet_Version_1.5.pdf +``` + +It imports the bundled key into a throwaway keyring (your own keyring is never +touched), prints the fingerprint to cross-check, and reports `OK` or `FAIL` per +file. A non-zero exit means at least one file failed. + +For the signer (S2 Underground) +------------------------------- +1. Publish your public key and its fingerprint out of band (see trust model). +2. Add the public key to this repo as `signing/GHOSTNET-signing-key.asc`: + ```sh + gpg --armor --export > signing/GHOSTNET-signing-key.asc + ``` +3. Sign the release PDFs and commit the `.asc` files: + ```sh + signing/sign-ghostnet.sh # signs all tracked PDFs + git add *.pdf.asc signing/GHOSTNET-signing-key.asc + ``` + Pick a specific key with `-u ` or `GHOSTNET_SIGNING_KEY=`. + +Optional: sign automatically on commit: +```sh +git config core.hooksPath hooks +``` +The `hooks/pre-commit` hook then re-signs any staged PDF and stages its +signature. This is a convenience for the signer only; git does not distribute +hooks to people who clone. + +Checking the scripts +-------------------- +`signing/selftest.sh` generates a throwaway key, signs a sample, and asserts a +good signature verifies while a tampered file is rejected. No network, no +changes to your keyring. diff --git a/signing/selftest.sh b/signing/selftest.sh new file mode 100755 index 0000000..a0d5ee4 --- /dev/null +++ b/signing/selftest.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Self-test for sign-ghostnet.sh / verify-ghostnet.sh. +# Generates a throwaway key in a temp keyring (no network, your real keyring is +# untouched), signs a dummy file, and asserts: good signature verifies, a +# tampered file does NOT. Exercises both scripts end to end. +# +# Run: signing/selftest.sh +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +work="$(mktemp -d)" +export GNUPGHOME="$work/gnupg" +mkdir -p "$GNUPGHOME"; chmod 700 "$GNUPGHOME" +trap 'rm -rf "$work"' EXIT + +echo "1. generating throwaway signing key" +gpg --homedir "$GNUPGHOME" --batch --quiet \ + --quick-generate-key "GhostNet Selftest " \ + default default none +fpr="$(gpg --homedir "$GNUPGHOME" --batch --with-colons --fingerprint \ + | awk -F: '/^fpr:/{print $10; exit}')" +gpg --homedir "$GNUPGHOME" --batch --armor --export "$fpr" > "$work/pub.asc" + +cd "$work" +printf 'GhostNet selftest payload\n' > sample.pdf + +echo "2. signing with sign-ghostnet.sh" +GHOSTNET_SIGNING_KEY="$fpr" "$here/sign-ghostnet.sh" sample.pdf +[ -f sample.pdf.asc ] || { echo "FAIL: no signature produced"; exit 1; } + +echo "3. verifying a good signature (expect OK)" +GHOSTNET_KEY_FILE="$work/pub.asc" "$here/verify-ghostnet.sh" sample.pdf \ + || { echo "FAIL: good signature did not verify"; exit 1; } + +echo "4. tampering the file (expect FAIL)" +printf 'forged content\n' > sample.pdf +if GHOSTNET_KEY_FILE="$work/pub.asc" "$here/verify-ghostnet.sh" sample.pdf; then + echo "FAIL: tampered file verified as good"; exit 1 +fi + +echo "PASS: good signature verifies, tampered file rejected" diff --git a/signing/sign-ghostnet.sh b/signing/sign-ghostnet.sh new file mode 100755 index 0000000..05e12f9 --- /dev/null +++ b/signing/sign-ghostnet.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Sign GhostNet release PDFs with a detached, armored GPG signature. +# Intended for the S2 Underground signer only. Produces ".asc" next to +# each PDF, which travels with the file even when it leaves GitHub. +# +# Usage: +# signing/sign-ghostnet.sh [file ...] +# With no arguments, signs every *.pdf tracked in the repository root. +# +# Key selection (first match wins): +# -u / --local-user KEYID explicit key +# $GHOSTNET_SIGNING_KEY environment override +# gpg default key otherwise +set -euo pipefail + +command -v gpg >/dev/null || { echo "error: gpg not found" >&2; exit 127; } + +key="${GHOSTNET_SIGNING_KEY:-}" +case "${1:-}" in + -u|--local-user) key="$2"; shift 2 ;; +esac + +# Default target list: tracked PDFs in the repo root. +if [ "$#" -gt 0 ]; then + pdfs=("$@") +else + mapfile -t pdfs < <(git ls-files -- '*.pdf' 2>/dev/null || true) +fi +[ "${#pdfs[@]}" -gt 0 ] || { echo "no PDFs to sign" >&2; exit 0; } + +for pdf in "${pdfs[@]}"; do + [ -f "$pdf" ] || { echo "skip (missing): $pdf" >&2; continue; } + gpg --batch --yes --armor --detach-sign \ + ${key:+--local-user "$key"} \ + --output "$pdf.asc" "$pdf" + echo "signed: $pdf -> $pdf.asc" +done diff --git a/signing/verify-ghostnet.sh b/signing/verify-ghostnet.sh new file mode 100755 index 0000000..0f0a7c5 --- /dev/null +++ b/signing/verify-ghostnet.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# Verify GhostNet release PDFs against their detached GPG signatures. +# Anyone can run this. It imports the bundled GhostNet signing key into a +# throwaway keyring (your own keyring is never touched) and checks each PDF. +# +# IMPORTANT: a good signature only proves the file matches the key in this +# repo. Confirm the printed fingerprint against a copy published OUT OF BAND +# (read during a net, printed inside the PDF, posted on QRZ / multiple sites) +# before you trust it. See signing/README.md. +# +# Usage: +# signing/verify-ghostnet.sh [file ...] +# With no arguments, verifies every *.pdf that has a matching *.asc. +# +# Key file: signing/GHOSTNET-signing-key.asc (override with $GHOSTNET_KEY_FILE). +set -euo pipefail + +command -v gpg >/dev/null || { echo "error: gpg not found" >&2; exit 127; } + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +keyfile="${GHOSTNET_KEY_FILE:-$here/GHOSTNET-signing-key.asc}" +if [ ! -f "$keyfile" ]; then + echo "error: signing key not found: $keyfile" >&2 + echo "The S2 Underground public key has not been added yet. Once published," >&2 + echo "save it as signing/GHOSTNET-signing-key.asc." >&2 + exit 2 +fi + +# Throwaway keyring so we never touch the user's real GnuPG home. +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT +chmod 700 "$tmp" +gpg --homedir "$tmp" --batch --quiet --import "$keyfile" +echo "Imported signing key. Cross-check this fingerprint out of band:" +gpg --homedir "$tmp" --batch --fingerprint --with-colons \ + | awk -F: '/^fpr:/{print " " $10; exit}' +echo + +# Target list: explicit args, or every PDF that has a signature. +if [ "$#" -gt 0 ]; then + pdfs=("$@") +else + pdfs=() + for sig in *.asc; do + [ -e "$sig" ] || continue + pdfs+=("${sig%.asc}") + done +fi +[ "${#pdfs[@]}" -gt 0 ] || { echo "no signed PDFs found" >&2; exit 0; } + +failed=0 +for pdf in "${pdfs[@]}"; do + if gpg --homedir "$tmp" --batch --verify "$pdf.asc" "$pdf" 2>/dev/null; then + echo "OK: $pdf" + else + echo "FAIL: $pdf" + failed=1 + fi +done +exit "$failed"