Services: Admin-Compliance, Backend-Compliance, AI-Compliance-SDK, Consent-SDK, Developer-Portal, PCA-Platform, DSMS Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
173 lines
4.6 KiB
Go
173 lines
4.6 KiB
Go
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
|
|
}
|