feat(wake): W7 (vi) fold in W6 monitor-integration hardening in beacon.sh
Some checks failed
ci/woodpecker/pr/ci Pipeline failed

Separate, clearly-labeled commit for observation set (vi) from the #910 review.
The HMAC-verify half is a non-trivial blast radius (emit must sign, record must
verify-and-bind), so it is isolated here rather than expanding the installer diff.
Both changes are additive and backward-compatible (the legacy unsigned path and
emit_ts fallback are preserved when no key is configured).

(vi-a) Monitor-side ingested_ts: `record` stamps the monitor's own receive-time,
       and `check` computes staleness from ingested_ts (falling back to emit_ts
       only for a legacy record). A host shipping a far-future emit_ts to defer
       its own staleness can no longer fool the off-host dead-man clock.
(vi-b) Beacon HMAC-verify at record: when a key name is configured
       (WAKE_BEACON_HMAC_KEY_NAME / WAKE_HMAC_KEY_NAME), `emit` signs the beacon
       core via the existing sign.sh sign-digest path and `record` verifies via
       sign.sh verify AND binds the envelope to this beacon (content_hash ==
       sha256(core), seq/emit_ts match). A spoofed or unsigned beacon is REJECTED
       fail-loud and never advances the received clock. Key is by-name only —
       beacon.sh inlines no key material.

test-wake-beacon.sh extended with B11 (ingested_ts staleness) and B12 (HMAC-verify
+ spoof/unsigned rejection); test-wake-install.sh gains I7/I8 for the same
invariants. Both harnesses shown red-first under targeted mutation, then green.

Part of #892

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0158NZqN2n2ymKFeJAZ4GUCb
This commit is contained in:
mosaic-coder
2026-07-25 22:07:46 -05:00
parent 231ba2ef32
commit ce53b0e646
3 changed files with 185 additions and 9 deletions

View File

