Implement Android TOTP and confirmed HOTP presentation (#107)
Android ARM verification / rust (push) Failing after 49m4s
Android ARM verification / android (push) Failing after 9s
Dependency security audit / rustsec (push) Successful in 21s

This commit is contained in:
hugo committed 2026-10-12 11:43:23 +02:00
1 parent d466b170ab
commit 6a864a8d7f
26 files changed
+1938 -94

No files matched your search

@@ -99,23 +99,16 @@ fun LazyListScope.entryViewer(state: PasswordState, repository: PasswordReposito
}
}
state.notice?.let { item { Text(it) } }
state.totp?.let { totp ->
state.otp?.let { code ->
item {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(stringResource(R.string.entry_totp), Modifier.semantics { heading() })
Text(totp.account)
Text(totp.code, style = MaterialTheme.typography.headlineMedium)
Text(
pluralStringResource(
R.plurals.entry_countdown,
totp.remainingSeconds.toInt(),
totp.remainingSeconds.toLong()
)
)
TextButton(enabled = !state.loading, onClick = repository::copyTotp) {
Text(stringResource(R.string.entry_copy_code))
}
}
OtpCodeView(
code,
state.loading,
repository::copyOtp,
repository::refreshOtp,
repository::advanceHotp,
repository::tickOtp
)
}
}
state.sections?.forEach { section ->
@@ -0,0 +1,159 @@
package de.rfc1437.ironstorage.features
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.TextAutoSize
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.SecureFlagPolicy
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
import de.rfc1437.ironstorage.core.PasswordOtp
import de.rfc1437.ironstorage.core.PasswordOtpKind
import kotlinx.coroutines.delay
@Composable
internal fun OtpCodeView(
code: PasswordOtp,
busy: Boolean,
copy: () -> Unit,
refresh: () -> Unit,
advance: () -> Unit,
tick: () -> Unit
) {
val lifecycle = LocalLifecycleOwner.current.lifecycle
LaunchedEffect(lifecycle, code.kind) {
if (code.kind == PasswordOtpKind.Totp) {
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
tick()
while (true) {
delay(1000L - Math.floorMod(System.currentTimeMillis(), 1000L))
tick()
}
}
}
}
var confirmation by remember(code.nextCounter) { mutableStateOf(false) }
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
stringResource(
if (code.kind ==
PasswordOtpKind.Totp
) {
R.string.entry_totp
} else {
R.string.otp_hotp
}
),
Modifier.semantics { heading() }
)
code.issuer?.let { Text(it, style = MaterialTheme.typography.titleLarge) }
Text(code.account)
code.groupedCode?.let { value ->
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) {
Text(
value,
Modifier.fillMaxWidth().testTag("otp-code"),
style = MaterialTheme.typography.displayMedium,
fontFamily = FontFamily.Monospace,
maxLines = 2,
autoSize = TextAutoSize.StepBased(minFontSize = 24.sp, maxFontSize = 48.sp)
)
}
}
?: Text(
stringResource(
if (code.kind ==
PasswordOtpKind.Hotp
) {
R.string.otp_hotp_pending
} else {
R.string.otp_waiting
}
)
)
val period = code.period
val remaining = code.remainingSeconds
if (code.kind == PasswordOtpKind.Totp && period != null && remaining != null) {
LinearProgressIndicator(progress = {
(
remaining.toFloat() /
period.toFloat()
).coerceIn(0f, 1f)
}, modifier = Modifier.fillMaxWidth())
Text(
pluralStringResource(
R.plurals.otp_countdown,
remaining.coerceAtMost(Int.MAX_VALUE.toULong()).toInt(),
remaining.toString()
)
)
}
if (code.kind == PasswordOtpKind.Hotp &&
code.code != null
) {
Text(stringResource(R.string.otp_counter, code.counter.toString()))
}
FlowRow {
TextButton(enabled = !busy && code.code != null, onClick = copy) {
Text(stringResource(R.string.entry_copy_code))
}
TextButton(enabled = !busy, onClick = refresh) {
Text(stringResource(R.string.otp_refresh))
}
if (code.kind ==
PasswordOtpKind.Hotp
) {
TextButton(enabled = !busy && code.nextCounter != null, onClick = {
confirmation =
true
}) { Text(stringResource(R.string.otp_generate_hotp)) }
}
}
}
if (confirmation) {
ProtectedWindow()
AlertDialog(
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
onDismissRequest = { confirmation = false },
title = { Text(stringResource(R.string.otp_generate_hotp)) },
text = { Text(stringResource(R.string.otp_confirm_hotp, code.nextCounter.toString())) },
confirmButton = {
TextButton(enabled = !busy, onClick = {
confirmation = false
advance()
}) { Text(stringResource(R.string.otp_generate_hotp)) }
},
dismissButton = {
TextButton(onClick = {
confirmation = false
}) { Text(stringResource(R.string.auth_cancel)) }
}
)
}
}
@@ -200,6 +200,10 @@ private fun ShellDestination(
Modifier.fillMaxSize(),
leaveOnDispose = false
)
ready && route == ShellRoute.Totp -> {
LaunchedEffect(viewModel) { viewModel.openTotp() }
TotpScreen(viewModel.totp, viewModel.authentication)
}
ready && route == ShellRoute.Home -> HomeScreen(
viewModel.home,
Modifier.fillMaxSize(),
@@ -15,6 +15,7 @@ import de.rfc1437.ironstorage.core.SettingsRepository
import de.rfc1437.ironstorage.core.ShellRepository
import de.rfc1437.ironstorage.core.ShellState
import de.rfc1437.ironstorage.core.ShellTab
import de.rfc1437.ironstorage.core.TotpRepository
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -30,6 +31,7 @@ class ShellViewModel @Inject constructor(
val authentication: AuthenticationRepository,
val editor: EditorRepository,
val settings: SettingsRepository,
val totp: TotpRepository,
private val saved: SavedStateHandle
) : ViewModel() {
val snapshot = repository.snapshot
@@ -71,6 +73,37 @@ class ShellViewModel @Inject constructor(
}
}
}
viewModelScope.launch {
totp.state.collect { saved["totp.detail"] = it.detail }
}
viewModelScope.launch {
totp.viewer.state.collect { value ->
val selected = value.selected
if (!value.loading && value.error == null && selected != null) {
saved["totp.selection"] = selected.id
saved["totp.revision"] = selected.revision
}
}
}
}
private var totpRestored = false
fun openTotp() {
if (!totpRestored) {
totpRestored = true
totp.enter(
if (saved.get<Boolean>("totp.detail") ==
true
) {
saved["totp.selection"]
} else {
null
},
saved["totp.revision"]
)
} else {
val selected = totp.viewer.state.value.selected.takeIf { totp.state.value.detail }
totp.enter(selected?.id, selected?.revision)
}
}
private var passwordsRestored = false
fun openSearch(row: PasswordRow) {
@@ -116,7 +149,7 @@ class ShellViewModel @Inject constructor(
settings.cancel()
if (authentication.state.value.busy) authentication.cancel()
}
ShellTab.Totp -> Unit
ShellTab.Totp -> totp.leave()
}
}
override fun onCleared() {
@@ -0,0 +1,192 @@
package de.rfc1437.ironstorage.features
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.rfc1437.ironstorage.core.AuthenticationFailureKind
import de.rfc1437.ironstorage.core.AuthenticationRepository
import de.rfc1437.ironstorage.core.TotpRepository
@Composable
fun TotpScreen(repository: TotpRepository, authentication: AuthenticationRepository) {
val state by repository.state.collectAsStateWithLifecycle()
val entry by repository.viewer.state.collectAsStateWithLifecycle()
val lease by authentication.state.collectAsStateWithLifecycle()
var manual by remember { mutableStateOf(false) }
var discovery by remember { mutableStateOf(false) }
var requested by remember { mutableStateOf(false) }
LifecycleEventEffect(Lifecycle.Event.ON_STOP) { repository.leave() }
BackHandler(state.detail) { repository.back() }
androidx.compose.runtime.LaunchedEffect(lease.failure, state.loading, entry.loading) {
if (requested && !state.loading && !entry.loading && !lease.busy) {
requested = false
if (lease.failure?.kind == AuthenticationFailureKind.PassphraseRequired ||
lease.failure?.kind == AuthenticationFailureKind.BiometryUnavailable
) {
manual = true
}
}
}
val requestCatalog = {
discovery = true
requested = true
repository.discover("")
}
val requestUnlock = {
discovery = false
requested = true
repository.viewer.unlock("")
}
BoxWithConstraints(Modifier.fillMaxSize()) {
val expanded = maxWidth >= 600.dp
Row(Modifier.fillMaxSize()) {
if (expanded || !state.detail) {
LazyColumn(
Modifier.weight(1f).padding(16.dp).testTag("totp-list"),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
item {
Text(
stringResource(R.string.entry_totp),
style = MaterialTheme.typography.headlineMedium
)
TextButton(
enabled = !state.loading && !lease.busy,
onClick = requestCatalog
) {
Text(stringResource(R.string.totp_refresh_catalog))
}
if (state.loading) {
CircularProgressIndicator()
TextButton(onClick = repository::cancel) {
Text(stringResource(R.string.auth_cancel))
}
}
state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
if (!state.hasCatalog) {
Text(stringResource(R.string.totp_needs_catalog))
} else if (state.rows.isEmpty()) {
Text(stringResource(R.string.totp_empty))
}
}
items(state.rows, key = { it.id }) { row ->
Column(
Modifier.fillMaxWidth().clickable(
enabled = !state.loading,
role = Role.Button,
onClick = {
repository.open(row.id, row.revision)
}
).padding(vertical = 12.dp)
) {
Text(row.title, style = MaterialTheme.typography.titleMedium)
Text(row.detail)
}
}
}
}
if (expanded || state.detail) {
LazyColumn(
Modifier.weight(1f).padding(16.dp).testTag("totp-detail"),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
item {
if (state.detail) {
TextButton(onClick = repository::back) {
Text(stringResource(R.string.totp_back))
}
}
entry.selected?.let {
Text(it.title, style = MaterialTheme.typography.headlineSmall)
}
if (entry.loading) CircularProgressIndicator()
entry.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
if (!state.detail) {
Text(stringResource(R.string.totp_choose))
} else if (entry.stale) {
Text(stringResource(R.string.totp_stale))
} else if (entry.selected != null &&
(!lease.unlocked || entry.sections == null)
) {
Button(
enabled = !entry.loading && !lease.busy,
onClick = requestUnlock
) {
Text(stringResource(R.string.password_unlock))
}
TextButton(enabled = !lease.busy, onClick = {
discovery = false
manual =
true
}) { Text(stringResource(R.string.auth_manual)) }
if (lease.busy) {
TextButton(onClick = authentication::cancel) {
Text(stringResource(R.string.auth_cancel))
}
}
} else if (lease.unlocked && entry.sections != null) {
entry.otp?.let {
OtpCodeView(
it,
entry.loading,
repository.viewer::copyOtp,
repository.viewer::refreshOtp,
repository.viewer::advanceHotp,
repository.viewer::tickOtp
)
}
?: Text(stringResource(R.string.totp_unavailable))
TextButton(onClick = repository.viewer::lock) {
Text(stringResource(R.string.password_lock))
}
entry.notice?.let { Text(it) }
}
}
}
}
}
}
if (manual) {
PassphraseDialog(
stringResource(R.string.auth_manual),
stringResource(R.string.auth_manual_detail),
presentation = authentication.presentation,
onCancel = {
manual = false
repository.cancel()
authentication.cancel()
},
onSubmit = {
manual = false
requested = true
if (discovery) repository.discover(it) else repository.viewer.unlock(it)
}
)
}
}
@@ -235,4 +235,22 @@
<string name="qr_export_private_confirm">I checked this fingerprint and authorize displaying its private QR codes.</string>
<string name="qr_animate">Automatically advance frames</string>
<string name="qr_setup_import">Prepare the scanned key</string>
<string name="otp_hotp">HOTP</string>
<string name="otp_waiting">Waiting for a current code.</string>
<string name="otp_hotp_pending">Generate a code to consume the next HOTP counter.</string>
<string name="otp_refresh">Refresh code</string>
<string name="otp_generate_hotp">Generate next HOTP code</string>
<string name="otp_confirm_hotp">This consumes counter %1$s and saves the incremented counter in the encrypted entry. Continue?</string>
<string name="otp_counter">Generated for counter %1$s</string>
<plurals name="otp_countdown">
<item quantity="one">%1$s second remaining</item>
<item quantity="other">%1$s seconds remaining</item>
</plurals>
<string name="totp_refresh_catalog">Refresh TOTP catalog</string>
<string name="totp_needs_catalog">Refresh the catalog after unlocking to find TOTP entries.</string>
<string name="totp_empty">No TOTP entries found.</string>
<string name="totp_choose">Choose a TOTP entry.</string>
<string name="totp_unavailable">This entry no longer has a TOTP code.</string>
<string name="totp_stale">This entry changed. Refresh the catalog and select it again.</string>
<string name="totp_back">Back to TOTP entries</string>
</resources>