feat(wake): W3 — cumulative-state digest renderer + non-circular HMAC signer (#904)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful

Co-authored-by: jason.woltje <jason@diversecanvas.com>
Co-committed-by: jason.woltje <jason@diversecanvas.com>
This commit was merged in pull request #904.
This commit is contained in:
2026-07-26 00:32:12 +00:00
committed by Mos
parent dc45eb7c30
commit 10d957d095
5 changed files with 1042 additions and 9 deletions

View File

@@ -0,0 +1,373 @@
#!/usr/bin/env bash
# sign.sh — A5 of the wake canon (EPIC #892, W3): the NON-CIRCULAR HMAC signer.
#
# CONTRACT ANCHORS (docs/scratchpads/heartbeat-planning/CONVERGED-DESIGN.md):
# §2.5 Integrity — non-circular HMAC tuple + stated threat boundary.
# §1.2 three-cursor entries carry an `hmac` field (W2 left it a placeholder).
#
# NON-CIRCULAR CONSTRUCTION (§2.5), verbatim intent:
# - `wake_id` is generated INDEPENDENTLY: a fresh unique id at emit. It is NOT
# derived from, and does NOT appear inside, the signed field-tuple. It is
# prepended to the MAC input to BIND it to its payload — never self-referenced.
# - wake_mac = HMAC(key, wake_id || agent_identity || mission_generation ||
# observed_seq || emit_ts || content_hash)
# The MAC is computed OVER wake_id PLUS the field-tuple. The value whose
# authenticity the MAC establishes (wake_mac itself) is NEVER an input to its
# own computation — that is what makes this genuinely non-circular.
#
# Rendered envelope makes the split explicit:
# { wake_id, # independent id, prepended to MAC input
# signed:{ agent_identity, mission_generation, observed_seq, emit_ts,
# content_hash }, # the signed field-tuple (no wake_id, no mac)
# wake_mac } # HMAC over wake_id || signed-tuple
#
# KEY HANDLING (§2.5): the key is resolved BY NAME from the operator credential
# store (the same store `load_credentials` reads), NEVER passed inline, NEVER
# echoed, NEVER placed in a unit, digest, or ledger. This tool exposes NO flag
# that accepts key material — only a key NAME.
#
# THREAT BOUNDARY, STATED HONESTLY (§2.5, §7.3): this is a SAME-UID signer. A
# same-uid attacker can read the credential file directly (and can observe the
# key in `openssl`'s argv while a MAC is computed) — so a same-uid compromise is
# OUT OF SCOPE for this tool. Closing it requires an OFF-UID signer (a separate
# privilege-separated process), which is tracked as a §4 G6 gate, NOT silently
# assumed away here.
#
# Operator-agnostic: key location via XDG/credential-store resolution only; no
# operator paths, names, or secrets appear in this file.
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
_need() {
command -v "$1" >/dev/null 2>&1 || {
echo "sign.sh: $1 is required" >&2
exit 3
}
}
usage() {
cat >&2 <<'EOF'
Usage: sign.sh <command> [options]
Commands:
sign [field opts] Emit a signed wake ENVELOPE (JSON) over an
independently-generated wake_id + the field-tuple.
verify Read an envelope on stdin; recompute and compare
its wake_mac. Exit 0 = authentic, 1 = tampered.
sign-entry [field opts] Read a durable store entry (hmac:"") on stdin;
fill its `hmac` with wake_mac + add `wake_id`.
verify-entry Read a signed entry on stdin; recompute and
compare its `hmac`. Exit 0/1.
sign-digest [field opts] Read rendered digest TEXT on stdin; hash it into
content_hash and emit a signed envelope.
Field options (all optional unless noted):
--key-name NAME credential-store key NAME to sign with (never the key
itself). Default: $WAKE_HMAC_KEY_NAME or "default".
--agent A agent_identity (default: $WAKE_AGENT or "default").
--mission-generation G (default: $WAKE_MISSION_GENERATION or 0).
--observed-seq N observed_seq (required for `sign`; taken from the entry
for sign-entry/verify-entry).
--emit-ts T epoch seconds (default: now, or the entry's emit_ts).
--content-hash H precomputed sha256 of the wake content.
--content STR content to hash into content_hash (use "-" to read stdin).
--wake-id ID override the independent wake_id (default: fresh random).
Environment:
WAKE_HMAC_KEY_NAME default credential-store key name.
WAKE_AGENT agent identity / per-agent namespace.
WAKE_MISSION_GENERATION mission generation counter.
MOSAIC_CREDENTIALS_FILE credential store the key NAME resolves against.
EOF
}
# --- key resolution (BY NAME, never inline) --------------------------------
# Resolves the HMAC key from the operator credential store at
# `.wake.hmac_keys."<name>"`. Sources the shared credentials lib ONLY to reuse
# its store-location resolution (MOSAIC_CREDENTIALS_FILE / XDG defaults) and its
# reader — the key is never taken from a flag or env literal.
_wake_load_key() {
local name="$1" key='' cred_lib
_need jq
cred_lib="$SCRIPT_DIR/../_lib/credentials.sh"
if [ -f "$cred_lib" ]; then
# shellcheck source=../_lib/credentials.sh disable=SC1091
. "$cred_lib"
fi
# MOSAIC_CREDENTIALS_FILE is exported by the lib; fall back to the XDG default.
local cred_file="${MOSAIC_CREDENTIALS_FILE:-$HOME/.config/mosaic/credentials.json}"
if [ ! -f "$cred_file" ]; then
echo "sign.sh: credential store not found ($cred_file) — cannot resolve key '$name'" >&2
return 1
fi
key="$(jq -r --arg n "$name" '.wake.hmac_keys[$n] // empty' "$cred_file" 2>/dev/null)"
if [ -z "$key" ]; then
echo "sign.sh: HMAC key '$name' not found in credential store (.wake.hmac_keys) — FAIL-LOUD, refusing to sign unsigned" >&2
return 1
fi
printf '%s' "$key"
}
# _reject_newline LABEL VALUE — the MAC input is newline-delimited, so a field
# containing a newline would make the concatenation ambiguous. ids/ints/hex are
# newline-free by construction; anything else is rejected fail-loud.
_reject_newline() {
local nl=$'\n'
case "$2" in
*"$nl"*)
echo "sign.sh: $1 must not contain a newline (MAC-input ambiguity)" >&2
exit 2
;;
esac
}
# _fresh_wake_id — an INDEPENDENT unique id, generated at emit. Not derived from
# any field-tuple value (§2.5). Random + a monotonic-ish time+pid salt so it is
# unique per emit even for byte-identical content.
_fresh_wake_id() {
local r=''
if command -v openssl >/dev/null 2>&1; then
r="$(openssl rand -hex 16 2>/dev/null || true)"
fi
[ -n "$r" ] || r="$(date +%s%N 2>/dev/null)$$${RANDOM:-0}${RANDOM:-0}"
printf 'wake_%s' "$r"
}
# _sha256 (stdin) — hex sha256 of stdin.
_sha256() {
openssl dgst -sha256 -r 2>/dev/null | awk '{print $1}'
}
# _wake_mac KEY WID AGENT GEN SEQ TS CHASH — the non-circular MAC.
# Input = wake_id || agent || gen || observed_seq || emit_ts || content_hash,
# newline-delimited (unambiguous: every field is newline-free). wake_mac is the
# OUTPUT and is never fed back in.
_wake_mac() {
local key="$1" wid="$2" agent="$3" gen="$4" seq="$5" ts="$6" chash="$7"
printf '%s\n%s\n%s\n%s\n%s\n%s' "$wid" "$agent" "$gen" "$seq" "$ts" "$chash" |
openssl dgst -sha256 -hmac "$key" -r 2>/dev/null | awk '{print $1}'
}
# Shared flag parser -> sets the field vars used by every subcommand.
_KEY_NAME='' _AGENT='' _GEN='' _SEQ='' _TS='' _CHASH='' _CONTENT='' _CONTENT_SET=0 _WID=''
_parse_fields() {
_KEY_NAME="${WAKE_HMAC_KEY_NAME:-default}"
_AGENT="${WAKE_AGENT:-default}"
_GEN="${WAKE_MISSION_GENERATION:-0}"
_SEQ='' _TS='' _CHASH='' _CONTENT='' _CONTENT_SET=0 _WID=''
while [ $# -gt 0 ]; do
case "$1" in
--key-name) _KEY_NAME="${2:-}"; shift 2 ;;
--agent) _AGENT="${2:-}"; shift 2 ;;
--mission-generation) _GEN="${2:-}"; shift 2 ;;
--observed-seq) _SEQ="${2:-}"; shift 2 ;;
--emit-ts) _TS="${2:-}"; shift 2 ;;
--content-hash) _CHASH="${2:-}"; shift 2 ;;
--content) _CONTENT="${2:-}"; _CONTENT_SET=1; shift 2 ;;
--wake-id) _WID="${2:-}"; shift 2 ;;
*)
echo "sign.sh: unknown option '$1'" >&2
exit 2
;;
esac
done
}
# Resolve _CHASH from --content(-)/--content-hash if needed.
_resolve_content_hash() {
if [ -z "$_CHASH" ] && [ "$_CONTENT_SET" = "1" ]; then
if [ "$_CONTENT" = "-" ]; then
_CHASH="$(_sha256)"
else
_CHASH="$(printf '%s' "$_CONTENT" | _sha256)"
fi
fi
}
# Emit the signed envelope JSON for the current field vars + a resolved key.
_emit_envelope() {
local key="$1" wid mac
wid="${_WID:-$(_fresh_wake_id)}"
_reject_newline "wake_id" "$wid"
_reject_newline "agent_identity" "$_AGENT"
_reject_newline "mission_generation" "$_GEN"
_reject_newline "observed_seq" "$_SEQ"
_reject_newline "emit_ts" "$_TS"
_reject_newline "content_hash" "$_CHASH"
mac="$(_wake_mac "$key" "$wid" "$_AGENT" "$_GEN" "$_SEQ" "$_TS" "$_CHASH")"
[ -n "$mac" ] || {
echo "sign.sh: MAC computation failed" >&2
exit 1
}
jq -cn \
--arg wid "$wid" \
--arg agent "$_AGENT" \
--arg gen "$_GEN" \
--arg seq "$_SEQ" \
--arg ts "$_TS" \
--arg chash "$_CHASH" \
--arg mac "$mac" \
'{wake_id:$wid,
signed:{agent_identity:$agent, mission_generation:$gen,
observed_seq:$seq, emit_ts:$ts, content_hash:$chash},
wake_mac:$mac}'
}
cmd_sign() {
_need jq
_need openssl
_parse_fields "$@"
[ -n "$_SEQ" ] || {
echo "sign.sh sign: --observed-seq is required" >&2
exit 2
}
[ -n "$_TS" ] || _TS="$(date +%s)"
_resolve_content_hash
[ -n "$_CHASH" ] || {
echo "sign.sh sign: need --content-hash or --content" >&2
exit 2
}
local key
key="$(_wake_load_key "$_KEY_NAME")" || exit 1
_emit_envelope "$key"
}
cmd_sign_digest() {
_need jq
_need openssl
_parse_fields "$@"
# content_hash is the sha256 of the rendered digest text on stdin.
_CHASH="$(_sha256)"
[ -n "$_SEQ" ] || _SEQ="0"
[ -n "$_TS" ] || _TS="$(date +%s)"
local key
key="$(_wake_load_key "$_KEY_NAME")" || exit 1
_emit_envelope "$key"
}
cmd_verify() {
_need jq
_need openssl
local key_name="${WAKE_HMAC_KEY_NAME:-default}"
# Only --key-name is honored for verify; the rest come from the envelope.
while [ $# -gt 0 ]; do
case "$1" in
--key-name) key_name="${2:-}"; shift 2 ;;
*)
echo "sign.sh verify: unknown option '$1'" >&2
exit 2
;;
esac
done
local env
env="$(cat)"
printf '%s' "$env" | jq -e . >/dev/null 2>&1 || {
echo "sign.sh verify: stdin is not a JSON envelope" >&2
exit 2
}
local wid agent gen seq ts chash claimed
wid="$(printf '%s' "$env" | jq -r '.wake_id')"
agent="$(printf '%s' "$env" | jq -r '.signed.agent_identity')"
gen="$(printf '%s' "$env" | jq -r '.signed.mission_generation')"
seq="$(printf '%s' "$env" | jq -r '.signed.observed_seq')"
ts="$(printf '%s' "$env" | jq -r '.signed.emit_ts')"
chash="$(printf '%s' "$env" | jq -r '.signed.content_hash')"
claimed="$(printf '%s' "$env" | jq -r '.wake_mac')"
local key recomputed
key="$(_wake_load_key "$key_name")" || exit 1
recomputed="$(_wake_mac "$key" "$wid" "$agent" "$gen" "$seq" "$ts" "$chash")"
if [ "$recomputed" = "$claimed" ] && [ -n "$claimed" ]; then
echo "AUTHENTIC"
return 0
fi
echo "TAMPERED" >&2
return 1
}
# Canonical content-hash for a store entry: sha256 over sorted-key {observed_seq,
# class, emit_ts, locators}. Binds the entry's obligation payload; the `hmac`
# placeholder and any wake_id are intentionally EXCLUDED (they are not content).
_entry_content_hash() {
jq -cS '{observed_seq, class, emit_ts, locators}' | _sha256
}
cmd_sign_entry() {
_need jq
_need openssl
_parse_fields "$@"
local entry
entry="$(cat)"
printf '%s' "$entry" | jq -e . >/dev/null 2>&1 || {
echo "sign.sh sign-entry: stdin is not a JSON entry" >&2
exit 2
}
_SEQ="$(printf '%s' "$entry" | jq -r '.observed_seq')"
_TS="$(printf '%s' "$entry" | jq -r '.emit_ts')"
_CHASH="$(printf '%s' "$entry" | _entry_content_hash)"
local wid key mac
wid="${_WID:-$(_fresh_wake_id)}"
_reject_newline "wake_id" "$wid"
key="$(_wake_load_key "$_KEY_NAME")" || exit 1
mac="$(_wake_mac "$key" "$wid" "$_AGENT" "$_GEN" "$_SEQ" "$_TS" "$_CHASH")"
[ -n "$mac" ] || {
echo "sign.sh sign-entry: MAC computation failed" >&2
exit 1
}
# Fill the hmac placeholder W2 left, and attach the independent wake_id.
printf '%s' "$entry" | jq -c \
--arg mac "$mac" --arg wid "$wid" \
'.hmac = $mac | .wake_id = $wid'
}
cmd_verify_entry() {
_need jq
_need openssl
_parse_fields "$@"
local entry
entry="$(cat)"
printf '%s' "$entry" | jq -e . >/dev/null 2>&1 || {
echo "sign.sh verify-entry: stdin is not a JSON entry" >&2
exit 2
}
local wid seq ts chash claimed
wid="$(printf '%s' "$entry" | jq -r '.wake_id // ""')"
seq="$(printf '%s' "$entry" | jq -r '.observed_seq')"
ts="$(printf '%s' "$entry" | jq -r '.emit_ts')"
claimed="$(printf '%s' "$entry" | jq -r '.hmac // ""')"
# Recompute content hash over the SAME canonical projection.
chash="$(printf '%s' "$entry" | _entry_content_hash)"
local key recomputed
key="$(_wake_load_key "$_KEY_NAME")" || exit 1
recomputed="$(_wake_mac "$key" "$wid" "$_AGENT" "$_GEN" "$seq" "$ts" "$chash")"
if [ "$recomputed" = "$claimed" ] && [ -n "$claimed" ]; then
echo "AUTHENTIC"
return 0
fi
echo "TAMPERED" >&2
return 1
}
main() {
[ $# -ge 1 ] || {
usage
exit 2
}
local cmd="$1"
shift
case "$cmd" in
sign) cmd_sign "$@" ;;
sign-digest) cmd_sign_digest "$@" ;;
sign-entry) cmd_sign_entry "$@" ;;
verify) cmd_verify "$@" ;;
verify-entry) cmd_verify_entry "$@" ;;
-h | --help | help) usage ;;
*)
echo "sign.sh: unknown command '$cmd'" >&2
usage
exit 2
;;
esac
}
main "$@"