#!/usr/bin/env bash # test-wake-digest-hmac.sh — RED-FIRST invariant harness for W3 (EPIC #892): # the cumulative-state digest renderer (digest.sh, A3) + the non-circular HMAC # signer (sign.sh, A5). # # Each test asserts ONE CONVERGED-DESIGN invariant and is designed to go RED if # that invariant regresses: # D1 CUMULATIVE-STATE: two still-pending changes both render (not just the # latest delta) — a delta would silently drop the older change. (§2.1) # D2 HARD-LOCATOR enforcement: an actionable claim with no precise locator # FAILS LOUD (non-zero exit, nothing emitted). (§2.1) # D3 TWO-TIER trust: orientation decides the no-op case with ZERO tool calls; # an actionable fact is a CLAIM-TO-VERIFY, never auto-actioned. (§2.1) # D4 SCRUB: a secret-canary + ANSI/bidi/zero-width in SOURCE free-text is # stripped/neutralized in the rendered digest. (§2.1) # H1 NON-CIRCULAR HMAC: wake_id is NOT a member of the signed field-tuple and # is independently generated; wake_mac is not its own input; tampering ANY # signed field (or wake_id) breaks the MAC. (§2.5) # H2 KEY BY-NAME, NEVER INLINE: the key is resolved by NAME from the credential # store; no flag accepts key material; the key never leaks into output; the # same-uid threat boundary is documented. (§2.5) # # Isolated: every test runs against a fresh temp state / credential file. set -uo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" STORE="$SCRIPT_DIR/store.sh" DIGEST="$SCRIPT_DIR/digest.sh" SIGN="$SCRIPT_DIR/sign.sh" command -v jq >/dev/null 2>&1 || { echo "SKIP: jq not available" >&2 exit 0 } command -v openssl >/dev/null 2>&1 || { echo "SKIP: openssl not available" >&2 exit 0 } TMP_ROOT="$(mktemp -d)" trap 'rm -rf "$TMP_ROOT"' EXIT # Failures recorded to a marker FILE (not a shell var): each test runs in a # subshell for env isolation and a subshell cannot mutate a parent variable, so # a var-based counter would silently swallow failures. FAILFILE="$TMP_ROOT/failures" : >"$FAILFILE" pass=0 fail_msg() { echo " FAIL: $*" >&2 echo "x" >>"$FAILFILE" } ok() { pass=$((pass + 1)); } fresh_state() { local d="$TMP_ROOT/$1" rm -rf "$d" mkdir -p "$d" printf '%s' "$d" } # A 40-hex sha, for hard locators. SHA40="abcdef0123456789abcdef0123456789abcdef01" # fresh_cred NAME KEYNAME KEYVALUE — write a temp credential store with a wake # HMAC key at .wake.hmac_keys. and echo its path. fresh_cred() { local f="$TMP_ROOT/$1.cred.json" jq -cn --arg k "$2" --arg v "$3" '{wake:{hmac_keys:{($k):$v}}}' >"$f" printf '%s' "$f" } # =========================================================================== echo "== D1: CUMULATIVE-STATE — two pending changes BOTH render (not just latest) ==" ( WAKE_STATE_HOME="$(fresh_state d1)" export WAKE_STATE_HOME unset WAKE_AGENT WAKE_LANE # Two distinct obligations observed since the last CONSUMED. A delta-style # renderer would show only the newest; cumulative-state must show BOTH. "$STORE" enqueue --seq 1 --class actionable --locators '{"repo":"r","issue":11}' >/dev/null "$STORE" enqueue --seq 2 --class actionable --locators '{"repo":"r","issue":22}' >/dev/null out="$("$DIGEST" render)" || fail_msg "D1: render exited non-zero" echo "$out" | grep -q 'issue=#11' || fail_msg "D1: OLDER change (issue #11) dropped — digest is a delta, not cumulative state" echo "$out" | grep -q 'issue=#22' || fail_msg "D1: newer change (issue #22) missing" echo "$out" | grep -q 'seq 1' || fail_msg "D1: seq 1 not listed in cumulative set" echo "$out" | grep -q 'seq 2' || fail_msg "D1: seq 2 not listed in cumulative set" # A coalescing digest-class entry is STATE (full), not a delta: a later digest # subsumes the earlier, but the cumulative unacked set (both actionables) stays. echo "$out" | grep -q 'pending=2' || fail_msg "D1: cumulative pending count wrong (expected 2)" ) && ok echo "== D2: HARD-LOCATOR enforcement — actionable claim with no locator FAILS LOUD ==" ( WAKE_STATE_HOME="$(fresh_state d2)" export WAKE_STATE_HOME unset WAKE_AGENT WAKE_LANE # An actionable claim carrying NO hard locator (no repo+issue / sha / file). "$STORE" enqueue --seq 1 --class actionable --locators '{"claim":"mergeable=true"}' >/dev/null if out="$("$DIGEST" render 2>/dev/null)"; then fail_msg "D2: malformed digest (actionable, no locator) must FAIL, but render succeeded" fi rc=0 "$DIGEST" render >/dev/null 2>&1 || rc=$? [ "$rc" -eq 4 ] || fail_msg "D2: expected fail-loud exit 4, got $rc" # Nothing emitted on stdout when malformed (no silent partial send). [ -z "${out:-}" ] || fail_msg "D2: a malformed digest emitted body instead of failing loud [$out]" # A present-but-imprecise sha (not 40 hex) does NOT satisfy the hard locator. WAKE_STATE_HOME="$(fresh_state d2b)" export WAKE_STATE_HOME "$STORE" enqueue --seq 1 --class actionable --locators '{"claim":"ci=green","sha":"abc123"}' >/dev/null rc=0 "$DIGEST" render >/dev/null 2>&1 || rc=$? [ "$rc" -eq 4 ] || fail_msg "D2: imprecise sha (not 40-hex) must not satisfy the hard-locator gate (got exit $rc)" # The SAME claim WITH a precise 40-hex sha renders fine. WAKE_STATE_HOME="$(fresh_state d2c)" export WAKE_STATE_HOME "$STORE" enqueue --seq 1 --class actionable --locators "$(jq -cn --arg s "$SHA40" '{claim:"ci=green",sha:$s}')" >/dev/null "$DIGEST" render >/dev/null 2>&1 || fail_msg "D2: a well-located actionable claim must render" ) && ok echo "== D3: TWO-TIER — orientation no-op with ZERO tool calls; actionable = claim-to-verify ==" ( WAKE_STATE_HOME="$(fresh_state d3)" export WAKE_STATE_HOME unset WAKE_AGENT WAKE_LANE # Tool-call tripwire: shim git/gh/tea/curl on PATH; any invocation touches a # marker. The orientation no-op path must decide with ZERO of them. bin="$TMP_ROOT/d3bin" mkdir -p "$bin" for t in git gh tea curl wget; do { printf '#!/usr/bin/env bash\n' printf 'touch "%s/TOOL_CALLED"\n' "$WAKE_STATE_HOME" printf 'exit 0\n' } >"$bin/$t" chmod +x "$bin/$t" done # No pending obligations => no-op common case. out="$(PATH="$bin:$PATH" "$DIGEST" render --lane build)" || fail_msg "D3: no-op render failed" echo "$out" | grep -q 'NO-OP' || fail_msg "D3: empty inbox must render an explicit NO-OP orientation" [ -e "$WAKE_STATE_HOME/TOOL_CALLED" ] && fail_msg "D3: orientation no-op made a live tool call (must be ZERO)" # Now an actionable, consequential fact. It must be a CLAIM-TO-VERIFY, never # rendered as a trusted assertion or an auto-action. "$STORE" enqueue --seq 1 --class actionable \ --locators "$(jq -cn --arg s "$SHA40" '{repo:"r",issue:7,claim:"ci=success",file:"pkg/x.ts",sha:$s}')" >/dev/null out2="$(PATH="$bin:$PATH" "$DIGEST" render)" || fail_msg "D3: actionable render failed" # Rendering itself still makes ZERO live calls (it only lays out claims). [ -e "$WAKE_STATE_HOME/TOOL_CALLED" ] && fail_msg "D3: rendering an actionable claim made a live call (must defer to the consumer's gate)" echo "$out2" | grep -q 'CLAIM@seq' || fail_msg "D3: consequential fact not framed as CLAIM@seq" echo "$out2" | grep -qi 'VERIFY LIVE' || fail_msg "D3: claim not marked for live verification" echo "$out2" | grep -qi 'do NOT act on this line' || fail_msg "D3: claim missing do-not-auto-action framing" # The consequential fact must NOT appear as a bare trusted directive. echo "$out2" | grep -qiE 'merge now|go ahead and (merge|deploy)|safe to merge' && fail_msg "D3: digest auto-actioned a consequential fact (imperative present)" # And its hard locator + one-call re-verify hint are present. echo "$out2" | grep -q "re-verify (ONE call)" || fail_msg "D3: actionable claim missing one-call re-verify locator" true ) && ok echo "== D4: SCRUB — secret-canary + ANSI/bidi/zero-width in source free-text neutralized ==" ( WAKE_STATE_HOME="$(fresh_state d4)" export WAKE_STATE_HOME unset WAKE_AGENT WAKE_LANE # Build hostile source free-text: ANSI CSI, bidi override (U+202E), zero-width # space (U+200B), ZWNBSP (U+FEFF), a C0 control (BEL), and two secret canaries. nasty="$(printf 'good\x1b[31mRED\xe2\x80\xaeEVIL\xe2\x80\x8bZW\xef\xbb\xbfBOM\x07BEL ghp_0123456789abcdefghijABCDEFGHIJ012345 AKIAIOSFODNN7EXAMPLE')" loc="$(jq -cn --arg s "$nasty" --arg sha "$SHA40" '{repo:"r",issue:3,claim:"changed",sha:$sha,summary:$s}')" "$STORE" enqueue --seq 1 --class actionable --locators "$loc" >/dev/null out="$("$DIGEST" render)" || fail_msg "D4: render failed" # ANSI escape / CSI must be gone. printf '%s' "$out" | LC_ALL=C grep -q "$(printf '\x1b')" && fail_msg "D4: ANSI ESC survived the scrub" # bidi/zero-width/BOM UTF-8 sequences must be gone. printf '%s' "$out" | LC_ALL=C grep -qP '\xe2\x80[\x8b-\x8f\xaa-\xae]|\xef\xbb\xbf' && fail_msg "D4: bidi/zero-width/BOM survived the scrub" # C0 control bytes (except tab/newline) must be gone. printf '%s' "$out" | LC_ALL=C grep -qP '[\x00-\x08\x0e-\x1f\x7f]' && fail_msg "D4: a C0 control byte survived the scrub" # Secret canaries must be redacted, never inlined. printf '%s' "$out" | grep -q 'ghp_0123456789' && fail_msg "D4: GitHub-token canary LEAKED into the digest" printf '%s' "$out" | grep -q 'AKIAIOSFODNN7EXAMPLE' && fail_msg "D4: AWS-key canary LEAKED into the digest" printf '%s' "$out" | grep -q 'REDACTED-SECRET' || fail_msg "D4: secret redaction marker absent — canary may not have been scrubbed" # Free-text is quoted inside a DELIMITED untrusted block, framed as NOT instructions. printf '%s' "$out" | grep -q 'BEGIN UNTRUSTED DATA' || fail_msg "D4: source free-text not placed in a delimited untrusted block" # The 40-hex git SHA locator must NOT be mangled by the secret scrubber. printf '%s' "$out" | grep -q "$SHA40" || fail_msg "D4: legitimate 40-hex SHA locator was wrongly scrubbed" true ) && ok echo "== H1: NON-CIRCULAR HMAC — wake_id independent + not a signed field; tamper breaks MAC ==" ( MOSAIC_CREDENTIALS_FILE="$(fresh_cred h1 default 'key-material-h1')" export MOSAIC_CREDENTIALS_FILE WAKE_AGENT=agentA WAKE_MISSION_GENERATION=3 export WAKE_AGENT WAKE_MISSION_GENERATION unset WAKE_HMAC_KEY_NAME env1="$("$SIGN" sign --observed-seq 5 --content-hash "$SHA40")" || fail_msg "H1: sign failed" # wake_id is NOT a member of the signed field-tuple (it is prepended to the MAC # input, not one of the signed fields whose authenticity the MAC establishes). echo "$env1" | jq -e '.signed | has("wake_id") | not' >/dev/null || fail_msg "H1: wake_id must NOT appear inside the signed field-tuple" # wake_mac is not its own input: it is not a member of `signed` either. echo "$env1" | jq -e '.signed | has("wake_mac") | not' >/dev/null || fail_msg "H1: wake_mac must not be part of its own signed inputs (self-reference)" echo "$env1" | jq -e 'has("wake_id") and has("wake_mac")' >/dev/null || fail_msg "H1: envelope must carry independent wake_id + wake_mac" # Independently generated: two emits over IDENTICAL fields yield DIFFERENT # wake_ids (id not derived from the tuple) AND different MACs (id is bound in). env2="$("$SIGN" sign --observed-seq 5 --content-hash "$SHA40")" w1="$(echo "$env1" | jq -r .wake_id)" w2="$(echo "$env2" | jq -r .wake_id)" [ -n "$w1" ] && [ "$w1" != "null" ] || fail_msg "H1: wake_id missing" [ "$w1" != "$w2" ] || fail_msg "H1: wake_id not independently generated (identical fields produced identical id — derived, not fresh)" m1="$(echo "$env1" | jq -r .wake_mac)" m2="$(echo "$env2" | jq -r .wake_mac)" [ "$m1" != "$m2" ] || fail_msg "H1: MAC did not bind the independent wake_id (same MAC despite different wake_id)" # Untampered verifies. echo "$env1" | "$SIGN" verify >/dev/null 2>&1 || fail_msg "H1: untampered envelope failed to verify" # Tampering ANY signed field breaks the MAC. for mut in \ '.signed.agent_identity="attacker"' \ '.signed.mission_generation="9"' \ '.signed.observed_seq="6"' \ '.signed.emit_ts="0"' \ '.signed.content_hash="deadbeef"'; do if echo "$env1" | jq "$mut" | "$SIGN" verify >/dev/null 2>&1; then fail_msg "H1: tampering [$mut] did NOT break the MAC" fi done # Tampering the independent wake_id also breaks the MAC (it is bound in). if echo "$env1" | jq '.wake_id="wake_forged"' | "$SIGN" verify >/dev/null 2>&1; then fail_msg "H1: forging wake_id did not break the MAC (id not bound)" fi # A wrong key fails verification (the MAC genuinely depends on the key). wrong="$(fresh_cred h1b default 'DIFFERENT-key')" if echo "$env1" | MOSAIC_CREDENTIALS_FILE="$wrong" "$SIGN" verify >/dev/null 2>&1; then fail_msg "H1: verify passed under a DIFFERENT key (MAC not key-dependent)" fi ) && ok echo "== H2: KEY BY-NAME, never inline; no key leak; same-uid boundary documented ==" ( MOSAIC_CREDENTIALS_FILE="$(fresh_cred h2 signing-key 'SUPERSECRET-KEYVALUE-XYZ')" export MOSAIC_CREDENTIALS_FILE WAKE_AGENT=agentB WAKE_MISSION_GENERATION=1 export WAKE_AGENT WAKE_MISSION_GENERATION # Resolve BY NAME (the credential-store key name), never the key material. env1="$(WAKE_HMAC_KEY_NAME=signing-key "$SIGN" sign --observed-seq 1 --content-hash "$SHA40")" || fail_msg "H2: by-name key resolution failed" echo "$env1" | jq -e .wake_mac >/dev/null || fail_msg "H2: no MAC produced from by-name key" # The key VALUE must never appear in the signed output. echo "$env1" | grep -q 'SUPERSECRET-KEYVALUE-XYZ' && fail_msg "H2: key material LEAKED into the signed envelope" # A signed store-entry (hmac placeholder filled) must not leak the key either. entry="$(printf '{"observed_seq":1,"locators":{"repo":"r","issue":1},"class":"actionable","emit_ts":1700000000,"hmac":""}' | WAKE_HMAC_KEY_NAME=signing-key "$SIGN" sign-entry)" echo "$entry" | grep -q 'SUPERSECRET-KEYVALUE-XYZ' && fail_msg "H2: key material LEAKED into the signed entry" echo "$entry" | jq -e '.hmac != "" and .hmac != null' >/dev/null || fail_msg "H2: sign-entry did not fill the hmac placeholder" # No flag may accept key MATERIAL inline — only a key NAME. An attempt to pass # a literal key must be rejected as an unknown option (never silently honored). for badflag in --key --secret --hmac-key --key-value; do if WAKE_HMAC_KEY_NAME=signing-key "$SIGN" sign --observed-seq 1 --content-hash "$SHA40" "$badflag" 'INLINE-KEY' >/dev/null 2>&1; then fail_msg "H2: sign.sh accepted an inline-key flag [$badflag] (key must be by-name only)" fi done # An unknown key NAME fails loud (never signs unsigned / with an empty key). if WAKE_HMAC_KEY_NAME=does-not-exist "$SIGN" sign --observed-seq 1 --content-hash "$SHA40" >/dev/null 2>&1; then fail_msg "H2: signing with an unresolvable key name must FAIL LOUD" fi # The same-uid threat boundary + off-uid follow-up must be DOCUMENTED in the tool. grep -qi 'same-uid' "$SIGN" || fail_msg "H2: same-uid threat boundary not documented in sign.sh" grep -qi 'off-uid' "$SIGN" || fail_msg "H2: off-uid future signer not named in sign.sh" ) && ok echo if [ -s "$FAILFILE" ]; then echo "wake digest/hmac harness: FAILED ($(grep -c . "$FAILFILE") assertion(s))" >&2 exit 1 fi echo "wake digest/hmac harness: all invariants passed ($pass groups)"