Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: [SIW-860,SIW-946] Add Android integrity implementation #5

Merged
merged 77 commits into from
Apr 5, 2024
Merged
Show file tree
Hide file tree
Changes from 65 commits
Commits
Show all changes
77 commits
Select commit Hold shift + click to select a range
ef2d6db
feat: add ios app attestation
hevelius Mar 7, 2024
18bedbe
chore: update
hevelius Mar 8, 2024
fa2bf5d
chore: update
hevelius Mar 8, 2024
01c7732
chore: update
hevelius Mar 9, 2024
e73647a
feat: add backend for local development
hevelius Mar 9, 2024
783018b
chore: update
hevelius Mar 10, 2024
c74e8e9
chore: update
hevelius Mar 10, 2024
31ebc63
Merge branch 'main' into SIW-910-ios-integrity-check
hevelius Mar 11, 2024
691bb3b
Merge branch 'SIW-910-ios-integrity-check' into backend-validation
hevelius Mar 11, 2024
63b062a
chore: update
hevelius Mar 11, 2024
b4307d6
chore: update
hevelius Mar 11, 2024
07fb4f0
Merge branch 'main' into SIW-910-ios-integrity-check
hevelius Mar 12, 2024
6ba0f1a
Merge branch 'SIW-910-ios-integrity-check' into backend-validation
hevelius Mar 12, 2024
dff1ae2
feat: add standard integrity check request
LazyAfternoons Mar 13, 2024
3dfeebf
chore: update
hevelius Mar 13, 2024
daf9047
chore: update
hevelius Mar 13, 2024
930e8e4
chore: update
hevelius Mar 14, 2024
6204b21
Merge branch 'SIW-910-ios-integrity-check' into backend-validation
hevelius Mar 14, 2024
445559b
fix: syntax
hevelius Mar 14, 2024
5f1988f
chore: update
hevelius Mar 14, 2024
9d02fa7
chore: update
hevelius Mar 14, 2024
c45a51c
feat: add key attestation method
LazyAfternoons Mar 14, 2024
14908a3
chore: add threading
LazyAfternoons Mar 15, 2024
4c0d33d
docs: update docs
LazyAfternoons Mar 15, 2024
c6b0ff5
chore: divide android app from iOS
LazyAfternoons Mar 15, 2024
92f46fe
chore: reject promise if OS is not Android
LazyAfternoons Mar 15, 2024
e707c0e
build: fix ios build hopefully
LazyAfternoons Mar 15, 2024
7176e90
Revert "build: fix ios build hopefully"
LazyAfternoons Mar 15, 2024
bc84659
chore: update with iosapp
hevelius Mar 18, 2024
fdb2578
chore: add reject based on platform
hevelius Mar 18, 2024
8357321
chore: add missing text on unavailable service
hevelius Mar 18, 2024
9183115
chore: update
hevelius Mar 18, 2024
14ccd86
Merge branch 'main' into SIW-860-integrity-android
LazyAfternoons Mar 18, 2024
2b38568
Merge branch 'main' into SIW-910-ios-integrity-check
hevelius Mar 18, 2024
1502468
chore: update
hevelius Mar 18, 2024
6ee24e2
Merge branch 'SIW-910-ios-integrity-check' into SIW-943-backend-compo…
hevelius Mar 18, 2024
772f865
chore: update
hevelius Mar 18, 2024
dfb1b09
chore: update
hevelius Mar 19, 2024
0842cdd
Merge remote-tracking branch 'origin/SIW-910-ios-integrity-check' int…
LazyAfternoons Mar 19, 2024
24d782a
chore: update
hevelius Mar 19, 2024
19487ca
Merge branch 'SIW-910-ios-integrity-check' into SIW-943-backend-compo…
hevelius Mar 19, 2024
c26c64b
fix: debug styles
hevelius Mar 19, 2024
937ee58
Merge branch 'SIW-910-ios-integrity-check' into SIW-860-integrity-and…
LazyAfternoons Mar 20, 2024
7f3274f
feat: add backend for local development
hevelius Mar 9, 2024
9471271
chore: update
hevelius Mar 10, 2024
8820373
fix: syntax
hevelius Mar 14, 2024
6795308
chore: update
hevelius Mar 14, 2024
f85fad7
chore: update
hevelius Mar 14, 2024
557deff
chore: update
hevelius Mar 18, 2024
1bcf66e
chore: update
hevelius Mar 19, 2024
f9bb2ad
feat: add verification backend
LazyAfternoons Mar 20, 2024
be57373
Merge branch 'SIW-943-backend-component' into SIW-860-integrity-android
LazyAfternoons Mar 20, 2024
b8dbdff
feat: add key attestation verification
LazyAfternoons Mar 22, 2024
3e0ab7d
chore: update example app README.md
LazyAfternoons Mar 25, 2024
5482064
chore: update example app README.md
LazyAfternoons Mar 25, 2024
868f895
chore: update README.md
LazyAfternoons Mar 28, 2024
428ad88
chore: update README.md
LazyAfternoons Mar 28, 2024
08902bf
fix: key purpose
LazyAfternoons Mar 28, 2024
8e3e031
chore: update README.md
LazyAfternoons Mar 28, 2024
45dc4f1
chore: update documentation
LazyAfternoons Mar 28, 2024
d37c699
chore: update documentation
LazyAfternoons Mar 28, 2024
77a7cae
chore: update env default value
LazyAfternoons Mar 28, 2024
135cc11
chore: remove explicit types
LazyAfternoons Mar 28, 2024
ac3b86a
chore: formatting
LazyAfternoons Mar 28, 2024
e63fc52
refactor: use explicit enum when checking for play services
LazyAfternoons Mar 28, 2024
29515ed
chore: add console warns when env is missing
LazyAfternoons Mar 28, 2024
ef557e2
chore: minor adjustments
LazyAfternoons Mar 28, 2024
7d08a89
refactor: remove lateinit
LazyAfternoons Mar 28, 2024
0bc6183
chore: update example app
LazyAfternoons Mar 28, 2024
60ce68c
fix: missing import
LazyAfternoons Mar 28, 2024
77967ff
refactor: remove init
LazyAfternoons Mar 29, 2024
d42b802
fix: empty certifications array
LazyAfternoons Mar 29, 2024
d198b6b
chore: remove safe call
LazyAfternoons Mar 29, 2024
a421012
Merge branch 'main' into SIW-860-integrity-android
LazyAfternoons Apr 5, 2024
19f0ca2
chore: use destructured req object
LazyAfternoons Apr 5, 2024
ed77135
chore: remove unwanted changes
LazyAfternoons Apr 5, 2024
f8f1d61
chore: remove unwanted changes
LazyAfternoons Apr 5, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ DerivedData
*.ipa
*.xcuserstate
project.xcworkspace
.xcode.env.local

# Android/IJ
#
Expand Down Expand Up @@ -76,3 +77,6 @@ android/keystores/debug.keystore

# generated by bob
lib/

# Env
.env
2 changes: 2 additions & 0 deletions android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -90,5 +90,7 @@ dependencies {
//noinspection GradleDynamicVersion
implementation "com.facebook.react:react-native:+"
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
implementation 'com.google.android.play:integrity:1.3.0'
implementation 'com.google.android.gms:play-services-base:18.3.0'
}

Original file line number Diff line number Diff line change
@@ -1,25 +1,327 @@
package com.pagopa.ioreactnativeintegrity

import android.content.pm.PackageManager
import android.os.Build
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyInfo
import android.security.keystore.KeyProperties
import android.security.keystore.KeyProperties.SECURITY_LEVEL_STRONGBOX
import android.security.keystore.KeyProperties.SECURITY_LEVEL_TRUSTED_ENVIRONMENT
import android.security.keystore.KeyProperties.SECURITY_LEVEL_UNKNOWN_SECURE
import android.util.Base64
import androidx.annotation.RequiresApi
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.WritableMap
import com.facebook.react.bridge.WritableNativeMap
import com.google.android.gms.common.ConnectionResult
import com.google.android.gms.common.GoogleApiAvailability
import com.google.android.gms.tasks.Task
import com.google.android.play.core.integrity.IntegrityManagerFactory
import com.google.android.play.core.integrity.StandardIntegrityManager
import com.google.android.play.core.integrity.StandardIntegrityManager.StandardIntegrityToken
import com.google.android.play.core.integrity.StandardIntegrityManager.StandardIntegrityTokenProvider
import com.google.android.play.core.integrity.StandardIntegrityManager.StandardIntegrityTokenRequest
import java.security.KeyFactory
import java.security.KeyPair
import java.security.KeyPairGenerator
import java.security.KeyStore
import java.security.PrivateKey
import java.security.spec.ECGenParameterSpec

