···
12
+
"golang.org/x/crypto/bcrypt"
17
+
OTPExpiryMinutes = 10
19
+
VerificationTTL = 365 * 24 * time.Hour // 1 year
22
+
RateLimitPerPhone = 3 // Max 3 requests per phone per hour
23
+
RateLimitPerDID = 5 // Max 5 requests per DID per day
24
+
PhoneRateWindow = 1 * time.Hour
25
+
DIDRateWindow = 24 * time.Hour
29
+
// E.164 phone number regex (basic validation)
30
+
e164Regex = regexp.MustCompile(`^\+[1-9]\d{1,14}$`)
33
+
// verificationService implements VerificationService
34
+
type verificationService struct {
35
+
repo VerificationRepository
36
+
smsProvider SMSProvider
37
+
signer SignatureService
39
+
hashProvider PhoneHashProvider
42
+
// PhoneHashProvider handles phone number hashing
43
+
type PhoneHashProvider interface {
44
+
HashPhone(phoneNumber string) string
47
+
// NewVerificationService creates a new verification service
48
+
func NewVerificationService(
49
+
repo VerificationRepository,
50
+
smsProvider SMSProvider,
51
+
signer SignatureService,
52
+
pdsWriter PDSWriter,
53
+
hashProvider PhoneHashProvider,
54
+
) VerificationService {
55
+
return &verificationService{
57
+
smsProvider: smsProvider,
59
+
pdsWriter: pdsWriter,
60
+
hashProvider: hashProvider,
64
+
// RequestPhoneVerification sends an OTP code to the provided phone number
65
+
func (s *verificationService) RequestPhoneVerification(ctx context.Context, did, phoneNumber string) (*VerificationRequest, error) {
66
+
// Validate phone number format
67
+
if !e164Regex.MatchString(phoneNumber) {
68
+
return nil, &InvalidPhoneNumberError{Phone: phoneNumber}
71
+
phoneHash := s.hashProvider.HashPhone(phoneNumber)
73
+
// Check if phone is already verified by another account
74
+
existingVerification, err := s.repo.GetVerificationByPhoneHash(ctx, phoneHash)
75
+
if err == nil && existingVerification != nil && existingVerification.DID != did {
76
+
return nil, &PhoneAlreadyVerifiedError{PhoneHash: phoneHash}
79
+
// Check rate limits (per phone)
80
+
phoneExceeded, err := s.repo.CheckRateLimit(ctx, "phone:"+phoneHash, RateLimitPerPhone, PhoneRateWindow)
82
+
return nil, fmt.Errorf("failed to check phone rate limit: %w", err)
86
+
_ = s.repo.LogAuditEvent(ctx, &AuditEvent{
88
+
EventType: "rate_limit_hit",
89
+
PhoneHash: &phoneHash,
90
+
Metadata: map[string]interface{}{"limit_type": "phone"},
91
+
CreatedAt: time.Now(),
93
+
return nil, &RateLimitExceededError{Identifier: "phone", RetryAfter: 3600}
96
+
// Check rate limits (per DID)
97
+
didExceeded, err := s.repo.CheckRateLimit(ctx, "did:"+did, RateLimitPerDID, DIDRateWindow)
99
+
return nil, fmt.Errorf("failed to check DID rate limit: %w", err)
102
+
_ = s.repo.LogAuditEvent(ctx, &AuditEvent{
104
+
EventType: "rate_limit_hit",
105
+
PhoneHash: &phoneHash,
106
+
Metadata: map[string]interface{}{"limit_type": "did"},
107
+
CreatedAt: time.Now(),
109
+
return nil, &RateLimitExceededError{Identifier: "did", RetryAfter: 86400}
112
+
// Generate OTP code
113
+
otpCode, err := generateOTP(OTPLength)
115
+
return nil, fmt.Errorf("failed to generate OTP: %w", err)
118
+
// Hash OTP code for storage
119
+
otpHash, err := bcrypt.GenerateFromPassword([]byte(otpCode), bcrypt.DefaultCost)
121
+
return nil, fmt.Errorf("failed to hash OTP: %w", err)
124
+
// Create verification request
125
+
req := &VerificationRequest{
126
+
RequestID: generateRequestID(),
128
+
PhoneHash: phoneHash,
129
+
OTPCodeHash: string(otpHash),
131
+
CreatedAt: time.Now(),
132
+
ExpiresAt: time.Now().Add(OTPExpiryMinutes * time.Minute),
136
+
if err := s.repo.StoreVerificationRequest(ctx, req); err != nil {
137
+
return nil, fmt.Errorf("failed to store verification request: %w", err)
141
+
if err := s.smsProvider.SendOTP(ctx, phoneNumber, otpCode); err != nil {
142
+
// Log failed SMS delivery
143
+
_ = s.repo.LogAuditEvent(ctx, &AuditEvent{
145
+
EventType: "sms_delivery_failed",
146
+
PhoneHash: &phoneHash,
147
+
Metadata: map[string]interface{}{"error": err.Error()},
148
+
CreatedAt: time.Now(),
150
+
return nil, &SMSDeliveryFailedError{Reason: err.Error()}
153
+
// Record rate limit attempt
154
+
_ = s.repo.RecordRateLimitAttempt(ctx, "phone:"+phoneHash)
155
+
_ = s.repo.RecordRateLimitAttempt(ctx, "did:"+did)
158
+
_ = s.repo.LogAuditEvent(ctx, &AuditEvent{
160
+
EventType: "request_sent",
161
+
PhoneHash: &phoneHash,
162
+
CreatedAt: time.Now(),
168
+
// VerifyPhone validates the OTP code and writes verification to PDS
169
+
func (s *verificationService) VerifyPhone(ctx context.Context, did, requestID, code string) (*VerificationResult, error) {
170
+
// Retrieve verification request
171
+
req, err := s.repo.GetVerificationRequest(ctx, requestID)
173
+
return nil, &RequestNotFoundError{RequestID: requestID}
176
+
// Check if request belongs to this DID
177
+
if req.DID != did {
178
+
return nil, &RequestNotFoundError{RequestID: requestID}
181
+
// Check if expired
182
+
if time.Now().After(req.ExpiresAt) {
183
+
_ = s.repo.DeleteVerificationRequest(ctx, requestID)
184
+
return nil, &CodeExpiredError{}
187
+
// Check attempt limit
188
+
if req.Attempts >= MaxAttempts {
189
+
_ = s.repo.DeleteVerificationRequest(ctx, requestID)
190
+
return nil, &TooManyAttemptsError{}
193
+
// Verify OTP code (constant-time comparison)
194
+
err = bcrypt.CompareHashAndPassword([]byte(req.OTPCodeHash), []byte(code))
196
+
// Increment attempts
197
+
_ = s.repo.IncrementAttempts(ctx, requestID)
199
+
// Log failed verification
200
+
_ = s.repo.LogAuditEvent(ctx, &AuditEvent{
202
+
EventType: "verification_failed",
203
+
PhoneHash: &req.PhoneHash,
204
+
Metadata: map[string]interface{}{"attempts": req.Attempts + 1},
205
+
CreatedAt: time.Now(),
208
+
return nil, &InvalidCodeError{}
211
+
// Code is valid - create verification
213
+
expiresAt := now.Add(VerificationTTL)
215
+
// Create verification data for signing
216
+
verificationData := &VerificationData{
218
+
VerifiedBy: s.signer.GetVerifierDID(),
220
+
ExpiresAt: expiresAt,
224
+
// Sign verification
225
+
signature, err := s.signer.SignVerification(ctx, verificationData)
227
+
return nil, fmt.Errorf("failed to sign verification: %w", err)
230
+
// Create signed verification for PDS
231
+
signedVerification := &SignedVerification{
232
+
Type: verificationData.Type,
233
+
VerifiedBy: verificationData.VerifiedBy,
234
+
VerifiedAt: verificationData.VerifiedAt.Format(time.RFC3339),
235
+
ExpiresAt: verificationData.ExpiresAt.Format(time.RFC3339),
236
+
Signature: signature,
239
+
// Write to PDS profile
240
+
if err := s.pdsWriter.WriteVerificationToProfile(ctx, did, signedVerification); err != nil {
241
+
return nil, &PDSWriteFailedError{DID: did, Reason: err.Error()}
244
+
// Store verification in AppView database
245
+
phoneVerification := &PhoneVerification{
247
+
PhoneHash: req.PhoneHash,
249
+
ExpiresAt: expiresAt,
251
+
if err := s.repo.StoreVerification(ctx, phoneVerification); err != nil {
252
+
return nil, fmt.Errorf("failed to store verification: %w", err)
255
+
// Clean up request
256
+
_ = s.repo.DeleteVerificationRequest(ctx, requestID)
258
+
// Log successful verification
259
+
_ = s.repo.LogAuditEvent(ctx, &AuditEvent{
261
+
EventType: "verification_success",
262
+
PhoneHash: &req.PhoneHash,
263
+
CreatedAt: time.Now(),
266
+
return &VerificationResult{
269
+
ExpiresAt: expiresAt,
273
+
// GetVerificationStatus retrieves current verification status for a user
274
+
func (s *verificationService) GetVerificationStatus(ctx context.Context, did string) (*VerificationStatus, error) {
275
+
verification, err := s.repo.GetVerification(ctx, did)
276
+
if err != nil || verification == nil {
277
+
return &VerificationStatus{
278
+
HasVerifiedPhone: false,
282
+
// Check if verification has expired
283
+
if time.Now().After(verification.ExpiresAt) {
284
+
return &VerificationStatus{
285
+
HasVerifiedPhone: false,
289
+
// Check if renewal needed (within 30 days of expiry)
290
+
needsRenewal := time.Until(verification.ExpiresAt) < 30*24*time.Hour
292
+
return &VerificationStatus{
293
+
HasVerifiedPhone: true,
294
+
VerifiedAt: &verification.VerifiedAt,
295
+
ExpiresAt: &verification.ExpiresAt,
296
+
NeedsRenewal: needsRenewal,
300
+
// CheckPhoneAvailability checks if phone number is already verified
301
+
func (s *verificationService) CheckPhoneAvailability(ctx context.Context, phoneNumber string) (bool, error) {
302
+
phoneHash := s.hashProvider.HashPhone(phoneNumber)
303
+
verification, err := s.repo.GetVerificationByPhoneHash(ctx, phoneHash)
307
+
return verification == nil, nil
310
+
// generateOTP generates a cryptographically secure numeric OTP
311
+
func generateOTP(length int) (string, error) {
312
+
max := new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(length)), nil)
313
+
n, err := rand.Int(rand.Reader, max)
317
+
return fmt.Sprintf("%0*d", length, n), nil
320
+
// generateRequestID generates a unique request ID
321
+
func generateRequestID() string {
322
+
b := make([]byte, 16)
324
+
return fmt.Sprintf("%x", b)