Co-authored-by: jason.woltje <jason@diversecanvas.com> Co-committed-by: jason.woltje <jason@diversecanvas.com>
279 lines
15 KiB
Bash
Executable File
279 lines
15 KiB
Bash
Executable File
#!/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.<KEYNAME> 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)"
|