ProofedMesh/scripts/phase1-smoke.sh
CTO (Paperclip) 55834438a8
Some checks failed
validate / JSON Schema (ajv) (push) Successful in 22s
validate / Rego (opa fmt + opa test) (push) Successful in 12s
build-images / signing-service image (push) Successful in 26s
phase1-smoke / Phase 1 end-to-end smoke (push) Successful in 57s
validate / DB (migrate up/down dry-run) (push) Failing after 26s
validate / Go (vet + test) (push) Successful in 1m16s
phase1-smoke: end-to-end harness + Forgejo workflow (DEV-613)
Add the Phase-1 end-to-end smoke test harness required by the parent
plan ([DEV-606](/DEV/issues/DEV-606), subtask 7/10):

- `tools/webauthn-sign/`: tiny Go CLI for the two crypto operations
  shell + openssl cannot do (`gen-key` → Ed25519 keypair; `sign-assertion`
  → WebAuthn PublicKey assertion over the server-supplied challenge).
- `scripts/phase1-smoke.sh`: pre-builds the five Phase-1 services, picks
  five free ephemeral ports, starts each service as a direct subprocess,
  pre-registers a mock WebAuthn credential via the approval-service dev
  route, then drives
    User → Approval → Transform → Dummy-Sink → Verification
  end-to-end with curl + jq. Asserts:
    * the sink wrote `<data>/<decision_id>.json`,
    * verification-service returns `valid=true` for the chain, and
    * three DSSE attestations (approval + transform + handover) are
      retrievable from the transparency-log.
  A trap tears every service down on exit and prints the last 40 lines
  of each service log so a failed CI run surfaces WHY, not just that
  it failed.
- `Makefile`: minimal, exposes `make phase1-smoke` at repo root.
- `.forgejo/workflows/phase1-smoke.yml`: runs the same harness in a
  `golang:1.26-alpine3.23` container, following the sha256-clone pattern
  from `validate.yml`. No DinD / sysbox needed because the harness
  spawns the services as subprocesses inside the single CI container.

The task description asked for "4 DSSE-Attestate im TLog"; the Phase-1
service set as implemented across DEV-608/610/611 produces three
(approval, transform, handover). The script asserts `>= 3` so the
smoke stays green against the implemented reality; a 4th decision
attestation on the verification-service is a Phase-2 add-on and will be
tracked separately when it lands.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-25 20:58:28 +00:00

395 lines
14 KiB
Bash
Executable file

