package auth import ( "context" "crypto/rand" "encoding/base64" "encoding/json" "fmt" "strings" "github.com/google/uuid" "go.b0esche.cloud/backend/internal/database" "golang.org/x/crypto/argon2" "golang.org/x/crypto/bcrypt" ) const ( ChallengeLength = 32 RPID = "b0esche.cloud" RPName = "b0esche Cloud" Origin = "https://b0esche.cloud" // Argon2id parameters (OWASP recommendations) Argon2Time = 2 // iterations Argon2Memory = 19 * 1024 // 19 MB Argon2Threads = 1 Argon2KeyLen = 32 ) type Service struct { db *database.DB } func NewService(db *database.DB) *Service { return &Service{db: db} } // StartRegistrationChallenge creates a challenge for passkey registration func (s *Service) StartRegistrationChallenge(ctx context.Context, userID uuid.UUID) (string, error) { challenge := make([]byte, ChallengeLength) if _, err := rand.Read(challenge); err != nil { return "", fmt.Errorf("failed to generate challenge: %w", err) } challengeStr := base64.StdEncoding.EncodeToString(challenge) // Store challenge in database if err := s.db.CreateAuthChallenge(ctx, userID, challenge, "registration"); err != nil { return "", fmt.Errorf("failed to store challenge: %w", err) } return challengeStr, nil } // StartAuthenticationChallenge creates a challenge for passkey authentication func (s *Service) StartAuthenticationChallenge(ctx context.Context, username string) (string, []string, error) { // Get user by username user, err := s.db.GetUserByUsername(ctx, username) if err != nil { return "", nil, fmt.Errorf("user not found: %w", err) } challenge := make([]byte, ChallengeLength) if _, err := rand.Read(challenge); err != nil { return "", nil, fmt.Errorf("failed to generate challenge: %w", err) } challengeStr := base64.StdEncoding.EncodeToString(challenge) // Store challenge in database if err := s.db.CreateAuthChallenge(ctx, user.ID, challenge, "authentication"); err != nil { return "", nil, fmt.Errorf("failed to store challenge: %w", err) } // Get user's credentials credentials, err := s.db.GetUserCredentials(ctx, user.ID) if err != nil { return "", nil, fmt.Errorf("failed to get credentials: %w", err) } // Return credential IDs (base64 encoded for transport) var credentialIDs []string for _, cred := range credentials { credentialIDs = append(credentialIDs, base64.StdEncoding.EncodeToString(cred.CredentialID)) } return challengeStr, credentialIDs, nil } // VerifyRegistrationResponse verifies the attestation response from the client func (s *Service) VerifyRegistrationResponse( ctx context.Context, userID uuid.UUID, challengeB64 string, credentialIDBase64 string, publicKeyBase64 string, clientDataJSON string, attestationObjectBase64 string, ) (*database.Credential, error) { // Decode inputs challenge, err := base64.StdEncoding.DecodeString(challengeB64) if err != nil { return nil, fmt.Errorf("invalid challenge encoding: %w", err) } credentialID, err := base64.StdEncoding.DecodeString(credentialIDBase64) if err != nil { return nil, fmt.Errorf("invalid credential ID encoding: %w", err) } publicKeyBytes, err := base64.StdEncoding.DecodeString(publicKeyBase64) if err != nil { return nil, fmt.Errorf("invalid public key encoding: %w", err) } _, err = base64.StdEncoding.DecodeString(attestationObjectBase64) if err != nil { return nil, fmt.Errorf("invalid attestation object encoding: %w", err) } // Verify challenge exists and belongs to this user if err := s.verifyChallenge(ctx, userID, challenge, "registration"); err != nil { return nil, fmt.Errorf("challenge verification failed: %w", err) } // In production, you would parse and verify the attestation object here // For now, we'll just verify the client data matches var clientData struct { Type string `json:"type"` Challenge string `json:"challenge"` Origin string `json:"origin"` } if err := json.Unmarshal([]byte(clientDataJSON), &clientData); err != nil { return nil, fmt.Errorf("invalid client data JSON: %w", err) } // Verify challenge in client data clientDataChallenge, err := base64.StdEncoding.DecodeString(clientData.Challenge) if err != nil { return nil, fmt.Errorf("invalid challenge in client data: %w", err) } // Verify challenge matches (we skip the hash verification since it's not needed for API validation) // clientDataHash := sha256.Sum256([]byte(clientDataJSON)) // Verify challenge matches if !byteArraysEqual(clientDataChallenge, challenge) { return nil, fmt.Errorf("challenge mismatch") } // Verify origin if clientData.Origin != Origin { return nil, fmt.Errorf("origin mismatch: expected %s, got %s", Origin, clientData.Origin) } // Verify type if clientData.Type != "webauthn.create" { return nil, fmt.Errorf("invalid client data type: %s", clientData.Type) } // Store credential in database credential := &database.Credential{ ID: base64.StdEncoding.EncodeToString(credentialID), UserID: userID, CredentialPublicKey: publicKeyBytes, CredentialID: credentialID, SignCount: 0, } if err := s.db.CreateCredential(ctx, credential); err != nil { return nil, fmt.Errorf("failed to store credential: %w", err) } // Mark challenge as used if err := s.db.MarkChallengeUsed(ctx, challenge); err != nil { return nil, fmt.Errorf("failed to mark challenge as used: %w", err) } return credential, nil } // VerifyAuthenticationResponse verifies the assertion response from the client func (s *Service) VerifyAuthenticationResponse( ctx context.Context, username string, challengeB64 string, credentialIDBase64 string, authenticatorData string, clientDataJSON string, signatureBase64 string, ) (*database.User, error) { // Get user by username user, err := s.db.GetUserByUsername(ctx, username) if err != nil { return nil, fmt.Errorf("user not found: %w", err) } // Decode challenge challenge, err := base64.StdEncoding.DecodeString(challengeB64) if err != nil { return nil, fmt.Errorf("invalid challenge encoding: %w", err) } // Verify challenge if err := s.verifyChallenge(ctx, user.ID, challenge, "authentication"); err != nil { return nil, fmt.Errorf("challenge verification failed: %w", err) } // Decode credential ID credentialID, err := base64.StdEncoding.DecodeString(credentialIDBase64) if err != nil { return nil, fmt.Errorf("invalid credential ID encoding: %w", err) } // Get credential from database credential, err := s.db.GetCredentialByID(ctx, credentialID) if err != nil { return nil, fmt.Errorf("credential not found: %w", err) } // Verify credential belongs to user if credential.UserID != user.ID { return nil, fmt.Errorf("credential does not belong to user") } // Parse and verify client data var clientData struct { Type string `json:"type"` Challenge string `json:"challenge"` Origin string `json:"origin"` } if err := json.Unmarshal([]byte(clientDataJSON), &clientData); err != nil { return nil, fmt.Errorf("invalid client data JSON: %w", err) } // Verify challenge matches clientDataChallenge, err := base64.StdEncoding.DecodeString(clientData.Challenge) if err != nil { return nil, fmt.Errorf("invalid challenge in client data: %w", err) } if !byteArraysEqual(clientDataChallenge, challenge) { return nil, fmt.Errorf("challenge mismatch") } // Verify origin if clientData.Origin != Origin { return nil, fmt.Errorf("origin mismatch: expected %s, got %s", Origin, clientData.Origin) } // Verify type if clientData.Type != "webauthn.get" { return nil, fmt.Errorf("invalid client data type: %s", clientData.Type) } // In production, you would verify the signature here using the public key // For now, we'll assume the signature is valid if we got this far // Mark challenge as used if err := s.db.MarkChallengeUsed(ctx, challenge); err != nil { return nil, fmt.Errorf("failed to mark challenge as used: %w", err) } // Update credential last used time if err := s.db.UpdateCredentialLastUsed(ctx, credential.ID); err != nil { return nil, fmt.Errorf("failed to update credential last used: %w", err) } // Update user last login if err := s.db.UpdateUserLastLogin(ctx, user.ID); err != nil { return nil, fmt.Errorf("failed to update user last login: %w", err) } return user, nil } func (s *Service) verifyChallenge(ctx context.Context, userID uuid.UUID, challenge []byte, challengeType string) error { return s.db.VerifyAuthChallenge(ctx, userID, challenge, challengeType) } func byteArraysEqual(a, b []byte) bool { if len(a) != len(b) { return false } for i := range a { if a[i] != b[i] { return false } } return true } // HashPassword hashes a password using Argon2id (quantum-resistant) // Format: $argon2id$v=19$m=19456,t=2,p=1$$ func (s *Service) HashPassword(password string) (string, error) { // Generate 16-byte random salt salt := make([]byte, 16) if _, err := rand.Read(salt); err != nil { return "", fmt.Errorf("failed to generate salt: %w", err) } // Hash with Argon2id hash := argon2.IDKey([]byte(password), salt, Argon2Time, Argon2Memory, Argon2Threads, Argon2KeyLen) // Encode in PHC string format b64Salt := base64.RawStdEncoding.EncodeToString(salt) b64Hash := base64.RawStdEncoding.EncodeToString(hash) return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s", Argon2Memory, Argon2Time, Argon2Threads, b64Salt, b64Hash), nil } // VerifyPassword checks if a password matches its hash // Supports both Argon2id (new) and bcrypt (legacy) for backward compatibility func (s *Service) VerifyPassword(passwordHash string, password string) bool { // Detect hash format if strings.HasPrefix(passwordHash, "$argon2id$") { return s.verifyArgon2(passwordHash, password) } else if strings.HasPrefix(passwordHash, "$2") { // Legacy bcrypt hash err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(password)) return err == nil } return false } func (s *Service) verifyArgon2(encodedHash string, password string) bool { // Parse PHC format: $argon2id$v=19$m=19456,t=2,p=1$$ parts := strings.Split(encodedHash, "$") if len(parts) != 6 { return false } var memory, time uint32 var threads uint8 _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &time, &threads) if err != nil { return false } salt, err := base64.RawStdEncoding.DecodeString(parts[4]) if err != nil { return false } hash, err := base64.RawStdEncoding.DecodeString(parts[5]) if err != nil { return false } // Compute hash with same parameters computedHash := argon2.IDKey([]byte(password), salt, time, memory, threads, uint32(len(hash))) // Constant-time comparison if len(hash) != len(computedHash) { return false } var diff byte for i := 0; i < len(hash); i++ { diff |= hash[i] ^ computedHash[i] } return diff == 0 } // VerifyPasswordLogin verifies username and password credentials func (s *Service) VerifyPasswordLogin(ctx context.Context, username, password string) (*database.User, error) { user, err := s.db.GetUserByUsername(ctx, username) if err != nil { return nil, fmt.Errorf("user not found: %w", err) } if user.PasswordHash == nil || *user.PasswordHash == "" { return nil, fmt.Errorf("user does not have a password set") } if !s.VerifyPassword(*user.PasswordHash, password) { return nil, fmt.Errorf("invalid password") } // Update last login if err := s.db.UpdateUserLastLogin(ctx, user.ID); err != nil { return nil, fmt.Errorf("failed to update user last login: %w", err) } return user, nil }