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