feat(wake): W3 — cumulative-state digest renderer + non-circular HMAC signer (#904)
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:
373
packages/mosaic/framework/tools/wake/sign.sh
Executable file
373
packages/mosaic/framework/tools/wake/sign.sh
Executable 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 "$@"
|
||||
Reference in New Issue
Block a user