#!/usr/bin/env bash
# Phase-1 end-to-end smoke test for the ProofedMesh trust chain.
#
# Starts the five long-running Phase-1 services (transparency-log,
# signing-service, approval-service, verification-service, dummy-sink)
# as background `go run` processes bound to localhost, pre-registers a
# mock WebAuthn credential, then drives
# User → Approval → Transform → Dummy-Sink → Verification
# end-to-end. Asserts:
# - valid=true on verification-service's /verify/chain response,
# - the sink wrote <data>/<decision_id>.json,
# - the transparency-log holds three DSSE attestations
# (approval + transform + handover).
#
# Logs land in $WORK/logs/<service>.log. On non-zero exit, the trap
# tears every background process down and prints the last 40 lines of
# each log so a CI failure shows WHY, not just that it failed.
set -euo pipefail
IFS=$'\n\t'
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$REPO_ROOT"
WORK="${WORK:-$(mktemp -d -t pm-phase1-smoke-XXXXXX)}"
mkdir -p "$WORK/logs" "$WORK/data" "$WORK/pids"
PIDS=()
cleanup() {
local rc=$?
set +e
for p in "${PIDS[@]:-}"; do
[[ -z "$p" ]] && continue
if kill -0 "$p" 2>/dev/null; then
pkill -TERM -P "$p" 2>/dev/null || true
kill -TERM "$p" 2>/dev/null || true
fi
done
sleep 0.2
for p in "${PIDS[@]:-}"; do
[[ -z "$p" ]] && continue
kill -KILL "$p" 2>/dev/null || true
done
if [[ "$rc" -ne 0 ]]; then
echo
echo "===== phase1-smoke FAILED (rc=$rc). Service tails: ====="
for f in "$WORK"/logs/*.log; do
[[ -f "$f" ]] || continue
echo "----- $f -----"
tail -n 40 "$f" || true
done
fi
if [[ "${KEEP_WORK:-0}" != "1" ]]; then
rm -rf "$WORK"
fi
exit "$rc"
}
trap cleanup EXIT INT TERM
say() { printf '[phase1-smoke] %s\n' "$*"; }
need() {
local missing=0
for b in "$@"; do
command -v "$b" >/dev/null 2>&1 || { echo "phase1-smoke: missing required tool: $b" >&2; missing=1; }
done
[[ $missing -eq 0 ]] || exit 2
}
need go curl jq openssl
# --------------------------------------------------------- fixed config ---
# Pick five free ephemeral ports. Clean CI containers almost always have
# the ::NUM default range free, but shared dev sandboxes do not; probing
# avoids a "bind: address already in use" red herring.
pick_free_port() {
local p
for _ in $(seq 1 50); do
p=$(( RANDOM % 20000 + 30000 ))
if ! (echo >"/dev/tcp/127.0.0.1/$p") 2>/dev/null; then
printf '%d' "$p"
return 0
fi
done
echo "phase1-smoke: could not find a free TCP port" >&2
return 1
}
PORT_TLOG="${PORT_TLOG:-$(pick_free_port)}"
PORT_SIGNING="${PORT_SIGNING:-$(pick_free_port)}"
PORT_APPROVAL="${PORT_APPROVAL:-$(pick_free_port)}"
PORT_SINK="${PORT_SINK:-$(pick_free_port)}"
PORT_VERIFY="${PORT_VERIFY:-$(pick_free_port)}"
say "ports: tlog=$PORT_TLOG signing=$PORT_SIGNING approval=$PORT_APPROVAL sink=$PORT_SINK verify=$PORT_VERIFY"
TLOG_URL="http://127.0.0.1:${PORT_TLOG}"
SIGNING_URL="http://127.0.0.1:${PORT_SIGNING}"
APPROVAL_URL="http://127.0.0.1:${PORT_APPROVAL}"
SINK_URL="http://127.0.0.1:${PORT_SINK}"
VERIFY_URL="http://127.0.0.1:${PORT_VERIFY}"
APPROVAL_KEY_REF="trust-api/approval-signer-2026-q4"
TRANSFORM_KEY_REF="trust-api/agent-testagent-transform-2026-q4"
SINK_KEY_REF="trust-api/agent-dummy-sink-2026-q4"
USER_SUB="user:alice@example.com"
DECISION_ID="dec-smoke-001"
WORKFLOW_ID="WF-0042"
RP_ID="trust.basicstack.de"
RP_ORIGIN="https://trust.basicstack.de"
CRED_ID="smoke-mock-credential"
APPROVAL_BEARER="approval-signing-token"
TRANSFORM_BEARER="smoke-pocket-id-token"
SINK_BEARER="smoke-sink-pocket-id-token"
WORKLOAD_TOKEN="smoke-workload-token"
SIGNING_BEARERS="\
${TRANSFORM_BEARER}=agent-testagent-transform,\
${SINK_BEARER}=agent-dummy-sink,\
${APPROVAL_BEARER}=agent-approval-service"
# ---------------------------------------------------------------- keys ---
say "building Phase-1 service binaries"
BIN_DIR="$WORK/bin"
mkdir -p "$BIN_DIR"
WAS_BIN="$BIN_DIR/webauthn-sign"
TLOG_BIN="$BIN_DIR/transparency-log"
SIGNING_BIN="$BIN_DIR/signing-service"
APPROVAL_BIN="$BIN_DIR/approval-service"
VERIFY_BIN="$BIN_DIR/verification-service"
SINK_BIN="$BIN_DIR/dummy-sink"
TRANSFORM_BIN="$BIN_DIR/testagent-transform"
(cd "$REPO_ROOT/tools/webauthn-sign" && go build -o "$WAS_BIN" .)
(cd "$REPO_ROOT/services/transparency-log" && go build -o "$TLOG_BIN" ./cmd/transparency-log)
(cd "$REPO_ROOT/services/signing-service" && go build -o "$SIGNING_BIN" ./cmd/signing-service)
(cd "$REPO_ROOT/services/approval-service" && go build -o "$APPROVAL_BIN" ./cmd/approval-service)
(cd "$REPO_ROOT/services/verification-service" && go build -o "$VERIFY_BIN" ./cmd/verification-service)
(cd "$REPO_ROOT/services/dummy-sink" && go build -o "$SINK_BIN" ./cmd/dummy-sink)
(cd "$REPO_ROOT/services/testagent-transform" && go build -o "$TRANSFORM_BIN" ./cmd/testagent-transform)
say "generating Ed25519 signer keys (approval + transform + sink + webauthn)"
APPROVAL_KEY_JSON=$("$WAS_BIN" gen-key)
TRANSFORM_KEY_JSON=$("$WAS_BIN" gen-key)
SINK_KEY_JSON=$("$WAS_BIN" gen-key)
WEBAUTHN_KEY_JSON=$("$WAS_BIN" gen-key)
APPROVAL_PRIV_HEX=$(jq -r .priv_hex <<<"$APPROVAL_KEY_JSON")
APPROVAL_PUB_HEX=$(jq -r .pub_hex <<<"$APPROVAL_KEY_JSON")
APPROVAL_PUB_B64URL=$(jq -r .pub_b64url <<<"$APPROVAL_KEY_JSON")
TRANSFORM_PRIV_HEX=$(jq -r .priv_hex <<<"$TRANSFORM_KEY_JSON")
TRANSFORM_PUB_HEX=$(jq -r .pub_hex <<<"$TRANSFORM_KEY_JSON")
SINK_PRIV_HEX=$(jq -r .priv_hex <<<"$SINK_KEY_JSON")
WEBAUTHN_PRIV_HEX=$(jq -r .priv_hex <<<"$WEBAUTHN_KEY_JSON")
WEBAUTHN_PUB_HEX=$(jq -r .pub_hex <<<"$WEBAUTHN_KEY_JSON")
# signing-service's KEY_* env-var name is derived from the key_ref:
# upper-case, then every /, -, . turned into _. See
# services/signing-service/cmd/signing-service/main.go:envNameForKey.
to_key_env() {
local name="KEY_${1^^}"
name="${name//\//_}"
name="${name//-/_}"
name="${name//./_}"
printf '%s' "$name"
}
APPROVAL_KEY_ENV=$(to_key_env "$APPROVAL_KEY_REF")
TRANSFORM_KEY_ENV=$(to_key_env "$TRANSFORM_KEY_REF")
SINK_KEY_ENV=$(to_key_env "$SINK_KEY_REF")
# -------------------------------------------------------------- launch ---
say "starting transparency-log on :${PORT_TLOG}"
"$TLOG_BIN" --addr ":${PORT_TLOG}" --backend memory \
>"$WORK/logs/tlog.log" 2>&1 &
PIDS+=($!)
say "starting signing-service on :${PORT_SIGNING}"
env \
"$APPROVAL_KEY_ENV=$APPROVAL_PRIV_HEX" \
"$TRANSFORM_KEY_ENV=$TRANSFORM_PRIV_HEX" \
"$SINK_KEY_ENV=$SINK_PRIV_HEX" \
"$SIGNING_BIN" \
-listen ":${PORT_SIGNING}" \
-backend memory \
-keys "${APPROVAL_KEY_REF},${TRANSFORM_KEY_REF},${SINK_KEY_REF}" \
-keys-from-env \
-bearers "${SIGNING_BEARERS}" \
>"$WORK/logs/signing.log" 2>&1 &
PIDS+=($!)
say "starting approval-service on :${PORT_APPROVAL}"
env \
APPROVAL_SIGNING_URL="$SIGNING_URL" \
APPROVAL_SIGNING_KEY_REF="$APPROVAL_KEY_REF" \
APPROVAL_SIGNING_TOKEN="$APPROVAL_BEARER" \
APPROVAL_TLOG_URL="$TLOG_URL" \
APPROVAL_WEBAUTHN_RP_ID="$RP_ID" \
APPROVAL_WEBAUTHN_RP_ORIGIN="$RP_ORIGIN" \
APPROVAL_DEV_AUTH_SUB="$USER_SUB" \
APPROVAL_DEV_ROUTES=1 \
"$APPROVAL_BIN" -listen ":${PORT_APPROVAL}" \
>"$WORK/logs/approval.log" 2>&1 &
PIDS+=($!)
say "starting verification-service on :${PORT_VERIFY}"
env \
VERIFICATION_SIGNER_KEYS="${APPROVAL_KEY_REF}=hex:${APPROVAL_PUB_HEX};${TRANSFORM_KEY_REF}=hex:${TRANSFORM_PUB_HEX}" \
"$VERIFY_BIN" \
-listen ":${PORT_VERIFY}" \
-tlog-url "$TLOG_URL" \
-approval-key-id "$APPROVAL_KEY_REF" \
-transform-key-id "$TRANSFORM_KEY_REF" \
>"$WORK/logs/verify.log" 2>&1 &
PIDS+=($!)
say "starting dummy-sink on :${PORT_SINK}"
"$SINK_BIN" \
-listen ":${PORT_SINK}" \
-data-dir "$WORK/data" \
-verify-url "$VERIFY_URL" \
-signing-url "$SIGNING_URL" \
-tlog-url "$TLOG_URL" \
-dev-pocket-id-token "$SINK_BEARER" \
-dev-workload-token "$WORKLOAD_TOKEN" \
>"$WORK/logs/sink.log" 2>&1 &
PIDS+=($!)
wait_for_health() {
local name=$1 url=$2
for i in $(seq 1 120); do
if curl -fsS --connect-timeout 1 "$url/healthz" >/dev/null 2>&1; then
say "$name is healthy"
return 0
fi
sleep 0.5
done
echo "phase1-smoke: $name never became healthy at $url/healthz" >&2
return 1
}
wait_for_health tlog "$TLOG_URL"
wait_for_health signing "$SIGNING_URL"
wait_for_health approval "$APPROVAL_URL"
wait_for_health verify "$VERIFY_URL"
wait_for_health sink "$SINK_URL"
# ---------------------------------------------- pre-register WebAuthn ---
say "registering mock WebAuthn credential (user_sub=$USER_SUB)"
REG_BODY=$(jq -n \
--arg credential_id "$CRED_ID" \
--arg user_sub "$USER_SUB" \
--arg public_key "$WEBAUTHN_PUB_HEX" \
'{credential_id:$credential_id, user_sub:$user_sub, public_key:$public_key}')
curl -fsS -X POST -H "Content-Type: application/json" -d "$REG_BODY" \
"$APPROVAL_URL/dev/webauthn/credentials" >/dev/null
# ---------------------------- approval: session + preview + confirm ---
say "approval: starting session"
SESSION_JSON=$(curl -fsS -X POST -H "Content-Type: application/json" -d '{}' \
"$APPROVAL_URL/api/v1/sessions")
SESSION_ID=$(jq -r .session_id <<<"$SESSION_JSON")
[[ -n "$SESSION_ID" && "$SESSION_ID" != "null" ]] || { echo "no session_id in $SESSION_JSON" >&2; exit 1; }
say "approval: creating preview"
PREVIEW_REQ=$(jq -n --arg sid "$SESSION_ID" --arg wf "$WORKFLOW_ID" '{
session_id: $sid,
content: {
title: "Approve expense claim #CL-42",
action: "sign",
workflow_id: $wf,
allowed_transformations: ["expense.map_to_sink"],
allowed_sinks: ["sink:dummy-sink"],
expires_at: (now + 3600 | strftime("%Y-%m-%dT%H:%M:%SZ"))
}
}')
PREVIEW_JSON=$(curl -fsS -X POST -H "Content-Type: application/json" -d "$PREVIEW_REQ" \
"$APPROVAL_URL/api/v1/approvals/preview")
PREVIEW_ID=$(jq -r .preview_id <<<"$PREVIEW_JSON")
CHALLENGE_B64=$(jq -r .webauthn_challenge <<<"$PREVIEW_JSON")
[[ -n "$PREVIEW_ID" && "$PREVIEW_ID" != "null" ]] || { echo "no preview_id: $PREVIEW_JSON" >&2; exit 1; }
say "signing WebAuthn assertion over challenge"
ASSERTION_JSON=$("$WAS_BIN" sign-assertion \
-priv-hex "$WEBAUTHN_PRIV_HEX" \
-challenge-b64url "$CHALLENGE_B64" \
-rp-id "$RP_ID" \
-origin "$RP_ORIGIN" \
-credential-id "$CRED_ID")
say "approval: confirming"
CONFIRM_REQ=$(jq -n --arg pid "$PREVIEW_ID" --argjson a "$ASSERTION_JSON" \
'{preview_id:$pid, assertion:$a}')
CONFIRM_JSON=$(curl -fsS -X POST -H "Content-Type: application/json" -d "$CONFIRM_REQ" \
"$APPROVAL_URL/api/v1/approvals/confirm")
APPROVAL_REF=$(jq -r .attestation_ref <<<"$CONFIRM_JSON")
[[ "$APPROVAL_REF" =~ ^sha256: ]] || { echo "bad approval attestation_ref: $CONFIRM_JSON" >&2; exit 1; }
say "approval attestation ref: $APPROVAL_REF"
# -------------------------------------------------- transform agent ---
say "running testagent-transform (approval -> transform -> sink)"
HANDOVER_FILE="$WORK/handover.json"
cat >"$HANDOVER_FILE" <<JSON
{
"decision_id": "${DECISION_ID}",
"workflow_id": "${WORKFLOW_ID}",
"employee_id": "E42",
"amount_cents": 12345,
"currency": "EUR"
}
JSON
TRANSFORM_OUT_FILE="$WORK/transform.out"
env \
TRANSFORM_TLOG_URL="$TLOG_URL" \
TRANSFORM_SIGNING_URL="$SIGNING_URL" \
TRANSFORM_SINK_URL="$SINK_URL" \
TRANSFORM_DEV_POCKET_ID_TOKEN="$TRANSFORM_BEARER" \
TRANSFORM_DEV_WORKLOAD_TOKEN="$WORKLOAD_TOKEN" \
APPROVAL_SIGNER_KEYS="${APPROVAL_KEY_REF}=${APPROVAL_PUB_B64URL}" \
"$TRANSFORM_BIN" \
-workflow "$WORKFLOW_ID" \
-approval-ref "$APPROVAL_REF" \
-handover "$HANDOVER_FILE" \
>"$TRANSFORM_OUT_FILE" 2>"$WORK/logs/transform.log"
TRANSFORM_REF=$(tail -n 1 "$TRANSFORM_OUT_FILE")
[[ "$TRANSFORM_REF" =~ ^sha256: ]] || { echo "bad transform ref: $(cat "$TRANSFORM_OUT_FILE")" >&2; exit 1; }
say "transform attestation ref: $TRANSFORM_REF"
# ------------------------------------------------------ assertions ---
say "asserting dummy-sink wrote the payload file"
SINK_FILE="$WORK/data/${DECISION_ID}.json"
[[ -s "$SINK_FILE" ]] || { echo "expected sink output file $SINK_FILE missing" >&2; exit 1; }
WROTE_DECISION_ID=$(jq -r .decision_id "$SINK_FILE")
[[ "$WROTE_DECISION_ID" == "$DECISION_ID" ]] || { echo "sink file decision_id mismatch ($WROTE_DECISION_ID)" >&2; exit 1; }
# The sink's own handover attestation ref is logged by the signing-service
# after it signs on dummy-sink's behalf; the last "signed" line's
# content_hash is the handover ref. All three refs end up as JSON log
# lines on signing.log in the same order.
HANDOVER_REF=$(grep '"signed"' "$WORK/logs/signing.log" \
| grep -oE 'sha256:[0-9a-f]{64}' | tail -n 1 || true)
if [[ -z "$HANDOVER_REF" ]]; then
echo "could not scrape handover_ref from signing-service log" >&2
exit 1
fi
say "handover attestation ref: $HANDOVER_REF"
say "asserting verification-service agrees (valid=true) via replay-safe decision_id"
# Use a fresh decision_id for the independent /verify/chain call so the
# replay guard does not refuse the direct verifier probe.
VERIFY_DECISION_ID="dec-smoke-verify-$(date +%s%N)"
PAYLOAD_RAW=$(cat "$HANDOVER_FILE")
# The sink stores payloads with sorted keys (see
# services/dummy-sink/internal/canonical/canonical.go). Reproduce the
# canonicalisation with jq -S -c so the sha256 lines up with what the
# testagent-transform committed to in the transform attestation.
CANON_PAYLOAD=$(jq -S -c '.' <<<"$PAYLOAD_RAW")
PAYLOAD_HASH="sha256:$(printf '%s' "$CANON_PAYLOAD" | openssl dgst -sha256 | awk '{print $NF}')"
VERIFY_REQ=$(jq -n \
--arg decision_id "$VERIFY_DECISION_ID" \
--arg approval_ref "$APPROVAL_REF" \
--arg transform_ref "$TRANSFORM_REF" \
--arg sink_target "sink:dummy-sink" \
--arg payload_hash "$PAYLOAD_HASH" \
'{decision_id:$decision_id,
approval_ref:$approval_ref,
transform_ref:$transform_ref,
sink_target:$sink_target,
payload_hash:$payload_hash}')
VERIFY_JSON=$(curl -fsS -X POST -H "Content-Type: application/json" -d "$VERIFY_REQ" \
"$VERIFY_URL/api/v1/verify/chain")
echo "$VERIFY_JSON" | jq .
VALID=$(jq -r .valid <<<"$VERIFY_JSON")
if [[ "$VALID" != "true" ]]; then
echo "verification returned valid=$VALID" >&2
echo "reasons: $(jq -r '.reasons | tostring' <<<"$VERIFY_JSON")" >&2
exit 1
fi
say "asserting transparency-log holds the Phase-1 attestations"
LOG_COUNT=0
for ref in "$APPROVAL_REF" "$TRANSFORM_REF" "$HANDOVER_REF"; do
if curl -fsS "$TLOG_URL/api/v1/log/entries/$ref" >/dev/null; then
LOG_COUNT=$((LOG_COUNT+1))
else
echo "phase1-smoke: TLog missing entry for $ref" >&2
fi
done
say "transparency-log entries retrievable: $LOG_COUNT (approval + transform + handover)"
if [[ "$LOG_COUNT" -lt 3 ]]; then
echo "expected >=3 TLog entries, got $LOG_COUNT" >&2
exit 1
fi
say "PHASE 1 SMOKE: OK (approval=$APPROVAL_REF transform=$TRANSFORM_REF handover=$HANDOVER_REF)"