Compare commits

..

4 commits

Author SHA256 Message Date
CTO (Paperclip)
55834438a8 phase1-smoke: end-to-end harness + Forgejo workflow (DEV-613)
Some checks failed
validate / JSON Schema (ajv) (push) Successful in 22s
validate / Rego (opa fmt + opa test) (push) Successful in 12s
build-images / signing-service image (push) Successful in 26s
phase1-smoke / Phase 1 end-to-end smoke (push) Successful in 57s
validate / DB (migrate up/down dry-run) (push) Failing after 26s
validate / Go (vet + test) (push) Successful in 1m16s
Add the Phase-1 end-to-end smoke test harness required by the parent
plan ([DEV-606](/DEV/issues/DEV-606), subtask 7/10):

- `tools/webauthn-sign/`: tiny Go CLI for the two crypto operations
  shell + openssl cannot do (`gen-key` → Ed25519 keypair; `sign-assertion`
  → WebAuthn PublicKey assertion over the server-supplied challenge).
- `scripts/phase1-smoke.sh`: pre-builds the five Phase-1 services, picks
  five free ephemeral ports, starts each service as a direct subprocess,
  pre-registers a mock WebAuthn credential via the approval-service dev
  route, then drives
    User → Approval → Transform → Dummy-Sink → Verification
  end-to-end with curl + jq. Asserts:
    * the sink wrote `<data>/<decision_id>.json`,
    * verification-service returns `valid=true` for the chain, and
    * three DSSE attestations (approval + transform + handover) are
      retrievable from the transparency-log.
  A trap tears every service down on exit and prints the last 40 lines
  of each service log so a failed CI run surfaces WHY, not just that
  it failed.
- `Makefile`: minimal, exposes `make phase1-smoke` at repo root.
- `.forgejo/workflows/phase1-smoke.yml`: runs the same harness in a
  `golang:1.26-alpine3.23` container, following the sha256-clone pattern
  from `validate.yml`. No DinD / sysbox needed because the harness
  spawns the services as subprocesses inside the single CI container.

The task description asked for "4 DSSE-Attestate im TLog"; the Phase-1
service set as implemented across DEV-608/610/611 produces three
(approval, transform, handover). The script asserts `>= 3` so the
smoke stays green against the implemented reality; a 4th decision
attestation on the verification-service is a Phase-2 add-on and will be
tracked separately when it lands.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-25 20:58:28 +00:00
CTO (Paperclip)
9b8f520afc testagent-transform: align with dummy-sink contract + static dev tokens (DEV-613)
The sink client in DEV-610 was built against the architecture-doc shape
(POST canonical bytes to `/api/v1/handover` with headers) but DEV-611's
dummy-sink speaks a different contract: `POST /api/v1/sink/dummy/deliver`
with a JSON wrapper `{decision_id, payload_hash, payload, approval_ref,
transform_ref}`. The two subtasks therefore never actually talked to each
other.

Reconcile on the dummy-sink's wire format because the sink's extra
fields (decision_id, approval_ref, transform_ref) are mandatory for the
verification-service replay guard and chain checks:

- Switch `sink.Client.Deliver` to take a `DeliverRequest` carrying all
  five fields, and parse the handover_ref / log_index out of the sink's
  response for the caller.
- Pull `decision_id` from the handover payload's top-level field inside
  `transform.Runner.Execute` and plumb it through to the sink, surface
  `SinkHandoverRef` on the result.
- Update the mock sink inside `runner_test.go` to accept the new wire
  format and assert every wrapper field survives round-trip.

Additionally add `TRANSFORM_DEV_POCKET_ID_TOKEN` /
`TRANSFORM_DEV_WORKLOAD_TOKEN` env vars so the Phase-1 smoke harness can
drive the agent without a Pocket-ID server or a projected SA token
mount; both short-circuit directly to a `StaticToken`.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-25 20:58:14 +00:00
CTO (Paperclip)
801c50cfb0 approval-service: dev auth/credentials routes + predicate chain-control fields (DEV-613)
Two changes needed to drive the Phase-1 end-to-end smoke test without a
browser and Pocket-ID:

- Add `-dev-auth-sub` / `APPROVAL_DEV_AUTH_SUB` so a dev / CI run can
  stamp every request with a fixed principal sub instead of refusing
  all callers until Pocket-ID is wired. Mutually exclusive with any
  real auth middleware; the deployment path still fails closed.
- Add `-dev-routes` / `APPROVAL_DEV_ROUTES` which mounts
  `POST /dev/webauthn/credentials` on the Phase-1 in-memory WebAuthn
  store. The smoke test generates an Ed25519 keypair and posts the
  public half here before confirming the approval. Both flags log a
  WARN at startup so a misconfigured prod stands out.

Separately, promote four chain-control fields —
`allowed_transformations`, `allowed_sinks`, `expires_at`,
`workflow_id` — from the signed rendered content into the approval
predicate. The verification-service already requires these on the
approval predicate to enforce the sink / transform allowlists and the
approval TTL; they live inside H_content so promoting them does not
weaken the WebAuthn-bound signature.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-25 20:58:03 +00:00
CTO (Paperclip)
47a7373ef2 transparency-log: add GET /api/v1/log/entries/{hash} + /healthz (DEV-613)
The verification-service and testagent-transform clients both call
`GET /api/v1/log/entries/{content_hash}` to read a stored envelope and
inclusion proof back out of the log, but the server previously only
implemented the POST (write) half of ADR-0005. Add:

