Skip to content

Commit 0532608

Browse files
[AI] Include the "X-Android-Package" and "X-Android-Cert" headers (#7679)
These headers are necessary to support [API Key restrictions](https://docs.cloud.google.com/docs/authentication/api-keys#adding-application-restrictions). This feature enable you to limit which apps (by matching package name and certificate fingerprint) are allowed to make request. **Important**: We still *strongly* recommend the use of Firebase App Check instead of, or in addition to, API key restrictions. --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
1 parent 275b405 commit 0532608

7 files changed

Lines changed: 219 additions & 4 deletions

File tree

firebase-ai/CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22

33
- [feature] Added support for configuring thinking levels with Gemini 3 series
44
models and onwards. (#7599)
5-
- [changed] Added `equals()` function to `GenerativeBackend`.
5+
- [feature] Added support for [API Key
6+
restrictions](https://docs.cloud.google.com/docs/authentication/api-keys#adding-application-restrictions) (#7679)
7+
- [changed] Added `equals()` function to `GenerativeBackend`. (#7597)
68

79
# 17.7.0
810

firebase-ai/src/main/kotlin/com/google/firebase/ai/common/APIController.kt

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@
1616

1717
package com.google.firebase.ai.common
1818

19+
import android.content.pm.PackageManager
20+
import android.content.pm.Signature
21+
import android.os.Build
1922
import android.util.Log
2023
import com.google.firebase.Firebase
2124
import com.google.firebase.FirebaseApp
@@ -65,6 +68,8 @@ import io.ktor.http.contentType
6568
import io.ktor.http.withCharset
6669
import io.ktor.serialization.kotlinx.json.json
6770
import io.ktor.utils.io.charsets.Charset
71+
import java.security.MessageDigest
72+
import java.security.NoSuchAlgorithmException
6873
import kotlin.math.max
6974
import kotlin.time.Duration
7075
import kotlin.time.Duration.Companion.seconds
@@ -138,6 +143,8 @@ internal constructor(
138143
)
139144

140145
private val model = fullModelName(model)
146+
private val appPackageName by lazy { firebaseApp.applicationContext.packageName }
147+
private val appSigningCertFingerprint by lazy { getSigningCertFingerprint() }
141148

142149
private val client =
143150
HttpClient(httpEngine) {
@@ -268,6 +275,8 @@ internal constructor(
268275
contentType(ContentType.Application.Json)
269276
header("x-goog-api-key", key)
270277
header("x-goog-api-client", apiClient)
278+
header("X-Android-Package", appPackageName)
279+
header("X-Android-Cert", appSigningCertFingerprint ?: "")
271280
if (firebaseApp.isDataCollectionDefaultEnabled) {
272281
header("X-Firebase-AppId", googleAppId)
273282
header("X-Firebase-AppVersion", appVersion)
@@ -345,6 +354,64 @@ internal constructor(
345354
}
346355
}
347356

357+
@OptIn(ExperimentalStdlibApi::class)
358+
private fun getSigningCertFingerprint(): String? {
359+
val signature = getCurrentSignature() ?: return null
360+
try {
361+
val messageDigest = MessageDigest.getInstance("SHA-1")
362+
val digest = messageDigest.digest(signature.toByteArray())
363+
return digest.toHexString(HexFormat.UpperCase)
364+
} catch (e: NoSuchAlgorithmException) {
365+
Log.w(TAG, "No support for SHA-1 algorithm found.", e)
366+
return null
367+
}
368+
}
369+
370+
@Suppress("DEPRECATION")
371+
private fun getCurrentSignature(): Signature? {
372+
val packageName = firebaseApp.applicationContext.packageName
373+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
374+
val packageInfo =
375+
try {
376+
firebaseApp.applicationContext.packageManager.getPackageInfo(
377+
packageName,
378+
PackageManager.GET_SIGNATURES
379+
)
380+
} catch (e: PackageManager.NameNotFoundException) {
381+
Log.d(TAG, "PackageManager couldn't find the package \"$packageName\"")
382+
return null
383+
}
384+
val signatures = packageInfo?.signatures ?: return null
385+
if (signatures.size > 1) {
386+
Log.d(
387+
TAG,
388+
"Multiple certificates found. On Android < P, certificate order is non-deterministic; an rotated/old cert may be used."
389+
)
390+
}
391+
return signatures.firstOrNull()
392+
}
393+
val packageInfo =
394+
try {
395+
firebaseApp.applicationContext.packageManager.getPackageInfo(
396+
packageName,
397+
PackageManager.GET_SIGNING_CERTIFICATES
398+
)
399+
} catch (e: PackageManager.NameNotFoundException) {
400+
Log.d(TAG, "PackageManager couldn't find the package \"$packageName\"")
401+
return null
402+
}
403+
val signingInfo = packageInfo?.signingInfo ?: return null
404+
if (signingInfo.hasMultipleSigners()) {
405+
Log.d(TAG, "App has been signed with multiple certificates. Defaulting to the first one")
406+
return signingInfo.apkContentsSigners.first()
407+
} else {
408+
// The `signingCertificateHistory` contains a sorted list of certificates used to sign this
409+
// artifact, with the original one first, and once it's rotated, the current one is added at
410+
// the end of the list. See the method's refdocs for more info.
411+
return signingInfo.signingCertificateHistory.lastOrNull()
412+
}
413+
}
414+
348415
companion object {
349416
private val TAG = APIController::class.java.simpleName
350417

firebase-ai/src/test/java/com/google/firebase/ai/DevAPIUnarySnapshotTests.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
package com.google.firebase.ai
1818

19+
import androidx.test.ext.junit.runners.AndroidJUnit4
1920
import com.google.firebase.ai.type.FinishReason
2021
import com.google.firebase.ai.type.InvalidAPIKeyException
2122
import com.google.firebase.ai.type.PublicPreviewAPI
@@ -36,7 +37,9 @@ import io.ktor.http.HttpStatusCode
3637
import kotlin.time.Duration.Companion.seconds
3738
import kotlinx.coroutines.withTimeout
3839
import org.junit.Test
40+
import org.junit.runner.RunWith
3941

42+
@RunWith(AndroidJUnit4::class)
4043
internal class DevAPIUnarySnapshotTests {
4144
private val testTimeout = 5.seconds
4245

firebase-ai/src/test/java/com/google/firebase/ai/GenerativeModelTesting.kt

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,17 @@
1616

1717
package com.google.firebase.ai
1818

19+
import android.content.Context
20+
import android.content.pm.PackageManager
21+
import androidx.test.core.app.ApplicationProvider
22+
import androidx.test.ext.junit.runners.AndroidJUnit4
1923
import com.google.firebase.FirebaseApp
2024
import com.google.firebase.ai.common.APIController
2125
import com.google.firebase.ai.common.JSON
2226
import com.google.firebase.ai.common.util.doBlocking
2327
import com.google.firebase.ai.type.Candidate
2428
import com.google.firebase.ai.type.Content
29+
import com.google.firebase.ai.type.CountTokensResponse
2530
import com.google.firebase.ai.type.GenerateContentResponse
2631
import com.google.firebase.ai.type.GenerativeBackend
2732
import com.google.firebase.ai.type.HarmBlockMethod
@@ -41,6 +46,7 @@ import io.kotest.assertions.json.shouldContainJsonKey
4146
import io.kotest.assertions.json.shouldContainJsonKeyValue
4247
import io.kotest.assertions.throwables.shouldThrow
4348
import io.kotest.matchers.collections.shouldNotBeEmpty
49+
import io.kotest.matchers.shouldBe
4450
import io.kotest.matchers.string.shouldContain
4551
import io.kotest.matchers.types.shouldBeInstanceOf
4652
import io.ktor.client.engine.mock.MockEngine
@@ -50,12 +56,15 @@ import io.ktor.http.HttpStatusCode
5056
import io.ktor.http.content.TextContent
5157
import io.ktor.http.headersOf
5258
import kotlin.time.Duration.Companion.seconds
59+
import kotlinx.coroutines.flow.collect
5360
import kotlinx.coroutines.withTimeout
5461
import kotlinx.serialization.encodeToString
5562
import org.junit.Before
5663
import org.junit.Test
64+
import org.junit.runner.RunWith
5765
import org.mockito.Mockito
5866

67+
@RunWith(AndroidJUnit4::class)
5968
internal class GenerativeModelTesting {
6069
private val TEST_CLIENT_ID = "test"
6170
private val TEST_APP_ID = "1:android:12345"
@@ -65,7 +74,9 @@ internal class GenerativeModelTesting {
6574

6675
@Before
6776
fun setup() {
77+
val context = ApplicationProvider.getApplicationContext<Context>()
6878
Mockito.`when`(mockFirebaseApp.isDataCollectionDefaultEnabled).thenReturn(false)
79+
Mockito.`when`(mockFirebaseApp.applicationContext).thenReturn(context)
6980
}
7081

7182
@Test
@@ -112,6 +123,104 @@ internal class GenerativeModelTesting {
112123
}
113124
}
114125

126+
@Test
127+
fun `security headers are included in request`() = doBlocking {
128+
val mockEngine = MockEngine {
129+
respond(
130+
generateContentResponseAsJsonString("text response"),
131+
HttpStatusCode.OK,
132+
headersOf(HttpHeaders.ContentType, "application/json")
133+
)
134+
}
135+
val generativeModel = generativeModelWithMockEngine(mockEngine)
136+
137+
withTimeout(5.seconds) { generativeModel.generateContent("my test prompt") }
138+
139+
val headers = mockEngine.requestHistory.first().headers
140+
headers["X-Android-Package"] shouldBe "com.google.firebase.ai.test"
141+
// X-Android-Cert will be empty because Robolectric doesn't provide signatures by default
142+
headers["X-Android-Cert"] shouldBe ""
143+
}
144+
145+
@Test
146+
fun `security headers are included in streaming request`() = doBlocking {
147+
val mockEngine = MockEngine {
148+
respond(
149+
generateContentResponseAsJsonString("text response"),
150+
HttpStatusCode.OK,
151+
headersOf(HttpHeaders.ContentType, "application/json")
152+
)
153+
}
154+
val generativeModel = generativeModelWithMockEngine(mockEngine)
155+
156+
withTimeout(5.seconds) { generativeModel.generateContentStream("my test prompt").collect() }
157+
158+
val headers = mockEngine.requestHistory.first().headers
159+
headers["X-Android-Package"] shouldBe "com.google.firebase.ai.test"
160+
// X-Android-Cert will be empty because Robolectric doesn't provide signatures by default
161+
headers["X-Android-Cert"] shouldBe ""
162+
}
163+
164+
@Test
165+
fun `security headers are included in countTokens request`() = doBlocking {
166+
val mockEngine = MockEngine {
167+
respond(
168+
JSON.encodeToString(CountTokensResponse.Internal(totalTokens = 10)),
169+
HttpStatusCode.OK,
170+
headersOf(HttpHeaders.ContentType, "application/json")
171+
)
172+
}
173+
val generativeModel = generativeModelWithMockEngine(mockEngine)
174+
175+
withTimeout(5.seconds) { generativeModel.countTokens("my test prompt") }
176+
177+
val headers = mockEngine.requestHistory.first().headers
178+
headers["X-Android-Package"] shouldBe "com.google.firebase.ai.test"
179+
// X-Android-Cert will be empty because Robolectric doesn't provide signatures by default
180+
headers["X-Android-Cert"] shouldBe ""
181+
}
182+
183+
@Test
184+
fun `X-Android-Cert is empty when signatures are missing`() = doBlocking {
185+
val mockEngine = MockEngine {
186+
respond(
187+
generateContentResponseAsJsonString("text response"),
188+
HttpStatusCode.OK,
189+
headersOf(HttpHeaders.ContentType, "application/json")
190+
)
191+
}
192+
193+
val mockPackageManager = Mockito.mock(PackageManager::class.java)
194+
val mockContext = Mockito.mock(Context::class.java)
195+
Mockito.`when`(mockContext.packageName).thenReturn("com.test.app")
196+
Mockito.`when`(mockContext.packageManager).thenReturn(mockPackageManager)
197+
198+
val mockApp = Mockito.mock(FirebaseApp::class.java)
199+
Mockito.`when`(mockApp.applicationContext).thenReturn(mockContext)
200+
201+
val apiController =
202+
APIController(
203+
"super_cool_test_key",
204+
"gemini-2.5-flash",
205+
RequestOptions(),
206+
mockEngine,
207+
TEST_CLIENT_ID,
208+
mockApp,
209+
TEST_VERSION,
210+
TEST_APP_ID,
211+
null,
212+
)
213+
214+
val generativeModel = GenerativeModel("gemini-2.5-flash", controller = apiController)
215+
216+
withTimeout(5.seconds) { generativeModel.generateContent("my test prompt") }
217+
218+
val headers = mockEngine.requestHistory.first().headers
219+
headers["X-Android-Package"] shouldBe "com.test.app"
220+
// X-Android-Cert will be empty because Robolectric doesn't provide signatures by default
221+
headers["X-Android-Cert"] shouldBe ""
222+
}
223+
115224
@Test
116225
fun `exception thrown when using invalid location`() = doBlocking {
117226
val mockEngine = MockEngine {
@@ -310,4 +419,21 @@ internal class GenerativeModelTesting {
310419
it.shouldContainJsonKeyValue("$.generation_config.thinking_config.thinking_level", "MEDIUM")
311420
}
312421
}
422+
423+
private fun generativeModelWithMockEngine(mockEngine: MockEngine): GenerativeModel {
424+
val apiController =
425+
APIController(
426+
"super_cool_test_key",
427+
"gemini-2.5-flash",
428+
RequestOptions(),
429+
mockEngine,
430+
TEST_CLIENT_ID,
431+
mockFirebaseApp,
432+
TEST_VERSION,
433+
TEST_APP_ID,
434+
null,
435+
)
436+
437+
return GenerativeModel("gemini-2.5-flash", controller = apiController)
438+
}
313439
}

firebase-ai/src/test/java/com/google/firebase/ai/common/APIControllerTests.kt

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@
1616

1717
package com.google.firebase.ai.common
1818

19+
import android.content.Context
20+
import androidx.test.core.app.ApplicationProvider
21+
import androidx.test.ext.junit.runners.AndroidJUnit4
1922
import com.google.firebase.FirebaseApp
2023
import com.google.firebase.ai.BuildConfig
2124
import com.google.firebase.ai.common.util.commonTest
@@ -56,15 +59,16 @@ import kotlinx.serialization.json.JsonObject
5659
import org.junit.Before
5760
import org.junit.Test
5861
import org.junit.runner.RunWith
59-
import org.junit.runners.Parameterized
6062
import org.mockito.Mockito
63+
import org.robolectric.ParameterizedRobolectricTestRunner
6164

6265
private val TEST_CLIENT_ID = "genai-android/test"
6366

6467
private val TEST_APP_ID = "1:android:12345"
6568

6669
private val TEST_VERSION = 1
6770

71+
@RunWith(AndroidJUnit4::class)
6872
internal class APIControllerTests {
6973
private val testTimeout = 5.seconds
7074

@@ -96,13 +100,16 @@ internal class APIControllerTests {
96100
}
97101

98102
@OptIn(ExperimentalSerializationApi::class)
103+
@RunWith(AndroidJUnit4::class)
99104
internal class RequestFormatTests {
100105

101106
private val mockFirebaseApp = Mockito.mock<FirebaseApp>()
102107

103108
@Before
104109
fun setup() {
110+
val context = ApplicationProvider.getApplicationContext<Context>()
105111
Mockito.`when`(mockFirebaseApp.isDataCollectionDefaultEnabled).thenReturn(false)
112+
Mockito.`when`(mockFirebaseApp.applicationContext).thenReturn(context)
106113
}
107114

108115
@Test
@@ -454,13 +461,15 @@ internal class RequestFormatTests {
454461
}
455462
}
456463

457-
@RunWith(Parameterized::class)
464+
@RunWith(ParameterizedRobolectricTestRunner::class)
458465
internal class ModelNamingTests(private val modelName: String, private val actualName: String) {
459466
private val mockFirebaseApp = Mockito.mock<FirebaseApp>()
460467

461468
@Before
462469
fun setup() {
470+
val context = ApplicationProvider.getApplicationContext<Context>()
463471
Mockito.`when`(mockFirebaseApp.isDataCollectionDefaultEnabled).thenReturn(false)
472+
Mockito.`when`(mockFirebaseApp.applicationContext).thenReturn(context)
464473
}
465474

466475
@Test
@@ -495,7 +504,7 @@ internal class ModelNamingTests(private val modelName: String, private val actua
495504

496505
companion object {
497506
@JvmStatic
498-
@Parameterized.Parameters
507+
@ParameterizedRobolectricTestRunner.Parameters
499508
fun data() =
500509
listOf(
501510
arrayOf("gemini-pro", "models/gemini-pro"),

firebase-ai/src/test/java/com/google/firebase/ai/common/util/tests.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818

1919
package com.google.firebase.ai.common.util
2020

21+
import android.content.Context
22+
import androidx.test.core.app.ApplicationProvider
2123
import com.google.firebase.FirebaseApp
2224
import com.google.firebase.ai.common.APIController
2325
import com.google.firebase.ai.common.JSON
@@ -95,7 +97,9 @@ internal fun commonTest(
9597
block: CommonTest,
9698
) = doBlocking {
9799
val mockFirebaseApp = Mockito.mock<FirebaseApp>()
100+
val context = ApplicationProvider.getApplicationContext<Context>()
98101
Mockito.`when`(mockFirebaseApp.isDataCollectionDefaultEnabled).thenReturn(false)
102+
Mockito.`when`(mockFirebaseApp.applicationContext).thenReturn(context)
99103

100104
val channel = ByteChannel(autoFlush = true)
101105
val apiController =

0 commit comments

Comments
 (0)