From 05a2ca94a6e858ba751b6069151386038b776943 Mon Sep 17 00:00:00 2001 From: COVIDSafe Support <64945427+covidsafe-support@users.noreply.github.com> Date: Fri, 5 Jun 2020 10:24:35 +1000 Subject: [PATCH] COVIDSafe code from version 1.0.21 (#3) --- .gitignore | 92 +++ app/build.gradle | 13 +- .../au/gov/health/covidsafe/Preference.kt | 77 ++- .../covidsafe/extensions/NetworkExtensions.kt | 30 +- .../covidsafe/factory/NetworkFactory.kt | 41 +- .../crypto/AESEncryptionForPreAndroidM.kt | 213 +++++++ .../security/crypto/EncryptedFile.java | 340 ++++++++++ .../crypto/EncryptedSharedPreferences.java | 600 ++++++++++++++++++ .../covidsafe/security/crypto/MasterKeys.java | 140 ++++ .../health/covidsafe/ui/home/HomeFragment.kt | 2 +- .../RegistrationConsentFragment.kt | 13 +- .../undersixteen/UnderSixteenFragment.kt | 17 +- .../presentation/UploadStepFourFragment.kt | 12 +- .../ic_notification_setting.xml | 13 - .../layout/fragment_home_external_links.xml | 12 +- .../layout/fragment_registration_consent.xml | 13 +- .../res/layout/fragment_under_sixteen.xml | 13 +- .../res/layout/fragment_upload_page_4.xml | 12 +- .../layout/view_card_external_link_card.xml | 2 +- app/src/main/res/values/strings.xml | 15 +- 20 files changed, 1558 insertions(+), 112 deletions(-) create mode 100644 .gitignore create mode 100644 app/src/main/java/au/gov/health/covidsafe/security/crypto/AESEncryptionForPreAndroidM.kt create mode 100644 app/src/main/java/au/gov/health/covidsafe/security/crypto/EncryptedFile.java create mode 100644 app/src/main/java/au/gov/health/covidsafe/security/crypto/EncryptedSharedPreferences.java create mode 100644 app/src/main/java/au/gov/health/covidsafe/security/crypto/MasterKeys.java delete mode 100644 app/src/main/res/drawable-anydpi-v24/ic_notification_setting.xml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..18d2e82 --- /dev/null +++ b/.gitignore @@ -0,0 +1,92 @@ +# Built application files +*.apk +*.aar +*.ap_ +*.aab + +# Files for the ART/Dalvik VM +*.dex + +# Java class files +*.class + +# Generated files +bin/ +gen/ +out/ +# Uncomment the following line in case you need and you don't have the release build type files in your app +# release/ + +# Gradle files +.gradle/ +build/ + +# Local configuration file (sdk path, etc) +local.properties + +# Proguard folder generated by Eclipse +proguard/ + +# Log Files +*.log + +# Android Studio Navigation editor temp files +.navigation/ + +# Android Studio captures folder +captures/ + +# IntelliJ +*.iml +.idea/workspace.xml +.idea/tasks.xml +.idea/gradle.xml +.idea/assetWizardSettings.xml +.idea/dictionaries +.idea/libraries +# Android Studio 3 in .gitignore file. +.idea/caches +.idea/modules.xml +# Comment next line if keeping position of elements in Navigation Editor is relevant for you +.idea/navEditor.xml +.idea/misc.xml +.idea/Project.xml + +# Keystore files +# Uncomment the following lines if you do not want to check your keystore files in. +#*.jks +#*.keystore + +# External native build folder generated in Android Studio 2.2 and later +.externalNativeBuild +.cxx/ + +# Google Services (e.g. APIs or Firebase) +# google-services.json + +# Freeline +freeline.py +freeline/ +freeline_project_description.json + +# fastlane +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots +fastlane/test_output +fastlane/readme.md + +# Version control +vcs.xml + +# lint +lint/intermediates/ +lint/generated/ +lint/outputs/ +lint/tmp/ +# lint/reports/ + +.idea +.DS_Store + +AndroidSqlEditorDatabases \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle index 3c12914..3693b7e 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -34,14 +34,14 @@ android { defaultConfig { applicationId "au.gov.health.covidsafe" resValue "string", "build_config_package", "au.gov.health.covidsafe" - minSdkVersion 23 - /* - TargetSdk is currently set to 28 because we are using a greylisted api in SDK 29 + minSdkVersion 21 + /* + TargetSdk is currently set to 28 because we are using a greylisted api in SDK 29, in order to fix a BLE vulnerability Before you increase the targetSdkVersion make sure that all its usage are still working */ targetSdkVersion 28 - versionCode 18 - versionName "1.0.18" + versionCode 21 + versionName "1.0.21" buildConfigField "String", "GITHASH", "\"${getGitHash()}\"" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" @@ -238,7 +238,8 @@ dependencies { implementation 'com.airbnb.android:lottie:3.4.0' implementation 'com.google.guava:guava:28.2-android' - implementation "androidx.security:security-crypto:1.0.0-beta01" + implementation "androidx.collection:collection:1.1.0" + implementation "com.google.crypto.tink:tink-android:1.4.0-rc2" implementation "androidx.lifecycle:lifecycle-service:2.2.0" implementation 'com.github.razir.progressbutton:progressbutton:2.0.1' diff --git a/app/src/main/java/au/gov/health/covidsafe/Preference.kt b/app/src/main/java/au/gov/health/covidsafe/Preference.kt index b5e4587..d23294c 100644 --- a/app/src/main/java/au/gov/health/covidsafe/Preference.kt +++ b/app/src/main/java/au/gov/health/covidsafe/Preference.kt @@ -1,8 +1,10 @@ package au.gov.health.covidsafe import android.content.Context -import androidx.security.crypto.EncryptedSharedPreferences -import androidx.security.crypto.MasterKeys +import android.os.Build +import au.gov.health.covidsafe.security.crypto.EncryptedSharedPreferences +import au.gov.health.covidsafe.security.crypto.MasterKeys +import au.gov.health.covidsafe.security.crypto.AESEncryptionForPreAndroidM object Preference { private const val PREF_ID = "Tracer_pref" @@ -10,6 +12,8 @@ object Preference { private const val PHONE_NUMBER = "PHONE_NUMBER" private const val HANDSHAKE_PIN = "HANDSHAKE_PIN" private const val DEVICE_ID = "DEVICE_ID" + private const val ENCRYPTED_AES_KEY = "ENCRYPTED_AES_KEY" + private const val AES_IV = "AES_IV" private const val JWT_TOKEN = "JWT_TOKEN" private const val IS_DATA_UPLOADED = "IS_DATA_UPLOADED" private const val DATA_UPLOADED_DATE_MS = "DATA_UPLOADED_DATE_MS" @@ -32,29 +36,66 @@ object Preference { ?.getString(DEVICE_ID, "") ?: "" } + fun putEncodedAESInitialisationVector(context: Context, value: String) { + context.getSharedPreferences(PREF_ID, Context.MODE_PRIVATE) + .edit().putString(AES_IV, value)?.apply() + } + + fun getEncodedAESInitialisationVector(context: Context) : String? { + return context.getSharedPreferences(PREF_ID, Context.MODE_PRIVATE) + .getString(AES_IV, null) + } + + fun putEncodedRSAEncryptedAESKey(context: Context, value: String) { + context.getSharedPreferences(PREF_ID, Context.MODE_PRIVATE) + .edit().putString(ENCRYPTED_AES_KEY, value)?.apply() + } + + fun getEncodedRSAEncryptedAESKey(context: Context): String? { + return context.getSharedPreferences(PREF_ID, Context.MODE_PRIVATE) + ?.getString(ENCRYPTED_AES_KEY, null) + } + fun putEncrypterJWTToken(context: Context?, jwtToken: String?) { context?.let { - val masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC) - EncryptedSharedPreferences.create( - PREF_ID, - masterKeyAlias, - context, - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM - ).edit()?.putString(JWT_TOKEN, jwtToken)?.apply() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + val masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC) + EncryptedSharedPreferences.create( + PREF_ID, + masterKeyAlias, + context, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ).edit()?.putString(JWT_TOKEN, jwtToken)?.apply() + } else { + val aesEncryptedJwtToken = jwtToken?.let { + AESEncryptionForPreAndroidM.encrypt(it) + } + + context.getSharedPreferences(PREF_ID, Context.MODE_PRIVATE) + .edit().putString(JWT_TOKEN, aesEncryptedJwtToken)?.apply() + } } } fun getEncrypterJWTToken(context: Context?): String? { return context?.let { - val masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC) - EncryptedSharedPreferences.create( - PREF_ID, - masterKeyAlias, - context, - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM - ).getString(JWT_TOKEN, null) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + + val masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC) + EncryptedSharedPreferences.create( + PREF_ID, + masterKeyAlias, + context, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ).getString(JWT_TOKEN, null) + } else { + context.getSharedPreferences(PREF_ID, Context.MODE_PRIVATE) + ?.getString(JWT_TOKEN, null)?.let { + AESEncryptionForPreAndroidM.decrypt(it) + } + } } } diff --git a/app/src/main/java/au/gov/health/covidsafe/extensions/NetworkExtensions.kt b/app/src/main/java/au/gov/health/covidsafe/extensions/NetworkExtensions.kt index 647f78c..b31e561 100644 --- a/app/src/main/java/au/gov/health/covidsafe/extensions/NetworkExtensions.kt +++ b/app/src/main/java/au/gov/health/covidsafe/extensions/NetworkExtensions.kt @@ -3,11 +3,33 @@ package au.gov.health.covidsafe.extensions import android.content.Context import android.net.ConnectivityManager import android.net.NetworkCapabilities +import android.os.Build fun Context.isInternetAvailable(): Boolean { val connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager? - val capabilities = connectivityManager?.getNetworkCapabilities(connectivityManager.activeNetwork) - return capabilities != null && (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) || - capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) || - capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)) + ?: return false + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + val capabilities = connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork) + ?: return false + + return capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) || + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) || + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) + } else{ + @Suppress("DEPRECATION") + val allNetworkInfo = connectivityManager.allNetworkInfo + + for (networkInfo in allNetworkInfo) { + @Suppress("DEPRECATION") + if(networkInfo.isConnected){ + when(networkInfo.type){ + ConnectivityManager.TYPE_MOBILE -> return true + ConnectivityManager.TYPE_WIFI -> return true + ConnectivityManager.TYPE_ETHERNET -> return true + } + } + } + return false + } } \ No newline at end of file diff --git a/app/src/main/java/au/gov/health/covidsafe/factory/NetworkFactory.kt b/app/src/main/java/au/gov/health/covidsafe/factory/NetworkFactory.kt index 17423b2..684dd22 100644 --- a/app/src/main/java/au/gov/health/covidsafe/factory/NetworkFactory.kt +++ b/app/src/main/java/au/gov/health/covidsafe/factory/NetworkFactory.kt @@ -1,11 +1,14 @@ package au.gov.health.covidsafe.factory +import android.os.Build +import au.gov.health.covidsafe.BuildConfig +import au.gov.health.covidsafe.networking.service.AwsClient +import okhttp3.CertificatePinner import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory -import au.gov.health.covidsafe.BuildConfig -import au.gov.health.covidsafe.networking.service.AwsClient + interface NetworkFactory { companion object { @@ -17,11 +20,37 @@ interface NetworkFactory { } val okHttpClient: OkHttpClient by lazy { - val builder = OkHttpClient.Builder() - if (!builder.interceptors().contains(logging) && BuildConfig.DEBUG) { - builder.addInterceptor(logging) + val okHttpClientBuilder = OkHttpClient.Builder() + + if (!okHttpClientBuilder.interceptors().contains(logging) && BuildConfig.DEBUG) { + okHttpClientBuilder.addInterceptor(logging) } - builder.build() + + // This certificate pinning mechanism is only needed on Android 23 and lower. + // For Android 24 and above, the pinning is set up in AndroidManifest.xml + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.M) { + // '**' in '**.aws.covidsafe.gov.au' is to include all sub domains + val hostPattern = "**.aws.covidsafe.gov.au" + + val amazonRootCa1Sha256 = "sha256/++MBgDH5WGvL9Bcn5Be30cRcL0f5O+NyoXuWtQdX1aI=" + val amazonRootCa2Sha256 = "sha256/f0KW/FtqTjs108NpYj42SrGvOB2PpxIVM8nWxjPqJGE=" + val amazonRootCa3Sha256 = "sha256/NqvDJlas/GRcYbcWE8S/IceH9cq77kg0jVhZeAPXq8k=" + val amazonRootCa4Sha256 = "sha256/9+ze1cZgR9KO1kZrVDxA4HQ6voHRCSVNz4RdTCx4U8U=" + val sfsRootCag2Sha256 = "sha256/KwccWaCgrnaw6tsrrSO61FgLacNgG2MMLq8GE6+oP5I=" + + val certificatePinner = CertificatePinner.Builder() + .add(hostPattern, + amazonRootCa1Sha256, + amazonRootCa2Sha256, + amazonRootCa3Sha256, + amazonRootCa4Sha256, + sfsRootCag2Sha256 + ) + .build() + okHttpClientBuilder.certificatePinner(certificatePinner) + } + + okHttpClientBuilder.build() } } } diff --git a/app/src/main/java/au/gov/health/covidsafe/security/crypto/AESEncryptionForPreAndroidM.kt b/app/src/main/java/au/gov/health/covidsafe/security/crypto/AESEncryptionForPreAndroidM.kt new file mode 100644 index 0000000..159b68d --- /dev/null +++ b/app/src/main/java/au/gov/health/covidsafe/security/crypto/AESEncryptionForPreAndroidM.kt @@ -0,0 +1,213 @@ +package au.gov.health.covidsafe.security.crypto + +import android.security.KeyPairGeneratorSpec +import android.security.keystore.KeyProperties +import android.util.Base64 +import au.gov.health.covidsafe.Preference +import au.gov.health.covidsafe.TracerApp +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.math.BigInteger +import java.security.Key +import java.security.KeyPairGenerator +import java.security.KeyStore +import java.security.SecureRandom +import java.util.* +import javax.crypto.Cipher +import javax.crypto.CipherInputStream +import javax.crypto.CipherOutputStream +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec +import javax.security.auth.x500.X500Principal + +/** + * This object provides AES encryption and decryption, with the AES key stored in an RSA encrypted + * format. The RSA keys are in turn stored in Android KeyStore. In this way, even if hackers get + * the AES encrypted key from Shared Preference, they wouldn't be able to decode and use them, + * because they can't get the RSA keys. + * + * This object should be used for pre Android M (API 23). For Android M and above, use MasterKeys + * instead to generate and store the AES key into Android KeyStore directly. + */ +object AESEncryptionForPreAndroidM { + // keystore: for storing RSA keys + private val ANDROID_KEY_STORE = "AndroidKeyStore" + private val RSA_KEY_ALIAS = "RSA_KEY_ALIAS" + + private val RSA_MODE = "RSA/ECB/PKCS1Padding" + private val AES_MODE = "AES/CBC/PKCS5Padding" + + lateinit var keyStore: KeyStore + + init { + generateAndStoreRSAKeyPairs() + generateEncryptAndStoreAESKey() + } + + /** + * Generate RSA key pairs and store them into the Android KeyStore + */ + private fun generateAndStoreRSAKeyPairs() { + keyStore = KeyStore.getInstance(ANDROID_KEY_STORE) + keyStore.load(null) + + if (!keyStore.containsAlias(RSA_KEY_ALIAS)) { + // Generate a key pair for encryption + val start: Calendar = Calendar.getInstance() + val end: Calendar = Calendar.getInstance() + end.add(Calendar.YEAR, 1) + + val spec = KeyPairGeneratorSpec.Builder(TracerApp.AppContext) + .setAlias(RSA_KEY_ALIAS) + .setSubject(X500Principal("CN=$RSA_KEY_ALIAS")) + .setSerialNumber(BigInteger.TEN) + .setStartDate(start.time) + .setEndDate(end.time) + .setKeySize(2048) + .build() + + val kpg: KeyPairGenerator = KeyPairGenerator.getInstance( + KeyProperties.KEY_ALGORITHM_RSA, + ANDROID_KEY_STORE + ) + + kpg.initialize(spec) + kpg.generateKeyPair() + } + } + + /** + * This function is used to encrypt the AES key + */ + private fun rsaEncrypt(plainBytes: ByteArray): ByteArray { + val privateKeyEntry = keyStore.getEntry(RSA_KEY_ALIAS, null) + as KeyStore.PrivateKeyEntry + + val inputCipher: Cipher = Cipher.getInstance(RSA_MODE) + inputCipher.init(Cipher.ENCRYPT_MODE, privateKeyEntry.certificate.publicKey) + val outputStream = ByteArrayOutputStream() + val cipherOutputStream = CipherOutputStream(outputStream, inputCipher) + cipherOutputStream.write(plainBytes) + cipherOutputStream.close() + return outputStream.toByteArray() + } + + /** + * This function is used to decrypt the AES key + */ + private fun rsaDecrypt(encrypted: ByteArray): ByteArray { + val privateKeyEntry = keyStore.getEntry(RSA_KEY_ALIAS, null) + as KeyStore.PrivateKeyEntry + + val outputCipher = Cipher.getInstance(RSA_MODE) + outputCipher.init(Cipher.DECRYPT_MODE, privateKeyEntry.privateKey) + + val cipherInputStream = CipherInputStream(ByteArrayInputStream(encrypted), outputCipher) + + val values: ArrayList = ArrayList() + var nextByte: Int + + while (cipherInputStream.read().also { nextByte = it } != -1) { + values.add(nextByte.toByte()) + } + + val bytes = ByteArray(values.size) + + for (i in bytes.indices) { + bytes[i] = values[i] + } + + return bytes + } + + /** + * Generate an AES key and encrypt it by RSA + */ + private fun generateEncryptAndStoreAESKey() { + var encodedRSAEncryptedAESKey = Preference.getEncodedRSAEncryptedAESKey(TracerApp.AppContext) + + if (encodedRSAEncryptedAESKey == null) { + // generate an AES key and iv + val secureRandom = SecureRandom() + val key = ByteArray(16) + secureRandom.nextBytes(key) + + val iv = ByteArray(16) + secureRandom.nextBytes(iv) + + // the IV is stored into Shared Preferences + Preference.putEncodedAESInitialisationVector( + TracerApp.AppContext, + Base64.encodeToString( + iv, + Base64.DEFAULT + ) + ) + + // encrypt it with RSA + val encryptedKey = rsaEncrypt(key) + + // encode the RSA encrypted AES key into Base64 + encodedRSAEncryptedAESKey = Base64.encodeToString(encryptedKey, Base64.DEFAULT) + + // store it into shared preference + Preference.putEncodedRSAEncryptedAESKey(TracerApp.AppContext, encodedRSAEncryptedAESKey) + } + } + + /** + * Get the RSA encrypted AES key from shared preferences and decrypt it + */ + private fun getAESKeyFromSharedPreferences(): Key { + Preference.getEncodedRSAEncryptedAESKey(TracerApp.AppContext)?.let { + // decode base64 + val rsaEncryptedAESKey = Base64.decode(it, Base64.DEFAULT) + + // decrypt the key + val aesKey = rsaDecrypt(rsaEncryptedAESKey) + + return SecretKeySpec(aesKey, "AES") + } + + throw IllegalStateException("Encrypted AES Key not available in shared preferences.") + } + + /** + * Encrypt a string with AES + */ + fun encrypt(plainText: String): String { + Preference.getEncodedAESInitialisationVector(TracerApp.AppContext)?.let { + val iv = Base64.decode(it, Base64.DEFAULT) + val cipher = Cipher.getInstance(AES_MODE) + cipher.init( + Cipher.ENCRYPT_MODE, + getAESKeyFromSharedPreferences(), + IvParameterSpec(iv) + ) + val encodedBytes = cipher.doFinal(plainText.toByteArray(Charsets.UTF_8)) + return Base64.encodeToString(encodedBytes, Base64.DEFAULT) + } + + throw IllegalStateException("AES IV not available in shared preferences.") + } + + /** + * Decrypt a string with AES + */ + fun decrypt(aesEncryptedText: String): String { + Preference.getEncodedAESInitialisationVector(TracerApp.AppContext)?.let { + val iv = Base64.decode(it, Base64.DEFAULT) + + val encryptedBytes = Base64.decode(aesEncryptedText, Base64.DEFAULT) + val cipher = Cipher.getInstance(AES_MODE) + cipher.init( + Cipher.DECRYPT_MODE, + getAESKeyFromSharedPreferences(), + IvParameterSpec(iv) + ) + return cipher.doFinal(encryptedBytes).toString(Charsets.UTF_8) + } + + throw IllegalStateException("AES IV not available in shared preferences.") + } +} \ No newline at end of file diff --git a/app/src/main/java/au/gov/health/covidsafe/security/crypto/EncryptedFile.java b/app/src/main/java/au/gov/health/covidsafe/security/crypto/EncryptedFile.java new file mode 100644 index 0000000..d824704 --- /dev/null +++ b/app/src/main/java/au/gov/health/covidsafe/security/crypto/EncryptedFile.java @@ -0,0 +1,340 @@ +/* + * Copyright 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package au.gov.health.covidsafe.security.crypto; + +import static au.gov.health.covidsafe.security.crypto.MasterKeys.KEYSTORE_PATH_URI; + +import static java.nio.charset.StandardCharsets.UTF_8; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import com.google.crypto.tink.KeyTemplate; +import com.google.crypto.tink.KeysetHandle; +import com.google.crypto.tink.StreamingAead; +import com.google.crypto.tink.config.TinkConfig; +import com.google.crypto.tink.integration.android.AndroidKeysetManager; +import com.google.crypto.tink.streamingaead.AesGcmHkdfStreamingKeyManager; + +import java.io.File; +import java.io.FileDescriptor; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.channels.FileChannel; +import java.security.GeneralSecurityException; + +/** + * Class used to create and read encrypted files. + * + *
+ *  String masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC);
+ *
+ *  File file = new File(context.getFilesDir(), "secret_data");
+ *  EncryptedFile encryptedFile = EncryptedFile.Builder(
+ *      file,
+ *      context,
+ *      masterKeyAlias,
+ *      EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB
+ *  ).build();
+ *
+ *  // write to the encrypted file
+ *  FileOutputStream encryptedOutputStream = encryptedFile.openFileOutput();
+ *
+ *  // read the encrypted file
+ *  FileInputStream encryptedInputStream = encryptedFile.openFileInput();
+ * 
+ * + */ +public final class EncryptedFile { + + private static final String KEYSET_PREF_NAME = + "__androidx_security_crypto_encrypted_file_pref__"; + private static final String KEYSET_ALIAS = + "__androidx_security_crypto_encrypted_file_keyset__"; + + final File mFile; + final Context mContext; + final String mMasterKeyAlias; + final StreamingAead mStreamingAead; + + + EncryptedFile( + @NonNull File file, + @NonNull String masterKeyAlias, + @NonNull StreamingAead streamingAead, + @NonNull Context context) { + mFile = file; + mContext = context; + mMasterKeyAlias = masterKeyAlias; + mStreamingAead = streamingAead; + } + + /** + * The encryption scheme to encrypt files. + */ + public enum FileEncryptionScheme { + /** + * The file content is encrypted using + * StreamingAead with AES-GCM, with the + * file name as associated data. + * + * For more information please see the Tink documentation: + * + * AesGcmHkdfStreamingKeyManager.aes256GcmHkdf4KBTemplate() + */ + AES256_GCM_HKDF_4KB(AesGcmHkdfStreamingKeyManager.aes256GcmHkdf4KBTemplate()); + + private final KeyTemplate mStreamingAeadKeyTemplate; + + FileEncryptionScheme(KeyTemplate keyTemplate) { + mStreamingAeadKeyTemplate = keyTemplate; + } + + KeyTemplate getKeyTemplate() { + return mStreamingAeadKeyTemplate; + } + } + + /** + * Builder class to configure EncryptedFile + */ + public static final class Builder { + + public Builder(@NonNull File file, + @NonNull Context context, + @NonNull String masterKeyAlias, + @NonNull FileEncryptionScheme fileEncryptionScheme) { + mFile = file; + mFileEncryptionScheme = fileEncryptionScheme; + mContext = context; + mMasterKeyAlias = masterKeyAlias; + } + + // Required parameters + File mFile; + final FileEncryptionScheme mFileEncryptionScheme; + final Context mContext; + final String mMasterKeyAlias; + + // Optional parameters + String mKeysetPrefName = KEYSET_PREF_NAME; + String mKeysetAlias = KEYSET_ALIAS; + + /** + * @param keysetPrefName The SharedPreferences file to store the keyset. + * @return This Builder + */ + @NonNull + public Builder setKeysetPrefName(@NonNull String keysetPrefName) { + mKeysetPrefName = keysetPrefName; + return this; + } + + /** + * @param keysetAlias The alias in the SharedPreferences file to store the keyset. + * @return This Builder + */ + @NonNull + public Builder setKeysetAlias(@NonNull String keysetAlias) { + mKeysetAlias = keysetAlias; + return this; + } + + /** + * @return An EncryptedFile with the specified parameters. + */ + @NonNull + public EncryptedFile build() throws GeneralSecurityException, IOException { + TinkConfig.register(); + + KeysetHandle streadmingAeadKeysetHandle = new AndroidKeysetManager.Builder() + .withKeyTemplate(mFileEncryptionScheme.getKeyTemplate()) + .withSharedPref(mContext, mKeysetAlias, mKeysetPrefName) + .withMasterKeyUri(KEYSTORE_PATH_URI + mMasterKeyAlias) + .build().getKeysetHandle(); + + StreamingAead streamingAead = + streadmingAeadKeysetHandle.getPrimitive(StreamingAead.class); + + return new EncryptedFile(mFile, mKeysetAlias, streamingAead, mContext); + } + } + + /** + * Opens a FileOutputStream for writing that automatically encrypts the data based on the + * provided settings. + * + * Please ensure that the same master key and keyset are used to decrypt or it + * will cause failures. + * + * @return The FileOutputStream that encrypts all data. + * @throws GeneralSecurityException when a bad master key or keyset has been used + * @throws IOException when the file already exists or is not available for writing + */ + @NonNull + public FileOutputStream openFileOutput() + throws GeneralSecurityException, IOException { + if (mFile.exists()) { + throw new IOException("output file already exists, please use a new file: " + + mFile.getName()); + } + FileOutputStream fileOutputStream = new FileOutputStream(mFile); + OutputStream encryptingStream = mStreamingAead.newEncryptingStream(fileOutputStream, + mFile.getName().getBytes(UTF_8)); + return new EncryptedFileOutputStream(fileOutputStream.getFD(), encryptingStream); + } + + /** + * Opens a FileInputStream that reads encrypted files based on the previous settings. + * + * Please ensure that the same master key and keyset are used to decrypt or it + * will cause failures. + * + * @return The input stream to read previously encrypted data. + * @throws GeneralSecurityException when a bad master key or keyset has been used + * @throws IOException when the file was not found + */ + @NonNull + public FileInputStream openFileInput() + throws GeneralSecurityException, IOException { + if (!mFile.exists()) { + throw new IOException("file doesn't exist: " + mFile.getName()); + } + FileInputStream fileInputStream = new FileInputStream(mFile); + InputStream decryptingStream = mStreamingAead.newDecryptingStream(fileInputStream, + mFile.getName().getBytes(UTF_8)); + return new EncryptedFileInputStream(fileInputStream.getFD(), decryptingStream); + } + + /** + * Encrypted file output stream + * + */ + private static final class EncryptedFileOutputStream extends FileOutputStream { + + private final OutputStream mEncryptedOutputStream; + + EncryptedFileOutputStream(FileDescriptor descriptor, OutputStream encryptedOutputStream) { + super(descriptor); + mEncryptedOutputStream = encryptedOutputStream; + } + + @Override + public void write(@NonNull byte[] b) throws IOException { + mEncryptedOutputStream.write(b); + } + + @Override + public void write(int b) throws IOException { + mEncryptedOutputStream.write(b); + } + + @Override + public void write(@NonNull byte[] b, int off, int len) throws IOException { + mEncryptedOutputStream.write(b, off, len); + } + + @Override + public void close() throws IOException { + mEncryptedOutputStream.close(); + } + + @NonNull + @Override + public FileChannel getChannel() { + throw new UnsupportedOperationException("For encrypted files, please open the " + + "relevant FileInput/FileOutputStream."); + } + + @Override + public void flush() throws IOException { + mEncryptedOutputStream.flush(); + } + + } + + /** + * Encrypted file input stream + */ + private static final class EncryptedFileInputStream extends FileInputStream { + + private final InputStream mEncryptedInputStream; + + EncryptedFileInputStream(FileDescriptor descriptor, + InputStream encryptedInputStream) { + super(descriptor); + mEncryptedInputStream = encryptedInputStream; + } + + @Override + public int read() throws IOException { + return mEncryptedInputStream.read(); + } + + @Override + public int read(@NonNull byte[] b) throws IOException { + return mEncryptedInputStream.read(b); + } + + @Override + public int read(@NonNull byte[] b, int off, int len) throws IOException { + return mEncryptedInputStream.read(b, off, len); + } + + @Override + public long skip(long n) throws IOException { + return mEncryptedInputStream.skip(n); + } + + @Override + public int available() throws IOException { + return mEncryptedInputStream.available(); + } + + @Override + public void close() throws IOException { + mEncryptedInputStream.close(); + } + + @Override + public FileChannel getChannel() { + throw new UnsupportedOperationException("For encrypted files, please open the " + + "relevant FileInput/FileOutputStream."); + } + + @Override + public synchronized void mark(int readlimit) { + mEncryptedInputStream.mark(readlimit); + } + + @Override + public synchronized void reset() throws IOException { + mEncryptedInputStream.reset(); + } + + @Override + public boolean markSupported() { + return mEncryptedInputStream.markSupported(); + } + + } + +} diff --git a/app/src/main/java/au/gov/health/covidsafe/security/crypto/EncryptedSharedPreferences.java b/app/src/main/java/au/gov/health/covidsafe/security/crypto/EncryptedSharedPreferences.java new file mode 100644 index 0000000..9afe84c --- /dev/null +++ b/app/src/main/java/au/gov/health/covidsafe/security/crypto/EncryptedSharedPreferences.java @@ -0,0 +1,600 @@ +/* + * Copyright 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package au.gov.health.covidsafe.security.crypto; + +import static au.gov.health.covidsafe.security.crypto.MasterKeys.KEYSTORE_PATH_URI; + +import static java.nio.charset.StandardCharsets.UTF_8; + +import android.content.Context; +import android.content.SharedPreferences; +import android.util.ArraySet; +import android.util.Pair; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.crypto.tink.Aead; +import com.google.crypto.tink.DeterministicAead; +import com.google.crypto.tink.KeyTemplate; +import com.google.crypto.tink.KeysetHandle; +import com.google.crypto.tink.aead.AesGcmKeyManager; +import com.google.crypto.tink.config.TinkConfig; +import com.google.crypto.tink.daead.AesSivKeyManager; +import com.google.crypto.tink.integration.android.AndroidKeysetManager; +import com.google.crypto.tink.subtle.Base64; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.security.GeneralSecurityException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * An implementation of {@link SharedPreferences} that encrypts keys and values. + * + *
+ *  String masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC);
+ *
+ *  SharedPreferences sharedPreferences = EncryptedSharedPreferences.create(
+ *      "secret_shared_prefs",
+ *      masterKeyAlias,
+ *      context,
+ *      EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
+ *      EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
+ *  );
+ *
+ *  // use the shared preferences and editor as you normally would
+ *  SharedPreferences.Editor editor = sharedPreferences.edit();
+ * 
+ */ +public final class EncryptedSharedPreferences implements SharedPreferences { + + private static final String KEY_KEYSET_ALIAS = + "__androidx_security_crypto_encrypted_prefs_key_keyset__"; + private static final String VALUE_KEYSET_ALIAS = + "__androidx_security_crypto_encrypted_prefs_value_keyset__"; + + private static final String NULL_VALUE = "__NULL__"; + + final SharedPreferences mSharedPreferences; + final List mListeners; + final String mFileName; + final String mMasterKeyAlias; + + final Aead mValueAead; + final DeterministicAead mKeyDeterministicAead; + + EncryptedSharedPreferences(@NonNull String name, + @NonNull String masterKeyAlias, + @NonNull SharedPreferences sharedPreferences, + @NonNull Aead aead, + @NonNull DeterministicAead deterministicAead) { + mFileName = name; + mSharedPreferences = sharedPreferences; + mMasterKeyAlias = masterKeyAlias; + mValueAead = aead; + mKeyDeterministicAead = deterministicAead; + mListeners = new ArrayList<>(); + } + + /** + * Opens an instance of encrypted SharedPreferences + * + * @param fileName The name of the file to open; can not contain path separators. + * @return The SharedPreferences instance that encrypts all data. + * @throws GeneralSecurityException when a bad master key or keyset has been attempted + * @throws IOException when fileName can not be used + */ + @NonNull + public static SharedPreferences create(@NonNull String fileName, + @NonNull String masterKeyAlias, + @NonNull Context context, + @NonNull PrefKeyEncryptionScheme prefKeyEncryptionScheme, + @NonNull PrefValueEncryptionScheme prefValueEncryptionScheme) + throws GeneralSecurityException, IOException { + TinkConfig.register(); + + KeysetHandle daeadKeysetHandle = new AndroidKeysetManager.Builder() + .withKeyTemplate(prefKeyEncryptionScheme.getKeyTemplate()) + .withSharedPref(context, KEY_KEYSET_ALIAS, fileName) + .withMasterKeyUri(KEYSTORE_PATH_URI + masterKeyAlias) + .build().getKeysetHandle(); + KeysetHandle aeadKeysetHandle = new AndroidKeysetManager.Builder() + .withKeyTemplate(prefValueEncryptionScheme.getKeyTemplate()) + .withSharedPref(context, VALUE_KEYSET_ALIAS, fileName) + .withMasterKeyUri(KEYSTORE_PATH_URI + masterKeyAlias) + .build().getKeysetHandle(); + + DeterministicAead daead = daeadKeysetHandle.getPrimitive(DeterministicAead.class); + Aead aead = aeadKeysetHandle.getPrimitive(Aead.class); + + return new EncryptedSharedPreferences(fileName, masterKeyAlias, + context.getSharedPreferences(fileName, Context.MODE_PRIVATE), aead, daead); + } + + /** + * The encryption scheme to encrypt keys. + */ + public enum PrefKeyEncryptionScheme { + /** + * Pref keys are encrypted deterministically with AES256-SIV-CMAC (RFC 5297). + * + * For more information please see the Tink documentation: + * + * AesSivKeyManager.aes256SivTemplate() + */ + AES256_SIV(AesSivKeyManager.aes256SivTemplate()); + + private final KeyTemplate mDeterministicAeadKeyTemplate; + + PrefKeyEncryptionScheme(KeyTemplate keyTemplate) { + mDeterministicAeadKeyTemplate = keyTemplate; + } + + KeyTemplate getKeyTemplate() { + return mDeterministicAeadKeyTemplate; + } + } + + /** + * The encryption scheme to encrypt values. + */ + public enum PrefValueEncryptionScheme { + /** + * Pref values are encrypted with AES256-GCM. The associated data is the encrypted pref key. + * + * For more information please see the Tink documentation: + * + * AesGcmKeyManager.aes256GcmTemplate() + */ + AES256_GCM(AesGcmKeyManager.aes256GcmTemplate()); + + private final KeyTemplate mAeadKeyTemplate; + + PrefValueEncryptionScheme(KeyTemplate keyTemplates) { + mAeadKeyTemplate = keyTemplates; + } + + KeyTemplate getKeyTemplate() { + return mAeadKeyTemplate; + } + } + + private static final class Editor implements SharedPreferences.Editor { + private final EncryptedSharedPreferences mEncryptedSharedPreferences; + private final SharedPreferences.Editor mEditor; + private final List mKeysChanged; + private AtomicBoolean mClearRequested = new AtomicBoolean(false); + + Editor(EncryptedSharedPreferences encryptedSharedPreferences, + SharedPreferences.Editor editor) { + mEncryptedSharedPreferences = encryptedSharedPreferences; + mEditor = editor; + mKeysChanged = new CopyOnWriteArrayList<>(); + } + + @Override + @NonNull + public SharedPreferences.Editor putString(@Nullable String key, @Nullable String value) { + if (value == null) { + value = NULL_VALUE; + } + byte[] stringBytes = value.getBytes(UTF_8); + int stringByteLength = stringBytes.length; + ByteBuffer buffer = ByteBuffer.allocate(Integer.BYTES + Integer.BYTES + + stringByteLength); + buffer.putInt(EncryptedType.STRING.getId()); + buffer.putInt(stringByteLength); + buffer.put(stringBytes); + putEncryptedObject(key, buffer.array()); + return this; + } + + @Override + @NonNull + public SharedPreferences.Editor putStringSet(@Nullable String key, + @Nullable Set values) { + if (values == null) { + values = new ArraySet<>(); + values.add(NULL_VALUE); + } + List byteValues = new ArrayList<>(values.size()); + int totalBytes = values.size() * Integer.BYTES; + for (String strValue : values) { + byte[] byteValue = strValue.getBytes(UTF_8); + byteValues.add(byteValue); + totalBytes += byteValue.length; + } + totalBytes += Integer.BYTES; + ByteBuffer buffer = ByteBuffer.allocate(totalBytes); + buffer.putInt(EncryptedType.STRING_SET.getId()); + for (byte[] bytes : byteValues) { + buffer.putInt(bytes.length); + buffer.put(bytes); + } + putEncryptedObject(key, buffer.array()); + return this; + } + + @Override + @NonNull + public SharedPreferences.Editor putInt(@Nullable String key, int value) { + ByteBuffer buffer = ByteBuffer.allocate(Integer.BYTES + Integer.BYTES); + buffer.putInt(EncryptedType.INT.getId()); + buffer.putInt(value); + putEncryptedObject(key, buffer.array()); + return this; + } + + @Override + @NonNull + public SharedPreferences.Editor putLong(@Nullable String key, long value) { + ByteBuffer buffer = ByteBuffer.allocate(Integer.BYTES + Long.BYTES); + buffer.putInt(EncryptedType.LONG.getId()); + buffer.putLong(value); + putEncryptedObject(key, buffer.array()); + return this; + } + + @Override + @NonNull + public SharedPreferences.Editor putFloat(@Nullable String key, float value) { + ByteBuffer buffer = ByteBuffer.allocate(Integer.BYTES + Float.BYTES); + buffer.putInt(EncryptedType.FLOAT.getId()); + buffer.putFloat(value); + putEncryptedObject(key, buffer.array()); + return this; + } + + @Override + @NonNull + public SharedPreferences.Editor putBoolean(@Nullable String key, boolean value) { + ByteBuffer buffer = ByteBuffer.allocate(Integer.BYTES + Byte.BYTES); + buffer.putInt(EncryptedType.BOOLEAN.getId()); + buffer.put(value ? (byte) 1 : (byte) 0); + putEncryptedObject(key, buffer.array()); + return this; + } + + @Override + @NonNull + public SharedPreferences.Editor remove(@Nullable String key) { + if (mEncryptedSharedPreferences.isReservedKey(key)) { + throw new SecurityException(key + " is a reserved key for the encryption keyset."); + } + mEditor.remove(mEncryptedSharedPreferences.encryptKey(key)); + mKeysChanged.remove(key); + return this; + } + + @Override + @NonNull + public SharedPreferences.Editor clear() { + // Set the flag to clear on commit, this operation happens first on commit. + // Cannot use underlying clear operation, it will remove the keysets and + // break the editor. + mClearRequested.set(true); + return this; + } + + @Override + public boolean commit() { + clearKeysIfNeeded(); + try { + return mEditor.commit(); + } finally { + notifyListeners(); + mKeysChanged.clear(); + } + } + + @Override + public void apply() { + clearKeysIfNeeded(); + mEditor.apply(); + notifyListeners(); + } + + private void clearKeysIfNeeded() { + // Call "clear" first as per the documentation, remove all keys that haven't + // been modified in this editor. + if (mClearRequested.getAndSet(false)) { + for (String key : mEncryptedSharedPreferences.getAll().keySet()) { + if (!mKeysChanged.contains(key) + && !mEncryptedSharedPreferences.isReservedKey(key)) { + mEditor.remove(mEncryptedSharedPreferences.encryptKey(key)); + } + } + } + } + + private void putEncryptedObject(String key, byte[] value) { + if (mEncryptedSharedPreferences.isReservedKey(key)) { + throw new SecurityException(key + " is a reserved key for the encryption keyset."); + } + mKeysChanged.add(key); + if (key == null) { + key = NULL_VALUE; + } + try { + Pair encryptedPair = mEncryptedSharedPreferences + .encryptKeyValuePair(key, value); + mEditor.putString(encryptedPair.first, encryptedPair.second); + } catch (GeneralSecurityException ex) { + throw new SecurityException("Could not encrypt data: " + ex.getMessage(), ex); + } + } + + private void notifyListeners() { + for (OnSharedPreferenceChangeListener listener : + mEncryptedSharedPreferences.mListeners) { + for (String key : mKeysChanged) { + listener.onSharedPreferenceChanged(mEncryptedSharedPreferences, key); + } + } + } + } + + // SharedPreferences methods + + @Override + @NonNull + public Map getAll() { + Map allEntries = new HashMap<>(); + for (Map.Entry entry : mSharedPreferences.getAll().entrySet()) { + if (!isReservedKey(entry.getKey())) { + String decryptedKey = decryptKey(entry.getKey()); + allEntries.put(decryptedKey, + getDecryptedObject(decryptedKey)); + } + } + return allEntries; + } + + @Nullable + @Override + public String getString(@Nullable String key, @Nullable String defValue) { + Object value = getDecryptedObject(key); + return (value != null && value instanceof String ? (String) value : defValue); + } + + @SuppressWarnings("unchecked") + @Nullable + @Override + public Set getStringSet(@Nullable String key, @Nullable Set defValues) { + Set returnValues; + Object value = getDecryptedObject(key); + if (value instanceof Set) { + returnValues = (Set) value; + } else { + returnValues = new ArraySet<>(); + } + return returnValues.size() > 0 ? returnValues : defValues; + } + + @Override + public int getInt(@Nullable String key, int defValue) { + Object value = getDecryptedObject(key); + return (value != null && value instanceof Integer ? (Integer) value : defValue); + } + + @Override + public long getLong(@Nullable String key, long defValue) { + Object value = getDecryptedObject(key); + return (value != null && value instanceof Long ? (Long) value : defValue); + } + + @Override + public float getFloat(@Nullable String key, float defValue) { + Object value = getDecryptedObject(key); + return (value != null && value instanceof Float ? (Float) value : defValue); + } + + @Override + public boolean getBoolean(@Nullable String key, boolean defValue) { + Object value = getDecryptedObject(key); + return (value != null && value instanceof Boolean ? (Boolean) value : defValue); + } + + @Override + public boolean contains(@Nullable String key) { + if (isReservedKey(key)) { + throw new SecurityException(key + " is a reserved key for the encryption keyset."); + } + String encryptedKey = encryptKey(key); + return mSharedPreferences.contains(encryptedKey); + } + + @Override + @NonNull + public SharedPreferences.Editor edit() { + return new Editor(this, mSharedPreferences.edit()); + } + + @Override + public void registerOnSharedPreferenceChangeListener( + @NonNull OnSharedPreferenceChangeListener listener) { + mListeners.add(listener); + } + + @Override + public void unregisterOnSharedPreferenceChangeListener( + @NonNull OnSharedPreferenceChangeListener listener) { + mListeners.remove(listener); + } + + /** + * Internal enum to set the type of encrypted data. + */ + private enum EncryptedType { + STRING(0), + STRING_SET(1), + INT(2), + LONG(3), + FLOAT(4), + BOOLEAN(5); + + private final int mId; + + EncryptedType(int id) { + mId = id; + } + + public int getId() { + return mId; + } + + public static EncryptedType fromId(int id) { + switch (id) { + case 0: + return STRING; + case 1: + return STRING_SET; + case 2: + return INT; + case 3: + return LONG; + case 4: + return FLOAT; + case 5: + return BOOLEAN; + } + return null; + } + } + + private Object getDecryptedObject(String key) { + if (isReservedKey(key)) { + throw new SecurityException(key + " is a reserved key for the encryption keyset."); + } + if (key == null) { + key = NULL_VALUE; + } + Object returnValue = null; + try { + String encryptedKey = encryptKey(key); + String encryptedValue = mSharedPreferences.getString(encryptedKey, null); + if (encryptedValue != null) { + byte[] cipherText = Base64.decode(encryptedValue, Base64.DEFAULT); + byte[] value = mValueAead.decrypt(cipherText, encryptedKey.getBytes(UTF_8)); + ByteBuffer buffer = ByteBuffer.wrap(value); + buffer.position(0); + int typeId = buffer.getInt(); + EncryptedType type = EncryptedType.fromId(typeId); + switch (type) { + case STRING: + int stringLength = buffer.getInt(); + ByteBuffer stringSlice = buffer.slice(); + buffer.limit(stringLength); + String stringValue = UTF_8.decode(stringSlice).toString(); + if (stringValue.equals(NULL_VALUE)) { + returnValue = null; + } else { + returnValue = stringValue; + } + break; + case INT: + returnValue = buffer.getInt(); + break; + case LONG: + returnValue = buffer.getLong(); + break; + case FLOAT: + returnValue = buffer.getFloat(); + break; + case BOOLEAN: + returnValue = buffer.get() != (byte) 0; + break; + case STRING_SET: + ArraySet stringSet = new ArraySet<>(); + while (buffer.hasRemaining()) { + int subStringLength = buffer.getInt(); + ByteBuffer subStringSlice = buffer.slice(); + subStringSlice.limit(subStringLength); + buffer.position(buffer.position() + subStringLength); + stringSet.add(UTF_8.decode(subStringSlice).toString()); + } + if (stringSet.size() == 1 && NULL_VALUE.equals(stringSet.valueAt(0))) { + returnValue = null; + } else { + returnValue = stringSet; + } + break; + } + } + } catch (GeneralSecurityException ex) { + throw new SecurityException("Could not decrypt value. " + ex.getMessage(), ex); + } + return returnValue; + } + + String encryptKey(String key) { + if (key == null) { + key = NULL_VALUE; + } + try { + byte[] encryptedKeyBytes = mKeyDeterministicAead.encryptDeterministically( + key.getBytes(UTF_8), + mFileName.getBytes()); + return Base64.encode(encryptedKeyBytes); + } catch (GeneralSecurityException ex) { + throw new SecurityException("Could not encrypt key. " + ex.getMessage(), ex); + } + } + + String decryptKey(String encryptedKey) { + try { + byte[] clearText = mKeyDeterministicAead.decryptDeterministically( + Base64.decode(encryptedKey, Base64.DEFAULT), + mFileName.getBytes()); + String key = new String(clearText, UTF_8); + if (key.equals(NULL_VALUE)) { + key = null; + } + return key; + } catch (GeneralSecurityException ex) { + throw new SecurityException("Could not decrypt key. " + ex.getMessage(), ex); + } + } + + + /** + * Check usage of the key and value keysets. + * + * @param key the plain text key + */ + boolean isReservedKey(String key) { + if (KEY_KEYSET_ALIAS.equals(key) || VALUE_KEYSET_ALIAS.equals(key)) { + return true; + } + return false; + } + + Pair encryptKeyValuePair(String key, byte[] value) + throws GeneralSecurityException { + String encryptedKey = encryptKey(key); + byte[] cipherText = mValueAead.encrypt(value, encryptedKey.getBytes(UTF_8)); + return new Pair<>(encryptedKey, Base64.encode(cipherText)); + } + +} diff --git a/app/src/main/java/au/gov/health/covidsafe/security/crypto/MasterKeys.java b/app/src/main/java/au/gov/health/covidsafe/security/crypto/MasterKeys.java new file mode 100644 index 0000000..216dbf4 --- /dev/null +++ b/app/src/main/java/au/gov/health/covidsafe/security/crypto/MasterKeys.java @@ -0,0 +1,140 @@ +/* + * Copyright 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package au.gov.health.covidsafe.security.crypto; + +import android.security.keystore.KeyGenParameterSpec; +import android.security.keystore.KeyProperties; + +import androidx.annotation.NonNull; +import androidx.annotation.VisibleForTesting; + +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.security.KeyStore; +import java.util.Arrays; + +import javax.crypto.KeyGenerator; + +/** + * Convenient methods to create and obtain master keys in Android Keystore. + * + *

The master keys are used to encrypt data encryption keys for encrypting files and preferences. + */ +public final class MasterKeys { + private MasterKeys() { + } + + private static final int KEY_SIZE = 256; + + private static final String ANDROID_KEYSTORE = "AndroidKeyStore"; + static final String KEYSTORE_PATH_URI = "android-keystore://"; + static final String MASTER_KEY_ALIAS = "_androidx_security_master_key_"; + + @NonNull + public static final KeyGenParameterSpec AES256_GCM_SPEC = + createAES256GCMKeyGenParameterSpec(MASTER_KEY_ALIAS); + + /** + * Provides a safe and easy to use KenGenParameterSpec with the settings. + * Algorithm: AES + * Block Mode: GCM + * Padding: No Padding + * Key Size: 256 + * + * @param keyAlias The alias for the master key + * @return The spec for the master key with the specified keyAlias + */ + @NonNull + private static KeyGenParameterSpec createAES256GCMKeyGenParameterSpec( + @NonNull String keyAlias) { + KeyGenParameterSpec.Builder builder = new KeyGenParameterSpec.Builder( + keyAlias, + KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(KEY_SIZE); + return builder.build(); + } + + /** + * Creates or gets the master key provided + * + * The encryption scheme is required fields to ensure that the type of + * encryption used is clear to developers. + * + * @param keyGenParameterSpec The key encryption scheme + * @return The key alias for the master key + */ + @NonNull + public static String getOrCreate( + @NonNull KeyGenParameterSpec keyGenParameterSpec) + throws GeneralSecurityException, IOException { + validate(keyGenParameterSpec); + if (!MasterKeys.keyExists(keyGenParameterSpec.getKeystoreAlias())) { + generateKey(keyGenParameterSpec); + } + return keyGenParameterSpec.getKeystoreAlias(); + } + + @VisibleForTesting + static void validate(KeyGenParameterSpec spec) { + if (spec.getKeySize() != KEY_SIZE) { + throw new IllegalArgumentException( + "invalid key size, want " + KEY_SIZE + " bits got " + spec.getKeySize() + + " bits"); + } + if (!Arrays.equals(spec.getBlockModes(), new String[]{KeyProperties.BLOCK_MODE_GCM})) { + throw new IllegalArgumentException( + "invalid block mode, want " + KeyProperties.BLOCK_MODE_GCM + " got " + + Arrays.toString(spec.getBlockModes())); + } + if (spec.getPurposes() != (KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT)) { + throw new IllegalArgumentException( + "invalid purposes mode, want PURPOSE_ENCRYPT | PURPOSE_DECRYPT got " + + spec.getPurposes()); + } + if (!Arrays.equals(spec.getEncryptionPaddings(), new String[] + {KeyProperties.ENCRYPTION_PADDING_NONE})) { + throw new IllegalArgumentException( + "invalid padding mode, want " + KeyProperties.ENCRYPTION_PADDING_NONE + " got " + + Arrays.toString(spec.getEncryptionPaddings())); + } + if (spec.isUserAuthenticationRequired() + && spec.getUserAuthenticationValidityDurationSeconds() < 1) { + throw new IllegalArgumentException( + "per-operation authentication is not supported " + + "(UserAuthenticationValidityDurationSeconds must be >0)"); + } + } + + private static void generateKey(@NonNull KeyGenParameterSpec keyGenParameterSpec) + throws GeneralSecurityException { + KeyGenerator keyGenerator = KeyGenerator.getInstance( + KeyProperties.KEY_ALGORITHM_AES, + ANDROID_KEYSTORE); + keyGenerator.init(keyGenParameterSpec); + keyGenerator.generateKey(); + } + + private static boolean keyExists(@NonNull String keyAlias) + throws GeneralSecurityException, IOException { + KeyStore keyStore = KeyStore.getInstance(ANDROID_KEYSTORE); + keyStore.load(null); + return keyStore.containsAlias(keyAlias); + } + +} diff --git a/app/src/main/java/au/gov/health/covidsafe/ui/home/HomeFragment.kt b/app/src/main/java/au/gov/health/covidsafe/ui/home/HomeFragment.kt index 768bff2..70222cb 100644 --- a/app/src/main/java/au/gov/health/covidsafe/ui/home/HomeFragment.kt +++ b/app/src/main/java/au/gov/health/covidsafe/ui/home/HomeFragment.kt @@ -221,7 +221,7 @@ class HomeFragment : BaseFragment(), EasyPermissions.PermissionCallbacks { } private fun allPermissionsEnabled(): Boolean { - val bluetoothEnabled = isBlueToothEnabled() ?: true + val bluetoothEnabled = isBlueToothEnabled() ?: false val pushNotificationEnabled = isPushNotificationEnabled() ?: true val nonBatteryOptimizationAllowed = isNonBatteryOptimizationAllowed() ?: true val locationStatusAllowed = isFineLocationEnabled() ?: true diff --git a/app/src/main/java/au/gov/health/covidsafe/ui/onboarding/fragment/registrationconsent/RegistrationConsentFragment.kt b/app/src/main/java/au/gov/health/covidsafe/ui/onboarding/fragment/registrationconsent/RegistrationConsentFragment.kt index 4b5bf9a..850e400 100644 --- a/app/src/main/java/au/gov/health/covidsafe/ui/onboarding/fragment/registrationconsent/RegistrationConsentFragment.kt +++ b/app/src/main/java/au/gov/health/covidsafe/ui/onboarding/fragment/registrationconsent/RegistrationConsentFragment.kt @@ -21,20 +21,15 @@ class RegistrationConsentFragment : PagerChildFragment() { override fun onResume() { super.onResume() - registration_consent_checkbox.setOnCheckedChangeListener { buttonView, isChecked -> - updateButtonState() - } // set accessibility focus to the title "I consent to the Australian ..." registration_consent_text.sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_FOCUSED) + + updateButtonState() } override fun updateButtonState() { - if (registration_consent_checkbox.isChecked) { - (activity as? PagerContainer)?.enableNextButton() - } else { - (activity as? PagerContainer)?.disableNextButton() - } + (activity as? PagerContainer)?.enableNextButton() } override fun onDestroyView() { @@ -42,7 +37,7 @@ class RegistrationConsentFragment : PagerChildFragment() { root.removeAllViews() } - override fun getUploadButtonLayout() = UploadButtonLayout.ContinueLayout(R.string.registration_consent_button) { + override fun getUploadButtonLayout() = UploadButtonLayout.ContinueLayout(R.string.consent_button) { navigateTo(RegistrationConsentFragmentDirections.actionRegistrationConsentFragmentToPersonalDetailsFragment().actionId) } } \ No newline at end of file diff --git a/app/src/main/java/au/gov/health/covidsafe/ui/onboarding/fragment/undersixteen/UnderSixteenFragment.kt b/app/src/main/java/au/gov/health/covidsafe/ui/onboarding/fragment/undersixteen/UnderSixteenFragment.kt index f163647..93b8705 100644 --- a/app/src/main/java/au/gov/health/covidsafe/ui/onboarding/fragment/undersixteen/UnderSixteenFragment.kt +++ b/app/src/main/java/au/gov/health/covidsafe/ui/onboarding/fragment/undersixteen/UnderSixteenFragment.kt @@ -24,17 +24,10 @@ class UnderSixteenFragment : PagerChildFragment() { override fun onResume() { super.onResume() - under_sixteen_checkbox.setOnCheckedChangeListener { buttonView, isChecked -> - updateButtonState() - } + updateButtonState() } - override fun onPause() { - super.onPause() - under_sixteen_checkbox.setOnCheckedChangeListener(null) - } - - override fun getUploadButtonLayout(): UploadButtonLayout = UploadButtonLayout.ContinueLayout(R.string.under_sixteen_button) { + override fun getUploadButtonLayout(): UploadButtonLayout = UploadButtonLayout.ContinueLayout(R.string.consent_button) { val bundle = bundleOf( EnterNumberFragment.ENTER_NUMBER_DESTINATION_ID to R.id.action_otpFragment_to_permissionFragment, EnterNumberFragment.ENTER_NUMBER_PROGRESS to 2) @@ -42,10 +35,6 @@ class UnderSixteenFragment : PagerChildFragment() { } override fun updateButtonState() { - if (under_sixteen_checkbox.isChecked) { - enableContinueButton() - } else { - disableContinueButton() - } + enableContinueButton() } } \ No newline at end of file diff --git a/app/src/main/java/au/gov/health/covidsafe/ui/upload/presentation/UploadStepFourFragment.kt b/app/src/main/java/au/gov/health/covidsafe/ui/upload/presentation/UploadStepFourFragment.kt index 5d23f6d..c3df3dc 100644 --- a/app/src/main/java/au/gov/health/covidsafe/ui/upload/presentation/UploadStepFourFragment.kt +++ b/app/src/main/java/au/gov/health/covidsafe/ui/upload/presentation/UploadStepFourFragment.kt @@ -28,26 +28,20 @@ class UploadStepFourFragment : PagerChildFragment() { override fun onResume() { super.onResume() - upload_consent_checkbox.setOnCheckedChangeListener { buttonView, isChecked -> - updateButtonState() - } + updateButtonState() // set accessibility focus to the title header.sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_FOCUSED) } override fun updateButtonState() { - if (upload_consent_checkbox.isChecked) { - enableContinueButton() - } else { - disableContinueButton() - } + enableContinueButton() } override val navigationIcon: Int? = R.drawable.ic_up override fun getUploadButtonLayout() = UploadButtonLayout.ContinueLayout( - R.string.action_continue) { + R.string.consent_button) { navigateToVerifyUploadPin() } diff --git a/app/src/main/res/drawable-anydpi-v24/ic_notification_setting.xml b/app/src/main/res/drawable-anydpi-v24/ic_notification_setting.xml deleted file mode 100644 index 3914c01..0000000 --- a/app/src/main/res/drawable-anydpi-v24/ic_notification_setting.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - diff --git a/app/src/main/res/layout/fragment_home_external_links.xml b/app/src/main/res/layout/fragment_home_external_links.xml index 500fe11..a718909 100644 --- a/app/src/main/res/layout/fragment_home_external_links.xml +++ b/app/src/main/res/layout/fragment_home_external_links.xml @@ -32,6 +32,8 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:background="@color/white" + android:paddingTop="@dimen/keyline_0" + android:paddingBottom="@dimen/keyline_0" android:minHeight="@dimen/external_link_height" app:external_linkCard_content="@string/home_set_complete_external_link_share_content" app:external_linkCard_icon="@drawable/ic_home_share" @@ -52,6 +54,8 @@ android:layout_height="wrap_content" android:background="@color/white" android:minHeight="@dimen/external_link_height" + android:paddingTop="@dimen/keyline_0" + android:paddingBottom="@dimen/keyline_0" app:external_linkCard_content="@string/home_set_complete_external_link_been_contacted_content" app:external_linkCard_icon="@drawable/ic_upload_icon" app:external_linkCard_icon_background="@drawable/background_circular_green" @@ -83,6 +87,8 @@ android:layout_height="wrap_content" android:background="@color/white" android:minHeight="@dimen/external_link_height" + android:paddingTop="@dimen/keyline_0" + android:paddingBottom="@dimen/keyline_0" app:external_linkCard_content="@string/home_set_complete_external_link_app_content" app:external_linkCard_icon="@drawable/ic_home_news" app:external_linkCard_icon_background="@drawable/background_circular_black" @@ -130,9 +136,11 @@ android:layout_height="wrap_content" android:background="@color/white" android:minHeight="@dimen/external_link_height" - app:external_linkCard_title="@string/home_set_complete_external_link_help_topics_title" + android:paddingTop="@dimen/keyline_0" + android:paddingBottom="@dimen/keyline_0" app:external_linkCard_content="@string/home_set_complete_external_link_help_topics_content" - app:external_linkCard_icon="@drawable/ic_question_circle" /> + app:external_linkCard_icon="@drawable/ic_question_circle" + app:external_linkCard_title="@string/home_set_complete_external_link_help_topics_title" /> diff --git a/app/src/main/res/layout/fragment_registration_consent.xml b/app/src/main/res/layout/fragment_registration_consent.xml index 99363a6..058eb7a 100644 --- a/app/src/main/res/layout/fragment_registration_consent.xml +++ b/app/src/main/res/layout/fragment_registration_consent.xml @@ -59,15 +59,14 @@ app:layout_constraintTop_toBottomOf="@+id/registration_consent_first_paragraph" app:ul_view_text="@string/registration_consent_second_paragraph" /> - diff --git a/app/src/main/res/layout/fragment_under_sixteen.xml b/app/src/main/res/layout/fragment_under_sixteen.xml index cc737f8..15e33ca 100644 --- a/app/src/main/res/layout/fragment_under_sixteen.xml +++ b/app/src/main/res/layout/fragment_under_sixteen.xml @@ -60,14 +60,13 @@ app:layout_constraintTop_toBottomOf="@+id/under_sixteen_first_paragraph" app:ul_view_text="@string/under_sixteen_second_paragraph" /> - diff --git a/app/src/main/res/layout/fragment_upload_page_4.xml b/app/src/main/res/layout/fragment_upload_page_4.xml index 86c9787..011ba39 100644 --- a/app/src/main/res/layout/fragment_upload_page_4.xml +++ b/app/src/main/res/layout/fragment_upload_page_4.xml @@ -32,16 +32,14 @@ android:layout_marginEnd="@dimen/keyline_5" android:text="@string/upload_step_4_sub_header"/> - + android:text="@string/consent_call_for_action" /> diff --git a/app/src/main/res/layout/view_card_external_link_card.xml b/app/src/main/res/layout/view_card_external_link_card.xml index 6f330c7..d2db75e 100644 --- a/app/src/main/res/layout/view_card_external_link_card.xml +++ b/app/src/main/res/layout/view_card_external_link_card.xml @@ -3,7 +3,7 @@ xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" - android:layout_height="@dimen/external_link_height" + android:layout_height="wrap_content" tools:parentTag="androidx.constraintlayout.widget.ConstraintLayout"> Registration and privacy Heading, Registration and privacy - It is important that you read the COVIDSafe privacy policy before you register for COVIDSafe.\n\nIf you are under 16 years of age, your parent/guardian must also read the privacy policy.\n\nUse of COVIDSafe is completely voluntary. You can install or delete the application at any time. If you delete COVIDSafe, you may also ask for your information to be deleted from the secure server.\n\nTo register for COVIDSafe, you will need to enter a name, mobile number, age range and postcode.\n\nInformation you submit when you register, and information about your use of COVIDSafe will be collected and stored on a highly secure server.\n\nCOVIDSafe will not collect your location information.\n\nCOVIDSafe will note the time of contact and an anonymous ID code of other COVIDSafe users you come into contact with.\n\nOther COVIDSafe users you come into contact with will record an anonymous ID code and the time of contact with your device.\n\nIf another user tests positive to COVID-19, they may upload their contact information and a state or territory health official may contact you for tracing purposes.\n\nYour registration details will only be used or disclosed for contact tracing and for the proper and lawful functioning of COVIDSafe.\n\nMore information is available at the Australian Government Department of Health website.\n\nSee the COVIDSafe privacy policy for further details about your rights about your information and how it will be handled and shared. + It is important that you read the COVIDSafe privacy policy before you register for COVIDSafe.\n\nIf you are under 16 years of age, your parent/guardian must also read the privacy policy.\n\nUse of COVIDSafe is completely voluntary. You can install or delete the application at any time. If you delete COVIDSafe, you may also ask for your information to be deleted from the secure server.\n\nTo register for COVIDSafe, you will need to enter a name, mobile number, age range and postcode.\n\nInformation you submit when you register, and information about your use of COVIDSafe will be collected and stored on a highly secure server.\n\nCOVIDSafe will not collect your location information.\n\nCOVIDSafe will note the time of contact and an anonymous ID code of other COVIDSafe users you come into contact with.\n\nOther COVIDSafe users you come into contact with will record an anonymous ID code and the time of contact with you on their device.\n\nIf another user tests positive to COVID-19, they may upload their contact information and a state or territory health official may contact you for tracing purposes.\n\nYour registration details will only be used or disclosed for contact tracing and for the proper and lawful functioning of COVIDSafe.\n\nMore information is available at the Australian Government Department of Health website.\n\nSee the COVIDSafe privacy policy for further details about your rights about your information and how it will be handled and shared. Next + + Select \'I agree\' to confirm consent. + I agree + Registration consent I consent to the Australian Department of Health collecting: - My registration information to allow contact tracing by state and territory health officials. + My registration information to allow contact tracing by state or territory health officials. My contact information from other COVIDSafe users after they test positive for COVID-19. - I consent. - Continue Enter your details @@ -93,8 +95,6 @@ I confirm my parent or guardian consents to the Australian Department of Health collecting: My registration information to allow contact tracing by state and territory health officials. My contact information from other COVIDSafe users after they test positive for COVID-19. - I confirm. - Continue @@ -125,7 +125,7 @@ Your device name Heading, Your device name The current name of your device is %s. - Other Bluetooth® devices around you will be able to see this name. You may like to consider making the device name anonymous. + Other Bluetooth® devices around you will be able to see this name. We recommend to use a device name that does not include your personal details. New device name Android phone Change and continue @@ -200,7 +200,6 @@ Upload consent Heading, Upload consent Unless you consent, your close contact information will not be uploaded.\n\nIf you consent, your close contact information will be uploaded and shared with state or territory health officials for contact tracing purposes.\n\nRead the COVIDSafe privacy policy for further details. - I consent to upload my information Upload your information Heading, Upload your information A state or territory health official will send a PIN to your device via text message. Enter it below to upload your information.