From c7f01c5995ebaaca8e0cd787ee66356402e933a9 Mon Sep 17 00:00:00 2001 From: LazyAfternoons Date: Fri, 5 Apr 2024 18:24:24 +0200 Subject: [PATCH] feat: [SIW-860,SIW-946] Add Android integrity implementation (#5) * feat: add ios app attestation * chore: update * chore: update * chore: update * feat: add backend for local development * chore: update * chore: update * chore: update * chore: update * feat: add standard integrity check request * chore: update * chore: update * chore: update * fix: syntax * chore: update * chore: update * feat: add key attestation method * chore: add threading * docs: update docs * chore: divide android app from iOS * chore: reject promise if OS is not Android * build: fix ios build hopefully * Revert "build: fix ios build hopefully" This reverts commit e707c0e6e8ca64aa60fc0c3a36d48134ceef3668. * chore: update with iosapp * chore: add reject based on platform * chore: add missing text on unavailable service * chore: update * chore: update * chore: update * chore: update * chore: update * fix: debug styles * feat: add backend for local development * chore: update * fix: syntax * chore: update * chore: update * chore: update * chore: update * feat: add verification backend * feat: add key attestation verification * chore: update example app README.md * chore: update example app README.md * chore: update README.md * chore: update README.md * fix: key purpose * chore: update README.md * chore: update documentation Co-authored-by: Fabio Bombardi <16268789+shadowsheep1@users.noreply.github.com> * chore: update documentation * chore: update env default value Co-authored-by: Fabio Bombardi <16268789+shadowsheep1@users.noreply.github.com> * chore: remove explicit types * chore: formatting * refactor: use explicit enum when checking for play services * chore: add console warns when env is missing * chore: minor adjustments * refactor: remove lateinit * chore: update example app Co-authored-by: Fabio Bombardi <16268789+shadowsheep1@users.noreply.github.com> * fix: missing import * refactor: remove init * fix: empty certifications array * chore: remove safe call * chore: use destructured req object * chore: remove unwanted changes * chore: remove unwanted changes --------- Co-authored-by: Mario Perrotta Co-authored-by: Fabio Bombardi <16268789+shadowsheep1@users.noreply.github.com> --- android/build.gradle | 2 + .../IoReactNativeIntegrityModule.kt | 321 +++++++++++++++++- backend/.env.local | 4 + backend/.node-version | 1 + backend/README.md | 23 +- backend/package.json | 1 + backend/src/android/androidIntegrity.ts | 172 ++++++++++ backend/src/android/androidRouter.ts | 69 ++++ .../android/googleHardwareAttestationRoot.key | 14 + backend/src/index.ts | 30 +- example/.env.local | 3 +- example/README.md | 25 +- .../MainActivity.kt | 2 +- .../MainApplication.kt | 22 +- example/android/build.gradle | 2 +- example/env.d.ts | 1 + example/src/AndroidApp.tsx | 228 +++++++++++++ example/src/App.tsx | 3 +- src/index.tsx | 80 ++++- yarn.lock | 167 ++++++++- 20 files changed, 1137 insertions(+), 33 deletions(-) create mode 100644 backend/.node-version create mode 100644 backend/src/android/androidIntegrity.ts create mode 100644 backend/src/android/androidRouter.ts create mode 100644 backend/src/android/googleHardwareAttestationRoot.key create mode 100644 example/src/AndroidApp.tsx diff --git a/android/build.gradle b/android/build.gradle index 979f818..8c0f2da 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -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' } diff --git a/android/src/main/java/com/pagopa/ioreactnativeintegrity/IoReactNativeIntegrityModule.kt b/android/src/main/java/com/pagopa/ioreactnativeintegrity/IoReactNativeIntegrityModule.kt index 3bf72ca..c59e6ce 100644 --- a/android/src/main/java/com/pagopa/ioreactnativeintegrity/IoReactNativeIntegrityModule.kt +++ b/android/src/main/java/com/pagopa/ioreactnativeintegrity/IoReactNativeIntegrityModule.kt @@ -1,25 +1,336 @@ 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.play.core.integrity.IntegrityManagerFactory +import com.google.android.play.core.integrity.StandardIntegrityManager +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 var integrityTokenProvider: StandardIntegrityTokenProvider? = null + + /** + * Lazily initialize the keystore manager only when the variable is called, + * otherwise it won't be created. Once created the same object will be used during + * its lifecycle. + */ + private val keyStore: KeyStore? by lazy { + try { + KeyStore.getInstance(KEYSTORE_PROVIDER).also { + it.load(null) + } + } catch (e: Exception) { + null + } + } + + /** + * 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?.apply { + addOnSuccessListener { res -> promise.resolve((res.token())) } + addOnFailureListener { ex -> + ModuleException.REQUEST_TOKEN_FAILED.reject( + promise, Pair(ERROR_USER_INFO_KEY, getExceptionMessageOrEmpty(ex)) + ) + } + } ?: 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 hasStrongBox 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 + } + keyStore?.let { + val chain = it.getCertificateChain(alias) + // The certificate chain consists of an array of certificates, thus we concat them into a string + var attestations = arrayOf() + 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) + } ?: ModuleException.KEYSTORE_NOT_INITIALIZED.reject( + promise + ) + } 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") + ), + KEYSTORE_NOT_INITIALIZED(Exception("KEYSTORE_NOT_INITIALIZED")); + + /** + * 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 + ) { + 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): Pair { + val writableMap = WritableNativeMap() + args.forEach { writableMap.putString(it.first, it.second) } + return Pair(this.ex.message ?: "UNKNOWN", writableMap) + } + } } } diff --git a/backend/.env.local b/backend/.env.local index 2d1cb3e..e4298e0 100644 --- a/backend/.env.local +++ b/backend/.env.local @@ -1,3 +1,7 @@ PORT = BUNDLE_IDENTIFIER = '' TEAM_IDENTIFIER = '' + +# Android +GOOGLE_APPLICATION_CREDENTIALS = '' +ANDROID_BUNDLE_IDENTIFIER = 'com.ioreactnativeintegrityexample' diff --git a/backend/.node-version b/backend/.node-version new file mode 100644 index 0000000..34bfa5c --- /dev/null +++ b/backend/.node-version @@ -0,0 +1 @@ +18.19.1 \ No newline at end of file diff --git a/backend/README.md b/backend/README.md index 64d7dab..18e052c 100644 --- a/backend/README.md +++ b/backend/README.md @@ -5,10 +5,8 @@ ## NodeJS To run the project you need to install the correct version of NodeJS. -We recommend the use of a virtual environment of your choice. For ease of use, this guide adopts [nodenv](https://github.com/nodenv/nodenv) for NodeJS, [rbenv](https://github.com/rbenv/rbenv) for Ruby. - -The node version used in this project is stored in [.node-version](.node-version), -while the version of Ruby is stored in [.ruby-version](.ruby-version). +We recommend the use of a virtual environment of your choice. For ease of use, this guide adopts [nodenv](https://github.com/nodenv/nodenv) for NodeJS. +The node version used in this project is stored in [.node-version](.node-version). ## Build the app @@ -24,10 +22,25 @@ $ npm install -g yarn && nodenv rehash $ yarn install ``` +## Android environment + +In order to use the Android verification endpoints you need to enable the Google Play Integrity API service on the Google Cloud project related to the `GOOGLE_CLOUD_PROJECT_NUMBER` provided in the example app `.env` file. Referer to the [setup page](https://developer.android.com/google/play/integrity/setup) for more information. + +Be sure to fill the `.env` file with the required enviroment variables: + +```javascript +ANDROID_BUNDLE_IDENTIFIER = +GOOGLE_APPLICATION_CREDENTIALS = +``` + +`ANDROID_BUNDLE_IDENTIFIER` is the package name of the app you want to verify, it's already included in the `.env.local` file for the example app of this project. + +`GOOGLE_APPLICATION_CREDENTIALS` consists of a JSON string with the service account credentials. Refer to the [instructions](https://developer.android.com/google/play/integrity/standard#decrypt-and) for more information. + ## Run the app ```bash -# First edit .env to add a BACKEND_ADDRESS (is required to use real local ip address instead localhost) +# First edit .env to add the required environment variables according to the platform you want to test $ cp .env.local .env $ yarn start ``` diff --git a/backend/package.json b/backend/package.json index 0c1cfe2..0c37c8b 100644 --- a/backend/package.json +++ b/backend/package.json @@ -27,6 +27,7 @@ "cbor": "^9.0.2", "dotenv": "^16.4.5", "express": "^4.18.3", + "googleapis": "^134.0.0", "pkijs": "^3.0.15", "uuid": "^9.0.1" } diff --git a/backend/src/android/androidIntegrity.ts b/backend/src/android/androidIntegrity.ts new file mode 100644 index 0000000..229522e --- /dev/null +++ b/backend/src/android/androidIntegrity.ts @@ -0,0 +1,172 @@ +import { google } from 'googleapis'; +import { X509Certificate } from 'crypto'; +import * as asn1js from 'asn1js'; +import * as pkijs from 'pkijs'; +import * as fs from 'fs'; +import * as crypto from 'crypto'; + +/** + * Simplified type definition for the Certificate Revocation List (CRL) object. + */ +type CRL = { + entries: Record< + string, + { status: string; expires: string; reason: string; comment: string } + >; +}; + +// Certificate Revocation status List +// https://developer.android.com/privacy-and-security/security-key-attestation#certificate_status +const CRL_URL = 'https://android.googleapis.com/attestation/status'; +// Key attestation extension data schema OID +// https://developer.android.com/privacy-and-security/security-key-attestation#key_attestation_ext_schema +const KEY_OID = '1.3.6.1.4.1.11129.2.1.17'; + +export const playintegrity = google.playintegrity('v1'); + +/** + * Calls the Google Play Integrity API to verify and decrypt an integrity token. + * @param credentialClientEmail - The service account email. + * @param credentialPrivateKey - The private key of the service account. + * @param packageName - The package name of the app which generated the integrity token. + * @param integrityToken - The integrity token to verify. + * @returns an object containing the result of the integrity token verification. This token shouldn't be returned to the client. + * A simple yes/no answer should be returned to the client instead, based on the result of the verification. + */ +export const verifyIntegrityToken = async ( + credentialClientEmail: string, + credentialPrivateKey: string, + packageName: string, + integrityToken: string +) => { + let jwtClient = new google.auth.JWT( + credentialClientEmail, + undefined, + credentialPrivateKey, + ['https://www.googleapis.com/auth/playintegrity'] + ); + google.options({ auth: jwtClient }); + return await playintegrity.v1.decodeIntegrityToken({ + packageName, + requestBody: { + integrityToken, + }, + }); +}; + +/** + * Verifies a key attestation which is a signed statement from a secure hardware module that attests to the security properties of the module. + * On Android it is represented as a chain of X.509 certificates. + * The native implementation contacates the chain of certificates into a single base64 encoded string divided by commas. + * {@link https://developer.android.com/training/articles/security-key-attestation} + * 1st and 2nd steps are not implemented here as they are done on the client side. + * Functions used to verify the attestation have their relative steps commented in the TSdoc. + * Even though some steps might be joined in a single function, they are separated in the comments for clarity at the cost of some repetition. + * The 5th step is not implemented as it is only implemented in newer chains. + * The 7th step which compares the attestation extension data with a fixed set of expected values is not implemented in this example as it heavily depends on the specific use case. + * @param x509Array - The chain of X.509 certificates in base64 format divided by commas. + * @throws {Error} - If the attestation is invalid. + */ +export const verifyAttestation = async (x509Array: string) => { + // We split the chain of certificates and convert them to PEM format to be parsed by the X509Certificate class + const decodedString = Buffer.from(x509Array, 'base64').toString('utf-8'); + const certificates = decodedString.split(','); + const x509Chain = certificates.map((cert) => { + return new X509Certificate(base64ToPem(cert)); + }); + validateIssuance(x509Chain); + await validateRevokation(x509Chain); + validateKeyAttestationExtension(x509Chain); +}; + +/** + * 3. + * Obtain a reference to the X.509 certificate chain parsing and validation library that is most appropriate for your toolset. + * Verify that the root public certificate is trustworthy and that each certificate signs the next certificate in the chain. + * @param x509Chain - The chain of {@link X509Certificate} certificates. + * @throws {Error} - If the chain is invalid. + */ +const validateIssuance = (x509Chain: X509Certificate[]) => { + if (x509Chain.length === 0) throw new Error('No certificates provided'); + + // Check dates + const now = new Date(); + const datesValid = x509Chain.every( + (c) => new Date(c.validFrom) < now && now < new Date(c.validTo) + ); + if (!datesValid) throw new Error('Certificates expired'); + + // Check that each certificate, except for the last, is issued by the subsequent one. + if (x509Chain.length >= 2) { + for (let i = 0; i < x509Chain.length - 1; i++) { + const subject = x509Chain[i]; + const issuer = x509Chain[i + 1]; + + if ( + !subject || + !issuer || + subject.checkIssued(issuer) === false || + subject.verify(issuer.publicKey) === false + ) { + throw new Error('Certificate chain is invalid'); + } + } + } + + // Ensure that the last certificate in the chain is the expected Google Hardware Attestation Root CA. + const pkFile = fs.readFileSync( + require('path').resolve(__dirname, 'googleHardwareAttestationRoot.key') + ); + const pk = crypto.createPublicKey(pkFile); + const rootCert = x509Chain[x509Chain.length - 1]; // Last certificate in the chain is the root certificate + if (!rootCert || !rootCert.verify(pk)) { + throw new Error( + 'Root certificate is not signed by Google Hardware Attestation Root CA' + ); + } +}; + +/** + * 4. + * Check each certificate's revocation status to ensure that none of the certificates have been revoked. + * @param x509Chain - The chain of {@link X509Certificate} certificates. + * @throws {Error} - If any certificate in the chain is revoked. + */ +const validateRevokation = async (x509Chain: X509Certificate[]) => { + if (x509Chain.length === 0) throw new Error('No certificates provided'); + const res = await fetch(CRL_URL, { method: 'GET' }); + if (!res.ok) throw new Error('Failed to fetch CRL'); + const crl = (await res.json()) as CRL; // Add type assertion for crl + const isExpired = x509Chain.some((cert) => { + return cert.serialNumber in crl.entries; + }); + if (isExpired) throw new Error('Certificate is revoked'); +}; + +/** + * 6. + * Obtain a reference to the ASN.1 parser library that is most appropriate for your toolset. + * Find the nearest certificate to the root that contains the key attestation certificate extension. + * If the provisioning information certificate extension was present, the key attestation certificate extension must be in the immediately subsequent certificate. + * Use the parser to extract the key attestation certificate extension data from that certificate. + * @param x509Chain - The chain of {@link X509Certificate} certificates. + * @throws {Error} - If no key attestation extension is found. + */ +const validateKeyAttestationExtension = (x509Chain: X509Certificate[]) => { + if (x509Chain.length === 0) throw new Error('No certificates provided'); + const found = x509Chain.reverse().some((certificate) => { + const asn1 = asn1js.fromBER(certificate.raw); + const parsedCertificate = new pkijs.Certificate({ schema: asn1.result }); + const extension = parsedCertificate.extensions?.find( + (e) => e.extnID === KEY_OID + ); + return extension ?? false; + }); + if (!found) throw new Error('No key attestation extension found'); +}; + +const base64ToPem = (b64cert: string) => { + return ( + '-----BEGIN CERTIFICATE-----\n' + b64cert + '-----END CERTIFICATE-----' + ); +}; diff --git a/backend/src/android/androidRouter.ts b/backend/src/android/androidRouter.ts new file mode 100644 index 0000000..9a89378 --- /dev/null +++ b/backend/src/android/androidRouter.ts @@ -0,0 +1,69 @@ +import express, { Router } from 'express'; +import { verifyAttestation, verifyIntegrityToken } from './androidIntegrity'; +import { ANDROID_BUNDLE_IDENTIFIER, GOOGLE_APPLICATION_CREDENTIALS } from '..'; + +const router: Router = express.Router(); + +/** + * Verifies a Google Play Integrity Token. + * The check is done for a standard request and the token is decrypted and verified on Google Cloud, not locally. + * The GOOGLE_APPLICATION_CREDENTIALS and ANDROID_BUNDLE_IDENTIFIER environment variables must be set. + * @param integrityToken - The integrity token to verify. + * @returns The result of the integrity token verification. + */ +router.post('/verifyIntegrityToken', async (req, res) => { + console.debug( + `Play integrity verdict was requested: ${JSON.stringify(req.body, null, 2)}` + ); + if (!GOOGLE_APPLICATION_CREDENTIALS || !ANDROID_BUNDLE_IDENTIFIER) { + res.status(500).send({ + error: + 'GOOGLE_APPLICATION_CREDENTIALS and ANDROID_BUNDLE_INDENTIFIER must be set in the .env file', + }); + return; + } + try { + const googleCredentials = JSON.parse(GOOGLE_APPLICATION_CREDENTIALS); + const { integrityToken } = req.body; + if (integrityToken === undefined) { + res.status(400).send({ error: 'Invalid integrity token' }); + return; + } + const { data } = await verifyIntegrityToken( + googleCredentials.client_email, + googleCredentials.private_key, + ANDROID_BUNDLE_IDENTIFIER, + integrityToken + ); + res.send(data); + } catch (error) { + console.error(error); + res + .status(500) + .send({ error: 'An error occurred while verifying the integrity token' }); + } +}); + +/** + * Verifies a key attestation which is a signed statement from a secure hardware module that attests to the security properties of the module. + * On Android it is represented as a chain of X.509 certificates. + * See {@link verifyAttestation} for more details. + * @param attestation - The key attestation to verify + * @returns The result of the chain verification. + */ +router.post('/verifyAttestation', async (req, res) => { + console.debug( + `Key attestation verdict was requested: ${JSON.stringify(req.body, null, 2)}` + ); + try { + await verifyAttestation(req.body.attestation); + res.status(200).send({ result: 'Attestation verified' }); + } catch (error) { + console.error(error); + res.status(500).send({ + error: `An error occurred while verifying the key attestation token ${error}`, + }); + } +}); + +export default router; diff --git a/backend/src/android/googleHardwareAttestationRoot.key b/backend/src/android/googleHardwareAttestationRoot.key new file mode 100644 index 0000000..5b99837 --- /dev/null +++ b/backend/src/android/googleHardwareAttestationRoot.key @@ -0,0 +1,14 @@ +-----BEGIN PUBLIC KEY----- +MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAr7bHgiuxpwHsK7Qui8xU +FmOr75gvMsd/dTEDDJdSSxtf6An7xyqpRR90PL2abxM1dEqlXnf2tqw1Ne4Xwl5j +lRfdnJLmN0pTy/4lj4/7tv0Sk3iiKkypnEUtR6WfMgH0QZfKHM1+di+y9TFRtv6y +//0rb+T+W8a9nsNL/ggjnar86461qO0rOs2cXjp3kOG1FEJ5MVmFmBGtnrKpa73X +pXyTqRxB/M0n1n/W9nGqC4FSYa04T6N5RIZGBN2z2MT5IKGbFlbC8UrW0DxW7AYI +mQQcHtGl/m00QLVWutHQoVJYnFPlXTcHYvASLu+RhhsbDmxMgJJ0mcDpvsC4PjvB ++TxywElgS70vE0XmLD+OJtvsBslHZvPBKCOdT0MS+tgSOIfga+z1Z1g7+DVagf7q +uvmag8jfPioyKvxnK/EgsTUVi2ghzq8wm27ud/mIM7AY2qEORR8Go3TVB4HzWQgp +Zrt3i5MIlCaY504LzSRiigHCzAPlHws+W0rB5N+er5/2pJKnfBSDiCiFAVtCLOZ7 +gLiMm0jhO2B6tUXHI/+MRPjy02i59lINMRRev56GKtcd9qO/0kUJWdZTdA2XoS82 +ixPvZtXQpUpuL12ab+9EaDK8Z4RHJYYfCT3Q5vNAXaiWQ+8PTWm2QgBR/bkwSWc+ +NpUFgNPN9PvQi8WEg5UmAGMCAwEAAQ== +-----END PUBLIC KEY----- \ No newline at end of file diff --git a/backend/src/index.ts b/backend/src/index.ts index 5d082ad..ae74741 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -4,6 +4,7 @@ import dotenv from 'dotenv'; import verifyAttestation from './verifyAttestation'; import bodyParser from 'body-parser'; import verifyAssertion from './verifyAssertion'; +import androidRouter from './android/androidRouter'; dotenv.config(); const app = express(); @@ -21,6 +22,15 @@ let attestation: any = null; // The bundle identifier and team identifier are used to verify the attestation and assertion. const BUNDLE_IDENTIFIER = process.env.BUNDLE_IDENTIFIER || ''; const TEAM_IDENTIFIER = process.env.TEAM_IDENTIFIER || ''; +export const GOOGLE_APPLICATION_CREDENTIALS = + process.env.GOOGLE_APPLICATION_CREDENTIALS || ''; +export const ANDROID_BUNDLE_IDENTIFIER = + process.env.ANDROID_BUNDLE_IDENTIFIER || ''; + +/** + * Router for the android specific endpoints. + */ +app.use('/android', androidRouter); /** * This endpoint is used to get the nonce for the attestation process. @@ -110,5 +120,23 @@ app.post(`/assertion/verify`, (req, res) => { }); app.listen(port, () => { - console.log(`[server]: Server is running at http://localhost:${port}`); + console.log(`[server] server is running at http://localhost:${port}`); + // Check if the environment variables are set, if not, a warning is displayed. + if (!GOOGLE_APPLICATION_CREDENTIALS) + console.warn('[server] GOOGLE_APPLICATION_CREDENTIALS is not set in .env'); + else { + try { + JSON.parse(GOOGLE_APPLICATION_CREDENTIALS); + } catch (error) { + console.warn( + '[server] GOOGLE_APPLICATION_CREDENTIALS is not a valid JSON in .env' + ); + } + } + if (!ANDROID_BUNDLE_IDENTIFIER) + console.warn('[server] ANDROID_BUNDLE_IDENTIFIER is not set in .env'); + if (!BUNDLE_IDENTIFIER) + console.warn('[server] BUNDLE_IDENTIFIER is not set in .env'); + if (!TEAM_IDENTIFIER) + console.warn('[server] TEAM_IDENTIFIER is not set in .env'); }); diff --git a/example/.env.local b/example/.env.local index e17feed..ffa9013 100644 --- a/example/.env.local +++ b/example/.env.local @@ -1,2 +1,3 @@ # Example BACKEND_ADDRESS = "http://192.168.0.1:3000" -BACKEND_ADDRESS = "" \ No newline at end of file +BACKEND_ADDRESS = "" +GOOGLE_CLOUD_PROJECT_NUMBER = "" diff --git a/example/README.md b/example/README.md index 0a98c16..1cbd5d5 100644 --- a/example/README.md +++ b/example/README.md @@ -2,12 +2,12 @@ This is a new [**React Native**](https://reactnative.dev) project, bootstrapped # Getting Started -> **Note**: Make sure you have completed the [React Native - Environment Setup](https://reactnative.dev/docs/environment-setup) instructions till "Creating a new application" step, before proceeding. +> **Note**: Make sure you have completed the [React Native - Environment Setup](https://reactnative.dev/docs/environment-setup) instructions till "Creating a new application" step, before proceeding. For Android, make sure you have Java 17 installed and the `JAVA_HOME` environment variable is set. ## NodeJS and Ruby To run the project you need to install the correct version of NodeJS and Ruby. -We recommend the use of a virtual environment of your choice. For ease of use, this guide adopts [nodenv](https://github.com/nodenv/nodenv) for NodeJS, [rbenv](https://github.com/rbenv/rbenv) for Ruby. +We recommend the use of a virtual environment of your choice. For ease of use, this guide uses [nodenv](https://github.com/nodenv/nodenv) for NodeJS, [rbenv](https://github.com/rbenv/rbenv) for Ruby. The node version used in this project is stored in [.node-version](.node-version), while the version of Ruby is stored in [.ruby-version](.ruby-version). @@ -40,11 +40,32 @@ $ yarn install $ cd ios && bundle exec pod install && cd .. ``` +## Environment variables + +In order to run the app, you need to create a `.env` file by copying the `.env.local` file and updating its values. + +```bash +$ cp .env.local .env +``` + +For the Google Play Integrity API calls, you need to update the `GOOGLE_CLOUD_PROJECT_NUMBER` value. This requires a Google Cloud project with the Play Integrity API enabled. Follow the official +documentation provided by Google [here](https://developer.android.com/google/play/integrity/setup). + +> [!IMPORTANT] +> When changing the `.env` file, make sure to clear the Metro bundler cache by running `yarn start --reset-cache`. + +## Local server + +If you want to verify the tokens and attestations generate by the example app you can use the provided local server. By default, the `.env.local` file is configured to use it but you can customize the `BACKEND_ADDRESS` value to use a different server. +More information about the local server setup can be found in its [README](../backend/README.md). + ## Run the app ```bash # Android $ yarn android +# If you want to use the local server, you need to reverse the 3000 port +$ adb reverse tcp:3000 tcp:3000 # iOS $ yarn ios diff --git a/example/android/app/src/main/java/com/ioreactnativeintegrityexample/MainActivity.kt b/example/android/app/src/main/java/com/ioreactnativeintegrityexample/MainActivity.kt index edc2bb6..c86fd76 100644 --- a/example/android/app/src/main/java/com/ioreactnativeintegrityexample/MainActivity.kt +++ b/example/android/app/src/main/java/com/ioreactnativeintegrityexample/MainActivity.kt @@ -18,5 +18,5 @@ class MainActivity : ReactActivity() { * which allows you to enable New Architecture with a single boolean flags [fabricEnabled] */ override fun createReactActivityDelegate(): ReactActivityDelegate = - DefaultReactActivityDelegate(this, mainComponentName, fabricEnabled) + DefaultReactActivityDelegate(this, mainComponentName, fabricEnabled) } diff --git a/example/android/app/src/main/java/com/ioreactnativeintegrityexample/MainApplication.kt b/example/android/app/src/main/java/com/ioreactnativeintegrityexample/MainApplication.kt index 37b9843..a3b2db6 100644 --- a/example/android/app/src/main/java/com/ioreactnativeintegrityexample/MainApplication.kt +++ b/example/android/app/src/main/java/com/ioreactnativeintegrityexample/MainApplication.kt @@ -15,20 +15,20 @@ import com.facebook.soloader.SoLoader class MainApplication : Application(), ReactApplication { override val reactNativeHost: ReactNativeHost = - object : DefaultReactNativeHost(this) { - override fun getPackages(): List = - PackageList(this).packages.apply { - // Packages that cannot be autolinked yet can be added manually here, for example: - // add(MyReactNativePackage()) - } + object : DefaultReactNativeHost(this) { + override fun getPackages(): List = + PackageList(this).packages.apply { + // Packages that cannot be autolinked yet can be added manually here, for example: + // add(MyReactNativePackage()) + } - override fun getJSMainModuleName(): String = "index" + override fun getJSMainModuleName(): String = "index" - override fun getUseDeveloperSupport(): Boolean = BuildConfig.DEBUG + override fun getUseDeveloperSupport(): Boolean = BuildConfig.DEBUG - override val isNewArchEnabled: Boolean = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED - override val isHermesEnabled: Boolean = BuildConfig.IS_HERMES_ENABLED - } + override val isNewArchEnabled: Boolean = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED + override val isHermesEnabled: Boolean = BuildConfig.IS_HERMES_ENABLED + } override val reactHost: ReactHost get() = getDefaultReactHost(this.applicationContext, reactNativeHost) diff --git a/example/android/build.gradle b/example/android/build.gradle index cb9d623..a60b4f9 100644 --- a/example/android/build.gradle +++ b/example/android/build.gradle @@ -1,7 +1,7 @@ buildscript { ext { buildToolsVersion = "34.0.0" - minSdkVersion = 21 + minSdkVersion = 23 compileSdkVersion = 34 targetSdkVersion = 34 ndkVersion = "25.1.8937393" diff --git a/example/env.d.ts b/example/env.d.ts index e29c264..a113db6 100644 --- a/example/env.d.ts +++ b/example/env.d.ts @@ -1,3 +1,4 @@ declare module '@env' { export const BACKEND_ADDRESS: string; + export const GOOGLE_CLOUD_PROJECT_NUMBER: string; } diff --git a/example/src/AndroidApp.tsx b/example/src/AndroidApp.tsx new file mode 100644 index 0000000..986a3a3 --- /dev/null +++ b/example/src/AndroidApp.tsx @@ -0,0 +1,228 @@ +import * as React from 'react'; +import { StyleSheet, SafeAreaView, Text, ScrollView, View } from 'react-native'; +import { + getAttestation, + isPlayServicesAvailable, + prepareIntegrityToken, + requestIntegrityToken, +} from '@pagopa/io-react-native-integrity'; +import ButtonWithLoader from './components/ButtonWithLoader'; +import { BACKEND_ADDRESS, GOOGLE_CLOUD_PROJECT_NUMBER } from '@env'; + +export default function AndroidApp() { + const [isServiceAvailable, setIsServiceAvailable] = + React.useState(false); + const [isPrepareLoading, setIsPrepareLoading] = + React.useState(false); + const [isRequestTokenLoading, setIsRequestTokenLoading] = + React.useState(false); + const [integrityToken, setIntegrityToken] = React.useState< + string | undefined + >(); + const [isVerifyintegrityTokenLoading, setIsVerifyintegrityTokenLoading] = + React.useState(false); + const [isGetAttestationLoading, setIsGetAttestationLoading] = + React.useState(false); + const [attestation, setAttestation] = React.useState(); + const [isVerifyAttestationLoading, setIsVerifyAttestationLoading] = + React.useState(false); + + const [debugLog, setDebugLog] = React.useState('.. >'); + + React.useEffect(() => { + isPlayServicesAvailable() + .then((result) => { + setIsServiceAvailable(result); + }) + .catch((error) => { + setDebugLog(error); + }); + }, []); + + const prepare = async () => { + if (isServiceAvailable) { + try { + setIsPrepareLoading(true); + await prepareIntegrityToken(GOOGLE_CLOUD_PROJECT_NUMBER); + setIsPrepareLoading(false); + } catch (e) { + setIsPrepareLoading(false); + setDebugLog(JSON.stringify(e)); + } + } + }; + + const requestToken = async () => { + if (isServiceAvailable) { + try { + setIsRequestTokenLoading(true); + const rq = await requestIntegrityToken('randomvalue'); + setIsRequestTokenLoading(false); + setIntegrityToken(rq); + setDebugLog(rq); + } catch (e) { + setIsRequestTokenLoading(false); + setDebugLog(`${e}`); + } + } + }; + + const requestAttestation = async () => { + try { + setIsGetAttestationLoading(true); + const att = await getAttestation('randomvalue', 'integrity-key'); + setAttestation(att); + setIsGetAttestationLoading(false); + setDebugLog(att); + } catch (e) { + setIsGetAttestationLoading(false); + setDebugLog(`${e}`); + } + }; + + const verifyToken = async () => { + try { + if (integrityToken === undefined) { + setDebugLog( + 'Integrity token is undefined, please call prepare and get token first.' + ); + return; + } + setIsVerifyintegrityTokenLoading(true); + const result = await fetch( + `${BACKEND_ADDRESS}/android/verifyIntegrityToken`, + { + method: 'POST', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + integrityToken, + }), + } + ); + const response = await result.json(); + setIsVerifyintegrityTokenLoading(false); + setDebugLog(JSON.stringify(response)); + } catch (e) { + setIsVerifyintegrityTokenLoading(false); + setDebugLog(`${e}`); + } + }; + + const verifyAttestation = async () => { + try { + if (attestation === undefined) { + setDebugLog( + 'Attestation is undefined, please call get attestation first.' + ); + return; + } + setIsVerifyAttestationLoading(true); + const result = await fetch( + `${BACKEND_ADDRESS}/android/verifyAttestation`, + { + method: 'POST', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + attestation, + }), + } + ); + const response = await result.json(); + setIsVerifyAttestationLoading(false); + setDebugLog(JSON.stringify(response)); + } catch (e) { + setIsVerifyAttestationLoading(false); + setDebugLog(`${e}`); + } + }; + + return ( + + + Integrity Check Demo App + {isServiceAvailable ? ( + <> + Play Integrity Standard Request + prepare()} + loading={isPrepareLoading} + /> + + requestToken()} + loading={isRequestTokenLoading} + /> + + verifyToken()} + loading={isVerifyintegrityTokenLoading} + /> + + Key Attestation + requestAttestation()} + loading={isGetAttestationLoading} + /> + + verifyAttestation()} + loading={isVerifyAttestationLoading} + /> + + + ) : null} + <> + {debugLog} + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + alignItems: 'center', + }, + scrollViewContentContainer: { + justifyContent: 'center', + alignItems: 'center', + flexGrow: 1, + padding: 10, + }, + h1: { + fontWeight: 'bold', + fontSize: 32, + textAlign: 'center', + marginTop: 16, + marginBottom: 16, + }, + h2: { + fontWeight: 'bold', + fontSize: 16, + textAlign: 'center', + marginTop: 16, + marginBottom: 16, + }, + spacer: { + height: 8, + }, + debug: { + width: '100%', + height: 300, + position: 'absolute', + bottom: 0, + backgroundColor: '#eaeaea', + }, +}); diff --git a/example/src/App.tsx b/example/src/App.tsx index 0a55aba..62c0b1d 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -1,7 +1,8 @@ import * as React from 'react'; import { Platform } from 'react-native'; import IosApp from './IosApp'; +import AndroidApp from './AndroidApp'; export default function App() { - return Platform.OS === 'ios' ? : <>; + return Platform.OS === 'ios' ? : ; } diff --git a/src/index.tsx b/src/index.tsx index 7ce3331..f7a3ba7 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,5 +1,19 @@ import { NativeModules, Platform } from 'react-native'; +/** + * ANDROID ONLY + * Error codes returned by the Android module. + */ +type IntegrityErrorCodesAndroid = + | 'WRONG_GOOGLE_CLOUD_PROJECT_NUMBER_FORMAT' + | 'PREPARE_FAILED' + | 'PREPARE_NOT_CALLED' + | 'REQUEST_TOKEN_FAILED' + | 'REQUEST_ATTESTATION_FAILED' + | 'KEY_IS_NOT_HARDWARE_BACKED' + | 'UNSUPPORTED_DEVICE' + | 'KEYSTORE_NOT_INITIALIZED'; + /** * Error codes returned by the iOS module. */ @@ -12,7 +26,9 @@ type IntegrityErrorCodesIOS = | 'CLIENT_DATA_ENCODING_ERROR' | 'GENERATION_ASSERTION_FAILED'; -export type IntegrityErrorCodes = IntegrityErrorCodesIOS; +export type IntegrityErrorCodes = + | IntegrityErrorCodesIOS + | IntegrityErrorCodesAndroid; /** * Error type returned by a rejected promise. @@ -104,3 +120,65 @@ export function generateHardwareSignatureWithAssertion( hardwareKeyTag ); } + +/** + * Checks whether the current platform is Android or not. + * @returns true if the current platform is Android, false otherwise. + */ +const isAndroid = () => Platform.OS === 'android'; + +/** + * Error message for functions available only on Android. + */ +const NOT_ANDROID_ERROR = 'This function is available only on Android'; + +/** + * ANDROID ONLY + * Checks whether Google Play Services is available on the device or not. + * @return a promise resolved to true if Google Play Services is available, to false otherwise. + */ +export function isPlayServicesAvailable(): Promise { + return isAndroid() + ? IoReactNativeIntegrity.isPlayServicesAvailable() + : Promise.resolve(false); +} + +/** + * ANDROID ONLY + * 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. + * 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. + * @return a resolved promise when the preparation is successful, rejected otherwise when: + * - The preparation fails or; + * - The provided cloudProjectNumber format is incorrect. + */ +export function prepareIntegrityToken( + cloudProjectNumber: string +): Promise { + return isAndroid() + ? IoReactNativeIntegrity.prepareIntegrityToken(cloudProjectNumber) + : Promise.reject(NOT_ANDROID_ERROR); +} + +/** + * ANDROID ONLY + * 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 {@link prepareIntegrityToken} has been called and resolved successfully. + * The React Native + * @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. + * @returns a resolved promise with with the token as payload, rejected otherwise when: + * - The integrity token request fails; + * - The {@link prepareIntegrityToken} function hasn't been called previously. + */ +export function requestIntegrityToken(requestHash?: string): Promise { + return isAndroid() + ? IoReactNativeIntegrity.requestIntegrityToken(requestHash) + : Promise.reject(NOT_ANDROID_ERROR); +} diff --git a/yarn.lock b/yarn.lock index 701d758..da0fd17 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2560,6 +2560,7 @@ __metadata: cbor: ^9.0.2 dotenv: ^16.4.5 express: ^4.18.3 + googleapis: ^134.0.0 nodemon: ^3.1.0 pkijs: ^3.0.15 rimraf: ^5.0.5 @@ -4223,7 +4224,7 @@ __metadata: languageName: node linkType: hard -"base64-js@npm:^1.3.1, base64-js@npm:^1.5.1": +"base64-js@npm:^1.3.0, base64-js@npm:^1.3.1, base64-js@npm:^1.5.1": version: 1.5.1 resolution: "base64-js@npm:1.5.1" checksum: 669632eb3745404c2f822a18fc3a0122d2f9a7a13f7fb8b5823ee19d1d2ff9ee5b52c53367176ea4ad093c332fd5ab4bd0ebae5a8e27917a4105a4cfc86b1005 @@ -4251,6 +4252,13 @@ __metadata: languageName: node linkType: hard +"bignumber.js@npm:^9.0.0": + version: 9.1.2 + resolution: "bignumber.js@npm:9.1.2" + checksum: 582c03af77ec9cb0ebd682a373ee6c66475db94a4325f92299621d544aa4bd45cb45fd60001610e94aef8ae98a0905fa538241d9638d4422d57abbeeac6fadaf + languageName: node + linkType: hard + "binary-extensions@npm:^2.0.0": version: 2.2.0 resolution: "binary-extensions@npm:2.2.0" @@ -4376,6 +4384,13 @@ __metadata: languageName: node linkType: hard +"buffer-equal-constant-time@npm:1.0.1": + version: 1.0.1 + resolution: "buffer-equal-constant-time@npm:1.0.1" + checksum: 80bb945f5d782a56f374b292770901065bad21420e34936ecbe949e57724b4a13874f735850dd1cc61f078773c4fb5493a41391e7bda40d1fa388d6bd80daaab + languageName: node + linkType: hard + "buffer-from@npm:^1.0.0": version: 1.1.2 resolution: "buffer-from@npm:1.1.2" @@ -5761,6 +5776,15 @@ __metadata: languageName: node linkType: hard +"ecdsa-sig-formatter@npm:1.0.11, ecdsa-sig-formatter@npm:^1.0.11": + version: 1.0.11 + resolution: "ecdsa-sig-formatter@npm:1.0.11" + dependencies: + safe-buffer: ^5.0.1 + checksum: 207f9ab1c2669b8e65540bce29506134613dd5f122cccf1e6a560f4d63f2732d427d938f8481df175505aad94583bcb32c688737bb39a6df0625f903d6d93c03 + languageName: node + linkType: hard + "ee-first@npm:1.1.1": version: 1.1.1 resolution: "ee-first@npm:1.1.1" @@ -6525,6 +6549,13 @@ __metadata: languageName: node linkType: hard +"extend@npm:^3.0.2": + version: 3.0.2 + resolution: "extend@npm:3.0.2" + checksum: a50a8309ca65ea5d426382ff09f33586527882cf532931cb08ca786ea3146c0553310bda688710ff61d7668eba9f96b923fe1420cdf56a2c3eaf30fcab87b515 + languageName: node + linkType: hard + "external-editor@npm:^3.0.3": version: 3.1.0 resolution: "external-editor@npm:3.1.0" @@ -6924,6 +6955,28 @@ __metadata: languageName: node linkType: hard +"gaxios@npm:^6.0.0, gaxios@npm:^6.0.3, gaxios@npm:^6.1.1": + version: 6.3.0 + resolution: "gaxios@npm:6.3.0" + dependencies: + extend: ^3.0.2 + https-proxy-agent: ^7.0.1 + is-stream: ^2.0.0 + node-fetch: ^2.6.9 + checksum: 4d4a8db32d833f8012435e2016cb0c919cac288e821bf81f877504e4284ef12b444cd903448e738c4031cd5219adf1e8d68e7df2b3dba774db9fde27f71109d4 + languageName: node + linkType: hard + +"gcp-metadata@npm:^6.1.0": + version: 6.1.0 + resolution: "gcp-metadata@npm:6.1.0" + dependencies: + gaxios: ^6.0.0 + json-bigint: ^1.0.0 + checksum: 55de8ae4a6b7664379a093abf7e758ae06e82f244d41bd58d881a470bf34db94c4067ce9e1b425d9455b7705636d5f8baad844e49bb73879c338753ba7785b2b + languageName: node + linkType: hard + "gensync@npm:^1.0.0-beta.2": version: 1.0.0-beta.2 resolution: "gensync@npm:1.0.0-beta.2" @@ -7219,6 +7272,44 @@ __metadata: languageName: node linkType: hard +"google-auth-library@npm:^9.0.0": + version: 9.7.0 + resolution: "google-auth-library@npm:9.7.0" + dependencies: + base64-js: ^1.3.0 + ecdsa-sig-formatter: ^1.0.11 + gaxios: ^6.1.1 + gcp-metadata: ^6.1.0 + gtoken: ^7.0.0 + jws: ^4.0.0 + checksum: b0f273fa08ac69cf38f037c195c137ef9d1f3d197c31fd57db957bd5c38c4a110e1c029504f09574a59fa6c9c85fc6fb9d7748c2ed75f30ecae0c71daa369c23 + languageName: node + linkType: hard + +"googleapis-common@npm:^7.0.0": + version: 7.0.1 + resolution: "googleapis-common@npm:7.0.1" + dependencies: + extend: ^3.0.2 + gaxios: ^6.0.3 + google-auth-library: ^9.0.0 + qs: ^6.7.0 + url-template: ^2.0.8 + uuid: ^9.0.0 + checksum: b87f3e14aed5fab82d3327d924e82a1f1527715c93c0b3534b2e28de698abc02daea00bebe375966ca8bb491394f5fd521e6b615cb89e1bfbe8870b7caa5f899 + languageName: node + linkType: hard + +"googleapis@npm:^134.0.0": + version: 134.0.0 + resolution: "googleapis@npm:134.0.0" + dependencies: + google-auth-library: ^9.0.0 + googleapis-common: ^7.0.0 + checksum: 0c2772f5641f9e06df149201f0284e1f5e8581690cac146c9255d1970ec0664e8c39fc891072aa20c56f920d3b144fe3307fcf84665df7790c8bdcedaf84f345 + languageName: node + linkType: hard + "gopd@npm:^1.0.1": version: 1.0.1 resolution: "gopd@npm:1.0.1" @@ -7268,6 +7359,16 @@ __metadata: languageName: node linkType: hard +"gtoken@npm:^7.0.0": + version: 7.1.0 + resolution: "gtoken@npm:7.1.0" + dependencies: + gaxios: ^6.0.0 + jws: ^4.0.0 + checksum: 1f338dced78f9d895ea03cd507454eb5a7b77e841ecd1d45e44483b08c1e64d16a9b0342358d37586d87462ffc2d5f5bff5dfe77ed8d4f0aafc3b5b0347d5d16 + languageName: node + linkType: hard + "handlebars@npm:^4.7.7": version: 4.7.8 resolution: "handlebars@npm:4.7.8" @@ -8947,6 +9048,15 @@ __metadata: languageName: node linkType: hard +"json-bigint@npm:^1.0.0": + version: 1.0.0 + resolution: "json-bigint@npm:1.0.0" + dependencies: + bignumber.js: ^9.0.0 + checksum: c67bb93ccb3c291e60eb4b62931403e378906aab113ec1c2a8dd0f9a7f065ad6fd9713d627b732abefae2e244ac9ce1721c7a3142b2979532f12b258634ce6f6 + languageName: node + linkType: hard + "json-buffer@npm:3.0.1": version: 3.0.1 resolution: "json-buffer@npm:3.0.1" @@ -9049,6 +9159,27 @@ __metadata: languageName: node linkType: hard +"jwa@npm:^2.0.0": + version: 2.0.0 + resolution: "jwa@npm:2.0.0" + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: ^5.0.1 + checksum: 8f00b71ad5fe94cb55006d0d19202f8f56889109caada2f7eeb63ca81755769ce87f4f48101967f398462e3b8ae4faebfbd5a0269cb755dead5d63c77ba4d2f1 + languageName: node + linkType: hard + +"jws@npm:^4.0.0": + version: 4.0.0 + resolution: "jws@npm:4.0.0" + dependencies: + jwa: ^2.0.0 + safe-buffer: ^5.0.1 + checksum: d68d07aa6d1b8cb35c363a9bd2b48f15064d342a5d9dc18a250dbbce8dc06bd7e4792516c50baa16b8d14f61167c19e851fd7f66b59ecc68b7f6a013759765f7 + languageName: node + linkType: hard + "keyv@npm:^4.5.3": version: 4.5.4 resolution: "keyv@npm:4.5.4" @@ -10120,7 +10251,7 @@ __metadata: languageName: node linkType: hard -"node-fetch@npm:^2.2.0, node-fetch@npm:^2.6.0, node-fetch@npm:^2.6.7": +"node-fetch@npm:^2.2.0, node-fetch@npm:^2.6.0, node-fetch@npm:^2.6.7, node-fetch@npm:^2.6.9": version: 2.7.0 resolution: "node-fetch@npm:2.7.0" dependencies: @@ -11134,6 +11265,15 @@ __metadata: languageName: node linkType: hard +"qs@npm:^6.7.0": + version: 6.12.0 + resolution: "qs@npm:6.12.0" + dependencies: + side-channel: ^1.0.6 + checksum: ba007fb2488880b9c6c3df356fe6888b9c1f4c5127552edac214486cfe83a332de09a5c40d490d79bb27bef977ba1085a8497512ff52eaac72e26564f77ce908 + languageName: node + linkType: hard + "queue-microtask@npm:^1.2.2": version: 1.2.3 resolution: "queue-microtask@npm:1.2.3" @@ -11918,7 +12058,7 @@ __metadata: languageName: node linkType: hard -"safe-buffer@npm:5.2.1, safe-buffer@npm:~5.2.0": +"safe-buffer@npm:5.2.1, safe-buffer@npm:^5.0.1, safe-buffer@npm:~5.2.0": version: 5.2.1 resolution: "safe-buffer@npm:5.2.1" checksum: b99c4b41fdd67a6aaf280fcd05e9ffb0813654894223afb78a31f14a19ad220bba8aba1cb14eddce1fcfb037155fe6de4e861784eb434f7d11ed58d1e70dd491 @@ -12160,6 +12300,18 @@ __metadata: languageName: node linkType: hard +"side-channel@npm:^1.0.6": + version: 1.0.6 + resolution: "side-channel@npm:1.0.6" + dependencies: + call-bind: ^1.0.7 + es-errors: ^1.3.0 + get-intrinsic: ^1.2.4 + object-inspect: ^1.13.1 + checksum: bfc1afc1827d712271453e91b7cd3878ac0efd767495fd4e594c4c2afaa7963b7b510e249572bfd54b0527e66e4a12b61b80c061389e129755f34c493aad9b97 + languageName: node + linkType: hard + "signal-exit@npm:^3.0.2, signal-exit@npm:^3.0.3, signal-exit@npm:^3.0.7": version: 3.0.7 resolution: "signal-exit@npm:3.0.7" @@ -13368,6 +13520,13 @@ __metadata: languageName: node linkType: hard +"url-template@npm:^2.0.8": + version: 2.0.8 + resolution: "url-template@npm:2.0.8" + checksum: 4183fccd74e3591e4154134d4443dccecba9c455c15c7df774f1f1e3fa340fd9bffb903b5beec347196d15ce49c34edf6dec0634a95d170ad6e78c0467d6e13e + languageName: node + linkType: hard + "util-deprecate@npm:^1.0.1, util-deprecate@npm:~1.0.1": version: 1.0.2 resolution: "util-deprecate@npm:1.0.2" @@ -13382,7 +13541,7 @@ __metadata: languageName: node linkType: hard -"uuid@npm:^9.0.1": +"uuid@npm:^9.0.0, uuid@npm:^9.0.1": version: 9.0.1 resolution: "uuid@npm:9.0.1" bin: