feat: add App Integrity + device attestation modules

This commit is contained in:
null 2026-06-17 19:37:19 -05:00
parent 0095151bd9
commit ec315c63e0
13 changed files with 340 additions and 28 deletions

View File

@ -109,6 +109,12 @@ dependencies {
// Encrypted storage // Encrypted storage
implementation("androidx.security:security-crypto:1.0.0") 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) // RevenueCat native Android SDK (group: com.revenuecat.purchases, artifact: purchases)
implementation("com.revenuecat.purchases:purchases:8.20.0") implementation("com.revenuecat.purchases:purchases:8.20.0")

View File

@ -3,17 +3,26 @@ package app.closer
import android.app.Application import android.app.Application
import app.closer.core.firebase.FirebaseInitializer import app.closer.core.firebase.FirebaseInitializer
import app.closer.data.repository.ActivityProvider import app.closer.data.repository.ActivityProvider
import app.closer.domain.security.DeviceIntegrityChecker
import dagger.hilt.android.HiltAndroidApp 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 import javax.inject.Inject
@HiltAndroidApp @HiltAndroidApp
class CloserApp : Application() { class CloserApp : Application() {
@Inject lateinit var firebaseInitializer: FirebaseInitializer @Inject lateinit var firebaseInitializer: FirebaseInitializer
@Inject lateinit var deviceIntegrityChecker: DeviceIntegrityChecker
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
ActivityProvider.register(this) ActivityProvider.register(this)
firebaseInitializer.initialize() firebaseInitializer.initialize()
appScope.launch { deviceIntegrityChecker.runCheck() }
} }
} }

View File

@ -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<DeviceIntegrityResult> = _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)
}
}
}

View File

@ -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()
}
}

View File

@ -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<DeviceIntegrityResult>
suspend fun runCheck()
}

View File

