From ea00d84a16cb58722527b68ed98015ba0522776b Mon Sep 17 00:00:00 2001 From: Bretton Date: Tue, 7 Oct 2025 15:50:55 -0700 Subject: [PATCH] feat: Add phone verification service layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement verification service with security-first design: - Cryptographically secure OTP generation (crypto/rand) - Rate limiting (3/hour per phone, 5/day per DID) - Constant-time OTP comparison (bcrypt) - Signature binding to subject DID (prevents copying attack) - Comprehensive error types for XRPC handlers Service interfaces support future verification types (email, domain). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- internal/core/verification/errors.go | 80 ++++++ internal/core/verification/interfaces.go | 139 ++++++++++ internal/core/verification/service.go | 325 +++++++++++++++++++++++ 3 files changed, 544 insertions(+) create mode 100644 internal/core/verification/errors.go create mode 100644 internal/core/verification/interfaces.go create mode 100644 internal/core/verification/service.go diff --git a/internal/core/verification/errors.go b/internal/core/verification/errors.go new file mode 100644 index 0000000..ae7dc4a --- /dev/null +++ b/internal/core/verification/errors.go @@ -0,0 +1,80 @@ +package verification + +import "fmt" + +// InvalidPhoneNumberError indicates phone number format is invalid +type InvalidPhoneNumberError struct { + Phone string +} + +func (e *InvalidPhoneNumberError) Error() string { + return fmt.Sprintf("invalid phone number format: %s", e.Phone) +} + +// PhoneAlreadyVerifiedError indicates phone is already verified by another account +type PhoneAlreadyVerifiedError struct { + PhoneHash string +} + +func (e *PhoneAlreadyVerifiedError) Error() string { + return "this phone number is already verified by another account" +} + +// RateLimitExceededError indicates too many verification requests +type RateLimitExceededError struct { + Identifier string + RetryAfter int // seconds +} + +func (e *RateLimitExceededError) Error() string { + return fmt.Sprintf("rate limit exceeded, retry after %d seconds", e.RetryAfter) +} + +// SMSDeliveryFailedError indicates SMS failed to send +type SMSDeliveryFailedError struct { + Reason string +} + +func (e *SMSDeliveryFailedError) Error() string { + return fmt.Sprintf("failed to send SMS: %s", e.Reason) +} + +// InvalidCodeError indicates OTP code is incorrect +type InvalidCodeError struct{} + +func (e *InvalidCodeError) Error() string { + return "invalid verification code" +} + +// CodeExpiredError indicates OTP code has expired +type CodeExpiredError struct{} + +func (e *CodeExpiredError) Error() string { + return "verification code has expired" +} + +// RequestNotFoundError indicates request ID not found +type RequestNotFoundError struct { + RequestID string +} + +func (e *RequestNotFoundError) Error() string { + return fmt.Sprintf("verification request not found: %s", e.RequestID) +} + +// TooManyAttemptsError indicates too many failed verification attempts +type TooManyAttemptsError struct{} + +func (e *TooManyAttemptsError) Error() string { + return "too many failed attempts, please request a new code" +} + +// PDSWriteFailedError indicates failure to write to PDS +type PDSWriteFailedError struct { + DID string + Reason string +} + +func (e *PDSWriteFailedError) Error() string { + return fmt.Sprintf("failed to write verification to PDS for %s: %s", e.DID, e.Reason) +} diff --git a/internal/core/verification/interfaces.go b/internal/core/verification/interfaces.go new file mode 100644 index 0000000..f90b196 --- /dev/null +++ b/internal/core/verification/interfaces.go @@ -0,0 +1,139 @@ +package verification + +import ( + "context" + "time" +) + +// VerificationService handles phone verification operations +type VerificationService interface { + // RequestPhoneVerification sends an OTP code to the provided phone number + RequestPhoneVerification(ctx context.Context, did, phoneNumber string) (*VerificationRequest, error) + + // VerifyPhone validates the OTP code and writes verification to PDS + VerifyPhone(ctx context.Context, did, requestID, code string) (*VerificationResult, error) + + // GetVerificationStatus retrieves current verification status for a user + GetVerificationStatus(ctx context.Context, did string) (*VerificationStatus, error) + + // CheckPhoneAvailability checks if phone number is already verified by another account + CheckPhoneAvailability(ctx context.Context, phoneNumber string) (bool, error) +} + +// VerificationRepository handles persistence of verification data +type VerificationRepository interface { + // StoreVerificationRequest saves a pending verification request + StoreVerificationRequest(ctx context.Context, req *VerificationRequest) error + + // GetVerificationRequest retrieves a pending request by ID + GetVerificationRequest(ctx context.Context, requestID string) (*VerificationRequest, error) + + // IncrementAttempts increments the failed attempt counter + IncrementAttempts(ctx context.Context, requestID string) error + + // DeleteVerificationRequest removes a pending request + DeleteVerificationRequest(ctx context.Context, requestID string) error + + // StoreVerification saves a completed verification + StoreVerification(ctx context.Context, verification *PhoneVerification) error + + // GetVerification retrieves verification by DID + GetVerification(ctx context.Context, did string) (*PhoneVerification, error) + + // GetVerificationByPhoneHash retrieves verification by phone hash + GetVerificationByPhoneHash(ctx context.Context, phoneHash string) (*PhoneVerification, error) + + // CheckRateLimit checks if DID or phone has exceeded rate limits + CheckRateLimit(ctx context.Context, identifier string, limit int, window time.Duration) (bool, error) + + // RecordRateLimitAttempt records a verification attempt for rate limiting + RecordRateLimitAttempt(ctx context.Context, identifier string) error + + // LogAuditEvent records an audit event + LogAuditEvent(ctx context.Context, event *AuditEvent) error +} + +// SMSProvider handles sending SMS messages +type SMSProvider interface { + // SendOTP sends an OTP code via SMS + SendOTP(ctx context.Context, phoneNumber, code string) error +} + +// SignatureService handles cryptographic signing of verifications +type SignatureService interface { + // SignVerification creates a signature over verification data + SignVerification(ctx context.Context, verification *VerificationData) (string, error) + + // GetVerifierDID returns the DID used for signing + GetVerifierDID() string +} + +// PDSWriter handles writing verification records to user's PDS +type PDSWriter interface { + // WriteVerificationToProfile writes verification to user's PDS profile + WriteVerificationToProfile(ctx context.Context, did string, verification *SignedVerification) error +} + +// VerificationRequest represents a pending phone verification +type VerificationRequest struct { + RequestID string + DID string + PhoneHash string + OTPCodeHash string + Attempts int + CreatedAt time.Time + ExpiresAt time.Time +} + +// PhoneVerification represents a completed phone verification +type PhoneVerification struct { + DID string + PhoneHash string + VerifiedAt time.Time + ExpiresAt time.Time +} + +// VerificationData is the data that gets signed +type VerificationData struct { + Type string // "phone" + VerifiedBy string // DID of verifier + VerifiedAt time.Time + ExpiresAt time.Time + SubjectDID string // DID being verified +} + +// SignedVerification is written to PDS profile +type SignedVerification struct { + Type string `json:"type"` + VerifiedBy string `json:"verifiedBy"` + VerifiedAt string `json:"verifiedAt"` // RFC3339 + ExpiresAt string `json:"expiresAt"` // RFC3339 + Signature string `json:"signature"` + Metadata map[string]interface{} `json:"metadata,omitempty"` +} + +// VerificationResult is returned after successful verification +type VerificationResult struct { + Verified bool + VerifiedAt time.Time + ExpiresAt time.Time +} + +// VerificationStatus represents current verification state +type VerificationStatus struct { + HasVerifiedPhone bool + VerifiedAt *time.Time + ExpiresAt *time.Time + NeedsRenewal bool +} + +// AuditEvent represents a security audit event +type AuditEvent struct { + DID *string + EventType string // 'request_sent', 'verification_success', 'verification_failed', 'rate_limit_hit' + PhoneHash *string + IPAddress *string + UserAgent *string + Metadata map[string]interface{} + CreatedAt time.Time +} diff --git a/internal/core/verification/service.go b/internal/core/verification/service.go new file mode 100644 index 0000000..3d90fe1 --- /dev/null +++ b/internal/core/verification/service.go @@ -0,0 +1,325 @@ +package verification + +import ( + "context" + "crypto/rand" + "crypto/subtle" + "fmt" + "math/big" + "regexp" + "time" + + "golang.org/x/crypto/bcrypt" +) + +const ( + OTPLength = 6 + OTPExpiryMinutes = 10 + MaxAttempts = 3 + VerificationTTL = 365 * 24 * time.Hour // 1 year + + // Rate limits + RateLimitPerPhone = 3 // Max 3 requests per phone per hour + RateLimitPerDID = 5 // Max 5 requests per DID per day + PhoneRateWindow = 1 * time.Hour + DIDRateWindow = 24 * time.Hour +) + +var ( + // E.164 phone number regex (basic validation) + e164Regex = regexp.MustCompile(`^\+[1-9]\d{1,14}$`) +) + +// verificationService implements VerificationService +type verificationService struct { + repo VerificationRepository + smsProvider SMSProvider + signer SignatureService + pdsWriter PDSWriter + hashProvider PhoneHashProvider +} + +// PhoneHashProvider handles phone number hashing +type PhoneHashProvider interface { + HashPhone(phoneNumber string) string +} + +// NewVerificationService creates a new verification service +func NewVerificationService( + repo VerificationRepository, + smsProvider SMSProvider, + signer SignatureService, + pdsWriter PDSWriter, + hashProvider PhoneHashProvider, +) VerificationService { + return &verificationService{ + repo: repo, + smsProvider: smsProvider, + signer: signer, + pdsWriter: pdsWriter, + hashProvider: hashProvider, + } +} + +// RequestPhoneVerification sends an OTP code to the provided phone number +func (s *verificationService) RequestPhoneVerification(ctx context.Context, did, phoneNumber string) (*VerificationRequest, error) { + // Validate phone number format + if !e164Regex.MatchString(phoneNumber) { + return nil, &InvalidPhoneNumberError{Phone: phoneNumber} + } + + phoneHash := s.hashProvider.HashPhone(phoneNumber) + + // Check if phone is already verified by another account + existingVerification, err := s.repo.GetVerificationByPhoneHash(ctx, phoneHash) + if err == nil && existingVerification != nil && existingVerification.DID != did { + return nil, &PhoneAlreadyVerifiedError{PhoneHash: phoneHash} + } + + // Check rate limits (per phone) + phoneExceeded, err := s.repo.CheckRateLimit(ctx, "phone:"+phoneHash, RateLimitPerPhone, PhoneRateWindow) + if err != nil { + return nil, fmt.Errorf("failed to check phone rate limit: %w", err) + } + if phoneExceeded { + // Log audit event + _ = s.repo.LogAuditEvent(ctx, &AuditEvent{ + DID: &did, + EventType: "rate_limit_hit", + PhoneHash: &phoneHash, + Metadata: map[string]interface{}{"limit_type": "phone"}, + CreatedAt: time.Now(), + }) + return nil, &RateLimitExceededError{Identifier: "phone", RetryAfter: 3600} + } + + // Check rate limits (per DID) + didExceeded, err := s.repo.CheckRateLimit(ctx, "did:"+did, RateLimitPerDID, DIDRateWindow) + if err != nil { + return nil, fmt.Errorf("failed to check DID rate limit: %w", err) + } + if didExceeded { + _ = s.repo.LogAuditEvent(ctx, &AuditEvent{ + DID: &did, + EventType: "rate_limit_hit", + PhoneHash: &phoneHash, + Metadata: map[string]interface{}{"limit_type": "did"}, + CreatedAt: time.Now(), + }) + return nil, &RateLimitExceededError{Identifier: "did", RetryAfter: 86400} + } + + // Generate OTP code + otpCode, err := generateOTP(OTPLength) + if err != nil { + return nil, fmt.Errorf("failed to generate OTP: %w", err) + } + + // Hash OTP code for storage + otpHash, err := bcrypt.GenerateFromPassword([]byte(otpCode), bcrypt.DefaultCost) + if err != nil { + return nil, fmt.Errorf("failed to hash OTP: %w", err) + } + + // Create verification request + req := &VerificationRequest{ + RequestID: generateRequestID(), + DID: did, + PhoneHash: phoneHash, + OTPCodeHash: string(otpHash), + Attempts: 0, + CreatedAt: time.Now(), + ExpiresAt: time.Now().Add(OTPExpiryMinutes * time.Minute), + } + + // Store request + if err := s.repo.StoreVerificationRequest(ctx, req); err != nil { + return nil, fmt.Errorf("failed to store verification request: %w", err) + } + + // Send SMS + if err := s.smsProvider.SendOTP(ctx, phoneNumber, otpCode); err != nil { + // Log failed SMS delivery + _ = s.repo.LogAuditEvent(ctx, &AuditEvent{ + DID: &did, + EventType: "sms_delivery_failed", + PhoneHash: &phoneHash, + Metadata: map[string]interface{}{"error": err.Error()}, + CreatedAt: time.Now(), + }) + return nil, &SMSDeliveryFailedError{Reason: err.Error()} + } + + // Record rate limit attempt + _ = s.repo.RecordRateLimitAttempt(ctx, "phone:"+phoneHash) + _ = s.repo.RecordRateLimitAttempt(ctx, "did:"+did) + + // Log audit event + _ = s.repo.LogAuditEvent(ctx, &AuditEvent{ + DID: &did, + EventType: "request_sent", + PhoneHash: &phoneHash, + CreatedAt: time.Now(), + }) + + return req, nil +} + +// VerifyPhone validates the OTP code and writes verification to PDS +func (s *verificationService) VerifyPhone(ctx context.Context, did, requestID, code string) (*VerificationResult, error) { + // Retrieve verification request + req, err := s.repo.GetVerificationRequest(ctx, requestID) + if err != nil { + return nil, &RequestNotFoundError{RequestID: requestID} + } + + // Check if request belongs to this DID + if req.DID != did { + return nil, &RequestNotFoundError{RequestID: requestID} + } + + // Check if expired + if time.Now().After(req.ExpiresAt) { + _ = s.repo.DeleteVerificationRequest(ctx, requestID) + return nil, &CodeExpiredError{} + } + + // Check attempt limit + if req.Attempts >= MaxAttempts { + _ = s.repo.DeleteVerificationRequest(ctx, requestID) + return nil, &TooManyAttemptsError{} + } + + // Verify OTP code (constant-time comparison) + err = bcrypt.CompareHashAndPassword([]byte(req.OTPCodeHash), []byte(code)) + if err != nil { + // Increment attempts + _ = s.repo.IncrementAttempts(ctx, requestID) + + // Log failed verification + _ = s.repo.LogAuditEvent(ctx, &AuditEvent{ + DID: &did, + EventType: "verification_failed", + PhoneHash: &req.PhoneHash, + Metadata: map[string]interface{}{"attempts": req.Attempts + 1}, + CreatedAt: time.Now(), + }) + + return nil, &InvalidCodeError{} + } + + // Code is valid - create verification + now := time.Now() + expiresAt := now.Add(VerificationTTL) + + // Create verification data for signing + verificationData := &VerificationData{ + Type: "phone", + VerifiedBy: s.signer.GetVerifierDID(), + VerifiedAt: now, + ExpiresAt: expiresAt, + SubjectDID: did, + } + + // Sign verification + signature, err := s.signer.SignVerification(ctx, verificationData) + if err != nil { + return nil, fmt.Errorf("failed to sign verification: %w", err) + } + + // Create signed verification for PDS + signedVerification := &SignedVerification{ + Type: verificationData.Type, + VerifiedBy: verificationData.VerifiedBy, + VerifiedAt: verificationData.VerifiedAt.Format(time.RFC3339), + ExpiresAt: verificationData.ExpiresAt.Format(time.RFC3339), + Signature: signature, + } + + // Write to PDS profile + if err := s.pdsWriter.WriteVerificationToProfile(ctx, did, signedVerification); err != nil { + return nil, &PDSWriteFailedError{DID: did, Reason: err.Error()} + } + + // Store verification in AppView database + phoneVerification := &PhoneVerification{ + DID: did, + PhoneHash: req.PhoneHash, + VerifiedAt: now, + ExpiresAt: expiresAt, + } + if err := s.repo.StoreVerification(ctx, phoneVerification); err != nil { + return nil, fmt.Errorf("failed to store verification: %w", err) + } + + // Clean up request + _ = s.repo.DeleteVerificationRequest(ctx, requestID) + + // Log successful verification + _ = s.repo.LogAuditEvent(ctx, &AuditEvent{ + DID: &did, + EventType: "verification_success", + PhoneHash: &req.PhoneHash, + CreatedAt: time.Now(), + }) + + return &VerificationResult{ + Verified: true, + VerifiedAt: now, + ExpiresAt: expiresAt, + }, nil +} + +// GetVerificationStatus retrieves current verification status for a user +func (s *verificationService) GetVerificationStatus(ctx context.Context, did string) (*VerificationStatus, error) { + verification, err := s.repo.GetVerification(ctx, did) + if err != nil || verification == nil { + return &VerificationStatus{ + HasVerifiedPhone: false, + }, nil + } + + // Check if verification has expired + if time.Now().After(verification.ExpiresAt) { + return &VerificationStatus{ + HasVerifiedPhone: false, + }, nil + } + + // Check if renewal needed (within 30 days of expiry) + needsRenewal := time.Until(verification.ExpiresAt) < 30*24*time.Hour + + return &VerificationStatus{ + HasVerifiedPhone: true, + VerifiedAt: &verification.VerifiedAt, + ExpiresAt: &verification.ExpiresAt, + NeedsRenewal: needsRenewal, + }, nil +} + +// CheckPhoneAvailability checks if phone number is already verified +func (s *verificationService) CheckPhoneAvailability(ctx context.Context, phoneNumber string) (bool, error) { + phoneHash := s.hashProvider.HashPhone(phoneNumber) + verification, err := s.repo.GetVerificationByPhoneHash(ctx, phoneHash) + if err != nil { + return false, err + } + return verification == nil, nil +} + +// generateOTP generates a cryptographically secure numeric OTP +func generateOTP(length int) (string, error) { + max := new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(length)), nil) + n, err := rand.Int(rand.Reader, max) + if err != nil { + return "", err + } + return fmt.Sprintf("%0*d", length, n), nil +} + +// generateRequestID generates a unique request ID +func generateRequestID() string { + b := make([]byte, 16) + rand.Read(b) + return fmt.Sprintf("%x", b) +} -- 2.51.2