phase1-smoke: end-to-end harness + Forgejo workflow (DEV-613)
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
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
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>
This commit is contained in:
parent
9b8f520afc
commit
55834438a8
5 changed files with 595 additions and 0 deletions
49
.forgejo/workflows/phase1-smoke.yml
Normal file
49
.forgejo/workflows/phase1-smoke.yml
Normal file
|
|
@ -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
|
||||||
15
Makefile
Normal file
15
Makefile
Normal file
|
|
@ -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)
|
||||||
395
scripts/phase1-smoke.sh
Executable file
395
scripts/phase1-smoke.sh
Executable file
|
|
@ -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 <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)"
|
||||||
3
tools/webauthn-sign/go.mod
Normal file
3
tools/webauthn-sign/go.mod
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
module basicstack.de/proofedmesh/tools/webauthn-sign
|
||||||
|
|
||||||
|
go 1.23
|
||||||
133
tools/webauthn-sign/main.go
Normal file
133
tools/webauthn-sign/main.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue