package services import ( "context" "crypto/rand" "encoding/base64" "fmt" "time" "veza-backend-api/internal/database" "veza-backend-api/internal/models" "github.com/google/uuid" "go.uber.org/zap" ) // WebAuthnChallenge represents an in-progress WebAuthn ceremony. // Challenges are stored ephemerally (Redis or in-memory) with a short TTL. type WebAuthnChallenge struct { Challenge string `json:"challenge"` UserID uuid.UUID `json:"user_id"` Type string `json:"type"` // "registration" or "authentication" CreatedAt time.Time `json:"created_at"` ExpiresAt time.Time `json:"expires_at"` } // WebAuthnService handles FIDO2/WebAuthn passkey operations. // F022: ORIGIN_FEATURES_REGISTRY.md — WebAuthn/Passkeys. type WebAuthnService struct { db *database.Database logger *zap.Logger rpID string // Relying Party ID (e.g., "veza.fr") rpName string // Relying Party Name (e.g., "Veza") } // NewWebAuthnService creates a new WebAuthn service. func NewWebAuthnService(db *database.Database, logger *zap.Logger, rpID, rpName string) *WebAuthnService { if rpID == "" { rpID = "localhost" } if rpName == "" { rpName = "Veza" } return &WebAuthnService{ db: db, logger: logger, rpID: rpID, rpName: rpName, } } // BeginRegistration generates a challenge for registering a new passkey. // Returns a challenge and options to be sent to the client. func (s *WebAuthnService) BeginRegistration(ctx context.Context, userID uuid.UUID, username string) (*WebAuthnChallenge, map[string]interface{}, error) { // Generate random challenge (32 bytes) challengeBytes := make([]byte, 32) if _, err := rand.Read(challengeBytes); err != nil { return nil, nil, fmt.Errorf("failed to generate challenge: %w", err) } challenge := base64.RawURLEncoding.EncodeToString(challengeBytes) // Get existing credentials for this user (to exclude) var existingCreds []models.WebAuthnCredential if s.db != nil { var err error existingCreds, err = s.GetUserCredentials(ctx, userID) if err != nil { s.logger.Warn("failed to get existing credentials", zap.Error(err)) existingCreds = nil } } excludeCredentials := make([]map[string]interface{}, 0, len(existingCreds)) for _, cred := range existingCreds { excludeCredentials = append(excludeCredentials, map[string]interface{}{ "type": "public-key", "id": base64.RawURLEncoding.EncodeToString(cred.CredentialID), }) } wc := &WebAuthnChallenge{ Challenge: challenge, UserID: userID, Type: "registration", CreatedAt: time.Now(), ExpiresAt: time.Now().Add(5 * time.Minute), } // Build PublicKeyCredentialCreationOptions options := map[string]interface{}{ "challenge": challenge, "rp": map[string]string{ "id": s.rpID, "name": s.rpName, }, "user": map[string]interface{}{ "id": base64.RawURLEncoding.EncodeToString(userID[:]), "name": username, "displayName": username, }, "pubKeyCredParams": []map[string]interface{}{ {"type": "public-key", "alg": -7}, // ES256 {"type": "public-key", "alg": -257}, // RS256 }, "timeout": 60000, "attestation": "none", "excludeCredentials": excludeCredentials, "authenticatorSelection": map[string]interface{}{ "authenticatorAttachment": "platform", "residentKey": "preferred", "userVerification": "preferred", }, } return wc, options, nil } // FinishRegistration validates the registration response and stores the credential. func (s *WebAuthnService) FinishRegistration(ctx context.Context, userID uuid.UUID, credentialID, publicKey, aaguid []byte, attestationType string, name string) (*models.WebAuthnCredential, error) { if len(credentialID) == 0 || len(publicKey) == 0 { return nil, fmt.Errorf("invalid credential data") } if name == "" { name = "My Passkey" } cred := &models.WebAuthnCredential{ ID: uuid.New(), UserID: userID, CredentialID: credentialID, PublicKey: publicKey, AttestationType: attestationType, AAGUID: aaguid, SignCount: 0, Name: name, CreatedAt: time.Now(), } _, err := s.db.ExecContext(ctx, ` INSERT INTO webauthn_credentials (id, user_id, credential_id, public_key, attestation_type, aaguid, sign_count, name, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) `, cred.ID, cred.UserID, cred.CredentialID, cred.PublicKey, cred.AttestationType, cred.AAGUID, cred.SignCount, cred.Name, cred.CreatedAt) if err != nil { return nil, fmt.Errorf("failed to store credential: %w", err) } s.logger.Info("WebAuthn credential registered", zap.String("user_id", userID.String()), zap.String("credential_id", cred.ID.String()), ) return cred, nil } // BeginAuthentication generates a challenge for authenticating with a passkey. func (s *WebAuthnService) BeginAuthentication(ctx context.Context, userID uuid.UUID) (*WebAuthnChallenge, map[string]interface{}, error) { challengeBytes := make([]byte, 32) if _, err := rand.Read(challengeBytes); err != nil { return nil, nil, fmt.Errorf("failed to generate challenge: %w", err) } challenge := base64.RawURLEncoding.EncodeToString(challengeBytes) creds, err := s.GetUserCredentials(ctx, userID) if err != nil || len(creds) == 0 { return nil, nil, fmt.Errorf("no passkeys registered for this user") } allowCredentials := make([]map[string]interface{}, 0, len(creds)) for _, cred := range creds { allowCredentials = append(allowCredentials, map[string]interface{}{ "type": "public-key", "id": base64.RawURLEncoding.EncodeToString(cred.CredentialID), }) } wc := &WebAuthnChallenge{ Challenge: challenge, UserID: userID, Type: "authentication", CreatedAt: time.Now(), ExpiresAt: time.Now().Add(5 * time.Minute), } options := map[string]interface{}{ "challenge": challenge, "rpId": s.rpID, "timeout": 60000, "userVerification": "preferred", "allowCredentials": allowCredentials, } return wc, options, nil } // UpdateSignCount updates the sign count after successful authentication. func (s *WebAuthnService) UpdateSignCount(ctx context.Context, credentialID []byte, newSignCount uint32) error { _, err := s.db.ExecContext(ctx, ` UPDATE webauthn_credentials SET sign_count = $1, last_used_at = NOW() WHERE credential_id = $2 `, newSignCount, credentialID) return err } // GetUserCredentials returns all WebAuthn credentials for a user. func (s *WebAuthnService) GetUserCredentials(ctx context.Context, userID uuid.UUID) ([]models.WebAuthnCredential, error) { rows, err := s.db.QueryContext(ctx, ` SELECT id, user_id, credential_id, public_key, attestation_type, aaguid, sign_count, name, created_at, last_used_at FROM webauthn_credentials WHERE user_id = $1 ORDER BY created_at DESC `, userID) if err != nil { return nil, fmt.Errorf("query webauthn credentials: %w", err) } defer rows.Close() var creds []models.WebAuthnCredential for rows.Next() { var c models.WebAuthnCredential if err := rows.Scan(&c.ID, &c.UserID, &c.CredentialID, &c.PublicKey, &c.AttestationType, &c.AAGUID, &c.SignCount, &c.Name, &c.CreatedAt, &c.LastUsedAt); err != nil { continue } creds = append(creds, c) } return creds, nil } // GetCredentialByID retrieves a credential by its WebAuthn credential ID bytes. func (s *WebAuthnService) GetCredentialByID(ctx context.Context, credentialID []byte) (*models.WebAuthnCredential, error) { var c models.WebAuthnCredential err := s.db.QueryRowContext(ctx, ` SELECT id, user_id, credential_id, public_key, attestation_type, aaguid, sign_count, name, created_at, last_used_at FROM webauthn_credentials WHERE credential_id = $1 `, credentialID).Scan(&c.ID, &c.UserID, &c.CredentialID, &c.PublicKey, &c.AttestationType, &c.AAGUID, &c.SignCount, &c.Name, &c.CreatedAt, &c.LastUsedAt) if err != nil { return nil, fmt.Errorf("credential not found: %w", err) } return &c, nil } // DeleteCredential removes a passkey by UUID (user must own it). func (s *WebAuthnService) DeleteCredential(ctx context.Context, userID, credID uuid.UUID) error { result, err := s.db.ExecContext(ctx, ` DELETE FROM webauthn_credentials WHERE id = $1 AND user_id = $2 `, credID, userID) if err != nil { return fmt.Errorf("failed to delete credential: %w", err) } rows, _ := result.RowsAffected() if rows == 0 { return fmt.Errorf("credential not found or not owned by user") } s.logger.Info("WebAuthn credential deleted", zap.String("user_id", userID.String()), zap.String("credential_id", credID.String()), ) return nil } // RenameCredential updates the display name of a passkey. func (s *WebAuthnService) RenameCredential(ctx context.Context, userID, credID uuid.UUID, newName string) error { if newName == "" { return fmt.Errorf("name cannot be empty") } result, err := s.db.ExecContext(ctx, ` UPDATE webauthn_credentials SET name = $1 WHERE id = $2 AND user_id = $3 `, newName, credID, userID) if err != nil { return fmt.Errorf("failed to rename credential: %w", err) } rows, _ := result.RowsAffected() if rows == 0 { return fmt.Errorf("credential not found or not owned by user") } return nil } // HasPasskeys returns whether a user has any WebAuthn credentials registered. func (s *WebAuthnService) HasPasskeys(ctx context.Context, userID uuid.UUID) (bool, error) { var count int err := s.db.QueryRowContext(ctx, ` SELECT COUNT(*) FROM webauthn_credentials WHERE user_id = $1 `, userID).Scan(&count) if err != nil { return false, err } return count > 0, nil }