ProofedMesh/tools/webauthn-sign/main.go

134 lines
4.1 KiB
Go
Raw Normal View History

phase1-smoke: end-to-end harness + Forgejo workflow (DEV-613) 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
// 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)
}