#!/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 /.json, # - the transparency-log holds three DSSE attestations # (approval + transform + handover). # # Logs land in $WORK/logs/.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" <"$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)"