@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
}; };
})(); })();
Object.defineProperty(exports, "__esModule", { value: true }); 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")); const functions = __importStar(require("firebase-functions"));
var revenueCatWebhook_1 = require("./billing/revenueCatWebhook"); var revenueCatWebhook_1 = require("./billing/revenueCatWebhook");
Object.defineProperty(exports, "revenueCatWebhook", { enumerable: true, get: function () { return revenueCatWebhook_1.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"); var reminders_1 = require("./notifications/reminders");
Object.defineProperty(exports, "sendDailyQuestionReminder", { enumerable: true, get: function () { return reminders_1.sendDailyQuestionReminder; } }); Object.defineProperty(exports, "sendDailyQuestionReminder", { enumerable: true, get: function () { return reminders_1.sendDailyQuestionReminder; } });
Object.defineProperty(exports, "sendPartnerAnsweredNotification", { enumerable: true, get: function () { return reminders_1.sendPartnerAnsweredNotification; } }); 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. * Basic health check callable.
* Useful for verifying function deployment and firebase-tools wiring. * Useful for verifying function deployment and firebase-tools wiring.

View File

@ -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"} {"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"}

View File

@ -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

View File

@ -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"}

View File

@ -9,7 +9,8 @@
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"firebase-admin": "^12.2.0", "firebase-admin": "^12.2.0",
"firebase-functions": "^5.1.0" "firebase-functions": "^5.1.0",
"google-auth-library": "^9.0.0"
}, },
"devDependencies": { "devDependencies": {
"typescript": "^5.4.5" "typescript": "^5.4.5"
@ -517,7 +518,6 @@
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"license": "MIT", "license": "MIT",
"optional": true,
"engines": { "engines": {
"node": ">= 14" "node": ">= 14"
} }
@ -612,15 +612,13 @@
"url": "https://feross.org/support" "url": "https://feross.org/support"
} }
], ],
"license": "MIT", "license": "MIT"
"optional": true
}, },
"node_modules/bignumber.js": { "node_modules/bignumber.js": {
"version": "9.3.1", "version": "9.3.1",
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
"integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==",
"license": "MIT", "license": "MIT",
"optional": true,
"engines": { "engines": {
"node": "*" "node": "*"
} }
@ -1031,8 +1029,7 @@
"version": "3.0.2", "version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"license": "MIT", "license": "MIT"
"optional": true
}, },
"node_modules/farmhash-modern": { "node_modules/farmhash-modern": {
"version": "1.1.0", "version": "1.1.0",
@ -1224,7 +1221,6 @@
"resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz",
"integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==", "integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true,
"dependencies": { "dependencies": {
"extend": "^3.0.2", "extend": "^3.0.2",
"https-proxy-agent": "^7.0.1", "https-proxy-agent": "^7.0.1",
@ -1246,7 +1242,6 @@
"https://github.com/sponsors/ctavan" "https://github.com/sponsors/ctavan"
], ],
"license": "MIT", "license": "MIT",
"optional": true,
"bin": { "bin": {
"uuid": "dist/bin/uuid" "uuid": "dist/bin/uuid"
} }
@ -1256,7 +1251,6 @@
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz",
"integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==", "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==",
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true,
"dependencies": { "dependencies": {
"gaxios": "^6.1.1", "gaxios": "^6.1.1",
"google-logging-utils": "^0.0.2", "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", "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz",
"integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==", "integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==",
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true,
"dependencies": { "dependencies": {
"base64-js": "^1.3.0", "base64-js": "^1.3.0",
"ecdsa-sig-formatter": "^1.0.11", "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", "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz",
"integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==", "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true,
"engines": { "engines": {
"node": ">=14" "node": ">=14"
} }
@ -1397,7 +1389,6 @@
"resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz", "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz",
"integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==", "integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==",
"license": "MIT", "license": "MIT",
"optional": true,
"dependencies": { "dependencies": {
"gaxios": "^6.0.0", "gaxios": "^6.0.0",
"jws": "^4.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", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"license": "MIT", "license": "MIT",
"optional": true,
"dependencies": { "dependencies": {
"agent-base": "^7.1.2", "agent-base": "^7.1.2",
"debug": "4" "debug": "4"
@ -1561,7 +1551,6 @@
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT", "license": "MIT",
"optional": true,
"dependencies": { "dependencies": {
"ms": "^2.1.3" "ms": "^2.1.3"
}, },
@ -1578,8 +1567,7 @@
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT", "license": "MIT"
"optional": true
}, },
"node_modules/iconv-lite": { "node_modules/iconv-lite": {
"version": "0.4.24", "version": "0.4.24",
@ -1623,7 +1611,6 @@
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
"integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
"license": "MIT", "license": "MIT",
"optional": true,
"engines": { "engines": {
"node": ">=8" "node": ">=8"
}, },
@ -1658,7 +1645,6 @@
"resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz",
"integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==",
"license": "MIT", "license": "MIT",
"optional": true,
"dependencies": { "dependencies": {
"bignumber.js": "^9.0.0" "bignumber.js": "^9.0.0"
} }
@ -1929,7 +1915,6 @@
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT", "license": "MIT",
"optional": true,
"dependencies": { "dependencies": {
"whatwg-url": "^5.0.0" "whatwg-url": "^5.0.0"
}, },
@ -2549,8 +2534,7 @@
"version": "0.0.3", "version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT", "license": "MIT"
"optional": true
}, },
"node_modules/tslib": { "node_modules/tslib": {
"version": "2.8.1", "version": "2.8.1",
@ -2643,8 +2627,7 @@
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause", "license": "BSD-2-Clause"
"optional": true
}, },
"node_modules/websocket-driver": { "node_modules/websocket-driver": {
"version": "0.7.5", "version": "0.7.5",
@ -2674,7 +2657,6 @@
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT", "license": "MIT",
"optional": true,
"dependencies": { "dependencies": {
"tr46": "~0.0.3", "tr46": "~0.0.3",
"webidl-conversions": "^3.0.0" "webidl-conversions": "^3.0.0"

View File

@ -16,7 +16,8 @@
}, },
"dependencies": { "dependencies": {
"firebase-admin": "^12.2.0", "firebase-admin": "^12.2.0",
"firebase-functions": "^5.1.0" "firebase-functions": "^5.1.0",
"google-auth-library": "^9.0.0"
}, },
"devDependencies": { "devDependencies": {
"typescript": "^5.4.5" "typescript": "^5.4.5"

View File

@ -6,6 +6,7 @@ export {
sendDailyQuestionReminder, sendDailyQuestionReminder,
sendPartnerAnsweredNotification, sendPartnerAnsweredNotification,
} from './notifications/reminders' } from './notifications/reminders'
export { checkDeviceIntegrity } from './security/checkDeviceIntegrity'
/** /**
* Basic health check callable. * Basic health check callable.

View File

@ -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<string[]> {
const auth = new GoogleAuth({
scopes: ['https://www.googleapis.com/auth/playintegrity'],
})
const client = await auth.getClient()
const response = await client.request<IntegrityResponse>({
url: PLAY_INTEGRITY_URL,
method: 'POST',
data: { integrity_token: token },
})
return (
response.data.tokenPayloadExternal?.deviceIntegrity
?.deviceRecognitionVerdict ?? []
)
}