Files
apxtri/setup/lock.sh
T
2026-08-14 21:20:26 +00:00

407 lines
13 KiB
Bash
Executable File

#!/bin/bash
# ============================================================================
# lock.sh - Core integrity lock + Release Governance CLI (Committee of Sages)
#
# Roles:
# - Sage (signataire) : init, add, list, verify-commit, release, vote, update
# - Captain (tournante) : release (squash signed on main + stake)
# - Dev / Détecteur : flaw --report
# - Validateur : flaw --vote
# - Admin town : check, status
#
# Usage examples (see documents/17-TEAMDEV.md Part F for the full workflow):
# setup/lock.sh init --key <gpg-key-or-file> --alias <alias> --email <email>
# setup/lock.sh add <pubkey.asc> --alias <alias> --email <email>
# setup/lock.sh list
# setup/lock.sh check
# setup/lock.sh verify-commit <sha>
# setup/lock.sh release --tag v1.4.0 --commit <sha> --stake 1000
# setup/lock.sh flaw --report --release v1.4.0 --title "..." --description "..."
# setup/lock.sh flaw --vote <flaw-id> --verdict confirmed
# setup/lock.sh update --release v1.4.0 --commit <sha> --relay
# setup/lock.sh status [--url https://dev.zero.ndda.fr]
# ============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_DIR="$(dirname "$SCRIPT_DIR")"
ENV_FILE="$REPO_DIR/.env"
KEYRING="$REPO_DIR/SIGNATORIES.asc"
SIGNATORIES_JSON="$REPO_DIR/SIGNATORIES.json"
if [[ -f "$ENV_FILE" ]]; then
set -a
# shellcheck disable=SC1090
. "$ENV_FILE"
set +a
fi
TOWN="${TOWN:-}"
NATION="${NATION:-}"
DOMAIN="${DOMAIN:-}"
APIPORT="${APIPORT:-3000}"
API_URL="${LOCK_URL:-http://localhost:${APIPORT}}"
ALIAS="${LOCK_ALIAS:-}"
usage() {
sed -n '1,20p' "$0" | sed 's/^# \{0,1\}//' | grep -v '^!'
echo ""
echo "Commands:"
echo " init --key <gpg-key|pubkey.asc> --alias <alias> [--email <email>]"
echo " Bootstrap the keyring with your public key + git signing config."
echo " add <pubkey.asc> --alias <alias> [--email <email>]"
echo " Co-opt a new signatory (signed commit)."
echo " list Show the signatories."
echo " check Local integrity check (HEAD signed + clean tree)."
echo " verify-commit <sha> Verify a commit signature against the keyring."
echo " release --tag <vX.Y.Z> --commit <sha> [--stake <APX3>]"
echo " Record a release + stake (sage/captain, calls the corelock API)."
echo " flaw --report --release <vX.Y.Z> --title <t> [--description <d>]"
echo " File a flaw report (any pagan)."
echo " flaw --vote <id> --verdict confirmed|rejected"
echo " Vote on a flaw (sage)."
echo " update --release <vX.Y.Z> --commit <sha> [--relay]"
echo " Signed update announcement (sage) - forces update+restart of towns."
echo " status [--url <base>] Show core lock status."
echo ""
echo "Global options:"
echo " --alias <alias> Override the authenticated alias (or \$LOCK_ALIAS)"
echo " --url <base> Override the API base URL (or \$LOCK_URL)"
}
# ---- helpers ---------------------------------------------------------------
require_env() {
if [[ -z "$TOWN" || -z "$NATION" ]]; then
echo "[ERROR] TOWN/NATION not found in .env. Run this from an installed apxtri instance." >&2
exit 1
fi
}
signed_headers() {
local alias_name="$1"
local xdays xhash
xdays="$(date +%s)"
xhash="$(printf '%s_%s' "$alias_name" "$xdays" | gpg --batch --yes --clearsign --armor 2>/dev/null | base64 -w0)"
HEADERS_ARGS=(-H "xalias: $alias_name" -H "xtribe: $TOWN" -H "xlang: en" -H "xdays: $xdays" -H "xhash: $xhash")
}
sign_payload() {
# clearsign the text "<release>\n<commit>", base64, for the /update signature field
local text="$1"
printf '%s' "$text" | gpg --batch --yes --clearsign --armor 2>/dev/null | base64 -w0
}
fingerprint_of_file() {
gpg --batch --with-colons --import-options show-only --import "$1" 2>/dev/null | awk -F: '$1=="fpr"{print $10; exit}'
}
write_signatories_json() {
local alias_name="$1" email="$2" fp="$3"
local tmp
tmp="$(mktemp)"
if [[ -f "$SIGNATORIES_JSON" ]]; then
jq --arg a "$alias_name" --arg e "$email" --arg f "$fp" \
'.signatories += [{"alias":$a,"email":$e,"fingerprint":$f}]' "$SIGNATORIES_JSON" > "$tmp"
else
jq -n --arg a "$alias_name" --arg e "$email" --arg f "$fp" \
'{signatories:[{"alias":$a,"email":$e,"fingerprint":$f}]}' > "$tmp"
fi
mv "$tmp" "$SIGNATORIES_JSON"
}
append_keyring() {
local pubfile="$1"
# add armored key to keyring if not already present
local fp
fp="$(fingerprint_of_file "$pubfile")"
if [[ -z "$fp" ]]; then
echo "[ERROR] No public key found in $pubfile" >&2
exit 1
fi
if [[ -f "$KEYRING" ]] && grep -q "$fp" "$KEYRING" 2>/dev/null; then
echo "[INFO] Key $fp already in keyring."
else
echo "" >> "$KEYRING"
cat "$pubfile" >> "$KEYRING"
echo "[OK] Key $fp added to keyring."
fi
echo "$fp"
}
git_signed_commit() {
local msg="$1"
git -C "$REPO_DIR" add "$KEYRING" "$SIGNATORIES_JSON"
git -C "$REPO_DIR" commit -S -m "$msg"
echo ""
echo "Commit created. Push it with:"
echo " git -C $REPO_DIR push origin main"
}
# ---- commands ---------------------------------------------------------------
cmd_init() {
require_env
local key="$1" alias_name="$2" email="$3"
local pubfile
pubfile="$(mktemp)"
if [[ -f "$key" ]]; then
cp "$key" "$pubfile"
else
gpg --batch --armor --export "$key" > "$pubfile" 2>/dev/null || {
echo "[ERROR] Cannot export key '$key'. Is it in your gpg keyring?" >&2
exit 1
}
fi
if [[ -f "$KEYRING" ]] && grep -q "BEGIN PGP PUBLIC KEY BLOCK" "$KEYRING"; then
echo "[WARN] Keyring already exists. Use 'add' to co-opt another signatory." >&2
fi
local fp
fp="$(append_keyring "$pubfile")"
write_signatories_json "$alias_name" "$email" "$fp"
rm -f "$pubfile"
git -C "$REPO_DIR" config user.signingkey "$fp"
git -C "$REPO_DIR" config commit.gpgsign true
echo "[OK] Git signing configured (key $fp)."
git_signed_commit "bootstrap: add signatory $alias_name"
}
cmd_add() {
require_env
local pubfile="$1" alias_name="$2" email="$3"
[[ -f "$pubfile" ]] || { echo "[ERROR] Public key file not found: $pubfile" >&2; exit 1; }
[[ -f "$KEYRING" ]] || { echo "[ERROR] Keyring missing. Run 'init' first." >&2; exit 1; }
local fp
fp="$(append_keyring "$pubfile")"
write_signatories_json "$alias_name" "$email" "$fp"
git_signed_commit "add signatory $alias_name ($fp)"
}
cmd_list() {
if [[ -f "$SIGNATORIES_JSON" ]]; then
jq -r '.signatories[] | "\(.alias)\t\(.fingerprint)\t\(.email // "")"' "$SIGNATORIES_JSON"
else
echo "[INFO] No signatories yet. Run 'init'."
fi
}
cmd_check() {
require_env
if [[ ! -f "$KEYRING" ]]; then
echo "[WARN] Core NOT LOCKED: SIGNATORIES.asc missing. Run 'init'."
exit 0
fi
local gnupghome
gnupghome="$(mktemp -d)"
chmod 700 "$gnupghome"
gpg --batch --yes --homedir "$gnupghome" --import "$KEYRING" >/dev/null 2>&1
local head
head="$(git -C "$REPO_DIR" rev-parse HEAD)"
echo "HEAD: $head"
if GNUPGHOME="$gnupghome" git -C "$REPO_DIR" -c safe.directory="$REPO_DIR" verify-commit HEAD >/dev/null 2>&1; then
echo "[OK] HEAD is signed by an authorized signatory."
else
echo "[FAIL] HEAD is NOT signed by an authorized signatory."
fi
local dirty
dirty="$(git -C "$REPO_DIR" -c safe.directory="$REPO_DIR" status --porcelain)"
if [[ -z "$dirty" ]]; then
echo "[OK] Working tree clean."
else
echo "[FAIL] Working tree modified:"
echo "$dirty"
fi
rm -rf "$gnupghome"
}
cmd_verify_commit() {
local sha="$1"
[[ -f "$KEYRING" ]] || { echo "[ERROR] Keyring missing." >&2; exit 1; }
local gnupghome
gnupghome="$(mktemp -d)"
chmod 700 "$gnupghome"
gpg --batch --yes --homedir "$gnupghome" --import "$KEYRING" >/dev/null 2>&1
if GNUPGHOME="$gnupghome" git -C "$REPO_DIR" -c safe.directory="$REPO_DIR" verify-commit "$sha"; then
echo "[OK] $sha is signed by an authorized signatory."
local code=0
else
echo "[FAIL] $sha is NOT signed by an authorized signatory."
local code=1
fi
rm -rf "$gnupghome"
exit "$code"
}
cmd_status() {
curl -s "$API_URL/api/apxtri/corelock/status" | jq .
}
cmd_release() {
require_env
local tag="$1" commit="$2" stake="$3"
[[ -n "$ALIAS" ]] || { echo "[ERROR] --alias is required" >&2; exit 1; }
signed_headers "$ALIAS"
curl -s -X POST "$API_URL/api/apxtri/corelock/releases" \
"${HEADERS_ARGS[@]}" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg tag "$tag" --arg commit "$commit" --argjson stake "${stake:-0}" '{tag:$tag,commit:$commit,stake:$stake}')" \
| jq .
}
cmd_flaw_report() {
require_env
local release="$1" title="$2" desc="$3"
[[ -n "$ALIAS" ]] || { echo "[ERROR] --alias is required" >&2; exit 1; }
signed_headers "$ALIAS"
curl -s -X POST "$API_URL/api/apxtri/corelock/flaws" \
"${HEADERS_ARGS[@]}" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg release "$release" --arg title "$title" --arg description "$desc" '{release:$release,title:$title,description:$description}')" \
| jq .
}
cmd_flaw_vote() {
require_env
local id="$1" verdict="$2"
[[ -n "$ALIAS" ]] || { echo "[ERROR] --alias is required" >&2; exit 1; }
signed_headers "$ALIAS"
curl -s -X POST "$API_URL/api/apxtri/corelock/flaws/$id/vote" \
"${HEADERS_ARGS[@]}" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg verdict "$verdict" '{verdict:$verdict}')" \
| jq .
}
cmd_update() {
require_env
local release="$1" commit="$2" relay="${3:-0}"
local signature
signature="$(sign_payload "${release}
${commit}")"
curl -s -X POST "$API_URL/api/apxtri/corelock/update" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg release "$release" --arg commit "$commit" --arg signatory "${ALIAS:-corelock}" --arg signature "$signature" --argjson relay "$([[ "$relay" == "1" ]] && echo true || echo false)" '{release:$release,commit:$commit,signatory:$signatory,signature:$signature,relay:$relay}')" \
| jq .
}
# ---- argument parsing -------------------------------------------------------
command="${1:-help}"
shift || true
URL=""
case "$command" in
init)
KEY="" ALIAS_NAME="" EMAIL=""
while [[ $# -gt 0 ]]; do
case "$1" in
--key) KEY="$2"; shift 2;;
--alias) ALIAS_NAME="$2"; shift 2;;
--email) EMAIL="$2"; shift 2;;
*) echo "[ERROR] Unknown option $1"; usage; exit 1;;
esac
done
[[ -n "$KEY" && -n "$ALIAS_NAME" ]] || { usage; exit 1; }
cmd_init "$KEY" "$ALIAS_NAME" "$EMAIL"
;;
add)
PUBFILE="${1:-}"
shift || true
ALIAS_NAME="" EMAIL=""
while [[ $# -gt 0 ]]; do
case "$1" in
--alias) ALIAS_NAME="$2"; shift 2;;
--email) EMAIL="$2"; shift 2;;
*) echo "[ERROR] Unknown option $1"; usage; exit 1;;
esac
done
[[ -n "$PUBFILE" && -n "$ALIAS_NAME" ]] || { usage; exit 1; }
cmd_add "$PUBFILE" "$ALIAS_NAME" "$EMAIL"
;;
list) cmd_list ;;
check) cmd_check ;;
verify-commit)
[[ $# -ge 1 ]] || { usage; exit 1; }
cmd_verify_commit "$1"
;;
release)
TAG="" COMMIT="" STAKE="0"
while [[ $# -gt 0 ]]; do
case "$1" in
--tag) TAG="$2"; shift 2;;
--commit) COMMIT="$2"; shift 2;;
--stake) STAKE="$2"; shift 2;;
--alias) ALIAS="$2"; shift 2;;
--url) API_URL="$2"; shift 2;;
*) echo "[ERROR] Unknown option $1"; usage; exit 1;;
esac
done
[[ -n "$TAG" && -n "$COMMIT" ]] || { usage; exit 1; }
cmd_release "$TAG" "$COMMIT" "$STAKE"
;;
flaw)
SUB="${1:-}"; shift || true
case "$SUB" in
--report)
REL="" TITLE="" DESC=""
while [[ $# -gt 0 ]]; do
case "$1" in
--release) REL="$2"; shift 2;;
--title) TITLE="$2"; shift 2;;
--description) DESC="$2"; shift 2;;
--alias) ALIAS="$2"; shift 2;;
--url) API_URL="$2"; shift 2;;
*) echo "[ERROR] Unknown option $1"; usage; exit 1;;
esac
done
[[ -n "$REL" && -n "$TITLE" ]] || { usage; exit 1; }
cmd_flaw_report "$REL" "$TITLE" "$DESC"
;;
--vote)
ID="${1:-}"; shift || true
VERDICT=""
while [[ $# -gt 0 ]]; do
case "$1" in
--verdict) VERDICT="$2"; shift 2;;
--alias) ALIAS="$2"; shift 2;;
--url) API_URL="$2"; shift 2;;
*) echo "[ERROR] Unknown option $1"; usage; exit 1;;
esac
done
[[ -n "$ID" && -n "$VERDICT" ]] || { usage; exit 1; }
cmd_flaw_vote "$ID" "$VERDICT"
;;
*) usage; exit 1;;
esac
;;
update)
REL="" COMMIT="" RELAY="0"
while [[ $# -gt 0 ]]; do
case "$1" in
--release) REL="$2"; shift 2;;
--commit) COMMIT="$2"; shift 2;;
--relay) RELAY="1"; shift;;
--alias) ALIAS="$2"; shift 2;;
--url) API_URL="$2"; shift 2;;
*) echo "[ERROR] Unknown option $1"; usage; exit 1;;
esac
done
[[ -n "$REL" && -n "$COMMIT" ]] || { usage; exit 1; }
cmd_update "$REL" "$COMMIT" "$RELAY"
;;
status)
while [[ $# -gt 0 ]]; do
case "$1" in
--url) API_URL="$2"; shift 2;;
*) echo "[ERROR] Unknown option $1"; usage; exit 1;;
esac
done
cmd_status
;;
help|-h|--help) usage ;;
*) echo "[ERROR] Unknown command: $command"; usage; exit 1;;
esac