OXYGENHUB
# d2e553089d82 oxygenhub-android / app / src / main / java / io / oxygenhub / android / secondfactor / PhoneKey.kt
4.8 KiB · last changed This phone as the second factor: a Keystore key opened by fingerprint, face or PIN approves sign-ins and sensitive changes on the web by the number the browser shows (closes #32) 3 days ago source blame history raw
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
}