Compare commits
4 commits
0ae29ae342
...
55834438a8
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
55834438a8 | ||
|
|
9b8f520afc | ||
|
|
801c50cfb0 | ||
|
|
47a7373ef2 |
17 changed files with 1097 additions and 96 deletions
49
.forgejo/workflows/phase1-smoke.yml
Normal file
49
.forgejo/workflows/phase1-smoke.yml
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
name: phase1-smoke
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
# Follows the sha256-clone pattern of validate.yml (DEV-598): the
|
||||||
|
# repository uses the sha256 git object format, so `actions/checkout` is
|
||||||
|
# off-limits and we clone manually from a container with git >= 2.45.
|
||||||
|
#
|
||||||
|
# `scripts/phase1-smoke.sh` builds every Phase-1 service as a static-ish
|
||||||
|
# binary (`go build`), spawns them bound to loopback on ephemeral ports,
|
||||||
|
# then drives User → Approval → Transform → Dummy-Sink → Verification
|
||||||
|
# with curl + jq. No docker-in-docker needed; the harness is self-
|
||||||
|
# contained inside the single CI container.
|
||||||
|
jobs:
|
||||||
|
phase1-smoke:
|
||||||
|
name: Phase 1 end-to-end smoke
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: golang:1.26-alpine3.23
|
||||||
|
steps:
|
||||||
|
- name: Install prerequisites (git, bash, curl, jq, openssl)
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
apk add --no-cache git bash curl jq openssl ca-certificates
|
||||||
|
git --version
|
||||||
|
go version
|
||||||
|
jq --version
|
||||||
|
openssl version
|
||||||
|
|
||||||
|
- name: Clone repository (sha256-aware)
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
git clone --depth=1 --branch="${GITHUB_REF_NAME}" \
|
||||||
|
"https://x-access-token:${GITHUB_TOKEN}@forgejo.basicstack.de/${GITHUB_REPOSITORY}.git" "${GITHUB_WORKSPACE}"
|
||||||
|
cd "${GITHUB_WORKSPACE}"
|
||||||
|
git log -1 --oneline
|
||||||
|
|
||||||
|
- name: Run phase1-smoke
|
||||||
|
working-directory: ${{ github.workspace }}
|
||||||
|
env:
|
||||||
|
# Keep working tree around when the trap tears down so a failed
|
||||||
|
# run surfaces the service logs in the step output.
|
||||||
|
KEEP_WORK: "1"
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
bash scripts/phase1-smoke.sh
|
||||||
15
Makefile
Normal file
15
Makefile
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
# ProofedMesh — repo-root Makefile.
|
||||||
|
#
|
||||||
|
# Keep this file minimal; service-specific Go workflows live in each
|
||||||
|
# services/*/ subtree. The targets here are composition-level flows the
|
||||||
|
# whole repo cares about.
|
||||||
|
|
||||||
|
SHELL := /usr/bin/env bash
|
||||||
|
|
||||||
|
.PHONY: phase1-smoke
|
||||||
|
phase1-smoke: ## Run the Phase-1 end-to-end trust-chain smoke test.
|
||||||
|
@scripts/phase1-smoke.sh
|
||||||
|
|
||||||
|
.PHONY: help
|
||||||
|
help:
|
||||||
|
@awk 'BEGIN {FS = ":.*##"; printf "Targets:\n"} /^[a-zA-Z0-9_.-]+:.*##/ { printf " %-20s %s\n", $$1, $$2 }' $(MAKEFILE_LIST)
|
||||||
395
scripts/phase1-smoke.sh
Executable file
395
scripts/phase1-smoke.sh
Executable file
|
|
@ -0,0 +1,395 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Phase-1 end-to-end smoke test for the ProofedMesh trust chain.
|
||||||
|
#
|
||||||
|
# Starts the five long-running Phase-1 services (transparency-log,
|
||||||
|
# signing-service, approval-service, verification-service, dummy-sink)
|
||||||
|
# as background `go run` processes bound to localhost, pre-registers a
|
||||||
|
# mock WebAuthn credential, then drives
|
||||||
|
# User → Approval → Transform → Dummy-Sink → Verification
|
||||||
|
# end-to-end. Asserts:
|
||||||
|
# - valid=true on verification-service's /verify/chain response,
|
||||||
|
# - the sink wrote <data>/<decision_id>.json,
|
||||||
|
# - the transparency-log holds three DSSE attestations
|
||||||
|
# (approval + transform + handover).
|
||||||
|
#
|
||||||
|
# Logs land in $WORK/logs/<service>.log. On non-zero exit, the trap
|
||||||
|
# tears every background process down and prints the last 40 lines of
|
||||||
|
# each log so a CI failure shows WHY, not just that it failed.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
IFS=$'\n\t'
|
||||||
|
|
||||||
|
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$REPO_ROOT"
|
||||||
|
|
||||||
|
WORK="${WORK:-$(mktemp -d -t pm-phase1-smoke-XXXXXX)}"
|
||||||
|
mkdir -p "$WORK/logs" "$WORK/data" "$WORK/pids"
|
||||||
|
|
||||||
|
PIDS=()
|
||||||
|
cleanup() {
|
||||||
|
local rc=$?
|
||||||
|
set +e
|
||||||
|
for p in "${PIDS[@]:-}"; do
|
||||||
|
[[ -z "$p" ]] && continue
|
||||||
|
if kill -0 "$p" 2>/dev/null; then
|
||||||
|
pkill -TERM -P "$p" 2>/dev/null || true
|
||||||
|
kill -TERM "$p" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
sleep 0.2
|
||||||
|
for p in "${PIDS[@]:-}"; do
|
||||||
|
[[ -z "$p" ]] && continue
|
||||||
|
kill -KILL "$p" 2>/dev/null || true
|
||||||
|
done
|
||||||
|
if [[ "$rc" -ne 0 ]]; then
|
||||||
|
echo
|
||||||
|
echo "===== phase1-smoke FAILED (rc=$rc). Service tails: ====="
|
||||||
|
for f in "$WORK"/logs/*.log; do
|
||||||
|
[[ -f "$f" ]] || continue
|
||||||
|
echo "----- $f -----"
|
||||||
|
tail -n 40 "$f" || true
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
if [[ "${KEEP_WORK:-0}" != "1" ]]; then
|
||||||
|
rm -rf "$WORK"
|
||||||
|
fi
|
||||||
|
exit "$rc"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT INT TERM
|
||||||
|
|
||||||
|
say() { printf '[phase1-smoke] %s\n' "$*"; }
|
||||||
|
|
||||||
|
need() {
|
||||||
|
local missing=0
|
||||||
|
for b in "$@"; do
|
||||||
|
command -v "$b" >/dev/null 2>&1 || { echo "phase1-smoke: missing required tool: $b" >&2; missing=1; }
|
||||||
|
done
|
||||||
|
[[ $missing -eq 0 ]] || exit 2
|
||||||
|
}
|
||||||
|
need go curl jq openssl
|
||||||
|
|
||||||
|
# --------------------------------------------------------- fixed config ---
|
||||||
|
# Pick five free ephemeral ports. Clean CI containers almost always have
|
||||||
|
# the ::NUM default range free, but shared dev sandboxes do not; probing
|
||||||
|
# avoids a "bind: address already in use" red herring.
|
||||||
|
pick_free_port() {
|
||||||
|
local p
|
||||||
|
for _ in $(seq 1 50); do
|
||||||
|
p=$(( RANDOM % 20000 + 30000 ))
|
||||||
|
if ! (echo >"/dev/tcp/127.0.0.1/$p") 2>/dev/null; then
|
||||||
|
printf '%d' "$p"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo "phase1-smoke: could not find a free TCP port" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
PORT_TLOG="${PORT_TLOG:-$(pick_free_port)}"
|
||||||
|
PORT_SIGNING="${PORT_SIGNING:-$(pick_free_port)}"
|
||||||
|
PORT_APPROVAL="${PORT_APPROVAL:-$(pick_free_port)}"
|
||||||
|
PORT_SINK="${PORT_SINK:-$(pick_free_port)}"
|
||||||
|
PORT_VERIFY="${PORT_VERIFY:-$(pick_free_port)}"
|
||||||
|
say "ports: tlog=$PORT_TLOG signing=$PORT_SIGNING approval=$PORT_APPROVAL sink=$PORT_SINK verify=$PORT_VERIFY"
|
||||||
|
|
||||||
|
TLOG_URL="http://127.0.0.1:${PORT_TLOG}"
|
||||||
|
SIGNING_URL="http://127.0.0.1:${PORT_SIGNING}"
|
||||||
|
APPROVAL_URL="http://127.0.0.1:${PORT_APPROVAL}"
|
||||||
|
SINK_URL="http://127.0.0.1:${PORT_SINK}"
|
||||||
|
VERIFY_URL="http://127.0.0.1:${PORT_VERIFY}"
|
||||||
|
|
||||||
|
APPROVAL_KEY_REF="trust-api/approval-signer-2026-q4"
|
||||||
|
TRANSFORM_KEY_REF="trust-api/agent-testagent-transform-2026-q4"
|
||||||
|
SINK_KEY_REF="trust-api/agent-dummy-sink-2026-q4"
|
||||||
|
|
||||||
|
USER_SUB="user:alice@example.com"
|
||||||
|
DECISION_ID="dec-smoke-001"
|
||||||
|
WORKFLOW_ID="WF-0042"
|
||||||
|
RP_ID="trust.basicstack.de"
|
||||||
|
RP_ORIGIN="https://trust.basicstack.de"
|
||||||
|
CRED_ID="smoke-mock-credential"
|
||||||
|
|
||||||
|
APPROVAL_BEARER="approval-signing-token"
|
||||||
|
TRANSFORM_BEARER="smoke-pocket-id-token"
|
||||||
|
SINK_BEARER="smoke-sink-pocket-id-token"
|
||||||
|
WORKLOAD_TOKEN="smoke-workload-token"
|
||||||
|
|
||||||
|
SIGNING_BEARERS="\
|
||||||
|
${TRANSFORM_BEARER}=agent-testagent-transform,\
|
||||||
|
${SINK_BEARER}=agent-dummy-sink,\
|
||||||
|
${APPROVAL_BEARER}=agent-approval-service"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- keys ---
|
||||||
|
say "building Phase-1 service binaries"
|
||||||
|
BIN_DIR="$WORK/bin"
|
||||||
|
mkdir -p "$BIN_DIR"
|
||||||
|
WAS_BIN="$BIN_DIR/webauthn-sign"
|
||||||
|
TLOG_BIN="$BIN_DIR/transparency-log"
|
||||||
|
SIGNING_BIN="$BIN_DIR/signing-service"
|
||||||
|
APPROVAL_BIN="$BIN_DIR/approval-service"
|
||||||
|
VERIFY_BIN="$BIN_DIR/verification-service"
|
||||||
|
SINK_BIN="$BIN_DIR/dummy-sink"
|
||||||
|
TRANSFORM_BIN="$BIN_DIR/testagent-transform"
|
||||||
|
(cd "$REPO_ROOT/tools/webauthn-sign" && go build -o "$WAS_BIN" .)
|
||||||
|
(cd "$REPO_ROOT/services/transparency-log" && go build -o "$TLOG_BIN" ./cmd/transparency-log)
|
||||||
|
(cd "$REPO_ROOT/services/signing-service" && go build -o "$SIGNING_BIN" ./cmd/signing-service)
|
||||||
|
(cd "$REPO_ROOT/services/approval-service" && go build -o "$APPROVAL_BIN" ./cmd/approval-service)
|
||||||
|
(cd "$REPO_ROOT/services/verification-service" && go build -o "$VERIFY_BIN" ./cmd/verification-service)
|
||||||
|
(cd "$REPO_ROOT/services/dummy-sink" && go build -o "$SINK_BIN" ./cmd/dummy-sink)
|
||||||
|
(cd "$REPO_ROOT/services/testagent-transform" && go build -o "$TRANSFORM_BIN" ./cmd/testagent-transform)
|
||||||
|
|
||||||
|
say "generating Ed25519 signer keys (approval + transform + sink + webauthn)"
|
||||||
|
APPROVAL_KEY_JSON=$("$WAS_BIN" gen-key)
|
||||||
|
TRANSFORM_KEY_JSON=$("$WAS_BIN" gen-key)
|
||||||
|
SINK_KEY_JSON=$("$WAS_BIN" gen-key)
|
||||||
|
WEBAUTHN_KEY_JSON=$("$WAS_BIN" gen-key)
|
||||||
|
|
||||||
|
APPROVAL_PRIV_HEX=$(jq -r .priv_hex <<<"$APPROVAL_KEY_JSON")
|
||||||
|
APPROVAL_PUB_HEX=$(jq -r .pub_hex <<<"$APPROVAL_KEY_JSON")
|
||||||
|
APPROVAL_PUB_B64URL=$(jq -r .pub_b64url <<<"$APPROVAL_KEY_JSON")
|
||||||
|
TRANSFORM_PRIV_HEX=$(jq -r .priv_hex <<<"$TRANSFORM_KEY_JSON")
|
||||||
|
TRANSFORM_PUB_HEX=$(jq -r .pub_hex <<<"$TRANSFORM_KEY_JSON")
|
||||||
|
SINK_PRIV_HEX=$(jq -r .priv_hex <<<"$SINK_KEY_JSON")
|
||||||
|
|
||||||
|
WEBAUTHN_PRIV_HEX=$(jq -r .priv_hex <<<"$WEBAUTHN_KEY_JSON")
|
||||||
|
WEBAUTHN_PUB_HEX=$(jq -r .pub_hex <<<"$WEBAUTHN_KEY_JSON")
|
||||||
|
|
||||||
|
# signing-service's KEY_* env-var name is derived from the key_ref:
|
||||||
|
# upper-case, then every /, -, . turned into _. See
|
||||||
|
# services/signing-service/cmd/signing-service/main.go:envNameForKey.
|
||||||
|
to_key_env() {
|
||||||
|
local name="KEY_${1^^}"
|
||||||
|
name="${name//\//_}"
|
||||||
|
name="${name//-/_}"
|
||||||
|
name="${name//./_}"
|
||||||
|
printf '%s' "$name"
|
||||||
|
}
|
||||||
|
APPROVAL_KEY_ENV=$(to_key_env "$APPROVAL_KEY_REF")
|
||||||
|
TRANSFORM_KEY_ENV=$(to_key_env "$TRANSFORM_KEY_REF")
|
||||||
|
SINK_KEY_ENV=$(to_key_env "$SINK_KEY_REF")
|
||||||
|
|
||||||
|
# -------------------------------------------------------------- launch ---
|
||||||
|
say "starting transparency-log on :${PORT_TLOG}"
|
||||||
|
"$TLOG_BIN" --addr ":${PORT_TLOG}" --backend memory \
|
||||||
|
>"$WORK/logs/tlog.log" 2>&1 &
|
||||||
|
PIDS+=($!)
|
||||||
|
|
||||||
|
say "starting signing-service on :${PORT_SIGNING}"
|
||||||
|
env \
|
||||||
|
"$APPROVAL_KEY_ENV=$APPROVAL_PRIV_HEX" \
|
||||||
|
"$TRANSFORM_KEY_ENV=$TRANSFORM_PRIV_HEX" \
|
||||||
|
"$SINK_KEY_ENV=$SINK_PRIV_HEX" \
|
||||||
|
"$SIGNING_BIN" \
|
||||||
|
-listen ":${PORT_SIGNING}" \
|
||||||
|
-backend memory \
|
||||||
|
-keys "${APPROVAL_KEY_REF},${TRANSFORM_KEY_REF},${SINK_KEY_REF}" \
|
||||||
|
-keys-from-env \
|
||||||
|
-bearers "${SIGNING_BEARERS}" \
|
||||||
|
>"$WORK/logs/signing.log" 2>&1 &
|
||||||
|
PIDS+=($!)
|
||||||
|
|
||||||
|
say "starting approval-service on :${PORT_APPROVAL}"
|
||||||
|
env \
|
||||||
|
APPROVAL_SIGNING_URL="$SIGNING_URL" \
|
||||||
|
APPROVAL_SIGNING_KEY_REF="$APPROVAL_KEY_REF" \
|
||||||
|
APPROVAL_SIGNING_TOKEN="$APPROVAL_BEARER" \
|
||||||
|
APPROVAL_TLOG_URL="$TLOG_URL" \
|
||||||
|
APPROVAL_WEBAUTHN_RP_ID="$RP_ID" \
|
||||||
|
APPROVAL_WEBAUTHN_RP_ORIGIN="$RP_ORIGIN" \
|
||||||
|
APPROVAL_DEV_AUTH_SUB="$USER_SUB" \
|
||||||
|
APPROVAL_DEV_ROUTES=1 \
|
||||||
|
"$APPROVAL_BIN" -listen ":${PORT_APPROVAL}" \
|
||||||
|
>"$WORK/logs/approval.log" 2>&1 &
|
||||||
|
PIDS+=($!)
|
||||||
|
|
||||||
|
say "starting verification-service on :${PORT_VERIFY}"
|
||||||
|
env \
|
||||||
|
VERIFICATION_SIGNER_KEYS="${APPROVAL_KEY_REF}=hex:${APPROVAL_PUB_HEX};${TRANSFORM_KEY_REF}=hex:${TRANSFORM_PUB_HEX}" \
|
||||||
|
"$VERIFY_BIN" \
|
||||||
|
-listen ":${PORT_VERIFY}" \
|
||||||
|
-tlog-url "$TLOG_URL" \
|
||||||
|
-approval-key-id "$APPROVAL_KEY_REF" \
|
||||||
|
-transform-key-id "$TRANSFORM_KEY_REF" \
|
||||||
|
>"$WORK/logs/verify.log" 2>&1 &
|
||||||
|
PIDS+=($!)
|
||||||
|
|
||||||
|
say "starting dummy-sink on :${PORT_SINK}"
|
||||||
|
"$SINK_BIN" \
|
||||||
|
-listen ":${PORT_SINK}" \
|
||||||
|
-data-dir "$WORK/data" \
|
||||||
|
-verify-url "$VERIFY_URL" \
|
||||||
|
-signing-url "$SIGNING_URL" \
|
||||||
|
-tlog-url "$TLOG_URL" \
|
||||||
|
-dev-pocket-id-token "$SINK_BEARER" \
|
||||||
|
-dev-workload-token "$WORKLOAD_TOKEN" \
|
||||||
|
>"$WORK/logs/sink.log" 2>&1 &
|
||||||
|
PIDS+=($!)
|
||||||
|
|
||||||
|
wait_for_health() {
|
||||||
|
local name=$1 url=$2
|
||||||
|
for i in $(seq 1 120); do
|
||||||
|
if curl -fsS --connect-timeout 1 "$url/healthz" >/dev/null 2>&1; then
|
||||||
|
say "$name is healthy"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
sleep 0.5
|
||||||
|
done
|
||||||
|
echo "phase1-smoke: $name never became healthy at $url/healthz" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_health tlog "$TLOG_URL"
|
||||||
|
wait_for_health signing "$SIGNING_URL"
|
||||||
|
wait_for_health approval "$APPROVAL_URL"
|
||||||
|
wait_for_health verify "$VERIFY_URL"
|
||||||
|
wait_for_health sink "$SINK_URL"
|
||||||
|
|
||||||
|
# ---------------------------------------------- pre-register WebAuthn ---
|
||||||
|
say "registering mock WebAuthn credential (user_sub=$USER_SUB)"
|
||||||
|
REG_BODY=$(jq -n \
|
||||||
|
--arg credential_id "$CRED_ID" \
|
||||||
|
--arg user_sub "$USER_SUB" \
|
||||||
|
--arg public_key "$WEBAUTHN_PUB_HEX" \
|
||||||
|
'{credential_id:$credential_id, user_sub:$user_sub, public_key:$public_key}')
|
||||||
|
curl -fsS -X POST -H "Content-Type: application/json" -d "$REG_BODY" \
|
||||||
|
"$APPROVAL_URL/dev/webauthn/credentials" >/dev/null
|
||||||
|
|
||||||
|
# ---------------------------- approval: session + preview + confirm ---
|
||||||
|
say "approval: starting session"
|
||||||
|
SESSION_JSON=$(curl -fsS -X POST -H "Content-Type: application/json" -d '{}' \
|
||||||
|
"$APPROVAL_URL/api/v1/sessions")
|
||||||
|
SESSION_ID=$(jq -r .session_id <<<"$SESSION_JSON")
|
||||||
|
[[ -n "$SESSION_ID" && "$SESSION_ID" != "null" ]] || { echo "no session_id in $SESSION_JSON" >&2; exit 1; }
|
||||||
|
|
||||||
|
say "approval: creating preview"
|
||||||
|
PREVIEW_REQ=$(jq -n --arg sid "$SESSION_ID" --arg wf "$WORKFLOW_ID" '{
|
||||||
|
session_id: $sid,
|
||||||
|
content: {
|
||||||
|
title: "Approve expense claim #CL-42",
|
||||||
|
action: "sign",
|
||||||
|
workflow_id: $wf,
|
||||||
|
allowed_transformations: ["expense.map_to_sink"],
|
||||||
|
allowed_sinks: ["sink:dummy-sink"],
|
||||||
|
expires_at: (now + 3600 | strftime("%Y-%m-%dT%H:%M:%SZ"))
|
||||||
|
}
|
||||||
|
}')
|
||||||
|
PREVIEW_JSON=$(curl -fsS -X POST -H "Content-Type: application/json" -d "$PREVIEW_REQ" \
|
||||||
|
"$APPROVAL_URL/api/v1/approvals/preview")
|
||||||
|
PREVIEW_ID=$(jq -r .preview_id <<<"$PREVIEW_JSON")
|
||||||
|
CHALLENGE_B64=$(jq -r .webauthn_challenge <<<"$PREVIEW_JSON")
|
||||||
|
[[ -n "$PREVIEW_ID" && "$PREVIEW_ID" != "null" ]] || { echo "no preview_id: $PREVIEW_JSON" >&2; exit 1; }
|
||||||
|
|
||||||
|
say "signing WebAuthn assertion over challenge"
|
||||||
|
ASSERTION_JSON=$("$WAS_BIN" sign-assertion \
|
||||||
|
-priv-hex "$WEBAUTHN_PRIV_HEX" \
|
||||||
|
-challenge-b64url "$CHALLENGE_B64" \
|
||||||
|
-rp-id "$RP_ID" \
|
||||||
|
-origin "$RP_ORIGIN" \
|
||||||
|
-credential-id "$CRED_ID")
|
||||||
|
|
||||||
|
say "approval: confirming"
|
||||||
|
CONFIRM_REQ=$(jq -n --arg pid "$PREVIEW_ID" --argjson a "$ASSERTION_JSON" \
|
||||||
|
'{preview_id:$pid, assertion:$a}')
|
||||||
|
CONFIRM_JSON=$(curl -fsS -X POST -H "Content-Type: application/json" -d "$CONFIRM_REQ" \
|
||||||
|
"$APPROVAL_URL/api/v1/approvals/confirm")
|
||||||
|
APPROVAL_REF=$(jq -r .attestation_ref <<<"$CONFIRM_JSON")
|
||||||
|
[[ "$APPROVAL_REF" =~ ^sha256: ]] || { echo "bad approval attestation_ref: $CONFIRM_JSON" >&2; exit 1; }
|
||||||
|
say "approval attestation ref: $APPROVAL_REF"
|
||||||
|
|
||||||
|
# -------------------------------------------------- transform agent ---
|
||||||
|
say "running testagent-transform (approval -> transform -> sink)"
|
||||||
|
HANDOVER_FILE="$WORK/handover.json"
|
||||||
|
cat >"$HANDOVER_FILE" <<JSON
|
||||||
|
{
|
||||||
|
"decision_id": "${DECISION_ID}",
|
||||||
|
"workflow_id": "${WORKFLOW_ID}",
|
||||||
|
"employee_id": "E42",
|
||||||
|
"amount_cents": 12345,
|
||||||
|
"currency": "EUR"
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
|
||||||
|
TRANSFORM_OUT_FILE="$WORK/transform.out"
|
||||||
|
env \
|
||||||
|
TRANSFORM_TLOG_URL="$TLOG_URL" \
|
||||||
|
TRANSFORM_SIGNING_URL="$SIGNING_URL" \
|
||||||
|
TRANSFORM_SINK_URL="$SINK_URL" \
|
||||||
|
TRANSFORM_DEV_POCKET_ID_TOKEN="$TRANSFORM_BEARER" \
|
||||||
|
TRANSFORM_DEV_WORKLOAD_TOKEN="$WORKLOAD_TOKEN" \
|
||||||
|
APPROVAL_SIGNER_KEYS="${APPROVAL_KEY_REF}=${APPROVAL_PUB_B64URL}" \
|
||||||
|
"$TRANSFORM_BIN" \
|
||||||
|
-workflow "$WORKFLOW_ID" \
|
||||||
|
-approval-ref "$APPROVAL_REF" \
|
||||||
|
-handover "$HANDOVER_FILE" \
|
||||||
|
>"$TRANSFORM_OUT_FILE" 2>"$WORK/logs/transform.log"
|
||||||
|
TRANSFORM_REF=$(tail -n 1 "$TRANSFORM_OUT_FILE")
|
||||||
|
[[ "$TRANSFORM_REF" =~ ^sha256: ]] || { echo "bad transform ref: $(cat "$TRANSFORM_OUT_FILE")" >&2; exit 1; }
|
||||||
|
say "transform attestation ref: $TRANSFORM_REF"
|
||||||
|
|
||||||
|
# ------------------------------------------------------ assertions ---
|
||||||
|
say "asserting dummy-sink wrote the payload file"
|
||||||
|
SINK_FILE="$WORK/data/${DECISION_ID}.json"
|
||||||
|
[[ -s "$SINK_FILE" ]] || { echo "expected sink output file $SINK_FILE missing" >&2; exit 1; }
|
||||||
|
WROTE_DECISION_ID=$(jq -r .decision_id "$SINK_FILE")
|
||||||
|
[[ "$WROTE_DECISION_ID" == "$DECISION_ID" ]] || { echo "sink file decision_id mismatch ($WROTE_DECISION_ID)" >&2; exit 1; }
|
||||||
|
|
||||||
|
# The sink's own handover attestation ref is logged by the signing-service
|
||||||
|
# after it signs on dummy-sink's behalf; the last "signed" line's
|
||||||
|
# content_hash is the handover ref. All three refs end up as JSON log
|
||||||
|
# lines on signing.log in the same order.
|
||||||
|
HANDOVER_REF=$(grep '"signed"' "$WORK/logs/signing.log" \
|
||||||
|
| grep -oE 'sha256:[0-9a-f]{64}' | tail -n 1 || true)
|
||||||
|
if [[ -z "$HANDOVER_REF" ]]; then
|
||||||
|
echo "could not scrape handover_ref from signing-service log" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
say "handover attestation ref: $HANDOVER_REF"
|
||||||
|
|
||||||
|
say "asserting verification-service agrees (valid=true) via replay-safe decision_id"
|
||||||
|
# Use a fresh decision_id for the independent /verify/chain call so the
|
||||||
|
# replay guard does not refuse the direct verifier probe.
|
||||||
|
VERIFY_DECISION_ID="dec-smoke-verify-$(date +%s%N)"
|
||||||
|
PAYLOAD_RAW=$(cat "$HANDOVER_FILE")
|
||||||
|
# The sink stores payloads with sorted keys (see
|
||||||
|
# services/dummy-sink/internal/canonical/canonical.go). Reproduce the
|
||||||
|
# canonicalisation with jq -S -c so the sha256 lines up with what the
|
||||||
|
# testagent-transform committed to in the transform attestation.
|
||||||
|
CANON_PAYLOAD=$(jq -S -c '.' <<<"$PAYLOAD_RAW")
|
||||||
|
PAYLOAD_HASH="sha256:$(printf '%s' "$CANON_PAYLOAD" | openssl dgst -sha256 | awk '{print $NF}')"
|
||||||
|
|
||||||
|
VERIFY_REQ=$(jq -n \
|
||||||
|
--arg decision_id "$VERIFY_DECISION_ID" \
|
||||||
|
--arg approval_ref "$APPROVAL_REF" \
|
||||||
|
--arg transform_ref "$TRANSFORM_REF" \
|
||||||
|
--arg sink_target "sink:dummy-sink" \
|
||||||
|
--arg payload_hash "$PAYLOAD_HASH" \
|
||||||
|
'{decision_id:$decision_id,
|
||||||
|
approval_ref:$approval_ref,
|
||||||
|
transform_ref:$transform_ref,
|
||||||
|
sink_target:$sink_target,
|
||||||
|
payload_hash:$payload_hash}')
|
||||||
|
VERIFY_JSON=$(curl -fsS -X POST -H "Content-Type: application/json" -d "$VERIFY_REQ" \
|
||||||
|
"$VERIFY_URL/api/v1/verify/chain")
|
||||||
|
echo "$VERIFY_JSON" | jq .
|
||||||
|
VALID=$(jq -r .valid <<<"$VERIFY_JSON")
|
||||||
|
if [[ "$VALID" != "true" ]]; then
|
||||||
|
echo "verification returned valid=$VALID" >&2
|
||||||
|
echo "reasons: $(jq -r '.reasons | tostring' <<<"$VERIFY_JSON")" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
say "asserting transparency-log holds the Phase-1 attestations"
|
||||||
|
LOG_COUNT=0
|
||||||
|
for ref in "$APPROVAL_REF" "$TRANSFORM_REF" "$HANDOVER_REF"; do
|
||||||
|
if curl -fsS "$TLOG_URL/api/v1/log/entries/$ref" >/dev/null; then
|
||||||
|
LOG_COUNT=$((LOG_COUNT+1))
|
||||||
|
else
|
||||||
|
echo "phase1-smoke: TLog missing entry for $ref" >&2
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
say "transparency-log entries retrievable: $LOG_COUNT (approval + transform + handover)"
|
||||||
|
if [[ "$LOG_COUNT" -lt 3 ]]; then
|
||||||
|
echo "expected >=3 TLog entries, got $LOG_COUNT" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
say "PHASE 1 SMOKE: OK (approval=$APPROVAL_REF transform=$TRANSFORM_REF handover=$HANDOVER_REF)"
|
||||||
|
|
@ -34,6 +34,8 @@ func main() {
|
||||||
WebauthnRPID string
|
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")
|
||||||
|
|
@ -45,6 +47,8 @@ func main() {
|
||||||
flag.StringVar(&cfg.WebauthnRPID, "webauthn-rp-id", envOr("APPROVAL_WEBAUTHN_RP_ID", "trust.basicstack.de"), "WebAuthn relying party ID")
|
flag.StringVar(&cfg.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))
|
||||||
|
|
@ -85,14 +89,21 @@ func main() {
|
||||||
AttestationIssuer: cfg.AttestationIss,
|
AttestationIssuer: cfg.AttestationIss,
|
||||||
}
|
}
|
||||||
|
|
||||||
server := &httpapi.Server{
|
var authResolver httpapi.AuthResolver
|
||||||
// Phase 1 wires an "unauthenticated" dev resolver — the Pocket-ID
|
if cfg.DevAuthSub != "" {
|
||||||
// cookie middleware (shared with admin-api) attaches in deployment.
|
sub := cfg.DevAuthSub
|
||||||
// This default is deliberately noisy so misconfigured deployments
|
logger.Warn("approval-service: dev auth resolver in use — DEV/CI ONLY", "sub", sub)
|
||||||
// fail closed rather than silently accepting anonymous callers.
|
authResolver = httpapi.AuthFunc(func(_ *http.Request) (auth.Principal, error) {
|
||||||
Auth: 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")
|
return auth.Principal{}, errors.New("approval-service: no auth middleware mounted; wire Pocket-ID before serving traffic")
|
||||||
}),
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
server := &httpapi.Server{
|
||||||
|
Auth: authResolver,
|
||||||
Sessions: mgr,
|
Sessions: mgr,
|
||||||
Orchestrator: orch,
|
Orchestrator: orch,
|
||||||
Logger: logger,
|
Logger: logger,
|
||||||
|
|
@ -100,6 +111,10 @@ 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,
|
||||||
|
|
@ -131,6 +146,20 @@ 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))
|
||||||
|
|
|
||||||
137
services/approval-service/internal/httpapi/dev.go
Normal file
137
services/approval-service/internal/httpapi/dev.go
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
// Package httpapi — dev-only routes. Mounted by main.go only when the
|
||||||
|
// operator passes -dev-routes (or APPROVAL_DEV_ROUTES=1). These routes
|
||||||
|
// seed in-memory WebAuthn credentials so the Phase-1 end-to-end smoke
|
||||||
|
// test can drive the approval flow without a browser or Pocket-ID.
|
||||||
|
// NEVER mount these in production.
|
||||||
|
|
||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ed25519"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"basicstack.de/proofedmesh/services/approval-service/internal/webauthn"
|
||||||
|
)
|
||||||
|
|
||||||
|
type devRegisterCredentialRequest struct {
|
||||||
|
CredentialID string `json:"credential_id"`
|
||||||
|
UserSub string `json:"user_sub"`
|
||||||
|
PublicKey string `json:"public_key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type devRegisterCredentialResponse struct {
|
||||||
|
CredentialID string `json:"credential_id"`
|
||||||
|
UserSub string `json:"user_sub"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MountDevRoutes exposes POST /dev/webauthn/credentials and GET
|
||||||
|
// /dev/healthz. Call only from a dev/CI build; the handler accepts any
|
||||||
|
// Ed25519 public key as a trusted credential, which is useless in prod.
|
||||||
|
func MountDevRoutes(mux *http.ServeMux, store *webauthn.Store, logger *slog.Logger) {
|
||||||
|
mux.HandleFunc("POST /dev/webauthn/credentials", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req devRegisterCredentialRequest
|
||||||
|
dec := json.NewDecoder(http.MaxBytesReader(nil, r.Body, 1<<20))
|
||||||
|
dec.DisallowUnknownFields()
|
||||||
|
if err := dec.Decode(&req); err != nil {
|
||||||
|
writeDevErr(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.CredentialID == "" {
|
||||||
|
writeDevErr(w, http.StatusBadRequest, "bad_request", "credential_id is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.UserSub == "" {
|
||||||
|
writeDevErr(w, http.StatusBadRequest, "bad_request", "user_sub is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pub, err := decodeDevPubKey(req.PublicKey)
|
||||||
|
if err != nil {
|
||||||
|
writeDevErr(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cred := webauthn.Credential{
|
||||||
|
ID: req.CredentialID,
|
||||||
|
UserSub: req.UserSub,
|
||||||
|
PublicKey: pub,
|
||||||
|
}
|
||||||
|
if err := store.AddCredential(cred); err != nil {
|
||||||
|
writeDevErr(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if logger != nil {
|
||||||
|
logger.Info("dev: webauthn credential registered",
|
||||||
|
"credential_id", req.CredentialID,
|
||||||
|
"user_sub", req.UserSub,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
_ = json.NewEncoder(w).Encode(devRegisterCredentialResponse{
|
||||||
|
CredentialID: req.CredentialID,
|
||||||
|
UserSub: req.UserSub,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeDevPubKey(s string) (ed25519.PublicKey, error) {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" {
|
||||||
|
return nil, errors.New("public_key is required (hex, b64, or b64url of a 32-byte Ed25519 public key)")
|
||||||
|
}
|
||||||
|
if b, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(s, "=")); err == nil && len(b) == ed25519.PublicKeySize {
|
||||||
|
return ed25519.PublicKey(b), nil
|
||||||
|
}
|
||||||
|
if b, err := base64.StdEncoding.DecodeString(s); err == nil && len(b) == ed25519.PublicKeySize {
|
||||||
|
return ed25519.PublicKey(b), nil
|
||||||
|
}
|
||||||
|
if b, err := decodeHex(s); err == nil && len(b) == ed25519.PublicKeySize {
|
||||||
|
return ed25519.PublicKey(b), nil
|
||||||
|
}
|
||||||
|
return nil, errors.New("public_key must be a 32-byte Ed25519 key encoded as hex, base64, or base64url")
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeHex(s string) ([]byte, error) {
|
||||||
|
n := len(s)
|
||||||
|
if n%2 != 0 {
|
||||||
|
return nil, errors.New("odd-length hex string")
|
||||||
|
}
|
||||||
|
out := make([]byte, n/2)
|
||||||
|
for i := 0; i < n; i += 2 {
|
||||||
|
hi, err := hexNibble(s[i])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
lo, err := hexNibble(s[i+1])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out[i/2] = hi<<4 | lo
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hexNibble(b byte) (byte, error) {
|
||||||
|
switch {
|
||||||
|
case b >= '0' && b <= '9':
|
||||||
|
return b - '0', nil
|
||||||
|
case b >= 'a' && b <= 'f':
|
||||||
|
return b - 'a' + 10, nil
|
||||||
|
case b >= 'A' && b <= 'F':
|
||||||
|
return b - 'A' + 10, nil
|
||||||
|
default:
|
||||||
|
return 0, errors.New("invalid hex character")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeDevErr(w http.ResponseWriter, status int, code, msg string) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"error": map[string]string{"code": code, "message": msg},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -203,6 +204,13 @@ func (o *Orchestrator) genID(prefix string) (string, error) { return o.NewID(pre
|
||||||
// buildStatement renders the in-toto v1 Statement for an approval. The
|
// 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
|
||||||
|
|
@ -222,6 +230,16 @@ 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,10 +33,13 @@ 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/handover` with the
|
6. **Handover.** `POST {TRANSFORM_SINK_URL}/api/v1/sink/dummy/deliver`
|
||||||
canonical `handover_payload` bytes. Headers set:
|
with the DEV-611 wrapper JSON `{decision_id, payload_hash, payload,
|
||||||
- `X-Payload-Schema: sink.dummy.v1`
|
approval_ref, transform_ref}`. The `payload` field is the canonical
|
||||||
- `X-Attestation-Ref: <transform content_hash>`
|
handover_payload the agent already committed to in the transform
|
||||||
|
attestation's subject digest; the sink re-hashes and refuses the
|
||||||
|
delivery unless it matches `payload_hash`. The `decision_id` is read
|
||||||
|
from the top-level `decision_id` field of the handover payload.
|
||||||
|
|
||||||
## Non-scope
|
## Non-scope
|
||||||
|
|
||||||
|
|
@ -65,7 +68,9 @@ to the Phase-1 dummy-sink. The exit code is 0 on success.
|
||||||
| `TRANSFORM_INPUT_SCHEMA` / `_VERSION` | `expense.canonical` / `1.2.0` | Input schema name/version carried in the attestation. |
|
| `TRANSFORM_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` | Value sent on the sink's `X-Payload-Schema` header. |
|
| `TRANSFORM_SINK_PAYLOAD_SCHEMA` | `sink.dummy.v1` | Informational schema identifier recorded in the handover attestation. |
|
||||||
|
| `TRANSFORM_DEV_POCKET_ID_TOKEN` | *(unset)* | DEV/CI ONLY: static Pocket-ID bearer, bypasses the OIDC client-credentials grant. |
|
||||||
|
| `TRANSFORM_DEV_WORKLOAD_TOKEN` | *(unset)* | DEV/CI ONLY: static projected ServiceAccount token value. |
|
||||||
|
|
||||||
## CLI
|
## CLI
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -120,21 +120,33 @@ 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")
|
||||||
}
|
}
|
||||||
|
|
||||||
issuer := envRequired("POCKET_ID_ISSUER")
|
// DEV/CI short-circuits: if a static Pocket-ID token is provided, we
|
||||||
clientID := envRequired("POCKET_ID_CLIENT_ID")
|
// skip the OIDC client-credentials dance entirely. Same for the
|
||||||
clientSecret := envRequired("POCKET_ID_CLIENT_SECRET")
|
// projected SA token. Both are mutually exclusive with the real
|
||||||
if issuer == "" || clientID == "" || clientSecret == "" {
|
// sources — set the static one or the trio of OIDC env vars.
|
||||||
return cfg, errors.New("POCKET_ID_ISSUER, POCKET_ID_CLIENT_ID, POCKET_ID_CLIENT_SECRET are required")
|
if devPocketID := envRequired("TRANSFORM_DEV_POCKET_ID_TOKEN"); devPocketID != "" {
|
||||||
}
|
cfg.pocketIDSource = auth.StaticToken(devPocketID)
|
||||||
cfg.pocketIDSource = &auth.PocketIDTokenSource{
|
} else {
|
||||||
Issuer: issuer,
|
issuer := envRequired("POCKET_ID_ISSUER")
|
||||||
ClientID: clientID,
|
clientID := envRequired("POCKET_ID_CLIENT_ID")
|
||||||
ClientSecret: clientSecret,
|
clientSecret := envRequired("POCKET_ID_CLIENT_SECRET")
|
||||||
Audience: envDefault("POCKET_ID_AUDIENCE", "trust-api"),
|
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)")
|
||||||
|
}
|
||||||
|
cfg.pocketIDSource = &auth.PocketIDTokenSource{
|
||||||
|
Issuer: issuer,
|
||||||
|
ClientID: clientID,
|
||||||
|
ClientSecret: clientSecret,
|
||||||
|
Audience: envDefault("POCKET_ID_AUDIENCE", "trust-api"),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
saPath := envDefault("TRANSFORM_WORKLOAD_TOKEN_PATH", "/var/run/secrets/tokens/trust-api")
|
if devWorkload := envRequired("TRANSFORM_DEV_WORKLOAD_TOKEN"); devWorkload != "" {
|
||||||
cfg.workloadSource = &auth.ProjectedSATokenSource{Path: saPath}
|
cfg.workloadSource = auth.StaticToken(devWorkload)
|
||||||
|
} else {
|
||||||
|
saPath := envDefault("TRANSFORM_WORKLOAD_TOKEN_PATH", "/var/run/secrets/tokens/trust-api")
|
||||||
|
cfg.workloadSource = &auth.ProjectedSATokenSource{Path: saPath}
|
||||||
|
}
|
||||||
|
|
||||||
kr, err := loadKeyring(keysEnv)
|
kr, err := loadKeyring(keysEnv)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,17 @@
|
||||||
// Package sink posts a handover_payload to the (phase-1) dummy sink.
|
// Package sink posts a handover envelope to the Phase-1 dummy sink.
|
||||||
//
|
//
|
||||||
// The payload is sent as the canonical JSON bytes the agent already hashed
|
// The dummy-sink's contract (DEV-611) is `POST /api/v1/sink/dummy/deliver`
|
||||||
// into the transform attestation's subject digest — a byte-for-byte match
|
// with a JSON wrapper `{decision_id, payload_hash, payload, approval_ref,
|
||||||
// is a hard requirement for a downstream verifier, so we send those bytes
|
// transform_ref}`. The sink re-hashes payload server-side and MUST see
|
||||||
// directly rather than re-marshaling.
|
// `payload_hash` match either the raw bytes or the canonicalised form,
|
||||||
|
// so we hand it the exact canonical bytes the transform attestation
|
||||||
|
// already committed to.
|
||||||
package sink
|
package sink
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
|
@ -19,15 +22,6 @@ 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")
|
||||||
|
|
||||||
|
|
@ -38,53 +32,100 @@ type Client struct {
|
||||||
TokenSource auth.TokenSource
|
TokenSource auth.TokenSource
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeliverResult is what the agent reports upstream after a sink POST.
|
// DeliverRequest carries everything the dummy-sink needs on its /deliver
|
||||||
type DeliverResult struct {
|
// endpoint. The runner builds this from the (approval_ref, transform_ref)
|
||||||
Status int
|
// chain plus the canonical handover bytes.
|
||||||
|
type DeliverRequest struct {
|
||||||
|
DecisionID string
|
||||||
|
PayloadBytes []byte
|
||||||
|
PayloadHash string
|
||||||
|
ApprovalRef string
|
||||||
|
TransformRef string
|
||||||
|
Schema string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deliver POSTs payloadBytes to the sink. The caller passes the schema
|
// DeliverResult is what the agent reports upstream after a sink POST.
|
||||||
// identifier (e.g. "sink.dummy.v1") and the transform attestation's
|
type DeliverResult struct {
|
||||||
// content_hash so both go on the request headers.
|
Status int
|
||||||
func (c Client) Deliver(ctx context.Context, schema, attestationRef string, payloadBytes []byte) (DeliverResult, error) {
|
HandoverRef string
|
||||||
|
LogIndex int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type deliverWire struct {
|
||||||
|
DecisionID string `json:"decision_id"`
|
||||||
|
PayloadHash string `json:"payload_hash"`
|
||||||
|
Payload json.RawMessage `json:"payload"`
|
||||||
|
ApprovalRef string `json:"approval_ref"`
|
||||||
|
TransformRef string `json:"transform_ref"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type deliverResponseWire struct {
|
||||||
|
HandoverRef string `json:"handover_ref"`
|
||||||
|
LogIndex int64 `json:"log_index"`
|
||||||
|
DecisionID string `json:"decision_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deliver POSTs the handover wrapper to the dummy-sink. The caller
|
||||||
|
// passes the schema identifier (currently informational — the sink does
|
||||||
|
// not read it) plus the canonical payload bytes committed to by the
|
||||||
|
// transform attestation. The returned HandoverRef is the sink's own
|
||||||
|
// handover attestation content_hash, surfaced so a caller can log it
|
||||||
|
// alongside the transform ref for later chain inspection.
|
||||||
|
func (c Client) Deliver(ctx context.Context, req DeliverRequest) (DeliverResult, error) {
|
||||||
if c.BaseURL == "" {
|
if c.BaseURL == "" {
|
||||||
return DeliverResult{}, errors.New("sink.Client: BaseURL is empty")
|
return DeliverResult{}, errors.New("sink.Client: BaseURL is empty")
|
||||||
}
|
}
|
||||||
if schema == "" {
|
if req.DecisionID == "" {
|
||||||
return DeliverResult{}, errors.New("sink.Client: schema is required")
|
return DeliverResult{}, errors.New("sink.Client: DecisionID is required")
|
||||||
}
|
}
|
||||||
if attestationRef == "" {
|
if len(req.PayloadBytes) == 0 {
|
||||||
return DeliverResult{}, errors.New("sink.Client: attestationRef is required")
|
return DeliverResult{}, errors.New("sink.Client: PayloadBytes is empty")
|
||||||
}
|
}
|
||||||
if len(payloadBytes) == 0 {
|
if req.PayloadHash == "" {
|
||||||
return DeliverResult{}, errors.New("sink.Client: payloadBytes is empty")
|
return DeliverResult{}, errors.New("sink.Client: PayloadHash is required")
|
||||||
}
|
}
|
||||||
|
if req.ApprovalRef == "" {
|
||||||
|
return DeliverResult{}, errors.New("sink.Client: ApprovalRef is required")
|
||||||
|
}
|
||||||
|
if req.TransformRef == "" {
|
||||||
|
return DeliverResult{}, errors.New("sink.Client: TransformRef is required")
|
||||||
|
}
|
||||||
|
|
||||||
hc := c.HTTPClient
|
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/handover")
|
endpoint, err := url.JoinPath(c.BaseURL, "/api/v1/sink/dummy/deliver")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return DeliverResult{}, fmt.Errorf("sink: build url: %w", err)
|
return DeliverResult{}, fmt.Errorf("sink: build url: %w", err)
|
||||||
}
|
}
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payloadBytes))
|
wire := deliverWire{
|
||||||
|
DecisionID: req.DecisionID,
|
||||||
|
PayloadHash: req.PayloadHash,
|
||||||
|
Payload: json.RawMessage(req.PayloadBytes),
|
||||||
|
ApprovalRef: req.ApprovalRef,
|
||||||
|
TransformRef: req.TransformRef,
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(wire)
|
||||||
|
if err != nil {
|
||||||
|
return DeliverResult{}, fmt.Errorf("sink: marshal body: %w", err)
|
||||||
|
}
|
||||||
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return DeliverResult{}, err
|
return DeliverResult{}, err
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/json")
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
req.Header.Set("Accept", "application/json")
|
httpReq.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 != "" {
|
||||||
req.Header.Set("Authorization", "Bearer "+tok)
|
httpReq.Header.Set("Authorization", "Bearer "+tok)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
resp, err := hc.Do(req)
|
resp, err := hc.Do(httpReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return DeliverResult{}, fmt.Errorf("%w: %v", ErrSinkUnavailable, err)
|
return DeliverResult{}, fmt.Errorf("%w: %v", ErrSinkUnavailable, err)
|
||||||
}
|
}
|
||||||
|
|
@ -92,7 +133,15 @@ func (c Client) Deliver(ctx context.Context, schema, attestationRef string, payl
|
||||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
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:
|
||||||
return DeliverResult{Status: resp.StatusCode}, nil
|
var out deliverResponseWire
|
||||||
|
if err := json.Unmarshal(raw, &out); err != nil {
|
||||||
|
return DeliverResult{}, fmt.Errorf("sink: decode response: %w (body=%q)", err, truncate(raw, 256))
|
||||||
|
}
|
||||||
|
return DeliverResult{
|
||||||
|
Status: resp.StatusCode,
|
||||||
|
HandoverRef: out.HandoverRef,
|
||||||
|
LogIndex: out.LogIndex,
|
||||||
|
}, nil
|
||||||
case resp.StatusCode >= 500:
|
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,8 +60,9 @@ 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 the sink expects on the
|
// SinkPayloadSchema is the schema identifier recorded in the
|
||||||
// `X-Payload-Schema` header. Phase-1: "sink.dummy.v1".
|
// handover attestation and forwarded on the sink.DeliverRequest.
|
||||||
|
// Phase-1: "sink.dummy.v1".
|
||||||
SinkPayloadSchema string
|
SinkPayloadSchema string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -81,9 +82,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_payload bytes to the sink.
|
// SinkDeliverer posts the handover wrapper to the sink.
|
||||||
type SinkDeliverer interface {
|
type SinkDeliverer interface {
|
||||||
Deliver(ctx context.Context, schema, attestationRef string, payloadBytes []byte) (sink.DeliverResult, error)
|
Deliver(ctx context.Context, req sink.DeliverRequest) (sink.DeliverResult, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Request is a single transform invocation. HandoverPayload is JSON-shaped
|
// Request is a single transform invocation. HandoverPayload is JSON-shaped
|
||||||
|
|
@ -100,6 +101,7 @@ type Result struct {
|
||||||
TransformRef string
|
TransformRef string
|
||||||
LogIndex int64
|
LogIndex int64
|
||||||
SinkStatus int
|
SinkStatus int
|
||||||
|
SinkHandoverRef string
|
||||||
HandoverHashHex string
|
HandoverHashHex string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -153,7 +155,18 @@ func (r *Runner) Execute(ctx context.Context, req Request) (Result, error) {
|
||||||
return Result{}, fmt.Errorf("tlog submit transform: %w", err)
|
return Result{}, fmt.Errorf("tlog submit transform: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
deliv, err := r.Sink.Deliver(ctx, r.SinkPayloadSchema, signed.ContentHash, payloadBytes)
|
decisionID, err := extractDecisionID(req.HandoverPayload)
|
||||||
|
if err != nil {
|
||||||
|
return Result{}, err
|
||||||
|
}
|
||||||
|
deliv, err := r.Sink.Deliver(ctx, sink.DeliverRequest{
|
||||||
|
DecisionID: decisionID,
|
||||||
|
PayloadBytes: payloadBytes,
|
||||||
|
PayloadHash: "sha256:" + payloadHashHex,
|
||||||
|
ApprovalRef: req.ApprovalRef,
|
||||||
|
TransformRef: signed.ContentHash,
|
||||||
|
Schema: r.SinkPayloadSchema,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Result{}, fmt.Errorf("sink deliver: %w", err)
|
return Result{}, fmt.Errorf("sink deliver: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -162,10 +175,27 @@ func (r *Runner) Execute(ctx context.Context, req Request) (Result, error) {
|
||||||
TransformRef: signed.ContentHash,
|
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,7 +9,6 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
@ -37,10 +36,12 @@ type mocks struct {
|
||||||
transformPub ed25519.PublicKey
|
transformPub ed25519.PublicKey
|
||||||
transformPriv ed25519.PrivateKey
|
transformPriv ed25519.PrivateKey
|
||||||
|
|
||||||
sinkPayload []byte
|
sinkPayload []byte
|
||||||
sinkSchema string
|
sinkDecisionID string
|
||||||
sinkAttestRef string
|
sinkPayloadHash string
|
||||||
sinkCalled bool
|
sinkApprovalRef string
|
||||||
|
sinkTransformRef string
|
||||||
|
sinkCalled bool
|
||||||
|
|
||||||
tlogSubmitted []string // sha256:… content_hashes seen on POST /log/entries
|
tlogSubmitted []string // sha256:… content_hashes seen on POST /log/entries
|
||||||
}
|
}
|
||||||
|
|
@ -70,7 +71,7 @@ const (
|
||||||
|
|
||||||
func buildMocks(t *testing.T, opts mockOpts) *mocks {
|
func buildMocks(t *testing.T, opts mockOpts) *mocks {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
m := &mocks{sinkSchema: "sink.dummy.v1"}
|
m := &mocks{}
|
||||||
|
|
||||||
// Keys.
|
// Keys.
|
||||||
approvalPub, approvalPriv, err := ed25519.GenerateKey(rand.Reader)
|
approvalPub, approvalPriv, err := ed25519.GenerateKey(rand.Reader)
|
||||||
|
|
@ -209,18 +210,38 @@ func buildMocks(t *testing.T, opts mockOpts) *mocks {
|
||||||
_ = json.NewEncoder(w).Encode(rcpt)
|
_ = json.NewEncoder(w).Encode(rcpt)
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// sinkSrv: POST /api/v1/handover; captures body + headers so the
|
// sinkSrv: POST /api/v1/sink/dummy/deliver with the DEV-611 wrapper
|
||||||
// test can assert the agent sent exactly the canonical bytes.
|
// shape; captures each field so the test can assert that the agent
|
||||||
|
// forwarded the canonical bytes verbatim and all four refs line up.
|
||||||
m.sinkSrv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
m.sinkSrv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.URL.Path != "/api/v1/handover" || r.Method != http.MethodPost {
|
if r.URL.Path != "/api/v1/sink/dummy/deliver" || r.Method != http.MethodPost {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
m.sinkCalled = true
|
m.sinkCalled = true
|
||||||
m.sinkSchema = r.Header.Get(sink.SchemaHeader)
|
var body struct {
|
||||||
m.sinkAttestRef = r.Header.Get(sink.AttestationHeader)
|
DecisionID string `json:"decision_id"`
|
||||||
m.sinkPayload, _ = io.ReadAll(r.Body)
|
PayloadHash string `json:"payload_hash"`
|
||||||
w.WriteHeader(http.StatusAccepted)
|
Payload json.RawMessage `json:"payload"`
|
||||||
|
ApprovalRef string `json:"approval_ref"`
|
||||||
|
TransformRef string `json:"transform_ref"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
http.Error(w, "decode: "+err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.sinkDecisionID = body.DecisionID
|
||||||
|
m.sinkPayloadHash = body.PayloadHash
|
||||||
|
m.sinkPayload = append(m.sinkPayload[:0], body.Payload...)
|
||||||
|
m.sinkApprovalRef = body.ApprovalRef
|
||||||
|
m.sinkTransformRef = body.TransformRef
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"handover_ref": "sha256:" + strings.Repeat("d", 64),
|
||||||
|
"log_index": int64(99),
|
||||||
|
"decision_id": body.DecisionID,
|
||||||
|
})
|
||||||
}))
|
}))
|
||||||
|
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
|
|
@ -269,7 +290,8 @@ func sampleRequest() Request {
|
||||||
WorkflowID: "WF-TEST-1",
|
WorkflowID: "WF-TEST-1",
|
||||||
ApprovalRef: attestationRef,
|
ApprovalRef: attestationRef,
|
||||||
HandoverPayload: map[string]any{
|
HandoverPayload: map[string]any{
|
||||||
"schema": map[string]any{"name": "sink.dummy.v1", "version": "1.0.0"},
|
"decision_id": "dec-WF-TEST-1",
|
||||||
|
"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"},
|
||||||
},
|
},
|
||||||
|
|
@ -304,11 +326,20 @@ 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.sinkSchema != "sink.dummy.v1" {
|
if m.sinkDecisionID != "dec-WF-TEST-1" {
|
||||||
t.Fatalf("sink schema header = %q", m.sinkSchema)
|
t.Fatalf("sink decision_id = %q, want %q", m.sinkDecisionID, "dec-WF-TEST-1")
|
||||||
}
|
}
|
||||||
if m.sinkAttestRef != res.TransformRef {
|
if m.sinkPayloadHash != "sha256:"+wantHash {
|
||||||
t.Fatalf("sink attestation header = %q, want %q", m.sinkAttestRef, res.TransformRef)
|
t.Fatalf("sink payload_hash = %q, want %q", m.sinkPayloadHash, "sha256:"+wantHash)
|
||||||
|
}
|
||||||
|
if m.sinkApprovalRef != req.ApprovalRef {
|
||||||
|
t.Fatalf("sink approval_ref = %q, want %q", m.sinkApprovalRef, req.ApprovalRef)
|
||||||
|
}
|
||||||
|
if m.sinkTransformRef != res.TransformRef {
|
||||||
|
t.Fatalf("sink transform_ref = %q, want %q", m.sinkTransformRef, res.TransformRef)
|
||||||
|
}
|
||||||
|
if res.SinkHandoverRef == "" {
|
||||||
|
t.Fatalf("SinkHandoverRef unset — expected sink's handover_ref")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TLog must have seen exactly our transform content_hash.
|
// TLog must have seen exactly our transform content_hash.
|
||||||
|
|
@ -318,8 +349,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.StatusAccepted {
|
if res.SinkStatus != http.StatusCreated {
|
||||||
t.Fatalf("SinkStatus = %d, want 202", res.SinkStatus)
|
t.Fatalf("SinkStatus = %d, want 201", res.SinkStatus)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,19 +18,21 @@ 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
|
||||||
now func() time.Time
|
envelopesByIndex map[int64]Envelope // original envelope per index (for Get)
|
||||||
|
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{},
|
||||||
now: func() time.Time { return time.Now().UTC() },
|
envelopesByIndex: map[int64]Envelope{},
|
||||||
|
now: func() time.Time { return time.Now().UTC() },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -73,6 +75,7 @@ func (m *Memory) Submit(_ context.Context, env Envelope, contentHash string) (Re
|
||||||
m.leaves = append(m.leaves, leafHash(canonical))
|
m.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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -99,6 +102,40 @@ 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,6 +58,13 @@ 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,6 +47,27 @@ 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,14 +22,44 @@ type Server struct {
|
||||||
LogError func(string, ...any)
|
LogError func(string, ...any)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handler returns an http.Handler that serves the ADR-0005 routes. Only
|
// Handler returns an http.Handler that serves the ADR-0005 routes plus a
|
||||||
// POST /api/v1/log/entries is implemented; everything else is 404.
|
// liveness check at GET /healthz. 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"`
|
||||||
|
|
|
||||||
3
tools/webauthn-sign/go.mod
Normal file
3
tools/webauthn-sign/go.mod
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
module basicstack.de/proofedmesh/tools/webauthn-sign
|
||||||
|
|
||||||
|
go 1.23
|
||||||
133
tools/webauthn-sign/main.go
Normal file
133
tools/webauthn-sign/main.go
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
// Command webauthn-sign is a Phase-1 smoke-test helper. It performs the
|
||||||
|
// two WebAuthn crypto operations the shell driver cannot easily do with
|
||||||
|
// openssl: generate an Ed25519 keypair (so the approval-service can
|
||||||
|
// pre-register a mock credential) and produce a WebAuthn PublicKey
|
||||||
|
// assertion over a server-supplied challenge (so the smoke test can
|
||||||
|
// drive /api/v1/approvals/confirm without a browser).
|
||||||
|
//
|
||||||
|
// Two subcommands:
|
||||||
|
//
|
||||||
|
// gen-key → {"priv_hex": "...", "pub_hex": "..."}
|
||||||
|
// sign-assertion [flags] → {"credential_id": "...", "client_data_json": "...",
|
||||||
|
// "authenticator_data": "...", "signature": "..."}
|
||||||
|
//
|
||||||
|
// Everything is base64url-encoded without padding, matching the wire
|
||||||
|
// format the approval-service's WebAuthn shim accepts.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ed25519"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if len(os.Args) < 2 {
|
||||||
|
die("usage: webauthn-sign {gen-key|sign-assertion}")
|
||||||
|
}
|
||||||
|
switch os.Args[1] {
|
||||||
|
case "gen-key":
|
||||||
|
runGenKey()
|
||||||
|
case "sign-assertion":
|
||||||
|
runSignAssertion(os.Args[2:])
|
||||||
|
default:
|
||||||
|
die("unknown subcommand %q", os.Args[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runGenKey() {
|
||||||
|
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
die("gen key: %v", err)
|
||||||
|
}
|
||||||
|
out := map[string]string{
|
||||||
|
"priv_hex": hex.EncodeToString(priv),
|
||||||
|
"pub_hex": hex.EncodeToString(pub),
|
||||||
|
"pub_b64url": base64.RawURLEncoding.EncodeToString(pub),
|
||||||
|
}
|
||||||
|
writeJSON(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runSignAssertion(argv []string) {
|
||||||
|
fs := flag.NewFlagSet("sign-assertion", flag.ExitOnError)
|
||||||
|
var (
|
||||||
|
privHex = fs.String("priv-hex", "", "hex-encoded 64-byte Ed25519 private key")
|
||||||
|
challengeB64u = fs.String("challenge-b64url", "", "base64url (no pad) WebAuthn challenge bytes")
|
||||||
|
rpID = fs.String("rp-id", "", "WebAuthn relying party id (hostname)")
|
||||||
|
origin = fs.String("origin", "", "browser origin (e.g. https://trust.basicstack.de)")
|
||||||
|
credentialID = fs.String("credential-id", "", "credential_id to emit on the wire; defaults to b64url(pub)")
|
||||||
|
)
|
||||||
|
if err := fs.Parse(argv); err != nil {
|
||||||
|
die("parse flags: %v", err)
|
||||||
|
}
|
||||||
|
if *privHex == "" || *challengeB64u == "" || *rpID == "" || *origin == "" {
|
||||||
|
die("sign-assertion requires -priv-hex, -challenge-b64url, -rp-id, -origin")
|
||||||
|
}
|
||||||
|
|
||||||
|
privRaw, err := hex.DecodeString(*privHex)
|
||||||
|
if err != nil {
|
||||||
|
die("decode priv-hex: %v", err)
|
||||||
|
}
|
||||||
|
if len(privRaw) != ed25519.PrivateKeySize {
|
||||||
|
die("priv-hex must decode to %d bytes, got %d", ed25519.PrivateKeySize, len(privRaw))
|
||||||
|
}
|
||||||
|
priv := ed25519.PrivateKey(privRaw)
|
||||||
|
pub := priv.Public().(ed25519.PublicKey)
|
||||||
|
credID := *credentialID
|
||||||
|
if credID == "" {
|
||||||
|
credID = base64.RawURLEncoding.EncodeToString(pub)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build clientDataJSON per the WebAuthn spec.
|
||||||
|
clientData := map[string]any{
|
||||||
|
"type": "webauthn.get",
|
||||||
|
"challenge": strings.TrimRight(*challengeB64u, "="),
|
||||||
|
"origin": *origin,
|
||||||
|
}
|
||||||
|
clientDataJSON, err := json.Marshal(clientData)
|
||||||
|
if err != nil {
|
||||||
|
die("marshal clientDataJSON: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// authenticator_data = sha256(rpID) || flags (0x01=UP) || signCount (4 bytes, zero).
|
||||||
|
rpHash := sha256.Sum256([]byte(*rpID))
|
||||||
|
authData := make([]byte, 0, 37)
|
||||||
|
authData = append(authData, rpHash[:]...)
|
||||||
|
authData = append(authData, 0x01)
|
||||||
|
authData = append(authData, 0, 0, 0, 0)
|
||||||
|
|
||||||
|
// Signed bytes = authenticator_data || sha256(clientDataJSON).
|
||||||
|
cdHash := sha256.Sum256(clientDataJSON)
|
||||||
|
signed := make([]byte, 0, len(authData)+len(cdHash))
|
||||||
|
signed = append(signed, authData...)
|
||||||
|
signed = append(signed, cdHash[:]...)
|
||||||
|
sig := ed25519.Sign(priv, signed)
|
||||||
|
|
||||||
|
out := map[string]string{
|
||||||
|
"credential_id": credID,
|
||||||
|
"client_data_json": base64.RawURLEncoding.EncodeToString(clientDataJSON),
|
||||||
|
"authenticator_data": base64.RawURLEncoding.EncodeToString(authData),
|
||||||
|
"signature": base64.RawURLEncoding.EncodeToString(sig),
|
||||||
|
}
|
||||||
|
writeJSON(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(v any) {
|
||||||
|
enc := json.NewEncoder(os.Stdout)
|
||||||
|
enc.SetIndent("", " ")
|
||||||
|
if err := enc.Encode(v); err != nil {
|
||||||
|
die("encode output: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func die(format string, args ...any) {
|
||||||
|
fmt.Fprintf(os.Stderr, "webauthn-sign: "+format+"\n", args...)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue