feat: add App Integrity + device attestation modules
This commit is contained in:
parent
0095151bd9
commit
ec315c63e0
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
@ -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
|
||||
|
|
@ -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"}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ export {
|
|||
sendDailyQuestionReminder,
|
||||
sendPartnerAnsweredNotification,
|
||||
} from './notifications/reminders'
|
||||
export { checkDeviceIntegrity } from './security/checkDeviceIntegrity'
|
||||
|
||||
/**
|
||||
* Basic health check callable.
|
||||
|
|
|
|||
|
|
@ -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 ?? []
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue