···
+
"golang.org/x/crypto/bcrypt"
+
VerificationTTL = 365 * 24 * time.Hour // 1 year
+
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
+
// 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
+
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,
+
hashProvider PhoneHashProvider,
+
) VerificationService {
+
return &verificationService{
+
smsProvider: smsProvider,
+
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)
+
return nil, fmt.Errorf("failed to check phone rate limit: %w", err)
+
_ = s.repo.LogAuditEvent(ctx, &AuditEvent{
+
EventType: "rate_limit_hit",
+
Metadata: map[string]interface{}{"limit_type": "phone"},
+
return nil, &RateLimitExceededError{Identifier: "phone", RetryAfter: 3600}
+
// Check rate limits (per DID)
+
didExceeded, err := s.repo.CheckRateLimit(ctx, "did:"+did, RateLimitPerDID, DIDRateWindow)
+
return nil, fmt.Errorf("failed to check DID rate limit: %w", err)
+
_ = s.repo.LogAuditEvent(ctx, &AuditEvent{
+
EventType: "rate_limit_hit",
+
Metadata: map[string]interface{}{"limit_type": "did"},
+
return nil, &RateLimitExceededError{Identifier: "did", RetryAfter: 86400}
+
otpCode, err := generateOTP(OTPLength)
+
return nil, fmt.Errorf("failed to generate OTP: %w", err)
+
// Hash OTP code for storage
+
otpHash, err := bcrypt.GenerateFromPassword([]byte(otpCode), bcrypt.DefaultCost)
+
return nil, fmt.Errorf("failed to hash OTP: %w", err)
+
// Create verification request
+
req := &VerificationRequest{
+
RequestID: generateRequestID(),
+
OTPCodeHash: string(otpHash),
+
ExpiresAt: time.Now().Add(OTPExpiryMinutes * time.Minute),
+
if err := s.repo.StoreVerificationRequest(ctx, req); err != nil {
+
return nil, fmt.Errorf("failed to store verification request: %w", err)
+
if err := s.smsProvider.SendOTP(ctx, phoneNumber, otpCode); err != nil {
+
// Log failed SMS delivery
+
_ = s.repo.LogAuditEvent(ctx, &AuditEvent{
+
EventType: "sms_delivery_failed",
+
Metadata: map[string]interface{}{"error": err.Error()},
+
return nil, &SMSDeliveryFailedError{Reason: err.Error()}
+
// Record rate limit attempt
+
_ = s.repo.RecordRateLimitAttempt(ctx, "phone:"+phoneHash)
+
_ = s.repo.RecordRateLimitAttempt(ctx, "did:"+did)
+
_ = s.repo.LogAuditEvent(ctx, &AuditEvent{
+
EventType: "request_sent",
+
// 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)
+
return nil, &RequestNotFoundError{RequestID: requestID}
+
// Check if request belongs to this DID
+
return nil, &RequestNotFoundError{RequestID: requestID}
+
if time.Now().After(req.ExpiresAt) {
+
_ = s.repo.DeleteVerificationRequest(ctx, requestID)
+
return nil, &CodeExpiredError{}
+
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))
+
_ = s.repo.IncrementAttempts(ctx, requestID)
+
// Log failed verification
+
_ = s.repo.LogAuditEvent(ctx, &AuditEvent{
+
EventType: "verification_failed",
+
PhoneHash: &req.PhoneHash,
+
Metadata: map[string]interface{}{"attempts": req.Attempts + 1},
+
return nil, &InvalidCodeError{}
+
// Code is valid - create verification
+
expiresAt := now.Add(VerificationTTL)
+
// Create verification data for signing
+
verificationData := &VerificationData{
+
VerifiedBy: s.signer.GetVerifierDID(),
+
signature, err := s.signer.SignVerification(ctx, verificationData)
+
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),
+
// 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{
+
PhoneHash: req.PhoneHash,
+
if err := s.repo.StoreVerification(ctx, phoneVerification); err != nil {
+
return nil, fmt.Errorf("failed to store verification: %w", err)
+
_ = s.repo.DeleteVerificationRequest(ctx, requestID)
+
// Log successful verification
+
_ = s.repo.LogAuditEvent(ctx, &AuditEvent{
+
EventType: "verification_success",
+
PhoneHash: &req.PhoneHash,
+
return &VerificationResult{
+
// 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,
+
// Check if verification has expired
+
if time.Now().After(verification.ExpiresAt) {
+
return &VerificationStatus{
+
HasVerifiedPhone: false,
+
// 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,
+
// 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)
+
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)
+
return fmt.Sprintf("%0*d", length, n), nil
+
// generateRequestID generates a unique request ID
+
func generateRequestID() string {
+
return fmt.Sprintf("%x", b)