package stepup import ( "crypto/rand" "encoding/base64" "sync" "time" "github.com/breakpilot/pca-platform/heuristic-service/internal/config" ) // WebAuthnService handles WebAuthn challenges and verification type WebAuthnService struct { config *config.WebAuthnConfig challenges map[string]*Challenge mu sync.RWMutex } // Challenge represents a WebAuthn challenge type Challenge struct { ID string `json:"id"` SessionID string `json:"session_id"` Challenge string `json:"challenge"` CreatedAt time.Time `json:"created_at"` ExpiresAt time.Time `json:"expires_at"` Verified bool `json:"verified"` } // ChallengeRequest is the client-side challenge request format type ChallengeRequest struct { SessionID string `json:"session_id"` } // ChallengeResponse is the WebAuthn public key request options type ChallengeResponse struct { PublicKey PublicKeyCredentialRequestOptions `json:"publicKey"` } // PublicKeyCredentialRequestOptions mirrors the WebAuthn API structure type PublicKeyCredentialRequestOptions struct { Challenge string `json:"challenge"` Timeout int `json:"timeout"` RpID string `json:"rpId,omitempty"` UserVerification string `json:"userVerification"` AllowCredentials []PublicKeyCredentialDescriptor `json:"allowCredentials,omitempty"` } // PublicKeyCredentialDescriptor for allowed credentials type PublicKeyCredentialDescriptor struct { Type string `json:"type"` ID string `json:"id"` Transports []string `json:"transports,omitempty"` } // VerifyRequest for client verification response type VerifyRequest struct { SessionID string `json:"session_id"` ChallengeID string `json:"challenge_id"` Credential map[string]interface{} `json:"credential"` } // NewWebAuthnService creates a new WebAuthn service func NewWebAuthnService(cfg *config.WebAuthnConfig) *WebAuthnService { return &WebAuthnService{ config: cfg, challenges: make(map[string]*Challenge), } } // CreateChallenge generates a new WebAuthn challenge for a session func (s *WebAuthnService) CreateChallenge(sessionID string) (*ChallengeResponse, error) { // Generate random challenge bytes challengeBytes := make([]byte, 32) if _, err := rand.Read(challengeBytes); err != nil { return nil, err } challengeStr := base64.RawURLEncoding.EncodeToString(challengeBytes) // Generate challenge ID idBytes := make([]byte, 16) rand.Read(idBytes) challengeID := base64.RawURLEncoding.EncodeToString(idBytes) // Create challenge challenge := &Challenge{ ID: challengeID, SessionID: sessionID, Challenge: challengeStr, CreatedAt: time.Now(), ExpiresAt: time.Now().Add(time.Duration(s.config.TimeoutMs) * time.Millisecond), Verified: false, } // Store challenge s.mu.Lock() s.challenges[challengeID] = challenge s.mu.Unlock() // Build response response := &ChallengeResponse{ PublicKey: PublicKeyCredentialRequestOptions{ Challenge: challengeStr, Timeout: s.config.TimeoutMs, UserVerification: s.config.UserVerification, // In production, you'd include allowed credentials from user registration AllowCredentials: []PublicKeyCredentialDescriptor{}, }, } return response, nil } // VerifyChallenge verifies a WebAuthn assertion response func (s *WebAuthnService) VerifyChallenge(req *VerifyRequest) (bool, error) { s.mu.RLock() challenge, exists := s.challenges[req.ChallengeID] s.mu.RUnlock() if !exists { return false, nil } // Check expiration if time.Now().After(challenge.ExpiresAt) { s.mu.Lock() delete(s.challenges, req.ChallengeID) s.mu.Unlock() return false, nil } // Check session match if challenge.SessionID != req.SessionID { return false, nil } // In production, you would: // 1. Parse the credential response // 2. Verify the signature against stored public key // 3. Verify the challenge matches // 4. Check the origin // For MVP, we accept any valid-looking response // Verify credential structure exists if req.Credential == nil { return false, nil } // Mark as verified s.mu.Lock() challenge.Verified = true s.mu.Unlock() return true, nil } // CleanupExpiredChallenges removes expired challenges func (s *WebAuthnService) CleanupExpiredChallenges() { s.mu.Lock() defer s.mu.Unlock() now := time.Now() for id, challenge := range s.challenges { if now.After(challenge.ExpiresAt) { delete(s.challenges, id) } } } // IsEnabled returns whether WebAuthn is enabled func (s *WebAuthnService) IsEnabled() bool { return s.config.Enabled }