A community based topic aggregation platform built on atproto

feat: Add phone verification service layer

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 <noreply@anthropic.com>

Changed files
+544
internal
core
+80
internal/core/verification/errors.go
···
+
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)
+
}
+139
internal/core/verification/interfaces.go
···
+
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
+
}
+325
internal/core/verification/service.go
···
+
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)
+
}