Implement Android TOTP and confirmed HOTP presentation (#107)
This commit is contained in:
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>
|
||||
Reference in new issue
Block a user