Compare commits
4 commits
0ae29ae342
...
55834438a8
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
55834438a8 | ||
|
|
9b8f520afc | ||
|
|
801c50cfb0 | ||
|
|
47a7373ef2 |
17 changed files with 1097 additions and 96 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)"
|
||||
|
|
@ -34,6 +34,8 @@ func main() {
|
|||
WebauthnRPID string
|
||||
WebauthnOrigins string
|
||||
AttestationIss string
|
||||
DevAuthSub string
|
||||
DevRoutes bool
|
||||
}{}
|
||||
flag.StringVar(&cfg.Listen, "listen", envOr("APPROVAL_LISTEN", ":8080"), "HTTP listen address")
|
||||
flag.StringVar(&cfg.SigningURL, "signing-url", os.Getenv("APPROVAL_SIGNING_URL"), "signing-service base URL")
|
||||
|
|
@ -45,6 +47,8 @@ func main() {
|
|||
flag.StringVar(&cfg.WebauthnRPID, "webauthn-rp-id", envOr("APPROVAL_WEBAUTHN_RP_ID", "trust.basicstack.de"), "WebAuthn relying party ID")
|
||||
flag.StringVar(&cfg.WebauthnOrigins, "webauthn-origins", envOr("APPROVAL_WEBAUTHN_RP_ORIGIN", "https://trust.basicstack.de"), "comma-separated allowed WebAuthn origins")
|
||||
flag.StringVar(&cfg.AttestationIss, "attestation-issuer", envOr("APPROVAL_POCKET_ID_ISSUER", "https://auth.basicstack.de"), "issuer embedded in attestations")
|
||||
flag.StringVar(&cfg.DevAuthSub, "dev-auth-sub", os.Getenv("APPROVAL_DEV_AUTH_SUB"), "DEV ONLY: when set, stamp every request with this Pocket-ID `sub` instead of requiring real auth")
|
||||
flag.BoolVar(&cfg.DevRoutes, "dev-routes", envBool("APPROVAL_DEV_ROUTES", false), "DEV ONLY: expose POST /dev/webauthn/credentials to register in-memory credentials for the smoke test")
|
||||
flag.Parse()
|
||||
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stderr, nil))
|
||||
|
|
@ -85,14 +89,21 @@ func main() {
|
|||
AttestationIssuer: cfg.AttestationIss,
|
||||
}
|
||||
|
||||
server := &httpapi.Server{
|
||||
// Phase 1 wires an "unauthenticated" dev resolver — the Pocket-ID
|
||||
// cookie middleware (shared with admin-api) attaches in deployment.
|
||||
// This default is deliberately noisy so misconfigured deployments
|
||||
// fail closed rather than silently accepting anonymous callers.
|
||||
Auth: httpapi.AuthFunc(func(_ *http.Request) (auth.Principal, error) {
|
||||
var authResolver httpapi.AuthResolver
|
||||
if cfg.DevAuthSub != "" {
|
||||
sub := cfg.DevAuthSub
|
||||
logger.Warn("approval-service: dev auth resolver in use — DEV/CI ONLY", "sub", sub)
|
||||
authResolver = httpapi.AuthFunc(func(_ *http.Request) (auth.Principal, error) {
|
||||
return auth.Principal{Sub: sub}, nil
|
||||
})
|
||||
} else {
|
||||
authResolver = httpapi.AuthFunc(func(_ *http.Request) (auth.Principal, error) {
|
||||
return auth.Principal{}, errors.New("approval-service: no auth middleware mounted; wire Pocket-ID before serving traffic")
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
server := &httpapi.Server{
|
||||
Auth: authResolver,
|
||||
Sessions: mgr,
|
||||
Orchestrator: orch,
|
||||
Logger: logger,
|
||||
|
|
@ -100,6 +111,10 @@ func main() {
|
|||
|
||||
mux := http.NewServeMux()
|
||||
server.Mount(mux)
|
||||
if cfg.DevRoutes {
|
||||
logger.Warn("approval-service: dev routes enabled — DEV/CI ONLY")
|
||||
httpapi.MountDevRoutes(mux, orch.Webauthn, logger)
|
||||
}
|
||||
|
||||
logger.Info("approval-service starting",
|
||||
"listen", cfg.Listen,
|
||||
|
|
@ -131,6 +146,20 @@ func envDur(key string, fallback time.Duration) time.Duration {
|
|||
return d
|
||||
}
|
||||
|
||||
func envBool(key string, fallback bool) bool {
|
||||
v := strings.TrimSpace(os.Getenv(key))
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
switch strings.ToLower(v) {
|
||||
case "1", "true", "yes", "on":
|
||||
return true
|
||||
case "0", "false", "no", "off":
|
||||
return false
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func splitAndTrim(s, sep string) []string {
|
||||
parts := strings.Split(s, sep)
|
||||
out := make([]string, 0, len(parts))
|
||||
|
|
|
|||
137
services/approval-service/internal/httpapi/dev.go
Normal file
137
services/approval-service/internal/httpapi/dev.go
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
// Package httpapi — dev-only routes. Mounted by main.go only when the
|
||||
// operator passes -dev-routes (or APPROVAL_DEV_ROUTES=1). These routes
|
||||
// seed in-memory WebAuthn credentials so the Phase-1 end-to-end smoke
|
||||
// test can drive the approval flow without a browser or Pocket-ID.
|
||||
// NEVER mount these in production.
|
||||
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"basicstack.de/proofedmesh/services/approval-service/internal/webauthn"
|
||||
)
|
||||
|
||||
type devRegisterCredentialRequest struct {
|
||||
CredentialID string `json:"credential_id"`
|
||||
UserSub string `json:"user_sub"`
|
||||
PublicKey string `json:"public_key"`
|
||||
}
|
||||
|
||||
type devRegisterCredentialResponse struct {
|
||||
CredentialID string `json:"credential_id"`
|
||||
UserSub string `json:"user_sub"`
|
||||
}
|
||||
|
||||
// MountDevRoutes exposes POST /dev/webauthn/credentials and GET
|
||||
// /dev/healthz. Call only from a dev/CI build; the handler accepts any
|
||||
// Ed25519 public key as a trusted credential, which is useless in prod.
|
||||
func MountDevRoutes(mux *http.ServeMux, store *webauthn.Store, logger *slog.Logger) {
|
||||
mux.HandleFunc("POST /dev/webauthn/credentials", func(w http.ResponseWriter, r *http.Request) {
|
||||
var req devRegisterCredentialRequest
|
||||
dec := json.NewDecoder(http.MaxBytesReader(nil, r.Body, 1<<20))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&req); err != nil {
|
||||
writeDevErr(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
if req.CredentialID == "" {
|
||||
writeDevErr(w, http.StatusBadRequest, "bad_request", "credential_id is required")
|
||||
return
|
||||
}
|
||||
if req.UserSub == "" {
|
||||
writeDevErr(w, http.StatusBadRequest, "bad_request", "user_sub is required")
|
||||
return
|
||||
}
|
||||
pub, err := decodeDevPubKey(req.PublicKey)
|
||||
if err != nil {
|
||||
writeDevErr(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
cred := webauthn.Credential{
|
||||
ID: req.CredentialID,
|
||||
UserSub: req.UserSub,
|
||||
PublicKey: pub,
|
||||
}
|
||||
if err := store.AddCredential(cred); err != nil {
|
||||
writeDevErr(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
if logger != nil {
|
||||
logger.Info("dev: webauthn credential registered",
|
||||
"credential_id", req.CredentialID,
|
||||
"user_sub", req.UserSub,
|
||||
)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(devRegisterCredentialResponse{
|
||||
CredentialID: req.CredentialID,
|
||||
UserSub: req.UserSub,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func decodeDevPubKey(s string) (ed25519.PublicKey, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return nil, errors.New("public_key is required (hex, b64, or b64url of a 32-byte Ed25519 public key)")
|
||||
}
|
||||
if b, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(s, "=")); err == nil && len(b) == ed25519.PublicKeySize {
|
||||
return ed25519.PublicKey(b), nil
|
||||
}
|
||||
if b, err := base64.StdEncoding.DecodeString(s); err == nil && len(b) == ed25519.PublicKeySize {
|
||||
return ed25519.PublicKey(b), nil
|
||||
}
|
||||
if b, err := decodeHex(s); err == nil && len(b) == ed25519.PublicKeySize {
|
||||
return ed25519.PublicKey(b), nil
|
||||
}
|
||||
return nil, errors.New("public_key must be a 32-byte Ed25519 key encoded as hex, base64, or base64url")
|
||||
}
|
||||
|
||||
func decodeHex(s string) ([]byte, error) {
|
||||
n := len(s)
|
||||
if n%2 != 0 {
|
||||
return nil, errors.New("odd-length hex string")
|
||||
}
|
||||
out := make([]byte, n/2)
|
||||
for i := 0; i < n; i += 2 {
|
||||
hi, err := hexNibble(s[i])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lo, err := hexNibble(s[i+1])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[i/2] = hi<<4 | lo
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func hexNibble(b byte) (byte, error) {
|
||||
switch {
|
||||
case b >= '0' && b <= '9':
|
||||
return b - '0', nil
|
||||
case b >= 'a' && b <= 'f':
|
||||
return b - 'a' + 10, nil
|
||||
case b >= 'A' && b <= 'F':
|
||||
return b - 'A' + 10, nil
|
||||
default:
|
||||
return 0, errors.New("invalid hex character")
|
||||
}
|
||||
}
|
||||
|
||||
func writeDevErr(w http.ResponseWriter, status int, code, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"error": map[string]string{"code": code, "message": msg},
|
||||
})
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ package service
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
|
@ -203,6 +204,13 @@ func (o *Orchestrator) genID(prefix string) (string, error) { return o.NewID(pre
|
|||
// buildStatement renders the in-toto v1 Statement for an approval. The
|
||||
// subject.digest IS the H_content of the preview; verifiers rehash the
|
||||
// stored canonical content to prove the signature covers it.
|
||||
//
|
||||
// A small set of chain-control fields on the rendered content —
|
||||
// allowed_transformations, allowed_sinks, expires_at, workflow_id — are
|
||||
// promoted onto the predicate so the verification-service can enforce
|
||||
// them without parsing the renderable content payload itself. All of
|
||||
// these are already part of the approving human's view (committed to by
|
||||
// H_content), so promoting them does not weaken the signature guarantee.
|
||||
func buildStatement(pv store.Preview, userSub, issuer string, performedAt time.Time) signer.Statement {
|
||||
// Strip the "sha256:" prefix for the digest map, per in-toto convention.
|
||||
digest := pv.ContentHash
|
||||
|
|
@ -222,6 +230,16 @@ func buildStatement(pv store.Preview, userSub, issuer string, performedAt time.T
|
|||
"performed_at": performedAt.UTC().Format(time.RFC3339),
|
||||
"verification_digits": pv.VerificationDigits,
|
||||
}
|
||||
|
||||
var rendered map[string]any
|
||||
if err := json.Unmarshal(pv.ContentCanonical, &rendered); err == nil {
|
||||
for _, k := range []string{"allowed_transformations", "allowed_sinks", "expires_at", "workflow_id"} {
|
||||
if v, ok := rendered[k]; ok {
|
||||
predicate[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return signer.Statement{
|
||||
"_type": "https://in-toto.io/Statement/v1",
|
||||
"subject": []any{
|
||||
|
|
|
|||
|
|
@ -33,10 +33,13 @@ to the Phase-1 dummy-sink. The exit code is 0 on success.
|
|||
`services/signing-service/internal/httpapi/sign.go`.
|
||||
5. **Log.** `POST {TRANSFORM_TLOG_URL}/api/v1/log/entries` with the
|
||||
envelope + `content_hash` from step 4.
|
||||
6. **Handover.** `POST {TRANSFORM_SINK_URL}/api/v1/handover` with the
|
||||
canonical `handover_payload` bytes. Headers set:
|
||||
- `X-Payload-Schema: sink.dummy.v1`
|
||||
- `X-Attestation-Ref: <transform content_hash>`
|
||||
6. **Handover.** `POST {TRANSFORM_SINK_URL}/api/v1/sink/dummy/deliver`
|
||||
with the DEV-611 wrapper JSON `{decision_id, payload_hash, payload,
|
||||
approval_ref, transform_ref}`. The `payload` field is the canonical
|
||||
handover_payload the agent already committed to in the transform
|
||||
attestation's subject digest; the sink re-hashes and refuses the
|
||||
delivery unless it matches `payload_hash`. The `decision_id` is read
|
||||
from the top-level `decision_id` field of the handover payload.
|
||||
|
||||
## Non-scope
|
||||
|
||||
|
|
@ -65,7 +68,9 @@ to the Phase-1 dummy-sink. The exit code is 0 on success.
|
|||
| `TRANSFORM_INPUT_SCHEMA` / `_VERSION` | `expense.canonical` / `1.2.0` | Input schema name/version carried in the attestation. |
|
||||
| `TRANSFORM_OUTPUT_SCHEMA` / `_VERSION` | `sink.db.expense` / `1.2.0` | Output schema name/version carried in the attestation. |
|
||||
| `TRANSFORM_VERSION` | `0.1.0` | Agent's own version string, carried in the attestation. |
|
||||
| `TRANSFORM_SINK_PAYLOAD_SCHEMA` | `sink.dummy.v1` | Value sent on the sink's `X-Payload-Schema` header. |
|
||||
| `TRANSFORM_SINK_PAYLOAD_SCHEMA` | `sink.dummy.v1` | Informational schema identifier recorded in the handover attestation. |
|
||||
| `TRANSFORM_DEV_POCKET_ID_TOKEN` | *(unset)* | DEV/CI ONLY: static Pocket-ID bearer, bypasses the OIDC client-credentials grant. |
|
||||
| `TRANSFORM_DEV_WORKLOAD_TOKEN` | *(unset)* | DEV/CI ONLY: static projected ServiceAccount token value. |
|
||||
|
||||
## CLI
|
||||
|
||||
|
|
|
|||
|
|
@ -120,11 +120,18 @@ func loadConfig(keysEnv string) (runtimeConfig, error) {
|
|||
return cfg, errors.New("TRANSFORM_TLOG_URL, TRANSFORM_SIGNING_URL, TRANSFORM_SINK_URL are required")
|
||||
}
|
||||
|
||||
// DEV/CI short-circuits: if a static Pocket-ID token is provided, we
|
||||
// skip the OIDC client-credentials dance entirely. Same for the
|
||||
// projected SA token. Both are mutually exclusive with the real
|
||||
// sources — set the static one or the trio of OIDC env vars.
|
||||
if devPocketID := envRequired("TRANSFORM_DEV_POCKET_ID_TOKEN"); devPocketID != "" {
|
||||
cfg.pocketIDSource = auth.StaticToken(devPocketID)
|
||||
} else {
|
||||
issuer := envRequired("POCKET_ID_ISSUER")
|
||||
clientID := envRequired("POCKET_ID_CLIENT_ID")
|
||||
clientSecret := envRequired("POCKET_ID_CLIENT_SECRET")
|
||||
if issuer == "" || clientID == "" || clientSecret == "" {
|
||||
return cfg, errors.New("POCKET_ID_ISSUER, POCKET_ID_CLIENT_ID, POCKET_ID_CLIENT_SECRET are required")
|
||||
return cfg, errors.New("POCKET_ID_ISSUER, POCKET_ID_CLIENT_ID, POCKET_ID_CLIENT_SECRET are required (or set TRANSFORM_DEV_POCKET_ID_TOKEN for dev/CI)")
|
||||
}
|
||||
cfg.pocketIDSource = &auth.PocketIDTokenSource{
|
||||
Issuer: issuer,
|
||||
|
|
@ -132,9 +139,14 @@ func loadConfig(keysEnv string) (runtimeConfig, error) {
|
|||
ClientSecret: clientSecret,
|
||||
Audience: envDefault("POCKET_ID_AUDIENCE", "trust-api"),
|
||||
}
|
||||
}
|
||||
|
||||
if devWorkload := envRequired("TRANSFORM_DEV_WORKLOAD_TOKEN"); devWorkload != "" {
|
||||
cfg.workloadSource = auth.StaticToken(devWorkload)
|
||||
} else {
|
||||
saPath := envDefault("TRANSFORM_WORKLOAD_TOKEN_PATH", "/var/run/secrets/tokens/trust-api")
|
||||
cfg.workloadSource = &auth.ProjectedSATokenSource{Path: saPath}
|
||||
}
|
||||
|
||||
kr, err := loadKeyring(keysEnv)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -1,14 +1,17 @@
|
|||
// Package sink posts a handover_payload to the (phase-1) dummy sink.
|
||||
// Package sink posts a handover envelope to the Phase-1 dummy sink.
|
||||
//
|
||||
// The payload is sent as the canonical JSON bytes the agent already hashed
|
||||
// into the transform attestation's subject digest — a byte-for-byte match
|
||||
// is a hard requirement for a downstream verifier, so we send those bytes
|
||||
// directly rather than re-marshaling.
|
||||
// The dummy-sink's contract (DEV-611) is `POST /api/v1/sink/dummy/deliver`
|
||||
// with a JSON wrapper `{decision_id, payload_hash, payload, approval_ref,
|
||||
// transform_ref}`. The sink re-hashes payload server-side and MUST see
|
||||
// `payload_hash` match either the raw bytes or the canonicalised form,
|
||||
// so we hand it the exact canonical bytes the transform attestation
|
||||
// already committed to.
|
||||
package sink
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
|
@ -19,15 +22,6 @@ import (
|
|||
"basicstack.de/proofedmesh/services/testagent-transform/internal/auth"
|
||||
)
|
||||
|
||||
// SchemaHeader carries the payload's schema identifier so the sink knows
|
||||
// which decoder to pick. Phase-1 sinks are told to expect "sink.dummy.v1".
|
||||
const SchemaHeader = "X-Payload-Schema"
|
||||
|
||||
// AttestationHeader carries the transform attestation's content_hash so
|
||||
// the sink can resolve the full attestation chain in the TLog without
|
||||
// re-parsing the signed envelope.
|
||||
const AttestationHeader = "X-Attestation-Ref"
|
||||
|
||||
// ErrSinkUnavailable classifies connection errors and 5xx responses.
|
||||
var ErrSinkUnavailable = errors.New("sink: dummy-sink unavailable")
|
||||
|
||||
|
|
@ -38,53 +32,100 @@ type Client struct {
|
|||
TokenSource auth.TokenSource
|
||||
}
|
||||
|
||||
// DeliverRequest carries everything the dummy-sink needs on its /deliver
|
||||
// endpoint. The runner builds this from the (approval_ref, transform_ref)
|
||||
// chain plus the canonical handover bytes.
|
||||
type DeliverRequest struct {
|
||||
DecisionID string
|
||||
PayloadBytes []byte
|
||||
PayloadHash string
|
||||
ApprovalRef string
|
||||
TransformRef string
|
||||
Schema string
|
||||
}
|
||||
|
||||
// DeliverResult is what the agent reports upstream after a sink POST.
|
||||
type DeliverResult struct {
|
||||
Status int
|
||||
HandoverRef string
|
||||
LogIndex int64
|
||||
}
|
||||
|
||||
// Deliver POSTs payloadBytes to the sink. The caller passes the schema
|
||||
// identifier (e.g. "sink.dummy.v1") and the transform attestation's
|
||||
// content_hash so both go on the request headers.
|
||||
func (c Client) Deliver(ctx context.Context, schema, attestationRef string, payloadBytes []byte) (DeliverResult, error) {
|
||||
type deliverWire struct {
|
||||
DecisionID string `json:"decision_id"`
|
||||
PayloadHash string `json:"payload_hash"`
|
||||
Payload json.RawMessage `json:"payload"`
|
||||
ApprovalRef string `json:"approval_ref"`
|
||||
TransformRef string `json:"transform_ref"`
|
||||
}
|
||||
|
||||
type deliverResponseWire struct {
|
||||
HandoverRef string `json:"handover_ref"`
|
||||
LogIndex int64 `json:"log_index"`
|
||||
DecisionID string `json:"decision_id"`
|
||||
}
|
||||
|
||||
// Deliver POSTs the handover wrapper to the dummy-sink. The caller
|
||||
// passes the schema identifier (currently informational — the sink does
|
||||
// not read it) plus the canonical payload bytes committed to by the
|
||||
// transform attestation. The returned HandoverRef is the sink's own
|
||||
// handover attestation content_hash, surfaced so a caller can log it
|
||||
// alongside the transform ref for later chain inspection.
|
||||
func (c Client) Deliver(ctx context.Context, req DeliverRequest) (DeliverResult, error) {
|
||||
if c.BaseURL == "" {
|
||||
return DeliverResult{}, errors.New("sink.Client: BaseURL is empty")
|
||||
}
|
||||
if schema == "" {
|
||||
return DeliverResult{}, errors.New("sink.Client: schema is required")
|
||||
if req.DecisionID == "" {
|
||||
return DeliverResult{}, errors.New("sink.Client: DecisionID is required")
|
||||
}
|
||||
if attestationRef == "" {
|
||||
return DeliverResult{}, errors.New("sink.Client: attestationRef is required")
|
||||
if len(req.PayloadBytes) == 0 {
|
||||
return DeliverResult{}, errors.New("sink.Client: PayloadBytes is empty")
|
||||
}
|
||||
if len(payloadBytes) == 0 {
|
||||
return DeliverResult{}, errors.New("sink.Client: payloadBytes is empty")
|
||||
if req.PayloadHash == "" {
|
||||
return DeliverResult{}, errors.New("sink.Client: PayloadHash is required")
|
||||
}
|
||||
if req.ApprovalRef == "" {
|
||||
return DeliverResult{}, errors.New("sink.Client: ApprovalRef is required")
|
||||
}
|
||||
if req.TransformRef == "" {
|
||||
return DeliverResult{}, errors.New("sink.Client: TransformRef is required")
|
||||
}
|
||||
|
||||
hc := c.HTTPClient
|
||||
if hc == nil {
|
||||
hc = &http.Client{Timeout: 10 * time.Second}
|
||||
}
|
||||
endpoint, err := url.JoinPath(c.BaseURL, "/api/v1/handover")
|
||||
endpoint, err := url.JoinPath(c.BaseURL, "/api/v1/sink/dummy/deliver")
|
||||
if err != nil {
|
||||
return DeliverResult{}, fmt.Errorf("sink: build url: %w", err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payloadBytes))
|
||||
wire := deliverWire{
|
||||
DecisionID: req.DecisionID,
|
||||
PayloadHash: req.PayloadHash,
|
||||
Payload: json.RawMessage(req.PayloadBytes),
|
||||
ApprovalRef: req.ApprovalRef,
|
||||
TransformRef: req.TransformRef,
|
||||
}
|
||||
body, err := json.Marshal(wire)
|
||||
if err != nil {
|
||||
return DeliverResult{}, fmt.Errorf("sink: marshal body: %w", err)
|
||||
}
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return DeliverResult{}, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set(SchemaHeader, schema)
|
||||
req.Header.Set(AttestationHeader, attestationRef)
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Accept", "application/json")
|
||||
if c.TokenSource != nil {
|
||||
tok, err := c.TokenSource.Token(ctx)
|
||||
if err != nil {
|
||||
return DeliverResult{}, fmt.Errorf("sink: token: %w", err)
|
||||
}
|
||||
if tok != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+tok)
|
||||
httpReq.Header.Set("Authorization", "Bearer "+tok)
|
||||
}
|
||||
}
|
||||
resp, err := hc.Do(req)
|
||||
resp, err := hc.Do(httpReq)
|
||||
if err != nil {
|
||||
return DeliverResult{}, fmt.Errorf("%w: %v", ErrSinkUnavailable, err)
|
||||
}
|
||||
|
|
@ -92,7 +133,15 @@ func (c Client) Deliver(ctx context.Context, schema, attestationRef string, payl
|
|||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
switch {
|
||||
case resp.StatusCode >= 200 && resp.StatusCode < 300:
|
||||
return DeliverResult{Status: resp.StatusCode}, nil
|
||||
var out deliverResponseWire
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return DeliverResult{}, fmt.Errorf("sink: decode response: %w (body=%q)", err, truncate(raw, 256))
|
||||
}
|
||||
return DeliverResult{
|
||||
Status: resp.StatusCode,
|
||||
HandoverRef: out.HandoverRef,
|
||||
LogIndex: out.LogIndex,
|
||||
}, nil
|
||||
case resp.StatusCode >= 500:
|
||||
return DeliverResult{}, fmt.Errorf("%w: status %d: %s", ErrSinkUnavailable, resp.StatusCode, truncate(raw, 256))
|
||||
default:
|
||||
|
|
|
|||
|
|
@ -60,8 +60,9 @@ type Config struct {
|
|||
// TransformVersion is the agent's own version string, carried into
|
||||
// the attestation so bugfixes are auditable.
|
||||
TransformVersion string
|
||||
// SinkPayloadSchema is the schema identifier the sink expects on the
|
||||
// `X-Payload-Schema` header. Phase-1: "sink.dummy.v1".
|
||||
// SinkPayloadSchema is the schema identifier recorded in the
|
||||
// handover attestation and forwarded on the sink.DeliverRequest.
|
||||
// Phase-1: "sink.dummy.v1".
|
||||
SinkPayloadSchema string
|
||||
}
|
||||
|
||||
|
|
@ -81,9 +82,9 @@ type TLogSubmitter interface {
|
|||
Submit(ctx context.Context, env dsse.Envelope, contentHash string) (tlog.Receipt, error)
|
||||
}
|
||||
|
||||
// SinkDeliverer posts the handover_payload bytes to the sink.
|
||||
// SinkDeliverer posts the handover wrapper to the sink.
|
||||
type SinkDeliverer interface {
|
||||
Deliver(ctx context.Context, schema, attestationRef string, payloadBytes []byte) (sink.DeliverResult, error)
|
||||
Deliver(ctx context.Context, req sink.DeliverRequest) (sink.DeliverResult, error)
|
||||
}
|
||||
|
||||
// Request is a single transform invocation. HandoverPayload is JSON-shaped
|
||||
|
|
@ -100,6 +101,7 @@ type Result struct {
|
|||
TransformRef string
|
||||
LogIndex int64
|
||||
SinkStatus int
|
||||
SinkHandoverRef string
|
||||
HandoverHashHex string
|
||||
}
|
||||
|
||||
|
|
@ -153,7 +155,18 @@ func (r *Runner) Execute(ctx context.Context, req Request) (Result, error) {
|
|||
return Result{}, fmt.Errorf("tlog submit transform: %w", err)
|
||||
}
|
||||
|
||||
deliv, err := r.Sink.Deliver(ctx, r.SinkPayloadSchema, signed.ContentHash, payloadBytes)
|
||||
decisionID, err := extractDecisionID(req.HandoverPayload)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
deliv, err := r.Sink.Deliver(ctx, sink.DeliverRequest{
|
||||
DecisionID: decisionID,
|
||||
PayloadBytes: payloadBytes,
|
||||
PayloadHash: "sha256:" + payloadHashHex,
|
||||
ApprovalRef: req.ApprovalRef,
|
||||
TransformRef: signed.ContentHash,
|
||||
Schema: r.SinkPayloadSchema,
|
||||
})
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("sink deliver: %w", err)
|
||||
}
|
||||
|
|
@ -162,10 +175,27 @@ func (r *Runner) Execute(ctx context.Context, req Request) (Result, error) {
|
|||
TransformRef: signed.ContentHash,
|
||||
LogIndex: receipt.LogIndex,
|
||||
SinkStatus: deliv.Status,
|
||||
SinkHandoverRef: deliv.HandoverRef,
|
||||
HandoverHashHex: payloadHashHex,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// extractDecisionID pulls the decision_id string from a handover payload.
|
||||
// Phase-1 handover payloads carry it at the top level; the sink requires
|
||||
// it to key its (idempotent) write and the verification-service's replay
|
||||
// guard.
|
||||
func extractDecisionID(payload map[string]any) (string, error) {
|
||||
v, ok := payload["decision_id"]
|
||||
if !ok {
|
||||
return "", errors.New("transform: handover_payload is missing top-level decision_id")
|
||||
}
|
||||
s, ok := v.(string)
|
||||
if !ok || s == "" {
|
||||
return "", errors.New("transform: handover_payload decision_id must be a non-empty string")
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (r *Runner) validate(req Request) error {
|
||||
if r.Approvals == nil || r.ApprovalKeyring == nil || r.Signer == nil || r.TLog == nil || r.Sink == nil {
|
||||
return errors.New("transform.Runner: missing dependency wiring")
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import (
|
|||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
|
|
@ -38,8 +37,10 @@ type mocks struct {
|
|||
transformPriv ed25519.PrivateKey
|
||||
|
||||
sinkPayload []byte
|
||||
sinkSchema string
|
||||
sinkAttestRef string
|
||||
sinkDecisionID string
|
||||
sinkPayloadHash string
|
||||
sinkApprovalRef string
|
||||
sinkTransformRef string
|
||||
sinkCalled bool
|
||||
|
||||
tlogSubmitted []string // sha256:… content_hashes seen on POST /log/entries
|
||||
|
|
@ -70,7 +71,7 @@ const (
|
|||
|
||||
func buildMocks(t *testing.T, opts mockOpts) *mocks {
|
||||
t.Helper()
|
||||
m := &mocks{sinkSchema: "sink.dummy.v1"}
|
||||
m := &mocks{}
|
||||
|
||||
// Keys.
|
||||
approvalPub, approvalPriv, err := ed25519.GenerateKey(rand.Reader)
|
||||
|
|
@ -209,18 +210,38 @@ func buildMocks(t *testing.T, opts mockOpts) *mocks {
|
|||
_ = json.NewEncoder(w).Encode(rcpt)
|
||||
}))
|
||||
|
||||
// sinkSrv: POST /api/v1/handover; captures body + headers so the
|
||||
// test can assert the agent sent exactly the canonical bytes.
|
||||
// sinkSrv: POST /api/v1/sink/dummy/deliver with the DEV-611 wrapper
|
||||
// shape; captures each field so the test can assert that the agent
|
||||
// forwarded the canonical bytes verbatim and all four refs line up.
|
||||
m.sinkSrv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/v1/handover" || r.Method != http.MethodPost {
|
||||
if r.URL.Path != "/api/v1/sink/dummy/deliver" || r.Method != http.MethodPost {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
m.sinkCalled = true
|
||||
m.sinkSchema = r.Header.Get(sink.SchemaHeader)
|
||||
m.sinkAttestRef = r.Header.Get(sink.AttestationHeader)
|
||||
m.sinkPayload, _ = io.ReadAll(r.Body)
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
var body struct {
|
||||
DecisionID string `json:"decision_id"`
|
||||
PayloadHash string `json:"payload_hash"`
|
||||
Payload json.RawMessage `json:"payload"`
|
||||
ApprovalRef string `json:"approval_ref"`
|
||||
TransformRef string `json:"transform_ref"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, "decode: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
m.sinkDecisionID = body.DecisionID
|
||||
m.sinkPayloadHash = body.PayloadHash
|
||||
m.sinkPayload = append(m.sinkPayload[:0], body.Payload...)
|
||||
m.sinkApprovalRef = body.ApprovalRef
|
||||
m.sinkTransformRef = body.TransformRef
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"handover_ref": "sha256:" + strings.Repeat("d", 64),
|
||||
"log_index": int64(99),
|
||||
"decision_id": body.DecisionID,
|
||||
})
|
||||
}))
|
||||
|
||||
t.Cleanup(func() {
|
||||
|
|
@ -269,6 +290,7 @@ func sampleRequest() Request {
|
|||
WorkflowID: "WF-TEST-1",
|
||||
ApprovalRef: attestationRef,
|
||||
HandoverPayload: map[string]any{
|
||||
"decision_id": "dec-WF-TEST-1",
|
||||
"schema": map[string]any{"name": "sink.dummy.v1", "version": "1.0.0"},
|
||||
"items": []any{
|
||||
map[string]any{"line": "1", "amount": "12.34"},
|
||||
|
|
@ -304,11 +326,20 @@ func TestExecute_HappyPath(t *testing.T) {
|
|||
if !bytes.Equal(m.sinkPayload, wantBytes) {
|
||||
t.Fatalf("sink payload bytes differ:\n got %q\nwant %q", m.sinkPayload, wantBytes)
|
||||
}
|
||||
if m.sinkSchema != "sink.dummy.v1" {
|
||||
t.Fatalf("sink schema header = %q", m.sinkSchema)
|
||||
if m.sinkDecisionID != "dec-WF-TEST-1" {
|
||||
t.Fatalf("sink decision_id = %q, want %q", m.sinkDecisionID, "dec-WF-TEST-1")
|
||||
}
|
||||
if m.sinkAttestRef != res.TransformRef {
|
||||
t.Fatalf("sink attestation header = %q, want %q", m.sinkAttestRef, res.TransformRef)
|
||||
if m.sinkPayloadHash != "sha256:"+wantHash {
|
||||
t.Fatalf("sink payload_hash = %q, want %q", m.sinkPayloadHash, "sha256:"+wantHash)
|
||||
}
|
||||
if m.sinkApprovalRef != req.ApprovalRef {
|
||||
t.Fatalf("sink approval_ref = %q, want %q", m.sinkApprovalRef, req.ApprovalRef)
|
||||
}
|
||||
if m.sinkTransformRef != res.TransformRef {
|
||||
t.Fatalf("sink transform_ref = %q, want %q", m.sinkTransformRef, res.TransformRef)
|
||||
}
|
||||
if res.SinkHandoverRef == "" {
|
||||
t.Fatalf("SinkHandoverRef unset — expected sink's handover_ref")
|
||||
}
|
||||
|
||||
// TLog must have seen exactly our transform content_hash.
|
||||
|
|
@ -318,8 +349,8 @@ func TestExecute_HappyPath(t *testing.T) {
|
|||
if res.LogIndex == 0 {
|
||||
t.Fatalf("LogIndex not set")
|
||||
}
|
||||
if res.SinkStatus != http.StatusAccepted {
|
||||
t.Fatalf("SinkStatus = %d, want 202", res.SinkStatus)
|
||||
if res.SinkStatus != http.StatusCreated {
|
||||
t.Fatalf("SinkStatus = %d, want 201", res.SinkStatus)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ type Memory struct {
|
|||
leaves [][]byte // ordered list of leaf hashes
|
||||
indexByHash map[string]int64 // contentHash -> log_index (zero-based)
|
||||
loggedAtByIndex map[int64]time.Time // original append time per index
|
||||
envelopesByIndex map[int64]Envelope // original envelope per index (for Get)
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
|
|
@ -30,6 +31,7 @@ func NewMemory() *Memory {
|
|||
return &Memory{
|
||||
indexByHash: map[string]int64{},
|
||||
loggedAtByIndex: map[int64]time.Time{},
|
||||
envelopesByIndex: map[int64]Envelope{},
|
||||
now: func() time.Time { return time.Now().UTC() },
|
||||
}
|
||||
}
|
||||
|
|
@ -73,6 +75,7 @@ func (m *Memory) Submit(_ context.Context, env Envelope, contentHash string) (Re
|
|||
m.leaves = append(m.leaves, leafHash(canonical))
|
||||
m.indexByHash[contentHash] = idx
|
||||
m.loggedAtByIndex[idx] = m.now()
|
||||
m.envelopesByIndex[idx] = env
|
||||
fresh = true
|
||||
}
|
||||
|
||||
|
|
@ -99,6 +102,40 @@ func (m *Memory) Submit(_ context.Context, env Envelope, contentHash string) (Re
|
|||
}, fresh, nil
|
||||
}
|
||||
|
||||
// Get returns the stored envelope + a fresh inclusion proof against the
|
||||
// current tree. Suitable for the ADR-0005 read endpoint.
|
||||
func (m *Memory) Get(_ context.Context, contentHash string) (Entry, error) {
|
||||
if contentHash == "" {
|
||||
return Entry{}, errors.New("transparency-log: empty content_hash")
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
idx, ok := m.indexByHash[contentHash]
|
||||
if !ok {
|
||||
return Entry{}, ErrEntryNotFound
|
||||
}
|
||||
env := m.envelopesByIndex[idx]
|
||||
root := merkleTreeHash(m.leaves)
|
||||
proof := auditPath(m.leaves, int(idx))
|
||||
hashes := make([]string, 0, len(proof))
|
||||
for _, h := range proof {
|
||||
hashes = append(hashes, sha256Hex(h))
|
||||
}
|
||||
return Entry{
|
||||
ContentHash: contentHash,
|
||||
Envelope: env,
|
||||
LogIndex: idx,
|
||||
LoggedAt: m.loggedAtByIndex[idx],
|
||||
InclusionProof: InclusionProof{
|
||||
TreeSize: int64(len(m.leaves)),
|
||||
TreeRootHash: sha256Hex(root),
|
||||
SignedTreeHeadRef: signedTreeHeadRef(int64(len(m.leaves)), root),
|
||||
Hashes: hashes,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// signedTreeHeadRef is a stand-in for the real STH reference. In the
|
||||
// in-memory backend we deterministically derive it from (tree_size, root),
|
||||
// so callers get a stable per-head identifier without needing a signer.
|
||||
|
|
|
|||
|
|
@ -58,6 +58,13 @@ type Trillian struct {
|
|||
dialErr error
|
||||
}
|
||||
|
||||
// Get is not yet wired against Trillian; the production read path is a
|
||||
// future subtask. The handler short-circuits to 503 when the client is
|
||||
// absent, so this stub only has to satisfy the interface.
|
||||
func (t *Trillian) Get(_ context.Context, _ string) (Entry, error) {
|
||||
return Entry{}, ErrTrillianUnavailable
|
||||
}
|
||||
|
||||
// Submit queues the envelope and returns the inclusion proof against the
|
||||
// current signed tree head. See the Backend interface for the fresh
|
||||
// semantics.
|
||||
|
|
|
|||
|
|
@ -47,6 +47,27 @@ type InclusionProof struct {
|
|||
// concurrent use and MUST be idempotent on contentHash: a repeated submit
|
||||
// with the same contentHash returns the originally assigned log_index and
|
||||
// a valid inclusion proof against the current tree size, and sets fresh=false.
|
||||
//
|
||||
// Get returns the stored envelope + a fresh inclusion proof against the
|
||||
// current tree size. Verifiers rely on this to independently recompute the
|
||||
// Merkle tree root (ADR-0005 §Read side).
|
||||
type Backend interface {
|
||||
Submit(ctx context.Context, env Envelope, contentHash string) (receipt Receipt, fresh bool, err error)
|
||||
Get(ctx context.Context, contentHash string) (Entry, error)
|
||||
}
|
||||
|
||||
// Entry is the shape returned by GET /api/v1/log/entries/{content_hash}.
|
||||
type Entry struct {
|
||||
ContentHash string `json:"content_hash"`
|
||||
Envelope Envelope `json:"envelope"`
|
||||
LogIndex int64 `json:"log_index"`
|
||||
LoggedAt time.Time `json:"logged_at"`
|
||||
InclusionProof InclusionProof `json:"inclusion_proof"`
|
||||
}
|
||||
|
||||
// ErrEntryNotFound is returned by Get when contentHash is not in the log.
|
||||
var ErrEntryNotFound = errorString("transparency-log: entry not found")
|
||||
|
||||
type errorString string
|
||||
|
||||
func (e errorString) Error() string { return string(e) }
|
||||
|
|
|
|||
|
|
@ -22,14 +22,44 @@ type Server struct {
|
|||
LogError func(string, ...any)
|
||||
}
|
||||
|
||||
// Handler returns an http.Handler that serves the ADR-0005 routes. Only
|
||||
// POST /api/v1/log/entries is implemented; everything else is 404.
|
||||
// Handler returns an http.Handler that serves the ADR-0005 routes plus a
|
||||
// liveness check at GET /healthz. Everything else is 404.
|
||||
func (s *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("POST /api/v1/log/entries", http.HandlerFunc(s.handleSubmit))
|
||||
mux.Handle("GET /api/v1/log/entries/{hash}", http.HandlerFunc(s.handleGetEntry))
|
||||
mux.Handle("GET /healthz", http.HandlerFunc(s.handleHealth))
|
||||
return mux
|
||||
}
|
||||
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"status":"ok"}`))
|
||||
}
|
||||
|
||||
func (s *Server) handleGetEntry(w http.ResponseWriter, r *http.Request) {
|
||||
hash := r.PathValue("hash")
|
||||
if hash == "" {
|
||||
writeError(w, http.StatusBadRequest, "content_hash is required")
|
||||
return
|
||||
}
|
||||
entry, err := s.Backend.Get(r.Context(), hash)
|
||||
switch {
|
||||
case errors.Is(err, backend.ErrEntryNotFound):
|
||||
writeError(w, http.StatusNotFound, "entry not found")
|
||||
return
|
||||
case errors.Is(err, backend.ErrTrillianUnavailable):
|
||||
writeError(w, http.StatusServiceUnavailable, err.Error())
|
||||
return
|
||||
case err != nil:
|
||||
s.logErr("transparency-log get: %v", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, entry)
|
||||
}
|
||||
|
||||
type submitRequest struct {
|
||||
Envelope backend.Envelope `json:"envelope"`
|
||||
ContentHash string `json:"content_hash"`
|
||||
|
|
|
|||
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