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
+14
-8
@@ -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
|
||||
|
||||
+187
@@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
-8
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+141
-40
@@ -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()
|
||||
}
|
||||
Reference in new issue
Block a user