diff --git a/README.md b/README.md index d3a6bc6..160a2ec 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,41 @@ Type `@` in Claude Code to see available Resources. You can ask your LLM to add new Resources by providing an `https://` URI to a public webpage or a `beam://` URI to a file in Clay. +## Contributing + +This repo requires commits to be signed with a [Groundwire](https://groundwire.network) identity. PRs with unsigned commits will be rejected by CI. + +### Setup commit signing + +You need an Urbit ship running the `%vitriol` agent. + +**Quick install:** + +```bash +./hooks/install.sh /vitriol "" +``` + +**Manual install:** + +```bash +git config gpg.program /path/to/hooks/groundwire-sign +git config commit.gpgsign true +git config groundwire.sign-endpoint /vitriol +git config groundwire.sign-token "" +``` + +Once configured, all commits will be automatically signed with your ship's Ed25519 networking key. The CI verifies signatures against on-chain keys via [vitriol.bot](https://vitriol.bot). + +### Re-signing existing commits + +If you have unsigned commits on a branch: + +```bash +git rebase --exec "true" HEAD~N +``` + +(where N is the number of commits to re-sign) + ## Development ### Build Commands diff --git a/hooks/groundwire-sign b/hooks/groundwire-sign new file mode 100755 index 0000000..803ec70 --- /dev/null +++ b/hooks/groundwire-sign @@ -0,0 +1,196 @@ +#!/usr/bin/env bash +# +# groundwire-sign — custom gpg.program for signing git commits with a +# Groundwire key via a remote signing service (an Urbit ship running %vitriol). +# +# Git calls gpg.program with: +# gpg.program --status-fd=2 -bsau +# and pipes the commit object (minus gpgsig) to stdin. +# +# This script: +# 1. Reads the commit content from stdin +# 2. Optionally fetches the maintainer's ecash pubkey and sats-per-pr price +# 3. Sends content (+ sats_required if applicable) to the committer's ship +# 4. Returns a structured signature block on stdout +# 5. Emits GPG-compatible status on fd 2 +# +# The signature block includes the signer's @p so the CI verifier can +# look up the identity on-chain via its own ship, rather than trusting +# the committer's self-reported pubkey. +# +# Configuration (git config or env vars): +# GROUNDWIRE_SIGN_ENDPOINT — base URL of the committer's ship (e.g. http://localhost:8080/vitriol) +# GROUNDWIRE_SIGN_TOKEN — auth cookie for the ship +# GROUNDWIRE_MAINTAINER_ENDPOINT — (optional) base URL of the maintainer's ship for ecash pubkey +# GROUNDWIRE_MAINTAINER_TOKEN — (optional) auth cookie for the maintainer's ship +# +# Install: +# git config --global gpg.program /path/to/groundwire-sign +# git config --global commit.gpgsign true +# + +set -euo pipefail + +# --- Configuration ----------------------------------------------------------- + +ENDPOINT="${GROUNDWIRE_SIGN_ENDPOINT:-$(git config --get groundwire.sign-endpoint 2>/dev/null || echo "")}" +TOKEN="${GROUNDWIRE_SIGN_TOKEN:-$(git config --get groundwire.sign-token 2>/dev/null || echo "")}" +MAINTAINER_ENDPOINT="${GROUNDWIRE_MAINTAINER_ENDPOINT:-$(git config --get groundwire.maintainer-endpoint 2>/dev/null || echo "")}" +MAINTAINER_TOKEN="${GROUNDWIRE_MAINTAINER_TOKEN:-$(git config --get groundwire.maintainer-token 2>/dev/null || echo "")}" + +if [ -z "$ENDPOINT" ]; then + echo "groundwire-sign: GROUNDWIRE_SIGN_ENDPOINT not set" >&2 + echo " Set via: git config --global groundwire.sign-endpoint " >&2 + echo " Or env: export GROUNDWIRE_SIGN_ENDPOINT=" >&2 + exit 1 +fi + +# --- Parse git's gpg arguments ----------------------------------------------- + +STATUS_FD="" +KEY_ID="" + +while [ $# -gt 0 ]; do + case "$1" in + --status-fd=*) + STATUS_FD="${1#--status-fd=}" + ;; + --status-fd) + shift + STATUS_FD="$1" + ;; + -bsau) + shift + KEY_ID="$1" + ;; + *) + ;; + esac + shift +done + +# --- Read commit content from stdin ------------------------------------------- + +COMMIT_CONTENT=$(cat) + +# --- Fetch maintainer's ecash pubkey and price (optional) -------------------- + +ECASH_PUBKEY="" +SATS_REQUIRED="" + +if [ -n "$MAINTAINER_ENDPOINT" ]; then + # Fetch ecash pubkey + ECASH_RESPONSE=$(curl -s -f \ + -H "Cookie: ${MAINTAINER_TOKEN}" \ + "${MAINTAINER_ENDPOINT}/ecash-pubkey") || { + echo "groundwire-sign: warning: could not fetch maintainer ecash pubkey" >&2 + } + if [ -n "$ECASH_RESPONSE" ]; then + ECASH_CONFIGURED=$(echo "$ECASH_RESPONSE" | jq -r '.configured // empty') + if [ "$ECASH_CONFIGURED" = "true" ]; then + ECASH_PUBKEY=$(echo "$ECASH_RESPONSE" | jq -r '.pubkey // empty') + else + echo "groundwire-sign: warning: maintainer ecash not configured" >&2 + fi + fi + + # Fetch sats-per-pr price + PRICE_RESPONSE=$(curl -s -f \ + -H "Cookie: ${MAINTAINER_TOKEN}" \ + "${MAINTAINER_ENDPOINT}/sats-per-pr") || { + echo "groundwire-sign: warning: could not fetch maintainer price" >&2 + } + if [ -n "$PRICE_RESPONSE" ]; then + PRICE_CONFIGURED=$(echo "$PRICE_RESPONSE" | jq -r '.configured // empty') + if [ "$PRICE_CONFIGURED" = "true" ]; then + SATS_REQUIRED=$(echo "$PRICE_RESPONSE" | jq -r '.sats // empty') + fi + fi +fi + +# --- Send to signing service -------------------------------------------------- + +PAYLOAD_ARGS=(--arg content "$COMMIT_CONTENT" --arg key_id "$KEY_ID") +PAYLOAD_EXPR='{content: $content, key_id: $key_id' + +if [ -n "$SATS_REQUIRED" ] && [ "$SATS_REQUIRED" != "0" ]; then + PAYLOAD_ARGS+=(--argjson sats "$SATS_REQUIRED") + PAYLOAD_EXPR+=', sats_required: $sats' +fi + +if [ -n "$ECASH_PUBKEY" ]; then + PAYLOAD_ARGS+=(--arg ecash_pubkey "$ECASH_PUBKEY") + PAYLOAD_EXPR+=', ecash_pubkey: $ecash_pubkey' +fi + +PAYLOAD_EXPR+='}' +PAYLOAD=$(jq -n "${PAYLOAD_ARGS[@]}" "$PAYLOAD_EXPR") + +RESPONSE=$(curl -s -f \ + -X POST \ + -H "Content-Type: application/json" \ + -H "Cookie: ${TOKEN}" \ + "${ENDPOINT}/sign" \ + -d "$PAYLOAD") || { + echo "groundwire-sign: signing service request failed" >&2 + exit 1 +} + +# Check for error in response (e.g. insufficient balance) +ERROR=$(echo "$RESPONSE" | jq -r '.error // empty') +if [ -n "$ERROR" ]; then + echo "groundwire-sign: $ERROR" >&2 + exit 1 +fi + +SIGNATURE=$(echo "$RESPONSE" | jq -r '.signature // empty') +SIGNER_ID=$(echo "$RESPONSE" | jq -r '.signer_id // empty') +PASS=$(echo "$RESPONSE" | jq -r '.pass // empty') +ECASH_AMOUNT=$(echo "$RESPONSE" | jq -r '.ecash_amount // empty') +ECASH_ENCRYPTED=$(echo "$RESPONSE" | jq -r '.ecash_encrypted // empty') +ECASH_CIPHERTEXT=$(echo "$RESPONSE" | jq -r '.ecash_ciphertext // empty') +ECASH_EPH_PUBKEY=$(echo "$RESPONSE" | jq -r '.ecash_ephemeral_pubkey // empty') +ECASH_MAC=$(echo "$RESPONSE" | jq -r '.ecash_mac // empty') +ECASH_TOKENS=$(echo "$RESPONSE" | jq -r '.ecash_tokens // empty') + +if [ -z "$SIGNATURE" ]; then + echo "groundwire-sign: no signature in response" >&2 + exit 1 +fi + +if [ -z "$SIGNER_ID" ]; then + echo "groundwire-sign: no signer_id in response" >&2 + exit 1 +fi + +# --- Output structured signature to stdout ------------------------------------ +# Git captures this verbatim as the gpgsig header. +# The CI verifier parses this to extract the signer's @p and signature. + +{ + echo "-----BEGIN GROUNDWIRE SIGNATURE-----" + echo "signer:${SIGNER_ID}" + echo "pass:${PASS}" + echo "sig:${SIGNATURE}" + [ -n "$ECASH_PUBKEY" ] && echo "ecash-pubkey:${ECASH_PUBKEY}" + if [ -n "$ECASH_AMOUNT" ] && [ "$ECASH_AMOUNT" != "0" ]; then + echo "ecash-amount:${ECASH_AMOUNT}" + if [ "$ECASH_ENCRYPTED" = "true" ]; then + echo "ecash-ciphertext:${ECASH_CIPHERTEXT}" + echo "ecash-ephemeral-pubkey:${ECASH_EPH_PUBKEY}" + echo "ecash-mac:${ECASH_MAC}" + elif [ -n "$ECASH_TOKENS" ] && [ "$ECASH_TOKENS" != "null" ]; then + echo "ecash-tokens:${ECASH_TOKENS}" + fi + fi + echo "-----END GROUNDWIRE SIGNATURE-----" +} + +# --- Emit GPG-compatible status on status-fd ---------------------------------- + +if [ -n "$STATUS_FD" ]; then + { + echo "[GNUPG:] SIG_CREATED D" + echo "[GNUPG:] BEGIN_SIGNING" + } >&"$STATUS_FD" +fi diff --git a/hooks/install.sh b/hooks/install.sh new file mode 100755 index 0000000..31fda1d --- /dev/null +++ b/hooks/install.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# +# Install Groundwire commit signing. +# +# Usage: +# ./install.sh [auth-cookie] [--maintainer [cookie]] +# +# This configures git globally to sign all commits with your Groundwire key. +# Your ship must be running the %vitriol agent with a signing key configured. +# +# When --maintainer is provided, the signing hook will fetch the maintainer's +# ecash pubkey and sats-per-PR price. If a price is set, ecash tokens from +# the committer's wallet are automatically included in the signature. +# +# Example: +# ./install.sh http://localhost:8080/vitriol "urbauth-~zod=0v5.abc..." +# ./install.sh http://localhost:8080/vitriol "urbauth-~zod=0v5.abc..." \ +# --maintainer http://maintainer:8080/vitriol "urbauth-~nec=0v5.xyz..." +# + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +SIGN_PROGRAM="${SCRIPT_DIR}/groundwire-sign" + +if [ $# -lt 1 ]; then + echo "Usage: $0 [auth-cookie]" + echo "" + echo " ship-url Base URL of your ship's vitriol endpoint (e.g. http://localhost:8080/vitriol)" + echo " auth-cookie Auth cookie for your ship (e.g. urbauth-~zod=0v5.abc...)" + exit 1 +fi + +ENDPOINT="$1" +TOKEN="${2:-}" + +# Parse optional --maintainer flag +MAINTAINER_ENDPOINT="" +MAINTAINER_TOKEN="" +shift; shift 2>/dev/null || true +while [ $# -gt 0 ]; do + case "$1" in + --maintainer) + shift + MAINTAINER_ENDPOINT="${1:-}" + shift 2>/dev/null || true + # Next arg is maintainer token if it doesn't start with -- + if [ $# -gt 0 ] && [[ "$1" != --* ]]; then + MAINTAINER_TOKEN="$1" + shift + fi + ;; + *) + shift + ;; + esac +done + +chmod +x "$SIGN_PROGRAM" + +git config --global gpg.program "$SIGN_PROGRAM" +git config --global commit.gpgsign true +git config --global groundwire.sign-endpoint "$ENDPOINT" + +if [ -n "$TOKEN" ]; then + git config --global groundwire.sign-token "$TOKEN" +fi + +if [ -n "$MAINTAINER_ENDPOINT" ]; then + git config --global groundwire.maintainer-endpoint "$MAINTAINER_ENDPOINT" + echo " groundwire.maintainer-endpoint: $MAINTAINER_ENDPOINT" + if [ -n "$MAINTAINER_TOKEN" ]; then + git config --global groundwire.maintainer-token "$MAINTAINER_TOKEN" + fi +fi + +echo "Groundwire commit signing configured." +echo " gpg.program: $SIGN_PROGRAM" +echo " groundwire.sign-endpoint: $ENDPOINT" +echo " commit.gpgsign: true" +if [ -n "$MAINTAINER_ENDPOINT" ]; then + echo " groundwire.maintainer-endpoint: $MAINTAINER_ENDPOINT" +fi +echo "" +echo "All future commits will be signed with your Groundwire key."