/**
* React Native bridge for Google Play Integrity API and key attestation.
* @property integrityTokenProvider the token integrity provider which should be initialized by calling [prepareIntegrityToken]
* @property keyStore the key store instance used to generate hardware backed keys and get a key attestation via [getAttestation]
*/
class IoReactNativeIntegrityModule(reactContext: ReactApplicationContext) :
ReactContextBaseJavaModule(reactContext) {

private lateinit var integrityTokenProvider: StandardIntegrityTokenProvider
shadowsheep1 marked this conversation as resolved.
Show resolved Hide resolved

private val keyStore = KeyStore.getInstance(KEYSTORE_PROVIDER)

/**
* Constructor which initializes the keystore engine.
*/
init {
keyStore.load(null)
shadowsheep1 marked this conversation as resolved.
Show resolved Hide resolved
}

/**
* Get name of the package, required by React Native bridge.
*/
override fun getName(): String {
return NAME
}

// Example method
// See https://reactnative.dev/docs/native-modules-android
/**
* Function which returns a resolved promise with a boolean value indicating whether or not
* Google Play Services is available on the device or not.
* isGooglePlayServicesAvailable returns status code indicating whether there was an error.
* Can be one of following in [com.google.android.gms.common.ConnectionResult]:
* SUCCESS: 0,
* SERVICE_MISSING:1,
* SERVICE_UPDATING: 18
* SERVICE_VERSION_UPDATE_REQUIRED: 2
* SERVICE_DISABLED: 3
* SERVICE_INVALID: 9
* We map SUCCESS, SERVICE_UPDATING, SERVICE_VERSION_UPDATE_REQUIRED (0, 18, 2) to true.
* SERVICE_MISSING, SERVICE_DISABLED and SERVICE_INVALID (1,3,9) to false.
* The promise is resolved to true if Google Play Services is available, to false otherwise.
* [Source](https://developers.google.com/android/reference/com/google/android/gms/common/GoogleApiAvailability#isGooglePlayServicesAvailable(android.content.Context))
* @param promise the React Native promise to be resolved or reject.
*/
@ReactMethod
fun isPlayServicesAvailable(promise: Promise) {
val status =
GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(reactApplicationContext)
val isAvailable = status in listOf(
ConnectionResult.SUCCESS,
ConnectionResult.SERVICE_UPDATING,
ConnectionResult.SERVICE_VERSION_UPDATE_REQUIRED
)
promise.resolve(isAvailable)
}

/**
* Preparation step for a [Play Integrity standard API request](https://developer.android.com/google/play/integrity/standard).
* It prepares the integrity token provider before obtaining the integrity verdict.
* It should be called well before the moment an integrity verdict is needed, for example
* when starting the application. It can also be called time to time to refresh it.
* The React Native promise is resolved with an empty payload on success, otherwise
* it gets rejected when:
* - The preparation fails;
* - The provided [cloudProjectNumber] format is incorrect.
* @param cloudProjectNumber a Google Cloud project number which is supposed to be composed only by numbers (Long).
* @param promise the React Native promise to be resolved or rejected.
*/
@ReactMethod
fun prepareIntegrityToken(cloudProjectNumber: String, promise: Promise) {
try {
val cpn = cloudProjectNumber.toLong()
val standardIntegrityManager = IntegrityManagerFactory.createStandard(reactApplicationContext)
standardIntegrityManager.prepareIntegrityToken(
StandardIntegrityManager.PrepareIntegrityTokenRequest.builder().setCloudProjectNumber(cpn)
.build()
).addOnSuccessListener { res -> integrityTokenProvider = res; promise.resolve(null) }
.addOnFailureListener { ex ->
ModuleException.PREPARE_FAILED.reject(
promise, Pair(ERROR_USER_INFO_KEY, getExceptionMessageOrEmpty(ex))
)
}
} catch (_: NumberFormatException) {
ModuleException.WRONG_GOOGLE_CLOUD_PROJECT_NUMBER_FORMAT.reject(promise)
} catch (e: Exception) {
ModuleException.PREPARE_FAILED.reject(
promise, Pair(ERROR_USER_INFO_KEY, getExceptionMessageOrEmpty(e))
)
}
}

/**
* Integrity token request step for a [Play Integrity standard API request](https://developer.android.com/google/play/integrity/standard).
* It requests an integrity token which is then attached to the request to be protected.
* It should be called AFTER [prepareIntegrityToken] has been called and resolved successfully.
* The React Native promise is resolved with with the token as payload or rejected when:
* - The integrity token request fails;
* - The [prepareIntegrityToken] function hasn't been called previously.
* @param requestHash a digest of all relevant request parameters (e.g. SHA256) from the user action or server request that is happening.
* The max size of this field is 500 bytes. Do not put sensitive information as plain text in this field.
* @param promise the React Native promise to be resolved or rejected.
*/
@ReactMethod
fun multiply(a: Double, b: Double, promise: Promise) {
promise.resolve(a * b)
fun requestIntegrityToken(requestHash: String?, promise: Promise) {
try {
val integrityTokenResponse = integrityTokenProvider.request(
StandardIntegrityTokenRequest.builder().setRequestHash(requestHash).build()
)
integrityTokenResponse.addOnSuccessListener { res -> promise.resolve((res.token())) }
.addOnFailureListener { ex ->
ModuleException.REQUEST_TOKEN_FAILED.reject(
promise, Pair(ERROR_USER_INFO_KEY, getExceptionMessageOrEmpty(ex))
)
}
} catch (_: UninitializedPropertyAccessException) {
ModuleException.PREPARE_NOT_CALLED.reject(promise)
} catch (e: Exception) {
ModuleException.REQUEST_TOKEN_FAILED.reject(
promise, Pair(ERROR_USER_INFO_KEY, getExceptionMessageOrEmpty(e))
)
}
}

/**
* Checks whether or not a [PrivateKey] is hardware backed (TEE/StrongBox) or not.
* Courtesy of @shadowsheep1
* @param key the [PrivateKey] to be checked.
* @returns true if the key is hardware backed according to its [security level](https://developer.android.com/reference/android/security/keystore/KeyProperties)
* with a fallback to the [isInsideSecureHardware](https://developer.android.com/reference/android/security/keystore/KeyInfo#isInsideSecureHardware())
* for version codes older than [Build.VERSION_CODES.S].
* False otherwise.
*/
private fun isKeyHardwareBacked(key: PrivateKey): Boolean {
try {
val factory = KeyFactory.getInstance(
key.algorithm, KEYSTORE_PROVIDER
)
val keyInfo = factory.getKeySpec(key, KeyInfo::class.java)
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
//
keyInfo.securityLevel == SECURITY_LEVEL_TRUSTED_ENVIRONMENT || keyInfo.securityLevel == SECURITY_LEVEL_STRONGBOX || keyInfo.securityLevel == SECURITY_LEVEL_UNKNOWN_SECURE
} else {
@Suppress("DEPRECATION") return keyInfo.isInsideSecureHardware
}
} catch (e: Exception) {
return false
}
}

/**
* Generates an attestation key pair using the [keyStore].
* @param keyAlias the key alias to generate.
* @param challenge the public key certificate for this key pair will contain an extension that
* describes the details of the key's configuration and authorizations, including the
* [challenge] value.
* If the key is in secure hardware, and if the secure hardware supports attestation,
* the certificate will be signed by a chain of certificates rooted at a trustworthy CA key.
* Otherwise the chain will be rooted at an untrusted certificate.
* @param useStrongBox indicates whether or not the key pair will be stored using StrongBox.
* @returns the generated key pair.
*/
@RequiresApi(Build.VERSION_CODES.N)
private fun generateAttestationKey(
keyAlias: String, challenge: ByteArray, hasStrongBox: Boolean
): KeyPair {
val builder = KeyGenParameterSpec.Builder(keyAlias, KeyProperties.PURPOSE_SIGN)
.setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1")) // P-256
.setDigests(KeyProperties.DIGEST_SHA256).setKeySize(256).setAttestationChallenge(challenge)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && hasStrongBox) {
builder.setIsStrongBoxBacked(true)
}
val keyPairGenerator = KeyPairGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_EC, KEYSTORE_PROVIDER
)
keyPairGenerator.initialize(builder.build())
return keyPairGenerator.generateKeyPair()
}

