Files
werkrevisor/tools/rotate-secret
claude-code 4d8e4e2276
werkator build succeeded after 00:32
werkator/werkrevisor-statistics build succeeded after 01:28
werkator/release build succeeded after 00:39
werkrevisor 0.56.0: rotate the secrets of the review builds without a stop (#128)
Co-authored-by: claude-code <claude-code@javagil.de>
2026-10-21 22:33:42 +02:00

179 lines
7.2 KiB
Bash
Executable File

#!/usr/bin/env bash
# Usage: tools/rotate-secret [-h] check <ssh-target> <KEY> [<sha256>]
# tools/rotate-secret [-h] set <ssh-target> <KEY> (the new value on stdin)
# tools/rotate-secret [-h] purge <ssh-target> <KEY> <sha256>
# tools/rotate-secret [-h] fetch <ssh-target> <KEY>
# tools/rotate-secret [-h] digest <KEY>
# tools/rotate-secret [-h] probe <KEY> [-] (with "-" the value on stdin)
#
# Handles one secret, e.g. GITEA_BOT_TOKEN, in ~/<repo>/.git/werkator/.werkator.yml of each repository
# on the Werkator host <ssh-target> that has it, and in the local secrets file
# $WERKREVISOR_SECRETS_ENV, by default ~/.config/ai-coding-agents/secrets.env.
# check tells per repository whether the value has the SHA-256 <sha256>, by default that of the secrets file,
# and how many backups hold that value.
# set replaces the value everywhere, after a backup <file>.bak-secret-<KEY>-<time>,
# and adds it to the secrets file if that lacks it;
# it refuses if the host holds another old value than the secrets file, or more than one.
# It prints the SHA-256 of the old value for purge, then checks the new one.
# purge removes the backups that hold the value with the SHA-256 <sha256>; it refuses while a file holds it.
# fetch adds the value that all machine configs hold to a secrets file without KEY, e.g. before set,
# so that its backup holds the old value for the probe after the revocation.
# digest prints the SHA-256 of the value in the secrets file.
# probe asks the service whether it accepts the value of the secrets file, or with "-" that on stdin,
# without a review and without costs (see werkrevisor/secretprobe.py).
# set and probe ask for a value on stdin without showing it, if stdin is a terminal.
# It never prints a value and never passes one as an argument; it opens one connection per command.
# Werkator reads a changed machine config at its next poll, without a restart.
# Exit code: 0 if all went well, 1 if check found another value or the service refused it, 2 on an error.
#
# Example:
# tools/rotate-secret set mih09-werkator@mih09.hostsharing.net OPENROUTER_API_KEY < new-key
set -euo pipefail
usage() {
awk 'NR > 1 && /^#/ { sub(/^# ?/, ""); print; next } NR > 1 { exit }' "$0"
exit "$1"
}
[[ "${1:-}" =~ ^(-h|-\?|--help)$ ]] && usage 0
tools=$(cd "$(dirname "$0")" && pwd)
secrets_env=${WERKREVISOR_SECRETS_ENV:-$HOME/.config/ai-coding-agents/secrets.env}
command=${1:-}
case "$command" in
check) [[ $# -eq 3 || $# -eq 4 ]] || usage 2 ;;
set) [[ $# -eq 3 ]] || usage 2 ;;
purge) [[ $# -eq 4 ]] || usage 2 ;;
fetch) [[ $# -eq 3 ]] || usage 2 ;;
digest) [[ $# -eq 2 ]] || usage 2 ;;
probe) [[ $# -eq 2 || ( $# -eq 3 && $3 == - ) ]] || usage 2 ;;
*) usage 2 ;;
esac
if [[ $command == digest || $command == probe ]]; then
key=$2
else
target=$2
key=$3
fi
[[ "$key" =~ ^[A-Z][A-Z0-9_]*$ ]] || { echo "no valid KEY: $key" >&2; exit 2; }
# The value lives only in variables and pipes; printf is a builtin, so no process lists it as an argument.
json() {
python3 -c 'import json, sys; print(json.dumps({sys.argv[1]: sys.stdin.read()}))' "$1"
}
# Writes the value of KEY in the secrets file, or its SHA-256 with "sha256"; only ever into a pipe or a variable.
# With "optional", a file without KEY writes nothing; a file it cannot read or parse is an error all the same.
from_secrets_file() {
python3 -c '
import sys
sys.path.insert(0, sys.argv[1])
import host_secret
try:
value = host_secret.value_of(sys.argv[3], sys.argv[2], False)
except host_secret.SecretError as e:
print(e, file=sys.stderr)
sys.exit(2)
if value is None:
if "optional" in sys.argv[4:]:
sys.exit(0)
print(f"{sys.argv[3]} does not set {sys.argv[2]}", file=sys.stderr)
sys.exit(2)
print(host_secret.sha256(value) if "sha256" in sys.argv[4:] else value, end="")' "$tools" "$key" "$secrets_env" "$@"
}
digest() {
from_secrets_file sha256 "$@"
}
# As in sync-host-config: the host refuses connections that follow too quickly.
retrying() {
local attempt
for attempt in 1 2 3 4 5 6; do
"$@" && return 0
local status=$?
[[ $status -eq 255 ]] || return $status
[[ $attempt -lt 6 ]] || break
echo "connection refused, retry in $((30 * attempt)) s" >&2
sleep $((30 * attempt))
done
# 1 would mean a differing or refused value, but no file was read.
echo "the host refused six connections" >&2
return 2
}
# Each side gets the payload on stdin; a function, so that a retry sends it again.
on_host() {
ssh -o BatchMode=yes "$target" \
"python3 -c $(printf '%q' "$(cat "$tools/host_secret.py")") $1 $key" <<< "$payload"
}
locally() {
[[ -f "$secrets_env" ]] || { echo "no $secrets_env" >&2; return 2; }
python3 "$tools/host_secret.py" "$1" "$key" "$secrets_env" <<< "$payload"
}
check() {
payload=$(printf %s "$1" | json sha256)
local host=0 here=0
retrying on_host check || host=$?
locally check || here=$?
return $((host > here ? host : here))
}
case "$command" in
digest)
digest
;;
check)
check "${4:-$(digest)}"
;;
fetch)
[[ -z "$(digest optional)" ]] || { echo "$(basename "$secrets_env") sets $key already" >&2; exit 2; }
payload="{}"
# The value goes from the host straight into the local set, through pipes only.
# A plain assignment, so that set -e stops on a failed export; before "&&" it would not.
payload=$(retrying on_host export)
locally set
unset payload
;;
purge)
[[ "$4" =~ ^[0-9a-f]{64}$ ]] || { echo "no SHA-256: $4" >&2; exit 2; }
payload=$(printf %s "$4" | json sha256)
retrying on_host purge
locally purge
;;
set)
if [[ -t 0 ]]; then
IFS= read -rs -p "new value of $key: " value
echo >&2
else
value=$(cat)
fi
# Before any change on the host: a secrets file that cannot be read would fail only after it.
old=$(digest optional)
new=$(printf %s "$value" | python3 -c 'import hashlib, sys; print(hashlib.sha256(sys.stdin.read().encode()).hexdigest())')
[[ "$old" != "$new" ]] || { echo "the secrets file holds this value already" >&2; exit 2; }
payload=$(printf %s "$value" | python3 -c '
import json, sys
print(json.dumps({"value": sys.stdin.read(), "old_sha256": sys.argv[1] or None}))' "$old")
unset value
host=$(retrying on_host set)
printf '%s\n' "$host"
old=${old:-$(sed -n 's/^old value: sha256 //p' <<< "$host")}
locally set | sed '/^old value: /d'
check "$new"
[[ -z "$old" ]] || echo "after the old value is revoked: tools/rotate-secret purge $target $key $old"
;;
probe)
cd "$tools/.."
if [[ $# -eq 3 && -t 0 ]]; then
IFS= read -rs -p "value of $key: " value
echo >&2
printf %s "$value" | python3 -m werkrevisor.secretprobe "$key"
elif [[ $# -eq 3 ]]; then
python3 -m werkrevisor.secretprobe "$key"
else
from_secrets_file | python3 -m werkrevisor.secretprobe "$key"
fi
;;
esac