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