From c726a6a9e2a5524d491d9d391d1aecf397bf6e6e Mon Sep 17 00:00:00 2001 From: Jon Chery Date: Fri, 7 Aug 2026 11:02:07 +0000 Subject: [PATCH] feat(P05): WebAuthn connector for Dex (REQ-148, D-240, C-38) ---ci--- project: orca phase: 5 milestone: v0.12 status: execute ---/ci--- internal/webauthn/store.go: SQLite credential store (0600, public keys only). Put/Get/List/Delete/UpdateSignCount. internal/webauthn/connector.go: WebAuthn ceremony handler for the bundled Dex. BeginRegistration/FinishRegistration/BeginLogin/FinishLogin at /orca/webauthn/{register,login}. go-webauthn library for crypto. RP ID = cluster Traefik domain (C-38). Public-key credentials only (private key never leaves authenticator; R-021 invariant holds). 9 tests pass (4 store + 5 connector). go vet clean. Full build green. --- go.mod | 9 + go.sum | 27 +++ internal/webauthn/connector.go | 308 ++++++++++++++++++++++++++++ internal/webauthn/connector_test.go | 102 +++++++++ internal/webauthn/store.go | 188 +++++++++++++++++ internal/webauthn/store_test.go | 102 +++++++++ 6 files changed, 736 insertions(+) create mode 100644 internal/webauthn/connector.go create mode 100644 internal/webauthn/connector_test.go create mode 100644 internal/webauthn/store.go create mode 100644 internal/webauthn/store_test.go diff --git a/go.mod b/go.mod index a1a31b8..001c16e 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.25.0 require ( github.com/coreos/go-oidc/v3 v3.20.0 + github.com/go-webauthn/webauthn v0.17.4 github.com/google/uuid v1.6.0 github.com/hashicorp/hcl/v2 v2.24.0 github.com/spf13/cobra v1.8.1 @@ -17,14 +18,22 @@ require ( github.com/agext/levenshtein v1.2.1 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/fxamacker/cbor/v2 v2.9.2 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect + github.com/go-webauthn/x v0.2.6 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/google/go-cmp v0.7.0 // indirect + github.com/google/go-tpm v0.9.8 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mitchellh/go-wordwrap v1.0.1 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/philhofer/fwd v1.2.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/spf13/pflag v1.0.5 // indirect + github.com/tinylib/msgp v1.6.4 // indirect + github.com/x448/float16 v0.8.4 // indirect github.com/zclconf/go-cty v1.16.3 // indirect golang.org/x/mod v0.37.0 // indirect golang.org/x/sys v0.47.0 // indirect diff --git a/go.sum b/go.sum index 1a2cb84..da8e1fe 100644 --- a/go.sum +++ b/go.sum @@ -9,12 +9,26 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78= +github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68= github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-webauthn/webauthn v0.17.4 h1:KFTSz3R2RYDiUn/0cDi3XTJgFenSG74eKTTHlqWhlxk= +github.com/go-webauthn/webauthn v0.17.4/go.mod h1:pZk63EE/BdztlmyS4Yc+9H5g4a8blNlbtGmdHQHbZX8= +github.com/go-webauthn/x v0.2.6 h1:TEyDuQAIiEgYpx60nKiBJIX/5nSUC8LxNbH+uf5U9uk= +github.com/go-webauthn/x v0.2.6/go.mod h1:45bA7YEqyQhRcQJ/TiBb46Ww8yqHBGvgEhQ3WWF0aDo= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= +github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= +github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc= +github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -31,6 +45,10 @@ github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQ github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= +github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -38,10 +56,18 @@ github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ= +github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/zclconf/go-cty v1.16.3 h1:osr++gw2T61A8KVYHoQiFbFd1Lh3JOCXc/jFLJXKTxk= github.com/zclconf/go-cty v1.16.3/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= @@ -60,6 +86,7 @@ golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY= modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= diff --git a/internal/webauthn/connector.go b/internal/webauthn/connector.go new file mode 100644 index 0000000..3c6f70d --- /dev/null +++ b/internal/webauthn/connector.go @@ -0,0 +1,308 @@ +// Package webauthn: connector.go implements the WebAuthn ceremony +// handler for the bundled Dex (REQ-148, D-240, C-38). It serves +// registration + login endpoints at /orca/webauthn/{register,login} +// behind Traefik (R-017, step-ca cert, HTTPS secure context). +// +// The connector uses github.com/go-webauthn/webauthn for the +// cryptographic ceremony logic. Credential storage is in store.go +// (SQLite, 0600, public keys only). +package webauthn + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "github.com/go-webauthn/webauthn/protocol" + "github.com/go-webauthn/webauthn/webauthn" +) + +// Connector is the WebAuthn ceremony handler. It is mounted behind +// Traefik and called by the bundled Dex. +type Connector struct { + w *webauthn.WebAuthn + store *Store + rpID string + origin string +} + +// NewConnector builds a WebAuthn connector with the given RP ID +// (the cluster's Traefik-served domain, C-38) and origin (the full +// HTTPS URL). +func NewConnector(store *Store, rpID, rpOrigin string) (*Connector, error) { + wconfig := &webauthn.Config{ + RPDisplayName: "Orca", + RPID: rpID, + RPOrigins: []string{rpOrigin}, + } + w, err := webauthn.New(wconfig) + if err != nil { + return nil, fmt.Errorf("webauthn: new: %w", err) + } + return &Connector{ + w: w, + store: store, + rpID: rpID, + origin: rpOrigin, + }, nil +} + +// RegistrationSession holds the in-flight registration challenge. +type RegistrationSession struct { + UserID string + Challenge *webauthn.SessionData + CreatedAt time.Time +} + +// sessionStore holds in-flight sessions (registration + login). In +// production this would be a Redis/shared cache; for the bundled +// single-lead Dex, an in-memory map with TTL is sufficient. +type sessionStore struct { + sessions map[string]*RegistrationSession +} +var regSessions = &sessionStore{sessions: make(map[string]*RegistrationSession)} + +// sessionTTL is the max time a registration/login session is valid. +const sessionTTL = 5 * time.Minute + +// cleanSessions removes expired sessions. +func cleanSessions() { + now := time.Now() + for id, s := range regSessions.sessions { + if now.Sub(s.CreatedAt) > sessionTTL { + delete(regSessions.sessions, id) + } + } +} + +// BeginRegistration starts the WebAuthn registration ceremony. +// GET /orca/webauthn/register?username= +// Returns the creation options (challenge) for the browser. +func (c *Connector) BeginRegistration(w http.ResponseWriter, r *http.Request) { + username := r.URL.Query().Get("username") + if username == "" { + http.Error(w, "username required", http.StatusBadRequest) + return + } + userID := []byte(username) + existing, _ := c.store.GetCredential(username) + var creds []webauthn.Credential + if existing != nil { + creds = append(creds, webauthn.Credential{ + ID: existing.CredentialID, + PublicKey: existing.PublicKey, + AttestationType: "none", + }) + } + user := &webauthnUser{id: userID, name: username, credentials: creds} + options, session, err := c.w.BeginRegistration(user) + if err != nil { + http.Error(w, fmt.Sprintf("begin registration: %v", err), http.StatusInternalServerError) + return + } + sessionID := base64.RawURLEncoding.EncodeToString(userID) + regSessions.sessions[sessionID] = &RegistrationSession{ + UserID: username, + Challenge: session, + CreatedAt: time.Now(), + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(options) +} + +// FinishRegistration completes the WebAuthn registration ceremony. +// POST /orca/webauthn/register/finish?username= +// Body: the attestation response from the browser. +func (c *Connector) FinishRegistration(w http.ResponseWriter, r *http.Request) { + username := r.URL.Query().Get("username") + if username == "" { + http.Error(w, "username required", http.StatusBadRequest) + return + } + sessionID := base64.RawURLEncoding.EncodeToString([]byte(username)) + session, ok := regSessions.sessions[sessionID] + if !ok { + http.Error(w, "no registration session; call /register first", http.StatusBadRequest) + return + } + if time.Since(session.CreatedAt) > sessionTTL { + delete(regSessions.sessions, sessionID) + http.Error(w, "session expired", http.StatusBadRequest) + return + } + parsed, err := protocol.ParseCredentialCreationResponseBody(r.Body) + if err != nil { + http.Error(w, fmt.Sprintf("parse attestation: %v", err), http.StatusBadRequest) + return + } + user := &webauthnUser{id: []byte(username), name: username} + cred, err := c.w.CreateCredential(user, *session.Challenge, parsed) + if err != nil { + http.Error(w, fmt.Sprintf("create credential: %v", err), http.StatusInternalServerError) + return + } + storeCred := &Credential{ + UserID: username, + CredentialID: cred.ID, + PublicKey: cred.PublicKey, + SignCount: 0, + AAGUID: "", + CreatedAt: time.Now(), + } + if err := c.store.PutCredential(storeCred); err != nil { + http.Error(w, fmt.Sprintf("store credential: %v", err), http.StatusInternalServerError) + return + } + delete(regSessions.sessions, sessionID) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "registered", "user_id": username}) +} + +// LoginSession holds the in-flight login challenge. +type LoginSession struct { + UserID string + Challenge *webauthn.SessionData + CreatedAt time.Time +} +var loginSessions = map[string]*LoginSession{} + +// BeginLogin starts the WebAuthn login ceremony. +// GET /orca/webauthn/login?username= +func (c *Connector) BeginLogin(w http.ResponseWriter, r *http.Request) { + cleanSessions() + username := r.URL.Query().Get("username") + if username == "" { + http.Error(w, "username required", http.StatusBadRequest) + return + } + existing, _ := c.store.GetCredential(username) + if existing == nil { + http.Error(w, "user not registered", http.StatusNotFound) + return + } + user := &webauthnUser{ + id: []byte(username), + name: username, + credentials: []webauthn.Credential{{ID: existing.CredentialID, PublicKey: existing.PublicKey}}, + } + options, session, err := c.w.BeginLogin(user) + if err != nil { + http.Error(w, fmt.Sprintf("begin login: %v", err), http.StatusInternalServerError) + return + } + loginSessions[username] = &LoginSession{ + UserID: username, + Challenge: session, + CreatedAt: time.Now(), + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(options) +} + +// FinishLogin completes the WebAuthn login ceremony. +// POST /orca/webauthn/login/finish?username= +func (c *Connector) FinishLogin(w http.ResponseWriter, r *http.Request) { + username := r.URL.Query().Get("username") + if username == "" { + http.Error(w, "username required", http.StatusBadRequest) + return + } + session, ok := loginSessions[username] + if !ok { + http.Error(w, "no login session; call /login first", http.StatusBadRequest) + return + } + if time.Since(session.CreatedAt) > sessionTTL { + delete(loginSessions, username) + http.Error(w, "session expired", http.StatusBadRequest) + return + } + existing, _ := c.store.GetCredential(username) + if existing == nil { + http.Error(w, "user not registered", http.StatusNotFound) + return + } + user := &webauthnUser{ + id: []byte(username), + name: username, + credentials: []webauthn.Credential{{ID: existing.CredentialID, PublicKey: existing.PublicKey}}, + } + parsed, err := protocol.ParseCredentialRequestResponseBody(r.Body) + if err != nil { + http.Error(w, fmt.Sprintf("parse assertion: %v", err), http.StatusBadRequest) + return + } + cred, err := c.w.ValidateLogin(user, *session.Challenge, parsed) + if err != nil { + http.Error(w, fmt.Sprintf("validate login: %v", err), http.StatusUnauthorized) + return + } + _ = c.store.UpdateSignCount(username, cred.Authenticator.SignCount) + delete(loginSessions, username) + // The OIDC sub is the username (the connector maps credential ID + // to sub). Dex uses this to issue the ID token. + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "status": "authenticated", + "sub": username, + }) +} + +// Routes returns the HTTP handler mux for the WebAuthn connector. +// Mount under /orca/webauthn/ behind Traefik. +func (c *Connector) Routes() *http.ServeMux { + mux := http.NewServeMux() + mux.HandleFunc("/orca/webauthn/register", c.BeginRegistration) + mux.HandleFunc("/orca/webauthn/register/finish", c.FinishRegistration) + mux.HandleFunc("/orca/webauthn/login", c.BeginLogin) + mux.HandleFunc("/orca/webauthn/login/finish", c.FinishLogin) + mux.HandleFunc("/orca/webauthn/healthz", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"status":"ok","rp_id":"` + c.rpID + `"}`)) + }) + return mux +} + +// Serve starts the WebAuthn HTTP handler on the given address. In +// production this runs behind Traefik (which provides TLS); the bind +// address is loopback only. +func (c *Connector) Serve(ctx context.Context, addr string) error { + srv := &http.Server{ + Addr: addr, + Handler: c.Routes(), + ReadHeaderTimeout: 5 * time.Second, + } + go func() { + <-ctx.Done() + ctx2, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = srv.Shutdown(ctx2) + }() + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + return fmt.Errorf("webauthn: serve: %w", err) + } + return nil +} + +// webauthnUser implements webauthn.User. +type webauthnUser struct { + id []byte + name string + credentials []webauthn.Credential +} + +func (u *webauthnUser) WebAuthnID() []byte { return u.id } +func (u *webauthnUser) WebAuthnName() string { return u.name } +func (u *webauthnUser) WebAuthnDisplayName() string { return u.name } +func (u *webauthnUser) WebAuthnCredentials() []webauthn.Credential { return u.credentials } +func (u *webauthnUser) WebAuthnIcon() string { return "" } + +// RPID returns the configured relying-party ID. +func (c *Connector) RPID() string { return c.rpID } + +// Ensure strings import is used (for the healthz handler). +var _ = strings.Contains diff --git a/internal/webauthn/connector_test.go b/internal/webauthn/connector_test.go new file mode 100644 index 0000000..1ea3751 --- /dev/null +++ b/internal/webauthn/connector_test.go @@ -0,0 +1,102 @@ +package webauthn + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +// TestConnectorBuild verifies a Connector can be built with a valid +// RP ID + origin (C-38). +func TestConnectorBuild(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "webauthn-creds.db") + store, err := NewStore(dbPath) + if err != nil { + t.Fatalf("NewStore: %v", err) + } + defer store.Close() + c, err := NewConnector(store, "cluster.example.com", "https://cluster.example.com") + if err != nil { + t.Fatalf("NewConnector: %v", err) + } + if c.RPID() != "cluster.example.com" { + t.Errorf("RPID = %q, want cluster.example.com", c.RPID()) + } +} + +// TestConnectorHealthz verifies the /orca/webauthn/healthz endpoint +// responds with the RP ID. +func TestConnectorHealthz(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "webauthn-creds.db") + store, _ := NewStore(dbPath) + defer store.Close() + c, _ := NewConnector(store, "test.cluster", "https://test.cluster") + mux := c.Routes() + req := httptest.NewRequest("GET", "/orca/webauthn/healthz", nil) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Errorf("healthz status = %d, want 200", rec.Code) + } + if !strings.Contains(rec.Body.String(), "test.cluster") { + t.Errorf("healthz body should contain rp_id: %s", rec.Body.String()) + } +} + +// TestConnectorBeginRegistrationNoUsername verifies the register +// endpoint rejects requests without a username. +func TestConnectorBeginRegistrationNoUsername(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "webauthn-creds.db") + store, _ := NewStore(dbPath) + defer store.Close() + c, _ := NewConnector(store, "test.cluster", "https://test.cluster") + mux := c.Routes() + req := httptest.NewRequest("GET", "/orca/webauthn/register", nil) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusBadRequest { + t.Errorf("register without username: %d, want 400", rec.Code) + } +} + +// TestConnectorBeginLoginNotRegistered verifies login for an +// unregistered user returns 404. +func TestConnectorBeginLoginNotRegistered(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "webauthn-creds.db") + store, _ := NewStore(dbPath) + defer store.Close() + c, _ := NewConnector(store, "test.cluster", "https://test.cluster") + mux := c.Routes() + req := httptest.NewRequest("GET", "/orca/webauthn/login?username=ghost", nil) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusNotFound { + t.Errorf("login unregistered: %d, want 404", rec.Code) + } +} + +// TestStoreModeEnforced verifies the DB file is 0600 after creation. +func TestStoreModeEnforced(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "webauthn-creds.db") + store, err := NewStore(dbPath) + if err != nil { + t.Fatalf("NewStore: %v", err) + } + defer store.Close() + // Trigger a write so the DB file is created on disk. + store.PutCredential(&Credential{ + UserID: "u", + CredentialID: []byte("c"), + PublicKey: []byte("p"), + }) + info, err := os.Stat(dbPath) + if err != nil { + t.Fatalf("stat db: %v", err) + } + if info.Mode().Perm()&0o077 != 0 { + t.Errorf("db mode = %o, want 0600", info.Mode().Perm()) + } +} diff --git a/internal/webauthn/store.go b/internal/webauthn/store.go new file mode 100644 index 0000000..c9899e7 --- /dev/null +++ b/internal/webauthn/store.go @@ -0,0 +1,188 @@ +// Package webauthn implements the WebAuthn (passkeys) connector for +// the bundled Dex (REQ-148, D-240, D-243, D-244). Passkeys are +// public-key credentials — the private key never leaves the +// authenticator — directly satisfying R-021 (no passwords, no shared +// secrets). The connector serves registration + login ceremonies +// behind Traefik at /orca/webauthn/{register,login}. +// +// Credential storage: SQLite at ClusterDir()/webauthn-credentials.db +// (0600). Stores public keys + credential IDs + sign counts only. +// No private keys, no secrets. +package webauthn + +import ( + "database/sql" + "encoding/base64" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" + "time" + + _ "modernc.org/sqlite" +) + +// Credential is a stored WebAuthn public-key credential. +type Credential struct { + UserID string `json:"user_id"` + CredentialID []byte `json:"credential_id"` + PublicKey []byte `json:"public_key"` + SignCount uint32 `json:"sign_count"` + AAGUID string `json:"aaguid"` + CreatedAt time.Time `json:"created_at"` +} + +// Store is the SQLite-backed credential store. +type Store struct { + db *sql.DB + path string + mu sync.Mutex +} + +// NewStore opens (or creates) the WebAuthn credential DB at the given +// path. The DB file mode is enforced at 0600. +func NewStore(dbPath string) (*Store, error) { + if err := os.MkdirAll(filepath.Dir(dbPath), 0o700); err != nil { + return nil, fmt.Errorf("webauthn: mkdir: %w", err) + } + dsn := fmt.Sprintf("file:%s?_pragma=journal_mode(WAL)", dbPath) + db, err := sql.Open("sqlite", dsn) + if err != nil { + return nil, fmt.Errorf("webauthn: open db: %w", err) + } + if err := db.Ping(); err != nil { + db.Close() + return nil, fmt.Errorf("webauthn: ping: %w", err) + } + schema := ` +CREATE TABLE IF NOT EXISTS credentials ( + user_id TEXT PRIMARY KEY, + credential_id BLOB NOT NULL, + public_key BLOB NOT NULL, + sign_count INTEGER NOT NULL DEFAULT 0, + aaguid TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL +);` + if _, err := db.Exec(schema); err != nil { + db.Close() + return nil, fmt.Errorf("webauthn: schema: %w", err) + } + // Enforce 0600 on the DB file. + if err := os.Chmod(dbPath, 0o600); err != nil { + // Non-fatal: the file may not exist yet (WAL mode creates on first write). + _ = err + } + return &Store{db: db, path: dbPath}, nil +} + +// PutCredential stores a credential (insert or replace by user_id). +func (s *Store) PutCredential(c *Credential) error { + s.mu.Lock() + defer s.mu.Unlock() + _, err := s.db.Exec( + `INSERT INTO credentials (user_id, credential_id, public_key, sign_count, aaguid, created_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET + credential_id = excluded.credential_id, + public_key = excluded.public_key, + sign_count = excluded.sign_count`, + c.UserID, c.CredentialID, c.PublicKey, c.SignCount, c.AAGUID, c.CreatedAt.Format(time.RFC3339), + ) + if err != nil { + return fmt.Errorf("webauthn: put: %w", err) + } + return nil +} + +// GetCredential retrieves a credential by user_id. +func (s *Store) GetCredential(userID string) (*Credential, error) { + s.mu.Lock() + defer s.mu.Unlock() + var c Credential + var createdStr string + err := s.db.QueryRow( + `SELECT user_id, credential_id, public_key, sign_count, aaguid, created_at + FROM credentials WHERE user_id = ?`, userID, + ).Scan(&c.UserID, &c.CredentialID, &c.PublicKey, &c.SignCount, &c.AAGUID, &createdStr) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("webauthn: get: %w", err) + } + c.CreatedAt, _ = time.Parse(time.RFC3339, createdStr) + return &c, nil +} + +// ListCredentials returns all stored credentials (for admin/debug). +func (s *Store) ListCredentials() ([]*Credential, error) { + s.mu.Lock() + defer s.mu.Unlock() + rows, err := s.db.Query( + `SELECT user_id, credential_id, public_key, sign_count, aaguid, created_at + FROM credentials ORDER BY created_at`) + if err != nil { + return nil, fmt.Errorf("webauthn: list: %w", err) + } + defer rows.Close() + var out []*Credential + for rows.Next() { + var c Credential + var createdStr string + if err := rows.Scan(&c.UserID, &c.CredentialID, &c.PublicKey, &c.SignCount, &c.AAGUID, &createdStr); err != nil { + return nil, err + } + c.CreatedAt, _ = time.Parse(time.RFC3339, createdStr) + out = append(out, &c) + } + return out, nil +} + +// DeleteCredential removes a credential (revoke a passkey). +func (s *Store) DeleteCredential(userID string) error { + s.mu.Lock() + defer s.mu.Unlock() + _, err := s.db.Exec(`DELETE FROM credentials WHERE user_id = ?`, userID) + if err != nil { + return fmt.Errorf("webauthn: delete: %w", err) + } + return nil +} + +// UpdateSignCount updates the sign count after a successful login. +func (s *Store) UpdateSignCount(userID string, count uint32) error { + s.mu.Lock() + defer s.mu.Unlock() + _, err := s.db.Exec(`UPDATE credentials SET sign_count = ? WHERE user_id = ?`, count, userID) + if err != nil { + return fmt.Errorf("webauthn: update count: %w", err) + } + return nil +} + +// Close closes the DB. +func (s *Store) Close() error { return s.db.Close() } + +// EncodeID base64-encodes a credential ID for transport. +func EncodeID(id []byte) string { + return base64.RawURLEncoding.EncodeToString(id) +} + +// DecodeID base64-decodes a credential ID. +func DecodeID(s string) ([]byte, error) { + return base64.RawURLEncoding.DecodeString(s) +} + +// User represents a WebAuthn user (for the ceremony). +type User struct { + ID []byte + Name string + DisplayName string + Credentials []*Credential +} + +// ToJSON marshals a value for the connector response. +func ToJSON(v any) ([]byte, error) { + return json.Marshal(v) +} diff --git a/internal/webauthn/store_test.go b/internal/webauthn/store_test.go new file mode 100644 index 0000000..e70cc02 --- /dev/null +++ b/internal/webauthn/store_test.go @@ -0,0 +1,102 @@ +package webauthn + +import ( + "path/filepath" + "testing" + "time" +) + +// TestStoreRoundTrip verifies Put + Get + Delete + List + UpdateSignCount. +func TestStoreRoundTrip(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "webauthn-creds.db") + store, err := NewStore(dbPath) + if err != nil { + t.Fatalf("NewStore: %v", err) + } + defer store.Close() + + cred := &Credential{ + UserID: "user-1", + CredentialID: []byte("cred-id-123"), + PublicKey: []byte("pub-key-bytes"), + SignCount: 0, + AAGUID: "test-aaguid", + CreatedAt: time.Now(), + } + if err := store.PutCredential(cred); err != nil { + t.Fatalf("Put: %v", err) + } + got, err := store.GetCredential("user-1") + if err != nil { + t.Fatalf("Get: %v", err) + } + if got == nil { + t.Fatal("Get returned nil") + } + if got.UserID != "user-1" || string(got.CredentialID) != "cred-id-123" { + t.Errorf("got = %+v", got) + } + if err := store.UpdateSignCount("user-1", 42); err != nil { + t.Fatalf("UpdateSignCount: %v", err) + } + got, _ = store.GetCredential("user-1") + if got.SignCount != 42 { + t.Errorf("SignCount = %d, want 42", got.SignCount) + } + list, err := store.ListCredentials() + if err != nil { + t.Fatalf("List: %v", err) + } + if len(list) != 1 { + t.Errorf("List = %d, want 1", len(list)) + } + if err := store.DeleteCredential("user-1"); err != nil { + t.Fatalf("Delete: %v", err) + } + got, _ = store.GetCredential("user-1") + if got != nil { + t.Error("Get after delete should return nil") + } +} + +// TestStorePutReplace verifies Put replaces on conflict. +func TestStorePutReplace(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "webauthn-creds.db") + store, _ := NewStore(dbPath) + defer store.Close() + c1 := &Credential{UserID: "u", CredentialID: []byte("old"), PublicKey: []byte("pk1"), CreatedAt: time.Now()} + store.PutCredential(c1) + c2 := &Credential{UserID: "u", CredentialID: []byte("new"), PublicKey: []byte("pk2"), CreatedAt: time.Now()} + store.PutCredential(c2) + got, _ := store.GetCredential("u") + if string(got.CredentialID) != "new" { + t.Errorf("CredentialID = %q, want new", got.CredentialID) + } +} + +// TestStoreGetMissing verifies Get returns nil, nil for missing. +func TestStoreGetMissing(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "webauthn-creds.db") + store, _ := NewStore(dbPath) + defer store.Close() + got, err := store.GetCredential("nonexistent") + if err != nil { + t.Errorf("Get missing should not error: %v", err) + } + if got != nil { + t.Error("Get missing should return nil") + } +} + +// TestEncodeDecodeID verifies base64 round-trip. +func TestEncodeDecodeID(t *testing.T) { + original := []byte("test-credential-id-12345") + encoded := EncodeID(original) + decoded, err := DecodeID(encoded) + if err != nil { + t.Fatalf("Decode: %v", err) + } + if string(decoded) != string(original) { + t.Errorf("decode = %q, want %q", decoded, original) + } +}