@@ -72,6 +72,26 @@ SEQ_FILE="$BEACON_DIR/seq"
# default keeps the primitive self-contained + testable.
RECEIVED_FILE="${WAKE_BEACON_RECEIVED:-$BEACON_DIR/received.json}"
# The non-circular HMAC signer (A5/W3). Reused VERBATIM here for beacon
# authentication (W7 monitor-integration hardening): emit signs the beacon core
# via `sign.sh sign-digest`, record verifies via the existing `sign.sh verify`
# path. Both resolve the key BY NAME (load_credentials store) — never inline.
SIGN_SH="$SCRIPT_DIR/sign.sh"
# Beacon HMAC key NAME (by-name, never the key itself). Signing is engaged ONLY
# when a key name is configured — WAKE_BEACON_HMAC_KEY_NAME, else WAKE_HMAC_KEY_NAME.
# When configured, record REQUIRES an authentic, bound envelope (a spoofed or
# unsigned beacon is REJECTED). When unset, the legacy unsigned path is preserved
# (W7 install-validate is what guarantees a key is configured in production).
_beacon_key_name() { printf '%s' "${WAKE_BEACON_HMAC_KEY_NAME:-${WAKE_HMAC_KEY_NAME:-}}"; }
# Canonical beacon CORE (stdin: a full beacon record; stdout: sorted-key JSON with
# the signature envelope and the monitor-stamped ingested_ts stripped). emit signs
# this exact projection; record recomputes it identically, so the MAC binds the
# beacon's payload and an envelope cannot be lifted onto a different beacon.
_beacon_core() { jq -cS 'del(.beacon_envelope) | del(.ingested_ts)'; }
_beacon_sha256() { openssl dgst -sha256 -r 2>/dev/null | awk '{print $1}'; }
_need_jq() {
command -v jq >/dev/null 2>&1 || {
echo "beacon.sh: jq is required" >&2
@@ -215,6 +235,22 @@ cmd_emit() {
independence:$independence, degraded:$degraded}
+ (if $supervision_root == "" then {} else {supervision_root:$supervision_root} end)')"
# HMAC-SIGN the beacon (W7 monitor-integration hardening) when a key name is
# configured. The signer is the existing non-circular sign.sh, keyed BY NAME —
# emit inlines no key. The signed envelope binds the beacon core (seq/emit_ts/
# content_hash), so the off-host monitor can reject a spoofed beacon at record.
local beacon_key; beacon_key="$(_beacon_key_name)"
if [ -n "$beacon_key" ]; then
command -v openssl >/dev/null 2>&1 || { echo "beacon.sh: openssl is required to HMAC-sign the beacon (key '$beacon_key' configured)." >&2; exit 3; }
local core envelope
core="$(printf '%s' "$record" | _beacon_core)"
if ! envelope="$(printf '%s' "$core" | "$SIGN_SH" sign-digest --key-name "$beacon_key" --observed-seq "$seq" --emit-ts "$emit_ts" 2>/dev/null)" || [ -z "$envelope" ]; then
echo "beacon.sh: FAIL LOUD — could not HMAC-sign the beacon with key '$beacon_key' (resolve it by-name in the credential store). Refusing to emit an UNSIGNED beacon when signing is configured." >&2
exit 1
fi
record="$(printf '%s' "$record" | jq -c --argjson env "$envelope" '. + {beacon_envelope:$env}')"
fi
# DEGRADED beacons are FLAGGED loudly (§1.3) — an isolated host must never
# silently present a weaker different-supervision-root leg as full independence.
if [ "$degraded" -eq 1 ]; then
@@ -262,6 +298,38 @@ cmd_record() {
;;
esac
# HMAC-VERIFY the beacon (W7 monitor-integration hardening) — additive to the
# integer/monotonicity validation above. When a key name is configured, a beacon
# is accepted ONLY if it carries an envelope that is (1) AUTHENTIC under the
# by-name key (via the existing sign.sh verify path) and (2) BOUND to THIS beacon
# (its signed content_hash == sha256(core) and its seq/emit_ts match). A spoofed
# or unsigned beacon is REJECTED, so a forged liveness signal cannot roll the
# monitor's dead-man clock forward.
local beacon_key; beacon_key="$(_beacon_key_name)"
if [ -n "$beacon_key" ]; then
command -v openssl >/dev/null 2>&1 || { echo "beacon.sh record: openssl is required to HMAC-verify the beacon (key '$beacon_key' configured)." >&2; exit 3; }
local env
env="$(printf '%s' "$incoming" | jq -c '.beacon_envelope // empty' 2>/dev/null)"
if [ -z "$env" ]; then
echo "beacon.sh record: FAIL LOUD — beacon carries no signature envelope but signing is configured (key '$beacon_key'). Rejecting an UNSIGNED/spoofed beacon." >&2
exit 2
fi
if ! printf '%s' "$env" | "$SIGN_SH" verify --key-name "$beacon_key" >/dev/null 2>&1; then
echo "beacon.sh record: FAIL LOUD — beacon signature is NOT authentic under key '$beacon_key' (bad HMAC). Rejecting a spoofed beacon." >&2
exit 2
fi
local core core_sha env_chash env_seq env_ts
core="$(printf '%s' "$incoming" | _beacon_core)"
core_sha="$(printf '%s' "$core" | _beacon_sha256)"
env_chash="$(printf '%s' "$env" | jq -r '.signed.content_hash // ""')"
env_seq="$(printf '%s' "$env" | jq -r '.signed.observed_seq // ""')"
env_ts="$(printf '%s' "$env" | jq -r '.signed.emit_ts // ""')"
if [ "$core_sha" != "$env_chash" ] || [ "$env_seq" != "$in_seq" ] || [ "$env_ts" != "$in_ts" ]; then
echo "beacon.sh record: FAIL LOUD — beacon signature is valid but NOT bound to this beacon (envelope lifted onto altered fields). Rejecting a spoofed beacon." >&2
exit 2
fi
fi
mkdir -p "$(dirname "$RECEIVED_FILE")"
# MONOTONIC receiver: keep the highest-seq beacon. A lower-or-equal seq is a
# stale/replayed beacon and is IGNORED (does not roll the liveness clock back).
@@ -274,7 +342,12 @@ cmd_record() {
echo "beacon.sh record: ignoring stale/replayed beacon seq $in_seq (<= recorded $last_seq)" >&2
exit 0
fi
printf '%s\n' "$incoming" | _atomic_write "$RECEIVED_FILE"
# Stamp a MONITOR-SIDE ingested_ts at receive time (W7 monitor-integration
# hardening). Staleness (check) is computed from THIS receive-time, never the
# host-supplied emit_ts — so a host shipping a far-future emit_ts to defer its
# own staleness cannot fool the off-host dead-man clock.
local ingested_ts; ingested_ts="$(date +%s)"
printf '%s' "$incoming" | jq -c --argjson t "$ingested_ts" '. + {ingested_ts:$t}' | _atomic_write "$RECEIVED_FILE"
}
# --- off-host monitor side: the DEAD-MAN / ABSENCE check -------------------
@@ -342,16 +415,20 @@ cmd_check() {
fi
local last_ts last_seq host_id age
last_ts="$(jq -r '.emit_ts // empty' "$RECEIVED_FILE" 2>/dev/null)"
# Staleness clock = the MONITOR's receive-time (ingested_ts), stamped by record.
# Fall back to emit_ts only for a legacy record written before ingested_ts
# existed. A host-supplied far-future emit_ts therefore CANNOT defer staleness:
# once ingested_ts is present it governs, and emit_ts is never used for age.
last_ts="$(jq -r '.ingested_ts // .emit_ts // empty' "$RECEIVED_FILE" 2>/dev/null)"
last_seq="$(jq -r '.beacon_seq // empty' "$RECEIVED_FILE" 2>/dev/null)"
host_id="$(jq -r '.host_id // "unknown"' "$RECEIVED_FILE" 2>/dev/null)"
case "$last_ts" in
'' | *[!0-9]*)
local payload
payload="$(jq -cn --argjson now "$now" --argjson slo "$slo" \
'{kind:"beacon-absence-alarm", reason:"received beacon has no valid emit_ts (corrupt)",
'{kind:"beacon-absence-alarm", reason:"received beacon has no valid receive timestamp (corrupt)",
detected_ts:$now, slo_seconds:$slo}')"
_fire_alarm "corrupt received beacon (no emit_ts)" "$payload"
_fire_alarm "corrupt received beacon (no ingested_ts/emit_ts)" "$payload"
;;
esac
@@ -370,7 +447,7 @@ cmd_check() {
--argjson age "$age" \
--argjson slo "$slo" \
'{kind:"beacon-absence-alarm", reason:"beacon stale past SLO",
host_id:$host_id, last_beacon_seq:$last_seq, last_emit_ts:$last_ts,
host_id:$host_id, last_beacon_seq:$last_seq, last_received_ts:$last_ts,
detected_ts:$now, age_seconds:$age, slo_seconds:$slo}')"
_fire_alarm "beacon stale (${age}s > SLO ${slo}s), host=$host_id last_seq=${last_seq:-?}" "$payload"
fi