Compare commits
No commits in common. "55834438a8fa32693a8269cd8ee41190e7cf0eacf60d47774757c169b62e09c1" and "0ae29ae342ad6aa97d0f8115c0afe3cd8ab743e03ec19f5cbafb9c629490fa82" have entirely different histories.
55834438a8
...
0ae29ae342
17 changed files with 94 additions and 1095 deletions
|
|
@ -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
|
|
||||||
15
Makefile
15
Makefile
|
|
@ -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)
|
|
||||||
|
|
@ -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)"
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
@ -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{
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -120,33 +120,21 @@ 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
|
issuer := envRequired("POCKET_ID_ISSUER")
|
||||||
// skip the OIDC client-credentials dance entirely. Same for the
|
clientID := envRequired("POCKET_ID_CLIENT_ID")
|
||||||
// projected SA token. Both are mutually exclusive with the real
|
clientSecret := envRequired("POCKET_ID_CLIENT_SECRET")
|
||||||
// sources — set the static one or the trio of OIDC env vars.
|
if issuer == "" || clientID == "" || clientSecret == "" {
|
||||||
if devPocketID := envRequired("TRANSFORM_DEV_POCKET_ID_TOKEN"); devPocketID != "" {
|
return cfg, errors.New("POCKET_ID_ISSUER, POCKET_ID_CLIENT_ID, POCKET_ID_CLIENT_SECRET are required")
|
||||||
cfg.pocketIDSource = auth.StaticToken(devPocketID)
|
}
|
||||||
} else {
|
cfg.pocketIDSource = &auth.PocketIDTokenSource{
|
||||||
issuer := envRequired("POCKET_ID_ISSUER")
|
Issuer: issuer,
|
||||||
clientID := envRequired("POCKET_ID_CLIENT_ID")
|
ClientID: clientID,
|
||||||
clientSecret := envRequired("POCKET_ID_CLIENT_SECRET")
|
ClientSecret: clientSecret,
|
||||||
if issuer == "" || clientID == "" || clientSecret == "" {
|
Audience: envDefault("POCKET_ID_AUDIENCE", "trust-api"),
|
||||||
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,
|
|
||||||
ClientID: clientID,
|
|
||||||
ClientSecret: clientSecret,
|
|
||||||
Audience: envDefault("POCKET_ID_AUDIENCE", "trust-api"),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if devWorkload := envRequired("TRANSFORM_DEV_WORKLOAD_TOKEN"); devWorkload != "" {
|
saPath := envDefault("TRANSFORM_WORKLOAD_TOKEN_PATH", "/var/run/secrets/tokens/trust-api")
|
||||||
cfg.workloadSource = auth.StaticToken(devWorkload)
|
cfg.workloadSource = &auth.ProjectedSATokenSource{Path: saPath}
|
||||||
} else {
|
|
||||||
saPath := envDefault("TRANSFORM_WORKLOAD_TOKEN_PATH", "/var/run/secrets/tokens/trust-api")
|
|
||||||
cfg.workloadSource = &auth.ProjectedSATokenSource{Path: saPath}
|
|
||||||
}
|
|
||||||
|
|
||||||
kr, err := loadKeyring(keysEnv)
|
kr, err := loadKeyring(keysEnv)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -36,12 +37,10 @@ type mocks struct {
|
||||||
transformPub ed25519.PublicKey
|
transformPub ed25519.PublicKey
|
||||||
transformPriv ed25519.PrivateKey
|
transformPriv ed25519.PrivateKey
|
||||||
|
|
||||||
sinkPayload []byte
|
sinkPayload []byte
|
||||||
sinkDecisionID string
|
sinkSchema string
|
||||||
sinkPayloadHash string
|
sinkAttestRef string
|
||||||
sinkApprovalRef string
|
sinkCalled bool
|
||||||
sinkTransformRef string
|
|
||||||
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,8 +269,7 @@ 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,21 +18,19 @@ import (
|
||||||
// originally assigned log_index with a fresh inclusion proof against the
|
// originally assigned log_index with a fresh inclusion proof against the
|
||||||
// current tree size.
|
// current tree size.
|
||||||
type Memory struct {
|
type Memory struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewMemory returns an empty in-memory backend.
|
// NewMemory returns an empty in-memory backend.
|
||||||
func NewMemory() *Memory {
|
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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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) }
|
|
||||||
|
|
|
||||||
|
|
@ -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"`
|
||||||
|
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
module basicstack.de/proofedmesh/tools/webauthn-sign
|
|
||||||
|
|
||||||
go 1.23
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
Loading…
Add table
Reference in a new issue