Coverage / app/src/main/java/io/oxygenhub/android/alerts/AlertKey.kt 0.0%
0 of 35 lines run, 0 of 9 functions, 0 of 14 branches · run #1653 on main · run missed
| 1 | package io.oxygenhub.android.alerts | |
| 2 | ||
| 3 | import android.content.Context | |
| 4 | import android.security.keystore.KeyGenParameterSpec | |
| 5 | import android.security.keystore.KeyProperties | |
| 6 | import android.security.keystore.StrongBoxUnavailableException | |
| 7 | import android.util.Base64 | |
| 8 | import androidx.core.content.edit | |
| 9 | import java.security.GeneralSecurityException | |
| 10 | import java.security.KeyStore | |
| 11 | import javax.crypto.Cipher | |
| 12 | import javax.crypto.KeyGenerator | |
| 13 | import javax.crypto.SecretKey | |
| 14 | import javax.crypto.spec.GCMParameterSpec | |
| 15 | import uniffi.rust_core.alertNewSecret | |
| 16 | import uniffi.rust_core.alertPublicKey | |
| 17 | ||
| 18 | /** | |
| 19 | * The phone's alert key (#16): an X25519 secret, made by the Rust core, | |
| 20 | * that the hub seals each alert to. Kept in app-private preferences only as | |
| 21 | * ciphertext under an AES-GCM key in the Android Keystore (StrongBox where | |
| 22 | * the phone has one), as the session's token is; out of backups, since a | |
| 23 | * restored copy could not be opened anyway. A lost key just means a new one | |
| 24 | * and registering again. | |
| 25 | */ | |
| 26 | object AlertKey { | |
| 27 | private const val PREFS = "alerts" | |
| 28 | private const val SEALED = "secret.sealed" | |
| 29 | private const val KEYSTORE = "AndroidKeyStore" | |
| 30 | private const val ALIAS = "io.oxygenhub.android.alerts" | |
| 31 | private const val TRANSFORMATION = "AES/GCM/NoPadding" | |
| 32 | ||
| 33 | /** The secret, made and kept the first time it is asked for. */ | |
| 34 | @Synchronized | |
| 35 | fun secret(context: Context): ByteArray { | |
| 36 | val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) | |
| 37 | prefs.getString(SEALED, null)?.let { sealed -> | |
| 38 | try { | |
| 39 | return open(sealed) | |
| 40 | } catch (_: GeneralSecurityException) { | |
| 41 | // The Keystore key is gone: a new secret, registered again. | |
| 42 | } catch (_: IllegalArgumentException) { | |
| 43 | } | |
| 44 | } | |
| 45 | val fresh = alertNewSecret() | |
| 46 | prefs.edit { putString(SEALED, seal(fresh)) } | |
| 47 | return fresh | |
| 48 | } | |
| 49 | ||
| 50 | /** The public key, hex, for the hub. */ | |
| 51 | fun publicKeyHex(context: Context): String = alertPublicKey(secret(context)).joinToString("") { "%02x".format(it) } | |
| 52 | ||
| 53 | private fun seal(plain: ByteArray): String { | |
| 54 | val cipher = Cipher.getInstance(TRANSFORMATION) | |
| 55 | cipher.init(Cipher.ENCRYPT_MODE, key()) | |
| 56 | return b64(cipher.iv) + ":" + b64(cipher.doFinal(plain)) | |
| 57 | } | |
| 58 | ||
| 59 | private fun open(sealed: String): ByteArray { | |
| 60 | val parts = sealed.split(':', limit = 2) | |
| 61 | require(parts.size == 2) { "not a sealed secret" } | |
| 62 | val key = existingKey() ?: throw GeneralSecurityException("no key") | |
| 63 | val cipher = Cipher.getInstance(TRANSFORMATION) | |
| 64 | cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(128, Base64.decode(parts[0], Base64.NO_WRAP))) | |
| 65 | return cipher.doFinal(Base64.decode(parts[1], Base64.NO_WRAP)) | |
| 66 | } | |
| 67 | ||
| 68 | private fun b64(bytes: ByteArray): String = Base64.encodeToString(bytes, Base64.NO_WRAP) | |
| 69 | ||
| 70 | private fun existingKey(): SecretKey? { | |
| 71 | val ks = KeyStore.getInstance(KEYSTORE).apply { load(null) } | |
| 72 | return (ks.getEntry(ALIAS, null) as? KeyStore.SecretKeyEntry)?.secretKey | |
| 73 | } | |
| 74 | ||
| 75 | private fun key(): SecretKey = existingKey() ?: try { | |
| 76 | generate(strongBox = true) | |
| 77 | } catch (_: StrongBoxUnavailableException) { | |
| 78 | generate(strongBox = false) | |
| 79 | } | |
| 80 | ||
| 81 | private fun generate(strongBox: Boolean): SecretKey { | |
| 82 | val spec = KeyGenParameterSpec.Builder(ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT) | |
| 83 | .setBlockModes(KeyProperties.BLOCK_MODE_GCM) | |
| 84 | .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) | |
| 85 | .setKeySize(256) | |
| 86 | .setIsStrongBoxBacked(strongBox) | |
| 87 | .build() | |
| 88 | return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE).apply { init(spec) }.generateKey() | |
| 89 | } | |
| 90 | } |