From 55834438a8fa32693a8269cd8ee41190e7cf0eacf60d47774757c169b62e09c1 Mon Sep 17 00:00:00 2001 From: "CTO (Paperclip)" Date: Sun, 25 Oct 2026 20:58:28 +0000 Subject: [PATCH] phase1-smoke: end-to-end harness + Forgejo workflow (DEV-613) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `/.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 --- .forgejo/workflows/phase1-smoke.yml | 49 ++++ Makefile | 15 ++ scripts/phase1-smoke.sh | 395 ++++++++++++++++++++++++++++ tools/webauthn-sign/go.mod | 3 + tools/webauthn-sign/main.go | 133 ++++++++++ 5 files changed, 595 insertions(+) create mode 100644 .forgejo/workflows/phase1-smoke.yml create mode 100644 Makefile create mode 100755 scripts/phase1-smoke.sh create mode 100644 tools/webauthn-sign/go.mod create mode 100644 tools/webauthn-sign/main.go diff --git a/.forgejo/workflows/phase1-smoke.yml b/.forgejo/workflows/phase1-smoke.yml new file mode 100644 index 0000000..3abb2a1 --- /dev/null +++ b/.forgejo/workflows/phase1-smoke.yml @@ -0,0 +1,49 @@ +name: phase1-smoke +on: + push: + branches: [main] + pull_request: + branches: [main] + +# Follows the sha256-clone pattern of validate.yml (DEV-598): the +# repository uses the sha256 git object format, so `actions/checkout` is +# off-limits and we clone manually from a container with git >= 2.45. +# +# `scripts/phase1-smoke.sh` builds every Phase-1 service as a static-ish +# binary (`go build`), spawns them bound to loopback on ephemeral ports, +# then drives User → Approval → Transform → Dummy-Sink → Verification +# with curl + jq. No docker-in-docker needed; the harness is self- +# contained inside the single CI container. +jobs: + phase1-smoke: + name: Phase 1 end-to-end smoke + runs-on: ubuntu-latest + container: + image: golang:1.26-alpine3.23 + steps: + - name: Install prerequisites (git, bash, curl, jq, openssl) + run: | + set -eu + apk add --no-cache git bash curl jq openssl ca-certificates + git --version + go version + jq --version + openssl version + + - name: Clone repository (sha256-aware) + run: | + set -euo pipefail + git clone --depth=1 --branch="${GITHUB_REF_NAME}" \ + "https://x-access-token:${GITHUB_TOKEN}@forgejo.basicstack.de/${GITHUB_REPOSITORY}.git" "${GITHUB_WORKSPACE}" + cd "${GITHUB_WORKSPACE}" + git log -1 --oneline + + - name: Run phase1-smoke + working-directory: ${{ github.workspace }} + env: + # Keep working tree around when the trap tears down so a failed + # run surfaces the service logs in the step output. + KEEP_WORK: "1" + run: | + set -euo pipefail + bash scripts/phase1-smoke.sh diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..4a24f88 --- /dev/null +++ b/Makefile @@ -0,0 +1,15 @@ +# ProofedMesh — repo-root Makefile. +# +# Keep this file minimal; service-specific Go workflows live in each +# services/*/ subtree. The targets here are composition-level flows the +# whole repo cares about. + +SHELL := /usr/bin/env bash + +.PHONY: phase1-smoke +phase1-smoke: ## Run the Phase-1 end-to-end trust-chain smoke test. + @scripts/phase1-smoke.sh + +.PHONY: help +help: + @awk 'BEGIN {FS = ":.*##"; printf "Targets:\n"} /^[a-zA-Z0-9_.-]+:.*##/ { printf " %-20s %s\n", $$1, $$2 }' $(MAKEFILE_LIST) diff --git a/scripts/phase1-smoke.sh b/scripts/phase1-smoke.sh new file mode 100755 index 0000000..fb8e564 --- /dev/null +++ b/scripts/phase1-smoke.sh @@ -0,0 +1,395 @@ +#!/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)" diff --git a/tools/webauthn-sign/go.mod b/tools/webauthn-sign/go.mod new file mode 100644 index 0000000..c4875b3 --- /dev/null +++ b/tools/webauthn-sign/go.mod @@ -0,0 +1,3 @@ +module basicstack.de/proofedmesh/tools/webauthn-sign + +go 1.23 diff --git a/tools/webauthn-sign/main.go b/tools/webauthn-sign/main.go new file mode 100644 index 0000000..d22c097 --- /dev/null +++ b/tools/webauthn-sign/main.go @@ -0,0 +1,133 @@ +// Command webauthn-sign is a Phase-1 smoke-test helper. It performs the +// two WebAuthn crypto operations the shell driver cannot easily do with +// openssl: generate an Ed25519 keypair (so the approval-service can +// pre-register a mock credential) and produce a WebAuthn PublicKey +// assertion over a server-supplied challenge (so the smoke test can +// drive /api/v1/approvals/confirm without a browser). +// +// Two subcommands: +// +// gen-key → {"priv_hex": "...", "pub_hex": "..."} +// sign-assertion [flags] → {"credential_id": "...", "client_data_json": "...", +// "authenticator_data": "...", "signature": "..."} +// +// Everything is base64url-encoded without padding, matching the wire +// format the approval-service's WebAuthn shim accepts. +package main + +import ( + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "flag" + "fmt" + "os" + "strings" +) + +func main() { + if len(os.Args) < 2 { + die("usage: webauthn-sign {gen-key|sign-assertion}") + } + switch os.Args[1] { + case "gen-key": + runGenKey() + case "sign-assertion": + runSignAssertion(os.Args[2:]) + default: + die("unknown subcommand %q", os.Args[1]) + } +} + +func runGenKey() { + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + die("gen key: %v", err) + } + out := map[string]string{ + "priv_hex": hex.EncodeToString(priv), + "pub_hex": hex.EncodeToString(pub), + "pub_b64url": base64.RawURLEncoding.EncodeToString(pub), + } + writeJSON(out) +} + +func runSignAssertion(argv []string) { + fs := flag.NewFlagSet("sign-assertion", flag.ExitOnError) + var ( + privHex = fs.String("priv-hex", "", "hex-encoded 64-byte Ed25519 private key") + challengeB64u = fs.String("challenge-b64url", "", "base64url (no pad) WebAuthn challenge bytes") + rpID = fs.String("rp-id", "", "WebAuthn relying party id (hostname)") + origin = fs.String("origin", "", "browser origin (e.g. https://trust.basicstack.de)") + credentialID = fs.String("credential-id", "", "credential_id to emit on the wire; defaults to b64url(pub)") + ) + if err := fs.Parse(argv); err != nil { + die("parse flags: %v", err) + } + if *privHex == "" || *challengeB64u == "" || *rpID == "" || *origin == "" { + die("sign-assertion requires -priv-hex, -challenge-b64url, -rp-id, -origin") + } + + privRaw, err := hex.DecodeString(*privHex) + if err != nil { + die("decode priv-hex: %v", err) + } + if len(privRaw) != ed25519.PrivateKeySize { + die("priv-hex must decode to %d bytes, got %d", ed25519.PrivateKeySize, len(privRaw)) + } + priv := ed25519.PrivateKey(privRaw) + pub := priv.Public().(ed25519.PublicKey) + credID := *credentialID + if credID == "" { + credID = base64.RawURLEncoding.EncodeToString(pub) + } + + // Build clientDataJSON per the WebAuthn spec. + clientData := map[string]any{ + "type": "webauthn.get", + "challenge": strings.TrimRight(*challengeB64u, "="), + "origin": *origin, + } + clientDataJSON, err := json.Marshal(clientData) + if err != nil { + die("marshal clientDataJSON: %v", err) + } + + // authenticator_data = sha256(rpID) || flags (0x01=UP) || signCount (4 bytes, zero). + rpHash := sha256.Sum256([]byte(*rpID)) + authData := make([]byte, 0, 37) + authData = append(authData, rpHash[:]...) + authData = append(authData, 0x01) + authData = append(authData, 0, 0, 0, 0) + + // Signed bytes = authenticator_data || sha256(clientDataJSON). + cdHash := sha256.Sum256(clientDataJSON) + signed := make([]byte, 0, len(authData)+len(cdHash)) + signed = append(signed, authData...) + signed = append(signed, cdHash[:]...) + sig := ed25519.Sign(priv, signed) + + out := map[string]string{ + "credential_id": credID, + "client_data_json": base64.RawURLEncoding.EncodeToString(clientDataJSON), + "authenticator_data": base64.RawURLEncoding.EncodeToString(authData), + "signature": base64.RawURLEncoding.EncodeToString(sig), + } + writeJSON(out) +} + +func writeJSON(v any) { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + if err := enc.Encode(v); err != nil { + die("encode output: %v", err) + } +} + +func die(format string, args ...any) { + fmt.Fprintf(os.Stderr, "webauthn-sign: "+format+"\n", args...) + os.Exit(1) +}