/**
* Generates a (Key Attestation)[https://developer.android.com/privacy-and-security/security-key-attestation].
* During key attestation, a key pair is generated along with its certificate chain,
* which can be used to verify the properties of that key pair.
* If the device supports hardware-level key attestation,
* the root certificate of the chain is signed using an attestation root key
* protected by the device's hardware-backed keystore.
* The promise is resolved with the chain or rejected when:
* - The device doesn't support key attestation;
* - The generated key pair is not hardware backed;
* - The [challenge] exceeds the size of 128 bytes;
* - The key attestation generation fails.
* @param challenge the challenge to be included which has a max size of 128 bytes.
* @param keyAlias optional key alias for the generated key pair.
* @param promise the React Native promise to be resolved or rejected.
*/
@ReactMethod
fun getAttestation(challenge: String, keyAlias: String?, promise: Promise) {
Thread {
try {
// Remove this block if the minSdkVersion is set to 24
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
ModuleException.UNSUPPORTED_DEVICE.reject(promise)
return@Thread
}
val alias = keyAlias ?: "attestationKeyAlias"
val hasStrongBox =
Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && reactApplicationContext.packageManager.hasSystemFeature(
PackageManager.FEATURE_STRONGBOX_KEYSTORE
)
val keyPair = generateAttestationKey(alias, challenge.toByteArray(), hasStrongBox)
if (!isKeyHardwareBacked(keyPair.private)) {
// We check if the key is hardware backed just to be sure exclude software fallback
ModuleException.KEY_IS_NOT_HARDWARE_BACKED.reject(promise)
return@Thread
}
val chain = keyStore.getCertificateChain(alias)
// The certificate chain consists of an array of certificates, thus we concat them into a string
var attestations = arrayOf<String>()
chain.forEachIndexed { _, certificate ->
val cert = Base64.encodeToString(certificate.encoded, Base64.DEFAULT)
attestations += cert
}
val concatenatedAttestations = attestations.joinToString(",")
val encodedAttestation =
Base64.encodeToString(concatenatedAttestations.toByteArray(), Base64.DEFAULT)
promise.resolve(encodedAttestation)
} catch (e: Exception) {
ModuleException.REQUEST_ATTESTATION_FAILED.reject(
promise, Pair(ERROR_USER_INFO_KEY, getExceptionMessageOrEmpty(e))
)
}
}.start()
}

