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) 263c6cad5e51
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 60e1b3b99878
- When
- · 2026-11-01 16:22 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
263c6cad5e510d06f8b43d5b7c6ab89a467a73feb6789757b63756fc69e9574b- Depends on
- 144cbf7bf2b5 1dfa631436b6 4a6d7ccd0958 4aee5293461d 57c0824300db 5ae4af48ddd2 62429abe5869 821035db5a1c 840c75813f86 aa21eb5e0e5b aa627197ec18 db00632a61d6 e0e4a1c48a77 e2df27a81e5d ff65ea456ff1
- Depended on by
- 1b55d7f47d9f
- In bookmarks
- main
- In tags
- v0.1.4 v0.1.5 v0.1.6 v0.1.7 v0.1.8 v0.1.9
- Edits
- 18 atoms, 6 tree operations, 12 files
Browse the tree at this change
added app/src/main/java/io/oxygenhub/android/secondfactor/Approvals.kt +107 −0
| 1 | + | package io.oxygenhub.android.secondfactor |
|
| 2 | + | ||
| 3 | + | import androidx.lifecycle.ViewModel |
|
| 4 | + | import androidx.lifecycle.viewModelScope |
|
| 5 | + | import io.oxygenhub.android.api.HubApi |
|
| 6 | + | import io.oxygenhub.android.api.HubException |
|
| 7 | + | import io.oxygenhub.android.api.PendingApproval |
|
| 8 | + | import kotlinx.coroutines.CancellationException |
|
| 9 | + | import kotlinx.coroutines.delay |
|
| 10 | + | import kotlinx.coroutines.flow.MutableStateFlow |
|
| 11 | + | import kotlinx.coroutines.flow.StateFlow |
|
| 12 | + | import kotlinx.coroutines.flow.asStateFlow |
|
| 13 | + | import kotlinx.coroutines.launch |
|
| 14 | + | ||
| 15 | + | /** |
|
| 16 | + | * Approvals waiting for this phone (#32, oxygenhub#356): looked for every |
|
| 17 | + | * few seconds while the app is in front (and when an alert wakes it), one |
|
| 18 | + | * shown at a time; answering signs with the phone's key. |
|
| 19 | + | */ |
|
| 20 | + | class ApprovalsViewModel(private val api: HubApi) : ViewModel() { |
|
| 21 | + | private val _waiting = MutableStateFlow<PendingApproval?>(null) |
|
| 22 | + | val waiting: StateFlow<PendingApproval?> = _waiting.asStateFlow() |
|
| 23 | + | ||
| 24 | + | /** What the last answer came to, to say once. */ |
|
| 25 | + | private val _said = MutableStateFlow<String?>(null) |
|
| 26 | + | val said: StateFlow<String?> = _said.asStateFlow() |
|
| 27 | + | ||
| 28 | + | private val _busy = MutableStateFlow(false) |
|
| 29 | + | val busy: StateFlow<Boolean> = _busy.asStateFlow() |
|
| 30 | + | ||
| 31 | + | /** Answered or put aside here: not shown again. */ |
|
| 32 | + | private val done = mutableSetOf<String>() |
|
| 33 | + | ||
| 34 | + | /** Look now. */ |
|
| 35 | + | suspend fun check() { |
|
| 36 | + | val list = try { |
|
| 37 | + | api.approvals() |
|
| 38 | + | } catch (e: CancellationException) { |
|
| 39 | + | throw e |
|
| 40 | + | } catch (_: Exception) { |
|
| 41 | + | return |
|
| 42 | + | } |
|
| 43 | + | if (!_busy.value) _waiting.value = list.firstOrNull { it.id !in done } |
|
| 44 | + | } |
|
| 45 | + | ||
| 46 | + | /** Look every [everyMs] for as long as the caller's scope lives (the app in front). */ |
|
| 47 | + | suspend fun watch(everyMs: Long = 3_000) { |
|
| 48 | + | while (true) { |
|
| 49 | + | check() |
|
| 50 | + | delay(everyMs) |
|
| 51 | + | } |
|
| 52 | + | } |
|
| 53 | + | ||
| 54 | + | /** Approve with [number]: [sign] signs the message after the system's prompt, or gives null when the person backs out. */ |
|
| 55 | + | fun approve(a: PendingApproval, number: Long, sign: suspend (ByteArray) -> ByteArray?) { |
|
| 56 | + | viewModelScope.launch { |
|
| 57 | + | _busy.value = true |
|
| 58 | + | try { |
|
| 59 | + | val signature = sign(ApprovalMessage.of(a.id, number, true)) |
|
| 60 | + | if (signature == null) { |
|
| 61 | + | _said.value = "Not approved: the phone's check did not finish." |
|
| 62 | + | return@launch |
|
| 63 | + | } |
|
| 64 | + | val state = api.answerApproval(a.id, true, number, signature.toHex()).state |
|
| 65 | + | _said.value = when (state) { |
|
| 66 | + | "approved" -> if (a.kind == "signin") "Approved: the browser is signing in." else "Approved." |
|
| 67 | + | else -> "That was not the number the browser showed, so it was refused. Start again in the browser." |
|
| 68 | + | } |
|
| 69 | + | finish(a) |
|
| 70 | + | } catch (e: CancellationException) { |
|
| 71 | + | throw e |
|
| 72 | + | } catch (e: HubException) { |
|
| 73 | + | _said.value = e.message ?: "The hub did not take the answer." |
|
| 74 | + | finish(a) |
|
| 75 | + | } finally { |
|
| 76 | + | _busy.value = false |
|
| 77 | + | } |
|
| 78 | + | } |
|
| 79 | + | } |
|
| 80 | + | ||
| 81 | + | /** Say no: the browser is told, and nobody gets in. */ |
|
| 82 | + | fun deny(a: PendingApproval) { |
|
| 83 | + | viewModelScope.launch { |
|
| 84 | + | _busy.value = true |
|
| 85 | + | try { |
|
| 86 | + | api.answerApproval(a.id, false, 0, "") |
|
| 87 | + | _said.value = "Refused. If that was not you, change your password." |
|
| 88 | + | } catch (e: CancellationException) { |
|
| 89 | + | throw e |
|
| 90 | + | } catch (_: Exception) { |
|
| 91 | + | _said.value = "Refused here; the hub could not be told." |
|
| 92 | + | } finally { |
|
| 93 | + | _busy.value = false |
|
| 94 | + | finish(a) |
|
| 95 | + | } |
|
| 96 | + | } |
|
| 97 | + | } |
|
| 98 | + | ||
| 99 | + | fun shown() { |
|
| 100 | + | _said.value = null |
|
| 101 | + | } |
|
| 102 | + | ||
| 103 | + | private fun finish(a: PendingApproval) { |
|
| 104 | + | done += a.id |
|
| 105 | + | if (_waiting.value?.id == a.id) _waiting.value = null |
|
| 106 | + | } |
|
| 107 | + | } |
added app/src/main/java/io/oxygenhub/android/secondfactor/PhoneKey.kt +114 −0
| 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 | + | } |
added app/src/main/java/io/oxygenhub/android/secondfactor/SecondFactorUi.kt +193 −0
| 1 | + | package io.oxygenhub.android.secondfactor |
|
| 2 | + | ||
| 3 | + | import android.widget.Toast |
|
| 4 | + | import androidx.compose.foundation.layout.Arrangement |
|
| 5 | + | import androidx.compose.foundation.layout.Column |
|
| 6 | + | import androidx.compose.foundation.layout.Row |
|
| 7 | + | import androidx.compose.foundation.layout.fillMaxWidth |
|
| 8 | + | import androidx.compose.foundation.layout.padding |
|
| 9 | + | import androidx.compose.foundation.layout.size |
|
| 10 | + | import androidx.compose.foundation.text.KeyboardOptions |
|
| 11 | + | import androidx.compose.material3.AlertDialog |
|
| 12 | + | import androidx.compose.material3.CircularProgressIndicator |
|
| 13 | + | import androidx.compose.material3.FilledTonalButton |
|
| 14 | + | import androidx.compose.material3.MaterialTheme |
|
| 15 | + | import androidx.compose.material3.OutlinedButton |
|
| 16 | + | import androidx.compose.material3.OutlinedTextField |
|
| 17 | + | import androidx.compose.material3.Text |
|
| 18 | + | import androidx.compose.material3.TextButton |
|
| 19 | + | import androidx.compose.runtime.Composable |
|
| 20 | + | import androidx.compose.runtime.LaunchedEffect |
|
| 21 | + | import androidx.compose.runtime.getValue |
|
| 22 | + | import androidx.compose.runtime.mutableStateOf |
|
| 23 | + | import androidx.compose.runtime.remember |
|
| 24 | + | import androidx.compose.runtime.rememberCoroutineScope |
|
| 25 | + | import androidx.compose.runtime.setValue |
|
| 26 | + | import androidx.compose.ui.Alignment |
|
| 27 | + | import androidx.compose.ui.Modifier |
|
| 28 | + | import androidx.compose.ui.platform.LocalContext |
|
| 29 | + | import androidx.compose.ui.text.font.FontWeight |
|
| 30 | + | import androidx.compose.ui.text.input.KeyboardType |
|
| 31 | + | import androidx.compose.ui.unit.dp |
|
| 32 | + | import androidx.compose.ui.unit.sp |
|
| 33 | + | import androidx.lifecycle.Lifecycle |
|
| 34 | + | import androidx.lifecycle.compose.LocalLifecycleOwner |
|
| 35 | + | import androidx.lifecycle.compose.collectAsStateWithLifecycle |
|
| 36 | + | import androidx.lifecycle.repeatOnLifecycle |
|
| 37 | + | import io.oxygenhub.android.api.HubApi |
|
| 38 | + | import io.oxygenhub.android.api.HubException |
|
| 39 | + | import io.oxygenhub.android.ui.common.Panel |
|
| 40 | + | import kotlinx.coroutines.CancellationException |
|
| 41 | + | import kotlinx.coroutines.launch |
|
| 42 | + | ||
| 43 | + | /** |
|
| 44 | + | * Account › Second factor (#32): this phone approves sign-ins and |
|
| 45 | + | * sensitive changes on the web, with a fingerprint, face or the PIN. |
|
| 46 | + | * Adding it needs a code from the authenticator app, which stays the way |
|
| 47 | + | * in when the phone is not to hand. |
|
| 48 | + | */ |
|
| 49 | + | @Composable |
|
| 50 | + | fun SecondFactorCard(api: HubApi, onChanged: () -> Unit = {}) { |
|
| 51 | + | val scope = rememberCoroutineScope() |
|
| 52 | + | // null while asking the hub; then whether this phone approves. |
|
| 53 | + | var on by remember { mutableStateOf<Boolean?>(null) } |
|
| 54 | + | var asking by remember { mutableStateOf(false) } |
|
| 55 | + | var working by remember { mutableStateOf(false) } |
|
| 56 | + | var error by remember { mutableStateOf<String?>(null) } |
|
| 57 | + | LaunchedEffect(Unit) { |
|
| 58 | + | on = try { |
|
| 59 | + | api.authenticators().any { it.isThis } && PhoneKey.exists() |
|
| 60 | + | } catch (e: CancellationException) { |
|
| 61 | + | throw e |
|
| 62 | + | } catch (_: Exception) { |
|
| 63 | + | false |
|
| 64 | + | } |
|
| 65 | + | } |
|
| 66 | + | Panel(Modifier.fillMaxWidth()) { |
|
| 67 | + | Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { |
|
| 68 | + | Text("Second factor", style = MaterialTheme.typography.titleMedium) |
|
| 69 | + | Text( |
|
| 70 | + | when (on) { |
|
| 71 | + | true -> "This phone approves your sign-ins and sensitive changes on the web: pick the number the browser shows, then your fingerprint, face or PIN." |
|
| 72 | + | else -> "Approve sign-ins on the web with this phone instead of typing a code. Needs two-factor sign-in turned on (Settings › Security on the hub)." |
|
| 73 | + | }, |
|
| 74 | + | style = MaterialTheme.typography.bodyMedium, |
|
| 75 | + | color = MaterialTheme.colorScheme.onSurfaceVariant, |
|
| 76 | + | ) |
|
| 77 | + | error?.let { Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) } |
|
| 78 | + | when (on) { |
|
| 79 | + | null -> CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp) |
|
| 80 | + | false -> FilledTonalButton(onClick = { error = null; asking = true }, enabled = !working) { Text("Use this phone as a second factor") } |
|
| 81 | + | true -> OutlinedButton(onClick = { |
|
| 82 | + | working = true |
|
| 83 | + | scope.launch { |
|
| 84 | + | try { |
|
| 85 | + | api.removeAuthenticator() |
|
| 86 | + | PhoneKey.delete() |
|
| 87 | + | on = false |
|
| 88 | + | onChanged() |
|
| 89 | + | } catch (e: CancellationException) { |
|
| 90 | + | throw e |
|
| 91 | + | } catch (e: Exception) { |
|
| 92 | + | error = e.message ?: "The hub could not be reached." |
|
| 93 | + | } finally { |
|
| 94 | + | working = false |
|
| 95 | + | } |
|
| 96 | + | } |
|
| 97 | + | }, enabled = !working) { Text("Stop using this phone") } |
|
| 98 | + | } |
|
| 99 | + | } |
|
| 100 | + | } |
|
| 101 | + | if (asking) { |
|
| 102 | + | var code by remember { mutableStateOf("") } |
|
| 103 | + | AlertDialog( |
|
| 104 | + | onDismissRequest = { if (!working) asking = false }, |
|
| 105 | + | title = { Text("Use this phone as a second factor") }, |
|
| 106 | + | text = { |
|
| 107 | + | Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { |
|
| 108 | + | Text("Enter the six-digit code your authenticator app shows now. It stays the way in if this phone is lost.") |
|
| 109 | + | OutlinedTextField( |
|
| 110 | + | value = code, |
|
| 111 | + | onValueChange = { code = it.filter(Char::isDigit).take(8) }, |
|
| 112 | + | label = { Text("Code") }, |
|
| 113 | + | singleLine = true, |
|
| 114 | + | keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword), |
|
| 115 | + | ) |
|
| 116 | + | error?.let { Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) } |
|
| 117 | + | } |
|
| 118 | + | }, |
|
| 119 | + | confirmButton = { |
|
| 120 | + | TextButton(onClick = { |
|
| 121 | + | working = true |
|
| 122 | + | error = null |
|
| 123 | + | scope.launch { |
|
| 124 | + | try { |
|
| 125 | + | val key = PhoneKey.create() |
|
| 126 | + | api.addAuthenticator(key, code) |
|
| 127 | + | on = true |
|
| 128 | + | asking = false |
|
| 129 | + | onChanged() |
|
| 130 | + | } catch (e: CancellationException) { |
|
| 131 | + | throw e |
|
| 132 | + | } catch (e: HubException) { |
|
| 133 | + | PhoneKey.delete() |
|
| 134 | + | error = e.message ?: "The hub refused it." |
|
| 135 | + | } catch (e: Exception) { |
|
| 136 | + | PhoneKey.delete() |
|
| 137 | + | error = "This phone could not make a key: ${e.message ?: e::class.simpleName}. A screen lock is needed." |
|
| 138 | + | } finally { |
|
| 139 | + | working = false |
|
| 140 | + | } |
|
| 141 | + | } |
|
| 142 | + | }, enabled = code.length >= 6 && !working) { Text("Add") } |
|
| 143 | + | }, |
|
| 144 | + | dismissButton = { TextButton(onClick = { asking = false }, enabled = !working) { Text("Cancel") } }, |
|
| 145 | + | ) |
|
| 146 | + | } |
|
| 147 | + | } |
|
| 148 | + | ||
| 149 | + | /** |
|
| 150 | + | * Approvals for this phone, while the app is in front (#32): "Is this |
|
| 151 | + | * you?", the three numbers, and No. Picking a number asks for the |
|
| 152 | + | * fingerprint, face or PIN, then signs. |
|
| 153 | + | */ |
|
| 154 | + | @Composable |
|
| 155 | + | fun ApprovalSheet(vm: ApprovalsViewModel) { |
|
| 156 | + | val owner = LocalLifecycleOwner.current |
|
| 157 | + | LaunchedEffect(owner) { owner.repeatOnLifecycle(Lifecycle.State.RESUMED) { vm.watch() } } |
|
| 158 | + | val context = LocalContext.current |
|
| 159 | + | val waiting by vm.waiting.collectAsStateWithLifecycle() |
|
| 160 | + | val busy by vm.busy.collectAsStateWithLifecycle() |
|
| 161 | + | val said by vm.said.collectAsStateWithLifecycle() |
|
| 162 | + | LaunchedEffect(said) { |
|
| 163 | + | said?.let { |
|
| 164 | + | Toast.makeText(context, it, Toast.LENGTH_LONG).show() |
|
| 165 | + | vm.shown() |
|
| 166 | + | } |
|
| 167 | + | } |
|
| 168 | + | val a = waiting ?: return |
|
| 169 | + | AlertDialog( |
|
| 170 | + | onDismissRequest = {}, |
|
| 171 | + | title = { Text(if (a.kind == "signin") "Is this you signing in?" else "Is this you?") }, |
|
| 172 | + | text = { |
|
| 173 | + | Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { |
|
| 174 | + | Text(a.what) |
|
| 175 | + | Text("Pick the number your browser shows.", color = MaterialTheme.colorScheme.onSurfaceVariant) |
|
| 176 | + | Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically) { |
|
| 177 | + | a.choices.forEach { n -> |
|
| 178 | + | FilledTonalButton( |
|
| 179 | + | onClick = { |
|
| 180 | + | vm.approve(a, n) { message -> |
|
| 181 | + | PhoneKey.sign(context, "Approve on OxygenHub", a.what, message) |
|
| 182 | + | } |
|
| 183 | + | }, |
|
| 184 | + | enabled = !busy, |
|
| 185 | + | ) { Text("$n", fontSize = 22.sp, fontWeight = FontWeight.Bold) } |
|
| 186 | + | } |
|
| 187 | + | } |
|
| 188 | + | } |
|
| 189 | + | }, |
|
| 190 | + | confirmButton = {}, |
|
| 191 | + | dismissButton = { TextButton(onClick = { vm.deny(a) }, enabled = !busy) { Text("No, it is not me") } }, |
|
| 192 | + | ) |
|
| 193 | + | } |
added app/src/test/java/io/oxygenhub/android/secondfactor/ApprovalsViewModelTest.kt +82 −0
| 1 | + | package io.oxygenhub.android.secondfactor |
|
| 2 | + | ||
| 3 | + | import io.oxygenhub.android.FakeHubApi |
|
| 4 | + | import io.oxygenhub.android.MainDispatcherRule |
|
| 5 | + | import io.oxygenhub.android.api.HubException |
|
| 6 | + | import io.oxygenhub.android.api.PendingApproval |
|
| 7 | + | import kotlinx.coroutines.test.runTest |
|
| 8 | + | import org.junit.Assert.assertEquals |
|
| 9 | + | import org.junit.Assert.assertNull |
|
| 10 | + | import org.junit.Assert.assertTrue |
|
| 11 | + | import org.junit.Rule |
|
| 12 | + | import org.junit.Test |
|
| 13 | + | ||
| 14 | + | class ApprovalsViewModelTest { |
|
| 15 | + | @get:Rule val main = MainDispatcherRule() |
|
| 16 | + | ||
| 17 | + | private val signIn = PendingApproval("ab12", "signin", "Sign in from Firefox on Linux, 203.0.113.9", listOf(12, 47, 83)) |
|
| 18 | + | ||
| 19 | + | @Test |
|
| 20 | + | fun theMessageIsTheOneTheHubChecks() { |
|
| 21 | + | // oxygenhub's auth::phone::message, byte for byte. |
|
| 22 | + | assertEquals("oxygenhub approval v1\nab12\n47\napprove", ApprovalMessage.of("ab12", 47, true).decodeToString()) |
|
| 23 | + | assertEquals("oxygenhub approval v1\nab12\n47\ndeny", ApprovalMessage.of("ab12", 47, false).decodeToString()) |
|
| 24 | + | assertEquals("00ff10", byteArrayOf(0, -1, 16).toHex()) |
|
| 25 | + | } |
|
| 26 | + | ||
| 27 | + | @Test |
|
| 28 | + | fun aWaitingApprovalIsShownAndApprovingSignsTheNumberPicked() = runTest { |
|
| 29 | + | val api = FakeHubApi() |
|
| 30 | + | api.approvals = { listOf(signIn) } |
|
| 31 | + | val vm = ApprovalsViewModel(api) |
|
| 32 | + | vm.check() |
|
| 33 | + | assertEquals(signIn, vm.waiting.value) |
|
| 34 | + | var signed: String? = null |
|
| 35 | + | vm.approve(signIn, 47) { m -> signed = m.decodeToString(); byteArrayOf(1, 2) } |
|
| 36 | + | assertEquals("oxygenhub approval v1\nab12\n47\napprove", signed) |
|
| 37 | + | assertEquals(listOf(Triple("ab12", true, 47L)), api.answers) |
|
| 38 | + | assertTrue(api.calls.contains("answer ab12 true 47 true")) |
|
| 39 | + | assertNull("answered: not shown again", vm.waiting.value) |
|
| 40 | + | assertEquals("Approved: the browser is signing in.", vm.said.value) |
|
| 41 | + | vm.check() |
|
| 42 | + | assertNull("still listed until the hub catches up, but not shown again", vm.waiting.value) |
|
| 43 | + | } |
|
| 44 | + | ||
| 45 | + | @Test |
|
| 46 | + | fun backingOutOfThePromptSendsNothing() = runTest { |
|
| 47 | + | val api = FakeHubApi() |
|
| 48 | + | api.approvals = { listOf(signIn) } |
|
| 49 | + | val vm = ApprovalsViewModel(api) |
|
| 50 | + | vm.check() |
|
| 51 | + | vm.approve(signIn, 47) { null } |
|
| 52 | + | assertTrue(api.answers.isEmpty()) |
|
| 53 | + | assertEquals(signIn, vm.waiting.value) |
|
| 54 | + | assertTrue(vm.said.value!!.startsWith("Not approved")) |
|
| 55 | + | } |
|
| 56 | + | ||
| 57 | + | @Test |
|
| 58 | + | fun aWrongNumberAndANoAreSaidPlainly() = runTest { |
|
| 59 | + | val api = FakeHubApi() |
|
| 60 | + | api.approvals = { listOf(signIn) } |
|
| 61 | + | api.answerState = { n -> if (n == 47L) "approved" else "denied" } |
|
| 62 | + | val vm = ApprovalsViewModel(api) |
|
| 63 | + | vm.check() |
|
| 64 | + | vm.approve(signIn, 12) { byteArrayOf(1) } |
|
| 65 | + | assertTrue(vm.said.value!!.contains("not the number")) |
|
| 66 | + | val other = signIn.copy(id = "cd34") |
|
| 67 | + | api.approvals = { listOf(other) } |
|
| 68 | + | vm.check() |
|
| 69 | + | vm.deny(other) |
|
| 70 | + | assertEquals(Triple("cd34", false, 0L), api.answers.last()) |
|
| 71 | + | assertTrue(vm.said.value!!.startsWith("Refused")) |
|
| 72 | + | } |
|
| 73 | + | ||
| 74 | + | @Test |
|
| 75 | + | fun aHubThatCannotSayLeavesNothingShown() = runTest { |
|
| 76 | + | val api = FakeHubApi() |
|
| 77 | + | api.approvals = { throw HubException.Http(404, "no such API route") } |
|
| 78 | + | val vm = ApprovalsViewModel(api) |
|
| 79 | + | vm.check() |
|
| 80 | + | assertNull(vm.waiting.value) |
|
| 81 | + | } |
|
| 82 | + | } |
modified CHANGELOG.md +4 −0
| 1 | 1 | # Changelog |
|
| 2 | 2 | ||
| 3 | + | ## Unreleased |
|
| 4 | + | ||
| 5 | + | - **This phone as your second factor (#32, oxygenhub#356):** Account › Second factor › *Use this phone as a second factor* makes a P-256 key in the Android Keystore that signs only after your fingerprint, face or PIN, and registers it with the hub (with a current code from your authenticator app, which stays the way in if the phone is lost). Signing in on the web, or confirming a sensitive change there, then asks the phone: while the app is open it shows "Is this you?" with three numbers, you pick the one the browser shows, confirm, and the browser carries on; "No, it is not me" refuses. Where the hub sends alerts, an approval wakes the phone and a tap opens the app. Needs OxygenHub with `/api/v1/user/approvals`. |
|
| 6 | + | ||
| 3 | 7 | ## 0.1.3 (2026-11-01) |
|
| 4 | 8 | ||
| 5 | 9 | - **The widget's foot (#31):** the OxygenHub mark and title are white on the dark widget, and a row of counts runs along the bottom: an envelope with your unread messages, proposals waiting for your review, and issues assigned to you (from OxygenHub's `GET /user/issues`, oxygenhub#359; a hub without it leaves that count out), each dimmed at zero and opening its screen (the one proposal or issue when there is just one), then, while the phone serves as an OxygenFlow runner, the job it runs, redrawn as that changes. The dashboard gains **Assigned to you**, the same issues, each opening. |
|
| … | 43 unchanged lines |
modified app/src/main/AndroidManifest.xml +2 −0
| … | 1 unchanged lines |
||
| 2 | 2 | <manifest xmlns:android="http://schemas.android.com/apk/res/android" |
|
| 3 | 3 | xmlns:tools="http://schemas.android.com/tools"> |
|
| 4 | 4 | ||
| 5 | + | <!-- The phone as a second factor (#32): the system prompt that opens its key. --> |
|
| 6 | + | <uses-permission android:name="android.permission.USE_BIOMETRIC" /> |
|
| 5 | 7 | <uses-permission android:name="android.permission.INTERNET" /> |
|
| 6 | 8 | <!-- The phone as an OxygenFlow runner (#8): a foreground service while it runs jobs, |
|
| 7 | 9 | its notification, and the network's state for the Wi-Fi rule. --> |
|
| … | 71 unchanged lines |
modified app/src/main/java/io/oxygenhub/android/alerts/PushAlerts.kt +8 −2
| … | 12 unchanged lines |
||
| 13 | 13 | import com.google.firebase.FirebaseOptions |
|
| 14 | 14 | import com.google.firebase.messaging.FirebaseMessaging |
|
| 15 | 15 | import io.oxygenhub.android.BuildConfig |
|
| 16 | + | import io.oxygenhub.android.MainActivity |
|
| 16 | 17 | import io.oxygenhub.android.R |
|
| 17 | 18 | import io.oxygenhub.android.api.HubApi |
|
| 18 | 19 | import kotlinx.coroutines.suspendCancellableCoroutine |
|
| … | 72 unchanged lines |
||
| 91 | 92 | }, |
|
| 92 | 93 | ) |
|
| 93 | 94 | val id = (alert.kind + alert.url).hashCode() |
|
| 94 | - | val open = Intent(Intent.ACTION_VIEW, Uri.parse(alert.url)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) |
|
| 95 | + | // An approval (#32) opens the app, which shows it with its numbers. |
|
| 96 | + | val open = if (alert.kind == "approval") { |
|
| 97 | + | Intent(context, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP) |
|
| 98 | + | } else { |
|
| 99 | + | Intent(Intent.ACTION_VIEW, Uri.parse(alert.url)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) |
|
| 100 | + | } |
|
| 95 | 101 | val tap = PendingIntent.getActivity(context, id, open, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) |
|
| 96 | 102 | val notification = Notification.Builder(context, CHANNEL) |
|
| 97 | 103 | .setSmallIcon(R.drawable.ic_launcher_monochrome) |
|
| 98 | - | .setContentTitle(alert.title.substringBefore(": ", alert.title)) |
|
| 104 | + | .setContentTitle(if (alert.kind == "approval") "Is this you? Open to approve" else alert.title.substringBefore(": ", alert.title)) |
|
| 99 | 105 | .setContentText(alert.title.substringAfter(": ", alert.title)) |
|
| 100 | 106 | .setStyle(Notification.BigTextStyle().bigText(alert.title.substringAfter(": ", alert.title))) |
|
| 101 | 107 | .setContentIntent(tap) |
|
| … | 5 unchanged lines |
modified app/src/main/java/io/oxygenhub/android/api/HubClient.kt +36 −0
| … | 56 unchanged lines |
||
| 57 | 57 | /** The open issues assigned to the caller, across repositories (#31, oxygenhub#359); a hub before it answers 404. */ |
|
| 58 | 58 | suspend fun userIssues(): List<IssueBrief> |
|
| 59 | 59 | ||
| 60 | + | /** This phone as a second factor (#32, oxygenhub#356): its Keystore key's SubjectPublicKeyInfo in hex and a current app code. */ |
|
| 61 | + | suspend fun addAuthenticator(publicKey: String, code: String): Authenticator |
|
| 62 | + | ||
| 63 | + | /** The account's phones that approve. */ |
|
| 64 | + | suspend fun authenticators(): List<Authenticator> |
|
| 65 | + | ||
| 66 | + | /** This phone stops approving. */ |
|
| 67 | + | suspend fun removeAuthenticator() |
|
| 68 | + | ||
| 69 | + | /** Approvals waiting for this phone's answer. */ |
|
| 70 | + | suspend fun approvals(): List<PendingApproval> |
|
| 71 | + | ||
| 72 | + | /** Answer an approval: to approve, the number picked and the key's signature (hex DER) over [io.oxygenhub.android.secondfactor.ApprovalMessage]. */ |
|
| 73 | + | suspend fun answerApproval(id: String, approve: Boolean, number: Long, signature: String): ApprovalState |
|
| 74 | + | ||
| 60 | 75 | /** Your sky's data, the dashboard backdrop's (#23); a hub before it answers 404. */ |
|
| 61 | 76 | suspend fun userSky(): UserSky |
|
| 62 | 77 | ||
| … | 315 unchanged lines |
||
| 378 | 393 | get(url("user", "proposals") { if (landable) addQueryParameter("landable", "true") }, UserProposals.serializer()) |
|
| 379 | 394 | ||
| 380 | 395 | override suspend fun userIssues(): List<IssueBrief> = get(url("user", "issues"), ListSerializer(IssueBrief.serializer())) |
|
| 396 | + | ||
| 397 | + | override suspend fun addAuthenticator(publicKey: String, code: String): Authenticator = |
|
| 398 | + | sendJson("POST", url("user", "authenticators"), buildJsonObject { |
|
| 399 | + | put("public_key", publicKey) |
|
| 400 | + | put("code", code) |
|
| 401 | + | }, Authenticator.serializer()) |
|
| 402 | + | ||
| 403 | + | override suspend fun authenticators(): List<Authenticator> = get(url("user", "authenticators"), ListSerializer(Authenticator.serializer())) |
|
| 404 | + | ||
| 405 | + | override suspend fun removeAuthenticator() { |
|
| 406 | + | send("DELETE", url("user", "authenticators")) |
|
| 407 | + | } |
|
| 408 | + | ||
| 409 | + | override suspend fun approvals(): List<PendingApproval> = get(url("user", "approvals"), ListSerializer(PendingApproval.serializer())) |
|
| 410 | + | ||
| 411 | + | override suspend fun answerApproval(id: String, approve: Boolean, number: Long, signature: String): ApprovalState = |
|
| 412 | + | sendJson("POST", url("user", "approvals", id), buildJsonObject { |
|
| 413 | + | put("approve", approve) |
|
| 414 | + | put("number", number) |
|
| 415 | + | put("signature", signature) |
|
| 416 | + | }, ApprovalState.serializer()) |
|
| 381 | 417 | ||
| 382 | 418 | override suspend fun proposals(owner: String, name: String, state: ProposalState): List<Proposal> = |
|
| 383 | 419 | get(url("repos", owner, name, "proposals") { addQueryParameter("state", state.query) }, ListSerializer(Proposal.serializer())) |
|
| … | 123 unchanged lines |
modified app/src/main/java/io/oxygenhub/android/api/Models.kt +27 −0
| … | 326 unchanged lines |
||
| 327 | 327 | } |
|
| 328 | 328 | } |
|
| 329 | 329 | ||
| 330 | + | /** A phone that approves for its account (oxygenhub#356, #32). */ |
|
| 331 | + | @Serializable |
|
| 332 | + | data class Authenticator( |
|
| 333 | + | val id: Long, |
|
| 334 | + | val name: String = "", |
|
| 335 | + | @SerialName("created_ms") val createdMs: Long = 0, |
|
| 336 | + | @SerialName("last_used_ms") val lastUsedMs: Long = 0, |
|
| 337 | + | /** This phone. */ |
|
| 338 | + | @SerialName("this") val isThis: Boolean = false, |
|
| 339 | + | ) |
|
| 340 | + | ||
| 341 | + | /** An approval waiting for this phone: a sign-in or a sensitive change on the web. */ |
|
| 342 | + | @Serializable |
|
| 343 | + | data class PendingApproval( |
|
| 344 | + | val id: String, |
|
| 345 | + | /** `signin` or `stepup`. */ |
|
| 346 | + | val kind: String = "", |
|
| 347 | + | val what: String = "", |
|
| 348 | + | /** The three numbers to offer; the browser shows one of them. */ |
|
| 349 | + | val choices: List<Long> = emptyList(), |
|
| 350 | + | @SerialName("created_ms") val createdMs: Long = 0, |
|
| 351 | + | @SerialName("expires_ms") val expiresMs: Long = 0, |
|
| 352 | + | ) |
|
| 353 | + | ||
| 354 | + | @Serializable |
|
| 355 | + | data class ApprovalState(val state: String = "") |
|
| 356 | + | ||
| 330 | 357 | /** An issue in a list across repositories (`GET /user/issues`, oxygenhub#359). */ |
|
| 331 | 358 | @Serializable |
|
| 332 | 359 | data class IssueBrief( |
|
| … | 222 unchanged lines |
modified app/src/main/java/io/oxygenhub/android/ui/account/AccountScreen.kt +2 −0
| … | 43 unchanged lines |
||
| 44 | 44 | import io.oxygenhub.android.runner.RunnerCard |
|
| 45 | 45 | import io.oxygenhub.android.ui.common.AppScreen |
|
| 46 | 46 | import io.oxygenhub.android.ui.common.AvatarImage |
|
| 47 | + | import io.oxygenhub.android.secondfactor.SecondFactorCard |
|
| 47 | 48 | import io.oxygenhub.android.ui.common.Panel |
|
| 48 | 49 | import io.oxygenhub.android.ui.theme.Appearance |
|
| 49 | 50 | import io.oxygenhub.android.ui.theme.Tones |
|
| … | 21 unchanged lines |
||
| 71 | 72 | Text(session.username, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) |
|
| 72 | 73 | } |
|
| 73 | 74 | LandAlertsCard() |
|
| 75 | + | SecondFactorCard(api) |
|
| 74 | 76 | RunnerCard(api, session.hub, session.canWrite) |
|
| 75 | 77 | UpdateCard(api, session.hub) |
|
| 76 | 78 | AppearanceCard() |
|
| … | 67 unchanged lines |
modified app/src/main/java/io/oxygenhub/android/ui/nav/AppNavigation.kt +7 −0
| … | 89 unchanged lines |
||
| 90 | 90 | import io.oxygenhub.android.ui.releases.SearchViewModel |
|
| 91 | 91 | import io.oxygenhub.android.ui.repos.NewRepositoryScreen |
|
| 92 | 92 | import io.oxygenhub.android.ui.repos.NewRepositoryViewModel |
|
| 93 | + | import io.oxygenhub.android.secondfactor.ApprovalSheet |
|
| 94 | + | import io.oxygenhub.android.secondfactor.ApprovalsViewModel |
|
| 95 | + | import io.oxygenhub.android.secondfactor.PhoneKey |
|
| 93 | 96 | import io.oxygenhub.android.ui.repos.RepositoriesScreen |
|
| 94 | 97 | import io.oxygenhub.android.ui.repos.RepositoriesViewModel |
|
| 95 | 98 | import io.oxygenhub.android.ui.repos.RepositoryScreen |
|
| … | 309 unchanged lines |
||
| 405 | 408 | composable<InboxRoute> { InboxScreen(inbox, onOpen = openLink) } |
|
| 406 | 409 | composable<AccountRoute> { AccountScreen(session, rememberAvatar(api, session.username), api, nativeCore, onSignOut) } |
|
| 407 | 410 | } |
|
| 411 | + | // Approvals for this phone, when it is a second factor (#32). |
|
| 412 | + | LaunchedEffect(Unit) { PhoneKey.refresh() } |
|
| 413 | + | val approves by PhoneKey.present.collectAsStateWithLifecycle() |
|
| 414 | + | if (approves) ApprovalSheet(rememberViewModel { ApprovalsViewModel(api) }) |
|
| 408 | 415 | LaunchedEffect(open) { |
|
| 409 | 416 | open?.let { |
|
| 410 | 417 | nav.navigate(it) { launchSingleTop = true } |
|
| … | 17 unchanged lines |
modified app/src/test/java/io/oxygenhub/android/FakeHubApi.kt +17 −0
| … | 194 unchanged lines |
||
| 195 | 195 | override suspend fun repoSky(owner: String, name: String) = |
|
| 196 | 196 | userSky.invoke().copy(repos = listOf("$owner/$name")).also { calls += "sky $owner/$name" } |
|
| 197 | 197 | override suspend fun userIssues() = userIssues.invoke().also { calls += "user issues" } |
|
| 198 | + | ||
| 199 | + | var approvals: () -> List<io.oxygenhub.android.api.PendingApproval> = { emptyList() } |
|
| 200 | + | var answers = mutableListOf<Triple<String, Boolean, Long>>() |
|
| 201 | + | var answerState: (Long) -> String = { "approved" } |
|
| 202 | + | var authenticators: () -> List<io.oxygenhub.android.api.Authenticator> = { emptyList() } |
|
| 203 | + | var addAuthenticator: (String, String) -> io.oxygenhub.android.api.Authenticator = { _, _ -> io.oxygenhub.android.api.Authenticator(1, "phone", isThis = true) } |
|
| 204 | + | override suspend fun addAuthenticator(publicKey: String, code: String) = addAuthenticator.invoke(publicKey, code).also { calls += "add authenticator" } |
|
| 205 | + | override suspend fun authenticators() = authenticators.invoke().also { calls += "authenticators" } |
|
| 206 | + | override suspend fun removeAuthenticator() { |
|
| 207 | + | calls += "remove authenticator" |
|
| 208 | + | } |
|
| 209 | + | override suspend fun approvals() = approvals.invoke().also { calls += "approvals" } |
|
| 210 | + | override suspend fun answerApproval(id: String, approve: Boolean, number: Long, signature: String): io.oxygenhub.android.api.ApprovalState { |
|
| 211 | + | calls += "answer $id $approve $number ${signature.isNotEmpty()}" |
|
| 212 | + | answers += Triple(id, approve, number) |
|
| 213 | + | return io.oxygenhub.android.api.ApprovalState(if (approve) answerState(number) else "denied") |
|
| 214 | + | } |
|
| 198 | 215 | override suspend fun userProposals(landable: Boolean) = |
|
| 199 | 216 | userProposals.invoke().also { calls += if (landable) "user proposals, landable" else "user proposals" } |
|
| 200 | 217 | override suspend fun proposals(owner: String, name: String, state: ProposalState): List<Proposal> = |
|
| … | 25 unchanged lines |