OXYGENHUB

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