Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
479 changes: 473 additions & 6 deletions src/app/authentication/request-code.ts

Large diffs are not rendered by default.

53 changes: 53 additions & 0 deletions src/config/blocked-countries.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// Destinations whose auth-code traffic we refuse to pay for.
//
// Every country here sent us auth-code traffic with zero conversions over the
// full Twilio retention window, and each was a source of the 2026-08-25
// SMS-pumping attack. Countries with even one real signup (JM, US, NG, IN, GB,
// CA, DE, GH, KY, BJ, RW, SD, CD, MV, BD, BE, UG, TT, ML, CO, SK) are absent by
// design: this is a fraud control, not a market policy.
//
// INVARIANT — no entry may share a calling code with a region that is NOT
// blocked. `checkAuthCodeDestination` cannot always name the region of a number
// it parses (~340 assigned NANP area codes are missing from the pinned
// libphonenumber-js metadata), so it falls back to gating such a number against
// EVERY region its calling code could denote, and blocks if any of them is
// blocked. Adding DO (+1 809/829/849) or any other NANP region to this list
// would therefore reject ordinary US numbers on those overlays — silently, from
// a routine configmap edit. `reportAmbiguousBlockedCountries` in
// src/config/yaml.ts re-checks the merged configmap at startup and LOGS AT
// ERROR LEVEL for a NANP entry — it does not throw, so a bad configmap entry
// ships and a Ready pod quietly rejects those numbers; the log line is the only
// backstop. Only the defaults below are pinned hard, by
// test/flash/unit/config/schema.spec.ts.
//
// This list lives in its own file so typos.toml can exclude the ISO 3166-1
// alpha-2 codes ("BA" is read as a misspelling of "BY"/"BE", and BY is itself
// an entry here) without either opening a repo-global spell-check hole or
// dropping the live config file src/config/schema.ts from spell checking.
export const SMS_PUMPING_HIGH_RISK_COUNTRIES = [
"TR",
"UZ",
"RU",
"IL",
"AM",
"UA",
"TZ",
"EC",
"BY",
"ZM",
"MR",
"BA",
"TN",
"CI",
"BI",
"TG",
"VE",
"XK",
"GN",
"SL",
"SN",
"CM",
"MZ",
"CF",
"LB",
]
37 changes: 36 additions & 1 deletion src/config/schema.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { AccountRoles, AccountStatus } from "@domain/accounts/primitives"
import { WalletCurrency } from "@domain/shared"

import { SMS_PUMPING_HIGH_RISK_COUNTRIES } from "./blocked-countries"

