diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 13cf4bec..ceaafcaa 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -109,6 +109,12 @@ dependencies { // Encrypted storage implementation("androidx.security:security-crypto:1.0.0") + // Play Integrity API — runtime device integrity check + implementation("com.google.android.play:integrity:1.4.0") + + // Firebase Functions — callable for server-side integrity token verification + implementation("com.google.firebase:firebase-functions-ktx") + // RevenueCat native Android SDK (group: com.revenuecat.purchases, artifact: purchases) implementation("com.revenuecat.purchases:purchases:8.20.0") diff --git a/app/src/main/java/app/closer/CloserApp.kt b/app/src/main/java/app/closer/CloserApp.kt index 7fb6b51f..21dd7665 100644 --- a/app/src/main/java/app/closer/CloserApp.kt +++ b/app/src/main/java/app/closer/CloserApp.kt @@ -3,17 +3,26 @@ package app.closer import android.app.Application import app.closer.core.firebase.FirebaseInitializer import app.closer.data.repository.ActivityProvider +import app.closer.domain.security.DeviceIntegrityChecker import dagger.hilt.android.HiltAndroidApp +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch import javax.inject.Inject @HiltAndroidApp class CloserApp : Application() { @Inject lateinit var firebaseInitializer: FirebaseInitializer + @Inject lateinit var deviceIntegrityChecker: DeviceIntegrityChecker + + private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) override fun onCreate() { super.onCreate() ActivityProvider.register(this) firebaseInitializer.initialize() + appScope.launch { deviceIntegrityChecker.runCheck() } } } diff --git a/app/src/main/java/app/closer/data/security/PlayIntegrityChecker.kt b/app/src/main/java/app/closer/data/security/PlayIntegrityChecker.kt new file mode 100644 index 00000000..f02834c8 --- /dev/null +++ b/app/src/main/java/app/closer/data/security/PlayIntegrityChecker.kt @@ -0,0 +1,96 @@ +package app.closer.data.security + +import android.content.Context +import android.util.Base64 +import app.closer.BuildConfig +import app.closer.domain.security.DeviceIntegrityChecker +import app.closer.domain.security.DeviceIntegrityResult +import com.google.android.play.core.integrity.IntegrityManagerFactory +import com.google.android.play.core.integrity.IntegrityTokenRequest +import com.google.firebase.functions.FirebaseFunctions +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.suspendCancellableCoroutine +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +/** + * Checks device integrity using the Play Integrity API. + * + * Flow: + * 1. Requests a signed integrity token from the Play Integrity API on-device. + * 2. Sends the token to the [checkDeviceIntegrity] Cloud Function for server-side + * verification via the Google Play Integrity API. + * 3. Exposes the verdict through [state]. + * + * The check runs once at app startup. A [COMPROMISED] result does not hard-block + * the app — it surfaces a warning so users on unintentionally modified devices + * can investigate. Firebase App Check (Play Integrity) already blocks Firebase + * API calls from compromised environments; this provides an in-app signal. + * + * In debug builds the check is skipped (many dev devices have unlocked bootloaders). + * + * Setup required: + * - Enable the Play Integrity API in your Google Cloud project. + * - Ensure the Cloud Functions service account has the "Play Integrity API User" role, + * or link the Cloud project to your Play Console app in the Play Console settings. + */ +@Singleton +class PlayIntegrityChecker @Inject constructor( + @ApplicationContext private val context: Context, + private val functions: FirebaseFunctions, +) : DeviceIntegrityChecker { + + private val _state = MutableStateFlow(DeviceIntegrityResult.UNKNOWN) + override val state: StateFlow = _state.asStateFlow() + + override suspend fun runCheck() { + if (BuildConfig.DEBUG) { + _state.value = DeviceIntegrityResult.UNAVAILABLE + return + } + + val token = try { + requestToken() + } catch (_: Exception) { + _state.value = DeviceIntegrityResult.UNAVAILABLE + return + } + + val passed = verifyWithServer(token) + _state.value = if (passed) DeviceIntegrityResult.PASSED else DeviceIntegrityResult.COMPROMISED + } + + private suspend fun requestToken(): String = suspendCancellableCoroutine { cont -> + val nonce = Base64.encodeToString( + "${UUID.randomUUID()}-${System.currentTimeMillis()}".toByteArray(Charsets.UTF_8), + Base64.URL_SAFE or Base64.NO_WRAP, + ) + IntegrityManagerFactory.create(context) + .requestIntegrityToken(IntegrityTokenRequest.builder().setNonce(nonce).build()) + .addOnSuccessListener { cont.resume(it.token()) } + .addOnFailureListener { cont.resumeWithException(it) } + } + + private suspend fun verifyWithServer(token: String): Boolean = + suspendCancellableCoroutine { cont -> + functions + .getHttpsCallable("checkDeviceIntegrity") + .call(mapOf("token" to token)) + .addOnSuccessListener { result -> + @Suppress("UNCHECKED_CAST") + val passed = (result.getData() as? Map<*, *>)?.get("passed") as? Boolean ?: true + cont.resume(passed) + } + .addOnFailureListener { + // Fail-open: if server verification is unavailable, don't penalise the user. + // Firebase App Check remains the server-side gatekeeper. + cont.resume(true) + } + } +} diff --git a/app/src/main/java/app/closer/di/SecurityModule.kt b/app/src/main/java/app/closer/di/SecurityModule.kt new file mode 100644 index 00000000..e993d3af --- /dev/null +++ b/app/src/main/java/app/closer/di/SecurityModule.kt @@ -0,0 +1,24 @@ +package app.closer.di + +import app.closer.data.security.PlayIntegrityChecker +import app.closer.domain.security.DeviceIntegrityChecker +import com.google.firebase.functions.FirebaseFunctions +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +abstract class SecurityModule { + + @Binds @Singleton + abstract fun bindDeviceIntegrityChecker(impl: PlayIntegrityChecker): DeviceIntegrityChecker + + companion object { + @Provides @Singleton + fun provideFirebaseFunctions(): FirebaseFunctions = FirebaseFunctions.getInstance() + } +} diff --git a/app/src/main/java/app/closer/domain/security/DeviceIntegrityChecker.kt b/app/src/main/java/app/closer/domain/security/DeviceIntegrityChecker.kt new file mode 100644 index 00000000..e5b0a309 --- /dev/null +++ b/app/src/main/java/app/closer/domain/security/DeviceIntegrityChecker.kt @@ -0,0 +1,15 @@ +package app.closer.domain.security + +import kotlinx.coroutines.flow.StateFlow + +enum class DeviceIntegrityResult { + UNKNOWN, // check not yet run + PASSED, // device meets basic integrity requirements + COMPROMISED, // device is rooted, unlocked, or otherwise modified + UNAVAILABLE, // Play Integrity API not available (old device, no Play Services, etc.) +} + +interface DeviceIntegrityChecker { + val state: StateFlow + suspend fun runCheck() +} diff --git a/functions/dist/index.js b/functions/dist/index.js index ca7934cc..e739c6fd 100644 --- a/functions/dist/index.js +++ b/functions/dist/index.js @@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () { }; })(); Object.defineProperty(exports, "__esModule", { value: true }); -exports.health = exports.sendPartnerAnsweredNotification = exports.sendDailyQuestionReminder = exports.syncEntitlement = exports.revenueCatWebhook = void 0; +exports.health = exports.checkDeviceIntegrity = exports.sendPartnerAnsweredNotification = exports.sendDailyQuestionReminder = exports.syncEntitlement = exports.revenueCatWebhook = void 0; const functions = __importStar(require("firebase-functions")); var revenueCatWebhook_1 = require("./billing/revenueCatWebhook"); Object.defineProperty(exports, "revenueCatWebhook", { enumerable: true, get: function () { return revenueCatWebhook_1.revenueCatWebhook; } }); @@ -42,6 +42,8 @@ Object.defineProperty(exports, "syncEntitlement", { enumerable: true, get: funct var reminders_1 = require("./notifications/reminders"); Object.defineProperty(exports, "sendDailyQuestionReminder", { enumerable: true, get: function () { return reminders_1.sendDailyQuestionReminder; } }); Object.defineProperty(exports, "sendPartnerAnsweredNotification", { enumerable: true, get: function () { return reminders_1.sendPartnerAnsweredNotification; } }); +var checkDeviceIntegrity_1 = require("./security/checkDeviceIntegrity"); +Object.defineProperty(exports, "checkDeviceIntegrity", { enumerable: true, get: function () { return checkDeviceIntegrity_1.checkDeviceIntegrity; } }); /** * Basic health check callable. * Useful for verifying function deployment and firebase-tools wiring. diff --git a/functions/dist/index.js.map b/functions/dist/index.js.map index 99a62658..a30d7395 100644 --- a/functions/dist/index.js.map +++ b/functions/dist/index.js.map @@ -1 +1 @@ -{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,8DAA+C;AAE/C,iEAA+D;AAAtD,sHAAA,iBAAiB,OAAA;AAC1B,6DAA2D;AAAlD,kHAAA,eAAe,OAAA;AACxB,uDAGkC;AAFhC,sHAAA,yBAAyB,OAAA;AACzB,4HAAA,+BAA+B,OAAA;AAGjC;;;GAGG;AACU,QAAA,MAAM,GAAG,SAAS,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE;IAC3D,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,CAAA;AACxC,CAAC,CAAC,CAAA"} \ No newline at end of file +{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,8DAA+C;AAE/C,iEAA+D;AAAtD,sHAAA,iBAAiB,OAAA;AAC1B,6DAA2D;AAAlD,kHAAA,eAAe,OAAA;AACxB,uDAGkC;AAFhC,sHAAA,yBAAyB,OAAA;AACzB,4HAAA,+BAA+B,OAAA;AAEjC,wEAAsE;AAA7D,4HAAA,oBAAoB,OAAA;AAE7B;;;GAGG;AACU,QAAA,MAAM,GAAG,SAAS,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE;IAC3D,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,CAAA;AACxC,CAAC,CAAC,CAAA"} \ No newline at end of file diff --git a/functions/dist/security/checkDeviceIntegrity.js b/functions/dist/security/checkDeviceIntegrity.js new file mode 100644 index 00000000..d661782e --- /dev/null +++ b/functions/dist/security/checkDeviceIntegrity.js @@ -0,0 +1,94 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", { value: true }); +exports.checkDeviceIntegrity = void 0; +const functions = __importStar(require("firebase-functions")); +const google_auth_library_1 = require("google-auth-library"); +const PACKAGE_NAME = 'app.closer'; +const PLAY_INTEGRITY_URL = `https://playintegrity.googleapis.com/v1/${PACKAGE_NAME}:decodeIntegrityToken`; +/** + * Verifies a Play Integrity API token server-side and returns whether the + * device meets basic integrity requirements. + * + * Called by [PlayIntegrityChecker] on app startup. Requires the caller to be + * authenticated — the Firebase Auth token is verified automatically by the + * Functions runtime. + * + * Setup required before this function works in production: + * 1. Enable the Play Integrity API in your Google Cloud project. + * 2. Link the Cloud project to your Play Console app (Play Console → + * Setup → API access → Link to Cloud project). + * 3. Grant the Cloud Functions service account the "Play Integrity API User" + * role in IAM, or use a dedicated service account key via + * GOOGLE_APPLICATION_CREDENTIALS. + * + * If the API is not yet configured the function fails-open (returns + * passed: true) and logs the error, so unconfigured environments don't + * block users. Firebase App Check remains the server-side gatekeeper. + */ +exports.checkDeviceIntegrity = functions.https.onCall(async (data, context) => { + if (!context.auth) { + throw new functions.https.HttpsError('unauthenticated', 'Caller must be authenticated.'); + } + const token = data === null || data === void 0 ? void 0 : data.token; + if (!token || typeof token !== 'string') { + throw new functions.https.HttpsError('invalid-argument', 'token is required.'); + } + try { + const verdicts = await decodeIntegrityToken(token); + const passed = verdicts.includes('MEETS_DEVICE_INTEGRITY') || + verdicts.includes('MEETS_STRONG_INTEGRITY'); + return { passed, verdicts }; + } + catch (err) { + console.error('[checkDeviceIntegrity] verification unavailable:', err); + // Fail-open: treat as passed if the API is not yet configured. + return { passed: true, verdicts: [], error: 'verification_unavailable' }; + } +}); +async function decodeIntegrityToken(token) { + var _a, _b, _c; + const auth = new google_auth_library_1.GoogleAuth({ + scopes: ['https://www.googleapis.com/auth/playintegrity'], + }); + const client = await auth.getClient(); + const response = await client.request({ + url: PLAY_INTEGRITY_URL, + method: 'POST', + data: { integrity_token: token }, + }); + return ((_c = (_b = (_a = response.data.tokenPayloadExternal) === null || _a === void 0 ? void 0 : _a.deviceIntegrity) === null || _b === void 0 ? void 0 : _b.deviceRecognitionVerdict) !== null && _c !== void 0 ? _c : []); +} +//# sourceMappingURL=checkDeviceIntegrity.js.map \ No newline at end of file diff --git a/functions/dist/security/checkDeviceIntegrity.js.map b/functions/dist/security/checkDeviceIntegrity.js.map new file mode 100644 index 00000000..a4880234 --- /dev/null +++ b/functions/dist/security/checkDeviceIntegrity.js.map @@ -0,0 +1 @@ +{"version":3,"file":"checkDeviceIntegrity.js","sourceRoot":"","sources":["../../src/security/checkDeviceIntegrity.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,8DAA+C;AAC/C,6DAAgD;AAEhD,MAAM,YAAY,GAAG,YAAY,CAAA;AACjC,MAAM,kBAAkB,GACtB,2CAA2C,YAAY,uBAAuB,CAAA;AAEhF;;;;;;;;;;;;;;;;;;;GAmBG;AACU,QAAA,oBAAoB,GAAG,SAAS,CAAC,KAAK,CAAC,MAAM,CACxD,KAAK,EAAE,IAAwB,EAAE,OAAO,EAAE,EAAE;IAC1C,IAAI,CAAC,OAAO,CAAC,IAAI,EAAE,CAAC;QAClB,MAAM,IAAI,SAAS,CAAC,KAAK,CAAC,UAAU,CAClC,iBAAiB,EACjB,+BAA+B,CAChC,CAAA;IACH,CAAC;IAED,MAAM,KAAK,GAAG,IAAI,aAAJ,IAAI,uBAAJ,IAAI,CAAE,KAAK,CAAA;IACzB,IAAI,CAAC,KAAK,IAAI,OAAO,KAAK,KAAK,QAAQ,EAAE,CAAC;QACxC,MAAM,IAAI,SAAS,CAAC,KAAK,CAAC,UAAU,CAClC,kBAAkB,EAClB,oBAAoB,CACrB,CAAA;IACH,CAAC;IAED,IAAI,CAAC;QACH,MAAM,QAAQ,GAAG,MAAM,oBAAoB,CAAC,KAAK,CAAC,CAAA;QAClD,MAAM,MAAM,GACV,QAAQ,CAAC,QAAQ,CAAC,wBAAwB,CAAC;YAC3C,QAAQ,CAAC,QAAQ,CAAC,wBAAwB,CAAC,CAAA;QAC7C,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,CAAA;IAC7B,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,OAAO,CAAC,KAAK,CAAC,kDAAkD,EAAE,GAAG,CAAC,CAAA;QACtE,+DAA+D;QAC/D,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,QAAQ,EAAE,EAAE,EAAE,KAAK,EAAE,0BAA0B,EAAE,CAAA;IAC1E,CAAC;AACH,CAAC,CACF,CAAA;AAUD,KAAK,UAAU,oBAAoB,CAAC,KAAa;;IAC/C,MAAM,IAAI,GAAG,IAAI,gCAAU,CAAC;QAC1B,MAAM,EAAE,CAAC,+CAA+C,CAAC;KAC1D,CAAC,CAAA;IACF,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,SAAS,EAAE,CAAA;IACrC,MAAM,QAAQ,GAAG,MAAM,MAAM,CAAC,OAAO,CAAoB;QACvD,GAAG,EAAE,kBAAkB;QACvB,MAAM,EAAE,MAAM;QACd,IAAI,EAAE,EAAE,eAAe,EAAE,KAAK,EAAE;KACjC,CAAC,CAAA;IACF,OAAO,CACL,MAAA,MAAA,MAAA,QAAQ,CAAC,IAAI,CAAC,oBAAoB,0CAAE,eAAe,0CAC/C,wBAAwB,mCAAI,EAAE,CACnC,CAAA;AACH,CAAC"} \ No newline at end of file diff --git a/functions/package-lock.json b/functions/package-lock.json index 4f143abc..dfb5b939 100644 --- a/functions/package-lock.json +++ b/functions/package-lock.json @@ -9,7 +9,8 @@ "version": "1.0.0", "dependencies": { "firebase-admin": "^12.2.0", - "firebase-functions": "^5.1.0" + "firebase-functions": "^5.1.0", + "google-auth-library": "^9.0.0" }, "devDependencies": { "typescript": "^5.4.5" @@ -517,7 +518,6 @@ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "license": "MIT", - "optional": true, "engines": { "node": ">= 14" } @@ -612,15 +612,13 @@ "url": "https://feross.org/support" } ], - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/bignumber.js": { "version": "9.3.1", "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", "license": "MIT", - "optional": true, "engines": { "node": "*" } @@ -1031,8 +1029,7 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/farmhash-modern": { "version": "1.1.0", @@ -1224,7 +1221,6 @@ "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", "integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==", "license": "Apache-2.0", - "optional": true, "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", @@ -1246,7 +1242,6 @@ "https://github.com/sponsors/ctavan" ], "license": "MIT", - "optional": true, "bin": { "uuid": "dist/bin/uuid" } @@ -1256,7 +1251,6 @@ "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==", "license": "Apache-2.0", - "optional": true, "dependencies": { "gaxios": "^6.1.1", "google-logging-utils": "^0.0.2", @@ -1318,7 +1312,6 @@ "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz", "integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==", "license": "Apache-2.0", - "optional": true, "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", @@ -1375,7 +1368,6 @@ "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==", "license": "Apache-2.0", - "optional": true, "engines": { "node": ">=14" } @@ -1397,7 +1389,6 @@ "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz", "integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==", "license": "MIT", - "optional": true, "dependencies": { "gaxios": "^6.0.0", "jws": "^4.0.0" @@ -1547,7 +1538,6 @@ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", "license": "MIT", - "optional": true, "dependencies": { "agent-base": "^7.1.2", "debug": "4" @@ -1561,7 +1551,6 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", - "optional": true, "dependencies": { "ms": "^2.1.3" }, @@ -1578,8 +1567,7 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/iconv-lite": { "version": "0.4.24", @@ -1623,7 +1611,6 @@ "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", "license": "MIT", - "optional": true, "engines": { "node": ">=8" }, @@ -1658,7 +1645,6 @@ "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", "license": "MIT", - "optional": true, "dependencies": { "bignumber.js": "^9.0.0" } @@ -1929,7 +1915,6 @@ "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", "license": "MIT", - "optional": true, "dependencies": { "whatwg-url": "^5.0.0" }, @@ -2549,8 +2534,7 @@ "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/tslib": { "version": "2.8.1", @@ -2643,8 +2627,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "license": "BSD-2-Clause", - "optional": true + "license": "BSD-2-Clause" }, "node_modules/websocket-driver": { "version": "0.7.5", @@ -2674,7 +2657,6 @@ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", "license": "MIT", - "optional": true, "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" diff --git a/functions/package.json b/functions/package.json index 29ca634e..07792205 100644 --- a/functions/package.json +++ b/functions/package.json @@ -16,7 +16,8 @@ }, "dependencies": { "firebase-admin": "^12.2.0", - "firebase-functions": "^5.1.0" + "firebase-functions": "^5.1.0", + "google-auth-library": "^9.0.0" }, "devDependencies": { "typescript": "^5.4.5" diff --git a/functions/src/index.ts b/functions/src/index.ts index c4c641e4..f63ccd4e 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -6,6 +6,7 @@ export { sendDailyQuestionReminder, sendPartnerAnsweredNotification, } from './notifications/reminders' +export { checkDeviceIntegrity } from './security/checkDeviceIntegrity' /** * Basic health check callable. diff --git a/functions/src/security/checkDeviceIntegrity.ts b/functions/src/security/checkDeviceIntegrity.ts new file mode 100644 index 00000000..662bfd68 --- /dev/null +++ b/functions/src/security/checkDeviceIntegrity.ts @@ -0,0 +1,81 @@ +import * as functions from 'firebase-functions' +import { GoogleAuth } from 'google-auth-library' + +const PACKAGE_NAME = 'app.closer' +const PLAY_INTEGRITY_URL = + `https://playintegrity.googleapis.com/v1/${PACKAGE_NAME}:decodeIntegrityToken` + +/** + * Verifies a Play Integrity API token server-side and returns whether the + * device meets basic integrity requirements. + * + * Called by [PlayIntegrityChecker] on app startup. Requires the caller to be + * authenticated — the Firebase Auth token is verified automatically by the + * Functions runtime. + * + * Setup required before this function works in production: + * 1. Enable the Play Integrity API in your Google Cloud project. + * 2. Link the Cloud project to your Play Console app (Play Console → + * Setup → API access → Link to Cloud project). + * 3. Grant the Cloud Functions service account the "Play Integrity API User" + * role in IAM, or use a dedicated service account key via + * GOOGLE_APPLICATION_CREDENTIALS. + * + * If the API is not yet configured the function fails-open (returns + * passed: true) and logs the error, so unconfigured environments don't + * block users. Firebase App Check remains the server-side gatekeeper. + */ +export const checkDeviceIntegrity = functions.https.onCall( + async (data: { token?: string }, context) => { + if (!context.auth) { + throw new functions.https.HttpsError( + 'unauthenticated', + 'Caller must be authenticated.' + ) + } + + const token = data?.token + if (!token || typeof token !== 'string') { + throw new functions.https.HttpsError( + 'invalid-argument', + 'token is required.' + ) + } + + try { + const verdicts = await decodeIntegrityToken(token) + const passed = + verdicts.includes('MEETS_DEVICE_INTEGRITY') || + verdicts.includes('MEETS_STRONG_INTEGRITY') + return { passed, verdicts } + } catch (err) { + console.error('[checkDeviceIntegrity] verification unavailable:', err) + // Fail-open: treat as passed if the API is not yet configured. + return { passed: true, verdicts: [], error: 'verification_unavailable' } + } + } +) + +interface IntegrityResponse { + tokenPayloadExternal?: { + deviceIntegrity?: { + deviceRecognitionVerdict?: string[] + } + } +} + +async function decodeIntegrityToken(token: string): Promise { + const auth = new GoogleAuth({ + scopes: ['https://www.googleapis.com/auth/playintegrity'], + }) + const client = await auth.getClient() + const response = await client.request({ + url: PLAY_INTEGRITY_URL, + method: 'POST', + data: { integrity_token: token }, + }) + return ( + response.data.tokenPayloadExternal?.deviceIntegrity + ?.deviceRecognitionVerdict ?? [] + ) +}