5232fcb808
R-023: Zero-trust enforcement operationally wired. ACL enforcement (C-45 staged rollout): - acl.Check wired into all 5 daemon handlers (dispatch/jobs/nodes/tasks) - health endpoints exempt (liveness probes not gated) - ACL log-only mode default (config acl.enforce=false); enforce after bootstrap ACL verified - sshpush auth: ORCA_OIDC_TOKEN validated against JWKS before apply - txn apply: Authorize hook validates OIDC token before running pull - acl.json mode 0600 (was 0644) - flock on acl.json for concurrent grant/revoke - bootstrap ACL: init grants cluster-admin to orca-admins group + SVID Audit actor identity: - currentActor reads OIDC sub from credentials.json (was hardcoded "cli") - threaded through all audit.Record calls via context WebAuthn registration auth: - BeginRegistration/FinishRegistration require authenticated session - fail-closed 401 when no authFunc configured New files: internal/daemon/acl.go, internal/cli/authactor.go, internal/engine/actor.go, internal/identity/authtoken.go, internal/sshpush/auth.go, internal/txn/auth_test.go ---ci--- project: orca phase: 4 milestone: v0.13 status: complete requirements: covered: [153] ---/ci---
374 lines
13 KiB
Go
374 lines
13 KiB
Go
// 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
|
|
|
|
// authFunc validates an authenticated session for registration
|
|
// (P04, T9, C-45). When non-nil, BeginRegistration and
|
|
// FinishRegistration require a valid session (cookie or bearer
|
|
// token) before proceeding; a nil/error result yields 401. When
|
|
// nil (fail-closed for new deployments), registration is rejected
|
|
// with 401 — the operator MUST wire an authFunc before enabling
|
|
// registration. This fixes C-45's unauthenticated-registration
|
|
// hole: previously anyone could register a credential for any
|
|
// username.
|
|
authFunc func(r *http.Request) (authenticated bool, existingUser string, err error)
|
|
}
|
|
|
|
// 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) {
|
|
return NewConnectorWithAuth(store, rpID, rpOrigin, nil)
|
|
}
|
|
|
|
// NewConnectorWithAuth builds a Connector with an explicit auth
|
|
// function for registration (P04, T9). The authFunc returns whether
|
|
// the request carries a valid authenticated session and, optionally,
|
|
// the existing user identity (so registration can be scoped to the
|
|
// authenticated user). When authFunc is nil, registration is fail-
|
|
// closed (401).
|
|
func NewConnectorWithAuth(store *Store, rpID, rpOrigin string, authFunc func(r *http.Request) (bool, string, error)) (*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,
|
|
authFunc: authFunc,
|
|
}, 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// requireAuth checks the request for an authenticated session. When
|
|
// c.authFunc is nil, registration is fail-closed (401). When the
|
|
// authFunc returns false or an error, the request is rejected with
|
|
// 401 Unauthorized. Returns true when the request is authenticated.
|
|
//
|
|
// The authFunc may also return the existing user identity so
|
|
// registration can be scoped (a user can only register credentials
|
|
// for their own account); the existing-user scoping is enforced by
|
|
// the caller via the username query param match (a future phase will
|
|
// wire the authenticated user as the registration target instead of
|
|
// accepting a free-form username).
|
|
func (c *Connector) requireAuth(w http.ResponseWriter, r *http.Request) bool {
|
|
if c.authFunc == nil {
|
|
http.Error(w, "registration requires authentication (no auth function configured)", http.StatusUnauthorized)
|
|
return false
|
|
}
|
|
ok, _, err := c.authFunc(r)
|
|
if err != nil || !ok {
|
|
http.Error(w, "authentication required", http.StatusUnauthorized)
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// SetAuthFunc sets the registration auth function (P04, T9). Allows
|
|
// callers to wire the auth check after construction (e.g. when the
|
|
// session store is initialized later).
|
|
func (c *Connector) SetAuthFunc(f func(r *http.Request) (bool, string, error)) {
|
|
c.authFunc = f
|
|
}
|
|
|
|
// BeginRegistration starts the WebAuthn registration ceremony.
|
|
// GET /orca/webauthn/register?username=<name>
|
|
// Returns the creation options (challenge) for the browser.
|
|
func (c *Connector) BeginRegistration(w http.ResponseWriter, r *http.Request) {
|
|
// P04 (T9, C-45): require an authenticated session before
|
|
// allowing registration. Without this, anyone could register a
|
|
// credential for any username. When authFunc is nil, fail-closed.
|
|
if !c.requireAuth(w, r) {
|
|
return
|
|
}
|
|
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=<name>
|
|
// Body: the attestation response from the browser.
|
|
func (c *Connector) FinishRegistration(w http.ResponseWriter, r *http.Request) {
|
|
// P04 (T9): require an authenticated session for finish too.
|
|
if !c.requireAuth(w, r) {
|
|
return
|
|
}
|
|
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=<name>
|
|
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=<name>
|
|
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
|