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