/**
* Extracts a message from an [Exception] with an empty string as fallback.
* @param e an exception.
* @return [e] message field or an empty string otherwise.
*/
private fun getExceptionMessageOrEmpty(e: Exception): String {
return e.message ?: ""
}

companion object {
const val NAME = "IoReactNativeIntegrity"
const val KEYSTORE_PROVIDER = "AndroidKeyStore"
const val ERROR_USER_INFO_KEY = "error"

/**
* Custom exceptions related to failure points.
* Each enum constant encapsulates a specific exception with an associated error message.
*
* @property ex the exception instance associated with the enum constant.
*/
private enum class ModuleException(
val ex: Exception
) {
WRONG_GOOGLE_CLOUD_PROJECT_NUMBER_FORMAT(Exception("WRONG_GOOGLE_CLOUD_PROJECT_NUMBER_FORMAT")), PREPARE_FAILED(
Exception("PREPARE_TOKEN_EXCEPTION")
),
PREPARE_NOT_CALLED(Exception("PREPARE_NOT_CALLED")), REQUEST_TOKEN_FAILED(Exception("REQUEST_TOKEN_FAILED")), REQUEST_ATTESTATION_FAILED(
Exception("REQUEST_ATTESTATION_FAILED")
),
KEY_IS_NOT_HARDWARE_BACKED(Exception("KEY_IS_NOT_HARDWARE_BACKED")), UNSUPPORTED_DEVICE(
Exception("UNSUPPORTED_DEVICE")
);

/**
* Rejects the provided promise with the appropriate error message and additional data.
*
* @param promise the promise to be rejected.
* @param args additional key-value pairs of data to be passed along with the error.
*/
fun reject(
promise: Promise, vararg args: Pair<String, String>
) {
exMap(*args).let {
promise.reject(it.first, ex.message, it.second)
}
}

/**
* Maps the additional key-value pairs of data to a pair containing the error message
* and a WritableMap of the additional data.
*
* @param args additional key-value pairs of data.
* @return A pair containing the error message and a WritableMap of the additional data.
*/
private fun exMap(vararg args: Pair<String, String>): Pair<String, WritableMap> {
val writableMap = WritableNativeMap()
args.forEach { writableMap.putString(it.first, it.second) }
return Pair(this.ex.message ?: "UNKNOWN", writableMap)
}
}
}
}
7 changes: 7 additions & 0 deletions backend/.env.local
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
PORT =
BUNDLE_IDENTIFIER = ''
TEAM_IDENTIFIER = ''

# Android
GOOGLE_APPLICATION_CREDENTIALS = ''
ANDROID_BUNDLE_IDENTIFIER = 'com.ioreactnativeintegrityexample'
1 change: 1 addition & 0 deletions backend/.node-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
18.19.1
Loading