|
| 1 | +# Fraud Protection: Exclude Verified Countries from IP-Country Warning |
| 2 | + |
| 3 | +## Summary |
| 4 | + |
| 5 | +Adjust `SMS__PHONE_COUNTRIES__BY_IP__DAILY_THRESHOLD_EXCEEDED` so it counts only countries that have **no verified SMS OTP in the same 24h window**. |
| 6 | + |
| 7 | +If an IP has at least one verified OTP for a country during that window, that country is excluded from the distinct-country count. The threshold remains `3`. |
| 8 | + |
| 9 | +This is a behavior change only. No backward-compatibility work is required. |
| 10 | + |
| 11 | +## Runtime Change |
| 12 | + |
| 13 | +### Current behavior |
| 14 | + |
| 15 | +`pkg/lib/fraudprotection/leaky_bucket_store.go` currently tracks distinct phone countries per IP in the Redis ZSET keyed by: |
| 16 | + |
| 17 | +`app:{appID}:fraud_protection:ip_countries:{ip}` |
| 18 | + |
| 19 | +`RecordSMSOTPSent(...)` updates that ZSET and triggers `IPCountriesDaily` when the raw distinct-country count exceeds the fixed threshold. |
| 20 | + |
| 21 | +### New behavior |
| 22 | + |
| 23 | +Add a second Redis ZSET per IP to record countries that have at least one verified SMS OTP in the same 24h window: |
| 24 | + |
| 25 | +`app:{appID}:fraud_protection:ip_verified_countries:{ip}` |
| 26 | + |
| 27 | +The send-path warning becomes: |
| 28 | + |
| 29 | +1. Count distinct countries seen from the IP in the last 24h. |
| 30 | +2. Remove any country that appears in the verified-country ZSET for that IP. |
| 31 | +3. Trigger `SMS__PHONE_COUNTRIES__BY_IP__DAILY_THRESHOLD_EXCEEDED` only when the filtered count is `> 3`. |
| 32 | + |
| 33 | +### Interface Details |
| 34 | + |
| 35 | +#### `pkg/lib/fraudprotection/service.go` |
| 36 | + |
| 37 | +`LeakyBucketer` becomes: |
| 38 | + |
| 39 | +```go |
| 40 | +type LeakyBucketer interface { |
| 41 | + RecordSMSOTPSent(ctx context.Context, ip, phoneCountry string, thresholds LeakyBucketThresholds) (LeakyBucketTriggered, LeakyBucketLevels, error) |
| 42 | + RecordSMSOTPVerified(ctx context.Context, ip, phoneCountry string, thresholds LeakyBucketThresholds, count int) error |
| 43 | + RecordSMSOTPVerifiedCountry(ctx context.Context, ip, phoneCountry string) error |
| 44 | +} |
| 45 | +``` |
| 46 | + |
| 47 | +`Service.RecordSMSOTPVerified(ctx, phoneNumber)` keeps the current parse/write/drain flow, but now invokes the bucket store in this order: |
| 48 | + |
| 49 | +1. read `ip` from the request context and parse `phoneNumber` to get `phoneCountry` |
| 50 | +2. `Metrics.RecordVerified(ctx, ip, phoneCountry)` |
| 51 | +3. `LeakyBucket.RecordSMSOTPVerifiedCountry(ctx, ip, phoneCountry)` |
| 52 | +4. `RevertSMSOTPSent(ctx, phoneNumber, 1)` |
| 53 | + |
| 54 | +The verified-country update is a side effect of a successful SMS OTP consumption only. It is not part of the alt-auth revert path. |
| 55 | + |
| 56 | +This split matters because the lower-level `LeakyBucketStore.RecordSMSOTPVerified(...)` method is also used by `RevertSMSOTPSent(...)` to drain unverified OTPs that were sent during a flow but never consumed. If the same method also marked a country as verified, alt-auth cleanup would incorrectly promote unverified sends into the verified-country set. |
| 57 | + |
| 58 | +So the invariant is: |
| 59 | + |
| 60 | +- service-level `RecordSMSOTPVerified(...)` = actual verification event |
| 61 | +- store-level `RecordSMSOTPVerified(...)` = drain-only bookkeeping for verified and reverted counts |
| 62 | +- store-level `RecordSMSOTPVerifiedCountry(...)` = explicit marker for a real verified OTP |
| 63 | + |
| 64 | +#### `pkg/lib/fraudprotection/leaky_bucket_store.go` |
| 65 | + |
| 66 | +`leakyBucketScript` remains unchanged and continues to be used only for the four leaky buckets on the send path. |
| 67 | + |
| 68 | +`ipCountriesScript` remains the script that computes the IP-country warning on the send path. That is where the filtered distinct-country count is evaluated. |
| 69 | + |
| 70 | +The verified-country marker is implemented by `RecordSMSOTPVerifiedCountry(ctx, ip, phoneCountry)`, which writes to the IP-scoped verified-country ZSET. It can reuse the same 24h retention model as the send-path country ZSET, but it is intentionally a separate store method so the service can call it only for real OTP consumption, not for alt-auth cleanup. |
| 71 | + |
| 72 | +`RecordSMSOTPVerified(ctx, ip, phoneCountry, thresholds, count)` remains drain-only and is still used by `RevertSMSOTPSent(...)` for unverified OTP cleanup. |
| 73 | + |
| 74 | +The filtered IP-country count is computed in the send-path script, not in Go: |
| 75 | + |
| 76 | +```go |
| 77 | +res, err := conn.Eval(ctx, ipCountriesScript, |
| 78 | + []string{s.ipCountriesKey(ip), s.ipVerifiedCountriesKey(ip)}, |
| 79 | + phoneCountry, now, ipCountriesThreshold, 2*bucketWindowDaily, |
| 80 | +).Slice() |
| 81 | +``` |
| 82 | + |
| 83 | +`ipCountriesScript` is responsible for: |
| 84 | + |
| 85 | +1. `ZADD` the sent country into `ip_countries` |
| 86 | +2. prune expired entries from both `ip_countries` and `ip_verified_countries` |
| 87 | +3. `ZRANGE` both ZSETs and build a Lua lookup table for the verified countries |
| 88 | +4. count the distinct sent countries that are not present in the verified-country lookup table |
| 89 | +5. return `{filtered_count, triggered_int}` |
| 90 | + |
| 91 | +This keeps the send-path warning atomic with the country update and avoids a race between sent-country recording and verified-country exclusion. |
| 92 | + |
| 93 | +## File-Level Changes |
| 94 | + |
| 95 | +### `pkg/lib/fraudprotection/leaky_bucket_store.go` |
| 96 | + |
| 97 | +- Keep the existing `ip_countries` ZSET and the four leaky buckets unchanged. |
| 98 | +- Add a verified-country ZSET helper named `ipVerifiedCountriesKey(ip string) string`. |
| 99 | +- Add a new store method `RecordSMSOTPVerifiedCountry(ctx context.Context, ip, phoneCountry string) error`. |
| 100 | +- Keep `RecordSMSOTPVerified(...)` drain-only. |
| 101 | +- Update `RecordSMSOTPSent(...)` so the IP-country warning uses the filtered count described above. |
| 102 | + |
| 103 | +### `pkg/lib/fraudprotection/service.go` |
| 104 | + |
| 105 | +- Extend `LeakyBucketer` with the new verified-country recording method. |
| 106 | +- Update `Service.RecordSMSOTPVerified(...)` so the verified-OTP flow becomes: |
| 107 | + 1. parse the phone number |
| 108 | + 2. write the `sms_otp_verified` metric |
| 109 | + 3. call `RecordSMSOTPVerifiedCountry(...)` |
| 110 | + 4. call `RevertSMSOTPSent(..., 1)` to drain the leaky buckets through the existing path |
| 111 | +- Leave `CheckAndRecord(...)`, threshold computation, and warning mapping unchanged. |
| 112 | + |
| 113 | +### `docs/specs/fraud-protection.md` |
| 114 | + |
| 115 | +- Rewrite the `SMS__PHONE_COUNTRIES__BY_IP__DAILY_THRESHOLD_EXCEEDED` section to state that the warning counts only countries without a verified SMS OTP in the same 24h window. |
| 116 | +- Keep the threshold at `3`. |
| 117 | + |
| 118 | +## Test Plan |
| 119 | + |
| 120 | +### Unit tests |
| 121 | + |
| 122 | +#### `pkg/lib/fraudprotection/leaky_bucket_store_test.go` |
| 123 | + |
| 124 | +- Add coverage for the new verified-country key helper. |
| 125 | +- Add a case proving that a verified country is excluded from the IP-country count. |
| 126 | +- Add a case proving the verified-country marker respects the same 24h expiry behavior as the existing country set. |
| 127 | +- Keep the current regression that proves four unverified countries from one IP still trigger the warning. |
| 128 | +- Add a case proving `RecordSMSOTPVerifiedCountry(...)` is called only for actual verification, not for alt-auth cleanup. |
| 129 | +- Add a case proving `RevertSMSOTPSent(...)` still drains the buckets and does not mark verified countries. |
| 130 | + |
| 131 | +#### `pkg/lib/fraudprotection/service_test.go` |
| 132 | + |
| 133 | +- Extend the leaky-bucket stub with the new verified-country method. |
| 134 | +- Add a test that `RecordSMSOTPVerified(...)` records the verified-country marker and still drains the buckets. |
| 135 | + |
| 136 | +### E2E tests |
| 137 | + |
| 138 | +- Keep `e2e/tests/fraud_protection/sms_phone_countries_by_ip_daily.test.yaml` as the baseline regression for the unverified-country case. |
| 139 | +- Add a new e2e test under `e2e/tests/fraud_protection/` that: |
| 140 | + - verifies one country first |
| 141 | + - sends unverified OTPs to three other countries successfully |
| 142 | + - blocks on the 4th unverified country |
| 143 | + - proves the verified country does not contribute to the threshold |
| 144 | + |
| 145 | +## Assumptions |
| 146 | + |
| 147 | +- “In the period” means the existing 24h sliding window. |
| 148 | +- The verified-country marker is keyed by IP because the warning itself is IP-scoped. |
| 149 | +- Old Redis keys can expire naturally; no migration or backfill is needed. |
| 150 | +- No config schema, database schema, or generated code changes are required. |
| 151 | + |
| 152 | +## Implementation Order |
| 153 | + |
| 154 | +1. Add the verified-country Redis storage and filtered counting logic in `pkg/lib/fraudprotection/leaky_bucket_store.go`. |
| 155 | +2. Wire the new method through `pkg/lib/fraudprotection/service.go`. |
| 156 | +3. Update unit tests for store and service behavior. |
| 157 | +4. Update the fraud-protection spec text. |
| 158 | +5. Add the e2e regression test for the verified-country exclusion case. |
| 159 | + |
| 160 | +## Atomic Commits |
| 161 | + |
| 162 | +1. `fraud: exclude verified countries from SMS IP-country counting` |
| 163 | + - Files: `pkg/lib/fraudprotection/leaky_bucket_store.go`, `pkg/lib/fraudprotection/service.go`, `pkg/lib/fraudprotection/leaky_bucket_store_test.go`, `pkg/lib/fraudprotection/service_test.go` |
| 164 | + - Scope: storage, service wiring, and unit coverage. |
| 165 | +2. `doc,e2e: update SMS IP-country fraud protection semantics` |
| 166 | + - Files: `docs/specs/fraud-protection.md`, `e2e/tests/fraud_protection/*.test.yaml` |
| 167 | + - Scope: spec wording and end-to-end regression coverage. |
0 commit comments