const displayCurrencyConfigSchema = {
type: "object",
properties: {
Expand Down Expand Up @@ -330,7 +332,7 @@ export const configSchema = {
blockDuration: 10800,
},
requestCodePerIp: {
points: 16,
points: 8,
duration: 3600,
blockDuration: 86400,
},
Expand Down Expand Up @@ -658,6 +660,13 @@ export const configSchema = {
chanId: [],
},
},
// Countries hidden from the client's country picker (`globals
// .supportedCountries`). This is a MARKET/PRESENTATION list, and it is
// deliberately NOT the fraud control: a country hidden here cannot be
// selected in the app at all, so a number in it never reaches the server
// and the existing-user carve-out in requestPhoneCode* is unreachable for
// it. Seeding this with the block list below would therefore lock every
// existing account in those countries out of its own login code.
smsAuthUnsupportedCountries: {
type: "array",
items: { type: "string" },
Expand All @@ -668,6 +677,32 @@ export const configSchema = {
items: { type: "string" },
default: [],
},
// Destinations that produced auth-code traffic but never a single
// conversion, and are the origin of the 2026-08-25 SMS-pumping attack.
// This is the FRAUD CONTROL: enforced server-side in requestPhoneCode*
// before any Twilio spend, while the picker still offers the country so an
// existing account there can ask for a login code and be served by the
// carve-out. Drop a country from this list when Flash opens that market.
//
// Each key gets its OWN copy of the seed. Ajv's `useDefaults` assigns
// defaults by reference, so sharing one array instance would make
// `yamlConfig.smsAuthBlockedCountries`,
// `yamlConfig.whatsAppAuthBlockedCountries` and this schema object the same
// live array in every environment whose configmap sets neither key — and
// these two keys exist precisely so they can diverge.
//
// See src/config/blocked-countries.ts for the no-shared-calling-code
// invariant the gate depends on.
smsAuthBlockedCountries: {
type: "array",
items: { type: "string" },
default: [...SMS_PUMPING_HIGH_RISK_COUNTRIES],
},
whatsAppAuthBlockedCountries: {
type: "array",
items: { type: "string" },
default: [...SMS_PUMPING_HIGH_RISK_COUNTRIES],
},
ibex: {
type: "object",
properties: {
Expand Down
2 changes: 2 additions & 0 deletions src/config/schema.types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,8 @@ type YamlSchema = {
skipFeeProbeConfig: { pubkey: string[]; chanId: string[] }
smsAuthUnsupportedCountries: string[]
whatsAppAuthUnsupportedCountries: string[]
smsAuthBlockedCountries: string[]
whatsAppAuthBlockedCountries: string[]
ibex: IbexConfig
bridge: BridgeConfig
exchangeRates: StaticRates
Expand Down
134 changes: 134 additions & 0 deletions src/config/yaml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import path from "path"
import Ajv from "ajv"
import { load as loadYaml } from "js-yaml"
import { I18n } from "i18n"
import { getCountries, getCountryCallingCode } from "libphonenumber-js"

import { baseLogger } from "@services/logger"
import { checkedToScanDepth } from "@domain/bitcoin/onchain"
Expand Down Expand Up @@ -203,6 +204,40 @@ export const getRequestCodePerLoginIdentifierLimits = () =>
export const getRequestCodePerIpLimits = () =>
getRateLimits(yamlConfig.rateLimits.requestCodePerIp)

/**
* Auth-code requests for a country whose destinations we refuse to pay for,
* per IP.
*
* The country gate rejects these before any provider spend, which is the point
* — but it also means probing costs the attacker nothing, and the existing-user
* carve-out makes the response differ by whether the number holds an account.
* That is an account-existence oracle, and the per-IP request-code budget (8/h)
* is far too generous to bound it. Tighter than that budget: a confirmed
* account refunds its point, so only sweeps over numbers that do NOT exist burn
* it.
*
* Not tighter still. This bucket is keyed on `req.originalIp`, so it is spent
* by mistyped numbers and shared by everyone behind one office NAT or CGNAT
* egress. At 2 points a real UZ account holder who fat-fingers their number
* twice is denied their own login code for an hour, and so is the second person
* behind a shared address — no attacker involved. The bound 2 bought over 5 is
* negligible anyway: a sweep is equally dead at 5/IP/h, and the 2026-08-25
* attacker drove ~100 rotating IPs, so the per-IP ceiling was never the binding
* constraint on enumeration.
*/
export const getRequestCodeBlockedCountryPerIpLimits = () => ({
points: 5,
duration: toSeconds(3600), // 1 hour
// One hour, NOT the 24 used by the other auth limiters. This one is keyed on
// `req.originalIp` (the `x-real-ip` header), and a large share of Flash's
// users reach us from behind carrier-grade NAT — one mobile egress address
// covers many subscribers. A 24h block means two sweep probes from that
// address cost every real customer behind it a full day of their own login
// codes. The bound that actually limits a sweep is `points` probes/IP/hour;
// the shorter block only decides how fast a shared-IP false positive heals.
blockDuration: toSeconds(3600), // 1 hour
})

export const getFailedLoginAttemptPerLoginIdentifierLimits = () =>
getRateLimits(yamlConfig.rateLimits.failedLoginAttemptPerLoginIdentifier)

Expand Down Expand Up @@ -408,6 +443,9 @@ export const getSwapConfig = (): SwapConfig => {
}
}

// Countries hidden from the client's country picker. Presentation only — a
// country listed here can never be selected in the app, so nothing in it ever
// reaches the auth-code endpoint.
export const getSmsAuthUnsupportedCountries = (): CountryCode[] => {
return yamlConfig.smsAuthUnsupportedCountries as CountryCode[]
}
Expand All @@ -416,6 +454,102 @@ export const getWhatsAppAuthUnsupportedCountries = (): CountryCode[] => {
return yamlConfig.whatsAppAuthUnsupportedCountries as CountryCode[]
}

// Countries whose auth-code destinations are refused server-side before any
// provider spend. Deliberately separate from the picker lists above: the
// existing-user carve-out only works if the country can still be selected.
export const getSmsAuthBlockedCountries = (): CountryCode[] => {
return yamlConfig.smsAuthBlockedCountries as CountryCode[]
}

export const getWhatsAppAuthBlockedCountries = (): CountryCode[] => {
return yamlConfig.whatsAppAuthBlockedCountries as CountryCode[]
}

const NANP_CALLING_CODE = "1"

/**
* Reports blocked countries that share a calling code with a region that is NOT
* blocked.
*
* `checkAuthCodeDestination` cannot always name the region of a number it
* parses — ~340 assigned NANP area codes are absent from the pinned
* libphonenumber-js metadata — so it falls back to gating such a number against
* EVERY region its calling code could denote, and fails closed if any of them
* is blocked. Every entry on the list therefore blocks its unattributable
* siblings too.
*
* `+1` is a different order of severity from the rest, so it gets its own
* level. Blocking any NANP region (DO's 809/829/849, say) rejects ordinary US
* numbers on +1 983 / +1 738 / +1 924 / +1 472 — a core market, broken silently
* by a one-line configmap edit. Any other shared calling code costs a market we
* did not choose to block (today: KZ, behind RU on +7), which is worth a
* warning but is a deliberate trade.
*
* Checked here, against the MERGED config, because the list is operator-tunable
* from the ops feed: a configmap can break this without touching the schema
* default that test/flash/unit/config/schema.spec.ts pins. Logged, not thrown —
* a bad entry must be loud, but must not wedge every pod in a crash loop at 3am.
*/
export const reportAmbiguousBlockedCountries = (
key: string,
blocked: readonly string[],
): void => {
// libphonenumber's own region type, not the branded domain `CountryCode`.
type Region = ReturnType<typeof getCountries>[number]

const normalized = new Set(blocked.map((code) => code.toUpperCase()))

for (const code of normalized) {
let callingCode: string
try {
callingCode = getCountryCallingCode(code as Region)
} catch {
// Not a region libphonenumber knows. The list is operator-editable via
// the configmap, so a typo or a non-region code (ZZ, say) must be skipped
// rather than thrown: such a code can never be a parsed number's region,
// so it cannot widen a candidate set either. Note XK does NOT land here —
// libphonenumber resolves it to +383.
continue
}

const unblockedSiblings = getCountries().filter(
(country) =>
country !== code &&
getCountryCallingCode(country) === callingCode &&
!normalized.has(country),
)
if (unblockedSiblings.length === 0) continue

const payload = { key, blockedCountry: code, callingCode, unblockedSiblings }

if (callingCode === NANP_CALLING_CODE) {
baseLogger.error(
payload,
`${key} blocks the NANP region ${code}: every +1 number whose region ` +
`libphonenumber cannot identify (~340 assigned US area codes) will now ` +
`be refused an auth code. Remove it.`,
)
continue
}

baseLogger.warn(
payload,
`${key} blocks ${code} (+${callingCode}), which shares that calling code ` +
`with ${unblockedSiblings.join(", ")}: numbers on it whose region cannot ` +
`be identified are refused for those regions too.`,
)
}
}

reportAmbiguousBlockedCountries(
"smsAuthBlockedCountries",
yamlConfig.smsAuthBlockedCountries as string[],
)
reportAmbiguousBlockedCountries(
"whatsAppAuthBlockedCountries",
yamlConfig.whatsAppAuthBlockedCountries as string[],
)

const { ask } = yamlConfig.exchangeRates["USD"]["JMD"]
const sellRate = JMDAmount.dollars(ask)
if (sellRate instanceof BigIntConversionError) throw sellRate
Expand Down
38 changes: 36 additions & 2 deletions src/domain/authentication/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@ import { ChannelType, PhoneCodeInvalidError } from "@domain/phone-provider"

import { EmailCodeInvalidError } from "./errors"

// The unsupported-country lists come straight from the configmap
// (src/config/yaml.ts casts them to CountryCode[] without validating), so an
// operator can write `uz` where `UZ` was meant. Comparing raw would make the
// fraud control silently do nothing and stop filtering the picker at the same
// time — both failures invisible. Normalize on every comparison instead.
const normalizeCountryCodes = (countries: CountryCode[]): string[] =>
countries.map((country) => String(country).toUpperCase())

export const getSupportedCountries = ({
allCountries,
unsupportedSmsCountries,
Expand All @@ -12,15 +20,18 @@ export const getSupportedCountries = ({
unsupportedWhatsAppCountries: CountryCode[]
}): Country[] => {
const countries: Country[] = []
const unsupportedSms = normalizeCountryCodes(unsupportedSmsCountries)
const unsupportedWhatsApp = normalizeCountryCodes(unsupportedWhatsAppCountries)

for (const country of allCountries) {
const supportedAuthMethods: ChannelType[] = []
const normalizedCountry = String(country).toUpperCase()

if (!unsupportedSmsCountries.includes(country)) {
if (!unsupportedSms.includes(normalizedCountry)) {
supportedAuthMethods.push(ChannelType.Sms)
}

if (!unsupportedWhatsAppCountries.includes(country)) {
if (!unsupportedWhatsApp.includes(normalizedCountry)) {
supportedAuthMethods.push(ChannelType.Whatsapp)
}

Expand All @@ -35,6 +46,29 @@ export const getSupportedCountries = ({
return countries
}

// The server-side fraud control, gated on the BLOCKED lists — not on the
// picker's unsupported lists. The two are separate config keys on purpose: a
// country hidden from the picker can never be selected, so the existing-user
// carve-out in requestPhoneCode* would be unreachable for it.
export const isAuthChannelSupportedForCountry = ({
countryCode,
channel,
blockedSmsCountries,
blockedWhatsAppCountries,
}: {
countryCode: CountryCode
channel: ChannelType
blockedSmsCountries: CountryCode[]
blockedWhatsAppCountries: CountryCode[]
}): boolean => {
const blockedCountries =
channel === ChannelType.Whatsapp ? blockedWhatsAppCountries : blockedSmsCountries

return !normalizeCountryCodes(blockedCountries).includes(
String(countryCode).toUpperCase(),
)
}

export const checkedToEmailCode = (code: string): EmailCode | ApplicationError => {
if (!/^[0-9]{6}$/.test(code)) return new EmailCodeInvalidError()
return code as EmailCode
Expand Down
1 change: 1 addition & 0 deletions src/domain/rate-limit/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export class UnknownRateLimitServiceError extends RateLimitServiceError {
export class RateLimiterExceededError extends RateLimitServiceError {}
export class UserCodeAttemptIdentifierRateLimiterExceededError extends RateLimiterExceededError {}
export class UserCodeAttemptIpRateLimiterExceededError extends RateLimiterExceededError {}
export class UserCodeAttemptBlockedCountryIpRateLimiterExceededError extends RateLimiterExceededError {}
export class CreateDeviceAccountIpRateLimiterExceededError extends RateLimiterExceededError {}
export class UserLoginIpRateLimiterExceededError extends RateLimiterExceededError {}
export class UserLoginIdentifierRateLimiterExceededError extends RateLimiterExceededError {}
Expand Down
Loading
Loading