| 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 |
} |