From f8a1b5dc6151428689134bea762ee59a0d278465 Mon Sep 17 00:00:00 2001 From: Bianca Mihaila <137913588+BiancaMihaila@users.noreply.github.com> Date: Fri, 15 Nov 2024 08:57:58 +0000 Subject: [PATCH] feat: Generate ProofOfPosession (#101) * feat: Create ProofOfPosession structure and amend KeyStoreManager - create KeyStreManager interface - amend KeyStoreManager impl name and availabel functions to allow for signing and verifying Signature for Jwt * refactor: Amend naming to be more accurate * feat: Amend FirebaseClientAttestationManager to enable getting PoP - amend implementation to create sign PoP and create PoP Jwt * test: Fix tests * fix: Change exp time to 3 minutes * test: Remove unused import and add test * feat: Expose ECKeyManager * refactor: Change the integrity package structure - pkg structure based on functionality * test: Amend tests * style: Add Log for the signature verification for QA purpose * refactor: Change names to be more specific --- .../FirebaseClientAttestationManagerTest.kt | 83 +++++++++++++--- .../integrity/KeystoreManagerTest.kt | 65 ------------- .../integrity/keymanager/ECKeyManagerTest.kt | 63 +++++++++++++ .../integrity/ClientAttestationManager.kt | 6 +- .../FirebaseClientAttestationManager.kt | 48 +++++++--- .../integrity/KeystoreManager.kt | 66 ------------- .../integrity/appcheck/AppChecker.kt | 7 -- .../{ => appcheck}/model/AppCheckToken.kt | 2 +- .../model/AttestationResponse.kt | 2 +- .../integrity/appcheck/usecase/AppChecker.kt | 7 ++ .../usecase/AttestationCaller.kt | 4 +- .../integrity/{ => appcheck}/usecase/JWK.kt | 2 +- .../integrity/keymanager/ECKeyManager.kt | 94 +++++++++++++++++++ .../integrity/keymanager/KeyStoreManager.kt | 7 ++ .../model/AppIntegrityConfiguration.kt | 8 +- .../integrity/model/SignedResponse.kt | 6 -- .../pop/ProofOfPossessionGenerator.kt | 73 ++++++++++++++ .../authentication/integrity/pop/SignedPoP.kt | 6 ++ .../{ => appcheck}/usecase/JWKTest.kt | 3 +- .../pop/ProofOfPossessionGeneratorTest.kt | 19 ++++ app/src/test/resources/bodyPoPBase64.txt | 1 + 21 files changed, 390 insertions(+), 182 deletions(-) delete mode 100644 app/src/androidTest/java/uk/gov/android/authentication/integrity/KeystoreManagerTest.kt create mode 100644 app/src/androidTest/java/uk/gov/android/authentication/integrity/keymanager/ECKeyManagerTest.kt delete mode 100644 app/src/main/java/uk/gov/android/authentication/integrity/KeystoreManager.kt delete mode 100644 app/src/main/java/uk/gov/android/authentication/integrity/appcheck/AppChecker.kt rename app/src/main/java/uk/gov/android/authentication/integrity/{ => appcheck}/model/AppCheckToken.kt (63%) rename app/src/main/java/uk/gov/android/authentication/integrity/{ => appcheck}/model/AttestationResponse.kt (89%) create mode 100644 app/src/main/java/uk/gov/android/authentication/integrity/appcheck/usecase/AppChecker.kt rename app/src/main/java/uk/gov/android/authentication/integrity/{ => appcheck}/usecase/AttestationCaller.kt (71%) rename app/src/main/java/uk/gov/android/authentication/integrity/{ => appcheck}/usecase/JWK.kt (91%) create mode 100644 app/src/main/java/uk/gov/android/authentication/integrity/keymanager/ECKeyManager.kt create mode 100644 app/src/main/java/uk/gov/android/authentication/integrity/keymanager/KeyStoreManager.kt delete mode 100644 app/src/main/java/uk/gov/android/authentication/integrity/model/SignedResponse.kt create mode 100644 app/src/main/java/uk/gov/android/authentication/integrity/pop/ProofOfPossessionGenerator.kt create mode 100644 app/src/main/java/uk/gov/android/authentication/integrity/pop/SignedPoP.kt rename app/src/test/java/uk/gov/android/authentication/integrity/{ => appcheck}/usecase/JWKTest.kt (81%) create mode 100644 app/src/test/java/uk/gov/android/authentication/integrity/pop/ProofOfPossessionGeneratorTest.kt create mode 100644 app/src/test/resources/bodyPoPBase64.txt diff --git a/app/src/androidTest/java/uk/gov/android/authentication/integrity/FirebaseClientAttestationManagerTest.kt b/app/src/androidTest/java/uk/gov/android/authentication/integrity/FirebaseClientAttestationManagerTest.kt index 4c02736..8897a05 100644 --- a/app/src/androidTest/java/uk/gov/android/authentication/integrity/FirebaseClientAttestationManagerTest.kt +++ b/app/src/androidTest/java/uk/gov/android/authentication/integrity/FirebaseClientAttestationManagerTest.kt @@ -6,25 +6,33 @@ import kotlin.test.BeforeTest import kotlin.test.Test import org.mockito.kotlin.mock import org.mockito.kotlin.whenever -import uk.gov.android.authentication.integrity.appcheck.AppChecker -import uk.gov.android.authentication.integrity.model.AppCheckToken +import uk.gov.android.authentication.integrity.appcheck.usecase.AppChecker +import uk.gov.android.authentication.integrity.keymanager.ECKeyManager +import uk.gov.android.authentication.integrity.keymanager.KeyStoreManager +import uk.gov.android.authentication.integrity.appcheck.model.AppCheckToken +import uk.gov.android.authentication.integrity.appcheck.model.AttestationResponse +import uk.gov.android.authentication.integrity.pop.ProofOfPossessionGenerator +import uk.gov.android.authentication.integrity.pop.SignedPoP +import uk.gov.android.authentication.integrity.appcheck.usecase.AttestationCaller 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 java.security.SignatureException +import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.test.assertEquals +import kotlin.test.assertTrue class FirebaseClientAttestationManagerTest { private lateinit var clientAttestationManager: ClientAttestationManager - private val caller: AttestationCaller = mock() + private val mockCaller: AttestationCaller = mock() private val mockAppChecker: AppChecker = mock() + private val mockKeyStoreManager: KeyStoreManager = mock() @BeforeTest fun setup() { val config = AppIntegrityConfiguration( - caller, - mockAppChecker + mockCaller, + mockAppChecker, + mockKeyStoreManager ) clientAttestationManager = FirebaseClientAttestationManager(config) @@ -34,14 +42,18 @@ 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( + whenever(mockCaller.call(any(), any())) + .thenReturn( + AttestationResponse.Success( "Success", 0 )) + whenever(mockKeyStoreManager.getPublicKey()) + .thenReturn(Pair("Success", "Success")) val result = clientAttestationManager.getAttestation() - assertEquals(AttestationResponse.Success("Success", 0), + assertEquals( + AttestationResponse.Success("Success", 0), result) } @@ -50,6 +62,9 @@ class FirebaseClientAttestationManagerTest { whenever(mockAppChecker.getAppCheckToken()).thenReturn( Result.failure(Exception("Error")) ) + whenever(mockKeyStoreManager.getPublicKey()) + .thenReturn(Pair("Success", "Success")) + val result = clientAttestationManager.getAttestation() assertEquals(Exception("Error").toString(), @@ -60,8 +75,10 @@ class FirebaseClientAttestationManagerTest { fun check_failure_response_from_get_attestation() = runBlocking { whenever(mockAppChecker.getAppCheckToken()) .thenReturn(Result.success(AppCheckToken("Success"))) - whenever(caller.call(any(), any())) + whenever(mockCaller.call(any(), any())) .thenReturn(AttestationResponse.Failure("Error")) + whenever(mockKeyStoreManager.getPublicKey()) + .thenReturn(Pair("Success", "Success")) val result = clientAttestationManager.getAttestation() assertEquals("Error", @@ -69,9 +86,45 @@ class FirebaseClientAttestationManagerTest { } @Test - fun check_failure_response_from_sign_attestation() = runBlocking { - val result = clientAttestationManager.signAttestation("attestation") + fun check_success_response_from_generate_PoP() { + val mockSignatureByte = "Success".toByteArray() + val mockSignature = ProofOfPossessionGenerator.getUrlSafeNoPaddingBase64(mockSignatureByte) + + whenever(mockKeyStoreManager.sign(any())).thenReturn(mockSignatureByte) + + val result = clientAttestationManager.generatePoP(MOCK_VALUE, MOCK_VALUE) + + assertTrue(result is SignedPoP.Success) + + val splitJwt = result.popJwt.split(".") + assertTrue(splitJwt.size == 3) + assertEquals(mockSignature, splitJwt.last()) + } + + @OptIn(ExperimentalEncodingApi::class) + @Test(expected = Exception::class) + fun check_failure_response_from_generate_PoP_verify_signature_failure() { + whenever(mockKeyStoreManager.verify(any(), any())) + .thenThrow(ECKeyManager.SigningError.InvalidSignature) + val result = clientAttestationManager.generatePoP("test", "test") + + assertTrue(result is SignedPoP.Failure) + assertTrue(result.error!! is ECKeyManager.SigningError) + assertEquals("Signature couldn't be verified.", result.reason) + } + + @Test(expected = Exception::class) + fun check_failure_response_from_generate_PoP_signing_failure() { + whenever(mockKeyStoreManager.sign(any())) + .thenThrow(SignatureException()) + val result = clientAttestationManager.generatePoP("test", "test") + + assertTrue(result is SignedPoP.Failure) + assertTrue(result.error!! is SignatureException) + assertEquals("Signing Error", result.reason) + } - assertEquals("Not yet implemented", (result as SignedResponse.Failure).reason) + companion object { + private const val MOCK_VALUE = "test" } } diff --git a/app/src/androidTest/java/uk/gov/android/authentication/integrity/KeystoreManagerTest.kt b/app/src/androidTest/java/uk/gov/android/authentication/integrity/KeystoreManagerTest.kt deleted file mode 100644 index 8567546..0000000 --- a/app/src/androidTest/java/uk/gov/android/authentication/integrity/KeystoreManagerTest.kt +++ /dev/null @@ -1,65 +0,0 @@ -package uk.gov.android.authentication.integrity - -import android.security.keystore.KeyProperties -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 - - @BeforeTest - fun setup() { - keyStore = KeyStore.getInstance("AndroidKeyStore").apply { - load(null) - } - keystoreManager = KeystoreManager() - } - - @Test - fun check_keys_created_on_initialization() { - assertTrue(keyStore.containsAlias("app_check_keys")) - } - - @Test - fun hasAppCheckKeys() { - val actual = keystoreManager.hasAppCheckKeys - assertTrue(actual) - } - - @Test - fun appCheckPrivateKey() { - 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 - } - } -} diff --git a/app/src/androidTest/java/uk/gov/android/authentication/integrity/keymanager/ECKeyManagerTest.kt b/app/src/androidTest/java/uk/gov/android/authentication/integrity/keymanager/ECKeyManagerTest.kt new file mode 100644 index 0000000..d271e2e --- /dev/null +++ b/app/src/androidTest/java/uk/gov/android/authentication/integrity/keymanager/ECKeyManagerTest.kt @@ -0,0 +1,63 @@ +package uk.gov.android.authentication.integrity.keymanager + +import org.junit.Assert.assertThrows +import uk.gov.android.authentication.integrity.pop.ProofOfPossessionGenerator +import org.junit.Test as JUnitTest +import java.security.KeyStore +import kotlin.io.encoding.ExperimentalEncodingApi +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertTrue + +@OptIn(ExperimentalEncodingApi::class) +class ECKeyManagerTest { + private lateinit var keyStore: KeyStore + private lateinit var ecKeyManager: ECKeyManager + + @BeforeTest + fun setup() { + keyStore = KeyStore.getInstance("AndroidKeyStore").apply { + load(null) + } + ecKeyManager = ECKeyManager() + } + + @Test + fun check_keys_created_on_initialization() { + assertTrue(keyStore.containsAlias("app_check_keys")) + } + + @Test + fun check_getPublicKey() { + val actual = ecKeyManager.getPublicKey() + + assertTrue(checkInputIsBase64(actual.first)) + assertTrue(checkInputIsBase64(actual.second)) + } + + @Test + fun check_sign_success() { + val signature = ecKeyManager.sign("Success".toByteArray()) + + assertTrue(ecKeyManager.verify("Success".toByteArray(), signature)) + } + + + + @JUnitTest + fun check_verify_failure() { + assertThrows(ECKeyManager.SigningError.InvalidSignature::class.java) { + ecKeyManager.verify("Success".toByteArray(), "Success".toByteArray()) + } + } + + @Suppress("SwallowedException") + private fun checkInputIsBase64(input: String): Boolean { + return try { + ProofOfPossessionGenerator.getUrlSafeNoPaddingBase64(input.toByteArray()) + true + } catch (e: IllegalArgumentException) { + false + } + } +} diff --git a/app/src/main/java/uk/gov/android/authentication/integrity/ClientAttestationManager.kt b/app/src/main/java/uk/gov/android/authentication/integrity/ClientAttestationManager.kt index b3b3fd9..56ac64d 100644 --- a/app/src/main/java/uk/gov/android/authentication/integrity/ClientAttestationManager.kt +++ b/app/src/main/java/uk/gov/android/authentication/integrity/ClientAttestationManager.kt @@ -1,9 +1,9 @@ package uk.gov.android.authentication.integrity -import uk.gov.android.authentication.integrity.model.AttestationResponse -import uk.gov.android.authentication.integrity.model.SignedResponse +import uk.gov.android.authentication.integrity.appcheck.model.AttestationResponse +import uk.gov.android.authentication.integrity.pop.SignedPoP interface ClientAttestationManager { suspend fun getAttestation(): AttestationResponse - suspend fun signAttestation(attestation: String): SignedResponse + fun generatePoP(iss: String, aud: String): SignedPoP } diff --git a/app/src/main/java/uk/gov/android/authentication/integrity/FirebaseClientAttestationManager.kt b/app/src/main/java/uk/gov/android/authentication/integrity/FirebaseClientAttestationManager.kt index 5e28f79..7e523d2 100644 --- a/app/src/main/java/uk/gov/android/authentication/integrity/FirebaseClientAttestationManager.kt +++ b/app/src/main/java/uk/gov/android/authentication/integrity/FirebaseClientAttestationManager.kt @@ -1,12 +1,17 @@ package uk.gov.android.authentication.integrity -import uk.gov.android.authentication.integrity.appcheck.AppChecker -import uk.gov.android.authentication.integrity.model.AppCheckToken +import android.util.Log +import uk.gov.android.authentication.integrity.appcheck.usecase.AppChecker +import uk.gov.android.authentication.integrity.keymanager.ECKeyManager +import uk.gov.android.authentication.integrity.keymanager.KeyStoreManager +import uk.gov.android.authentication.integrity.appcheck.model.AppCheckToken +import uk.gov.android.authentication.integrity.appcheck.model.AttestationResponse +import uk.gov.android.authentication.integrity.pop.ProofOfPossessionGenerator +import uk.gov.android.authentication.integrity.pop.SignedPoP +import uk.gov.android.authentication.integrity.appcheck.usecase.AttestationCaller 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 uk.gov.android.authentication.integrity.appcheck.usecase.JWK +import java.security.SignatureException import kotlin.io.encoding.ExperimentalEncodingApi @OptIn(ExperimentalEncodingApi::class) @@ -15,7 +20,7 @@ class FirebaseClientAttestationManager( ) : ClientAttestationManager { private val appChecker: AppChecker = config.appChecker private val attestationCaller: AttestationCaller = config.attestationCaller - private val keyManager = KeystoreManager() + private val keyStoreManager: KeyStoreManager = config.keyStoreManager override suspend fun getAttestation(): AttestationResponse { // Get Firebase token @@ -23,7 +28,7 @@ class FirebaseClientAttestationManager( AttestationResponse.Failure(err.toString()) } // If successful -> functionality to get signed attestation form Mobile back-end - val pubKeyECCoord = keyManager.getPubKeyBase64ECCoord() + val pubKeyECCoord = keyStoreManager.getPublicKey() val jwk = JWK.makeJWK(x = pubKeyECCoord.first, y = pubKeyECCoord.second) return if (token is AppCheckToken) { attestationCaller.call( @@ -36,8 +41,29 @@ class FirebaseClientAttestationManager( } } - override suspend fun signAttestation(attestation: String): SignedResponse { - // Not yet implemented - return SignedResponse.Failure("Not yet implemented") + override fun generatePoP(iss: String, aud: String): SignedPoP { + // Create Proof of Possession + val pop = ProofOfPossessionGenerator.createBase64PoP(iss, aud) + // Convert into ByteArray + val popByteArray = pop.toByteArray() + return try { + // Get signature to be appended to PoPJwt + val byteSignature = keyStoreManager.sign(popByteArray) + // Encode signature in Base64 configured with UrlSafe and no padding + val signature = ProofOfPossessionGenerator.getUrlSafeNoPaddingBase64(byteSignature) + // Return the signed PopJwt + val signedPop = "$pop.$signature" + Log.d("SignedPoP", signedPop) + SignedPoP.Success(signedPop) + } catch (e: ECKeyManager.SigningError) { + SignedPoP.Failure(e.message ?: VERIFF_ERROR, e) + } catch (e: SignatureException) { + SignedPoP.Failure(e.message ?: SIGN_ERROR, e) + } + } + + companion object { + const val VERIFF_ERROR = "Verification Error" + const val SIGN_ERROR = "Signing Error" } } diff --git a/app/src/main/java/uk/gov/android/authentication/integrity/KeystoreManager.kt b/app/src/main/java/uk/gov/android/authentication/integrity/KeystoreManager.kt deleted file mode 100644 index f7e6d2a..0000000 --- a/app/src/main/java/uk/gov/android/authentication/integrity/KeystoreManager.kt +++ /dev/null @@ -1,66 +0,0 @@ -package uk.gov.android.authentication.integrity - -import android.security.keystore.KeyGenParameterSpec -import android.security.keystore.KeyProperties -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 { - load(null) - } - - private val parameterSpec: KeyGenParameterSpec = KeyGenParameterSpec.Builder( - ALIAS, - KeyProperties.PURPOSE_SIGN or KeyProperties.PURPOSE_VERIFY - ).run { - setDigests(KeyProperties.DIGEST_SHA256, KeyProperties.DIGEST_SHA512) - build() - } - - val hasAppCheckKeys: Boolean - get() = ks.containsAlias(ALIAS) - - 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 { - 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, - KEYSTORE - ).apply { - initialize(parameterSpec) - genKeyPair() - } - } - - companion object { - private const val ALIAS = "app_check_keys" - private const val KEYSTORE = "AndroidKeyStore" - } -} diff --git a/app/src/main/java/uk/gov/android/authentication/integrity/appcheck/AppChecker.kt b/app/src/main/java/uk/gov/android/authentication/integrity/appcheck/AppChecker.kt deleted file mode 100644 index ef3d09b..0000000 --- a/app/src/main/java/uk/gov/android/authentication/integrity/appcheck/AppChecker.kt +++ /dev/null @@ -1,7 +0,0 @@ -package uk.gov.android.authentication.integrity.appcheck - -import uk.gov.android.authentication.integrity.model.AppCheckToken - -fun interface AppChecker { - suspend fun getAppCheckToken(): Result -} diff --git a/app/src/main/java/uk/gov/android/authentication/integrity/model/AppCheckToken.kt b/app/src/main/java/uk/gov/android/authentication/integrity/appcheck/model/AppCheckToken.kt similarity index 63% rename from app/src/main/java/uk/gov/android/authentication/integrity/model/AppCheckToken.kt rename to app/src/main/java/uk/gov/android/authentication/integrity/appcheck/model/AppCheckToken.kt index eaa7e6b..29d9488 100644 --- a/app/src/main/java/uk/gov/android/authentication/integrity/model/AppCheckToken.kt +++ b/app/src/main/java/uk/gov/android/authentication/integrity/appcheck/model/AppCheckToken.kt @@ -1,4 +1,4 @@ -package uk.gov.android.authentication.integrity.model +package uk.gov.android.authentication.integrity.appcheck.model import kotlinx.serialization.Serializable diff --git a/app/src/main/java/uk/gov/android/authentication/integrity/model/AttestationResponse.kt b/app/src/main/java/uk/gov/android/authentication/integrity/appcheck/model/AttestationResponse.kt similarity index 89% rename from app/src/main/java/uk/gov/android/authentication/integrity/model/AttestationResponse.kt rename to app/src/main/java/uk/gov/android/authentication/integrity/appcheck/model/AttestationResponse.kt index e282005..9d64fbf 100644 --- a/app/src/main/java/uk/gov/android/authentication/integrity/model/AttestationResponse.kt +++ b/app/src/main/java/uk/gov/android/authentication/integrity/appcheck/model/AttestationResponse.kt @@ -1,4 +1,4 @@ -package uk.gov.android.authentication.integrity.model +package uk.gov.android.authentication.integrity.appcheck.model import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.Serializable diff --git a/app/src/main/java/uk/gov/android/authentication/integrity/appcheck/usecase/AppChecker.kt b/app/src/main/java/uk/gov/android/authentication/integrity/appcheck/usecase/AppChecker.kt new file mode 100644 index 0000000..3fdcba3 --- /dev/null +++ b/app/src/main/java/uk/gov/android/authentication/integrity/appcheck/usecase/AppChecker.kt @@ -0,0 +1,7 @@ +package uk.gov.android.authentication.integrity.appcheck.usecase + +import uk.gov.android.authentication.integrity.appcheck.model.AppCheckToken + +fun interface AppChecker { + suspend fun getAppCheckToken(): Result +} diff --git a/app/src/main/java/uk/gov/android/authentication/integrity/usecase/AttestationCaller.kt b/app/src/main/java/uk/gov/android/authentication/integrity/appcheck/usecase/AttestationCaller.kt similarity index 71% rename from app/src/main/java/uk/gov/android/authentication/integrity/usecase/AttestationCaller.kt rename to app/src/main/java/uk/gov/android/authentication/integrity/appcheck/usecase/AttestationCaller.kt index 3bb40d9..7ff8352 100644 --- a/app/src/main/java/uk/gov/android/authentication/integrity/usecase/AttestationCaller.kt +++ b/app/src/main/java/uk/gov/android/authentication/integrity/appcheck/usecase/AttestationCaller.kt @@ -1,6 +1,6 @@ -package uk.gov.android.authentication.integrity.usecase +package uk.gov.android.authentication.integrity.appcheck.usecase -import uk.gov.android.authentication.integrity.model.AttestationResponse +import uk.gov.android.authentication.integrity.appcheck.model.AttestationResponse @Suppress("unused") fun interface AttestationCaller { diff --git a/app/src/main/java/uk/gov/android/authentication/integrity/usecase/JWK.kt b/app/src/main/java/uk/gov/android/authentication/integrity/appcheck/usecase/JWK.kt similarity index 91% rename from app/src/main/java/uk/gov/android/authentication/integrity/usecase/JWK.kt rename to app/src/main/java/uk/gov/android/authentication/integrity/appcheck/usecase/JWK.kt index ea7a0a6..89b9b52 100644 --- a/app/src/main/java/uk/gov/android/authentication/integrity/usecase/JWK.kt +++ b/app/src/main/java/uk/gov/android/authentication/integrity/appcheck/usecase/JWK.kt @@ -1,4 +1,4 @@ -package uk.gov.android.authentication.integrity.usecase +package uk.gov.android.authentication.integrity.appcheck.usecase import kotlinx.serialization.Serializable diff --git a/app/src/main/java/uk/gov/android/authentication/integrity/keymanager/ECKeyManager.kt b/app/src/main/java/uk/gov/android/authentication/integrity/keymanager/ECKeyManager.kt new file mode 100644 index 0000000..6098644 --- /dev/null +++ b/app/src/main/java/uk/gov/android/authentication/integrity/keymanager/ECKeyManager.kt @@ -0,0 +1,94 @@ +package uk.gov.android.authentication.integrity.keymanager + +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Log +import uk.gov.android.authentication.integrity.pop.ProofOfPossessionGenerator +import java.security.KeyPairGenerator +import java.security.KeyStore +import java.security.KeyStore.PrivateKeyEntry +import java.security.Signature +import java.security.interfaces.ECPublicKey +import kotlin.io.encoding.ExperimentalEncodingApi + +@ExperimentalEncodingApi +@Suppress("MemberVisibilityCanBePrivate", "unused") +class ECKeyManager : KeyStoreManager { + private val ks: KeyStore = KeyStore.getInstance(KEYSTORE).apply { + load(null) + } + + private val parameterSpec: KeyGenParameterSpec = KeyGenParameterSpec.Builder( + ALIAS, + KeyProperties.PURPOSE_SIGN or KeyProperties.PURPOSE_VERIFY + ).run { + setDigests(KeyProperties.DIGEST_SHA256, KeyProperties.DIGEST_SHA512) + build() + } + + private val hasAppCheckKeys: Boolean + get() = ks.containsAlias(ALIAS) + + private val appCheckPrivateKeyEntry: PrivateKeyEntry + get() = ks.getEntry(ALIAS, null) as PrivateKeyEntry + + private val appCheckPublicKey: ECPublicKey + get() = appCheckPrivateKeyEntry.certificate.publicKey as ECPublicKey + + init { + if (!hasAppCheckKeys) { + Log.d(this::class.simpleName, "Generating key pair") + createNewKeys() + } + } + + override fun getPublicKey(): Pair { + val xByteArr = appCheckPublicKey.w.affineX.toByteArray() + val yByteArr = appCheckPublicKey.w.affineY.toByteArray() + val x = ProofOfPossessionGenerator.getUrlSafeNoPaddingBase64(xByteArr) + val y = ProofOfPossessionGenerator.getUrlSafeNoPaddingBase64(yByteArr) + return Pair(x, y) + } + + override fun sign(input: ByteArray): ByteArray { + val signature = Signature.getInstance(ALG).run { + initSign(appCheckPrivateKeyEntry.privateKey) + update(input) + sign() + } + val verifyResult = verify(input, signature) + Log.d("VerifySignature", "$verifyResult") + return signature + } + + + override fun verify(data: ByteArray, signature: ByteArray): Boolean { + val successfulSignature = Signature.getInstance(ALG).run { + initVerify(appCheckPrivateKeyEntry.certificate) + update(data) + verify(signature) + } + if (!successfulSignature) throw SigningError.InvalidSignature + return true + } + + private fun createNewKeys() { + KeyPairGenerator.getInstance( + KeyProperties.KEY_ALGORITHM_EC, + KEYSTORE + ).apply { + initialize(parameterSpec) + genKeyPair() + } + } + + sealed class SigningError(error: String) : Exception(error) { + data object InvalidSignature : SigningError("Signature couldn't be verified.") + } + + companion object { + private const val ALIAS = "app_check_keys" + private const val KEYSTORE = "AndroidKeyStore" + private const val ALG = "SHA256withECDSA" + } +} diff --git a/app/src/main/java/uk/gov/android/authentication/integrity/keymanager/KeyStoreManager.kt b/app/src/main/java/uk/gov/android/authentication/integrity/keymanager/KeyStoreManager.kt new file mode 100644 index 0000000..087ad74 --- /dev/null +++ b/app/src/main/java/uk/gov/android/authentication/integrity/keymanager/KeyStoreManager.kt @@ -0,0 +1,7 @@ +package uk.gov.android.authentication.integrity.keymanager + +interface KeyStoreManager { + fun getPublicKey(): Pair + fun sign(input: ByteArray): ByteArray + fun verify(data: ByteArray, signature: ByteArray): Boolean +} diff --git a/app/src/main/java/uk/gov/android/authentication/integrity/model/AppIntegrityConfiguration.kt b/app/src/main/java/uk/gov/android/authentication/integrity/model/AppIntegrityConfiguration.kt index 076f77d..826b10e 100644 --- a/app/src/main/java/uk/gov/android/authentication/integrity/model/AppIntegrityConfiguration.kt +++ b/app/src/main/java/uk/gov/android/authentication/integrity/model/AppIntegrityConfiguration.kt @@ -1,9 +1,11 @@ package uk.gov.android.authentication.integrity.model -import uk.gov.android.authentication.integrity.appcheck.AppChecker -import uk.gov.android.authentication.integrity.usecase.AttestationCaller +import uk.gov.android.authentication.integrity.appcheck.usecase.AppChecker +import uk.gov.android.authentication.integrity.keymanager.KeyStoreManager +import uk.gov.android.authentication.integrity.appcheck.usecase.AttestationCaller data class AppIntegrityConfiguration( val attestationCaller: AttestationCaller, - val appChecker: AppChecker + val appChecker: AppChecker, + val keyStoreManager: KeyStoreManager ) diff --git a/app/src/main/java/uk/gov/android/authentication/integrity/model/SignedResponse.kt b/app/src/main/java/uk/gov/android/authentication/integrity/model/SignedResponse.kt deleted file mode 100644 index af918f5..0000000 --- a/app/src/main/java/uk/gov/android/authentication/integrity/model/SignedResponse.kt +++ /dev/null @@ -1,6 +0,0 @@ -package uk.gov.android.authentication.integrity.model - -sealed class SignedResponse { - data class Success(val signedAttestationJwt: String) : SignedResponse() - data class Failure(val reason: String, val error: Throwable? = null) : SignedResponse() -} diff --git a/app/src/main/java/uk/gov/android/authentication/integrity/pop/ProofOfPossessionGenerator.kt b/app/src/main/java/uk/gov/android/authentication/integrity/pop/ProofOfPossessionGenerator.kt new file mode 100644 index 0000000..817f52e --- /dev/null +++ b/app/src/main/java/uk/gov/android/authentication/integrity/pop/ProofOfPossessionGenerator.kt @@ -0,0 +1,73 @@ +package uk.gov.android.authentication.integrity.pop + +import android.util.Log +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +object ProofOfPossessionGenerator { + @OptIn(ExperimentalUuidApi::class) + fun createBase64PoP( + iss: String, + aud: String, + exp: Long = getExpiryTime(), + jti: String = Uuid.random().toString() + ): String { + val pop = ProofOfPossession( + header = Header(alg = ALG), + payload = Payload( + iss = iss, + aud = aud, + exp = exp, + jti = jti + ) + ) + // Convert into ByteArray + val headerByteArray = Json.encodeToString(pop.header).toByteArray() + val payloadByteArray = Json.encodeToString(pop.payload).toByteArray() + // Get Base64 configured with UrlSafe and no padding + val headerBase64 = getUrlSafeNoPaddingBase64(headerByteArray) + val payloadBase64 = getUrlSafeNoPaddingBase64(payloadByteArray) + Log.d("HeaderJson", headerBase64) + Log.d("PayloadJson", payloadBase64) + // Return the PoP + return "$headerBase64.$payloadBase64" + } + + @Serializable + data class ProofOfPossession( + val header: Header, + val payload: Payload + ) + + @Serializable + data class Header( + val alg: String + ) + + @Serializable + data class Payload( + val iss: String, + val aud: String, + val exp: Long, + val jti: String + ) + + private fun getExpiryTime(): Long { + return (System.currentTimeMillis() + (MINUTES * MINUTE_IN_MILLISECONDS)) + } + + @OptIn(ExperimentalEncodingApi::class) + fun getUrlSafeNoPaddingBase64(input: ByteArray): String { + return Base64.UrlSafe.withPadding(Base64.PaddingOption.ABSENT) + .encode(input) + } + + private const val ALG = "ES256" + private const val MINUTES = 3 + private const val MINUTE_IN_MILLISECONDS = 60000 +} diff --git a/app/src/main/java/uk/gov/android/authentication/integrity/pop/SignedPoP.kt b/app/src/main/java/uk/gov/android/authentication/integrity/pop/SignedPoP.kt new file mode 100644 index 0000000..35caa20 --- /dev/null +++ b/app/src/main/java/uk/gov/android/authentication/integrity/pop/SignedPoP.kt @@ -0,0 +1,6 @@ +package uk.gov.android.authentication.integrity.pop + +sealed class SignedPoP { + data class Success(val popJwt: String) : SignedPoP() + data class Failure(val reason: String, val error: Throwable? = null) : SignedPoP() +} diff --git a/app/src/test/java/uk/gov/android/authentication/integrity/usecase/JWKTest.kt b/app/src/test/java/uk/gov/android/authentication/integrity/appcheck/usecase/JWKTest.kt similarity index 81% rename from app/src/test/java/uk/gov/android/authentication/integrity/usecase/JWKTest.kt rename to app/src/test/java/uk/gov/android/authentication/integrity/appcheck/usecase/JWKTest.kt index 6a905d2..1f89914 100644 --- a/app/src/test/java/uk/gov/android/authentication/integrity/usecase/JWKTest.kt +++ b/app/src/test/java/uk/gov/android/authentication/integrity/appcheck/usecase/JWKTest.kt @@ -1,5 +1,6 @@ -package uk.gov.android.authentication.integrity.usecase +package uk.gov.android.authentication.integrity.appcheck.usecase +import uk.gov.android.authentication.integrity.appcheck.usecase.JWK import kotlin.test.Test import kotlin.test.assertEquals diff --git a/app/src/test/java/uk/gov/android/authentication/integrity/pop/ProofOfPossessionGeneratorTest.kt b/app/src/test/java/uk/gov/android/authentication/integrity/pop/ProofOfPossessionGeneratorTest.kt new file mode 100644 index 0000000..c8228f7 --- /dev/null +++ b/app/src/test/java/uk/gov/android/authentication/integrity/pop/ProofOfPossessionGeneratorTest.kt @@ -0,0 +1,19 @@ +package uk.gov.android.authentication.integrity.pop + +import kotlin.test.Test +import kotlin.test.assertEquals + +class ProofOfPossessionGeneratorTest { + @Test + fun `create Base64 Proof of Possession`() { + val expectedResult = ClassLoader.getSystemResource("bodyPoPBase64.txt").readText() + val result = ProofOfPossessionGenerator.createBase64PoP( + "iss", + "aud", + 0, + "jti" + ) + + assertEquals(expectedResult, result) + } +} diff --git a/app/src/test/resources/bodyPoPBase64.txt b/app/src/test/resources/bodyPoPBase64.txt new file mode 100644 index 0000000..7b781ec --- /dev/null +++ b/app/src/test/resources/bodyPoPBase64.txt @@ -0,0 +1 @@ +eyJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJpc3MiLCJhdWQiOiJhdWQiLCJleHAiOjAsImp0aSI6Imp0aSJ9 \ No newline at end of file