// Package server: WebAuthn/passkey REST API handlers. // // Security design: // - Registration requires re-authentication (current password) to prevent a // stolen session token from enrolling attacker-controlled credentials. // - Challenge sessions are stored in a sync.Map with a 120-second TTL and are // single-use (deleted on consumption) to prevent replay attacks. // - All credential material (IDs, public keys) is encrypted at rest with // AES-256-GCM via the vault master key. // - Sign counter validation detects cloned authenticators. // - Login endpoints return generic errors to prevent credential enumeration. package server import ( "bytes" "encoding/json" "fmt" "net/http" "strconv" "sync" "time" "github.com/go-webauthn/webauthn/protocol" libwebauthn "github.com/go-webauthn/webauthn/webauthn" "git.wntrmute.dev/kyle/mcias/internal/audit" "git.wntrmute.dev/kyle/mcias/internal/auth" "git.wntrmute.dev/kyle/mcias/internal/crypto" "git.wntrmute.dev/kyle/mcias/internal/middleware" "git.wntrmute.dev/kyle/mcias/internal/model" "git.wntrmute.dev/kyle/mcias/internal/token" mciaswebauthn "git.wntrmute.dev/kyle/mcias/internal/webauthn" ) const ( webauthnCeremonyTTL = 120 * time.Second webauthnCleanupPeriod = 5 * time.Minute webauthnCeremonyNonce = 16 // 128 bits of entropy ) // webauthnCeremony holds a pending registration or login ceremony. type webauthnCeremony struct { expiresAt time.Time session *libwebauthn.SessionData accountID int64 // 0 for discoverable login } // pendingWebAuthnCeremonies is the package-level ceremony store. // Stored on the Server struct would require adding fields; using a // package-level map is consistent with the TOTP/token pattern from the UI. var pendingWebAuthnCeremonies sync.Map //nolint:gochecknoglobals func init() { go cleanupWebAuthnCeremonies() } func cleanupWebAuthnCeremonies() { ticker := time.NewTicker(webauthnCleanupPeriod) defer ticker.Stop() for range ticker.C { now := time.Now() pendingWebAuthnCeremonies.Range(func(key, value any) bool { c, ok := value.(*webauthnCeremony) if !ok || now.After(c.expiresAt) { pendingWebAuthnCeremonies.Delete(key) } return true }) } } func storeWebAuthnCeremony(session *libwebauthn.SessionData, accountID int64) (string, error) { raw, err := crypto.RandomBytes(webauthnCeremonyNonce) if err != nil { return "", fmt.Errorf("webauthn: generate ceremony nonce: %w", err) } nonce := fmt.Sprintf("%x", raw) pendingWebAuthnCeremonies.Store(nonce, &webauthnCeremony{ session: session, accountID: accountID, expiresAt: time.Now().Add(webauthnCeremonyTTL), }) return nonce, nil } func consumeWebAuthnCeremony(nonce string) (*webauthnCeremony, bool) { v, ok := pendingWebAuthnCeremonies.LoadAndDelete(nonce) if !ok { return nil, false } c, ok2 := v.(*webauthnCeremony) if !ok2 || time.Now().After(c.expiresAt) { return nil, false } return c, true } // ---- Registration ---- type webauthnRegisterBeginRequest struct { Password string `json:"password"` Name string `json:"name"` } type webauthnRegisterBeginResponse struct { Nonce string `json:"nonce"` Options json.RawMessage `json:"options"` } // handleWebAuthnRegisterBegin starts a WebAuthn credential registration ceremony. // // Security (SEC-01): the current password is required to prevent a stolen // session from enrolling attacker-controlled credentials. func (s *Server) handleWebAuthnRegisterBegin(w http.ResponseWriter, r *http.Request) { if !s.cfg.WebAuthnEnabled() { middleware.WriteError(w, http.StatusNotFound, "WebAuthn not configured", "not_found") return } claims := middleware.ClaimsFromContext(r.Context()) acct, err := s.db.GetAccountByUUID(claims.Subject) if err != nil { middleware.WriteError(w, http.StatusUnauthorized, "account not found", "unauthorized") return } var req webauthnRegisterBeginRequest if !decodeJSON(w, r, &req) { return } if req.Password == "" { middleware.WriteError(w, http.StatusBadRequest, "password is required", "bad_request") return } // Security: check lockout before password verification. locked, lockErr := s.db.IsLockedOut(acct.ID) if lockErr != nil { s.logger.Error("lockout check (WebAuthn register)", "error", lockErr) } if locked { s.writeAudit(r, model.EventWebAuthnEnrolled, &acct.ID, &acct.ID, `{"result":"locked"}`) middleware.WriteError(w, http.StatusTooManyRequests, "account temporarily locked", "account_locked") return } // Security: verify current password with constant-time Argon2id. ok, verifyErr := auth.VerifyPassword(req.Password, acct.PasswordHash) if verifyErr != nil || !ok { _ = s.db.RecordLoginFailure(acct.ID) s.writeAudit(r, model.EventWebAuthnEnrolled, &acct.ID, &acct.ID, `{"result":"wrong_password"}`) middleware.WriteError(w, http.StatusUnauthorized, "password is incorrect", "unauthorized") return } masterKey, err := s.vault.MasterKey() if err != nil { middleware.WriteError(w, http.StatusServiceUnavailable, "vault sealed", "vault_sealed") return } // Load existing credentials to exclude them from registration. dbCreds, err := s.db.GetWebAuthnCredentials(acct.ID) if err != nil { s.logger.Error("load webauthn credentials", "error", err) middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") return } libCreds, err := mciaswebauthn.DecryptCredentials(masterKey, dbCreds) if err != nil { s.logger.Error("decrypt webauthn credentials", "error", err) middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") return } wa, err := mciaswebauthn.NewWebAuthn(&s.cfg.WebAuthn) if err != nil { s.logger.Error("create webauthn instance", "error", err) middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") return } user := mciaswebauthn.NewAccountUser([]byte(acct.UUID), acct.Username, libCreds) creation, session, err := wa.BeginRegistration(user, libwebauthn.WithExclusions(libwebauthn.Credentials(libCreds).CredentialDescriptors()), libwebauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementPreferred), ) if err != nil { s.logger.Error("begin webauthn registration", "error", err) middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") return } nonce, err := storeWebAuthnCeremony(session, acct.ID) if err != nil { middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") return } optionsJSON, err := json.Marshal(creation) if err != nil { middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") return } writeJSON(w, http.StatusOK, webauthnRegisterBeginResponse{ Options: optionsJSON, Nonce: nonce, }) } // handleWebAuthnRegisterFinish completes WebAuthn credential registration. func (s *Server) handleWebAuthnRegisterFinish(w http.ResponseWriter, r *http.Request) { if !s.cfg.WebAuthnEnabled() { middleware.WriteError(w, http.StatusNotFound, "WebAuthn not configured", "not_found") return } claims := middleware.ClaimsFromContext(r.Context()) acct, err := s.db.GetAccountByUUID(claims.Subject) if err != nil { middleware.WriteError(w, http.StatusUnauthorized, "account not found", "unauthorized") return } // Read the raw body so we can extract the nonce and also pass // the credential response to the library via a reconstructed request. r.Body = http.MaxBytesReader(w, r.Body, maxJSONBytes) bodyBytes, err := readAllBody(r) if err != nil { middleware.WriteError(w, http.StatusBadRequest, "invalid request body", "bad_request") return } // Extract nonce and name from the wrapper. var wrapper struct { Nonce string `json:"nonce"` Name string `json:"name"` Credential json.RawMessage `json:"credential"` } if err := json.Unmarshal(bodyBytes, &wrapper); err != nil { middleware.WriteError(w, http.StatusBadRequest, "invalid JSON", "bad_request") return } ceremony, ok := consumeWebAuthnCeremony(wrapper.Nonce) if !ok { middleware.WriteError(w, http.StatusBadRequest, "ceremony expired or invalid", "bad_request") return } if ceremony.accountID != acct.ID { middleware.WriteError(w, http.StatusForbidden, "ceremony mismatch", "forbidden") return } masterKey, err := s.vault.MasterKey() if err != nil { middleware.WriteError(w, http.StatusServiceUnavailable, "vault sealed", "vault_sealed") return } dbCreds, err := s.db.GetWebAuthnCredentials(acct.ID) if err != nil { middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") return } libCreds, err := mciaswebauthn.DecryptCredentials(masterKey, dbCreds) if err != nil { middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") return } wa, err := mciaswebauthn.NewWebAuthn(&s.cfg.WebAuthn) if err != nil { middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") return } user := mciaswebauthn.NewAccountUser([]byte(acct.UUID), acct.Username, libCreds) // Build a fake http.Request from the credential JSON for the library. fakeReq, err := http.NewRequest(http.MethodPost, "/", bytes.NewReader(wrapper.Credential)) if err != nil { middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") return } fakeReq.Header.Set("Content-Type", "application/json") cred, err := wa.FinishRegistration(user, *ceremony.session, fakeReq) if err != nil { s.logger.Error("finish webauthn registration", "error", err) middleware.WriteError(w, http.StatusBadRequest, "registration failed", "bad_request") return } // Determine if the credential is discoverable based on the flags. discoverable := cred.Flags.UserVerified && cred.Flags.BackupEligible name := wrapper.Name if name == "" { name = "Passkey" } // Encrypt and store the credential. modelCred, err := mciaswebauthn.EncryptCredential(masterKey, cred, name, discoverable) if err != nil { middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") return } modelCred.AccountID = acct.ID credID, err := s.db.CreateWebAuthnCredential(modelCred) if err != nil { s.logger.Error("store webauthn credential", "error", err) middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") return } s.writeAudit(r, model.EventWebAuthnEnrolled, &acct.ID, &acct.ID, audit.JSON("credential_id", fmt.Sprintf("%d", credID), "name", name)) writeJSON(w, http.StatusCreated, map[string]interface{}{ "id": credID, "name": name, }) } // ---- Login ---- type webauthnLoginBeginRequest struct { Username string `json:"username,omitempty"` } type webauthnLoginBeginResponse struct { Nonce string `json:"nonce"` Options json.RawMessage `json:"options"` } // handleWebAuthnLoginBegin starts a WebAuthn login ceremony. // If username is provided, loads that account's credentials (non-discoverable flow). // If empty, starts a discoverable login. func (s *Server) handleWebAuthnLoginBegin(w http.ResponseWriter, r *http.Request) { if !s.cfg.WebAuthnEnabled() { middleware.WriteError(w, http.StatusNotFound, "WebAuthn not configured", "not_found") return } var req webauthnLoginBeginRequest if !decodeJSON(w, r, &req) { return } wa, err := mciaswebauthn.NewWebAuthn(&s.cfg.WebAuthn) if err != nil { s.logger.Error("create webauthn instance", "error", err) middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") return } var ( assertion *protocol.CredentialAssertion session *libwebauthn.SessionData accountID int64 ) if req.Username != "" { // Non-discoverable flow: load account credentials. acct, lookupErr := s.db.GetAccountByUsername(req.Username) if lookupErr != nil || acct.Status != model.AccountStatusActive { // Security: return a valid-looking response even for unknown users // to prevent username enumeration. Use discoverable login as a dummy. assertion, session, err = wa.BeginDiscoverableLogin() if err != nil { middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") return } } else { // Check lockout. locked, lockErr := s.db.IsLockedOut(acct.ID) if lockErr != nil { s.logger.Error("lockout check (WebAuthn login)", "error", lockErr) } if locked { // Return discoverable login as dummy to avoid enumeration. assertion, session, err = wa.BeginDiscoverableLogin() if err != nil { middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") return } } else { masterKey, mkErr := s.vault.MasterKey() if mkErr != nil { middleware.WriteError(w, http.StatusServiceUnavailable, "vault sealed", "vault_sealed") return } dbCreds, dbErr := s.db.GetWebAuthnCredentials(acct.ID) if dbErr != nil { middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") return } if len(dbCreds) == 0 { middleware.WriteError(w, http.StatusBadRequest, "no WebAuthn credentials registered", "no_credentials") return } libCreds, decErr := mciaswebauthn.DecryptCredentials(masterKey, dbCreds) if decErr != nil { middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") return } user := mciaswebauthn.NewAccountUser([]byte(acct.UUID), acct.Username, libCreds) assertion, session, err = wa.BeginLogin(user) if err != nil { s.logger.Error("begin webauthn login", "error", err) middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") return } accountID = acct.ID } } } else { // Discoverable login (passkey). assertion, session, err = wa.BeginDiscoverableLogin() if err != nil { s.logger.Error("begin discoverable webauthn login", "error", err) middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") return } } nonce, err := storeWebAuthnCeremony(session, accountID) if err != nil { middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") return } optionsJSON, err := json.Marshal(assertion) if err != nil { middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") return } writeJSON(w, http.StatusOK, webauthnLoginBeginResponse{ Options: optionsJSON, Nonce: nonce, }) } // handleWebAuthnLoginFinish completes a WebAuthn login ceremony and issues a JWT. func (s *Server) handleWebAuthnLoginFinish(w http.ResponseWriter, r *http.Request) { if !s.cfg.WebAuthnEnabled() { middleware.WriteError(w, http.StatusNotFound, "WebAuthn not configured", "not_found") return } r.Body = http.MaxBytesReader(w, r.Body, maxJSONBytes) bodyBytes, err := readAllBody(r) if err != nil { middleware.WriteError(w, http.StatusBadRequest, "invalid request body", "bad_request") return } var wrapper struct { Nonce string `json:"nonce"` Credential json.RawMessage `json:"credential"` } if err := json.Unmarshal(bodyBytes, &wrapper); err != nil { middleware.WriteError(w, http.StatusBadRequest, "invalid JSON", "bad_request") return } ceremony, ok := consumeWebAuthnCeremony(wrapper.Nonce) if !ok { middleware.WriteError(w, http.StatusUnauthorized, "invalid credentials", "unauthorized") return } wa, err := mciaswebauthn.NewWebAuthn(&s.cfg.WebAuthn) if err != nil { middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") return } masterKey, err := s.vault.MasterKey() if err != nil { middleware.WriteError(w, http.StatusServiceUnavailable, "vault sealed", "vault_sealed") return } fakeReq, err := http.NewRequest(http.MethodPost, "/", bytes.NewReader(wrapper.Credential)) if err != nil { middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") return } fakeReq.Header.Set("Content-Type", "application/json") var ( acct *model.Account cred *libwebauthn.Credential dbCreds []*model.WebAuthnCredential ) if ceremony.accountID != 0 { // Non-discoverable: we know the account. acct, err = s.db.GetAccountByID(ceremony.accountID) if err != nil { middleware.WriteError(w, http.StatusUnauthorized, "invalid credentials", "unauthorized") return } dbCreds, err = s.db.GetWebAuthnCredentials(acct.ID) if err != nil { middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") return } libCreds, decErr := mciaswebauthn.DecryptCredentials(masterKey, dbCreds) if decErr != nil { middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") return } user := mciaswebauthn.NewAccountUser([]byte(acct.UUID), acct.Username, libCreds) cred, err = wa.FinishLogin(user, *ceremony.session, fakeReq) if err != nil { s.writeAudit(r, model.EventWebAuthnLoginFail, &acct.ID, nil, `{"reason":"assertion_failed"}`) _ = s.db.RecordLoginFailure(acct.ID) middleware.WriteError(w, http.StatusUnauthorized, "invalid credentials", "unauthorized") return } } else { // Discoverable login: the library resolves the user from the credential. handler := func(rawID, userHandle []byte) (libwebauthn.User, error) { // userHandle is the WebAuthnID we set (account UUID as bytes). acctUUID := string(userHandle) foundAcct, lookupErr := s.db.GetAccountByUUID(acctUUID) if lookupErr != nil { return nil, fmt.Errorf("account not found") } if foundAcct.Status != model.AccountStatusActive { return nil, fmt.Errorf("account inactive") } acct = foundAcct foundDBCreds, credErr := s.db.GetWebAuthnCredentials(foundAcct.ID) if credErr != nil { return nil, fmt.Errorf("load credentials: %w", credErr) } dbCreds = foundDBCreds libCreds, decErr := mciaswebauthn.DecryptCredentials(masterKey, foundDBCreds) if decErr != nil { return nil, fmt.Errorf("decrypt credentials: %w", decErr) } return mciaswebauthn.NewAccountUser(userHandle, foundAcct.Username, libCreds), nil } cred, err = wa.FinishDiscoverableLogin(handler, *ceremony.session, fakeReq) if err != nil { s.writeAudit(r, model.EventWebAuthnLoginFail, nil, nil, `{"reason":"discoverable_assertion_failed"}`) middleware.WriteError(w, http.StatusUnauthorized, "invalid credentials", "unauthorized") return } } if acct == nil { middleware.WriteError(w, http.StatusUnauthorized, "invalid credentials", "unauthorized") return } // Security: check account status and lockout. if acct.Status != model.AccountStatusActive { middleware.WriteError(w, http.StatusUnauthorized, "invalid credentials", "unauthorized") return } locked, lockErr := s.db.IsLockedOut(acct.ID) if lockErr != nil { s.logger.Error("lockout check (WebAuthn login finish)", "error", lockErr) } if locked { s.writeAudit(r, model.EventWebAuthnLoginFail, &acct.ID, nil, `{"reason":"account_locked"}`) middleware.WriteError(w, http.StatusUnauthorized, "invalid credentials", "unauthorized") return } // Security: validate sign counter to detect cloned authenticators. // Find the matching DB credential to update. var matchedDBCred *model.WebAuthnCredential for _, dc := range dbCreds { decrypted, decErr := mciaswebauthn.DecryptCredential(masterKey, dc) if decErr != nil { continue } if bytes.Equal(decrypted.ID, cred.ID) { matchedDBCred = dc break } } if matchedDBCred != nil { // Security: reject sign counter rollback (cloned authenticator detection). // If both are 0, the authenticator doesn't support counters — allow it. if cred.Authenticator.SignCount > 0 || matchedDBCred.SignCount > 0 { if cred.Authenticator.SignCount <= matchedDBCred.SignCount { s.writeAudit(r, model.EventWebAuthnLoginFail, &acct.ID, nil, audit.JSON("reason", "counter_rollback", "expected_gt", fmt.Sprintf("%d", matchedDBCred.SignCount), "got", fmt.Sprintf("%d", cred.Authenticator.SignCount))) _ = s.db.RecordLoginFailure(acct.ID) middleware.WriteError(w, http.StatusUnauthorized, "invalid credentials", "unauthorized") return } } // Update sign count and last used. _ = s.db.UpdateWebAuthnSignCount(matchedDBCred.ID, cred.Authenticator.SignCount) _ = s.db.UpdateWebAuthnLastUsed(matchedDBCred.ID) } // Login succeeded: clear lockout counter. _ = s.db.ClearLoginFailures(acct.ID) // Issue JWT. roles, err := s.db.GetRoles(acct.ID) if err != nil { middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") return } expiry := s.cfg.DefaultExpiry() for _, role := range roles { if role == "admin" { expiry = s.cfg.AdminExpiry() break } } privKey, err := s.vault.PrivKey() if err != nil { middleware.WriteError(w, http.StatusServiceUnavailable, "vault sealed", "vault_sealed") return } tokenStr, tokenClaims, err := token.IssueToken(privKey, s.cfg.Tokens.Issuer, acct.UUID, roles, expiry) if err != nil { middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") return } if err := s.db.TrackToken(tokenClaims.JTI, acct.ID, tokenClaims.IssuedAt, tokenClaims.ExpiresAt); err != nil { middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") return } s.writeAudit(r, model.EventWebAuthnLoginOK, &acct.ID, nil, "") s.writeAudit(r, model.EventTokenIssued, &acct.ID, nil, audit.JSON("jti", tokenClaims.JTI, "via", "webauthn")) writeJSON(w, http.StatusOK, loginResponse{ Token: tokenStr, ExpiresAt: tokenClaims.ExpiresAt.Format("2006-01-02T15:04:05Z"), }) } // ---- Credential management ---- type webauthnCredentialView struct { CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` LastUsedAt string `json:"last_used_at,omitempty"` Name string `json:"name"` AAGUID string `json:"aaguid"` Transports string `json:"transports,omitempty"` ID int64 `json:"id"` SignCount uint32 `json:"sign_count"` Discoverable bool `json:"discoverable"` } // handleListWebAuthnCredentials returns metadata for an account's WebAuthn credentials. func (s *Server) handleListWebAuthnCredentials(w http.ResponseWriter, r *http.Request) { acct, ok := s.loadAccount(w, r) if !ok { return } creds, err := s.db.GetWebAuthnCredentials(acct.ID) if err != nil { middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") return } views := make([]webauthnCredentialView, 0, len(creds)) for _, c := range creds { v := webauthnCredentialView{ ID: c.ID, Name: c.Name, AAGUID: c.AAGUID, SignCount: c.SignCount, Discoverable: c.Discoverable, Transports: c.Transports, CreatedAt: c.CreatedAt.Format("2006-01-02T15:04:05Z"), UpdatedAt: c.UpdatedAt.Format("2006-01-02T15:04:05Z"), } if c.LastUsedAt != nil { v.LastUsedAt = c.LastUsedAt.Format("2006-01-02T15:04:05Z") } views = append(views, v) } writeJSON(w, http.StatusOK, views) } // handleDeleteWebAuthnCredential removes a specific WebAuthn credential. func (s *Server) handleDeleteWebAuthnCredential(w http.ResponseWriter, r *http.Request) { acct, ok := s.loadAccount(w, r) if !ok { return } credIDStr := r.PathValue("credentialId") credID, err := strconv.ParseInt(credIDStr, 10, 64) if err != nil { middleware.WriteError(w, http.StatusBadRequest, "invalid credential ID", "bad_request") return } if err := s.db.DeleteWebAuthnCredentialAdmin(credID); err != nil { middleware.WriteError(w, http.StatusNotFound, "credential not found", "not_found") return } s.writeAudit(r, model.EventWebAuthnRemoved, nil, &acct.ID, audit.JSON("credential_id", credIDStr)) w.WriteHeader(http.StatusNoContent) } // readAllBody reads the entire request body and returns it as a byte slice. func readAllBody(r *http.Request) ([]byte, error) { var buf bytes.Buffer _, err := buf.ReadFrom(r.Body) if err != nil { return nil, err } return buf.Bytes(), nil }