From 06575363a2c0b1853e712d4518185914ab917cb1 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Fri, 9 Oct 2026 22:11:37 +0200 Subject: [PATCH] feat(android): add structured entry editor and encrypted draft recovery (#103) --- README.md | 2 + .../rfc1437/ironstorage/AdaptiveShellTest.kt | 73 +++ .../ironstorage/EntryEditorScreenTest.kt | 236 +++++++++ .../ironstorage/ProtectedTestContent.kt | 8 +- .../de/rfc1437/ironstorage/ShellModule.kt | 13 + .../ironstorage/core/NativeEntryViewerTest.kt | 137 +++++- .../ironstorage/core/EditorRepository.kt | 54 +++ .../core/NativeAuthenticationRepository.kt | 13 + .../core/NativeEditorRepository.kt | 332 +++++++++++++ .../ironstorage/features/EntryEditorScreen.kt | 457 ++++++++++++++++++ .../ironstorage/features/PasswordScreen.kt | 53 +- .../ironstorage/features/ShellScreen.kt | 30 +- .../ironstorage/features/ShellViewModel.kt | 12 + .../features/src/main/res/values/strings.xml | 30 ++ apple/Generated/ironstorage_mobile.swift | 139 ++++++ apple/Generated/ironstorage_mobileFFI.h | 45 ++ crates/mobile-bridge/src/lib.rs | 72 ++- crates/storage/src/document.rs | 90 +++- crates/storage/src/mobile_authentication.rs | 201 +++++++- crates/storage/src/mobile_entry.rs | 14 +- crates/storage/src/write.rs | 11 + crates/storage/tests/authentication_leases.rs | 268 ++++++++++ docs/android/entry-editor.md | 61 +++ 23 files changed, 2330 insertions(+), 21 deletions(-) create mode 100644 android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/EntryEditorScreenTest.kt create mode 100644 android/core/src/main/kotlin/de/rfc1437/ironstorage/core/EditorRepository.kt create mode 100644 android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativeEditorRepository.kt create mode 100644 android/features/src/main/kotlin/de/rfc1437/ironstorage/features/EntryEditorScreen.kt create mode 100644 docs/android/entry-editor.md diff --git a/README.md b/README.md index 0dda686..59f5e60 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,8 @@ Name-only Android search and its state/security policy are documented in [`docs/android/name-search.md`](docs/android/name-search.md). Adaptive navigation, appearance, safe restoration and shell validation are documented in [`docs/android/adaptive-shell.md`](docs/android/adaptive-shell.md). +Native entry creation, editing and encrypted draft recovery are documented in +[`docs/android/entry-editor.md`](docs/android/entry-editor.md). ## Project layout diff --git a/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/AdaptiveShellTest.kt b/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/AdaptiveShellTest.kt index 112339e..1bc8cc9 100644 --- a/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/AdaptiveShellTest.kt +++ b/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/AdaptiveShellTest.kt @@ -25,6 +25,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModelProvider import de.rfc1437.ironstorage.core.OnboardingRepository +import de.rfc1437.ironstorage.core.SetupStatus import de.rfc1437.ironstorage.core.ShellFixtures import de.rfc1437.ironstorage.core.ShellRepository import de.rfc1437.ironstorage.core.ShellSnapshot @@ -64,6 +65,7 @@ class AdaptiveShellTest { original.home, original.search, original.authentication, + original.editor, saved ) compose.activity.runOnUiThread { @@ -138,6 +140,7 @@ class AdaptiveShellTest { original.home, original.search, original.authentication, + original.editor, SavedStateHandle( saved.keys().associateWith { saved.get(it) @@ -162,6 +165,76 @@ class AdaptiveShellTest { compose.onNodeWithTag("shell-open-appearance").assertIsDisplayed() } + @Test fun processRecreationExplainsDiscardedDraftWithoutRestoringSecretState() { + val original = ViewModelProvider(compose.activity)[ShellViewModel::class.java] + val setup = ViewModelProvider(compose.activity)[SetupFormViewModel::class.java] + val saved = SavedStateHandle(mapOf("shell.tab" to "Passwords", "editor.wasOpen" to true)) + val model = ShellViewModel( + Fixture(), + original.onboarding, + original.passwords, + original.home, + original.search, + original.authentication, + original.editor, + saved + ) + compose.activity.runOnUiThread { + compose.activity.setProtectedTestContent { + ApplicationTheme(original.authentication) { ShellScreen(model, setup) } + } + } + compose.waitUntil(10000) { original.editor.state.value.error != null } + compose.onNodeWithText( + "An unsaved draft was discarded after the application process restarted. " + + "Unlock the entry to edit it again." + ).assertIsDisplayed() + compose.runOnIdle { + assertFalse(original.editor.state.value.open) + assertTrue(original.editor.state.value.fields.isEmpty()) + assertFalse(original.authentication.state.value.unlocked) + assertEquals(false, saved.get("editor.wasOpen")) + assertTrue(saved.keys().none { it.contains("draft") || it.contains("secret") }) + } + } + + @Test fun configuredEmptyStoreOffersEntryAndFolderCreation() { + val original = ViewModelProvider(compose.activity)[ShellViewModel::class.java] + val setup = ViewModelProvider(compose.activity)[SetupFormViewModel::class.java] + val repository = Fixture().apply { + snapshot.value = ShellSnapshot( + ShellState.Empty, + ShellFixtures.all.getValue(ShellState.Empty), + selectedTab = ShellTab.Passwords + ) + } + val onboarding = object : OnboardingRepository by original.onboarding { + override val setup = MutableStateFlow(SetupStatus(configured = true)) + } + val model = ShellViewModel( + repository, + onboarding, + original.passwords, + original.home, + original.search, + original.authentication, + original.editor, + SavedStateHandle(mapOf("shell.tab" to "Passwords")) + ) + compose.activity.runOnUiThread { + compose.activity.setProtectedTestContent { + ApplicationTheme(original.authentication) { ShellScreen(model, setup) } + } + } + compose.onNodeWithText("New entry").assertIsDisplayed().performClick() + compose.onNodeWithText("Create").assertIsDisplayed() + compose.onNodeWithText("Cancel").performClick() + compose.onNodeWithText("New folder").assertIsDisplayed().performClick() + compose.onNodeWithText("Create").assertIsDisplayed() + compose.onNodeWithText("Cancel").performClick() + compose.runOnIdle { assertFalse(original.editor.state.value.open) } + } + private class Fixture : ShellRepository { override val snapshot = MutableStateFlow( ShellSnapshot( diff --git a/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/EntryEditorScreenTest.kt b/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/EntryEditorScreenTest.kt new file mode 100644 index 0000000..76f4c2b --- /dev/null +++ b/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/EntryEditorScreenTest.kt @@ -0,0 +1,236 @@ +package de.rfc1437.ironstorage + +import android.os.Bundle +import android.os.Parcel +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.input.InputMode +import androidx.compose.ui.input.InputModeManager +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalInputModeManager +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.performScrollToNode +import androidx.compose.ui.test.performSemanticsAction +import androidx.compose.ui.test.performTextReplacement +import androidx.compose.ui.test.pressKey +import androidx.compose.ui.unit.Density +import androidx.test.platform.app.InstrumentationRegistry +import de.rfc1437.ironstorage.core.AuthenticationRepository +import de.rfc1437.ironstorage.core.AuthenticationState +import de.rfc1437.ironstorage.core.EditorField +import de.rfc1437.ironstorage.core.EditorFieldKind +import de.rfc1437.ironstorage.core.EditorRepository +import de.rfc1437.ironstorage.core.EditorState +import de.rfc1437.ironstorage.core.ProtectedPresentation +import de.rfc1437.ironstorage.features.EditorDiscardDialog +import de.rfc1437.ironstorage.features.EntryEditorScreen +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test + +class EntryEditorScreenTest { + @get:Rule val compose = createAndroidComposeRule() + + @Test fun structuredInputsGenerationAndAccessibleReorderForwardTypedIds() { + val repository = Fixture() + val lease = MutableStateFlow(AuthenticationState(unlocked = true)) + val presentation = ProtectedPresentation() + val authentication = object : AuthenticationRepository by compose.activity.authentication { + override val state = lease + override val presentation = presentation + override fun lock() { + lease.value = AuthenticationState() + presentation.clear() + } + } + lateinit var inputMode: InputModeManager + compose.activity.runOnUiThread { + compose.activity.setProtectedTestContent(presentation) { + inputMode = LocalInputModeManager.current + val density = LocalDensity.current + CompositionLocalProvider(LocalDensity provides Density(density.density, 2f)) { + MaterialTheme { + EntryEditorScreen(repository, authentication) + EditorDiscardDialog(repository) + } + } + } + } + fun field(tag: String) = compose.onNodeWithTag("entry-editor") + .performScrollToNode(hasTestTag(tag)).let { compose.onNodeWithTag(tag) } + fun control(text: String) = compose.onNodeWithTag("entry-editor") + .performScrollToNode(hasText(text)).let { compose.onNodeWithText(text) } + val password = field("editor-value-41") + assertTrue(password.fetchSemanticsNode().config.contains(SemanticsProperties.Password)) + password.performTextReplacement("public replacement secret") + assertEquals(41uL, repository.changedId) + assertEquals("public replacement secret", repository.state.value.fields.first().value) + field("editor-value-87").performTextReplacement("first\nsecond\n日本語") + assertEquals(87uL, repository.changedId) + field("editor-down-73").performClick() + assertEquals(73uL to 2u, repository.moved) + field("editor-remove-87").performClick() + assertEquals(87uL, repository.removed) + control("Generate password").performClick() + assertEquals(0, repository.generations) + compose.onNodeWithText("Password length").performTextReplacement("24") + compose.onNodeWithText("Generate replacement").performClick() + assertEquals(1, repository.generations) + assertEquals(24u, repository.length) + control("Add named field").performClick() + compose.onNodeWithTag("editor-add-name").performTextReplacement("custom") + compose.onNodeWithTag("editor-add-value").performTextReplacement("public value") + compose.onNodeWithText("Add field").performClick() + assertEquals(Triple(EditorFieldKind.Field, "custom", "public value"), repository.added) + control("Cancel").performClick() + compose.onNodeWithText("Discard unsaved changes?").assertIsDisplayed() + compose.onNodeWithText("Keep editing").performClick() + assertFalse(repository.state.value.discardRequested) + assertEquals("public replacement secret", repository.state.value.fields.first().value) + compose.activity.runOnUiThread { + compose.activity.onBackPressedDispatcher.onBackPressed() + } + compose.onNodeWithText("Discard unsaved changes?").assertIsDisplayed() + compose.onNodeWithText("Keep editing").performClick() + compose.runOnIdle { assertTrue(inputMode.requestInputMode(InputMode.Keyboard)) } + control("Save entry") + .performSemanticsAction(SemanticsActions.RequestFocus) { assertTrue(it()) } + .performKeyInput { pressKey(Key.Enter) } + assertEquals(1, repository.saves) + val draft = repository.state.value + compose.runOnIdle { + repository.state.value = + repository.state.value.copy(locked = true, fields = emptyList()) + presentation.clear() + } + compose.onNodeWithTag("editor-value-41").assertDoesNotExist() + compose.onNodeWithText("Unlock").assertIsDisplayed() + compose.runOnIdle { repository.state.value = draft } + field("editor-value-41").assertIsDisplayed() + compose.runOnIdle { + val saved = Bundle() + InstrumentationRegistry.getInstrumentation().callActivityOnSaveInstanceState( + compose.activity, + saved + ) + val parcel = Parcel.obtain() + try { + parcel.writeBundle(saved) + val bytes = parcel.marshall() + for (marker in repository.state.value.fields.mapNotNull { it.value }) { + assertFalse(bytes.toString(Charsets.UTF_8).contains(marker)) + assertFalse(bytes.toString(Charsets.UTF_16LE).contains(marker)) + } + } finally { + parcel.recycle() + } + } + } + + private class Fixture : EditorRepository { + override val state = MutableStateFlow( + EditorState( + open = true, + dirty = true, + defaultLength = 25u, + maximumLength = 4096u, + fields = listOf( + field( + 41uL, + EditorFieldKind.Password, + null, + "Password", + "public fixture secret", + false + ), + field(73uL, EditorFieldKind.Field, "custom", "Custom", "public field", true), + field( + 87uL, + EditorFieldKind.Note, + null, + "Notes", + "first note\nsecond note", + true + ) + ) + ) + ) + var changedId: ULong? = null + var moved: Pair? = null + var removed: ULong? = null + var added: Triple? = null + var generations = 0 + var length: UInt? = null + var saves = 0 + override fun processRecreated() = Unit + override fun begin(path: String) = Unit + override fun create(parent: String, name: String, passphrase: String) = Unit + override fun createFolder(parent: String, name: String) = Unit + override fun change(id: ULong, name: String?, value: String?) { + changedId = id + state.value = state.value.copy( + fields = state.value.fields.map { + if (it.id == id) it.copy(name = name, value = value) else it + } + ) + } + override fun add(kind: EditorFieldKind, name: String?, value: String) { + added = Triple(kind, name, value) + state.value = + state.value.copy(completedOperations = state.value.completedOperations + 1) + } + override fun remove(id: ULong) { + removed = id + } + override fun reorder(id: ULong, index: UInt) { + moved = id to index + } + override fun generate(length: UInt?, noSymbols: Boolean) { + generations += 1 + this.length = + length + } + override fun save() { + saves += 1 + } + override fun resume(passphrase: String) = Unit + override fun requestClose(after: () -> Unit) { + state.value = + state.value.copy(discardRequested = true) + } + override fun keepEditing() { + state.value = state.value.copy(discardRequested = false) + } + override fun confirmDiscard() { + state.value = EditorState() + } + companion object { + private fun field( + id: ULong, + kind: EditorFieldKind, + name: String?, + label: String, + value: String, + movable: Boolean + ) = EditorField( + id, kind, name, label, value, "••••••", null, + sensitive = true, multiline = kind == EditorFieldKind.Note, + nameEditable = kind == EditorFieldKind.Field, + removable = movable, reorderable = movable + ) + } + } +} diff --git a/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/ProtectedTestContent.kt b/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/ProtectedTestContent.kt index a0e0f25..d90d594 100644 --- a/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/ProtectedTestContent.kt +++ b/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/ProtectedTestContent.kt @@ -2,9 +2,13 @@ package de.rfc1437.ironstorage import androidx.activity.compose.setContent import androidx.compose.runtime.Composable +import de.rfc1437.ironstorage.core.ProtectedPresentation import de.rfc1437.ironstorage.features.ProtectedContent /** Keep the production surface and IME protections around substituted test screens. */ -internal fun MainActivity.setProtectedTestContent(content: @Composable () -> Unit) { - setContent { ProtectedContent(authentication.presentation, content) } +internal fun MainActivity.setProtectedTestContent( + presentation: ProtectedPresentation = authentication.presentation, + content: @Composable () -> Unit +) { + setContent { ProtectedContent(presentation, content) } } diff --git a/android/app/src/main/kotlin/de/rfc1437/ironstorage/ShellModule.kt b/android/app/src/main/kotlin/de/rfc1437/ironstorage/ShellModule.kt index 62730e9..2679411 100644 --- a/android/app/src/main/kotlin/de/rfc1437/ironstorage/ShellModule.kt +++ b/android/app/src/main/kotlin/de/rfc1437/ironstorage/ShellModule.kt @@ -7,8 +7,10 @@ import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import de.rfc1437.ironstorage.core.AuthenticationRepository +import de.rfc1437.ironstorage.core.EditorRepository import de.rfc1437.ironstorage.core.HomeRepository import de.rfc1437.ironstorage.core.NativeAuthenticationRepository +import de.rfc1437.ironstorage.core.NativeEditorRepository import de.rfc1437.ironstorage.core.NativeFixtureShellRepository import de.rfc1437.ironstorage.core.NativeHomeRepository import de.rfc1437.ironstorage.core.NativeOnboardingRepository @@ -30,6 +32,17 @@ import kotlinx.coroutines.SupervisorJob @Module @InstallIn(SingletonComponent::class) object ShellModule { + @Provides + @Singleton + fun editorRepository( + authentication: NativeAuthenticationRepository, + passwords: PasswordRepository + ): EditorRepository = NativeEditorRepository( + authentication, + passwords, + CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + ) + @Provides @Singleton fun searchRepository( diff --git a/android/core/src/androidTest/kotlin/de/rfc1437/ironstorage/core/NativeEntryViewerTest.kt b/android/core/src/androidTest/kotlin/de/rfc1437/ironstorage/core/NativeEntryViewerTest.kt index 873ec92..4633407 100644 --- a/android/core/src/androidTest/kotlin/de/rfc1437/ironstorage/core/NativeEntryViewerTest.kt +++ b/android/core/src/androidTest/kotlin/de/rfc1437/ironstorage/core/NativeEntryViewerTest.kt @@ -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 diff --git a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/EditorRepository.kt b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/EditorRepository.kt new file mode 100644 index 0000000..4d104b6 --- /dev/null +++ b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/EditorRepository.kt @@ -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 = 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 + 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() +} diff --git a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativeAuthenticationRepository.kt b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativeAuthenticationRepository.kt index 3740164..4ad8a1f 100644 --- a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativeAuthenticationRepository.kt +++ b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativeAuthenticationRepository.kt @@ -116,6 +116,19 @@ class NativeAuthenticationRepository( } } } + internal suspend fun checkpointEditor( + editor: ULong, + version: ULong, + fields: List + ) = 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) { diff --git a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativeEditorRepository.kt b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativeEditorRepository.kt new file mode 100644 index 0000000..f57e939 --- /dev/null +++ b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativeEditorRepository.kt @@ -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 = 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 +) diff --git a/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/EntryEditorScreen.kt b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/EntryEditorScreen.kt new file mode 100644 index 0000000..3bc9ab5 --- /dev/null +++ b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/EntryEditorScreen.kt @@ -0,0 +1,457 @@ +package de.rfc1437.ironstorage.features + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +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.LocalFocusManager +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogProperties +import androidx.compose.ui.window.SecureFlagPolicy +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import de.rfc1437.ironstorage.core.AuthenticationRepository +import de.rfc1437.ironstorage.core.EditorFieldKind +import de.rfc1437.ironstorage.core.EditorRepository + +@Composable +fun EntryEditorScreen(repository: EditorRepository, authentication: AuthenticationRepository) { + val state by repository.state.collectAsStateWithLifecycle() + val lease by authentication.state.collectAsStateWithLifecycle() + var manual by remember { mutableStateOf(false) } + var adding by remember { mutableStateOf(null) } + var generating by remember { mutableStateOf(false) } + var revealed by remember { mutableStateOf(emptySet()) } + val focus = LocalFocusManager.current + ProtectedInputSurface(authentication.presentation) + BackHandler { repository.requestClose() } + LazyColumn( + Modifier.fillMaxSize().imePadding().testTag("entry-editor"), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + item { + Text( + state.title, + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.semantics { + heading() + } + ) + Text(state.path) + FlowRow { + TextButton(onClick = { + repository.requestClose() + }) { Text(stringResource(R.string.editor_cancel)) } + Button( + enabled = + !state.busy && !state.locked && state.dirty && state.fields.isNotEmpty() && + !lease.busy, + onClick = { + focus.clearFocus() + repository.save() + } + ) { Text(stringResource(R.string.editor_save)) } + TextButton(enabled = !state.busy, onClick = { + repository.requestClose(authentication::lock) + }) { + Text(stringResource(R.string.password_lock)) + } + } + if (state.busy || lease.busy) CircularProgressIndicator() + state.error?.let { + Text( + it, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.testTag("editor-error") + ) + } + if (state.locked || state.fields.isEmpty()) { + Text(stringResource(R.string.editor_locked)) + Button(enabled = !state.busy && !lease.busy, onClick = { repository.resume("") }) { + Text(stringResource(R.string.password_unlock)) + } + TextButton(enabled = !state.busy && !lease.busy, onClick = { manual = true }) { + Text(stringResource(R.string.auth_manual)) + } + } else { + FlowRow { + TextButton(enabled = !state.busy, onClick = { + adding = EditorFieldKind.Field + }) { Text(stringResource(R.string.editor_add_field)) } + TextButton(enabled = !state.busy, onClick = { + repository.add(EditorFieldKind.Note, null, "") + }) { Text(stringResource(R.string.editor_add_notes)) } + TextButton(enabled = !state.busy, onClick = { + adding = EditorFieldKind.OtpUri + }) { Text(stringResource(R.string.editor_add_otp)) } + TextButton(enabled = !state.busy, onClick = { + generating = true + }) { Text(stringResource(R.string.editor_generate)) } + } + } + } + itemsIndexed(state.fields, key = { _, field -> field.id.toString() }) { index, field -> + Column( + Modifier.fillMaxWidth().widthIn(max = 800.dp).padding(4.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (field.nameEditable) { + OutlinedTextField( + value = field.name.orEmpty(), + onValueChange = { repository.change(field.id, it, field.value) }, + enabled = !state.busy && !lease.busy, + label = { Text(stringResource(R.string.editor_field_name)) }, + modifier = Modifier.fillMaxWidth().testTag("editor-name-${field.id}"), + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + keyboardActions = KeyboardActions(onNext = { + focus.moveFocus(androidx.compose.ui.focus.FocusDirection.Next) + }) + ) + } + val shown = field.id in revealed + OutlinedTextField( + value = field.value.orEmpty(), + onValueChange = { repository.change(field.id, field.name, it) }, + enabled = !state.busy && !lease.busy && field.value != null, + label = { Text(field.label) }, + modifier = Modifier.fillMaxWidth().testTag("editor-value-${field.id}"), + visualTransformation = if (shown) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + }, + singleLine = !field.multiline, + minLines = if (field.multiline) 3 else 1, + keyboardOptions = KeyboardOptions( + keyboardType = if (shown) KeyboardType.Text else KeyboardType.Password, + autoCorrectEnabled = false, + imeAction = if (field.multiline) ImeAction.Default else ImeAction.Next + ), + keyboardActions = KeyboardActions(onNext = { + focus.moveFocus(androidx.compose.ui.focus.FocusDirection.Next) + }) + ) + field.diagnostic?.let { Text(it) } + FlowRow { + TextButton(onClick = { + revealed = + if (shown) revealed - field.id else revealed + field.id + }) { + Text( + stringResource( + if (shown) R.string.editor_hide else R.string.editor_reveal + ) + ) + } + if (field.removable) { + TextButton(enabled = !state.busy, onClick = { + repository.remove(field.id) + }, modifier = Modifier.testTag("editor-remove-${field.id}")) { + Text(stringResource(R.string.editor_remove)) + } + } + if (field.reorderable) { + TextButton( + enabled = + !state.busy && index > 0 && state.fields[index - 1].reorderable, + onClick = { + repository.reorder( + field.id, + ( + index - + 1 + ).toUInt() + ) + }, + modifier = Modifier.testTag("editor-up-${field.id}") + ) { + Text(stringResource(R.string.editor_up)) + } + val canMoveDown = !state.busy && index < state.fields.lastIndex + TextButton(enabled = canMoveDown, onClick = { + repository.reorder( + field.id, + ( + index + + 1 + ).toUInt() + ) + }, modifier = Modifier.testTag("editor-down-${field.id}")) { + Text(stringResource(R.string.editor_down)) + } + } + } + } + } + } + if (manual) { + PassphraseDialog( + stringResource(R.string.auth_manual), + stringResource(R.string.editor_unlock_detail), + authentication.presentation, + onCancel = { manual = false }, + onSubmit = { + manual = false + repository.resume(it) + } + ) + } + adding?.let { kind -> + EditorAddDialog(repository, authentication, kind) { adding = null } + } + if (generating) { + EditorGenerateDialog(repository, authentication) { generating = false } + } +} + +@Composable +fun EditorDiscardDialog(repository: EditorRepository) { + val state by repository.state.collectAsStateWithLifecycle() + if (!state.discardRequested) return + ProtectedWindow() + AlertDialog( + properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn), + onDismissRequest = repository::keepEditing, + title = { Text(stringResource(R.string.editor_discard_title)) }, + text = { Text(stringResource(R.string.editor_discard_detail)) }, + confirmButton = { + TextButton(onClick = repository::confirmDiscard) { + Text(stringResource(R.string.editor_discard)) + } + }, + dismissButton = { + TextButton(onClick = repository::keepEditing) { + Text(stringResource(R.string.editor_keep)) + } + } + ) +} + +@Composable +private fun EditorAddDialog( + repository: EditorRepository, + authentication: AuthenticationRepository, + kind: EditorFieldKind, + close: () -> Unit +) { + val state by repository.state.collectAsStateWithLifecycle() + var submitted by remember { mutableStateOf(null) } + LaunchedEffect(state.busy, state.completedOperations) { + if (submitted != null && !state.busy && state.completedOperations != submitted) close() + } + var name by rememberProtectedInput(authentication.presentation) + var value by rememberProtectedInput(authentication.presentation) + ProtectedWindow() + AlertDialog( + properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn), + onDismissRequest = close, + title = { + Text( + stringResource( + if (kind == + EditorFieldKind.OtpUri + ) { + R.string.editor_add_otp + } else { + R.string.editor_add_field + } + ) + ) + }, + text = { + ProtectedInputSurface(authentication.presentation) + Column { + state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) } + if (kind == + EditorFieldKind.Field + ) { + OutlinedTextField(name, { + name = it + }, label = { + Text(stringResource(R.string.editor_field_name)) + }, modifier = Modifier.testTag("editor-add-name"), singleLine = true) + } + OutlinedTextField( + value, + { + value = it + }, + label = { + Text(stringResource(R.string.editor_field_value)) + }, + modifier = Modifier.testTag("editor-add-value"), + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + autoCorrectEnabled = false + ) + ) + } + }, + confirmButton = { + TextButton(onClick = { + submitted = state.completedOperations + repository.add( + kind, + if (kind == + EditorFieldKind.Field + ) { + name + } else { + null + }, + value + ) + }) { Text(stringResource(R.string.editor_add)) } + }, + dismissButton = { + TextButton(onClick = close) { Text(stringResource(R.string.editor_cancel)) } + } + ) +} + +@Composable +private fun EditorGenerateDialog( + repository: EditorRepository, + authentication: AuthenticationRepository, + close: () -> Unit +) { + val state by repository.state.collectAsStateWithLifecycle() + var length by remember { mutableStateOf(state.defaultLength.toString()) } + var noSymbols by remember { mutableStateOf(false) } + val noSymbolsLabel = stringResource(R.string.editor_no_symbols) + ProtectedWindow() + AlertDialog( + properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn), + onDismissRequest = close, + title = { Text(stringResource(R.string.editor_generate)) }, + text = { + Column { + Text(stringResource(R.string.editor_generate_detail)) + OutlinedTextField( + length, + { + length = it + }, + label = { + Text(stringResource(R.string.editor_length)) + }, + supportingText = { + Text( + stringResource(R.string.editor_length_help, state.maximumLength.toInt()) + ) + }, + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number + ) + ) + FlowRow { + Checkbox( + noSymbols, + { noSymbols = it }, + modifier = Modifier.semantics { + contentDescription = + noSymbolsLabel + } + ) + Text(stringResource(R.string.editor_no_symbols)) + } + } + }, + confirmButton = { + TextButton(onClick = { + repository.generate( + if (length.isBlank()) { + null + } else { + length.toUIntOrNull() + ?: 0u + }, + noSymbols + ) + close() + }) { Text(stringResource(R.string.editor_generate_confirm)) } + }, + dismissButton = { + TextButton(onClick = close) { Text(stringResource(R.string.editor_cancel)) } + } + ) +} + +@Composable +internal fun EditorCreationDialog( + authentication: AuthenticationRepository, + folder: Boolean, + close: () -> Unit, + create: (String) -> Unit +) { + var name by rememberProtectedInput(authentication.presentation) + ProtectedWindow() + AlertDialog( + properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn), + onDismissRequest = close, + title = { + Text( + stringResource( + if (folder) R.string.editor_new_folder else R.string.editor_new_entry + ) + ) + }, + text = { + ProtectedInputSurface(authentication.presentation) + OutlinedTextField( + name, + { + name = it + }, + label = { Text(stringResource(R.string.editor_name)) }, + singleLine = true, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Done, + autoCorrectEnabled = false + ), + keyboardActions = KeyboardActions(onDone = { create(name) }) + ) + }, + confirmButton = { + TextButton(onClick = { create(name) }) { Text(stringResource(R.string.editor_create)) } + }, + dismissButton = { + TextButton(onClick = close) { Text(stringResource(R.string.editor_cancel)) } + } + ) +} diff --git a/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/PasswordScreen.kt b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/PasswordScreen.kt index 48c67fd..de0343e 100644 --- a/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/PasswordScreen.kt +++ b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/PasswordScreen.kt @@ -4,6 +4,7 @@ import androidx.activity.compose.BackHandler import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -47,6 +48,7 @@ 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.EditorRepository import de.rfc1437.ironstorage.core.PasswordKind import de.rfc1437.ironstorage.core.PasswordRepository import de.rfc1437.ironstorage.core.ShellIcon @@ -57,7 +59,8 @@ fun PasswordScreen( repository: PasswordRepository, authentication: AuthenticationRepository, modifier: Modifier = Modifier, - leaveOnDispose: Boolean = true + leaveOnDispose: Boolean = true, + editor: EditorRepository? = null ) { DisposableEffect(repository, authentication) { onDispose { @@ -67,6 +70,8 @@ fun PasswordScreen( val state by repository.state.collectAsStateWithLifecycle() val navigator = rememberListDetailPaneScaffoldNavigator() var actions by remember { mutableStateOf(false) } + var createFolder by remember { mutableStateOf(null) } + val editorState = editor?.state?.collectAsStateWithLifecycle()?.value val lease by authentication.state.collectAsStateWithLifecycle() var manual by remember { mutableStateOf(false) } var requested by remember { mutableStateOf(false) } @@ -110,6 +115,23 @@ fun PasswordScreen( Text(stringResource(R.string.password_refresh)) } } + if (editor != null) { + FlowRow { + TextButton(enabled = !state.loading && editorState?.busy != true, onClick = { + createFolder = + false + }) { + Text(stringResource(R.string.editor_new_entry)) + } + TextButton(enabled = !state.loading && editorState?.busy != true, onClick = { + createFolder = + true + }) { + Text(stringResource(R.string.editor_new_folder)) + } + } + editorState?.error?.let { Text(it, color = MaterialTheme.colorScheme.error) } + } ListDetailPaneScaffold( directive = navigator.scaffoldDirective, value = navigator.scaffoldValue, @@ -255,6 +277,15 @@ fun PasswordScreen( } } } else { + if (editor != null) { + item { + TextButton(enabled = !state.loading, onClick = { + editor.begin(entry.path) + }) { + Text(stringResource(R.string.editor_edit_entry)) + } + } + } entryViewer(state, repository) } } @@ -263,6 +294,26 @@ fun PasswordScreen( } ) } + createFolder?.let { folder -> + if (editor != null) { + EditorCreationDialog( + authentication, + folder, + close = { createFolder = null }, + create = { name -> + createFolder = null + if (folder) { + editor.createFolder( + state.path, + name + ) + } else { + editor.create(state.path, name) + } + } + ) + } + } LaunchedEffect(lease.failure, state.loading) { if (requested && !state.loading && !lease.busy) { requested = false diff --git a/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/ShellScreen.kt b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/ShellScreen.kt index 8f45ce3..11bfb82 100644 --- a/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/ShellScreen.kt +++ b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/ShellScreen.kt @@ -57,6 +57,7 @@ private enum class ShellRoute(val tab: ShellTab?) { @Composable fun ShellScreen(viewModel: ShellViewModel, setupForm: SetupFormViewModel) { + EditorDiscardDialog(viewModel.editor) val snapshot by viewModel.snapshot.collectAsStateWithLifecycle() val appearancePage by viewModel.appearancePage.collectAsStateWithLifecycle() fun selectTab(tab: ShellTab) { @@ -149,12 +150,20 @@ private fun ShellDestination( viewModel: ShellViewModel, setupForm: SetupFormViewModel ) { + val setup by viewModel.onboarding.setup.collectAsStateWithLifecycle() + val editor by viewModel.editor.state.collectAsStateWithLifecycle() val ready = !snapshot.fixture && snapshot.nativeFailure == null && snapshot.storageRecovery == null && - (snapshot.state == ShellState.Ready || snapshot.state == ShellState.Locked) + ( + snapshot.state == ShellState.Ready || snapshot.state == ShellState.Locked || + (snapshot.state == ShellState.Empty && setup.configured) + ) when { !snapshot.fixture && snapshot.nativeFailure == null && snapshot.storageRecovery == null && - (snapshot.state == ShellState.Empty || route == ShellRoute.Preferences) -> { + ( + (snapshot.state == ShellState.Empty && !setup.configured) || + route == ShellRoute.Preferences + ) -> { Column( Modifier.fillMaxSize().verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(16.dp) @@ -176,12 +185,17 @@ private fun ShellDestination( } ready && route == ShellRoute.Passwords -> { LaunchedEffect(viewModel) { viewModel.openPasswords() } - PasswordScreen( - viewModel.passwords, - viewModel.authentication, - Modifier.fillMaxSize(), - leaveOnDispose = false - ) + if (editor.open) { + EntryEditorScreen(viewModel.editor, viewModel.authentication) + } else { + PasswordScreen( + viewModel.passwords, + viewModel.authentication, + Modifier.fillMaxSize(), + leaveOnDispose = false, + editor = viewModel.editor + ) + } } ready && route == ShellRoute.Search -> SearchScreen( viewModel.search, diff --git a/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/ShellViewModel.kt b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/ShellViewModel.kt index d088dfb..b9b8fe9 100644 --- a/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/ShellViewModel.kt +++ b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/ShellViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import de.rfc1437.ironstorage.core.AuthenticationRepository +import de.rfc1437.ironstorage.core.EditorRepository import de.rfc1437.ironstorage.core.HomeRepository import de.rfc1437.ironstorage.core.OnboardingRepository import de.rfc1437.ironstorage.core.PasswordRepository @@ -26,6 +27,7 @@ class ShellViewModel @Inject constructor( val home: HomeRepository, val search: SearchRepository, val authentication: AuthenticationRepository, + val editor: EditorRepository, private val saved: SavedStateHandle ) : ViewModel() { val snapshot = repository.snapshot @@ -45,6 +47,12 @@ class ShellViewModel @Inject constructor( } init { + if (saved.get("editor.wasOpen") == true && !editor.state.value.open) { + editor.processRecreated() + } + viewModelScope.launch { + editor.state.collect { saved["editor.wasOpen"] = it.open } + } val restored = saved.get("shell.tab")?.let { name -> ShellTab.entries.firstOrNull { it.name == name } } @@ -84,6 +92,10 @@ class ShellViewModel @Inject constructor( } fun refreshShell() = repository.refresh() fun selectTab(tab: ShellTab) { + if (tab != snapshot.value.selectedTab && editor.state.value.open) { + editor.requestClose { selectTab(tab) } + return + } if (tab != snapshot.value.selectedTab) leaveDestination() saved["shell.tab"] = tab.name repository.selectTab(tab) diff --git a/android/features/src/main/res/values/strings.xml b/android/features/src/main/res/values/strings.xml index cf6e7c7..985fb34 100644 --- a/android/features/src/main/res/values/strings.xml +++ b/android/features/src/main/res/values/strings.xml @@ -118,4 +118,34 @@ Light Dark Reload state + Cancel + Save entry + The draft is protected. Unlock to resume editing. No plaintext draft is saved on disk. + Unlock the GPG key to resume this in-memory draft. + Add named field + Add notes + Add OTP URI + Field name + Field value + Generate password + Generate a replacement password in the draft. Save the entry to apply it. + Password length + Use letters and numbers only + Generate replacement + Reveal value + Hide value + Remove field + Move up + Move down + Add field + Discard unsaved changes? + The entry draft will be discarded without changing the password store. + Discard changes + Keep editing + New entry + New folder + Edit entry + Name + Create + Leave blank for the default. Maximum: %1$d. diff --git a/apple/Generated/ironstorage_mobile.swift b/apple/Generated/ironstorage_mobile.swift index 849bc15..d205849 100644 --- a/apple/Generated/ironstorage_mobile.swift +++ b/apple/Generated/ironstorage_mobile.swift @@ -1408,6 +1408,10 @@ public protocol MobileAuthenticationProtocol: AnyObject, Sendable { func cancel() throws + func checkpointEntryEditor(editor: UInt64, version: UInt64) throws + + func checkpointEntryEditorInputs(editor: UInt64, version: UInt64, fields: [MobileEntryEditorInput]) throws + func copyEntryField(path: String, field: UInt64) throws -> MobileEntryCopy func copyPresentedEntryField(path: String, fieldId: UInt64, revision: String) throws -> MobileEntryCopy @@ -1462,6 +1466,8 @@ public protocol MobileAuthenticationProtocol: AnyObject, Sendable { func restoreBiometricLease() throws -> MobileAuthenticationState + func resumeEntryEditor(editor: UInt64, passphrase: String?) throws -> MobileEntryEditorRecovery + func revealEntryField(path: String, field: UInt64) throws -> String func revealPresentedEntryField(path: String, fieldId: UInt64, revision: String) throws -> String @@ -1627,6 +1633,27 @@ open func cancel()throws {try rustCallWithError(FfiConverterTypeMobileAuthenti } } +open func checkpointEntryEditor(editor: UInt64, version: UInt64)throws {try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) { + uniffiCallStatus in + uniffi_ironstorage_mobile_fn_method_mobileauthentication_checkpoint_entry_editor( + self.uniffiCloneHandle(), + FfiConverterUInt64.lower(editor), + FfiConverterUInt64.lower(version),uniffiCallStatus + ) +} +} + +open func checkpointEntryEditorInputs(editor: UInt64, version: UInt64, fields: [MobileEntryEditorInput])throws {try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) { + uniffiCallStatus in + uniffi_ironstorage_mobile_fn_method_mobileauthentication_checkpoint_entry_editor_inputs( + self.uniffiCloneHandle(), + FfiConverterUInt64.lower(editor), + FfiConverterUInt64.lower(version), + FfiConverterSequenceTypeMobileEntryEditorInput.lower(fields),uniffiCallStatus + ) +} +} + open func copyEntryField(path: String, field: UInt64)throws -> MobileEntryCopy { return try FfiConverterTypeMobileEntryCopy_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) { uniffiCallStatus in @@ -1918,6 +1945,17 @@ open func restoreBiometricLease()throws -> MobileAuthenticationState { }) } +open func resumeEntryEditor(editor: UInt64, passphrase: String?)throws -> MobileEntryEditorRecovery { + return try FfiConverterTypeMobileEntryEditorRecovery_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) { + uniffiCallStatus in + uniffi_ironstorage_mobile_fn_method_mobileauthentication_resume_entry_editor( + self.uniffiCloneHandle(), + FfiConverterUInt64.lower(editor), + FfiConverterOptionString.lower(passphrase),uniffiCallStatus + ) +}) +} + open func revealEntryField(path: String, field: UInt64)throws -> String { return try FfiConverterString.lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) { uniffiCallStatus in @@ -5032,6 +5070,60 @@ public func FfiConverterTypeMobileEntryEditorPage_lower(_ value: MobileEntryEdit } +public struct MobileEntryEditorRecovery: Equatable, Hashable { + public var session: MobileEntryEditorSession + public var inputs: [MobileEntryEditorInput]? + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(session: MobileEntryEditorSession, inputs: [MobileEntryEditorInput]?) { + self.session = session + self.inputs = inputs + } + + + + +} + +#if compiler(>=6) +extension MobileEntryEditorRecovery: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeMobileEntryEditorRecovery: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileEntryEditorRecovery { + return + try MobileEntryEditorRecovery( + session: FfiConverterTypeMobileEntryEditorSession.read(from: &buf), + inputs: FfiConverterOptionSequenceTypeMobileEntryEditorInput.read(from: &buf) + ) + } + + public static func write(_ value: MobileEntryEditorRecovery, into buf: inout [UInt8]) { + FfiConverterTypeMobileEntryEditorSession.write(value.session, into: &buf) + FfiConverterOptionSequenceTypeMobileEntryEditorInput.write(value.inputs, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileEntryEditorRecovery_lift(_ buf: RustBuffer) throws -> MobileEntryEditorRecovery { + return try FfiConverterTypeMobileEntryEditorRecovery.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileEntryEditorRecovery_lower(_ value: MobileEntryEditorRecovery) -> RustBuffer { + return FfiConverterTypeMobileEntryEditorRecovery.lower(value) +} + + public struct MobileEntryEditorSession: Equatable, Hashable { public var id: UInt64 public var page: MobileEntryEditorPage @@ -11889,6 +11981,30 @@ fileprivate struct FfiConverterOptionTypeMobilePrivateStorageErrorKind: FfiConve } } +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterOptionSequenceTypeMobileEntryEditorInput: FfiConverterRustBuffer { + typealias SwiftType = [MobileEntryEditorInput]? + + public static func write(_ value: SwiftType, into buf: inout [UInt8]) { + guard let value = value else { + writeInt(&buf, Int8(0)) + return + } + writeInt(&buf, Int8(1)) + FfiConverterSequenceTypeMobileEntryEditorInput.write(value, into: &buf) + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SwiftType { + switch try readInt(&buf) as Int8 { + case 0: return nil + case 1: return try FfiConverterSequenceTypeMobileEntryEditorInput.read(from: &buf) + default: throw UniffiInternalError.unexpectedOptionalTag + } + } +} + #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -12320,6 +12436,13 @@ public func mobileAuthentication()throws -> MobileAuthentication { ) }) } +public func mobileEditorProcessRecreatedNotice() -> String { + return try! FfiConverterString.lift(try! rustCall() { + uniffiCallStatus in + uniffi_ironstorage_mobile_fn_func_mobile_editor_process_recreated_notice(uniffiCallStatus + ) +}) +} public func mobileHomeOperation(authentication: MobileAuthentication?) -> MobileHomeOperation { return try! FfiConverterTypeMobileHomeOperation_lift(try! rustCall() { uniffiCallStatus in @@ -12521,6 +12644,9 @@ private let initializationResult: InitializationResult = { if (uniffi_ironstorage_mobile_checksum_func_mobile_authentication() != 54684) { return InitializationResult.apiChecksumMismatch } + if (uniffi_ironstorage_mobile_checksum_func_mobile_editor_process_recreated_notice() != 20887) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_ironstorage_mobile_checksum_func_mobile_home_operation() != 10726) { return InitializationResult.apiChecksumMismatch } @@ -12608,6 +12734,12 @@ private let initializationResult: InitializationResult = { if (uniffi_ironstorage_mobile_checksum_method_mobileauthentication_cancel() != 40449) { return InitializationResult.apiChecksumMismatch } + if (uniffi_ironstorage_mobile_checksum_method_mobileauthentication_checkpoint_entry_editor() != 51032) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_ironstorage_mobile_checksum_method_mobileauthentication_checkpoint_entry_editor_inputs() != 62194) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_ironstorage_mobile_checksum_method_mobileauthentication_copy_entry_field() != 46283) { return InitializationResult.apiChecksumMismatch } @@ -12689,6 +12821,9 @@ private let initializationResult: InitializationResult = { if (uniffi_ironstorage_mobile_checksum_method_mobileauthentication_restore_biometric_lease() != 7408) { return InitializationResult.apiChecksumMismatch } + if (uniffi_ironstorage_mobile_checksum_method_mobileauthentication_resume_entry_editor() != 56859) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_ironstorage_mobile_checksum_method_mobileauthentication_reveal_entry_field() != 13272) { return InitializationResult.apiChecksumMismatch } @@ -13036,6 +13171,10 @@ extension MobileEntryEditorPage: CustomStringConvertible, CustomDebugStringConve public var description: String { "MobileEntryEditorPage([REDACTED])" } public var debugDescription: String { description } } +extension MobileEntryEditorRecovery: CustomStringConvertible, CustomDebugStringConvertible { + public var description: String { "MobileEntryEditorRecovery([REDACTED])" } + public var debugDescription: String { description } +} extension MobileEntryEditorSession: CustomStringConvertible, CustomDebugStringConvertible { public var description: String { "MobileEntryEditorSession([REDACTED])" } public var debugDescription: String { description } diff --git a/apple/Generated/ironstorage_mobileFFI.h b/apple/Generated/ironstorage_mobileFFI.h index 4e60e41..ae85793 100644 --- a/apple/Generated/ironstorage_mobileFFI.h +++ b/apple/Generated/ironstorage_mobileFFI.h @@ -464,6 +464,16 @@ RustBuffer uniffi_ironstorage_mobile_fn_method_mobileauthentication_cached_totp_ void uniffi_ironstorage_mobile_fn_method_mobileauthentication_cancel(uint64_t ptr, RustCallStatus *_Nonnull out_status ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEAUTHENTICATION_CHECKPOINT_ENTRY_EDITOR +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEAUTHENTICATION_CHECKPOINT_ENTRY_EDITOR +void uniffi_ironstorage_mobile_fn_method_mobileauthentication_checkpoint_entry_editor(uint64_t ptr, uint64_t editor, uint64_t version, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEAUTHENTICATION_CHECKPOINT_ENTRY_EDITOR_INPUTS +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEAUTHENTICATION_CHECKPOINT_ENTRY_EDITOR_INPUTS +void uniffi_ironstorage_mobile_fn_method_mobileauthentication_checkpoint_entry_editor_inputs(uint64_t ptr, uint64_t editor, uint64_t version, RustBuffer fields, RustCallStatus *_Nonnull out_status +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEAUTHENTICATION_COPY_ENTRY_FIELD #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEAUTHENTICATION_COPY_ENTRY_FIELD RustBuffer uniffi_ironstorage_mobile_fn_method_mobileauthentication_copy_entry_field(uint64_t ptr, RustBuffer path, uint64_t field, RustCallStatus *_Nonnull out_status @@ -599,6 +609,11 @@ RustBuffer uniffi_ironstorage_mobile_fn_method_mobileauthentication_replace_pres RustBuffer uniffi_ironstorage_mobile_fn_method_mobileauthentication_restore_biometric_lease(uint64_t ptr, RustCallStatus *_Nonnull out_status ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEAUTHENTICATION_RESUME_ENTRY_EDITOR +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEAUTHENTICATION_RESUME_ENTRY_EDITOR +RustBuffer uniffi_ironstorage_mobile_fn_method_mobileauthentication_resume_entry_editor(uint64_t ptr, uint64_t editor, RustBuffer passphrase, RustCallStatus *_Nonnull out_status +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEAUTHENTICATION_REVEAL_ENTRY_FIELD #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEAUTHENTICATION_REVEAL_ENTRY_FIELD RustBuffer uniffi_ironstorage_mobile_fn_method_mobileauthentication_reveal_entry_field(uint64_t ptr, RustBuffer path, uint64_t field, RustCallStatus *_Nonnull out_status @@ -1193,6 +1208,12 @@ void uniffi_ironstorage_mobile_fn_method_mobileprivateruntime_verify_secure_stor #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_FUNC_MOBILE_AUTHENTICATION uint64_t uniffi_ironstorage_mobile_fn_func_mobile_authentication(RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_FUNC_MOBILE_EDITOR_PROCESS_RECREATED_NOTICE +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_FUNC_MOBILE_EDITOR_PROCESS_RECREATED_NOTICE +RustBuffer uniffi_ironstorage_mobile_fn_func_mobile_editor_process_recreated_notice(RustCallStatus *_Nonnull out_status + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_FUNC_MOBILE_HOME_OPERATION @@ -1575,6 +1596,12 @@ void ffi_ironstorage_mobile_rust_future_complete_void(uint64_t handle, RustCallS #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_FUNC_MOBILE_AUTHENTICATION uint16_t uniffi_ironstorage_mobile_checksum_func_mobile_authentication(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_FUNC_MOBILE_EDITOR_PROCESS_RECREATED_NOTICE +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_FUNC_MOBILE_EDITOR_PROCESS_RECREATED_NOTICE +uint16_t uniffi_ironstorage_mobile_checksum_func_mobile_editor_process_recreated_notice(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_FUNC_MOBILE_HOME_OPERATION @@ -1749,6 +1776,18 @@ uint16_t uniffi_ironstorage_mobile_checksum_method_mobileauthentication_cached_t #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_CANCEL uint16_t uniffi_ironstorage_mobile_checksum_method_mobileauthentication_cancel(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_CHECKPOINT_ENTRY_EDITOR +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_CHECKPOINT_ENTRY_EDITOR +uint16_t uniffi_ironstorage_mobile_checksum_method_mobileauthentication_checkpoint_entry_editor(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_CHECKPOINT_ENTRY_EDITOR_INPUTS +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_CHECKPOINT_ENTRY_EDITOR_INPUTS +uint16_t uniffi_ironstorage_mobile_checksum_method_mobileauthentication_checkpoint_entry_editor_inputs(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_COPY_ENTRY_FIELD @@ -1911,6 +1950,12 @@ uint16_t uniffi_ironstorage_mobile_checksum_method_mobileauthentication_replace_ #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_RESTORE_BIOMETRIC_LEASE uint16_t uniffi_ironstorage_mobile_checksum_method_mobileauthentication_restore_biometric_lease(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_RESUME_ENTRY_EDITOR +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_RESUME_ENTRY_EDITOR +uint16_t uniffi_ironstorage_mobile_checksum_method_mobileauthentication_resume_entry_editor(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_REVEAL_ENTRY_FIELD diff --git a/crates/mobile-bridge/src/lib.rs b/crates/mobile-bridge/src/lib.rs index f3b1f86..d5fcc0b 100644 --- a/crates/mobile-bridge/src/lib.rs +++ b/crates/mobile-bridge/src/lib.rs @@ -24,7 +24,9 @@ use ironstorage::{ MobileAuthenticationError as StorageAuthenticationError, MobileAuthenticationErrorKind as StorageAuthenticationErrorKind, MobileAuthenticationState as StorageAuthenticationState, - MobileEntryCopy as StorageEntryCopy, MobileEntryPresentation as StorageEntryPresentation, + MobileEntryCopy as StorageEntryCopy, + MobileEntryEditorRecovery as StorageEntryEditorRecovery, + MobileEntryPresentation as StorageEntryPresentation, MobilePreferences as StorageMobilePreferences, }, mobile_entry::{ @@ -88,6 +90,11 @@ pub use android_onboarding::*; mod platform; pub use platform::*; +#[uniffi::export] +pub fn mobile_editor_process_recreated_notice() -> String { + ironstorage::mobile_entry::editor_process_recreated_notice().to_owned() +} + #[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] pub enum MobileIcon { Home, @@ -1389,6 +1396,29 @@ impl From for StorageEntryEditorInput { } } +#[derive(Clone, uniffi::Record)] +pub struct MobileEntryEditorRecovery { + pub session: MobileEntryEditorSession, + pub inputs: Option>, +} + +impl From for MobileEntryEditorRecovery { + fn from(recovery: StorageEntryEditorRecovery) -> Self { + Self { + session: recovery.session.into(), + inputs: recovery.inputs.map(|inputs| { + inputs + .into_iter() + .map(|input| { + let (id, name, value) = input.into_parts(); + MobileEntryEditorInput { id, name, value } + }) + .collect() + }), + } + } +} + impl From for MobileEntryCopy { fn from(copy: StorageEntryCopy) -> Self { Self { @@ -1954,6 +1984,46 @@ impl MobileAuthentication { .map_err(Into::into) } + pub fn checkpoint_entry_editor( + &self, + editor: u64, + version: u64, + ) -> Result<(), MobileAuthenticationFfiError> { + self.authentication + .checkpoint_entry_editor(editor, version) + .map_err(Into::into) + } + + pub fn checkpoint_entry_editor_inputs( + &self, + editor: u64, + version: u64, + fields: Vec, + ) -> Result<(), MobileAuthenticationFfiError> { + self.authentication + .checkpoint_entry_editor_inputs( + editor, + version, + fields.into_iter().map(Into::into).collect(), + ) + .map_err(Into::into) + } + + pub fn resume_entry_editor( + &self, + editor: u64, + passphrase: Option, + ) -> Result { + self.authentication + .resume_entry_editor( + editor, + passphrase + .map(|value| ironstorage::repository::SecretBytes::new(value.into_bytes())), + ) + .map(Into::into) + .map_err(Into::into) + } + pub fn begin_entry_editor( &self, path: String, diff --git a/crates/storage/src/document.rs b/crates/storage/src/document.rs index 4cf3b44..41a7387 100644 --- a/crates/storage/src/document.rs +++ b/crates/storage/src/document.rs @@ -6,7 +6,7 @@ use sha2::{Digest as _, Sha256}; use crate::{ command::EditRequest, - crypto::{KeyStore, SecretProvider}, + crypto::{CryptoError, KeyHandle, KeyStore, SecretProvider}, otp::{OtpAlgorithm, OtpError, OtpKind, OtpUri}, recipient::SigningPolicy, repository::{EncryptedEntry, EntryPath, Repository, SecretBytes}, @@ -240,7 +240,95 @@ pub struct EntryDocument { modified: bool, } +/// An editor checkpoint contains ciphertext only and is never written to a repository. +pub(crate) struct SealedEntryDocument { + path: EntryPath, + fields: Vec<(EntryFieldId, Range, Vec)>, + next_id: u64, + conflict_token: DocumentConflictToken, + original_ciphertext: Option, + original_plaintext: Range, + ciphertext: EncryptedEntry, + modified: bool, +} + +impl SealedEntryDocument { + pub(crate) fn probe(&self) -> &EncryptedEntry { + &self.ciphertext + } + + pub(crate) fn restore( + &self, + keys: &KeyStore, + provider: &mut impl SecretProvider, + ) -> Result { + let decrypted = keys.decrypt(&self.ciphertext, provider)?; + let read = |range: &Range| -> Result { + decrypted + .expose() + .get(range.clone()) + .map(|bytes| SecretBytes::new(bytes.to_vec())) + .ok_or(CryptoError::CorruptMessage) + }; + let plaintext = read(&self.original_plaintext)?; + let mut fields = self + .fields + .iter() + .map(|(id, contents, ending)| { + Ok(EntryField { + id: *id, + contents: read(contents)?, + ending: ending.clone(), + metadata: empty_note_metadata(), + }) + }) + .collect::, CryptoError>>()?; + classify_all(&mut fields); + Ok(EntryDocument { + path: self.path.clone(), + fields, + next_id: self.next_id, + conflict_token: self.conflict_token, + session: EditSession::restored( + self.path.clone(), + self.original_ciphertext.clone(), + plaintext, + ), + modified: self.modified, + }) + } +} + impl EntryDocument { + pub(crate) fn seal( + &self, + keys: &KeyStore, + recipient: &KeyHandle, + ) -> Result { + let mut body = self.session.plaintext().expose().to_vec(); + let original_plaintext = 0..body.len(); + let fields = self + .fields + .iter() + .map(|field| { + let start = body.len(); + body.extend_from_slice(field.contents.expose()); + (field.id, start..body.len(), field.ending.clone()) + }) + .collect(); + let ciphertext = keys.encrypt(SecretBytes::new(body), std::slice::from_ref(recipient))?; + Ok(SealedEntryDocument { + path: self.path.clone(), + fields, + next_id: self.next_id, + conflict_token: self.conflict_token, + original_ciphertext: self.session.original_ciphertext().cloned(), + original_plaintext, + ciphertext, + modified: self.modified, + }) + } + fn from_session(session: EditSession) -> Self { let path = session.path().clone(); let conflict_token = conflict_token(&session); diff --git a/crates/storage/src/mobile_authentication.rs b/crates/storage/src/mobile_authentication.rs index ace03e3..8c7bb33 100644 --- a/crates/storage/src/mobile_authentication.rs +++ b/crates/storage/src/mobile_authentication.rs @@ -16,7 +16,9 @@ use crate::{ }, config::{Config, ConfigError, GitRemote, MobileAppearance}, crypto::{CryptoError, KeyInfo, KeyStore, SecretProvider, SecretProviderError}, - document::{DocumentError, EntryDocument, EntryDocumentService, EntryFieldId}, + document::{ + DocumentError, EntryDocument, EntryDocumentService, EntryFieldId, SealedEntryDocument, + }, git::{AutomaticEntryCommitter, AutomaticTreeCommitter, GitError, GitIdentity}, mobile_entry::{ MobileEntryDraft, MobileEntryEditorError, MobileEntryEditorFieldKind, @@ -306,12 +308,37 @@ struct MobileAuthenticationStatus { active: Option, next_editor_id: u64, editors: BTreeMap, + editor_checkpoints: BTreeMap, mutation_active: bool, repository_operation_active: bool, watch_shared_totp_entries: std::collections::BTreeSet, watch_snapshot: WatchSnapshotSender, } +struct SealedEditorInput { + id: u64, + name: Option>, + value: Option>, +} +struct SealedEditorInputs { + fields: Vec, + ciphertext: EncryptedEntry, +} +struct EditorCheckpoint { + document: SealedEntryDocument, + creating: bool, + recipient: crate::crypto::KeyHandle, + field_ids: std::collections::BTreeSet, + version: u64, + inputs: Option, +} + +/// Only released after the checkpoint's encryption key has an active authentication lease. +pub struct MobileEntryEditorRecovery { + pub session: MobileEntryEditorSession, + pub inputs: Option>, +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum MobileRepositoryOperation { Commit, @@ -424,6 +451,7 @@ impl MobileAuthentication { active: None, next_editor_id: 0, editors: BTreeMap::new(), + editor_checkpoints: BTreeMap::new(), mutation_active: false, repository_operation_active: false, watch_shared_totp_entries: config.watch_shared_totp_entries().clone(), @@ -1584,11 +1612,173 @@ impl MobileAuthentication { return Err(document_error(error)); } self.reconcile_saved_document(draft.document()); + self.status()?.editor_checkpoints.remove(&editor); Ok(MobileEntryPage::from_document(draft.document())) } + /// Seal the lossless document, original revision and stable IDs in memory only. + pub fn checkpoint_entry_editor( + &self, + editor: u64, + version: u64, + ) -> Result<(), MobileAuthenticationError> { + self.ensure_active()?; + let mut status = self.status()?; + let key = &status.active.as_ref().ok_or_else(locked_error)?.key; + let recipient = self + .keys + .resolve(key.fingerprint().as_str()) + .map_err(key_error)?; + let draft = status.editors.get(&editor).ok_or_else(editor_missing)?; + let checkpoint = EditorCheckpoint { + document: draft + .document() + .seal(&self.keys, &recipient) + .map_err(key_error)?, + creating: draft.creating(), + recipient, + field_ids: draft + .document() + .fields() + .iter() + .map(|field| field.id().value()) + .collect(), + version, + inputs: None, + }; + status.editor_checkpoints.insert(editor, checkpoint); + Ok(()) + } + + /// Accept an already collected input even if background revocation won the race. + /// This only encrypts caller-supplied values; it cannot read or publish protected data. + pub fn checkpoint_entry_editor_inputs( + &self, + editor: u64, + version: u64, + inputs: Vec, + ) -> Result<(), MobileAuthenticationError> { + let mut status = self.status()?; + let checkpoint = status + .editor_checkpoints + .get_mut(&editor) + .ok_or_else(editor_missing)?; + if version <= checkpoint.version { + return Ok(()); + } + let inputs = inputs + .into_iter() + .map(MobileEntryEditorInput::into_parts) + .collect::>(); + let ids = inputs + .iter() + .map(|input| input.0) + .collect::>(); + if ids != checkpoint.field_ids || ids.len() != inputs.len() { + return Err(editor_error(MobileEntryEditorError::FieldSetChanged)); + } + let mut body = Vec::new(); + let mut append = |text: Option| { + text.map(|text| { + let start = body.len(); + let bytes = SecretBytes::new(text.into_bytes()); + body.extend_from_slice(bytes.expose()); + start..body.len() + }) + }; + let fields = inputs + .into_iter() + .map(|(id, name, value)| SealedEditorInput { + id, + name: append(name), + value: append(value), + }) + .collect(); + let ciphertext = self + .keys + .encrypt( + SecretBytes::new(body), + std::slice::from_ref(&checkpoint.recipient), + ) + .map_err(key_error)?; + checkpoint.inputs = Some(SealedEditorInputs { fields, ciphertext }); + checkpoint.version = version; + Ok(()) + } + + pub fn resume_entry_editor( + &self, + editor: u64, + passphrase: Option, + ) -> Result { + let probe = self + .status()? + .editor_checkpoints + .get(&editor) + .ok_or_else(editor_missing)? + .document + .probe() + .clone(); + self.unlock_ciphertext(&probe, passphrase)?; + self.ensure_active()?; + let mut status = self.status()?; + let active = status.active.as_ref().ok_or_else(locked_error)?; + let handle = active.handle.clone(); + let mut provider = KeyOnlyProvider::new(handle.clone(), &active.key); + let checkpoint = status + .editor_checkpoints + .get(&editor) + .ok_or_else(editor_missing)?; + let document = checkpoint + .document + .restore(&self.keys, &mut provider) + .map_err(key_error)?; + let inputs = checkpoint + .inputs + .as_ref() + .map(|inputs| { + let plaintext = self + .keys + .decrypt(&inputs.ciphertext, &mut provider) + .map_err(key_error)?; + let read = + |range: &std::ops::Range| -> Result { + let bytes = plaintext + .expose() + .get(range.clone()) + .ok_or_else(|| key_error(CryptoError::CorruptMessage))?; + String::from_utf8(bytes.to_vec()) + .map_err(|_| editor_error(MobileEntryEditorError::NonUtf8)) + }; + inputs + .fields + .iter() + .map(|input| { + Ok(MobileEntryEditorInput::new( + input.id, + input.name.as_ref().map(&read).transpose()?, + input.value.as_ref().map(&read).transpose()?, + )) + }) + .collect::, MobileAuthenticationError>>() + }) + .transpose()?; + handle + .ensure_active() + .map_err(MobileAuthenticationError::authentication)?; + let draft = MobileEntryDraft::new(document, checkpoint.creating); + let page = draft.page(); + status.editors.insert(editor, draft); + Ok(MobileEntryEditorRecovery { + session: MobileEntryEditorSession::new(editor, page), + inputs, + }) + } + pub fn discard_entry_editor(&self, editor: u64) -> Result<(), MobileAuthenticationError> { - self.status()?.editors.remove(&editor); + let mut status = self.status()?; + status.editors.remove(&editor); + status.editor_checkpoints.remove(&editor); Ok(()) } @@ -1611,8 +1801,11 @@ impl MobileAuthentication { MobileLifecycleEvent::Foreground => {} MobileLifecycleEvent::Background | MobileLifecycleEvent::DeviceLocked - | MobileLifecycleEvent::MemoryPressure - | MobileLifecycleEvent::ProcessRecreated => self.manual_lock()?, + | MobileLifecycleEvent::MemoryPressure => self.manual_lock()?, + MobileLifecycleEvent::ProcessRecreated => { + self.manual_lock()?; + self.status()?.editor_checkpoints.clear(); + } MobileLifecycleEvent::UserActivity => { if self.state()?.unlocked() { self.touch_user_activity()?; diff --git a/crates/storage/src/mobile_entry.rs b/crates/storage/src/mobile_entry.rs index f4b4354..5264983 100644 --- a/crates/storage/src/mobile_entry.rs +++ b/crates/storage/src/mobile_entry.rs @@ -2,6 +2,10 @@ use crate::mobile::MobileIcon; +pub fn editor_process_recreated_notice() -> &'static str { + "An unsaved draft was discarded after the application process restarted. Unlock the entry to edit it again." +} + use std::{collections::BTreeSet, error::Error, fmt, num::NonZeroUsize}; use crate::document::{ @@ -258,6 +262,9 @@ pub struct MobileEntryEditorInput { } impl MobileEntryEditorInput { + pub fn into_parts(self) -> (u64, Option, Option) { + (self.id, self.name, self.value) + } pub fn new(id: u64, name: Option, value: Option) -> Self { Self { id, name, value } } @@ -273,6 +280,10 @@ impl MobileEntryDraft { Self { document, creating } } + pub(crate) fn creating(&self) -> bool { + self.creating + } + pub fn page(&self) -> MobileEntryEditorPage { let fields = self .document @@ -640,7 +651,8 @@ fn editor_field(index: usize, field: &EntryField) -> MobileEntryEditorField { sensitive, multiline: matches!(kind, MobileEntryEditorFieldKind::Note) || field.value().contains(&b'\n'), - name_editable: matches!(kind, MobileEntryEditorFieldKind::Field), + name_editable: matches!(kind, MobileEntryEditorFieldKind::Field) + && metadata.diagnostic() != Some(EntryFieldDiagnostic::NonUtf8Value), removable: index > 0, reorderable: index > 0, } diff --git a/crates/storage/src/write.rs b/crates/storage/src/write.rs index bb973c0..495b58e 100644 --- a/crates/storage/src/write.rs +++ b/crates/storage/src/write.rs @@ -173,6 +173,17 @@ pub struct EditSession { } impl EditSession { + pub(crate) fn restored( + path: EntryPath, + original_ciphertext: Option, + plaintext: SecretBytes, + ) -> Self { + Self { + path, + original_ciphertext, + plaintext, + } + } pub fn path(&self) -> &EntryPath { &self.path } diff --git a/crates/storage/tests/authentication_leases.rs b/crates/storage/tests/authentication_leases.rs index 99287d1..e1bb317 100644 --- a/crates/storage/tests/authentication_leases.rs +++ b/crates/storage/tests/authentication_leases.rs @@ -919,3 +919,271 @@ fn locking_interrupts_an_os_prompt_before_waiting_for_the_lease_mutex() -> TestR assert_eq!(session.remaining_time()?, None); Ok(()) } + +#[test] +fn encrypted_editor_checkpoint_retains_inputs_ids_and_conflict_base_across_lock() -> TestResult { + use ironstorage::{ + config::Config, + mobile_authentication::MobileAuthentication, + mobile_entry::{MobileEntryEditorFieldKind, MobileEntryEditorInput}, + mobile_platform::MobileLifecycleEvent, + }; + let fixture = FixtureSet::load()?; + let vault = fixture.materialize_store("basic")?; + let root = tempfile::tempdir()?; + let source = root.path().join("config.toml"); + std::fs::write( + &source, + format!( + "vault = {:?}\nkey_material = {:?}\ndefault_key = {:?}\n", + vault.path().to_str().unwrap(), + fixture.path("keys").to_str().unwrap(), + fixture.key("alice")?.primary_fingerprint, + ), + )?; + let clock = ManualClock::default(); + let backend = MemoryBackend::default(); + let make = || { + MobileAuthentication::with_platform( + Config::load(Some(&source)).unwrap(), + Arc::new(backend.clone()), + Arc::new(clock.clone()), + ) + }; + let authentication = make()?; + let password = + || SecretBytes::new(fixture.key("alice").unwrap().passphrase.as_bytes().to_vec()); + authentication.create_directory("", "checkpoint")?; + let session = authentication.begin_create_entry("checkpoint", "entry", Some(password()))?; + let id = session.id(); + for (kind, name, value) in [ + ( + MobileEntryEditorFieldKind::Field, + Some("custom".to_owned()), + "first", + ), + ( + MobileEntryEditorFieldKind::Field, + Some("custom".to_owned()), + "second", + ), + ( + MobileEntryEditorFieldKind::Field, + Some("pin".to_owned()), + "123493", + ), + ( + MobileEntryEditorFieldKind::Note, + None, + "first note\nsecond note", + ), + ( + MobileEntryEditorFieldKind::OtpUri, + None, + "otpauth://totp/Example:alice?secret=JBSWY3DPEHPK3PXP&issuer=Example", + ), + ] { + authentication.add_entry_editor_field(id, kind, name, value.to_owned())?; + } + let page = authentication.entry_editor(id)?; + let ids = page + .fields() + .iter() + .map(|field| field.id()) + .collect::>(); + let inputs = |text: &str| { + page.fields() + .iter() + .map(|field| { + MobileEntryEditorInput::new( + field.id(), + field.name().map(str::to_owned), + if field.kind() == MobileEntryEditorFieldKind::Password { + Some(text.to_owned()) + } else { + field.value().map(str::to_owned) + }, + ) + }) + .collect::>() + }; + authentication.checkpoint_entry_editor(id, 1)?; + authentication.checkpoint_entry_editor_inputs(id, 3, inputs("latest draft"))?; + authentication.checkpoint_entry_editor_inputs(id, 2, inputs("stale input"))?; + authentication.lifecycle(MobileLifecycleEvent::Background)?; + assert!(!authentication.state()?.unlocked()); + assert!(authentication.entry_editor(id).is_err()); + // A queued input may finish after the cover/lease revocation; it only encrypts. + authentication.checkpoint_entry_editor_inputs(id, 4, inputs("background-safe draft"))?; + assert!( + authentication + .resume_entry_editor(id, Some(SecretBytes::new(b"incorrect".to_vec()))) + .is_err() + ); + let recovered = authentication.resume_entry_editor(id, Some(password()))?; + assert_eq!(recovered.session.id(), id); + assert_eq!( + recovered + .session + .page() + .fields() + .iter() + .map(|field| field.id()) + .collect::>(), + ids + ); + let recovered_inputs = recovered.inputs.unwrap(); + assert_eq!( + recovered_inputs + .first() + .unwrap() + .clone() + .into_parts() + .2 + .as_deref(), + Some("background-safe draft") + ); + // Validation failures leave both the document and encrypted checkpoint available. + let invalid = page + .fields() + .iter() + .map(|field| { + MobileEntryEditorInput::new( + field.id(), + if field.name_editable() { + Some("bad\nname".to_owned()) + } else { + field.name().map(str::to_owned) + }, + field.value().map(str::to_owned), + ) + }) + .collect(); + authentication.checkpoint_entry_editor_inputs(id, 5, invalid)?; + let invalid = authentication + .resume_entry_editor(id, None)? + .inputs + .unwrap(); + assert!(authentication.save_entry_editor(id, invalid).is_err()); + assert!(authentication.entry_editor(id).is_ok()); + authentication.save_entry_editor(id, recovered_inputs)?; + assert!( + authentication.resume_entry_editor(id, None).is_err(), + "successful save deletes the checkpoint" + ); + let opened = authentication.begin_entry_editor("checkpoint/entry")?; + authentication.checkpoint_entry_editor(opened.id(), 6)?; + let other = make()?; + other.unlock_entry("checkpoint/entry", Some(password()))?; + other.replace_entry_field("checkpoint/entry", ids[0], "concurrent change".to_owned())?; + authentication.manual_lock()?; + let recovered = authentication.resume_entry_editor(opened.id(), Some(password()))?; + let fields = recovered + .session + .page() + .fields() + .iter() + .map(|field| { + MobileEntryEditorInput::new( + field.id(), + field.name().map(str::to_owned), + field.value().map(str::to_owned), + ) + }) + .collect(); + assert!( + authentication + .save_entry_editor(opened.id(), fields) + .is_err(), + "checkpoint must preserve its original conflict base" + ); + authentication.discard_entry_editor(opened.id())?; + assert!( + authentication + .resume_entry_editor(opened.id(), None) + .is_err() + ); + let opened = authentication.begin_entry_editor("checkpoint/entry")?; + authentication.checkpoint_entry_editor(opened.id(), 7)?; + authentication.set_authentication_timeout(1)?; + clock.advance(Duration::from_secs(2)); + assert!(!authentication.state()?.unlocked()); + assert!( + authentication + .resume_entry_editor(opened.id(), Some(password())) + .is_ok() + ); + authentication.lifecycle(MobileLifecycleEvent::ProcessRecreated)?; + assert!(!authentication.state()?.unlocked()); + assert!( + authentication + .resume_entry_editor(opened.id(), Some(password())) + .is_err() + ); + let repository = Repository::open(vault.path())?; + let keys = KeyStore::load(fixture.path("keys"))?; + let path = ironstorage::repository::EntryPath::parse("checkpoint/binary")?; + let recipients = ironstorage::recipient::RecipientPolicyManager::new(&repository, &keys) + .resolve_for_entry(&path, None)?; + let bytes = b"opaque password\nunknown: \xff\nPIN: 123493\n"; + let encrypted = keys.encrypt(SecretBytes::new(bytes.to_vec()), recipients.recipients())?; + repository.write_entry(&path, &encrypted)?; + authentication.unlock_entry("checkpoint/binary", Some(password()))?; + let editor = authentication.begin_entry_editor("checkpoint/binary")?; + let opaque = editor + .page() + .fields() + .iter() + .find(|field| field.value().is_none()) + .unwrap(); + assert!(!opaque.name_editable()); + authentication.checkpoint_entry_editor(editor.id(), 8)?; + authentication.manual_lock()?; + let recovered = authentication.resume_entry_editor(editor.id(), Some(password()))?; + let fields = recovered + .session + .page() + .fields() + .iter() + .map(|field| { + MobileEntryEditorInput::new( + field.id(), + field.name().map(str::to_owned), + if field.name() == Some("PIN") { + Some("123494".to_owned()) + } else { + field.value().map(str::to_owned) + }, + ) + }) + .collect(); + authentication.save_entry_editor(editor.id(), fields)?; + struct FixtureSecret { + fingerprint: String, + password: Vec, + } + impl ironstorage::crypto::SecretProvider for FixtureSecret { + fn secret_for( + &mut self, + key: &KeyInfo, + ) -> Result { + if key.fingerprint().as_str() != self.fingerprint { + return Err(ironstorage::crypto::SecretProviderError::Unavailable); + } + Ok(SecretBytes::new(self.password.clone())) + } + } + let decrypted = keys.decrypt( + &repository.read_entry(&path)?, + &mut FixtureSecret { + fingerprint: fixture.key("alice")?.primary_fingerprint.clone(), + password: fixture.key("alice")?.passphrase.as_bytes().to_vec(), + }, + )?; + assert_eq!( + decrypted.expose(), + b"opaque password\nunknown: \xff\nPIN: 123494\n", + "opaque non-UTF8 fields must round-trip losslessly" + ); + Ok(()) +} diff --git a/docs/android/entry-editor.md b/docs/android/entry-editor.md new file mode 100644 index 0000000..3feaf35 --- /dev/null +++ b/docs/android/entry-editor.md @@ -0,0 +1,61 @@ +# Android entry editor + +Passwords offers New folder and New entry in configured stores, including an +empty store. An unlocked entry offers Edit entry. The native form presents +Rust field IDs, kinds, names, sensitivity, capabilities and generation options. +Values are hidden until explicitly revealed. Named/repeated fields, multiline +notes and OTP URIs can be added; accessible Up/Down and Remove controls obey +Rust capabilities. Opaque non-UTF8 fields remain intact and cannot be edited. +Generation requires confirmation before replacing the password. + +Rust owns validation, lossless entry serialization, recipients, generation, +revision checks, atomic encryption/write and Git commits. Kotlin collects input +and presents typed results. Failed validation and stale saves keep the draft; +an explicit discard is required before Back, another tab or manual Lock can +abandon dirty changes. Background/expiry clears plaintext presentation at once. + +The Rust authentication owner retains a ciphertext-only checkpoint in memory, +including stable IDs, exact bytes and the original conflict base. Collected +inputs are encrypted with monotonic versions; late completion cannot replace +newer input. Unlock restores that draft without silently adopting a newer +repository revision. The checkpoint is neither a repository format nor a disk +draft. Save and confirmed discard remove it; process recreation removes all +checkpoints and starts locked. SavedStateHandle contains only an editor-open +boolean for the Rust-provided discarded-draft explanation, never fields or +native editor handles. + +The existing protected Compose surface disables autofill/content capture, +personalized IME learning and extraction. Activity/dialog secure flags protect +screenshots; window focus loss and memory pressure clear protected presentation. +Editor records redact their string representations. Long fields use a lazy +scrolling form with IME padding, focus traversal and multiline input; keyboard +activation and 200% text are exercised by instrumentation. + +Regression coverage uses public encrypted fixtures for folder/entry creation, +dynamic repeated fields, PINs, multiline Unicode, OTP, add/remove/reorder, +generation, atomic save, invalid input, stale conflict, explicit discard, +background and two-second authentication expiry. A Rust fixture preserves an +opaque non-UTF8 value through checkpoint recovery and a neighboring field edit. +Compose tests cover typed callbacks, masking, keyboard save, confirmation, +configured-empty creation and process-restart saved-state boundaries. + +Validation uses ARM64 emulators on API 26 (4 KB), API 37 (4 KB) and API 37 +(strict 16 KB), with app/core/bridge instrumentation and five cold starts on +each. Debug/release builds, both ARM native ABIs, packaging, lint, architecture, +dependency/license and SBOM gates accompany all four Rust workspace checks. +ARMv7 is built and packaged; no live ARMv7 image was available. Physical devices +and x86_64 are outside this milestone's support scope. + +Computer use in Android Studio's embedded emulator exercises a freshly created +public fixture store: folder/entry creation, confirmed generation, dynamic PIN +fields, save and reopening, dirty tab cancellation, background/expiry recovery +and confirmed discard. The fixture uses a known public passphrase, with no user +secret required. Editor entry clears viewer presentation without starting a +competing storage refresh; completed mutations wait for directory refresh before +accepting another operation. The core regression keeps the native process's +single immutable private root throughout viewer and editor checks. + +The additive shared contract regenerates hardened Swift bindings. The iPhone +and dependent watch simulator targets build after XcodeGen generation; the +iPhone update preserves its app container, and computer use verifies locked +folder navigation with existing data and biometric enrollment retained.