Compare commits

..

No commits in common. "55834438a8fa32693a8269cd8ee41190e7cf0eacf60d47774757c169b62e09c1" and "0ae29ae342ad6aa97d0f8115c0afe3cd8ab743e03ec19f5cbafb9c629490fa82" have entirely different histories.

17 changed files with 94 additions and 1095 deletions

View file

@ -1,49 +0,0 @@
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

View file

@ -1,15 +0,0 @@
# 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)

View file

@ -1,395 +0,0 @@
#!/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)"

View file

@ -34,8 +34,6 @@ func main() {
WebauthnRPID string WebauthnRPID string
WebauthnOrigins string WebauthnOrigins string
AttestationIss string AttestationIss string
DevAuthSub string
DevRoutes bool
}{} }{}
flag.StringVar(&cfg.Listen, "listen", envOr("APPROVAL_LISTEN", ":8080"), "HTTP listen address") 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") flag.StringVar(&cfg.SigningURL, "signing-url", os.Getenv("APPROVAL_SIGNING_URL"), "signing-service base URL")
@ -47,8 +45,6 @@ func main() {
flag.StringVar(&cfg.WebauthnRPID, "webauthn-rp-id", envOr("APPROVAL_WEBAUTHN_RP_ID", "trust.basicstack.de"), "WebAuthn relying party ID") 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.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.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() flag.Parse()
logger := slog.New(slog.NewJSONHandler(os.Stderr, nil)) logger := slog.New(slog.NewJSONHandler(os.Stderr, nil))
@ -89,21 +85,14 @@ func main() {
AttestationIssuer: cfg.AttestationIss, AttestationIssuer: cfg.AttestationIss,
} }
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{ server := &httpapi.Server{
Auth: authResolver, // 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) {
return auth.Principal{}, errors.New("approval-service: no auth middleware mounted; wire Pocket-ID before serving traffic")
}),
Sessions: mgr, Sessions: mgr,
Orchestrator: orch, Orchestrator: orch,
Logger: logger, Logger: logger,
@ -111,10 +100,6 @@ func main() {
mux := http.NewServeMux() mux := http.NewServeMux()
server.Mount(mux) 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", logger.Info("approval-service starting",
"listen", cfg.Listen, "listen", cfg.Listen,
@ -146,20 +131,6 @@ func envDur(key string, fallback time.Duration) time.Duration {
return d 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 { func splitAndTrim(s, sep string) []string {
parts := strings.Split(s, sep) parts := strings.Split(s, sep)
out := make([]string, 0, len(parts)) out := make([]string, 0, len(parts))

View file

@ -1,137 +0,0 @@
// 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},
})
}

View file

@ -6,7 +6,6 @@ package service
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"time" "time"
@ -204,13 +203,6 @@ func (o *Orchestrator) genID(prefix string) (string, error) { return o.NewID(pre
// buildStatement renders the in-toto v1 Statement for an approval. The // buildStatement renders the in-toto v1 Statement for an approval. The
// subject.digest IS the H_content of the preview; verifiers rehash the // subject.digest IS the H_content of the preview; verifiers rehash the
// stored canonical content to prove the signature covers it. // 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 { 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. // Strip the "sha256:" prefix for the digest map, per in-toto convention.
digest := pv.ContentHash digest := pv.ContentHash
@ -230,16 +222,6 @@ func buildStatement(pv store.Preview, userSub, issuer string, performedAt time.T
"performed_at": performedAt.UTC().Format(time.RFC3339), "performed_at": performedAt.UTC().Format(time.RFC3339),
"verification_digits": pv.VerificationDigits, "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{ return signer.Statement{
"_type": "https://in-toto.io/Statement/v1", "_type": "https://in-toto.io/Statement/v1",
"subject": []any{ "subject": []any{

View file

@ -33,13 +33,10 @@ to the Phase-1 dummy-sink. The exit code is 0 on success.
`services/signing-service/internal/httpapi/sign.go`. `services/signing-service/internal/httpapi/sign.go`.
5. **Log.** `POST {TRANSFORM_TLOG_URL}/api/v1/log/entries` with the 5. **Log.** `POST {TRANSFORM_TLOG_URL}/api/v1/log/entries` with the
envelope + `content_hash` from step 4. envelope + `content_hash` from step 4.
6. **Handover.** `POST {TRANSFORM_SINK_URL}/api/v1/sink/dummy/deliver` 6. **Handover.** `POST {TRANSFORM_SINK_URL}/api/v1/handover` with the
with the DEV-611 wrapper JSON `{decision_id, payload_hash, payload, canonical `handover_payload` bytes. Headers set:
approval_ref, transform_ref}`. The `payload` field is the canonical - `X-Payload-Schema: sink.dummy.v1`
handover_payload the agent already committed to in the transform - `X-Attestation-Ref: <transform content_hash>`
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 ## Non-scope
@ -68,9 +65,7 @@ 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_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_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_VERSION` | `0.1.0` | Agent's own version string, carried in the attestation. |
| `TRANSFORM_SINK_PAYLOAD_SCHEMA` | `sink.dummy.v1` | Informational schema identifier recorded in the handover attestation. | | `TRANSFORM_SINK_PAYLOAD_SCHEMA` | `sink.dummy.v1` | Value sent on the sink's `X-Payload-Schema` header. |
| `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 ## CLI

View file

@ -120,18 +120,11 @@ func loadConfig(keysEnv string) (runtimeConfig, error) {
return cfg, errors.New("TRANSFORM_TLOG_URL, TRANSFORM_SIGNING_URL, TRANSFORM_SINK_URL are required") 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") issuer := envRequired("POCKET_ID_ISSUER")
clientID := envRequired("POCKET_ID_CLIENT_ID") clientID := envRequired("POCKET_ID_CLIENT_ID")
clientSecret := envRequired("POCKET_ID_CLIENT_SECRET") clientSecret := envRequired("POCKET_ID_CLIENT_SECRET")
if issuer == "" || clientID == "" || clientSecret == "" { if issuer == "" || clientID == "" || clientSecret == "" {
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)") return cfg, errors.New("POCKET_ID_ISSUER, POCKET_ID_CLIENT_ID, POCKET_ID_CLIENT_SECRET are required")
} }
cfg.pocketIDSource = &auth.PocketIDTokenSource{ cfg.pocketIDSource = &auth.PocketIDTokenSource{
Issuer: issuer, Issuer: issuer,
@ -139,14 +132,9 @@ func loadConfig(keysEnv string) (runtimeConfig, error) {
ClientSecret: clientSecret, ClientSecret: clientSecret,
Audience: envDefault("POCKET_ID_AUDIENCE", "trust-api"), 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") saPath := envDefault("TRANSFORM_WORKLOAD_TOKEN_PATH", "/var/run/secrets/tokens/trust-api")
cfg.workloadSource = &auth.ProjectedSATokenSource{Path: saPath} cfg.workloadSource = &auth.ProjectedSATokenSource{Path: saPath}
}
kr, err := loadKeyring(keysEnv) kr, err := loadKeyring(keysEnv)
if err != nil { if err != nil {

View file

@ -1,17 +1,14 @@
// Package sink posts a handover envelope to the Phase-1 dummy sink. // Package sink posts a handover_payload to the (phase-1) dummy sink.
// //
// The dummy-sink's contract (DEV-611) is `POST /api/v1/sink/dummy/deliver` // The payload is sent as the canonical JSON bytes the agent already hashed
// with a JSON wrapper `{decision_id, payload_hash, payload, approval_ref, // into the transform attestation's subject digest — a byte-for-byte match
// transform_ref}`. The sink re-hashes payload server-side and MUST see // is a hard requirement for a downstream verifier, so we send those bytes
// `payload_hash` match either the raw bytes or the canonicalised form, // directly rather than re-marshaling.
// so we hand it the exact canonical bytes the transform attestation
// already committed to.
package sink package sink
import ( import (
"bytes" "bytes"
"context" "context"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"io" "io"
@ -22,6 +19,15 @@ import (
"basicstack.de/proofedmesh/services/testagent-transform/internal/auth" "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. // ErrSinkUnavailable classifies connection errors and 5xx responses.
var ErrSinkUnavailable = errors.New("sink: dummy-sink unavailable") var ErrSinkUnavailable = errors.New("sink: dummy-sink unavailable")
@ -32,100 +38,53 @@ type Client struct {
TokenSource auth.TokenSource 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. // DeliverResult is what the agent reports upstream after a sink POST.
type DeliverResult struct { type DeliverResult struct {
Status int Status int
HandoverRef string
LogIndex int64
} }
type deliverWire struct { // Deliver POSTs payloadBytes to the sink. The caller passes the schema
DecisionID string `json:"decision_id"` // identifier (e.g. "sink.dummy.v1") and the transform attestation's
PayloadHash string `json:"payload_hash"` // content_hash so both go on the request headers.
Payload json.RawMessage `json:"payload"` func (c Client) Deliver(ctx context.Context, schema, attestationRef string, payloadBytes []byte) (DeliverResult, error) {
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 == "" { if c.BaseURL == "" {
return DeliverResult{}, errors.New("sink.Client: BaseURL is empty") return DeliverResult{}, errors.New("sink.Client: BaseURL is empty")
} }
if req.DecisionID == "" { if schema == "" {
return DeliverResult{}, errors.New("sink.Client: DecisionID is required") return DeliverResult{}, errors.New("sink.Client: schema is required")
} }
if len(req.PayloadBytes) == 0 { if attestationRef == "" {
return DeliverResult{}, errors.New("sink.Client: PayloadBytes is empty") return DeliverResult{}, errors.New("sink.Client: attestationRef is required")
} }
if req.PayloadHash == "" { if len(payloadBytes) == 0 {
return DeliverResult{}, errors.New("sink.Client: PayloadHash is required") return DeliverResult{}, errors.New("sink.Client: payloadBytes is empty")
} }
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 hc := c.HTTPClient
if hc == nil { if hc == nil {
hc = &http.Client{Timeout: 10 * time.Second} hc = &http.Client{Timeout: 10 * time.Second}
} }
endpoint, err := url.JoinPath(c.BaseURL, "/api/v1/sink/dummy/deliver") endpoint, err := url.JoinPath(c.BaseURL, "/api/v1/handover")
if err != nil { if err != nil {
return DeliverResult{}, fmt.Errorf("sink: build url: %w", err) return DeliverResult{}, fmt.Errorf("sink: build url: %w", err)
} }
wire := deliverWire{ req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payloadBytes))
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 { if err != nil {
return DeliverResult{}, err return DeliverResult{}, err
} }
httpReq.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
req.Header.Set(SchemaHeader, schema)
req.Header.Set(AttestationHeader, attestationRef)
if c.TokenSource != nil { if c.TokenSource != nil {
tok, err := c.TokenSource.Token(ctx) tok, err := c.TokenSource.Token(ctx)
if err != nil { if err != nil {
return DeliverResult{}, fmt.Errorf("sink: token: %w", err) return DeliverResult{}, fmt.Errorf("sink: token: %w", err)
} }
if tok != "" { if tok != "" {
httpReq.Header.Set("Authorization", "Bearer "+tok) req.Header.Set("Authorization", "Bearer "+tok)
} }
} }
resp, err := hc.Do(httpReq) resp, err := hc.Do(req)
if err != nil { if err != nil {
return DeliverResult{}, fmt.Errorf("%w: %v", ErrSinkUnavailable, err) return DeliverResult{}, fmt.Errorf("%w: %v", ErrSinkUnavailable, err)
} }
@ -133,15 +92,7 @@ func (c Client) Deliver(ctx context.Context, req DeliverRequest) (DeliverResult,
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
switch { switch {
case resp.StatusCode >= 200 && resp.StatusCode < 300: case resp.StatusCode >= 200 && resp.StatusCode < 300:
var out deliverResponseWire return DeliverResult{Status: resp.StatusCode}, nil
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: case resp.StatusCode >= 500:
return DeliverResult{}, fmt.Errorf("%w: status %d: %s", ErrSinkUnavailable, resp.StatusCode, truncate(raw, 256)) return DeliverResult{}, fmt.Errorf("%w: status %d: %s", ErrSinkUnavailable, resp.StatusCode, truncate(raw, 256))
default: default:

View file

@ -60,9 +60,8 @@ type Config struct {
// TransformVersion is the agent's own version string, carried into // TransformVersion is the agent's own version string, carried into
// the attestation so bugfixes are auditable. // the attestation so bugfixes are auditable.
TransformVersion string TransformVersion string
// SinkPayloadSchema is the schema identifier recorded in the // SinkPayloadSchema is the schema identifier the sink expects on the
// handover attestation and forwarded on the sink.DeliverRequest. // `X-Payload-Schema` header. Phase-1: "sink.dummy.v1".
// Phase-1: "sink.dummy.v1".
SinkPayloadSchema string SinkPayloadSchema string
} }
@ -82,9 +81,9 @@ type TLogSubmitter interface {
Submit(ctx context.Context, env dsse.Envelope, contentHash string) (tlog.Receipt, error) Submit(ctx context.Context, env dsse.Envelope, contentHash string) (tlog.Receipt, error)
} }
// SinkDeliverer posts the handover wrapper to the sink. // SinkDeliverer posts the handover_payload bytes to the sink.
type SinkDeliverer interface { type SinkDeliverer interface {
Deliver(ctx context.Context, req sink.DeliverRequest) (sink.DeliverResult, error) Deliver(ctx context.Context, schema, attestationRef string, payloadBytes []byte) (sink.DeliverResult, error)
} }
// Request is a single transform invocation. HandoverPayload is JSON-shaped // Request is a single transform invocation. HandoverPayload is JSON-shaped
@ -101,7 +100,6 @@ type Result struct {
TransformRef string TransformRef string
LogIndex int64 LogIndex int64
SinkStatus int SinkStatus int
SinkHandoverRef string
HandoverHashHex string HandoverHashHex string
} }
@ -155,18 +153,7 @@ func (r *Runner) Execute(ctx context.Context, req Request) (Result, error) {
return Result{}, fmt.Errorf("tlog submit transform: %w", err) return Result{}, fmt.Errorf("tlog submit transform: %w", err)
} }
decisionID, err := extractDecisionID(req.HandoverPayload) deliv, err := r.Sink.Deliver(ctx, r.SinkPayloadSchema, signed.ContentHash, payloadBytes)
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 { if err != nil {
return Result{}, fmt.Errorf("sink deliver: %w", err) return Result{}, fmt.Errorf("sink deliver: %w", err)
} }
@ -175,27 +162,10 @@ func (r *Runner) Execute(ctx context.Context, req Request) (Result, error) {
TransformRef: signed.ContentHash, TransformRef: signed.ContentHash,
LogIndex: receipt.LogIndex, LogIndex: receipt.LogIndex,
SinkStatus: deliv.Status, SinkStatus: deliv.Status,
SinkHandoverRef: deliv.HandoverRef,
HandoverHashHex: payloadHashHex, HandoverHashHex: payloadHashHex,
}, nil }, 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 { func (r *Runner) validate(req Request) error {
if r.Approvals == nil || r.ApprovalKeyring == nil || r.Signer == nil || r.TLog == nil || r.Sink == nil { if r.Approvals == nil || r.ApprovalKeyring == nil || r.Signer == nil || r.TLog == nil || r.Sink == nil {
return errors.New("transform.Runner: missing dependency wiring") return errors.New("transform.Runner: missing dependency wiring")

View file

@ -9,6 +9,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
@ -37,10 +38,8 @@ type mocks struct {
transformPriv ed25519.PrivateKey transformPriv ed25519.PrivateKey
sinkPayload []byte sinkPayload []byte
sinkDecisionID string sinkSchema string
sinkPayloadHash string sinkAttestRef string
sinkApprovalRef string
sinkTransformRef string
sinkCalled bool sinkCalled bool
tlogSubmitted []string // sha256:… content_hashes seen on POST /log/entries tlogSubmitted []string // sha256:… content_hashes seen on POST /log/entries
@ -71,7 +70,7 @@ const (
func buildMocks(t *testing.T, opts mockOpts) *mocks { func buildMocks(t *testing.T, opts mockOpts) *mocks {
t.Helper() t.Helper()
m := &mocks{} m := &mocks{sinkSchema: "sink.dummy.v1"}
// Keys. // Keys.
approvalPub, approvalPriv, err := ed25519.GenerateKey(rand.Reader) approvalPub, approvalPriv, err := ed25519.GenerateKey(rand.Reader)
@ -210,38 +209,18 @@ func buildMocks(t *testing.T, opts mockOpts) *mocks {
_ = json.NewEncoder(w).Encode(rcpt) _ = json.NewEncoder(w).Encode(rcpt)
})) }))
// sinkSrv: POST /api/v1/sink/dummy/deliver with the DEV-611 wrapper // sinkSrv: POST /api/v1/handover; captures body + headers so the
// shape; captures each field so the test can assert that the agent // test can assert the agent sent exactly the canonical bytes.
// forwarded the canonical bytes verbatim and all four refs line up.
m.sinkSrv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { m.sinkSrv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/sink/dummy/deliver" || r.Method != http.MethodPost { if r.URL.Path != "/api/v1/handover" || r.Method != http.MethodPost {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
m.sinkCalled = true m.sinkCalled = true
var body struct { m.sinkSchema = r.Header.Get(sink.SchemaHeader)
DecisionID string `json:"decision_id"` m.sinkAttestRef = r.Header.Get(sink.AttestationHeader)
PayloadHash string `json:"payload_hash"` m.sinkPayload, _ = io.ReadAll(r.Body)
Payload json.RawMessage `json:"payload"` w.WriteHeader(http.StatusAccepted)
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() { t.Cleanup(func() {
@ -290,7 +269,6 @@ func sampleRequest() Request {
WorkflowID: "WF-TEST-1", WorkflowID: "WF-TEST-1",
ApprovalRef: attestationRef, ApprovalRef: attestationRef,
HandoverPayload: map[string]any{ HandoverPayload: map[string]any{
"decision_id": "dec-WF-TEST-1",
"schema": map[string]any{"name": "sink.dummy.v1", "version": "1.0.0"}, "schema": map[string]any{"name": "sink.dummy.v1", "version": "1.0.0"},
"items": []any{ "items": []any{
map[string]any{"line": "1", "amount": "12.34"}, map[string]any{"line": "1", "amount": "12.34"},
@ -326,20 +304,11 @@ func TestExecute_HappyPath(t *testing.T) {
if !bytes.Equal(m.sinkPayload, wantBytes) { if !bytes.Equal(m.sinkPayload, wantBytes) {
t.Fatalf("sink payload bytes differ:\n got %q\nwant %q", m.sinkPayload, wantBytes) t.Fatalf("sink payload bytes differ:\n got %q\nwant %q", m.sinkPayload, wantBytes)
} }
if m.sinkDecisionID != "dec-WF-TEST-1" { if m.sinkSchema != "sink.dummy.v1" {
t.Fatalf("sink decision_id = %q, want %q", m.sinkDecisionID, "dec-WF-TEST-1") t.Fatalf("sink schema header = %q", m.sinkSchema)
} }
if m.sinkPayloadHash != "sha256:"+wantHash { if m.sinkAttestRef != res.TransformRef {
t.Fatalf("sink payload_hash = %q, want %q", m.sinkPayloadHash, "sha256:"+wantHash) t.Fatalf("sink attestation header = %q, want %q", m.sinkAttestRef, res.TransformRef)
}
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. // TLog must have seen exactly our transform content_hash.
@ -349,8 +318,8 @@ func TestExecute_HappyPath(t *testing.T) {
if res.LogIndex == 0 { if res.LogIndex == 0 {
t.Fatalf("LogIndex not set") t.Fatalf("LogIndex not set")
} }
if res.SinkStatus != http.StatusCreated { if res.SinkStatus != http.StatusAccepted {
t.Fatalf("SinkStatus = %d, want 201", res.SinkStatus) t.Fatalf("SinkStatus = %d, want 202", res.SinkStatus)
} }
} }

View file

@ -22,7 +22,6 @@ type Memory struct {
leaves [][]byte // ordered list of leaf hashes leaves [][]byte // ordered list of leaf hashes
indexByHash map[string]int64 // contentHash -> log_index (zero-based) indexByHash map[string]int64 // contentHash -> log_index (zero-based)
loggedAtByIndex map[int64]time.Time // original append time per index loggedAtByIndex map[int64]time.Time // original append time per index
envelopesByIndex map[int64]Envelope // original envelope per index (for Get)
now func() time.Time now func() time.Time
} }
@ -31,7 +30,6 @@ func NewMemory() *Memory {
return &Memory{ return &Memory{
indexByHash: map[string]int64{}, indexByHash: map[string]int64{},
loggedAtByIndex: map[int64]time.Time{}, loggedAtByIndex: map[int64]time.Time{},
envelopesByIndex: map[int64]Envelope{},
now: func() time.Time { return time.Now().UTC() }, now: func() time.Time { return time.Now().UTC() },
} }
} }
@ -75,7 +73,6 @@ func (m *Memory) Submit(_ context.Context, env Envelope, contentHash string) (Re
m.leaves = append(m.leaves, leafHash(canonical)) m.leaves = append(m.leaves, leafHash(canonical))
m.indexByHash[contentHash] = idx m.indexByHash[contentHash] = idx
m.loggedAtByIndex[idx] = m.now() m.loggedAtByIndex[idx] = m.now()
m.envelopesByIndex[idx] = env
fresh = true fresh = true
} }
@ -102,40 +99,6 @@ func (m *Memory) Submit(_ context.Context, env Envelope, contentHash string) (Re
}, fresh, nil }, 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 // signedTreeHeadRef is a stand-in for the real STH reference. In the
// in-memory backend we deterministically derive it from (tree_size, root), // in-memory backend we deterministically derive it from (tree_size, root),
// so callers get a stable per-head identifier without needing a signer. // so callers get a stable per-head identifier without needing a signer.

View file

@ -58,13 +58,6 @@ type Trillian struct {
dialErr error 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 // Submit queues the envelope and returns the inclusion proof against the
// current signed tree head. See the Backend interface for the fresh // current signed tree head. See the Backend interface for the fresh
// semantics. // semantics.

View file

@ -47,27 +47,6 @@ type InclusionProof struct {
// concurrent use and MUST be idempotent on contentHash: a repeated submit // concurrent use and MUST be idempotent on contentHash: a repeated submit
// with the same contentHash returns the originally assigned log_index and // with the same contentHash returns the originally assigned log_index and
// a valid inclusion proof against the current tree size, and sets fresh=false. // 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 { type Backend interface {
Submit(ctx context.Context, env Envelope, contentHash string) (receipt Receipt, fresh bool, err error) 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) }

View file

@ -22,44 +22,14 @@ type Server struct {
LogError func(string, ...any) LogError func(string, ...any)
} }
// Handler returns an http.Handler that serves the ADR-0005 routes plus a // Handler returns an http.Handler that serves the ADR-0005 routes. Only
// liveness check at GET /healthz. Everything else is 404. // POST /api/v1/log/entries is implemented; everything else is 404.
func (s *Server) Handler() http.Handler { func (s *Server) Handler() http.Handler {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.Handle("POST /api/v1/log/entries", http.HandlerFunc(s.handleSubmit)) 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 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 { type submitRequest struct {
Envelope backend.Envelope `json:"envelope"` Envelope backend.Envelope `json:"envelope"`
ContentHash string `json:"content_hash"` ContentHash string `json:"content_hash"`

View file

@ -1,3 +0,0 @@
module basicstack.de/proofedmesh/tools/webauthn-sign
go 1.23

View file

@ -1,133 +0,0 @@
// 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)
}