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

@@ -24,9 +24,14 @@ import org.junit.Test
/** Actual encrypted Rust documents and Kotlin facade, with deterministic OS capabilities. */
class NativeEntryViewerTest {
companion object {
// Rust activates one private root per process; each test recreates this disposable root.
internal val rootName = "native-controller-${UUID.randomUUID()}"
}
@Test fun fieldsEditingCopyMutationsAndTotpExpiryUseTheNativeOwner(): Unit = runBlocking {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val name = "viewer-${UUID.randomUUID()}"
val name = rootName
val roots = listOf(context.filesDir, context.noBackupFilesDir, context.cacheDir).map {
File(it, name).apply { mkdirs() }.canonicalPath
}
@@ -168,8 +173,8 @@ class NativeEntryViewerTest {
assertTrue(sensitive.all { it.value == "Hidden" && !it.revealed && !it.selectable })
assertEquals(2, fields().count { it.label == "Custom" })
assertTrue(fields().any { it.value == "First line\nSecond line" && it.multiline })
assertNotNull(repository.state.value.totp)
assertTrue(repository.state.value.totp!!.remainingSeconds in 1uL..30uL)
assertNotNull(repository.state.value.otp)
assertTrue(repository.state.value.otp!!.remainingSeconds!! in 1uL..30uL)
for (event in listOf(
AuthenticationLifecycle.Background,
AuthenticationLifecycle.DeviceLocked,
@@ -184,7 +189,7 @@ class NativeEntryViewerTest {
authentication.lifecycle(event)
assertNull(repository.state.value.sections)
assertNull(repository.state.value.edit)
assertNull(repository.state.value.totp)
assertNull(repository.state.value.otp)
assertNull(clipboard.value)
assertFalse(authentication.state.value.unlocked)
}
@@ -227,7 +232,7 @@ class NativeEntryViewerTest {
request { repository.lock() }
assertNull(repository.state.value.sections)
assertNull(repository.state.value.edit)
assertNull(repository.state.value.totp)
assertNull(repository.state.value.otp)
withTimeout(10000) { while (clipboard.value != null) delay(20) }
request { repository.unlock(password) }
authentication.authenticated {
@@ -243,7 +248,7 @@ class NativeEntryViewerTest {
authentication.authenticated { it.setAuthenticationTimeout(2u) }
withTimeout(10000) { authentication.state.first { !it.unlocked } }
withTimeout(10000) { repository.state.first { it.sections == null } }
assertNull(repository.state.value.totp)
assertNull(repository.state.value.otp)
assertFalse(authentication.state.value.unlocked)
authentication.authenticated { it.setAuthenticationTimeout(300u) }
structuredEditorCreatesMutatesSavesAndRecoversEncryptedDrafts(
@@ -287,6 +292,7 @@ class NativeEntryViewerTest {
} finally {
withContext(Dispatchers.Main.immediate) { repository.lock() }
scope.cancel()
roots.forEach { assertTrue(File(it).deleteRecursively()) }
}
}
@@ -593,7 +599,7 @@ class NativeEntryViewerTest {
request { settings.setTimeout("300") }
}
private class MemoryClipboard : SecretClipboard {
internal class MemoryClipboard : SecretClipboard {
override fun beginCopy(): SecretClipboard = this
@Volatile var value: ByteArray? = null
@@ -605,7 +611,7 @@ class NativeEntryViewerTest {
value = null
}
}
private class MemoryKeystore : AndroidKeystore {
internal class MemoryKeystore : AndroidKeystore {
private val keys = mutableSetOf<String>()
val keyCount get() = keys.size
var invalidated = false
@@ -0,0 +1,187 @@
package de.rfc1437.ironstorage.core
import androidx.test.platform.app.InstrumentationRegistry
import de.rfc1437.ironstorage.bridge.generated.MobileClock
import de.rfc1437.ironstorage.bridge.generated.MobileEntryEditorFieldKind
import de.rfc1437.ironstorage.bridge.generated.MobileEntryEditorInput
import java.io.File
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicLong
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class NativeOtpPresentationTest {
@Test fun catalogCodesBoundariesHotpAndLifecycleUseRust(): Unit = runBlocking {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val name = NativeEntryViewerTest.rootName
val roots = listOf(context.filesDir, context.noBackupFilesDir, context.cacheDir).map {
File(it, name).apply { mkdirs() }.canonicalPath
}
val monotonic = AtomicLong(0)
val wall = AtomicLong(59)
val session = NativePrivateSession(context, {
PrivateDirectories(roots[0], roots[1], roots[2])
}, NativeEntryViewerTest.MemoryKeystore())
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
val memoryClipboard = NativeEntryViewerTest.MemoryClipboard()
var expireCopy = false
val clipboard = object : SecretClipboard by memoryClipboard {
override fun beginCopy(): SecretClipboard {
if (expireCopy) wall.set(60)
return memoryClipboard
}
}
val authentication =
NativeAuthenticationRepository(
session,
scope,
Dispatchers.IO,
object : MobileClock {
override fun monotonicMilliseconds() = monotonic.get().toULong()
}
)
val viewer = NativePasswordRepository(
session,
scope,
authentication,
Dispatchers.IO,
clipboard,
{ wall.get().toULong() }
)
val catalog = NativeTotpRepository(session, authentication, viewer, scope)
val passphrase = "public-otp-fixture"
suspend fun view(action: () -> Unit): PasswordState {
withContext(Dispatchers.Main.immediate) { action() }
return withTimeout(30000) { viewer.state.first { !it.loading } }
}
try {
withContext(Dispatchers.IO) {
session.initialize()
val setup = session.storage().androidOnboarding(session.backend())
val key = requireNotNull(setup.generate("OTP fixture", passphrase).selectedKey)
setup.setupLocal(key, passphrase)
}
authentication.authenticated { auth ->
for ((entry, uri) in listOf(
"timed" to
"otpauth://totp/RFC:alice?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&digits=8&issuer=RFC",
"counter" to
"otpauth://hotp/RFC:alice?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&counter=0&issuer=RFC"
)) {
val draft = auth.beginCreateEntry("", entry, passphrase)
val page = auth.addEntryEditorField(
draft.id,
MobileEntryEditorFieldKind.OTP_URI,
null,
uri
)
auth.saveEntryEditor(
draft.id,
page.fields.map {
MobileEntryEditorInput(it.id, it.name, it.value.orEmpty())
}
)
}
auth.setAuthenticationTimeout(2u)
}
withContext(Dispatchers.Main.immediate) {
authentication.lock()
catalog.enter(null, null)
}
val cached = withTimeout(30000) { catalog.state.first { !it.loading } }
assertNull(cached.error)
assertTrue(cached.hasCatalog)
assertEquals(listOf("timed"), cached.rows.map { it.title })
assertFalse(authentication.state.value.unlocked)
assertNull(viewer.state.value.otp)
val row = cached.rows.single()
view { catalog.open(row.id, row.revision) }
assertNull(viewer.state.value.otp)
val opened = view { viewer.unlock(passphrase) }
assertNull(opened.error)
assertEquals("9428 7082", requireNotNull(opened.otp).groupedCode)
assertEquals(1uL, requireNotNull(opened.otp).remainingSeconds)
val entered = CountDownLatch(1)
val release = CountDownLatch(1)
val observation = scope.async {
runCatching {
authentication.observe { auth ->
val value = auth.state()
entered.countDown()
assertTrue(release.await(10, TimeUnit.SECONDS))
value
}
}
}
withContext(Dispatchers.IO) { assertTrue(entered.await(10, TimeUnit.SECONDS)) }
val userAction = scope.async { authentication.authenticated { it.state() } }
withTimeout(10000) { authentication.state.first { it.busy } }
release.countDown()
assertTrue(observation.await().isFailure)
assertTrue(userAction.await().unlocked)
assertTrue(authentication.state.value.unlocked)
view { viewer.copyOtp() }
withTimeout(10000) { while (memoryClipboard.value == null) delay(20) }
assertEquals("94287082", memoryClipboard.value!!.toString(Charsets.UTF_8))
withContext(Dispatchers.Main.immediate) { memoryClipboard.clear() }
expireCopy = true
val rejectedCopy = view { viewer.copyOtp() }
assertTrue(rejectedCopy.error != null)
assertNull(memoryClipboard.value)
expireCopy = false
wall.set(60)
monotonic.set(1000)
withContext(Dispatchers.Main.immediate) { viewer.tickOtp() }
withTimeout(30000) { viewer.state.first { it.otp?.remainingSeconds == 30uL } }
assertEquals(30uL, viewer.state.value.otp!!.remainingSeconds)
assertNotEquals("94287082", viewer.state.value.otp!!.code)
monotonic.set(2000)
withTimeout(10000) {
authentication.state.first { !it.unlocked }
}
assertFalse(authentication.state.value.unlocked)
assertNull(memoryClipboard.value)
view { viewer.restore("", null, null) }
val counter = viewer.state.value.rows.single { it.title == "counter" }
view { viewer.activate(counter.id, viewer.state.value.revision) }
val pending = view { viewer.unlock(passphrase) }
assertEquals(PasswordOtpKind.Hotp, requireNotNull(pending.otp).kind)
assertNull(requireNotNull(pending.otp).code)
assertEquals(1uL, requireNotNull(pending.otp).nextCounter)
val generated = view { viewer.advanceHotp() }
assertNull(generated.error)
assertEquals("287 082", requireNotNull(generated.otp).groupedCode)
assertEquals(1uL, requireNotNull(generated.otp).counter)
view { viewer.refreshOtp() }
view { viewer.copyOtp() }
assertEquals(1uL, viewer.state.value.otp!!.counter)
val next = view { viewer.advanceHotp() }
assertEquals("359152", requireNotNull(next.otp).code)
assertEquals(2uL, requireNotNull(next.otp).counter)
withContext(Dispatchers.Main.immediate) {
authentication.lifecycle(AuthenticationLifecycle.Background)
assertNull(viewer.state.value.otp)
assertNull(viewer.state.value.sections)
assertNull(memoryClipboard.value)
}
} finally {
withContext(Dispatchers.Main.immediate) { scope.cancel() }
roots.forEach { File(it).deleteRecursively() }
}
}
}
@@ -26,7 +26,10 @@ import kotlinx.coroutines.withContext
class NativeAuthenticationRepository(
private val session: NativePrivateSession,
private val scope: CoroutineScope,
private val io: CoroutineDispatcher
private val io: CoroutineDispatcher,
private val clock: MobileClock = object : MobileClock {
override fun monotonicMilliseconds(): ULong = SystemClock.elapsedRealtime().toULong()
}
) : AuthenticationRepository {
internal fun text(id: Int) = session.text(id)
@@ -38,9 +41,6 @@ class NativeAuthenticationRepository(
private var revocation: Job? = null
@Volatile private var current: MobileAuthentication? = null
private val clock = object : MobileClock {
override fun monotonicMilliseconds(): ULong = SystemClock.elapsedRealtime().toULong()
}
init {
presentation.onWindowFocusLost = { lifecycle(AuthenticationLifecycle.Background) }
scope.launch {
@@ -69,9 +69,17 @@ class NativeAuthenticationRepository(
}
internal suspend fun <T> authenticated(block: (MobileAuthentication) -> T): T =
access(true, block)
internal suspend fun <T> observe(block: (MobileAuthentication) -> T): T? =
withContext(kotlinx.coroutines.Dispatchers.Main.immediate) {
val ticket = sequence.incrementAndGet()
mutable.value = mutable.value.copy(busy = true, failure = null)
if (mutable.value.busy) null else access(false, block)
}
private suspend fun <T> access(showBusy: Boolean, block: (MobileAuthentication) -> T): T =
withContext(kotlinx.coroutines.Dispatchers.Main.immediate) {
val ticket = if (showBusy) sequence.incrementAndGet() else sequence.get()
if (showBusy) mutable.value = mutable.value.copy(busy = true, failure = null)
operation.withLock {
revocation?.join()
if (ticket != sequence.get()) throw cancelled(session)
@@ -81,7 +89,7 @@ class NativeAuthenticationRepository(
if (ticket != sequence.get()) throw cancelled(session)
val result = block(authentication)
if (ticket != sequence.get()) {
authentication.manualLock()
if (showBusy) authentication.manualLock()
throw cancelled(session)
}
result
@@ -114,7 +122,11 @@ class NativeAuthenticationRepository(
if (ticket == sequence.get()) unavailable()
throw error
} finally {
if (ticket == sequence.get()) mutable.value = mutable.value.copy(busy = false)
if (showBusy &&
ticket == sequence.get()
) {
mutable.value = mutable.value.copy(busy = false)
}
}
}
}
@@ -9,6 +9,8 @@ import de.rfc1437.ironstorage.bridge.generated.MobileClipboardWait
import de.rfc1437.ironstorage.bridge.generated.MobileEntryPresentation
import de.rfc1437.ironstorage.bridge.generated.MobileMutationAction
import de.rfc1437.ironstorage.bridge.generated.MobileMutationRequest
import de.rfc1437.ironstorage.bridge.generated.MobileOtpCodeKind
import de.rfc1437.ironstorage.bridge.generated.MobileOtpCodePresentation
import de.rfc1437.ironstorage.bridge.generated.MobilePasswordFfiException
import de.rfc1437.ironstorage.bridge.generated.MobilePasswordNavigation
import de.rfc1437.ironstorage.bridge.generated.MobilePasswordRowKind
@@ -16,6 +18,7 @@ import de.rfc1437.ironstorage.bridge.generated.MobilePlatformFfiException
import de.rfc1437.ironstorage.bridge.generated.MobilePrivateStorageFfiException
import de.rfc1437.ironstorage.bridge.generated.clearMobileEntryCopy
import de.rfc1437.ironstorage.bridge.generated.copyMobileEntryClearing
import de.rfc1437.ironstorage.bridge.generated.mobileOtpWindowCurrent
import java.util.concurrent.atomic.AtomicLong
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineDispatcher
@@ -24,6 +27,9 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
@@ -35,7 +41,8 @@ class NativePasswordRepository(
private val scope: CoroutineScope,
private val authentication: NativeAuthenticationRepository,
private val io: CoroutineDispatcher,
private val clipboard: SecretClipboard
private val clipboard: SecretClipboard,
private val unixSeconds: () -> ULong = ::unixSeconds
) : PasswordRepository {
private val mutable = MutableStateFlow(PasswordState())
override val state: StateFlow<PasswordState> = mutable
@@ -51,32 +58,77 @@ class NativePasswordRepository(
}
}
scope.coroutineContext[kotlinx.coroutines.Job]?.invokeOnCompletion { registration.close() }
// Hidden codes still expire; only the composed UI requests decryption/countdown refresh.
scope.launch {
while (true) {
delay(1000)
val current = mutable.value
if (current.totp != null && !current.loading &&
authentication.state.value.unlocked &&
!authentication.state.value.busy
) {
content(false) { current ->
val detail = authentication.authenticated { auth ->
auth.refreshEntryPresentation(
requireNotNull(current.selected).path,
requireNotNull(current.documentRevision),
unixSeconds()
).totp
}
current.copy(
totp = detail?.let {
PasswordTotp(it.code, it.account, it.remainingSeconds)
}
)
mutable.map { state ->
state.otp?.takeIf { it.kind == PasswordOtpKind.Totp && it.code != null }?.validUntil
}.distinctUntilChanged().collectLatest { end ->
if (end != null) {
while (true) {
delay(1000L - Math.floorMod(System.currentTimeMillis(), 1000L))
expireOtp()
}
}
}
}
}
private suspend fun expireOtp() {
val current = mutable.value
val code = current.otp ?: return
if (code.validUntil != null && code.period != null && code.code != null) {
val valid = withContext(io) {
mobileOtpWindowCurrent(code.validUntil, code.period, unixSeconds())
}
if (!valid &&
mutable.value.otp === code
) {
mutable.value = current.copy(otp = code.masked())
}
}
}
override fun tickOtp() {
if (mutable.value.otp?.kind != PasswordOtpKind.Totp) return
scope.launch {
expireOtp()
if (!mutex.isLocked && !mutable.value.loading &&
authentication.state.value.unlocked && !authentication.state.value.busy
) {
refreshOtp(false)
}
}
}
override fun refreshOtp() = refreshOtp(true)
private fun refreshOtp(userActivity: Boolean) = content(userActivity) { current ->
val code = authentication.observe { auth ->
auth.presentedOtpCode(
current.entryPath(),
requireNotNull(current.documentRevision),
unixSeconds(),
current.otp?.counter
)
}
if (code == null) current else current.copy(otp = code.toFacade())
}
override fun advanceHotp() = content { current ->
val code = authentication.authenticated { auth ->
auth.advancePresentedHotp(
current.entryPath(),
requireNotNull(current.documentRevision),
true
)
}
val page = authentication.authenticated { auth ->
auth.refreshEntryPresentation(current.entryPath(), code.documentRevision, unixSeconds())
}
val folder = session.storage().passwordNavigation(current.path, null, null, false)
val refreshed = session.storage().passwordNavigation(
current.path,
current.selected?.id,
folder.revision,
false
).toFacade()
if (refreshed.selected == null) refreshed else refreshed.present(page, code)
}
override fun restore(path: String, selected: String?, revision: String?) =
navigate(path, selected, revision, false)
override fun openSearch(id: String, revision: String) {
@@ -122,16 +174,18 @@ class NativePasswordRepository(
false
).toFacade()
if (verified.selected == null) return@run verified
val page = authentication.authenticated { auth ->
auth.navigationEntry(
val (page, code) = authentication.authenticated { auth ->
val page = auth.navigationEntry(
current.path,
selected.id,
current.revision,
passphrase.ifEmpty { null },
(System.currentTimeMillis() / 1000).toULong()
unixSeconds()
)
page to
auth.presentedOtpCode(selected.path, page.page.revision, unixSeconds(), null)
}
verified.present(page)
verified.present(page, code)
}
}
override fun leave() {
@@ -216,7 +270,11 @@ class NativePasswordRepository(
value
)
}
val page = authentication.authenticated { it.entryPage(current.entryPath(), unixSeconds()) }
val (page, code) = authentication.authenticated {
val page = it.entryPage(current.entryPath(), unixSeconds())
page to
it.presentedOtpCode(current.entryPath(), page.page.revision, unixSeconds(), null)
}
val folder = session.storage().passwordNavigation(current.path, null, null, false)
val refreshed = session.storage().passwordNavigation(
current.path,
@@ -224,27 +282,35 @@ class NativePasswordRepository(
folder.revision,
false
).toFacade()
if (refreshed.selected == null) refreshed else refreshed.present(page)
if (refreshed.selected == null) refreshed else refreshed.present(page, code)
}
override fun copy(id: ULong) = copyValue { auth, current ->
auth.copyPresentedEntryField(
current.entryPath(),
id,
requireNotNull(current.documentRevision)
)
) to null
}
override fun copyTotp() = copyValue { auth, current ->
auth.copyPresentedTotpCode(
override fun copyOtp() = copyValue { auth, current ->
val now = unixSeconds()
val code = auth.presentedOtpCode(
current.entryPath(),
requireNotNull(current.documentRevision),
unixSeconds()
now,
current.otp?.counter
)
auth.copyPresentedOtpCode(
current.entryPath(),
requireNotNull(current.documentRevision),
now,
current.otp?.counter
) to code
}
private fun copyValue(
get: (de.rfc1437.ironstorage.bridge.generated.MobileAuthentication, PasswordState) ->
de.rfc1437.ironstorage.bridge.generated.MobileEntryCopy
Pair<de.rfc1437.ironstorage.bridge.generated.MobileEntryCopy, MobileOtpCodePresentation?>
) = content { current ->
val copy = authentication.authenticated { get(it, current) }
val (copy, code) = authentication.authenticated { get(it, current) }
val ticket = sequence.get()
val owned = clipboard.beginCopy()
val written = CompletableDeferred<Boolean>()
@@ -258,6 +324,15 @@ class NativePasswordRepository(
if (!authentication.state.value.unlocked) {
throw MobilePlatformFfiException.Cancelled()
}
if (code?.kind == MobileOtpCodeKind.TOTP &&
!mobileOtpWindowCurrent(
requireNotNull(code.validUntil),
requireNotNull(code.period),
unixSeconds()
)
) {
throw MobilePlatformFfiException.Cancelled()
}
clipboardAccess { owned.write(value) }
written.complete(true)
Unit
@@ -372,19 +447,23 @@ class NativePasswordRepository(
return
}
if (userActivity) authentication.touch()
run(false) { action(current) }
run(false, userActivity) { action(current) }
}
private fun run(clear: Boolean = true, action: suspend () -> PasswordState) {
private fun run(
clear: Boolean = true,
showLoading: Boolean = true,
action: suspend () -> PasswordState
) {
val ticket = sequence.incrementAndGet()
mutable.value =
(if (clear) mutable.value.cleared() else mutable.value).copy(
loading = true,
loading = showLoading,
error = null
)
scope.launch {
mutex.withLock {
if (sequence.get() != ticket) return@withLock
val result = try {
var result = try {
withContext(io) { action() }
} catch (error: MobilePasswordFfiException.Failed) {
mutable.value.copy(error = error.detail, selected = null)
@@ -402,6 +481,17 @@ class NativePasswordRepository(
selected = null
)
}
val code = result.otp
if (code?.kind == PasswordOtpKind.Totp && code.code != null &&
code.validUntil != null &&
code.period != null
) {
val valid =
withContext(io) {
mobileOtpWindowCurrent(code.validUntil, code.period, unixSeconds())
}
if (!valid) result = result.copy(otp = code.masked())
}
if (sequence.get() == ticket) {
mutable.value =
(if (authentication.state.value.unlocked) result else result.cleared())
@@ -439,13 +529,16 @@ internal fun MobilePasswordNavigation.toFacade(): PasswordState = PasswordState(
private fun unixSeconds() = (System.currentTimeMillis() / 1000).toULong()
private fun PasswordState.cleared() = copy(
sections = null,
totp = null,
otp = null,
documentRevision = null,
edit = null,
mutation = null,
notice = null
)
private fun PasswordState.present(value: MobileEntryPresentation) = copy(
private fun PasswordState.present(
value: MobileEntryPresentation,
code: MobileOtpCodePresentation?
) = copy(
documentRevision = value.page.revision,
sections = value.page.sections.map { section ->
PasswordSection(
@@ -460,11 +553,19 @@ private fun PasswordState.present(value: MobileEntryPresentation) = copy(
}
)
},
totp = value.totp?.let { PasswordTotp(it.code, it.account, it.remainingSeconds) },
otp = code?.toFacade(),
notice = value.cacheNotice,
edit = null,
mutation = null
)
private fun PasswordOtp.masked() = copy(code = null, groupedCode = null, remainingSeconds = 0u)
private fun MobileOtpCodePresentation.toFacade() = PasswordOtp(
when (kind) {
MobileOtpCodeKind.TOTP -> PasswordOtpKind.Totp
MobileOtpCodeKind.HOTP -> PasswordOtpKind.Hotp
},
code, groupedCode, issuer, account, remainingSeconds, period, validUntil, counter, nextCounter
)
private fun PasswordMutation.native() = when (this) {
PasswordMutation.Move -> MobileMutationAction.MOVE
PasswordMutation.Copy -> MobileMutationAction.COPY
@@ -0,0 +1,131 @@
package de.rfc1437.ironstorage.core
import de.rfc1437.ironstorage.bridge.NativeBridgeException
import de.rfc1437.ironstorage.bridge.generated.MobileAuthenticationFfiException
import de.rfc1437.ironstorage.bridge.generated.MobilePrivateStorageFfiException
import de.rfc1437.ironstorage.bridge.generated.MobileTotpOperation
import de.rfc1437.ironstorage.bridge.generated.mobileTotpOperation
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
class NativeTotpRepository(
private val session: NativePrivateSession,
private val authentication: NativeAuthenticationRepository,
override val viewer: PasswordRepository,
private val scope: CoroutineScope
) : TotpRepository {
private val mutable = MutableStateFlow(TotpState())
override val state: StateFlow<TotpState> = mutable
private val serial = Mutex()
@Volatile private var epoch = 0L
@Volatile private var discovery: MobileTotpOperation? = null
init {
val cleanup = authentication.presentation.register(::cancel)
scope.coroutineContext[Job]?.invokeOnCompletion { cleanup.close() }
}
override fun enter(selected: String?, revision: String?) = load(selected, revision)
override fun reload() = load(null, null)
private fun load(selected: String?, revision: String?) = run { ticket ->
val rows = authentication.authenticated { it.cachedTotpNavigation() }
if (ticket == epoch) {
mutable.value =
mutable.value.copy(
rows = rows.orEmpty().map { it.toFacade() },
hasCatalog =
rows != null
)
if (selected != null && revision != null) open(selected, revision)
}
}
override fun open(id: String, revision: String) {
if (mutable.value.rows.none { it.id == id && it.revision == revision }) return
mutable.value = mutable.value.copy(detail = true, error = null)
viewer.openSearch(id, revision)
}
override fun back() {
viewer.leave()
mutable.value = mutable.value.copy(detail = false)
reload()
}
override fun discover(passphrase: String) = run { ticket ->
val active = withContext(Dispatchers.IO) { mobileTotpOperation() }
discovery = active
if (ticket != epoch) active.cancel()
try {
val rows = authentication.authenticated {
it.unlockTotp(passphrase.ifEmpty { null })
it.totpPage(active)
it.cachedTotpNavigation()
}
if (ticket ==
epoch
) {
mutable.value =
mutable.value.copy(
rows = rows.orEmpty().map { it.toFacade() },
hasCatalog =
rows != null
)
}
} finally {
discovery = null
active.close()
}
}
override fun cancel() {
epoch += 1
discovery?.cancel()
mutable.value = mutable.value.copy(loading = false)
}
override fun leave() {
cancel()
viewer.leave()
}
private fun run(action: suspend (Long) -> Unit) {
if (mutable.value.loading) return
val ticket = epoch
mutable.value = mutable.value.copy(loading = true, error = null)
scope.launch {
serial.withLock {
if (ticket != epoch) return@withLock
try {
action(ticket)
} catch (error: MobileAuthenticationFfiException.Failed) {
if (ticket ==
epoch
) {
mutable.value =
mutable.value.copy(error = error.title + "\n" + error.detail)
}
} catch (_: MobilePrivateStorageFfiException) {
if (ticket ==
epoch
) {
mutable.value =
mutable.value.copy(error = session.text(R.string.core_private_recovery))
}
} catch (_: NativeBridgeException) {
if (ticket ==
epoch
) {
mutable.value =
mutable.value.copy(error = session.text(R.string.core_update_retry))
}
} finally {
if (ticket == epoch) mutable.value = mutable.value.copy(loading = false)
}
}
}
}
}
@@ -28,8 +28,20 @@ data class PasswordField(
override fun toString() = "PasswordField(id=$id, [REDACTED])"
}
data class PasswordSection(val title: String, val fields: List<PasswordField>)
data class PasswordTotp(val code: String, val account: String, val remainingSeconds: ULong) {
override fun toString() = "PasswordTotp([REDACTED])"
enum class PasswordOtpKind { Totp, Hotp }
data class PasswordOtp(
val kind: PasswordOtpKind,
val code: String?,
val groupedCode: String?,
val issuer: String?,
val account: String,
val remainingSeconds: ULong?,
val period: ULong?,
val validUntil: ULong?,
val counter: ULong?,
val nextCounter: ULong?
) {
override fun toString() = "PasswordOtp([REDACTED])"
}
data class PasswordEdit(
val id: ULong,
@@ -65,7 +77,7 @@ data class PasswordState(
val stale: Boolean = false,
val error: String? = null,
val sections: List<PasswordSection>? = null,
val totp: PasswordTotp? = null,
val otp: PasswordOtp? = null,
val documentRevision: String? = null,
val notice: String? = null,
val edit: PasswordEdit? = null,
@@ -85,7 +97,10 @@ interface PasswordRepository {
fun reveal(id: ULong)
fun hide(id: ULong)
fun copy(id: ULong)
fun copyTotp()
fun copyOtp()
fun tickOtp() = Unit
fun refreshOtp()
fun advanceHotp()
fun edit(id: ULong)
fun dismissEdit()
fun saveEdit(value: String)
@@ -0,0 +1,24 @@
package de.rfc1437.ironstorage.core
import kotlinx.coroutines.flow.StateFlow
data class TotpState(
val rows: List<PasswordRow> = emptyList(),
val loading: Boolean = false,
val hasCatalog: Boolean = false,
val detail: Boolean = false,
val error: String? = null
) {
override fun toString() = "TotpState(loading=$loading, [REDACTED])"
}
interface TotpRepository {
val state: StateFlow<TotpState>
val viewer: PasswordRepository
fun enter(selected: String?, revision: String?)
fun reload()
fun open(id: String, revision: String)
fun back()
fun discover(passphrase: String)
fun cancel()
fun leave()
}