A community based topic aggregation platform built on atproto
1package verification
2
3import (
4 "context"
5 "crypto/rand"
6 "crypto/subtle"
7 "fmt"
8 "math/big"
9 "regexp"
10 "time"
11
12 "golang.org/x/crypto/bcrypt"
13)
14
15const (
16 OTPLength = 6
17 OTPExpiryMinutes = 10
18 MaxAttempts = 3
19 VerificationTTL = 365 * 24 * time.Hour // 1 year
20
21 // Rate limits
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
26)
27
28var (
29 // E.164 phone number regex (basic validation)
30 e164Regex = regexp.MustCompile(`^\+[1-9]\d{1,14}$`)
31)
32
33// verificationService implements VerificationService
34type verificationService struct {
35 repo VerificationRepository
36 smsProvider SMSProvider
37 signer SignatureService
38 pdsWriter PDSWriter
39 hashProvider PhoneHashProvider
40}
41
42// PhoneHashProvider handles phone number hashing
43type PhoneHashProvider interface {
44 HashPhone(phoneNumber string) string
45}
46
47// NewVerificationService creates a new verification service
48func NewVerificationService(
49 repo VerificationRepository,
50 smsProvider SMSProvider,
51 signer SignatureService,
52 pdsWriter PDSWriter,
53 hashProvider PhoneHashProvider,
54) VerificationService {
55 return &verificationService{
56 repo: repo,
57 smsProvider: smsProvider,
58 signer: signer,
59 pdsWriter: pdsWriter,
60 hashProvider: hashProvider,
61 }
62}
63
64// RequestPhoneVerification sends an OTP code to the provided phone number
65func (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}
69 }
70
71 phoneHash := s.hashProvider.HashPhone(phoneNumber)
72
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}
77 }
78
79 // Check rate limits (per phone)
80 phoneExceeded, err := s.repo.CheckRateLimit(ctx, "phone:"+phoneHash, RateLimitPerPhone, PhoneRateWindow)
81 if err != nil {
82 return nil, fmt.Errorf("failed to check phone rate limit: %w", err)
83 }
84 if phoneExceeded {
85 // Log audit event
86 _ = s.repo.LogAuditEvent(ctx, &AuditEvent{
87 DID: &did,
88 EventType: "rate_limit_hit",
89 PhoneHash: &phoneHash,
90 Metadata: map[string]interface{}{"limit_type": "phone"},
91 CreatedAt: time.Now(),
92 })
93 return nil, &RateLimitExceededError{Identifier: "phone", RetryAfter: 3600}
94 }
95
96 // Check rate limits (per DID)
97 didExceeded, err := s.repo.CheckRateLimit(ctx, "did:"+did, RateLimitPerDID, DIDRateWindow)
98 if err != nil {
99 return nil, fmt.Errorf("failed to check DID rate limit: %w", err)
100 }
101 if didExceeded {
102 _ = s.repo.LogAuditEvent(ctx, &AuditEvent{
103 DID: &did,
104 EventType: "rate_limit_hit",
105 PhoneHash: &phoneHash,
106 Metadata: map[string]interface{}{"limit_type": "did"},
107 CreatedAt: time.Now(),
108 })
109 return nil, &RateLimitExceededError{Identifier: "did", RetryAfter: 86400}
110 }
111
112 // Generate OTP code
113 otpCode, err := generateOTP(OTPLength)
114 if err != nil {
115 return nil, fmt.Errorf("failed to generate OTP: %w", err)
116 }
117
118 // Hash OTP code for storage
119 otpHash, err := bcrypt.GenerateFromPassword([]byte(otpCode), bcrypt.DefaultCost)
120 if err != nil {
121 return nil, fmt.Errorf("failed to hash OTP: %w", err)
122 }
123
124 // Create verification request
125 req := &VerificationRequest{
126 RequestID: generateRequestID(),
127 DID: did,
128 PhoneHash: phoneHash,
129 OTPCodeHash: string(otpHash),
130 Attempts: 0,
131 CreatedAt: time.Now(),
132 ExpiresAt: time.Now().Add(OTPExpiryMinutes * time.Minute),
133 }
134
135 // Store request
136 if err := s.repo.StoreVerificationRequest(ctx, req); err != nil {
137 return nil, fmt.Errorf("failed to store verification request: %w", err)
138 }
139
140 // Send SMS
141 if err := s.smsProvider.SendOTP(ctx, phoneNumber, otpCode); err != nil {
142 // Log failed SMS delivery
143 _ = s.repo.LogAuditEvent(ctx, &AuditEvent{
144 DID: &did,
145 EventType: "sms_delivery_failed",
146 PhoneHash: &phoneHash,
147 Metadata: map[string]interface{}{"error": err.Error()},
148 CreatedAt: time.Now(),
149 })
150 return nil, &SMSDeliveryFailedError{Reason: err.Error()}
151 }
152
153 // Record rate limit attempt
154 _ = s.repo.RecordRateLimitAttempt(ctx, "phone:"+phoneHash)
155 _ = s.repo.RecordRateLimitAttempt(ctx, "did:"+did)
156
157 // Log audit event
158 _ = s.repo.LogAuditEvent(ctx, &AuditEvent{
159 DID: &did,
160 EventType: "request_sent",
161 PhoneHash: &phoneHash,
162 CreatedAt: time.Now(),
163 })
164
165 return req, nil
166}
167
168// VerifyPhone validates the OTP code and writes verification to PDS
169func (s *verificationService) VerifyPhone(ctx context.Context, did, requestID, code string) (*VerificationResult, error) {
170 // Retrieve verification request
171 req, err := s.repo.GetVerificationRequest(ctx, requestID)
172 if err != nil {
173 return nil, &RequestNotFoundError{RequestID: requestID}
174 }
175
176 // Check if request belongs to this DID
177 if req.DID != did {
178 return nil, &RequestNotFoundError{RequestID: requestID}
179 }
180
181 // Check if expired
182 if time.Now().After(req.ExpiresAt) {
183 _ = s.repo.DeleteVerificationRequest(ctx, requestID)
184 return nil, &CodeExpiredError{}
185 }
186
187 // Check attempt limit
188 if req.Attempts >= MaxAttempts {
189 _ = s.repo.DeleteVerificationRequest(ctx, requestID)
190 return nil, &TooManyAttemptsError{}
191 }
192
193 // Verify OTP code (constant-time comparison)
194 err = bcrypt.CompareHashAndPassword([]byte(req.OTPCodeHash), []byte(code))
195 if err != nil {
196 // Increment attempts
197 _ = s.repo.IncrementAttempts(ctx, requestID)
198
199 // Log failed verification
200 _ = s.repo.LogAuditEvent(ctx, &AuditEvent{
201 DID: &did,
202 EventType: "verification_failed",
203 PhoneHash: &req.PhoneHash,
204 Metadata: map[string]interface{}{"attempts": req.Attempts + 1},
205 CreatedAt: time.Now(),
206 })
207
208 return nil, &InvalidCodeError{}
209 }
210
211 // Code is valid - create verification
212 now := time.Now()
213 expiresAt := now.Add(VerificationTTL)
214
215 // Create verification data for signing
216 verificationData := &VerificationData{
217 Type: "phone",
218 VerifiedBy: s.signer.GetVerifierDID(),
219 VerifiedAt: now,
220 ExpiresAt: expiresAt,
221 SubjectDID: did,
222 }
223
224 // Sign verification
225 signature, err := s.signer.SignVerification(ctx, verificationData)
226 if err != nil {
227 return nil, fmt.Errorf("failed to sign verification: %w", err)
228 }
229
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,
237 }
238
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()}
242 }
243
244 // Store verification in AppView database
245 phoneVerification := &PhoneVerification{
246 DID: did,
247 PhoneHash: req.PhoneHash,
248 VerifiedAt: now,
249 ExpiresAt: expiresAt,
250 }
251 if err := s.repo.StoreVerification(ctx, phoneVerification); err != nil {
252 return nil, fmt.Errorf("failed to store verification: %w", err)
253 }
254
255 // Clean up request
256 _ = s.repo.DeleteVerificationRequest(ctx, requestID)
257
258 // Log successful verification
259 _ = s.repo.LogAuditEvent(ctx, &AuditEvent{
260 DID: &did,
261 EventType: "verification_success",
262 PhoneHash: &req.PhoneHash,
263 CreatedAt: time.Now(),
264 })
265
266 return &VerificationResult{
267 Verified: true,
268 VerifiedAt: now,
269 ExpiresAt: expiresAt,
270 }, nil
271}
272
273// GetVerificationStatus retrieves current verification status for a user
274func (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,
279 }, nil
280 }
281
282 // Check if verification has expired
283 if time.Now().After(verification.ExpiresAt) {
284 return &VerificationStatus{
285 HasVerifiedPhone: false,
286 }, nil
287 }
288
289 // Check if renewal needed (within 30 days of expiry)
290 needsRenewal := time.Until(verification.ExpiresAt) < 30*24*time.Hour
291
292 return &VerificationStatus{
293 HasVerifiedPhone: true,
294 VerifiedAt: &verification.VerifiedAt,
295 ExpiresAt: &verification.ExpiresAt,
296 NeedsRenewal: needsRenewal,
297 }, nil
298}
299
300// CheckPhoneAvailability checks if phone number is already verified
301func (s *verificationService) CheckPhoneAvailability(ctx context.Context, phoneNumber string) (bool, error) {
302 phoneHash := s.hashProvider.HashPhone(phoneNumber)
303 verification, err := s.repo.GetVerificationByPhoneHash(ctx, phoneHash)
304 if err != nil {
305 return false, err
306 }
307 return verification == nil, nil
308}
309
310// generateOTP generates a cryptographically secure numeric OTP
311func 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)
314 if err != nil {
315 return "", err
316 }
317 return fmt.Sprintf("%0*d", length, n), nil
318}
319
320// generateRequestID generates a unique request ID
321func generateRequestID() string {
322 b := make([]byte, 16)
323 rand.Read(b)
324 return fmt.Sprintf("%x", b)
325}