Co-authored-by: jason.woltje <jason@diversecanvas.com> Co-committed-by: jason.woltje <jason@diversecanvas.com>
435 lines
26 KiB
Bash
Executable File
435 lines
26 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 is
|
|
# QUARANTINED — dead-lettered + alarmed + excluded — never delivered as
|
|
# valid (fail-loud PER-ENTRY, #920; the rest of the drain still renders). (§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)
|
|
# D5 (#914a) EMBEDDED ACK NAMESPACE: the copy-run ack line rendered at
|
|
# WAKE_AGENT=X render time targets X EXPLICITLY, so an env-less copy-run
|
|
# still resolves to the correct per-agent namespace (never silently
|
|
# falls back to `default`). (§2.1/§2.2)
|
|
# D6 (#914b) DIGEST-CLASS LOCATOR THREADING: an ORIENTATION pointer for a
|
|
# digest-class entry (the shape detector.sh actually enqueues:
|
|
# kind/id/observed_hash/remote/path, not repo/issue/sha/file) carries a
|
|
# non-empty, usable locator — and the ACTIONABLE-tier hard-locator
|
|
# FAIL-LOUD is PRESERVED (now per-entry quarantine, #920) for a malformed
|
|
# actionable claim. (§2.1)
|
|
#
|
|
# 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
|
|
}
|
|
# openssl gates the HMAC legs (H1/H2). #912: the wake trust layer MUST be
|
|
# exercised in real CI, so when running under CI (Woodpecker sets CI=woodpecker)
|
|
# openssl is HARD-REQUIRED — a missing openssl fails the suite LOUD rather than
|
|
# silently skipping the signer (the §4 G6 evidence must come from an
|
|
# actually-run HMAC leg, not a skipped one). In an openssl-less LOCAL DEV env
|
|
# the whole suite still skips so `pnpm test` stays runnable without openssl.
|
|
if ! command -v openssl >/dev/null 2>&1; then
|
|
if [ -n "${CI:-}" ]; then
|
|
echo "FATAL (#912): openssl is REQUIRED in CI to exercise the wake digest/HMAC trust layer, but is not on PATH. The CI image must provide openssl (see .woodpecker/ci-image.yml)." >&2
|
|
exit 1
|
|
fi
|
|
echo "SKIP: openssl not available (local dev; CI hard-requires it)" >&2
|
|
exit 0
|
|
fi
|
|
|
|
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 — a malformed actionable claim is QUARANTINED (fail-loud PER-ENTRY, #920); never delivered as valid =="
|
|
(
|
|
h="$(fresh_state d2)"
|
|
WAKE_STATE_HOME="$h"
|
|
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
|
|
err="$TMP_ROOT/d2.err"
|
|
rc=0
|
|
out="$("$DIGEST" render 2>"$err")" || rc=$?
|
|
# #920: NO longer a whole-digest exit-4 — the malformed entry is QUARANTINED
|
|
# (dead-lettered + alarmed + excluded) and the digest STILL renders (exit 0),
|
|
# but the unlocated claim is NEVER delivered as a valid CLAIM (fail-loud is
|
|
# preserved, now per-entry). The old exit-4 wedged the entire drain (#920).
|
|
[ "$rc" -eq 0 ] || fail_msg "D2: a malformed actionable must be quarantined (render exit 0, #920), got $rc"
|
|
printf '%s' "$out" | grep -q 'mergeable=true' && fail_msg "D2: an unlocated actionable claim must NOT be delivered as a valid CLAIM"
|
|
grep -q 'mergeable=true' "$h/default/dead-letter.jsonl" 2>/dev/null || fail_msg "D2: the malformed claim must be DEAD-LETTERED (fail-loud preserved, per-entry)"
|
|
grep -qi 'QUARANTINE' "$err" || fail_msg "D2: a LOUD per-entry alarm must fire for the malformed claim"
|
|
|
|
# A present-but-imprecise sha (not 40 hex) does NOT satisfy the hard locator ->
|
|
# quarantined, not delivered.
|
|
h="$(fresh_state d2b)"
|
|
WAKE_STATE_HOME="$h"
|
|
export WAKE_STATE_HOME
|
|
"$STORE" enqueue --seq 1 --class actionable --locators '{"claim":"ci=green","sha":"abc123"}' >/dev/null
|
|
rc=0
|
|
out="$("$DIGEST" render 2>/dev/null)" || rc=$?
|
|
[ "$rc" -eq 0 ] || fail_msg "D2: imprecise-sha entry must be quarantined (render exit 0), got $rc"
|
|
printf '%s' "$out" | grep -q 'ci=green' && fail_msg "D2: imprecise sha (not 40-hex) must not satisfy the hard-locator gate (claim must not deliver)"
|
|
grep -q 'ci=green' "$h/default/dead-letter.jsonl" 2>/dev/null || fail_msg "D2: imprecise-sha claim must be dead-lettered"
|
|
|
|
# The SAME claim WITH a precise 40-hex sha renders fine (delivered, not quarantined).
|
|
h="$(fresh_state d2c)"
|
|
WAKE_STATE_HOME="$h"
|
|
export WAKE_STATE_HOME
|
|
"$STORE" enqueue --seq 1 --class actionable --locators "$(jq -cn --arg s "$SHA40" '{claim:"ci=green",sha:$s}')" >/dev/null
|
|
out="$("$DIGEST" render 2>/dev/null)" || fail_msg "D2: a well-located actionable claim must render"
|
|
printf '%s' "$out" | grep -q "$SHA40" || fail_msg "D2: a well-located actionable claim must be DELIVERED"
|
|
[ -s "$h/default/dead-letter.jsonl" ] && fail_msg "D2: a well-located claim must NOT be quarantined"
|
|
# --- #905 bypass-prevention (STILL enforced, now via quarantine): a NON-
|
|
# CANONICAL entry with a TOP-LEVEL `.claim` (store.sh never emits this shape;
|
|
# only .locators.claim is canonical) and EMPTY .locators, fed via --stdin /
|
|
# --from-file, must ALSO be gated. The classifier honors `.claim //
|
|
# .locators.claim`, so a hand-crafted caller cannot bypass the hard-locator gate
|
|
# — the entry is QUARANTINED (not delivered), not silently rendered.
|
|
h="$(fresh_state d2d)"
|
|
WAKE_STATE_HOME="$h"
|
|
export WAKE_STATE_HOME
|
|
toplevel_claim_entry='{"observed_seq":1,"class":"reaction","claim":"mergeable=true","locators":{}}'
|
|
rc=0
|
|
out="$(printf '%s\n' "$toplevel_claim_entry" | "$DIGEST" render --stdin 2>/dev/null)" || rc=$?
|
|
[ "$rc" -eq 0 ] || fail_msg "D2(#905): top-level .claim must be quarantined via --stdin (exit 0), got $rc"
|
|
printf '%s' "$out" | grep -q 'mergeable=true' && fail_msg "D2(#905): --stdin top-level-.claim must NOT be delivered (gate not bypassed)"
|
|
grep -q 'mergeable=true' "$h/default/dead-letter.jsonl" 2>/dev/null || fail_msg "D2(#905): --stdin top-level .claim must be dead-lettered (gate enforced)"
|
|
ff="$TMP_ROOT/d2d-entry.jsonl"
|
|
printf '%s\n' "$toplevel_claim_entry" >"$ff"
|
|
h="$(fresh_state d2e)"
|
|
WAKE_STATE_HOME="$h"
|
|
export WAKE_STATE_HOME
|
|
rc=0
|
|
out2="$("$DIGEST" render --from-file "$ff" 2>/dev/null)" || rc=$?
|
|
[ "$rc" -eq 0 ] || fail_msg "D2(#905): top-level .claim must be quarantined via --from-file (exit 0), got $rc"
|
|
printf '%s' "$out2" | grep -q 'mergeable=true' && fail_msg "D2(#905): --from-file top-level-.claim must NOT be delivered (gate not bypassed)"
|
|
grep -q 'mergeable=true' "$h/default/dead-letter.jsonl" 2>/dev/null || fail_msg "D2(#905): --from-file top-level .claim must be dead-lettered (gate enforced)"
|
|
) && 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.
|
|
# #912: patterns are LITERAL bytes + `grep -E`, NOT PCRE `grep -P`. BusyBox
|
|
# grep (Alpine/musl CI) has no `-P` — a `grep -qP` there errors
|
|
# ("unrecognized option: P"), returns non-zero, and the `&&` silently skips
|
|
# the assertion, so the scrub was NEVER checked in CI. Literal-byte ranges
|
|
# under `grep -E` + LC_ALL=C match identically on BusyBox and GNU grep.
|
|
# Two DISJOINT byte ranges: U+200B..U+200F (E2 80 8B..8F, zero-width) and
|
|
# U+202A..U+202E (E2 80 AA..AE, bidi). NOT a single 8B..AE range — that would
|
|
# wrongly flag legitimate E2 80 xx punctuation in between, e.g. U+2014 EM DASH
|
|
# (E2 80 94) which the digest body uses.
|
|
_b280="$(printf '%b' '\xe2\x80')"
|
|
_b8b="$(printf '%b' '\x8b')"; _b8f="$(printf '%b' '\x8f')"
|
|
_baa="$(printf '%b' '\xaa')"; _bae="$(printf '%b' '\xae')"
|
|
_bbom="$(printf '%b' '\xef\xbb\xbf')"
|
|
printf '%s' "$out" | LC_ALL=C grep -qE "${_b280}[${_b8b}-${_b8f}${_baa}-${_bae}]|${_bbom}" &&
|
|
fail_msg "D4: bidi/zero-width/BOM survived the scrub"
|
|
# C0 control bytes (except tab/newline) must be gone.
|
|
_c00="$(printf '%b' '\x01')"; _c08="$(printf '%b' '\x08')"
|
|
_c0e="$(printf '%b' '\x0e')"; _c1f="$(printf '%b' '\x1f')"; _c7f="$(printf '%b' '\x7f')"
|
|
printf '%s' "$out" | LC_ALL=C grep -qE "[${_c00}-${_c08}${_c0e}-${_c1f}${_c7f}]" &&
|
|
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 "== D5 (#914a): EMBEDDED ACK NAMESPACE — env-less copy-run targets the RENDER-TIME agent, not 'default' =="
|
|
(
|
|
WAKE_STATE_HOME="$(fresh_state d5)"
|
|
export WAKE_STATE_HOME
|
|
# A pending obligation observed under the 'someagent' namespace (render-time
|
|
# WAKE_AGENT is known; the bug is that the EMBEDDED line forgets it).
|
|
WAKE_AGENT=someagent "$STORE" enqueue --seq 1 --class actionable --locators '{"repo":"r","issue":41}' >/dev/null
|
|
out="$(WAKE_AGENT=someagent "$DIGEST" render)" || fail_msg "D5: render (WAKE_AGENT=someagent) failed"
|
|
ack_line="$(printf '%s\n' "$out" | awk '/^-- ACK/{f=1;next} f && NF {print; exit}')"
|
|
[ -n "$ack_line" ] || fail_msg "D5: no embedded ack line extracted from the digest"
|
|
printf '%s' "$ack_line" | grep -q 'WAKE_AGENT=someagent' ||
|
|
fail_msg "D5: embedded ack line has no explicit WAKE_AGENT=someagent prefix — an env-less copy-run silently resolves to 'default' [$ack_line]"
|
|
# Actually RUN it with NO WAKE_AGENT in the environment (the real failure
|
|
# mode: a consumer copy-pastes the line into a fresh shell).
|
|
( unset WAKE_AGENT; env -u WAKE_AGENT sh -c "$ack_line" >/dev/null 2>&1 )
|
|
rc=$?
|
|
[ "$rc" -eq 0 ] || fail_msg "D5: env-less copy-run of the embedded ack line exited non-zero (rc=$rc) [$ack_line]"
|
|
someagent_consumed="$(cat "$WAKE_STATE_HOME/someagent/consumed_seq" 2>/dev/null || echo '?')"
|
|
[ "$someagent_consumed" = "1" ] ||
|
|
fail_msg "D5: env-less copy-run did NOT advance the someagent namespace's consumed_seq (got '$someagent_consumed') — resolved to the wrong namespace"
|
|
# The 'default' namespace must stay untouched — no silent fallback.
|
|
if [ -f "$WAKE_STATE_HOME/default/consumed_seq" ]; then
|
|
default_consumed="$(cat "$WAKE_STATE_HOME/default/consumed_seq" 2>/dev/null || echo 0)"
|
|
[ "$default_consumed" = "0" ] || fail_msg "D5: env-less copy-run advanced the WRONG ('default') namespace instead of someagent"
|
|
fi
|
|
|
|
# --- injection-safety: a hostile agent value must not become a shell
|
|
# injection vector when the embedded line is later copy-run, and must be
|
|
# scrubbed (control/ANSI bytes stripped) like any other inlined value.
|
|
wd="$TMP_ROOT/d5-wd"
|
|
rm -rf "$wd"
|
|
mkdir -p "$wd"
|
|
WAKE_STATE_HOME2="$(fresh_state d5b)"
|
|
# shellcheck disable=SC2016 # deliberately literal: this is the injection
|
|
# payload under test, not an expression we want the test script to expand.
|
|
nasty='$(touch INJECTED)nasty'
|
|
WAKE_AGENT="$nasty" WAKE_STATE_HOME="$WAKE_STATE_HOME2" "$STORE" enqueue --seq 1 --class actionable --locators '{"repo":"r","issue":1}' >/dev/null
|
|
out2="$(WAKE_AGENT="$nasty" WAKE_STATE_HOME="$WAKE_STATE_HOME2" "$DIGEST" render)" || fail_msg "D5: render with a hostile agent value failed"
|
|
ack_line2="$(printf '%s\n' "$out2" | awk '/^-- ACK/{f=1;next} f && NF {print; exit}')"
|
|
( cd "$wd" && unset WAKE_AGENT && env -u WAKE_AGENT WAKE_STATE_HOME="$WAKE_STATE_HOME2" sh -c "$ack_line2" >/dev/null 2>&1 )
|
|
[ -e "$wd/INJECTED" ] && fail_msg "D5: a hostile WAKE_AGENT value achieved shell injection via the embedded ack line [$ack_line2]"
|
|
true
|
|
) && ok
|
|
|
|
echo "== D6 (#914b): DIGEST-CLASS LOCATOR THREADING — ORIENTATION pointer carries its (soft) locator; ACTIONABLE hard-locator FAIL-LOUD unchanged =="
|
|
(
|
|
WAKE_STATE_HOME="$(fresh_state d6)"
|
|
export WAKE_STATE_HOME
|
|
unset WAKE_AGENT WAKE_LANE
|
|
# The REAL shape store.sh receives for a digest-class entry, per detector.sh's
|
|
# own enqueue-building jq filter (kind/id/observed_hash + whichever of
|
|
# repo/path/anchor/remote/branches the source def declares) — NOT
|
|
# repo/issue/sha/file, which is what _locator_line originally recognized.
|
|
loc="$(jq -cn '{kind:"repo", id:"r1", observed_hash:"deadbeefcafe0123456789abcdef0123456789abcdef0123456789abcdef01", remote:"example/repo"}')"
|
|
"$STORE" enqueue --seq 1 --class digest --locators "$loc" >/dev/null
|
|
out="$("$DIGEST" render)" || fail_msg "D6: render failed"
|
|
orientline="$(printf '%s\n' "$out" | grep -E '^\s*\* seq 1 \[digest\]')"
|
|
[ -n "$orientline" ] || fail_msg "D6: no ORIENTATION line found for seq 1"
|
|
printf '%s' "$orientline" | grep -qE 'locator: *$' &&
|
|
fail_msg "D6: digest-class ORIENTATION pointer rendered an EMPTY locator despite a populated .locators field [$orientline]"
|
|
# Must surface something a consumer can act on to re-verify.
|
|
printf '%s' "$orientline" | grep -qE 'remote=example/repo|id=r1|kind=repo' ||
|
|
fail_msg "D6: digest-class ORIENTATION pointer does not carry a usable locator [$orientline]"
|
|
|
|
# --- ACTIONABLE-tier hard-locator FAIL-LOUD must be PRESERVED (now PER-ENTRY
|
|
# quarantine, #920): a digest-class ORIENTATION pointer (above) renders soft,
|
|
# but a genuine ACTIONABLE claim with no hard locator is still fail-loud — it is
|
|
# QUARANTINED (dead-lettered + alarmed + excluded), never delivered as valid.
|
|
h="$(fresh_state d6b)"
|
|
WAKE_STATE_HOME="$h"
|
|
export WAKE_STATE_HOME
|
|
"$STORE" enqueue --seq 1 --class actionable --locators '{"claim":"mergeable=true"}' >/dev/null
|
|
err="$TMP_ROOT/d6b.err"
|
|
rc=0
|
|
out="$("$DIGEST" render 2>"$err")" || rc=$?
|
|
[ "$rc" -eq 0 ] || fail_msg "D6: a malformed actionable is now per-entry quarantined (render exit 0, #920), got $rc"
|
|
printf '%s' "$out" | grep -q 'mergeable=true' && fail_msg "D6: a malformed actionable claim must NOT be delivered as valid (fail-loud preserved)"
|
|
grep -q 'mergeable=true' "$h/default/dead-letter.jsonl" 2>/dev/null || fail_msg "D6: a malformed actionable must be DEAD-LETTERED (fail-loud preserved, per-entry)"
|
|
grep -qi 'QUARANTINE' "$err" || fail_msg "D6: a malformed actionable must raise a loud per-entry alarm"
|
|
) && 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)"
|