| 1 |
package io.oxygenhub.android.secondfactor |
| 2 |
|
| 3 |
import android.content.Context |
| 4 |
import android.hardware.biometrics.BiometricManager.Authenticators |
| 5 |
import android.hardware.biometrics.BiometricPrompt |
| 6 |
import android.os.CancellationSignal |
| 7 |
import android.security.keystore.KeyGenParameterSpec |
| 8 |
import android.security.keystore.KeyProperties |
| 9 |
import kotlinx.coroutines.flow.MutableStateFlow |
| 10 |
import kotlinx.coroutines.flow.StateFlow |
| 11 |
import kotlinx.coroutines.flow.asStateFlow |
| 12 |
import kotlinx.coroutines.suspendCancellableCoroutine |
| 13 |
import java.security.KeyPairGenerator |
| 14 |
import java.security.KeyStore |
| 15 |
import java.security.PrivateKey |
| 16 |
import java.security.Signature |
| 17 |
import java.security.spec.ECGenParameterSpec |
| 18 |
import kotlin.coroutines.resume |
| 19 |
|
| 20 |
/** What the phone signs to answer an approval: the hub checks the same bytes (oxygenhub#356). */ |
| 21 |
object ApprovalMessage { |
| 22 |
fun of(id: String, number: Long, approve: Boolean): ByteArray = |
| 23 |
"oxygenhub approval v1\n$id\n$number\n${if (approve) "approve" else "deny"}".toByteArray() |
| 24 |
} |
| 25 |
|
| 26 |
fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) } |
| 27 |
|
| 28 |
/** |
| 29 |
* This phone's second-factor key (#32): a P-256 key made in the Android |
| 30 |
* Keystore that never leaves it and signs only after a fingerprint, face |
| 31 |
* or the device PIN, each time. |
| 32 |
*/ |
| 33 |
object PhoneKey { |
| 34 |
private const val ALIAS = "oxygenhub-second-factor" |
| 35 |
|
| 36 |
private fun store(): KeyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } |
| 37 |
|
| 38 |
fun exists(): Boolean = runCatching { store().containsAlias(ALIAS) }.getOrDefault(false) |
| 39 |
|
| 40 |
/** Whether this phone holds its key: the app looks for approvals only then. */ |
| 41 |
private val _present = MutableStateFlow(false) |
| 42 |
val present: StateFlow<Boolean> = _present.asStateFlow() |
| 43 |
|
| 44 |
fun refresh() { |
| 45 |
_present.value = exists() |
| 46 |
} |
| 47 |
|
| 48 |
/** A new key, replacing any before it: its SubjectPublicKeyInfo, in hex, for the hub. */ |
| 49 |
fun create(): String { |
| 50 |
val ks = store() |
| 51 |
if (ks.containsAlias(ALIAS)) ks.deleteEntry(ALIAS) |
| 52 |
val gen = KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_EC, "AndroidKeyStore") |
| 53 |
gen.initialize( |
| 54 |
KeyGenParameterSpec.Builder(ALIAS, KeyProperties.PURPOSE_SIGN) |
| 55 |
.setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1")) |
| 56 |
.setDigests(KeyProperties.DIGEST_SHA256) |
| 57 |
.setUserAuthenticationRequired(true) |
| 58 |
// Every use asks: a fingerprint or face, or the device PIN. |
| 59 |
.setUserAuthenticationParameters(0, KeyProperties.AUTH_BIOMETRIC_STRONG or KeyProperties.AUTH_DEVICE_CREDENTIAL) |
| 60 |
// The PIN opens it anyway, so a new fingerprint need not end it. |
| 61 |
.setInvalidatedByBiometricEnrollment(false) |
| 62 |
.build(), |
| 63 |
) |
| 64 |
return gen.generateKeyPair().public.encoded.toHex().also { _present.value = true } |
| 65 |
} |
| 66 |
|
| 67 |
fun delete() { |
| 68 |
runCatching { store().deleteEntry(ALIAS) } |
| 69 |
_present.value = false |
| 70 |
} |
| 71 |
|
| 72 |
/** A signature ready to be opened by the prompt; null when the key is gone or no longer usable. */ |
| 73 |
fun signature(): Signature? = runCatching { |
| 74 |
val key = store().getKey(ALIAS, null) as? PrivateKey ?: return null |
| 75 |
Signature.getInstance("SHA256withECDSA").apply { initSign(key) } |
| 76 |
}.getOrNull() |
| 77 |
|
| 78 |
/** |
| 79 |
* Sign [message] after the system's prompt: null when the person |
| 80 |
* cancels, the prompt fails, or the key is gone (the lock screen was |
| 81 |
* removed, say). |
| 82 |
*/ |
| 83 |
suspend fun sign(context: Context, title: String, subtitle: String, message: ByteArray): ByteArray? { |
| 84 |
val sig = signature() ?: return null |
| 85 |
return suspendCancellableCoroutine { cont -> |
| 86 |
val cancel = CancellationSignal() |
| 87 |
cont.invokeOnCancellation { cancel.cancel() } |
| 88 |
val prompt = BiometricPrompt.Builder(context) |
| 89 |
.setTitle(title) |
| 90 |
.setSubtitle(subtitle) |
| 91 |
.setAllowedAuthenticators(Authenticators.BIOMETRIC_STRONG or Authenticators.DEVICE_CREDENTIAL) |
| 92 |
.build() |
| 93 |
prompt.authenticate( |
| 94 |
BiometricPrompt.CryptoObject(sig), |
| 95 |
cancel, |
| 96 |
context.mainExecutor, |
| 97 |
object : BiometricPrompt.AuthenticationCallback() { |
| 98 |
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { |
| 99 |
val signed = runCatching { |
| 100 |
val s = result.cryptoObject.signature ?: sig |
| 101 |
s.update(message) |
| 102 |
s.sign() |
| 103 |
}.getOrNull() |
| 104 |
if (cont.isActive) cont.resume(signed) |
| 105 |
} |
| 106 |
|
| 107 |
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { |
| 108 |
if (cont.isActive) cont.resume(null) |
| 109 |
} |
| 110 |
}, |
| 111 |
) |
| 112 |
} |
| 113 |
} |
| 114 |
} |