feat(android): add structured entry editor and encrypted draft recovery (#103)
Android ARM verification / rust (push) Failing after 46m30s
Android ARM verification / android (push) Failing after 8s
Dependency security audit / rustsec (push) Successful in 22s

This commit is contained in:
hugo committed 2026-10-09 22:11:37 +02:00
1 parent d22d71eb5e
commit 06575363a2
23 files changed
+2330 -21

No files matched your search

@@ -143,9 +143,14 @@ class NativeEntryViewerTest {
assertFalse(authentication.state.value.unlocked)
val selected = request {
repository.openSearch(result.id, result.revision)
assertTrue(repository.state.value.loading)
assertEquals("", repository.state.value.revision)
assertNull(repository.state.value.selected)
val immediate = repository.state.value
assertNull(immediate.sections)
if (immediate.loading) {
assertEquals("", immediate.revision)
assertNull(immediate.selected)
} else {
assertEquals(result.id, immediate.selected?.id)
}
}
assertEquals(result.id, selected.selected?.id)
assertNull(selected.sections)
@@ -228,6 +233,7 @@ class NativeEntryViewerTest {
}
request { repository.reveal(pin.id) }
assertNull(repository.state.value.sections)
withTimeout(10000) { authentication.state.first { !it.unlocked } }
assertFalse(authentication.state.value.unlocked)
val changed = request { repository.restore("destination", null, null) }
request { repository.activate(changed.rows.single().id, changed.revision) }
@@ -237,11 +243,136 @@ class NativeEntryViewerTest {
withTimeout(10000) { repository.state.first { it.sections == null } }
assertNull(repository.state.value.totp)
assertFalse(authentication.state.value.unlocked)
authentication.authenticated { it.setAuthenticationTimeout(300u) }
structuredEditorCreatesMutatesSavesAndRecoversEncryptedDrafts(
authentication,
repository,
scope,
password
)
} finally {
withContext(Dispatchers.Main.immediate) { repository.lock() }
scope.cancel()
}
}
private suspend fun structuredEditorCreatesMutatesSavesAndRecoversEncryptedDrafts(
authentication: NativeAuthenticationRepository,
passwords: NativePasswordRepository,
scope: CoroutineScope,
passphrase: String
) {
// The native private root is intentionally immutable for the life of this test process.
val editor = NativeEditorRepository(authentication, passwords, scope)
suspend fun request(action: () -> Unit): EditorState {
withContext(Dispatchers.Main.immediate) { action() }
return withTimeout(30000) { editor.state.first { !it.busy } }
}
try {
assertNull(request { editor.createFolder("", "editor") }.error)
assertNull(request { editor.create("editor", "new", passphrase) }.error)
val first = editor.state.value.fields.first().id
request { editor.change(first, null, "initial password") }
request { editor.add(EditorFieldKind.Field, "custom", "Unicode 日本語") }
request { editor.add(EditorFieldKind.Field, "custom", "repeated") }
request { editor.add(EditorFieldKind.Field, "PIN", "123493") }
request { editor.add(EditorFieldKind.Note, null, "first note\nsecond note") }
request {
editor.add(
EditorFieldKind.OtpUri,
null,
"otpauth://totp/Example:alice?secret=JBSWY3DPEHPK3PXP&issuer=Example"
)
}
assertNull(editor.state.value.error)
assertTrue(editor.state.value.fields.single { it.name == "PIN" }.sensitive)
val custom = editor.state.value.fields.first { it.name == "custom" }.id
request { editor.reorder(custom, 2u) }
assertEquals(custom, editor.state.value.fields[2].id)
request { editor.add(EditorFieldKind.Field, "temporary", "remove me") }
val temporary = editor.state.value.fields.single { it.name == "temporary" }.id
request { editor.remove(temporary) }
assertTrue(editor.state.value.fields.none { it.id == temporary })
request { editor.generate(24u, true) }
val generated = requireNotNull(editor.state.value.fields.first().value)
assertEquals(24, generated.length)
assertTrue(generated.all { it.isLetterOrDigit() && it.code < 128 })
assertNull(request { editor.save() }.error)
assertFalse(editor.state.value.open)
withContext(Dispatchers.Main.immediate) { passwords.restore("editor", null, null) }
val navigation = withTimeout(30000) { passwords.state.first { !it.loading } }
val row = navigation.rows.single { it.title == "new" }
withContext(Dispatchers.Main.immediate) {
passwords.activate(row.id, navigation.revision)
}
withTimeout(30000) { passwords.state.first { !it.loading } }
withContext(Dispatchers.Main.immediate) { passwords.unlock(passphrase) }
withTimeout(30000) { passwords.state.first { !it.loading } }
assertNotNull(passwords.state.value.sections)
assertNull(request { editor.begin("editor/new") }.error)
assertNull(passwords.state.value.sections)
assertEquals(generated, editor.state.value.fields.first().value)
assertEquals(2, editor.state.value.fields.count { it.name == "custom" })
assertEquals(
"first note\nsecond note",
editor.state.value.fields.single {
it.kind ==
EditorFieldKind.Note
}.value
)
assertEquals("123493", editor.state.value.fields.single { it.name == "PIN" }.value)
val field = editor.state.value.fields.first { it.name == "custom" }
request { editor.change(field.id, "bad\nname", "retained after validation") }
assertNotNull(request { editor.save() }.error)
assertEquals(
"retained after validation",
editor.state.value.fields.single {
it.id ==
field.id
}.value
)
request { editor.change(field.id, "custom", "retained after validation") }
assertNull(request { editor.save() }.error)
request { editor.begin("editor/new") }
val passwordId = editor.state.value.fields.first().id
request { editor.change(passwordId, null, "in-memory recovered secret") }
withContext(Dispatchers.Main.immediate) {
authentication.lifecycle(AuthenticationLifecycle.Background)
}
assertTrue(editor.state.value.locked)
assertTrue(editor.state.value.fields.isEmpty())
assertFalse(authentication.state.value.unlocked)
assertNull(request { editor.resume(passphrase) }.error)
assertEquals("in-memory recovered secret", editor.state.value.fields.first().value)
assertTrue(editor.state.value.dirty)
var navigated = false
request { editor.requestClose { navigated = true } }
assertTrue(editor.state.value.discardRequested)
assertFalse(navigated)
request { editor.keepEditing() }
assertTrue(editor.state.value.open)
assertFalse(navigated)
request { editor.requestClose { navigated = true } }
request { editor.confirmDiscard() }
withContext(Dispatchers.Main.immediate) { assertTrue(navigated) }
request { editor.begin("editor/new") }
assertEquals(
"discard cannot change the stored entry",
generated,
editor.state.value.fields.first().value
)
authentication.authenticated { it.setAuthenticationTimeout(2u) }
withTimeout(10000) { editor.state.first { it.locked } }
assertTrue(editor.state.value.fields.isEmpty())
assertNull(request { editor.resume(passphrase) }.error)
authentication.authenticated { it.setAuthenticationTimeout(300u) }
request { editor.requestClose() }
if (editor.state.value.discardRequested) request { editor.confirmDiscard() }
assertFalse(editor.state.value.open)
} finally {
withContext(Dispatchers.Main.immediate) { authentication.lock() }
}
}
private class MemoryClipboard : SecretClipboard {
override fun beginCopy(): SecretClipboard = this
@@ -0,0 +1,54 @@
package de.rfc1437.ironstorage.core
import kotlinx.coroutines.flow.StateFlow
enum class EditorFieldKind { Password, Field, Note, OtpUri }
data class EditorField(
val id: ULong,
val kind: EditorFieldKind,
val name: String?,
val label: String,
val value: String?,
val maskedValue: String,
val diagnostic: String?,
val sensitive: Boolean,
val multiline: Boolean,
val nameEditable: Boolean,
val removable: Boolean,
val reorderable: Boolean
) {
override fun toString() = "EditorField(id=$id, [REDACTED])"
}
data class EditorState(
val open: Boolean = false,
val locked: Boolean = false,
val busy: Boolean = false,
val title: String = "Entry editor",
val path: String = "",
val fields: List<EditorField> = emptyList(),
val dirty: Boolean = false,
val defaultLength: UInt = 0u,
val maximumLength: UInt = 0u,
val error: String? = null,
val discardRequested: Boolean = false,
val completedOperations: Long = 0
) {
override fun toString() = "EditorState(open=$open, locked=$locked, [REDACTED])"
}
interface EditorRepository {
val state: StateFlow<EditorState>
fun processRecreated()
fun begin(path: String)
fun create(parent: String, name: String, passphrase: String = "")
fun createFolder(parent: String, name: String)
fun change(id: ULong, name: String?, value: String?)
fun add(kind: EditorFieldKind, name: String?, value: String)
fun remove(id: ULong)
fun reorder(id: ULong, index: UInt)
fun generate(length: UInt?, noSymbols: Boolean)
fun save()
fun resume(passphrase: String)
fun requestClose(after: () -> Unit = {})
fun keepEditing()
fun confirmDiscard()
}
@@ -116,6 +116,19 @@ class NativeAuthenticationRepository(
}
}
}
internal suspend fun checkpointEditor(
editor: ULong,
version: ULong,
fields: List<de.rfc1437.ironstorage.bridge.generated.MobileEntryEditorInput>
) = withContext(io) {
(current ?: throw cancelled()).checkpointEntryEditorInputs(editor, version, fields)
}
internal suspend fun discardEditor(editor: ULong) = withContext(io) {
current?.discardEntryEditor(editor)
Unit
}
override fun setAppearance(appearance: Appearance) = run {
it.setMobileAppearance(
when (appearance) {
@@ -0,0 +1,332 @@
package de.rfc1437.ironstorage.core
import de.rfc1437.ironstorage.bridge.NativeBridgeException
import de.rfc1437.ironstorage.bridge.generated.MobileAuthenticationFfiException
import de.rfc1437.ironstorage.bridge.generated.MobileEntryEditorFieldKind
import de.rfc1437.ironstorage.bridge.generated.MobileEntryEditorInput
import de.rfc1437.ironstorage.bridge.generated.MobileEntryEditorPage
import de.rfc1437.ironstorage.bridge.generated.MobileEntryEditorSession
import de.rfc1437.ironstorage.bridge.generated.MobilePrivateStorageFfiException
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/** Frontend operation/input state only; Rust owns documents and encrypted recovery checkpoints. */
class NativeEditorRepository(
private val authentication: NativeAuthenticationRepository,
private val passwords: PasswordRepository,
private val scope: CoroutineScope
) : EditorRepository {
private val mutable = MutableStateFlow(EditorState())
override val state: StateFlow<EditorState> = mutable
private val operation = Mutex()
@Volatile private var epoch = 0L
@Volatile private var version = 0uL
@Volatile private var checkpoint: ULong? = null
private var pending: Job? = null
private var afterClose: () -> Unit = {}
private var retry: (suspend (String) -> MobileEntryEditorSession)? = null
init {
val registration = authentication.presentation.register {
if (mutable.value.open) {
mutable.value = mutable.value.copy(locked = true, fields = emptyList())
}
}
scope.coroutineContext[Job]?.invokeOnCompletion { registration.close() }
}
override fun processRecreated() {
scope.launch {
val notice = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
de.rfc1437.ironstorage.bridge.generated.mobileEditorProcessRecreatedNotice()
}
if (!mutable.value.open) mutable.value = mutable.value.copy(error = notice)
}
}
override fun begin(path: String) {
if (mutable.value.open) return
epoch += 1
authentication.presentation.clear()
mutable.value = EditorState(open = true, path = path)
retry = { passphrase ->
authentication.authenticated {
it.unlockEntry(path, passphrase.ifEmpty { null })
it.beginEntryEditor(path).also { session ->
checkpoint = session.id
it.checkpointEntryEditor(session.id, ++version)
}
}
}
resume("")
}
override fun create(parent: String, name: String, passphrase: String) {
if (mutable.value.open) return
epoch += 1
authentication.presentation.clear()
mutable.value = EditorState(open = true)
retry = { secret ->
authentication.authenticated {
it.beginCreateEntry(parent, name, secret.ifEmpty { null }).also { session ->
checkpoint = session.id
it.checkpointEntryEditor(session.id, ++version)
}
}
}
resume(passphrase)
}
override fun createFolder(parent: String, name: String) = run(protectedResult = false) {
authentication.authenticated { it.createDirectory(parent, name) }
passwords.restore(passwords.state.value.path, null, null)
passwords.state.first { !it.loading }
EditorState()
}
override fun resume(passphrase: String) = run {
pending?.join()
val id = checkpoint
if (id == null) {
requireNotNull(retry)(passphrase).page.facade()
} else {
val recovered = authentication.authenticated {
it.resumeEntryEditor(id, passphrase.ifEmpty { null })
}
val inputs = recovered.inputs?.associateBy { it.id }
recovered.session.page.facade().let { page ->
page.copy(
fields = page.fields.map { field ->
val input = inputs?.get(field.id)
if (input ==
null
) {
field
} else {
field.copy(name = input.name, value = input.value)
}
},
dirty = page.dirty || inputs != null
)
}
}
}
override fun change(id: ULong, name: String?, value: String?) {
val current = mutable.value
if (!current.open || current.locked || current.busy) return
if (current.fields.none { it.id == id }) return
mutable.value = current.copy(
fields = current.fields.map {
if (it.id ==
id
) {
it.copy(name = name, value = value)
} else {
it
}
},
dirty = true,
error = null
)
val editor = checkpoint ?: return
val fields = inputs()
val ticket = epoch
val inputVersion = ++version
pending?.cancel()
pending = scope.launch {
try {
authentication.checkpointEditor(editor, inputVersion, fields)
} catch (error: CancellationException) {
throw error
} catch (error: MobileAuthenticationFfiException.Failed) {
if (ticket == epoch &&
inputVersion == version
) {
mutable.value =
mutable.value.copy(error = error.detail)
}
} catch (_: NativeBridgeException) {
if (ticket ==
epoch
) {
mutable.value =
mutable.value.copy(error = "Update the application and retry.")
}
}
}
}
private fun inputs() = mutable.value.fields.map {
MobileEntryEditorInput(it.id, it.name, it.value)
}
private fun modify(
action: (
de.rfc1437.ironstorage.bridge.generated.MobileAuthentication,
ULong
) -> MobileEntryEditorPage
) = run {
pending?.join()
val id = requireNotNull(checkpoint)
val fields = inputs()
authentication.touch()
authentication.authenticated {
it.updateEntryEditor(id, fields)
action(it, id).also { _ -> it.checkpointEntryEditor(id, ++version) }
}.facade()
}
override fun add(kind: EditorFieldKind, name: String?, value: String) = modify { auth, id ->
auth.addEntryEditorField(id, kind.native(), name, value)
}
override fun remove(id: ULong) =
modify { auth, editor -> auth.removeEntryEditorField(editor, id) }
override fun reorder(id: ULong, index: UInt) =
modify { auth, editor -> auth.reorderEntryEditorField(editor, id, index) }
override fun generate(length: UInt?, noSymbols: Boolean) =
modify { auth, editor -> auth.generateEntryEditorPassword(editor, length, noSymbols) }
override fun save() = run(protectedResult = false) {
pending?.join()
val id = requireNotNull(checkpoint)
val fields = inputs()
authentication.touch()
authentication.authenticated { it.saveEntryEditor(id, fields) }
checkpoint = null
retry = null
passwords.restore(passwords.state.value.path, null, null)
passwords.state.first { !it.loading }
EditorState()
}
override fun requestClose(after: () -> Unit) {
afterClose = after
if (mutable.value.dirty) {
mutable.value = mutable.value.copy(discardRequested = true)
} else {
confirmDiscard()
}
}
override fun keepEditing() {
afterClose = {}
mutable.value = mutable.value.copy(discardRequested = false)
}
override fun confirmDiscard() {
val after = afterClose
afterClose = {}
epoch += 1
val ticket = epoch
mutable.value = mutable.value.copy(busy = true, discardRequested = false)
scope.launch {
operation.withLock {
try {
pending?.join()
checkpoint?.let { authentication.discardEditor(it) }
if (ticket != epoch) return@withLock
checkpoint = null
retry = null
mutable.value = EditorState()
after()
} catch (error: MobileAuthenticationFfiException.Failed) {
if (ticket ==
epoch
) {
mutable.value =
mutable.value.copy(busy = false, error = error.detail)
}
} catch (_: NativeBridgeException) {
if (ticket ==
epoch
) {
mutable.value =
mutable.value.copy(
busy = false,
error = "Update the application and retry."
)
}
}
}
}
}
private fun run(protectedResult: Boolean = true, action: suspend () -> EditorState) {
if (mutable.value.busy) return
val ticket = epoch
mutable.value = mutable.value.copy(busy = true, error = null)
scope.launch {
operation.withLock {
if (ticket != epoch) return@withLock
try {
val result = action()
if (ticket == epoch &&
(!protectedResult || authentication.state.value.unlocked)
) {
mutable.value = result.copy(
completedOperations = mutable.value.completedOperations + 1
)
}
} 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 = "Unlock the device and retry."
)
}
} catch (_: NativeBridgeException) {
if (ticket ==
epoch
) {
mutable.value =
mutable.value.copy(error = "Update the application and retry.")
}
} catch (_: java.io.IOException) {
if (ticket ==
epoch
) {
mutable.value =
mutable.value.copy(
error = "Storage unavailable. Keep the draft and retry."
)
}
} finally {
if (ticket == epoch) mutable.value = mutable.value.copy(busy = false)
}
}
}
}
}
private fun EditorFieldKind.native() = when (this) {
EditorFieldKind.Password -> MobileEntryEditorFieldKind.PASSWORD
EditorFieldKind.Field -> MobileEntryEditorFieldKind.FIELD
EditorFieldKind.Note -> MobileEntryEditorFieldKind.NOTE
EditorFieldKind.OtpUri -> MobileEntryEditorFieldKind.OTP_URI
}
private fun MobileEntryEditorPage.facade() = EditorState(
open = true,
title = title,
path = path,
fields = fields.map { field ->
EditorField(
field.id,
when (field.kind) {
MobileEntryEditorFieldKind.PASSWORD -> EditorFieldKind.Password
MobileEntryEditorFieldKind.FIELD -> EditorFieldKind.Field
MobileEntryEditorFieldKind.NOTE -> EditorFieldKind.Note
MobileEntryEditorFieldKind.OTP_URI -> EditorFieldKind.OtpUri
},
field.name, field.label, field.value, field.maskedValue, field.diagnostic,
field.sensitive, field.multiline, field.nameEditable, field.removable, field.reorderable
)
},
dirty = dirty,
defaultLength = defaultPasswordLength,
maximumLength = maximumPasswordLength
)