Skip to content

Commit 06acd3e

Browse files
authored
Merge pull request #149 from code-payments/chore/integrate-appcheck-for-device-token-validation
feat: integrate AppCheck for integrity/attestation antispam
2 parents f80a2c7 + 222667c commit 06acd3e

7 files changed

Lines changed: 158 additions & 16 deletions

File tree

api/build.gradle.kts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,11 @@ dependencies {
6363
implementation(Libs.okhttp)
6464
implementation(Libs.mixpanel)
6565

66+
implementation(platform(Libs.firebase_bom))
67+
implementation(Libs.firebase_appcheck)
68+
implementation(Libs.firebase_appcheck_debug)
69+
implementation(Libs.firebase_appcheck_playintegrity)
70+
6671
implementation(Libs.androidx_paging_runtime)
6772

6873
kapt(Libs.androidx_room_compiler)

api/src/main/java/com/getcode/model/intents/IntentType.kt

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import com.getcode.ed25519.Ed25519
55
import com.getcode.solana.keys.Signature
66
import com.getcode.model.intents.actions.ActionType
77
import com.getcode.model.intents.actions.numberActions
8+
import com.getcode.network.appcheck.toDeviceToken
89
import com.getcode.network.repository.*
910
import com.getcode.solana.keys.PublicKey
1011

@@ -42,12 +43,17 @@ abstract class IntentType {
4243
.build()
4344
}
4445

45-
fun requestToSubmitActions(owner: Ed25519.KeyPair): TransactionService.SubmitIntentRequest {
46+
fun requestToSubmitActions(owner: Ed25519.KeyPair, deviceToken: String? = null): TransactionService.SubmitIntentRequest {
4647
val submitActionsBuilder = TransactionService.SubmitIntentRequest.SubmitActions.newBuilder()
4748
submitActionsBuilder.owner = owner.publicKeyBytes.toSolanaAccount()
4849
submitActionsBuilder.id = id.toIntentId()
4950
submitActionsBuilder.metadata = metadata()
5051
submitActionsBuilder.addAllActions(actionGroup.actions.map { it.action() })
52+
53+
if (deviceToken != null) {
54+
submitActionsBuilder.setDeviceToken(deviceToken.toDeviceToken())
55+
}
56+
5157
submitActionsBuilder.signature = submitActionsBuilder.sign(owner)
5258

5359
return TransactionService.SubmitIntentRequest.newBuilder()
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package com.getcode.network.appcheck
2+
3+
import com.codeinc.gen.common.v1.Model
4+
import com.getcode.api.BuildConfig
5+
import com.google.firebase.Firebase
6+
import com.google.firebase.appcheck.AppCheckToken
7+
import com.google.firebase.appcheck.AppCheckTokenResult
8+
import com.google.firebase.appcheck.appCheck
9+
import com.google.firebase.appcheck.debug.DebugAppCheckProviderFactory
10+
import com.google.firebase.appcheck.debug.internal.DebugAppCheckProvider
11+
import com.google.firebase.appcheck.playintegrity.PlayIntegrityAppCheckProviderFactory
12+
import io.reactivex.rxjava3.core.BackpressureStrategy
13+
import io.reactivex.rxjava3.core.Flowable
14+
import io.reactivex.rxjava3.core.Single
15+
import kotlinx.coroutines.flow.Flow
16+
import kotlinx.coroutines.reactive.asFlow
17+
import timber.log.Timber
18+
import java.lang.Exception
19+
20+
data class DeviceTokenResult(
21+
val token: AppCheckToken?
22+
)
23+
24+
object AppCheck {
25+
26+
fun register() {
27+
if (BuildConfig.DEBUG) {
28+
Firebase.appCheck.installAppCheckProviderFactory(
29+
DebugAppCheckProviderFactory.getInstance()
30+
)
31+
} else {
32+
Firebase.appCheck.installAppCheckProviderFactory(
33+
PlayIntegrityAppCheckProviderFactory.getInstance()
34+
)
35+
}
36+
}
37+
38+
private fun handleAppCheckError(error: Exception): Boolean {
39+
val match = "code: \\d+".toRegex().find(error.message.orEmpty())
40+
val errorCode = match?.value?.removePrefix("code: ")?.toInt() ?: -1
41+
Timber.e("Failed to get appcheck token: errorCode=$errorCode ${error.message}")
42+
43+
return errorCode == 403 // bad attestation
44+
|| errorCode >= 500
45+
}
46+
47+
@Deprecated("Replace with Flow variant")
48+
fun limitedUseTokenSingle(): Single<DeviceTokenResult> {
49+
return Single.create { emitter ->
50+
Firebase.appCheck.limitedUseAppCheckToken
51+
.addOnSuccessListener {
52+
Timber.d("attestation passed")
53+
emitter.onSuccess(DeviceTokenResult(it))
54+
}
55+
.addOnFailureListener { error ->
56+
if (!handleAppCheckError(error)) {
57+
emitter.onError(error)
58+
return@addOnFailureListener
59+
}
60+
61+
emitter.onSuccess(DeviceTokenResult(null))
62+
}
63+
}
64+
}
65+
66+
@Deprecated("Replace with Flow variant")
67+
fun limitedUseTokenFlowable(
68+
backpressureStrategy: BackpressureStrategy = BackpressureStrategy.BUFFER
69+
): Flowable<DeviceTokenResult> {
70+
return Flowable.create({ emitter ->
71+
Firebase.appCheck.limitedUseAppCheckToken
72+
.addOnSuccessListener {
73+
Timber.d("attestation passed")
74+
emitter.onNext(DeviceTokenResult(it))
75+
}
76+
.addOnFailureListener { error ->
77+
if (!handleAppCheckError(error)) {
78+
emitter.onError(error)
79+
return@addOnFailureListener
80+
}
81+
82+
emitter.onNext(DeviceTokenResult(null))
83+
}
84+
}, backpressureStrategy)
85+
}
86+
87+
fun limitedUseToken(
88+
backpressureStrategy: BackpressureStrategy = BackpressureStrategy.BUFFER
89+
): Flow<DeviceTokenResult> {
90+
return limitedUseTokenFlowable(backpressureStrategy).asFlow()
91+
}
92+
}
93+
94+
fun AppCheckToken.toDeviceToken() = Model.DeviceToken.newBuilder().setValue(this.token).build()
95+
fun String.toDeviceToken() = Model.DeviceToken.newBuilder().setValue(this).build()

api/src/main/java/com/getcode/network/repository/PhoneRepository.kt

Lines changed: 34 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,33 @@ package com.getcode.network.repository
22

33
import com.codeinc.gen.phone.v1.PhoneVerificationService
44
import com.getcode.db.Database
5-
import com.getcode.db.InMemoryDao
65
import com.getcode.ed25519.Ed25519
7-
import com.getcode.model.PrefsBool
8-
import com.getcode.model.PrefsString
9-
import com.getcode.network.core.NetworkOracle
106
import com.getcode.network.api.PhoneApi
7+
import com.getcode.network.appcheck.AppCheck
8+
import com.getcode.network.appcheck.toDeviceToken
9+
import com.getcode.network.core.NetworkOracle
10+
import com.google.firebase.Firebase
11+
import com.google.firebase.appcheck.AppCheckToken
12+
import com.google.firebase.appcheck.appCheck
13+
import io.reactivex.rxjava3.core.BackpressureStrategy
1114
import io.reactivex.rxjava3.core.Flowable
1215
import io.reactivex.rxjava3.core.Single
1316
import kotlinx.coroutines.flow.MutableStateFlow
1417
import java.io.ByteArrayOutputStream
1518
import javax.inject.Inject
1619
import javax.inject.Singleton
1720

21+
22+
fun appCheckToken(
23+
backpressureStrategy: BackpressureStrategy = BackpressureStrategy.BUFFER
24+
): Flowable<AppCheckToken> {
25+
return Flowable.create({ emitter ->
26+
Firebase.appCheck.limitedUseAppCheckToken
27+
.addOnSuccessListener { emitter.onNext(it) }
28+
.addOnFailureListener { emitter.onError(it) }
29+
}, backpressureStrategy)
30+
}
31+
1832
@Singleton
1933
class PhoneRepository @Inject constructor(
2034
private val phoneApi: PhoneApi,
@@ -37,14 +51,22 @@ class PhoneRepository @Inject constructor(
3751
if (isMock()) return Single.just(PhoneVerificationService.SendVerificationCodeResponse.Result.OK)
3852
.toFlowable()
3953

40-
val request =
41-
PhoneVerificationService.SendVerificationCodeRequest.newBuilder()
42-
.setPhoneNumber(phoneValue.toPhoneNumber())
43-
.build()
54+
return AppCheck.limitedUseTokenFlowable()
55+
.flatMap { tokenResult ->
56+
val request =
57+
PhoneVerificationService.SendVerificationCodeRequest.newBuilder()
58+
.setPhoneNumber(phoneValue.toPhoneNumber())
59+
.apply {
60+
if (tokenResult.token != null) {
61+
setDeviceToken(tokenResult.token.toDeviceToken())
62+
}
63+
}.build()
4464

45-
return phoneApi.sendVerificationCode(request)
46-
.map { it.result }
47-
.let { networkOracle.managedRequest(it) }
65+
66+
phoneApi.sendVerificationCode(request)
67+
.map { it.result }
68+
.let { networkOracle.managedRequest(it) }
69+
}
4870
}
4971

5072
fun checkVerificationCode(
@@ -68,7 +90,7 @@ class PhoneRepository @Inject constructor(
6890
): Flowable<GetAssociatedPhoneNumberResponse> {
6991
if (isMock()) {
7092
return Flowable.just(
71-
GetAssociatedPhoneNumberResponse(true, true, false,"+12223334455")
93+
GetAssociatedPhoneNumberResponse(true, true, false, "+12223334455")
7294
)
7395
}
7496

api/src/main/java/com/getcode/network/repository/TransactionRepository.kt

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import com.getcode.model.intents.IntentType
2424
import com.getcode.model.intents.IntentUpgradePrivacy
2525
import com.getcode.model.intents.ServerParameter
2626
import com.getcode.network.api.TransactionApiV2
27+
import com.getcode.network.appcheck.AppCheck
2728
import com.getcode.solana.keys.AssociatedTokenAccount
2829
import com.getcode.solana.keys.PublicKey
2930
import com.getcode.solana.organizer.AccountType
@@ -86,7 +87,11 @@ class TransactionRepository @Inject constructor(
8687
.delay(1, TimeUnit.SECONDS)
8788

8889
val createAccounts = IntentCreateAccounts.newInstance(organizer)
89-
return submit(createAccounts, organizer.tray.owner.getCluster().authority.keyPair)
90+
91+
return AppCheck.limitedUseTokenSingle()
92+
.flatMap { tokenResult ->
93+
submit(createAccounts, organizer.tray.owner.getCluster().authority.keyPair, tokenResult.token?.token)
94+
}
9095
}
9196

9297
fun transfer(
@@ -236,7 +241,7 @@ class TransactionRepository @Inject constructor(
236241
return submit(intent, owner = organizer.tray.owner.getCluster().authority.keyPair)
237242
}
238243

239-
private fun submit(intent: IntentType, owner: KeyPair): Single<IntentType> {
244+
private fun submit(intent: IntentType, owner: KeyPair, deviceToken: String? = null): Single<IntentType> {
240245
Timber.i("Submit ${intent.javaClass.simpleName}")
241246
val subject = SingleSubject.create<IntentType>()
242247

@@ -319,7 +324,7 @@ class TransactionRepository @Inject constructor(
319324

320325
// 1. Send `submitActions` request with
321326
// actions generated by the intent
322-
serverMessageStream.onNext(intent.requestToSubmitActions(owner))
327+
serverMessageStream.onNext(intent.requestToSubmitActions(owner, deviceToken))
323328

324329
return subject
325330
}

app/src/main/java/com/getcode/App.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,11 @@ import android.app.Application
44
import androidx.appcompat.app.AppCompatDelegate
55
import com.bugsnag.android.Bugsnag
66
import com.getcode.manager.AuthManager
7+
import com.getcode.network.appcheck.AppCheck
78
import com.getcode.utils.ErrorUtils
89
import com.getcode.view.main.bill.CashBillAssets
10+
import com.google.firebase.Firebase
11+
import com.google.firebase.initialize
912
import dagger.hilt.android.HiltAndroidApp
1013
import io.reactivex.rxjava3.plugins.RxJavaPlugins
1114
import timber.log.Timber
@@ -23,6 +26,9 @@ class App : Application() {
2326

2427
CashBillAssets.load(this)
2528

29+
Firebase.initialize(this)
30+
AppCheck.register()
31+
2632
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
2733

2834
RxJavaPlugins.setErrorHandler {

buildSrc/src/main/java/Dependencies.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,9 @@ object Libs {
179179

180180
const val firebase_bom = "com.google.firebase:firebase-bom:${Versions.firebase_bom}"
181181
const val firebase_analytics = "com.google.firebase:firebase-analytics"
182+
const val firebase_appcheck = "com.google.firebase:firebase-appcheck"
183+
const val firebase_appcheck_debug = "com.google.firebase:firebase-appcheck-debug"
184+
const val firebase_appcheck_playintegrity = "com.google.firebase:firebase-appcheck-playintegrity"
182185
const val firebase_crashlytics = "com.google.firebase:firebase-crashlytics"
183186
const val firebase_messaging = "com.google.firebase:firebase-messaging"
184187
const val firebase_perf = "com.google.firebase:firebase-perf"

0 commit comments

Comments
 (0)