Skip to content

Commit

Permalink
feat: Add functionality for mobile-backend/client-attestation call (#98)
Browse files Browse the repository at this point in the history
* feat: Add functionality for mobile-backend/client-attestation call

- update AttestationCaller interface to enable use of firebase token
and jwk
- update jwk to a custom impl (JWK provided by jose4.jwt was not serializable) to
enable use in the backend call
- update FirebaseClientAttestationManager to add logic for the attestation call
once firebase call is successful
- update keystore manager to enable getting the public key in the required format

* fix: Fix detekt issue in test file

* style: Remove unused import

* test: Amend tests with the correct impl

* refactor: Amend AttestationCaller and AttestationResponse

- simplify the result returned from the attestation caller
- update attestation response to meet requirements from backend

* style: Remove unnecessary lint supression
  • Loading branch information
BiancaMihaila authored Nov 8, 2024
1 parent bcd5c1d commit 0fb4ce9
Show file tree
Hide file tree
Showing 8 changed files with 123 additions and 27 deletions.
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package uk.gov.android.authentication.integrity

import kotlinx.coroutines.runBlocking
import org.mockito.kotlin.any
import kotlin.test.BeforeTest
import kotlin.test.Test
import org.mockito.kotlin.mock
Expand Down Expand Up @@ -33,14 +34,19 @@ class FirebaseClientAttestationManagerTest {
fun check_success_response_from_get_attestation(): Unit = runBlocking {
whenever(mockAppChecker.getAppCheckToken())
.thenReturn(Result.success(AppCheckToken("Success")))
whenever(caller.call(any(), any()))
.thenReturn(AttestationResponse.Success(
"Success",
"0"
))
val result = clientAttestationManager.getAttestation()

assertEquals(AttestationResponse.Success("Success"),
assertEquals(AttestationResponse.Success("Success", "0"),
result)
}

@Test
fun check_failure_response_from_get_attestation() = runBlocking {
fun check_failure_response_from_get_firebase_token() = runBlocking {
whenever(mockAppChecker.getAppCheckToken()).thenReturn(
Result.failure(Exception("Error"))
)
Expand All @@ -50,6 +56,18 @@ class FirebaseClientAttestationManagerTest {
(result as AttestationResponse.Failure).reason)
}

@Test
fun check_failure_response_from_get_attestation() = runBlocking {
whenever(mockAppChecker.getAppCheckToken())
.thenReturn(Result.success(AppCheckToken("Success")))
whenever(caller.call(any(), any()))
.thenReturn(AttestationResponse.Failure("Error"))
val result = clientAttestationManager.getAttestation()

assertEquals("Error",
(result as AttestationResponse.Failure).reason)
}

@Test
fun check_failure_response_from_sign_attestation() = runBlocking {
val result = clientAttestationManager.signAttestation("attestation")
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
package uk.gov.android.authentication.integrity

import android.security.keystore.KeyProperties
import kotlin.test.BeforeTest
import java.security.KeyStore
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue

@OptIn(ExperimentalEncodingApi::class)
class KeystoreManagerTest {
private lateinit var keyStore: KeyStore
private lateinit var keystoreManager: KeystoreManager
Expand Down Expand Up @@ -35,4 +38,28 @@ class KeystoreManagerTest {
val privateKey = keystoreManager.appCheckPrivateKey
assertEquals(KeyProperties.KEY_ALGORITHM_EC, privateKey.algorithm)
}

@Test
fun appCheckPublicKey() {
val publicKey = keystoreManager.appCheckPublicKey
assertEquals(KeyProperties.KEY_ALGORITHM_EC, publicKey.algorithm)
}

@Test
fun check_getPubKeyBase64ECCoord() {
val actual = keystoreManager.getPubKeyBase64ECCoord()

assertTrue(checkInputIsBase64(actual.first))
assertTrue(checkInputIsBase64(actual.second))
}

@Suppress("SwallowedException")
private fun checkInputIsBase64(input: String): Boolean {
return try {
Base64.decode(input)
true
} catch (e: IllegalArgumentException) {
false
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ import uk.gov.android.authentication.integrity.model.AppIntegrityConfiguration
import uk.gov.android.authentication.integrity.model.AttestationResponse
import uk.gov.android.authentication.integrity.model.SignedResponse
import uk.gov.android.authentication.integrity.usecase.AttestationCaller
import uk.gov.android.authentication.integrity.usecase.JWK
import kotlin.io.encoding.ExperimentalEncodingApi

@Suppress("UnusedPrivateProperty")
@OptIn(ExperimentalEncodingApi::class)
class FirebaseClientAttestationManager(
config: AppIntegrityConfiguration
) : ClientAttestationManager {
Expand All @@ -21,8 +23,13 @@ class FirebaseClientAttestationManager(
AttestationResponse.Failure(err.toString())
}
// If successful -> functionality to get signed attestation form Mobile back-end
val pubKeyECCoord = keyManager.getPubKeyBase64ECCoord()
val jwk = JWK.makeJWK(x = pubKeyECCoord.first, y = pubKeyECCoord.second)
return if (token is AppCheckToken) {
AttestationResponse.Success(token.jwtToken)
attestationCaller.call(
token.jwtToken,
jwk
)
// If unsuccessful -> return the failure
} else {
token as AttestationResponse.Failure
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ import android.util.Log
import java.security.KeyPairGenerator
import java.security.KeyStore
import java.security.PrivateKey
import java.security.interfaces.ECPublicKey
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi

@ExperimentalEncodingApi
@Suppress("MemberVisibilityCanBePrivate", "unused")
internal class KeystoreManager {
private val ks: KeyStore = KeyStore.getInstance(KEYSTORE).apply {
Expand All @@ -27,13 +31,24 @@ internal class KeystoreManager {
val appCheckPrivateKey: PrivateKey
get() = ks.getKey(ALIAS, null) as PrivateKey

val appCheckPublicKey: ECPublicKey
get() = ks.getCertificate(ALIAS).publicKey as ECPublicKey

init {
if (!hasAppCheckKeys) {
Log.d(this::class.simpleName, "Generating key pair")
createNewKeys()
}
}

fun getPubKeyBase64ECCoord(): Pair<String, String> {
val xByteArr = appCheckPublicKey.w.affineX.toByteArray()
val yByteArr = appCheckPublicKey.w.affineY.toByteArray()
val x = Base64.encode(xByteArr)
val y = Base64.encode(yByteArr)
return Pair(x, y)
}

private fun createNewKeys() {
KeyPairGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_EC,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
package uk.gov.android.authentication.integrity.model

import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonNames

@OptIn(ExperimentalSerializationApi::class)
sealed class AttestationResponse {
data class Success(val attestationJwt: String) : AttestationResponse()
@Serializable
data class Success(
@JsonNames("client_attestation")
val attestationJwt: String,
@JsonNames("expires_in")
val expiresIn: String
) : AttestationResponse()
data class Failure(val reason: String, val error: Throwable? = null) : AttestationResponse()
}
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
package uk.gov.android.authentication.integrity.usecase

import uk.gov.android.authentication.integrity.model.AttestationResponse

@Suppress("unused")
fun interface AttestationCaller {
suspend fun call(
signedProofOfPossession: String,
jwkX: String,
jwkY: String
): Result<Response>

data class Response(val jwt: String, val expiresIn: Long)
firebaseToken: String,
jwk: JWK.JsonWebKey
): AttestationResponse

companion object {
const val FIREBASE_HEADER = "X-Firebase-AppCheck"
const val CONTENT_TYPE = "Content-type"
const val CONTENT_TYPE_VALUE = "application/json"
}
}
Original file line number Diff line number Diff line change
@@ -1,25 +1,34 @@
package uk.gov.android.authentication.integrity.usecase

import org.jose4j.jwk.JsonWebKey
import kotlinx.serialization.Serializable

@Suppress("MemberVisibilityCanBePrivate")
object JWK {
const val keyType = "kty"
const val use = "use"
const val curve = "crv"
const val x = "x"
const val y = "y"
private const val keyTypeValue = "EC"
private const val useValue = "sig"
private const val curveValue = "P-256"

fun makeJWK(x: String, y: String): JsonWebKey = JsonWebKey.Factory.newJwk(
mapOf(
keyType to keyTypeValue,
use to useValue,
curve to curveValue,
JWK.x to x,
JWK.y to y
fun makeJWK(x: String, y: String): JsonWebKey = JsonWebKey(
jwk = JsonWebKeyFormat(
keyTypeValue,
useValue,
curveValue,
x,
y
)
)

@Serializable
data class JsonWebKey(
val jwk: JsonWebKeyFormat
)

@Serializable
data class JsonWebKeyFormat(
val kty: String,
val use: String,
val crv: String,
val x: String,
val y: String
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,20 @@ class JWKTest {
@Test
fun `makeJWK sets defaults`() {
val actual = JWK.makeJWK(X, Y)
assertEquals(expected = "EC", actual = actual.keyType)
assertEquals(expected = "sig", actual = actual.use)
assertEquals(jwk, actual)
}

companion object {
const val X = "18wHLeIgW9wVN6VD1Txgpqy2LszYkMf6J8njVAibvhM"
const val Y = "-V4dS4UaLMgP_4fY4j8ir7cl1TXlFdAgcx55o7TkcSA"
val jwk = JWK.JsonWebKey(
jwk = JWK.JsonWebKeyFormat(
"EC",
"sig",
"P-256",
X,
Y
)
)
}
}

0 comments on commit 0fb4ce9

Please sign in to comment.