#!/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 [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.""`. 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 "$@"