407 lines
13 KiB
Bash
Executable File
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
|