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>
133 lines
4.1 KiB
Go
133 lines
4.1 KiB
Go
// 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)
|
|
}
|