diff --git a/go.mod b/go.mod index 7d06e3a5..177cc2d2 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( firebase.google.com/go/v4 v4.8.0 github.com/aws/aws-sdk-go-v2 v0.17.0 github.com/bits-and-blooms/bloom/v3 v3.1.0 - github.com/code-payments/code-protobuf-api v1.16.2 + github.com/code-payments/code-protobuf-api v1.16.5 github.com/emirpasic/gods v1.12.0 github.com/envoyproxy/protoc-gen-validate v1.0.4 github.com/golang-jwt/jwt/v5 v5.0.0 diff --git a/go.sum b/go.sum index 4d97be47..395102b5 100644 --- a/go.sum +++ b/go.sum @@ -121,8 +121,8 @@ github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWH github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I= github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= -github.com/code-payments/code-protobuf-api v1.16.2 h1:SH8I+JOYOnoARJ5h3jDX92vcoHeqm8UCQBlqP5516rI= -github.com/code-payments/code-protobuf-api v1.16.2/go.mod h1:pHQm75vydD6Cm2qHAzlimW6drysm489Z4tVxC2zHSsU= +github.com/code-payments/code-protobuf-api v1.16.5 h1:mQYhFbpqiXdecrSo9cbmP3zoUT37qWZAYOMcrWGyo78= +github.com/code-payments/code-protobuf-api v1.16.5/go.mod h1:pHQm75vydD6Cm2qHAzlimW6drysm489Z4tVxC2zHSsU= github.com/containerd/continuity v0.0.0-20190827140505-75bee3e2ccb6 h1:NmTXa/uVnDyp0TY5MKi197+3HWcnYWfnHGyaFthlnGw= github.com/containerd/continuity v0.0.0-20190827140505-75bee3e2ccb6/go.mod h1:GL3xCUCBDV3CZiTSEKksMWbLE66hEyuu9qyDOOqM47Y= github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= diff --git a/pkg/code/antispam/airdrop.go b/pkg/code/antispam/airdrop.go index 5701e301..18223328 100644 --- a/pkg/code/antispam/airdrop.go +++ b/pkg/code/antispam/airdrop.go @@ -62,7 +62,7 @@ func (g *Guard) AllowWelcomeBonus(ctx context.Context, owner *common.Account) (b } // Deny abusers from known phone ranges - if hasBannedPhoneNumberPrefix(verification.PhoneNumber) { + if isSanctionedPhoneNumber(verification.PhoneNumber) { log.Info("denying phone prefix") recordDenialEvent(ctx, actionWelcomeBonus, "phone prefix banned") return false, nil @@ -170,10 +170,10 @@ func (g *Guard) AllowReferralBonus( log := log.WithField("phone", verification.PhoneNumber) - // Deny abusers from known phone ranges - if hasBannedPhoneNumberPrefix(verification.PhoneNumber) { - log.Info("denying phone prefix") - recordDenialEvent(ctx, actionReferralBonus, "phone prefix banned") + // Deny users from sanctioned countries + if isSanctionedPhoneNumber(verification.PhoneNumber) { + log.Info("denying sanctioned country") + recordDenialEvent(ctx, actionReferralBonus, "sanctioned country") return false, nil } diff --git a/pkg/code/antispam/guard_test.go b/pkg/code/antispam/guard_test.go index 5449e3f8..ed6a5257 100644 --- a/pkg/code/antispam/guard_test.go +++ b/pkg/code/antispam/guard_test.go @@ -414,7 +414,7 @@ func TestAllowOpenAccounts_HappyPath(t *testing.T) { // Account isn't phone verified, so it cannot be created for i := 0; i < 5; i++ { - allow, _, err := env.guard.AllowOpenAccounts(env.ctx, ownerAccount1, pointer.String(memory_device_verifier.ValidDeviceToken)) + allow, _, _, err := env.guard.AllowOpenAccounts(env.ctx, ownerAccount1, pointer.String(memory_device_verifier.ValidDeviceToken)) require.NoError(t, err) assert.False(t, allow) } @@ -431,17 +431,19 @@ func TestAllowOpenAccounts_HappyPath(t *testing.T) { // New accounts are always denied when using a fake or unverifiable device. for i := 0; i < 5; i++ { - allow, _, err := env.guard.AllowOpenAccounts(env.ctx, ownerAccount1, pointer.String(memory_device_verifier.InvalidDeviceToken)) + allow, reason, _, err := env.guard.AllowOpenAccounts(env.ctx, ownerAccount1, pointer.String(memory_device_verifier.InvalidDeviceToken)) require.NoError(t, err) assert.False(t, allow) + assert.Equal(t, ReasonUnsupportedDevice, reason) - allow, _, err = env.guard.AllowOpenAccounts(env.ctx, ownerAccount1, nil) + allow, reason, _, err = env.guard.AllowOpenAccounts(env.ctx, ownerAccount1, nil) require.NoError(t, err) assert.False(t, allow) + assert.Equal(t, ReasonUnsupportedDevice, reason) } // The first account creation should always be successful - allow, successCallback, err := env.guard.AllowOpenAccounts(env.ctx, ownerAccount1, pointer.String(memory_device_verifier.ValidDeviceToken)) + allow, _, successCallback, err := env.guard.AllowOpenAccounts(env.ctx, ownerAccount1, pointer.String(memory_device_verifier.ValidDeviceToken)) require.NoError(t, err) assert.True(t, allow) require.NotNil(t, successCallback) @@ -454,7 +456,7 @@ func TestAllowOpenAccounts_HappyPath(t *testing.T) { // Account creation is unaffected by previous creations that didn't // result in success - allow, _, err = env.guard.AllowOpenAccounts(env.ctx, ownerAccount1, pointer.String(memory_device_verifier.ValidDeviceToken)) + allow, _, _, err = env.guard.AllowOpenAccounts(env.ctx, ownerAccount1, pointer.String(memory_device_verifier.ValidDeviceToken)) require.NoError(t, err) assert.True(t, allow) @@ -464,9 +466,10 @@ func TestAllowOpenAccounts_HappyPath(t *testing.T) { // Account creations are denied after breaching the daily count limit regardless // of owner account associated with the phone number for i := 0; i < 5; i++ { - allow, _, err := env.guard.AllowOpenAccounts(env.ctx, ownerAccount2, pointer.String(memory_device_verifier.ValidDeviceToken)) + allow, reason, _, err := env.guard.AllowOpenAccounts(env.ctx, ownerAccount2, pointer.String(memory_device_verifier.ValidDeviceToken)) require.NoError(t, err) assert.False(t, allow) + assert.Equal(t, ReasonTooManyFreeAccountsForPhoneNumber, reason) } // New accounts are always denied within the same device @@ -482,9 +485,10 @@ func TestAllowOpenAccounts_HappyPath(t *testing.T) { require.NoError(t, env.guard.data.SavePhoneVerification(env.ctx, verification)) for i := 0; i < 5; i++ { - allow, _, err := env.guard.AllowOpenAccounts(env.ctx, ownerAccount2, pointer.String(memory_device_verifier.ValidDeviceToken)) + allow, reason, _, err := env.guard.AllowOpenAccounts(env.ctx, ownerAccount2, pointer.String(memory_device_verifier.ValidDeviceToken)) require.NoError(t, err) assert.False(t, allow) + assert.Equal(t, ReasonTooManyFreeAccountsForDevice, reason) } } } @@ -519,7 +523,7 @@ func TestAllowOpenAccounts_StaffUser(t *testing.T) { } // First account creation is always successful, regardless of user status - allow, _, err := env.guard.AllowOpenAccounts(env.ctx, ownerAccount1, pointer.String(memory_device_verifier.ValidDeviceToken)) + allow, _, _, err := env.guard.AllowOpenAccounts(env.ctx, ownerAccount1, pointer.String(memory_device_verifier.ValidDeviceToken)) require.NoError(t, err) assert.True(t, allow) @@ -527,7 +531,7 @@ func TestAllowOpenAccounts_StaffUser(t *testing.T) { simulateAccountCreation(t, env, ownerAccount1, intent.StateConfirmed, time.Now()) // Staff users should not be subject to any denials - allow, _, err = env.guard.AllowOpenAccounts(env.ctx, ownerAccount2, pointer.String(memory_device_verifier.ValidDeviceToken)) + allow, _, _, err = env.guard.AllowOpenAccounts(env.ctx, ownerAccount2, pointer.String(memory_device_verifier.ValidDeviceToken)) require.NoError(t, err) assert.Equal(t, isStaffUser, allow) } @@ -643,20 +647,22 @@ func TestAllowNewPhoneVerification_HappyPath(t *testing.T) { // New verifications are always allowed when the phone has never started one // and has a valid device token for i := 0; i < 5; i++ { - allow, err := env.guard.AllowNewPhoneVerification(env.ctx, phoneNumber, pointer.String(memory_device_verifier.ValidDeviceToken)) + allow, _, err := env.guard.AllowNewPhoneVerification(env.ctx, phoneNumber, pointer.String(memory_device_verifier.ValidDeviceToken)) require.NoError(t, err) assert.True(t, allow) } // New verifications are always denied when using a fake or unverifiable device. for i := 0; i < 5; i++ { - allow, err := env.guard.AllowNewPhoneVerification(env.ctx, phoneNumber, pointer.String(memory_device_verifier.InvalidDeviceToken)) + allow, reason, err := env.guard.AllowNewPhoneVerification(env.ctx, phoneNumber, pointer.String(memory_device_verifier.InvalidDeviceToken)) require.NoError(t, err) assert.False(t, allow) + assert.Equal(t, ReasonUnsupportedDevice, reason) - allow, err = env.guard.AllowNewPhoneVerification(env.ctx, phoneNumber, nil) + allow, reason, err = env.guard.AllowNewPhoneVerification(env.ctx, phoneNumber, nil) require.NoError(t, err) assert.False(t, allow) + assert.Equal(t, ReasonUnsupportedDevice, reason) } // New verifications are allowed when we're under the time interval limit, @@ -665,7 +671,7 @@ func TestAllowNewPhoneVerification_HappyPath(t *testing.T) { for j := 0; j < 3; j++ { simulateSmsCodeSent(t, env, phoneNumber, fmt.Sprintf("verification%d", i)) - allow, err := env.guard.AllowNewPhoneVerification(env.ctx, phoneNumber, pointer.String(memory_device_verifier.ValidDeviceToken)) + allow, _, err := env.guard.AllowNewPhoneVerification(env.ctx, phoneNumber, pointer.String(memory_device_verifier.ValidDeviceToken)) require.NoError(t, err) assert.True(t, allow) } @@ -675,13 +681,13 @@ func TestAllowNewPhoneVerification_HappyPath(t *testing.T) { // interval limit. simulateSmsCodeSent(t, env, phoneNumber, "last_allowed_verification") for i := 0; i < 5; i++ { - allow, err := env.guard.AllowNewPhoneVerification(env.ctx, phoneNumber, pointer.String(memory_device_verifier.ValidDeviceToken)) + allow, _, err := env.guard.AllowNewPhoneVerification(env.ctx, phoneNumber, pointer.String(memory_device_verifier.ValidDeviceToken)) require.NoError(t, err) assert.False(t, allow) } // Phone numbers are not affected by limits enforced on other phone numbers - allow, err := env.guard.AllowNewPhoneVerification(env.ctx, otherPhoneNumber, pointer.String(memory_device_verifier.ValidDeviceToken)) + allow, _, err := env.guard.AllowNewPhoneVerification(env.ctx, otherPhoneNumber, pointer.String(memory_device_verifier.ValidDeviceToken)) require.NoError(t, err) assert.True(t, allow) } @@ -705,7 +711,7 @@ func TestAllowNewPhoneVerification_StaffUser(t *testing.T) { for j := 0; j < 3; j++ { simulateSmsCodeSent(t, env, phoneNumber, fmt.Sprintf("verification%d", i)) - allow, err := env.guard.AllowNewPhoneVerification(env.ctx, phoneNumber, nil) + allow, _, err := env.guard.AllowNewPhoneVerification(env.ctx, phoneNumber, nil) require.NoError(t, err) assert.True(t, allow) } diff --git a/pkg/code/antispam/intent.go b/pkg/code/antispam/intent.go index dfce6de5..4527b23b 100644 --- a/pkg/code/antispam/intent.go +++ b/pkg/code/antispam/intent.go @@ -17,7 +17,7 @@ import ( // AllowOpenAccounts determines whether a phone-verified owner account can create // a Code account via an open accounts intent. The objective here is to limit attacks // against our Subsidizer's SOL balance. -func (g *Guard) AllowOpenAccounts(ctx context.Context, owner *common.Account, deviceToken *string) (bool, func() error, error) { +func (g *Guard) AllowOpenAccounts(ctx context.Context, owner *common.Account, deviceToken *string) (bool, Reason, func() error, error) { tracer := metrics.TraceMethodCall(ctx, metricsStructName, "AllowOpenAccounts") defer tracer.End() @@ -31,7 +31,7 @@ func (g *Guard) AllowOpenAccounts(ctx context.Context, owner *common.Account, de if isIpBanned(ctx) { log.Info("ip is banned") recordDenialEvent(ctx, actionOpenAccounts, "ip banned") - return false, nil, nil + return false, ReasonUnspecified, nil, nil } verification, err := g.data.GetLatestPhoneVerificationForAccount(ctx, owner.PublicKey().ToBase58()) @@ -39,20 +39,20 @@ func (g *Guard) AllowOpenAccounts(ctx context.Context, owner *common.Account, de // Owner account was never phone verified, so deny the action. log.Info("owner account is not phone verified") recordDenialEvent(ctx, actionOpenAccounts, "not phone verified") - return false, nil, nil + return false, ReasonUnspecified, nil, nil } else if err != nil { tracer.OnError(err) log.WithError(err).Warn("failure getting phone verification record") - return false, nil, err + return false, ReasonUnspecified, nil, err } log = log.WithField("phone", verification.PhoneNumber) - // Deny abusers from known phone ranges - if hasBannedPhoneNumberPrefix(verification.PhoneNumber) { - log.Info("denying phone prefix") - recordDenialEvent(ctx, actionOpenAccounts, "phone prefix banned") - return false, nil, nil + // Deny users from sanctioned countries + if isSanctionedPhoneNumber(verification.PhoneNumber) { + log.Info("denying sanctioned country") + recordDenialEvent(ctx, actionOpenAccounts, "sanctioned country") + return false, ReasonUnsupportedCountry, nil, nil } user, err := g.data.GetUserByPhoneView(ctx, verification.PhoneNumber) @@ -62,18 +62,18 @@ func (g *Guard) AllowOpenAccounts(ctx context.Context, owner *common.Account, de if user.IsBanned { log.Info("denying banned user") recordDenialEvent(ctx, actionOpenAccounts, "user banned") - return false, nil, nil + return false, ReasonUnspecified, nil, nil } // Staff users have unlimited access to enable testing and demoing. if user.IsStaffUser { - return true, func() error { return nil }, nil + return true, ReasonUnspecified, func() error { return nil }, nil } case identity.ErrNotFound: default: tracer.OnError(err) log.WithError(err).Warn("failure getting user identity by phone view") - return false, nil, err + return false, ReasonUnspecified, nil, err } // Account creation limit since the beginning of time @@ -87,7 +87,7 @@ func (g *Guard) AllowOpenAccounts(ctx context.Context, owner *common.Account, de if err != nil { tracer.OnError(err) log.WithError(err).Warn("failure getting intent count") - return false, nil, err + return false, ReasonUnspecified, nil, err } // Device-based restrictions guaranteeing 1 free account per valid device @@ -97,27 +97,27 @@ func (g *Guard) AllowOpenAccounts(ctx context.Context, owner *common.Account, de if deviceToken == nil { log.Info("denying attempt without device token") recordDenialEvent(ctx, actionOpenAccounts, "device token missing") - return false, nil, nil + return false, ReasonUnsupportedDevice, nil, nil } isValidDeviceToken, reason, err := g.deviceVerifier.IsValid(ctx, *deviceToken) if err != nil { log.WithError(err).Warn("failure performing device validation check") - return false, nil, err + return false, ReasonUnspecified, nil, err } else if !isValidDeviceToken { log.WithField("reason", reason).Info("denying fake device") recordDenialEvent(ctx, actionOpenAccounts, "fake device") - return false, nil, nil + return false, ReasonUnsupportedDevice, nil, nil } hasCreatedFreeAccount, err := g.deviceVerifier.HasCreatedFreeAccount(ctx, *deviceToken) if err != nil { log.WithError(err).Warn("failure performing free account check for device") - return false, nil, err + return false, ReasonUnspecified, nil, err } else if hasCreatedFreeAccount { log.Info("denying duplicate device") recordDenialEvent(ctx, actionOpenAccounts, "duplicate device") - return false, nil, nil + return false, ReasonTooManyFreeAccountsForDevice, nil, nil } } @@ -126,7 +126,7 @@ func (g *Guard) AllowOpenAccounts(ctx context.Context, owner *common.Account, de if int(count) >= 1 { log.Info("phone is rate limited by lifetime account creation count") recordDenialEvent(ctx, actionOpenAccounts, "lifetime limit exceeded") - return false, nil, nil + return false, ReasonTooManyFreeAccountsForPhoneNumber, nil, nil } onFreeAccountCreated := func() error { @@ -135,7 +135,7 @@ func (g *Guard) AllowOpenAccounts(ctx context.Context, owner *common.Account, de } return g.deviceVerifier.MarkCreatedFreeAccount(ctx, *deviceToken) } - return true, onFreeAccountCreated, nil + return true, ReasonUnspecified, onFreeAccountCreated, nil } // AllowSendPayment determines whether a phone-verified owner account is allowed to @@ -178,10 +178,10 @@ func (g *Guard) AllowSendPayment(ctx context.Context, owner *common.Account, isP log = log.WithField("phone", verification.PhoneNumber) - // Deny abusers from known phone ranges - if hasBannedPhoneNumberPrefix(verification.PhoneNumber) { - log.Info("denying phone prefix") - recordDenialEvent(ctx, actionSendPayment, "phone prefix banned") + // Deny users from sanctioned countries + if isSanctionedPhoneNumber(verification.PhoneNumber) { + log.Info("denying sanctioned country") + recordDenialEvent(ctx, actionSendPayment, "sanctioned country") return false, nil } @@ -322,10 +322,10 @@ func (g *Guard) AllowReceivePayments(ctx context.Context, owner *common.Account, log = log.WithField("phone", verification.PhoneNumber) - // Deny abusers from known phone ranges - if hasBannedPhoneNumberPrefix(verification.PhoneNumber) { - log.Info("denying phone prefix") - recordDenialEvent(ctx, actionReceivePayments, "phone prefix banned") + // Deny users from sanctioned countries + if isSanctionedPhoneNumber(verification.PhoneNumber) { + log.Info("denying sanctioned country") + recordDenialEvent(ctx, actionReceivePayments, "sanctioned country") return false, nil } @@ -466,10 +466,10 @@ func (g *Guard) AllowEstablishNewRelationship(ctx context.Context, owner *common log = log.WithField("phone", verification.PhoneNumber) - // Deny abusers from known phone ranges - if hasBannedPhoneNumberPrefix(verification.PhoneNumber) { - log.Info("denying phone prefix") - recordDenialEvent(ctx, actionEstablishNewRelationship, "phone prefix banned") + // Deny users from sanctioned countries + if isSanctionedPhoneNumber(verification.PhoneNumber) { + log.Info("denying sanctioned country") + recordDenialEvent(ctx, actionEstablishNewRelationship, "sanctioned country") return false, nil } diff --git a/pkg/code/antispam/phone.go b/pkg/code/antispam/phone.go index d7399e54..018bb34f 100644 --- a/pkg/code/antispam/phone.go +++ b/pkg/code/antispam/phone.go @@ -4,7 +4,7 @@ import "strings" // todo: Put in a DB somehwere? Or make configurable? // todo: Needs tests -func hasBannedPhoneNumberPrefix(phoneNumber string) bool { +func isSanctionedPhoneNumber(phoneNumber string) bool { // Check that a +7 phone number is not a mobile number from Kazakhstan if strings.HasPrefix(phoneNumber, "+76") || strings.HasPrefix(phoneNumber, "+77") { return false diff --git a/pkg/code/antispam/phone_verification.go b/pkg/code/antispam/phone_verification.go index b3fac257..7f61724b 100644 --- a/pkg/code/antispam/phone_verification.go +++ b/pkg/code/antispam/phone_verification.go @@ -15,7 +15,7 @@ import ( // AllowNewPhoneVerification determines whether a phone is allowed to start a // new verification flow. -func (g *Guard) AllowNewPhoneVerification(ctx context.Context, phoneNumber string, deviceToken *string) (bool, error) { +func (g *Guard) AllowNewPhoneVerification(ctx context.Context, phoneNumber string, deviceToken *string) (bool, Reason, error) { tracer := metrics.TraceMethodCall(ctx, metricsStructName, "AllowNewPhoneVerification") defer tracer.End() @@ -29,14 +29,14 @@ func (g *Guard) AllowNewPhoneVerification(ctx context.Context, phoneNumber strin if isIpBanned(ctx) { log.Info("ip is banned") recordDenialEvent(ctx, actionNewPhoneVerification, "ip banned") - return false, nil + return false, ReasonUnspecified, nil } - // Deny abusers from known phone ranges - if hasBannedPhoneNumberPrefix(phoneNumber) { - log.Info("denying phone prefix") - recordDenialEvent(ctx, actionNewPhoneVerification, "phone prefix banned") - return false, nil + // Deny users from sanctioned countries + if isSanctionedPhoneNumber(phoneNumber) { + log.Info("denying sanctioned country") + recordDenialEvent(ctx, actionNewPhoneVerification, "sanctioned country") + return false, ReasonUnsupportedCountry, nil } user, err := g.data.GetUserByPhoneView(ctx, phoneNumber) @@ -46,18 +46,18 @@ func (g *Guard) AllowNewPhoneVerification(ctx context.Context, phoneNumber strin if user.IsBanned { log.Info("denying banned user") recordDenialEvent(ctx, actionNewPhoneVerification, "user banned") - return false, nil + return false, ReasonUnspecified, nil } // Staff users have unlimited access to enable testing and demoing. if user.IsStaffUser { - return true, nil + return true, ReasonUnspecified, nil } case identity.ErrNotFound: default: tracer.OnError(err) log.WithError(err).Warn("failure getting user identity by phone view") - return false, err + return false, ReasonUnspecified, err } _, isAndroidDev := g.conf.androidDevsByPhoneNumber[phoneNumber] @@ -67,16 +67,16 @@ func (g *Guard) AllowNewPhoneVerification(ctx context.Context, phoneNumber strin if deviceToken == nil { log.Info("denying attempt without device token") recordDenialEvent(ctx, actionNewPhoneVerification, "device token missing") - return false, nil + return false, ReasonUnsupportedDevice, nil } isValidDeviceToken, reason, err := g.deviceVerifier.IsValid(ctx, *deviceToken) if err != nil { log.WithError(err).Warn("failure performing device check") - return false, err + return false, ReasonUnspecified, err } else if !isValidDeviceToken { log.WithField("reason", reason).Info("denying fake device") recordDenialEvent(ctx, actionNewPhoneVerification, "fake device") - return false, nil + return false, ReasonUnsupportedDevice, nil } } @@ -85,15 +85,15 @@ func (g *Guard) AllowNewPhoneVerification(ctx context.Context, phoneNumber strin if err != nil { tracer.OnError(err) log.WithError(err).Warn("failure counting unique verification ids for number") - return false, err + return false, ReasonUnspecified, err } if count >= g.conf.phoneVerificationsPerInternval { log.Info("phone is rate limited") recordDenialEvent(ctx, actionNewPhoneVerification, "rate limit exceeded") - return false, nil + return false, ReasonUnspecified, nil } - return true, nil + return true, ReasonUnspecified, nil } // AllowSendSmsVerificationCode determines whether a phone number can be sent diff --git a/pkg/code/antispam/reason.go b/pkg/code/antispam/reason.go new file mode 100644 index 00000000..36959722 --- /dev/null +++ b/pkg/code/antispam/reason.go @@ -0,0 +1,11 @@ +package antispam + +type Reason int + +const ( + ReasonUnspecified Reason = iota + ReasonUnsupportedCountry + ReasonUnsupportedDevice + ReasonTooManyFreeAccountsForPhoneNumber + ReasonTooManyFreeAccountsForDevice +) diff --git a/pkg/code/antispam/swap.go b/pkg/code/antispam/swap.go index 9ee626af..fd0fd1e3 100644 --- a/pkg/code/antispam/swap.go +++ b/pkg/code/antispam/swap.go @@ -47,10 +47,10 @@ func (g *Guard) AllowSwap(ctx context.Context, owner *common.Account) (bool, err log = log.WithField("phone", verification.PhoneNumber) - // Deny abusers from known phone ranges - if hasBannedPhoneNumberPrefix(verification.PhoneNumber) { - log.Info("denying phone prefix") - recordDenialEvent(ctx, actionSwap, "phone prefix banned") + // Deny users from sanctioned countries + if isSanctionedPhoneNumber(verification.PhoneNumber) { + log.Info("denying sanctioned country") + recordDenialEvent(ctx, actionSwap, "sanctioned country") return false, nil } diff --git a/pkg/code/server/grpc/phone/server.go b/pkg/code/server/grpc/phone/server.go index 4c4e3937..a4acc823 100644 --- a/pkg/code/server/grpc/phone/server.go +++ b/pkg/code/server/grpc/phone/server.go @@ -127,13 +127,22 @@ func (s *phoneVerificationServer) SendVerificationCode(ctx context.Context, req // Now that we're custom managing verifications on behalf of Twilio, it's on us // to do antispam checks to avoid excessive new verifications and sent SMS messages. if !isActiveVerification { - allow, err := s.guard.AllowNewPhoneVerification(ctx, req.PhoneNumber.Value, deviceToken) + allow, reason, err := s.guard.AllowNewPhoneVerification(ctx, req.PhoneNumber.Value, deviceToken) if err != nil { log.WithError(err).Warn("failure performing antispam check") return nil, status.Error(codes.Internal, "") } else if !allow { + var resultCode phonepb.SendVerificationCodeResponse_Result + switch reason { + case antispam.ReasonUnsupportedCountry: + resultCode = phonepb.SendVerificationCodeResponse_UNSUPPORTED_COUNTRY + case antispam.ReasonUnsupportedDevice: + resultCode = phonepb.SendVerificationCodeResponse_UNSUPPORTED_DEVICE + default: + resultCode = phonepb.SendVerificationCodeResponse_RATE_LIMITED + } return &phonepb.SendVerificationCodeResponse{ - Result: phonepb.SendVerificationCodeResponse_RATE_LIMITED, + Result: resultCode, }, nil } } diff --git a/pkg/code/server/grpc/phone/server_test.go b/pkg/code/server/grpc/phone/server_test.go index 5a7e1eec..8b629694 100644 --- a/pkg/code/server/grpc/phone/server_test.go +++ b/pkg/code/server/grpc/phone/server_test.go @@ -270,7 +270,7 @@ func TestSendVerificationCode_AntispamGuard_DeviceVerification(t *testing.T) { } sendCodeResp, err := env.client.SendVerificationCode(env.ctx, sendCodeReq) require.NoError(t, err) - assert.Equal(t, phonepb.SendVerificationCodeResponse_RATE_LIMITED, sendCodeResp.Result) + assert.Equal(t, phonepb.SendVerificationCodeResponse_UNSUPPORTED_DEVICE, sendCodeResp.Result) } func TestCheckVerificationCode_InvalidCode(t *testing.T) { diff --git a/pkg/code/server/grpc/transaction/v2/errors.go b/pkg/code/server/grpc/transaction/v2/errors.go index 5c4d2d66..3473792f 100644 --- a/pkg/code/server/grpc/transaction/v2/errors.go +++ b/pkg/code/server/grpc/transaction/v2/errors.go @@ -11,6 +11,7 @@ import ( commonpb "github.com/code-payments/code-protobuf-api/generated/go/common/v1" transactionpb "github.com/code-payments/code-protobuf-api/generated/go/transaction/v2" + "github.com/code-payments/code-server/pkg/code/antispam" "github.com/code-payments/code-server/pkg/code/transaction" "github.com/code-payments/code-server/pkg/solana" ) @@ -23,7 +24,6 @@ var ( ErrTimedOutReceivingRequest = errors.New("timed out receiving request") ErrNotPhoneVerified = newIntentDeniedError("not phone verified") - ErrTooManyAccountCreations = newIntentDeniedError("too many account creations") ErrTooManyPayments = newIntentDeniedError("too many payments") ErrTooManyNewRelationships = newIntentDeniedError("too many new relationships") ErrTransactionLimitExceeded = newIntentDeniedError("dollar value exceeds limit") @@ -69,15 +69,24 @@ func (e IntentValidationError) Error() string { type IntentDeniedError struct { message string + reason antispam.Reason } func newIntentDeniedError(message string) IntentDeniedError { return IntentDeniedError{ message: message, + reason: antispam.ReasonUnspecified, } } -func newIntentDeniedErrorf(format string, args ...any) IntentDeniedError { +func newIntentDeniedErrorWithAntispamReason(reason antispam.Reason, message string) IntentDeniedError { + return IntentDeniedError{ + message: message, + reason: antispam.ReasonUnspecified, + } +} + +func newIntentDeniedErrorf(reason antispam.Reason, format string, args ...any) IntentDeniedError { return newIntentDeniedError(fmt.Sprintf(format, args...)) } @@ -105,11 +114,13 @@ func (e SwapValidationError) Error() string { type SwapDeniedError struct { message string + reason antispam.Reason } func newSwapDeniedError(message string) SwapDeniedError { return SwapDeniedError{ message: message, + reason: antispam.ReasonUnspecified, } } @@ -186,6 +197,50 @@ func toInvalidSignatureErrorDetails( } } +func toDeniedErrorDetails(err error) *transactionpb.ErrorDetails { + if err == nil { + return nil + } + + reasonString := err.Error() + if len(reasonString) > maxReasonStringLength { + reasonString = reasonString[:maxReasonStringLength] + } + + var antispamReason antispam.Reason + switch typed := err.(type) { + case IntentDeniedError: + antispamReason = typed.reason + case SwapDeniedError: + antispamReason = typed.reason + default: + antispamReason = antispam.ReasonUnspecified + } + + var code transactionpb.DeniedErrorDetails_Code + switch antispamReason { + case antispam.ReasonUnsupportedCountry: + code = transactionpb.DeniedErrorDetails_UNSUPPORTED_COUNTRY + case antispam.ReasonUnsupportedDevice: + code = transactionpb.DeniedErrorDetails_UNSUPPORTED_DEVICE + case antispam.ReasonTooManyFreeAccountsForPhoneNumber: + code = transactionpb.DeniedErrorDetails_TOO_MANY_FREE_ACCOUNTS_FOR_PHONE_NUMBER + case antispam.ReasonTooManyFreeAccountsForDevice: + code = transactionpb.DeniedErrorDetails_TOO_MANY_FREE_ACCOUNTS_FOR_DEVIEC + default: + code = transactionpb.DeniedErrorDetails_UNSPECIFIED + } + + return &transactionpb.ErrorDetails{ + Type: &transactionpb.ErrorDetails_Denied{ + Denied: &transactionpb.DeniedErrorDetails{ + Code: code, + Reason: reasonString, + }, + }, + } +} + func handleSubmitIntentError(streamer transactionpb.Transaction_SubmitIntentServer, err error) error { // gRPC status errors are passed through as is if _, ok := status.FromError(err); ok { @@ -204,7 +259,7 @@ func handleSubmitIntentError(streamer transactionpb.Transaction_SubmitIntentServ return handleSubmitIntentStructuredError( streamer, transactionpb.SubmitIntentResponse_Error_DENIED, - toReasonStringErrorDetails(err), + toDeniedErrorDetails(err), ) case StaleStateError: return handleSubmitIntentStructuredError( @@ -267,7 +322,7 @@ func handleSwapError(streamer transactionpb.Transaction_SwapServer, err error) e return handleSwapStructuredError( streamer, transactionpb.SwapResponse_Error_DENIED, - toReasonStringErrorDetails(err), + toDeniedErrorDetails(err), ) } diff --git a/pkg/code/server/grpc/transaction/v2/intent_handler.go b/pkg/code/server/grpc/transaction/v2/intent_handler.go index 22f0d331..b54cb7b8 100644 --- a/pkg/code/server/grpc/transaction/v2/intent_handler.go +++ b/pkg/code/server/grpc/transaction/v2/intent_handler.go @@ -198,11 +198,11 @@ func (h *OpenAccountsIntentHandler) AllowCreation(ctx context.Context, intentRec // if !h.conf.disableAntispamChecks.Get(ctx) { - allow, successCallback, err := h.antispamGuard.AllowOpenAccounts(ctx, initiatiorOwnerAccount, deviceToken) + allow, reason, successCallback, err := h.antispamGuard.AllowOpenAccounts(ctx, initiatiorOwnerAccount, deviceToken) if err != nil { return err } else if !allow { - return ErrTooManyAccountCreations + return newIntentDeniedErrorWithAntispamReason(reason, "antispam guard denied account creation") } h.antispamSuccessCallback = successCallback } diff --git a/pkg/code/server/grpc/transaction/v2/intent_test.go b/pkg/code/server/grpc/transaction/v2/intent_test.go index 8fdf7bbb..cc8f6746 100644 --- a/pkg/code/server/grpc/transaction/v2/intent_test.go +++ b/pkg/code/server/grpc/transaction/v2/intent_test.go @@ -97,7 +97,7 @@ func TestSubmitIntent_OpenAccounts_AntispamGuard(t *testing.T) { server.phoneVerifyUser(t, phone) submitIntentCall := phone.openAccounts(t) - submitIntentCall.assertDeniedResponse(t, "too many account creations") + submitIntentCall.assertDeniedResponse(t, "antispam guard denied account creation") server.assertIntentNotSubmitted(t, submitIntentCall.intentId) } diff --git a/pkg/code/server/grpc/transaction/v2/testutil.go b/pkg/code/server/grpc/transaction/v2/testutil.go index 5b2826e9..38c42626 100644 --- a/pkg/code/server/grpc/transaction/v2/testutil.go +++ b/pkg/code/server/grpc/transaction/v2/testutil.go @@ -6084,8 +6084,8 @@ func (m submitIntentCallMetadata) assertDeniedResponse(t *testing.T, message str require.Len(t, m.resp.GetError().GetErrorDetails(), 1) errorDetails := m.resp.GetError().GetErrorDetails()[0] - require.NotNil(t, errorDetails.GetReasonString()) - assert.True(t, strings.Contains(errorDetails.GetReasonString().Reason, message)) + require.NotNil(t, errorDetails.GetDenied()) + assert.True(t, strings.Contains(errorDetails.GetDenied().Reason, message)) } func (m submitIntentCallMetadata) assertInvalidIntentResponse(t *testing.T, message string) {