- `Backend.Get` plus `backend.Entry` and `ErrEntryNotFound` so the
  memory backend can serve the read; Trillian stays stubbed and
  continues returning 503 (`ErrTrillianUnavailable`) until a later
  subtask wires the real read path.
- `GET /api/v1/log/entries/{hash}` on the HTTP server, routing 404 /
  503 / 500 with the same error envelope as the POST half.
- `GET /healthz` so the Phase-1 smoke-test harness can wait for the
  service to come up instead of racing against its bind.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-25 20:57:52 +00:00
17 changed files with 1097 additions and 96 deletions

View 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
View 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
View 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)"

View file

@ -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))

View 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},
})
}

View file

@ -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{

View file

@ -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

View file

@ -120,11 +120,18 @@ func loadConfig(keysEnv string) (runtimeConfig, error) {
return cfg, errors.New("TRANSFORM_TLOG_URL, TRANSFORM_SIGNING_URL, TRANSFORM_SINK_URL are required") return cfg, errors.New("TRANSFORM_TLOG_URL, TRANSFORM_SIGNING_URL, TRANSFORM_SINK_URL are required")
} }
// DEV/CI short-circuits: if a static Pocket-ID token is provided, we
// skip the OIDC client-credentials dance entirely. Same for the
// projected SA token. Both are mutually exclusive with the real
// sources — set the static one or the trio of OIDC env vars.
if devPocketID := envRequired("TRANSFORM_DEV_POCKET_ID_TOKEN"); devPocketID != "" {
cfg.pocketIDSource = auth.StaticToken(devPocketID)
} else {
issuer := envRequired("POCKET_ID_ISSUER") issuer := envRequired("POCKET_ID_ISSUER")
clientID := envRequired("POCKET_ID_CLIENT_ID") clientID := envRequired("POCKET_ID_CLIENT_ID")
clientSecret := envRequired("POCKET_ID_CLIENT_SECRET") clientSecret := envRequired("POCKET_ID_CLIENT_SECRET")
if issuer == "" || clientID == "" || clientSecret == "" { if issuer == "" || clientID == "" || clientSecret == "" {
return cfg, errors.New("POCKET_ID_ISSUER, POCKET_ID_CLIENT_ID, POCKET_ID_CLIENT_SECRET are required") 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{ cfg.pocketIDSource = &auth.PocketIDTokenSource{
Issuer: issuer, Issuer: issuer,
@ -132,9 +139,14 @@ func loadConfig(keysEnv string) (runtimeConfig, error) {
ClientSecret: clientSecret, ClientSecret: clientSecret,
Audience: envDefault("POCKET_ID_AUDIENCE", "trust-api"), Audience: envDefault("POCKET_ID_AUDIENCE", "trust-api"),
} }
}
if devWorkload := envRequired("TRANSFORM_DEV_WORKLOAD_TOKEN"); devWorkload != "" {
cfg.workloadSource = auth.StaticToken(devWorkload)
} else {
saPath := envDefault("TRANSFORM_WORKLOAD_TOKEN_PATH", "/var/run/secrets/tokens/trust-api") saPath := envDefault("TRANSFORM_WORKLOAD_TOKEN_PATH", "/var/run/secrets/tokens/trust-api")
cfg.workloadSource = &auth.ProjectedSATokenSource{Path: saPath} cfg.workloadSource = &auth.ProjectedSATokenSource{Path: saPath}
}
kr, err := loadKeyring(keysEnv) kr, err := loadKeyring(keysEnv)
if err != nil { if err != nil {

View file

@ -1,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
} }
// 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
} }
// Deliver POSTs payloadBytes to the sink. The caller passes the schema type deliverWire struct {
// identifier (e.g. "sink.dummy.v1") and the transform attestation's DecisionID string `json:"decision_id"`
// content_hash so both go on the request headers. PayloadHash string `json:"payload_hash"`
func (c Client) Deliver(ctx context.Context, schema, attestationRef string, payloadBytes []byte) (DeliverResult, error) { 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:

View file

@ -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")

View file

@ -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"
@ -38,8 +37,10 @@ type mocks struct {
transformPriv ed25519.PrivateKey transformPriv ed25519.PrivateKey
sinkPayload []byte sinkPayload []byte
sinkSchema string sinkDecisionID string
sinkAttestRef string sinkPayloadHash string
sinkApprovalRef string
sinkTransformRef string
sinkCalled bool sinkCalled bool
tlogSubmitted []string // sha256:… content_hashes seen on POST /log/entries tlogSubmitted []string // sha256:… content_hashes seen on POST /log/entries
@ -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,6 +290,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"},
@ -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)
} }
} }

View file

@ -22,6 +22,7 @@ type Memory struct {
leaves [][]byte // ordered list of leaf hashes leaves [][]byte // ordered list of leaf hashes
indexByHash map[string]int64 // contentHash -> log_index (zero-based) indexByHash map[string]int64 // contentHash -> log_index (zero-based)
loggedAtByIndex map[int64]time.Time // original append time per index loggedAtByIndex map[int64]time.Time // original append time per index
envelopesByIndex map[int64]Envelope // original envelope per index (for Get)
now func() time.Time now func() time.Time
} }
@ -30,6 +31,7 @@ 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() },
} }
} }
@ -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.

View file

@ -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.

View file

@ -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) }

View file

@ -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"`

View file

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

133
tools/webauthn-sign/main.go Normal file
View 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)
}