diff --git a/.gitea/scripts/android_ci.py b/.gitea/scripts/android_ci.py index 5313b04..456d783 100644 --- a/.gitea/scripts/android_ci.py +++ b/.gitea/scripts/android_ci.py @@ -97,7 +97,7 @@ def verification_phases(): return ( tuple(f":{module}:testDebugUnitTest" for module in MODULES), (":app:assembleDebug", ":app:assembleRelease", ":app:bundleRelease"), - *((f":{module}:assembleDebugAndroidTest",) for module in ("app", "rust-bridge", "platform")), + *((f":{module}:assembleDebugAndroidTest",) for module in ("app", "core", "rust-bridge", "platform")), *((f":{module}:ktlintCheck", f":{module}:lintDebug") for module in MODULES), ("androidVerification",), ) @@ -268,6 +268,7 @@ def emulator(serial): command("install", "-r", apk) # Android's test runner and Rust fixtures cover typed errors, callbacks and cancellation. suites = [("app", "de.rfc1437.ironstorage.test", None), + ("core", "de.rfc1437.ironstorage.core.test", None), ("rust-bridge", "de.rfc1437.ironstorage.rustbridge.test", None)] native_timings = [] for module, package, _ in suites: @@ -311,7 +312,7 @@ class SelfTest(unittest.TestCase): phases = verification_phases() expected = {f":{module}:{task}" for module in MODULES for task in ("testDebugUnitTest", "ktlintCheck", "lintDebug")} - expected.update(f":{module}:assembleDebugAndroidTest" for module in ("app", "rust-bridge", "platform")) + expected.update(f":{module}:assembleDebugAndroidTest" for module in ("app", "core", "rust-bridge", "platform")) expected.update((":app:assembleDebug", ":app:assembleRelease", ":app:bundleRelease", "androidVerification")) self.assertEqual({task for phase in phases for task in phase}, expected) self.assertEqual(phases[-1], ("androidVerification",)) diff --git a/README.md b/README.md index d1b80a7..8bd74b1 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,8 @@ The native Android architecture, support policy and complete mobile parity/issue map are recorded in [`docs/android/architecture.md`](docs/android/architecture.md) and [`docs/android/parity.md`](docs/android/parity.md). Android implementation belongs to milestone 06; the decision record does not claim a shipped client. +The structured Android viewer and its validation are documented in +[`docs/android/entry-viewer.md`](docs/android/entry-viewer.md). ## Project layout diff --git a/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/EntryViewerTest.kt b/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/EntryViewerTest.kt new file mode 100644 index 0000000..4461f9a --- /dev/null +++ b/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/EntryViewerTest.kt @@ -0,0 +1,335 @@ +package de.rfc1437.ironstorage + +import android.content.ClipboardManager +import android.view.WindowManager +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performScrollToNode +import androidx.compose.ui.test.performSemanticsAction +import androidx.compose.ui.test.performTextReplacement +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.PasswordDestination +import de.rfc1437.ironstorage.core.PasswordEdit +import de.rfc1437.ironstorage.core.PasswordField +import de.rfc1437.ironstorage.core.PasswordKind +import de.rfc1437.ironstorage.core.PasswordMutation +import de.rfc1437.ironstorage.core.PasswordMutationPlan +import de.rfc1437.ironstorage.core.PasswordRepository +import de.rfc1437.ironstorage.core.PasswordRow +import de.rfc1437.ironstorage.core.PasswordSection +import de.rfc1437.ironstorage.core.PasswordState +import de.rfc1437.ironstorage.core.PasswordTotp +import de.rfc1437.ironstorage.core.ShellIcon +import de.rfc1437.ironstorage.features.PasswordScreen +import de.rfc1437.ironstorage.platform.AndroidSecretClipboard +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Assert.assertEquals +import org.junit.Assume.assumeTrue +import org.junit.Rule +import org.junit.Test + +@OptIn(ExperimentalTestApi::class) +class EntryViewerTest { + @get:Rule val compose = createAndroidComposeRule() + + @Test fun maskedFieldsExplicitActionsEditorDiagnosticsAndRevocation() { + val repository = Fixture() + compose.activity.runOnUiThread { + compose.activity.setContent { + MaterialTheme { PasswordScreen(repository, repository.authentication) } + } + } + compose.onNodeWithText("654321").assertDoesNotExist() + compose.onNodeWithText("Unlock").performClick() + compose.onNodeWithText("alice 日本語").performScrollTo().assertIsDisplayed() + compose.onNodeWithText("123456").performScrollTo().assertIsDisplayed() + compose.onNodeWithContentDescription("Reveal PIN").performScrollTo() + .performSemanticsAction(SemanticsActions.OnClick) { org.junit.Assert.assertTrue(it()) } + assertEquals(2uL, repository.lastId) + compose.onNodeWithTag("entry-detail").performScrollToNode(hasText("654321")) + compose.onNodeWithText("654321").assertIsDisplayed() + compose.onNodeWithContentDescription("Hide PIN").performScrollTo().performClick() + compose.onNodeWithText("654321").assertDoesNotExist() + compose.onNodeWithContentDescription("Copy PIN").performScrollTo() + .performSemanticsAction(SemanticsActions.OnClick) { org.junit.Assert.assertTrue(it()) } + assertEquals(2uL, repository.lastId) + compose.onNodeWithContentDescription("Edit PIN").performScrollTo().performClick() + compose.onNode(hasSetTextAction()).performTextReplacement("987654") + compose.onNodeWithText("Save").performClick() + assertEquals("987654", repository.saved) + compose.onNodeWithTag( + "entry-detail" + ).performScrollToNode(hasText("First line\nSecond line 🌻")) + compose.onNodeWithText("First line\nSecond line 🌻").assertIsDisplayed() + compose.onNodeWithTag("entry-detail").performScrollToNode(hasText("Invalid OTP URI")) + compose.onNodeWithText("Invalid OTP URI").assertIsDisplayed() + compose.runOnIdle { + repository.state.value = + repository.state.value.copy(edit = PasswordEdit(2u, "PIN", "987654", false, true)) + } + compose.runOnIdle { repository.lease.value = AuthenticationState() } + compose.onNode(hasSetTextAction()).assertDoesNotExist() + compose.onNodeWithText("123456").assertDoesNotExist() + compose.onNodeWithText("First line\nSecond line 🌻").assertDoesNotExist() + compose.onNodeWithText("Unlock").assertIsDisplayed() + } + + @Test fun overflowRequiresDestinationAndExplicitMutationConfirmation() { + val repository = Fixture() + repository.unlock("") + compose.activity.runOnUiThread { + compose.activity.setContent { + MaterialTheme { PasswordScreen(repository, repository.authentication) } + } + } + compose.onNodeWithText("More").performScrollTo().performClick() + compose.onNodeWithText("Move entry").performClick() + compose.onNodeWithText("destination\nFolder").performClick() + compose.onNodeWithText("Confirm").performClick() + assertEquals("destination", repository.destination) + assertEquals(PasswordMutation.Move, repository.action) + } + + @Test fun clipboardMarksExplicitCopiesSensitiveAndViewerPreventsScreenCapture() { + compose.runOnIdle { + val clipboard = AndroidSecretClipboard(compose.activity) + clipboard.write("public-copy-fixture".toByteArray()) + val manager = compose.activity.getSystemService(ClipboardManager::class.java) + org.junit.Assert.assertTrue( + manager.primaryClipDescription!!.extras!! + .getBoolean("android.content.extra.IS_SENSITIVE") + ) + assertEquals("public-copy-fixture", clipboard.read()!!.toString(Charsets.UTF_8)) + clipboard.clear() + org.junit.Assert.assertTrue( + compose.activity.window.attributes.flags and + WindowManager.LayoutParams.FLAG_SECURE != 0 + ) + } + } + + @Test fun inspectDisposableViewerWithComputerUse() { + val arguments = InstrumentationRegistry.getArguments() + assumeTrue(arguments.getString("viewerInspect") == "true") + val repository = Fixture() + val done = CountDownLatch(1) + compose.activity.runOnUiThread { + compose.activity.setContent { + val density = LocalDensity.current + CompositionLocalProvider( + LocalDensity provides Density( + density.density, + arguments.getString("viewerFont")?.toFloat() ?: 1f + ) + ) { + MaterialTheme { + Column(Modifier.fillMaxSize()) { + TextButton(onClick = { + done.countDown() + }) { Text("Finish viewer inspection") } + PasswordScreen( + repository, + repository.authentication, + Modifier.weight(1f) + ) + } + } + } + } + } + compose.waitForIdle() + android.util.Log.i("IronStorage99", "viewer-inspection-ready") + val started = android.os.SystemClock.elapsedRealtime() + while (!done.await(100, TimeUnit.MILLISECONDS)) { + compose.mainClock.advanceTimeBy(100) + compose.waitForIdle() + org.junit.Assert.assertTrue(android.os.SystemClock.elapsedRealtime() - started < 600000) + } + } + private class Fixture : PasswordRepository { + private val row = + PasswordRow( + "row", + "viewer", + "Viewer", + "Locked entry", + PasswordKind.Entry, + "r", + ShellIcon.Key + ) + override val state = + MutableStateFlow(PasswordState(rows = listOf(row), selected = row, revision = "r")) + val lease = MutableStateFlow(AuthenticationState()) + val authentication = object : AuthenticationRepository { + override val state = lease + override fun reload() = Unit + override fun unlock() = Unit + override fun enroll(passphrase: String) = Unit + override fun disable() = Unit + override fun lock() { + lease.value = AuthenticationState() + } + override fun cancel() = lock() + override fun touch() = Unit + } + var lastId: ULong? = null + var saved: String? = null + var destination: String? = null + var action: PasswordMutation? = null + private val sections = listOf( + PasswordSection( + "Password", + listOf( + PasswordField(0u, "Password", "Hidden", null, sensitive = true, editable = true) + ) + ), + PasswordSection( + "Details", + listOf( + PasswordField( + 1u, + "Username", + "alice 日本語", + null, + selectable = true, + editable = true + ), + PasswordField(2u, "PIN", "Hidden", null, sensitive = true, editable = true), + PasswordField(3u, "custom", "Repeated one", null, selectable = true), + PasswordField(4u, "custom", "Repeated two", null, selectable = true) + ) + ), + PasswordSection( + "OTP", + listOf( + PasswordField( + 5u, + "OTP URI", + "Hidden", + "Invalid OTP URI", + sensitive = true + ) + ) + ), + PasswordSection( + "Notes", + listOf( + PasswordField( + 6u, + "Notes", + "First line\nSecond line 🌻", + null, + selectable = true, + multiline = true + ) + ) + ) + ) + override fun restore(path: String, selected: String?, revision: String?) = Unit + override fun refresh() = Unit + override fun activate(id: String, revision: String) = Unit + override fun inspect(id: String, revision: String) = Unit + override fun back() = lock() + override fun unlock(passphrase: String) { + lease.value = AuthenticationState(unlocked = true) + state.value = + state.value.copy(sections = sections, totp = PasswordTotp("123456", "fixture", 17u)) + } + override fun lock() { + authentication.lock() + state.value = + state.value.copy(sections = null, totp = null, edit = null) + } + private fun field(id: ULong, reveal: Boolean) { + lastId = id + state.value = state.value.copy( + sections = state.value.sections?.map { section -> + section.copy( + fields = section.fields.map { + if (it.id == + id + ) { + it.copy( + value = if (reveal) "654321" else "Hidden", + revealed = reveal + ) + } else { + it + } + } + ) + } + ) + } + override fun reveal(id: ULong) = field(id, true) + override fun hide(id: ULong) = field(id, false) + override fun copy(id: ULong) { + lastId = id + android.util.Log.i("IronStorage99", "copy-field-$id") + } + override fun copyTotp() = Unit + override fun edit(id: ULong) { + lastId = id + state.value = + state.value.copy(edit = PasswordEdit(id, "PIN", "654321", false, true)) + } + override fun dismissEdit() { + state.value = state.value.copy(edit = null) + } + override fun saveEdit(value: String) { + saved = value + dismissEdit() + } + override fun prepareMutation(action: PasswordMutation) { + this.action = action + state.value = + state.value.copy( + mutation = PasswordMutationPlan( + action, + "viewer", + "r", + listOf( + PasswordDestination( + "destination", + "destination", + "Folder", + false + ) + ), + false + ) + ) + } + override fun dismissMutation() { + state.value = state.value.copy(mutation = null) + } + override fun mutate(destination: String?, overwrite: Boolean) { + this.destination = + destination + dismissMutation() + } + } +} diff --git a/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/PasswordScreenTest.kt b/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/PasswordScreenTest.kt index 713af8b..c8aaa37 100644 --- a/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/PasswordScreenTest.kt +++ b/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/PasswordScreenTest.kt @@ -177,6 +177,16 @@ class PasswordScreenTest { } var lastId: String? = null var replaceOnRefresh = false + override fun reveal(id: ULong) = Unit + override fun hide(id: ULong) = Unit + override fun copy(id: ULong) = Unit + override fun copyTotp() = Unit + override fun edit(id: ULong) = Unit + override fun dismissEdit() = Unit + override fun saveEdit(value: String) = Unit + override fun prepareMutation(action: de.rfc1437.ironstorage.core.PasswordMutation) = Unit + override fun dismissMutation() = Unit + override fun mutate(destination: String?, overwrite: Boolean) = Unit override fun restore(path: String, selected: String?, revision: String?) = Unit override fun refresh() { if (!replaceOnRefresh) return diff --git a/android/app/src/main/kotlin/de/rfc1437/ironstorage/MainActivity.kt b/android/app/src/main/kotlin/de/rfc1437/ironstorage/MainActivity.kt index 6e1e3b9..040b133 100644 --- a/android/app/src/main/kotlin/de/rfc1437/ironstorage/MainActivity.kt +++ b/android/app/src/main/kotlin/de/rfc1437/ironstorage/MainActivity.kt @@ -1,6 +1,7 @@ package de.rfc1437.ironstorage import android.os.Bundle +import android.view.WindowManager import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.viewModels @@ -46,6 +47,7 @@ class MainActivity : FragmentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) keystore.attach(this) enableEdgeToEdge() setContent { 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 44af54c..e7e435e 100644 --- a/android/app/src/main/kotlin/de/rfc1437/ironstorage/ShellModule.kt +++ b/android/app/src/main/kotlin/de/rfc1437/ironstorage/ShellModule.kt @@ -18,6 +18,7 @@ import de.rfc1437.ironstorage.core.OnboardingRepository import de.rfc1437.ironstorage.core.PasswordRepository import de.rfc1437.ironstorage.core.ShellRepository import de.rfc1437.ironstorage.platform.AndroidKeystoreAdapter +import de.rfc1437.ironstorage.platform.AndroidSecretClipboard import de.rfc1437.ironstorage.platform.privateDirectories import javax.inject.Singleton import kotlinx.coroutines.CoroutineScope @@ -43,12 +44,14 @@ object ShellModule { @Singleton fun passwordRepository( session: NativePrivateSession, - authentication: NativeAuthenticationRepository + authentication: NativeAuthenticationRepository, + @ApplicationContext context: Context ): PasswordRepository = NativePasswordRepository( session, CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate), authentication, - Dispatchers.IO + Dispatchers.IO, + AndroidSecretClipboard(context) ) @Provides diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 9fab16f..63e16ae 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -135,5 +135,5 @@ tasks.register("androidVerification") { dependsOn(subprojects.map { "${it.path}:ktlintCheck" }) dependsOn(subprojects.map { "${it.path}:lintDebug" }) dependsOn(subprojects.map { "${it.path}:testDebugUnitTest" }) - dependsOn(":app:assembleDebugAndroidTest", ":rust-bridge:assembleDebugAndroidTest", ":platform:assembleDebugAndroidTest") + dependsOn(":core:assembleDebugAndroidTest", ":app:assembleDebugAndroidTest", ":rust-bridge:assembleDebugAndroidTest", ":platform:assembleDebugAndroidTest") } diff --git a/android/core/build.gradle.kts b/android/core/build.gradle.kts index 7db1351..b0ae39d 100644 --- a/android/core/build.gradle.kts +++ b/android/core/build.gradle.kts @@ -11,6 +11,7 @@ android { ndkVersion = libs.versions.ndk.get() defaultConfig { minSdk = libs.versions.minSdk.get().toInt() + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } compileOptions { sourceCompatibility = JavaVersion.VERSION_17 @@ -36,6 +37,9 @@ dependencies { implementation(libs.coroutines.android) testImplementation(libs.coroutines.test) testImplementation(libs.junit) + androidTestImplementation(libs.junit) + androidTestImplementation(libs.android.test.runner) + androidTestImplementation(libs.android.test.core) } val fixtureOutput = layout.buildDirectory.file( diff --git a/android/core/gradle.lockfile b/android/core/gradle.lockfile index 625b158..7a29251 100644 --- a/android/core/gradle.lockfile +++ b/android/core/gradle.lockfile @@ -3,7 +3,17 @@ # This file is expected to be part of source control. # To regenerate this file, run: ./gradlew :core:dependencies --write-locks androidx.annotation:annotation-jvm:1.11.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.annotation:annotation-jvm:1.7.0=debugAndroidTestCompileClasspath androidx.annotation:annotation:1.11.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.annotation:annotation:1.7.0=debugAndroidTestCompileClasspath +androidx.concurrent:concurrent-futures-ktx:1.2.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath +androidx.concurrent:concurrent-futures:1.2.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath +androidx.lifecycle:lifecycle-common:2.3.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath +androidx.test.services:storage:1.6.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath +androidx.test:core:1.7.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath +androidx.test:monitor:1.8.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath +androidx.test:runner:1.7.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath +androidx.tracing:tracing:1.1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath ch.qos.logback:logback-classic:1.3.15=ktlint ch.qos.logback:logback-core:1.3.15=ktlint com.android.tools.analytics-library:protos:32.4.1=androidLintTool @@ -42,6 +52,7 @@ com.google.code.gson:gson:2.11.0=androidLintTool com.google.errorprone:error_prone_annotations:2.36.0=androidLintTool com.google.guava:failureaccess:1.0.2=androidLintTool com.google.guava:guava:33.4.0-jre=androidLintTool +com.google.guava:listenablefuture:1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=androidLintTool com.google.j2objc:j2objc-annotations:3.0.0=androidLintTool com.google.jimfs:jimfs:1.1=androidLintTool @@ -75,7 +86,7 @@ io.github.oshai:kotlin-logging:5.1.0=ktlint,ktlintBaselineReporter,ktlintReporte jakarta.activation:jakarta.activation-api:1.2.1=androidLintTool jakarta.xml.bind:jakarta.xml.bind-api:2.3.2=androidLintTool javax.inject:javax.inject:1=androidLintTool -junit:junit:4.13.2=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +junit:junit:4.13.2=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath net.java.dev.jna:jna-platform:5.6.0=androidLintTool net.java.dev.jna:jna:5.19.1=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath net.java.dev.jna:jna:5.6.0=androidLintTool @@ -94,7 +105,7 @@ org.checkerframework:checker-qual:3.43.0=androidLintTool org.ec4j.core:ec4j-core:1.1.1=ktlint,ktlintBaselineReporter,ktlintRuleset org.glassfish.jaxb:jaxb-runtime:2.3.2=androidLintTool org.glassfish.jaxb:txw2:2.3.2=androidLintTool -org.hamcrest:hamcrest-core:1.3=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +org.hamcrest:hamcrest-core:1.3=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath org.jetbrains.kotlin:kotlin-build-tools-api:2.4.0=kotlinAbiValidationCompatClasspath org.jetbrains.kotlin:kotlin-build-tools-api:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath org.jetbrains.kotlin:kotlin-build-tools-compat:2.4.21=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath 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 new file mode 100644 index 0000000..cab3904 --- /dev/null +++ b/android/core/src/androidTest/kotlin/de/rfc1437/ironstorage/core/NativeEntryViewerTest.kt @@ -0,0 +1,228 @@ +package de.rfc1437.ironstorage.core + +import androidx.test.platform.app.InstrumentationRegistry +import de.rfc1437.ironstorage.bridge.generated.MobileEntryEditorFieldKind +import de.rfc1437.ironstorage.bridge.generated.MobileEntryEditorInput +import java.io.File +import java.util.UUID +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Actual encrypted Rust documents and Kotlin facade, with deterministic OS capabilities. */ +class NativeEntryViewerTest { + @Test fun fieldsEditingCopyMutationsAndTotpExpiryUseTheNativeOwner() = runBlocking { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val name = "viewer-${UUID.randomUUID()}" + val roots = listOf(context.filesDir, context.noBackupFilesDir, context.cacheDir).map { + File(it, name).apply { mkdirs() }.canonicalPath + } + val session = NativePrivateSession(context, { + PrivateDirectories(roots[0], roots[1], roots[2]) + }, MemoryKeystore()) + withContext(Dispatchers.IO) { session.initialize() } + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + val clipboard = MemoryClipboard() + val authentication = NativeAuthenticationRepository(session, scope, Dispatchers.IO) + val repository = + NativePasswordRepository(session, scope, authentication, Dispatchers.IO, clipboard) + val password = "public-viewer-passphrase" + suspend fun request(action: () -> Unit): PasswordState { + withContext(Dispatchers.Main.immediate) { action() } + return withTimeout(30000) { repository.state.first { !it.loading } } + } + fun fields() = requireNotNull(repository.state.value.sections).flatMap { it.fields } + suspend fun open() { + val navigation = request { repository.restore("", null, null) } + val row = navigation.rows.single { it.title == "viewer" } + request { repository.activate(row.id, navigation.revision) } + assertNull(repository.state.value.sections) + val page = request { repository.unlock(password) } + assertNull(page.error) + assertNotNull(page.sections) + } + try { + withContext(Dispatchers.IO) { + val setup = session.storage().androidOnboarding(session.backend()) + val key = setup.generate("Viewer fixture", password).selectedKey + setup.setupLocal(requireNotNull(key), password) + } + authentication.authenticated { auth -> + val draft = auth.beginCreateEntry("", "viewer", password) + val first = draft.page.fields.first() + auth.updateEntryEditor( + draft.id, + listOf(MobileEntryEditorInput(first.id, first.name, "fixture-secret")) + ) + auth.addEntryEditorField( + draft.id, + MobileEntryEditorFieldKind.FIELD, + "PIN", + "123493" + ) + auth.addEntryEditorField( + draft.id, + MobileEntryEditorFieldKind.FIELD, + "custom", + "Unicode Ελληνικά 日本語" + ) + auth.addEntryEditorField( + draft.id, + MobileEntryEditorFieldKind.FIELD, + "custom", + "Repeated field" + ) + auth.addEntryEditorField( + draft.id, + MobileEntryEditorFieldKind.NOTE, + null, + "First line\nSecond line" + ) + val page = auth.addEntryEditorField( + draft.id, + MobileEntryEditorFieldKind.OTP_URI, + null, + "otpauth://totp/Fixture:alice?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&issuer=Fixture" + ) + auth.saveEntryEditor( + draft.id, + page.fields.map { + MobileEntryEditorInput(it.id, it.name, it.value.orEmpty()) + } + ) + auth.createDirectory("", "destination") + auth.setAuthenticationTimeout(30u) + } + open() + val sensitive = fields().filter { it.sensitive } + assertEquals(3, sensitive.size) + assertTrue(sensitive.all { it.value == "Hidden" && !it.revealed && !it.selectable }) + assertEquals(2, fields().count { it.label == "Custom" }) + assertTrue(fields().any { it.value == "First line\nSecond line" && it.multiline }) + assertNotNull(repository.state.value.totp) + assertTrue(repository.state.value.totp!!.remainingSeconds in 1uL..30uL) + val pin = sensitive.single { it.label == "PIN" } + request { repository.reveal(pin.id) } + assertEquals("123493", fields().single { it.id == pin.id }.value) + request { repository.hide(pin.id) } + assertEquals("Hidden", fields().single { it.id == pin.id }.value) + request { repository.copy(pin.id) } + withTimeout(10000) { while (clipboard.value == null) delay(20) } + assertEquals("123493", clipboard.value!!.toString(Charsets.UTF_8)) + request { repository.edit(pin.id) } + assertEquals(pin.id, repository.state.value.edit!!.id) + request { repository.saveEdit("654321") } + assertNull(repository.state.value.error) + assertNull(repository.state.value.edit) + assertEquals("Hidden", fields().single { it.id == pin.id }.value) + request { repository.reveal(pin.id) } + assertEquals("654321", fields().single { it.id == pin.id }.value) + request { repository.prepareMutation(PasswordMutation.Copy) } + val target = repository.state.value.mutation!!.destinations.single { + it.path == + "destination" + } + request { repository.mutate(target.path, false) } + assertNull(repository.state.value.error) + request { repository.restore("destination", null, null) } + assertEquals("viewer", repository.state.value.rows.single().title) + open() + request { repository.prepareMutation(PasswordMutation.Delete) } + request { repository.mutate(null, false) } + assertTrue(repository.state.value.rows.none { it.title == "viewer" }) + val copied = request { repository.restore("destination", null, null) } + request { repository.activate(copied.rows.single().id, copied.revision) } + request { repository.unlock(password) } + request { repository.edit(fields().first().id) } + request { repository.lock() } + assertNull(repository.state.value.sections) + assertNull(repository.state.value.edit) + assertNull(repository.state.value.totp) + withTimeout(10000) { while (clipboard.value != null) delay(20) } + request { repository.unlock(password) } + authentication.authenticated { + it.replaceEntryField("destination/viewer", pin.id, "stale external update") + } + request { repository.reveal(pin.id) } + assertNull(repository.state.value.sections) + assertFalse(authentication.state.value.unlocked) + val changed = request { repository.restore("destination", null, null) } + request { repository.activate(changed.rows.single().id, changed.revision) } + request { repository.unlock(password) } + authentication.authenticated { it.setAuthenticationTimeout(2u) } + withTimeout(10000) { authentication.state.first { !it.unlocked } } + withTimeout(10000) { repository.state.first { it.sections == null } } + assertNull(repository.state.value.totp) + assertFalse(authentication.state.value.unlocked) + } finally { + withContext(Dispatchers.Main.immediate) { repository.lock() } + scope.cancel() + } + } + private class MemoryClipboard : SecretClipboard { + @Volatile var value: ByteArray? = null + override fun read() = value?.copyOf() + override fun write(value: ByteArray) { + this.value = value.copyOf() + } + override fun clear() { + value = null + } + } + private class MemoryKeystore : AndroidKeystore { + private val keys = mutableSetOf() + override fun deviceState() = + KeystoreDeviceState(android.os.Build.VERSION.SDK_INT, false, true, false, false) + override fun createKey(specification: KeystoreKeySpecification) { + keys.add(specification.alias) + } + private data class Record(val alias: String, val aad: ByteArray, val secret: ByteArray) + private val ciphertexts = mutableMapOf, Record>() + override fun encrypt( + alias: String, + authorization: KeystoreAuthorization, + aad: ByteArray, + value: ByteArray + ): KeystoreCiphertext { + if (alias !in keys) throw KeystoreException(KeystoreFailure.MissingKey) + val seed = UUID.randomUUID().toString().toByteArray() + val token = ByteArray(value.size + 16) { seed[it % seed.size] } + ciphertexts[token.toList()] = Record(alias, aad.copyOf(), value.copyOf()) + return KeystoreCiphertext(token.take(12).toByteArray(), token) + } + override fun decrypt( + alias: String, + authorization: KeystoreAuthorization, + aad: ByteArray, + value: KeystoreCiphertext + ): ByteArray { + if (alias !in keys) throw KeystoreException(KeystoreFailure.MissingKey) + val record = + ciphertexts[value.bytes.toList()] + ?: throw KeystoreException(KeystoreFailure.Corrupted) + if (record.alias != alias || !record.aad.contentEquals(aad) || + !value.nonce.contentEquals(value.bytes.take(12).toByteArray()) + ) { + throw KeystoreException(KeystoreFailure.Corrupted) + } + return record.secret.copyOf() + } + override fun deleteKey(alias: String) { + keys.remove(alias) + } + override fun aliases() = keys.toList() + override fun cancelPending() = Unit + } +} diff --git a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativePasswordRepository.kt b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativePasswordRepository.kt index c4ae1f2..9500cb8 100644 --- a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativePasswordRepository.kt +++ b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativePasswordRepository.kt @@ -1,17 +1,29 @@ package de.rfc1437.ironstorage.core +import android.os.SystemClock import de.rfc1437.ironstorage.bridge.NativeBridgeException import de.rfc1437.ironstorage.bridge.generated.MobileAuthenticationFfiException +import de.rfc1437.ironstorage.bridge.generated.MobileClipboard +import de.rfc1437.ironstorage.bridge.generated.MobileClipboardContent +import de.rfc1437.ironstorage.bridge.generated.MobileClipboardWait +import de.rfc1437.ironstorage.bridge.generated.MobileEntryPresentation +import de.rfc1437.ironstorage.bridge.generated.MobileMutationAction +import de.rfc1437.ironstorage.bridge.generated.MobileMutationRequest import de.rfc1437.ironstorage.bridge.generated.MobilePasswordFfiException import de.rfc1437.ironstorage.bridge.generated.MobilePasswordNavigation import de.rfc1437.ironstorage.bridge.generated.MobilePasswordRowKind +import de.rfc1437.ironstorage.bridge.generated.MobilePlatformFfiException import de.rfc1437.ironstorage.bridge.generated.MobilePrivateStorageFfiException +import de.rfc1437.ironstorage.bridge.generated.copyMobileEntryClearing import java.util.concurrent.atomic.AtomicLong import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext @@ -20,7 +32,8 @@ class NativePasswordRepository( private val session: NativePrivateSession, private val scope: CoroutineScope, private val authentication: NativeAuthenticationRepository, - private val io: CoroutineDispatcher + private val io: CoroutineDispatcher, + private val clipboard: SecretClipboard ) : PasswordRepository { private val mutable = MutableStateFlow(PasswordState()) override val state: StateFlow = mutable @@ -29,7 +42,32 @@ class NativePasswordRepository( init { scope.launch { authentication.state.collect { lease -> - if (!lease.unlocked) mutable.value = mutable.value.copy(sections = null) + if (!lease.unlocked) mutable.value = mutable.value.cleared() + } + } + scope.launch { + while (true) { + delay(1000) + val current = mutable.value + if (current.totp != null && !current.loading && + authentication.state.value.unlocked && + !authentication.state.value.busy + ) { + content { current -> + val detail = authentication.authenticated { auth -> + auth.refreshEntryPresentation( + requireNotNull(current.selected).path, + requireNotNull(current.documentRevision), + unixSeconds() + ).totp + } + current.copy( + totp = detail?.let { + PasswordTotp(it.code, it.account, it.remainingSeconds) + } + ) + } + } } } } @@ -83,31 +121,228 @@ class NativePasswordRepository( (System.currentTimeMillis() / 1000).toULong() ) } - verified.copy( - sections = page.page.sections.map { section -> - PasswordSection( - section.title, - section.fields.map { field -> - PasswordField( - field.id, - field.label, - field.value ?: field.maskedValue, - field.diagnostic - ) - } - ) - } - ) + verified.present(page) } } override fun lock() { sequence.incrementAndGet() - mutable.value = mutable.value.copy(sections = null, loading = false) + mutable.value = mutable.value.cleared().copy(loading = false) authentication.lock() } - private fun run(action: suspend () -> PasswordState) { + override fun hide(id: ULong) { + sequence.incrementAndGet() + mutable.value = mutable.value.copy( + loading = false, + sections = mutable.value.sections?.map { section -> + section.copy( + fields = section.fields.map { field -> + if (field.id == + id + ) { + field.copy(value = field.maskedValue, revealed = false) + } else { + field + } + } + ) + } + ) + } + override fun reveal(id: ULong) = content { current -> + val value = authentication.authenticated { + it.revealPresentedEntryField( + current.entryPath(), + id, + requireNotNull(current.documentRevision) + ) + } + current.copy( + sections = current.sections?.map { section -> + section.copy( + fields = section.fields.map { field -> + if (field.id == id) field.copy(value = value, revealed = true) else field + } + ) + } + ) + } + override fun edit(id: ULong) = content { current -> + val field = + current.sections?.flatMap { it.fields }?.find { it.id == id && it.editable } + ?: return@content current + val value = authentication.authenticated { + it.revealPresentedEntryField( + current.entryPath(), + id, + requireNotNull(current.documentRevision) + ) + } + current.copy( + edit = PasswordEdit( + id, + field.label, + value, + field.multiline, + field.sensitive + ) + ) + } + override fun dismissEdit() { + sequence.incrementAndGet() + mutable.value = mutable.value.copy(edit = null, loading = false) + } + override fun saveEdit(value: String) = content { current -> + val edit = current.edit ?: return@content current + authentication.authenticated { + it.replacePresentedEntryField( + current.entryPath(), + edit.id, + requireNotNull(current.documentRevision), + value + ) + } + val page = authentication.authenticated { it.entryPage(current.entryPath(), unixSeconds()) } + val folder = session.storage().passwordNavigation(current.path, null, null, false) + val refreshed = session.storage().passwordNavigation( + current.path, + current.selected?.id, + folder.revision, + false + ).toFacade() + if (refreshed.selected == null) refreshed else refreshed.present(page) + } + override fun copy(id: ULong) = copyValue { auth, current -> + auth.copyPresentedEntryField( + current.entryPath(), + id, + requireNotNull(current.documentRevision) + ) + } + override fun copyTotp() = copyValue { auth, current -> + auth.copyPresentedTotpCode( + current.entryPath(), + requireNotNull(current.documentRevision), + unixSeconds() + ) + } + private fun copyValue( + get: (de.rfc1437.ironstorage.bridge.generated.MobileAuthentication, PasswordState) -> + de.rfc1437.ironstorage.bridge.generated.MobileEntryCopy + ) = content { current -> + val copy = authentication.authenticated { get(it, current) } + val ticket = sequence.get() + scope.launch(io) { + try { + copyMobileEntryClearing( + copy, + object : MobileClipboard { + override fun read(): MobileClipboardContent = + runBlocking(Dispatchers.Main.immediate) { + clipboard.read()?.let { MobileClipboardContent.Text(it) } + ?: MobileClipboardContent.EmptyOrNonText + } + override fun write(value: ByteArray) = + runBlocking(Dispatchers.Main.immediate) { + // A lock may have happened after Rust returned the copy value. + if (!authentication.state.value.unlocked) { + throw MobilePlatformFfiException.Cancelled() + } + clipboard.write(value) + if (ticket == sequence.get()) { + mutable.value = mutable.value.copy( + notice = "Copied. The clipboard will be " + + "cleared automatically." + ) + } + } + override fun clear() = runBlocking(Dispatchers.Main.immediate) { + clipboard.clear() + } + override fun waitForTimeout(milliseconds: ULong): MobileClipboardWait { + val start = SystemClock.elapsedRealtime() + while ((SystemClock.elapsedRealtime() - start).toULong() < + milliseconds + ) { + if (!authentication.state.value.unlocked) { + return MobileClipboardWait.CANCELLED + } + Thread.sleep(50) + } + return MobileClipboardWait.ELAPSED + } + } + ) + } catch (_: MobilePlatformFfiException.Cancelled) { + // Rust cleans up before returning cancellation. + } catch (_: MobilePlatformFfiException) { + withContext(Dispatchers.Main.immediate) { + if (ticket == sequence.get()) { + mutable.value = + mutable.value.copy(error = "Copy is unavailable.", notice = null) + } + } + } + } + current + } + override fun prepareMutation(action: PasswordMutation) = content { current -> + val plan = authentication.authenticated { + it.preparePresentedEntryMutation( + current.entryPath(), + requireNotNull(current.documentRevision), + action.native() + ) + } + current.copy( + mutation = PasswordMutationPlan( + action, + plan.source, + plan.revision, + plan.destinations.map { + PasswordDestination(it.path, it.title, it.detail, it.requiresOverwrite) + }, + plan.hasOpenEditor + ) + ) + } + override fun dismissMutation() { + sequence.incrementAndGet() + mutable.value = mutable.value.copy(mutation = null, loading = false) + } + override fun mutate(destination: String?, overwrite: Boolean) = content { current -> + val plan = current.mutation ?: return@content current + authentication.authenticated { + it.performEntryMutation( + MobileMutationRequest( + plan.action.native(), + plan.source, + plan.revision, + destination, + true, + overwrite, + plan.discardEditor + ) + ) + } + session.storage().passwordNavigation(current.path, null, null, false).toFacade() + } + private fun content(action: suspend (PasswordState) -> PasswordState) { + val current = mutable.value + if (current.sections == null || current.selected == null || + current.documentRevision == null || !authentication.state.value.unlocked || + current.loading + ) { + return + } + run(false) { action(current) } + } + private fun run(clear: Boolean = true, action: suspend () -> PasswordState) { val ticket = sequence.incrementAndGet() - mutable.value = mutable.value.copy(loading = true, error = null, sections = null) + mutable.value = + (if (clear) mutable.value.cleared() else mutable.value).copy( + loading = true, + error = null + ) scope.launch { mutex.withLock { if (sequence.get() != ticket) return@withLock @@ -130,10 +365,9 @@ class NativePasswordRepository( ) } if (sequence.get() == ticket) { - mutable.value = result.copy( - loading = false, - sections = result.sections.takeIf { authentication.state.value.unlocked } - ) + mutable.value = + (if (authentication.state.value.unlocked) result else result.cleared()) + .copy(loading = false) } } } @@ -164,3 +398,40 @@ private fun MobilePasswordNavigation.toFacade(): PasswordState { stale = stale ) } + +private fun unixSeconds() = (System.currentTimeMillis() / 1000).toULong() +private fun PasswordState.cleared() = copy( + sections = null, + totp = null, + documentRevision = null, + edit = null, + mutation = null, + notice = null +) +private fun PasswordState.present(value: MobileEntryPresentation) = copy( + documentRevision = value.page.revision, + sections = value.page.sections.map { section -> + PasswordSection( + section.title, + section.fields.map { field -> + PasswordField( + field.id, field.label, field.value ?: field.maskedValue, field.diagnostic, + field.sensitive, field.selectable, field.editable, + field.multiline, field.detail, + maskedValue = field.maskedValue + ) + } + ) + }, + totp = value.totp?.let { PasswordTotp(it.code, it.account, it.remainingSeconds) }, + notice = value.cacheNotice, + edit = null, + mutation = null +) +private fun PasswordMutation.native() = when (this) { + PasswordMutation.Move -> MobileMutationAction.MOVE + PasswordMutation.Copy -> MobileMutationAction.COPY + PasswordMutation.Delete -> MobileMutationAction.DELETE +} + +private fun PasswordState.entryPath() = requireNotNull(selected).path diff --git a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/PasswordRepository.kt b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/PasswordRepository.kt index d55460f..1a64958 100644 --- a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/PasswordRepository.kt +++ b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/PasswordRepository.kt @@ -16,9 +16,44 @@ data class PasswordField( val id: ULong, val label: String, val value: String, - val diagnostic: String? -) + val diagnostic: String?, + val sensitive: Boolean = false, + val selectable: Boolean = false, + val editable: Boolean = false, + val multiline: Boolean = false, + val detail: String? = null, + val revealed: Boolean = false, + val maskedValue: String = value +) { + override fun toString() = "PasswordField(id=$id, [REDACTED])" +} data class PasswordSection(val title: String, val fields: List) +data class PasswordTotp(val code: String, val account: String, val remainingSeconds: ULong) { + override fun toString() = "PasswordTotp([REDACTED])" +} +data class PasswordEdit( + val id: ULong, + val label: String, + val value: String, + val multiline: Boolean, + val sensitive: Boolean +) { + override fun toString() = "PasswordEdit(id=$id, [REDACTED])" +} +enum class PasswordMutation { Move, Copy, Delete } +data class PasswordDestination( + val path: String, + val title: String, + val detail: String, + val overwrite: Boolean +) +data class PasswordMutationPlan( + val action: PasswordMutation, + val source: String, + val revision: String, + val destinations: List, + val discardEditor: Boolean +) data class PasswordState( val path: String = "", val title: String = "Passwords", @@ -29,7 +64,12 @@ data class PasswordState( val loading: Boolean = false, val stale: Boolean = false, val error: String? = null, - val sections: List? = null + val sections: List? = null, + val totp: PasswordTotp? = null, + val documentRevision: String? = null, + val notice: String? = null, + val edit: PasswordEdit? = null, + val mutation: PasswordMutationPlan? = null ) interface PasswordRepository { val state: StateFlow @@ -40,4 +80,14 @@ interface PasswordRepository { fun back() fun unlock(passphrase: String) fun lock() + fun reveal(id: ULong) + fun hide(id: ULong) + fun copy(id: ULong) + fun copyTotp() + fun edit(id: ULong) + fun dismissEdit() + fun saveEdit(value: String) + fun prepareMutation(action: PasswordMutation) + fun dismissMutation() + fun mutate(destination: String?, overwrite: Boolean) } diff --git a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/SecretClipboard.kt b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/SecretClipboard.kt new file mode 100644 index 0000000..0cbe976 --- /dev/null +++ b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/SecretClipboard.kt @@ -0,0 +1,8 @@ +package de.rfc1437.ironstorage.core + +/** OS clipboard capability; Rust owns timeout, cancellation cleanup and content comparison. */ +interface SecretClipboard { + fun read(): ByteArray? + fun write(value: ByteArray) + fun clear() +} diff --git a/android/dependency-policy.json b/android/dependency-policy.json index 2cd7233..1f48680 100644 --- a/android/dependency-policy.json +++ b/android/dependency-policy.json @@ -132,7 +132,8 @@ "org.jetbrains:annotations:23.0.0": "Apache-2.0", "org.jspecify:jspecify:1.0.0": "Apache-2.0", "androidx.compose.material3.adaptive:adaptive-layout-android:1.3.0": "Apache-2.0", - "androidx.compose.material3.adaptive:adaptive-navigation-android:1.3.0": "Apache-2.0" + "androidx.compose.material3.adaptive:adaptive-navigation-android:1.3.0": "Apache-2.0", + "androidx.lifecycle:lifecycle-common:2.3.1": "Apache-2.0" }, "rust_licenses": [ "(Apache-2.0 OR MIT) AND BSD-3-Clause", diff --git a/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/EntryViewer.kt b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/EntryViewer.kt new file mode 100644 index 0000000..30f8739 --- /dev/null +++ b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/EntryViewer.kt @@ -0,0 +1,332 @@ +package de.rfc1437.ironstorage.features + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +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.ClipEntry +import androidx.compose.ui.platform.Clipboard +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.res.pluralStringResource +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 de.rfc1437.ironstorage.core.PasswordMutation +import de.rfc1437.ironstorage.core.PasswordRepository +import de.rfc1437.ironstorage.core.PasswordState + +@OptIn(ExperimentalMaterial3Api::class) +fun LazyListScope.entryViewer(state: PasswordState, repository: PasswordRepository) { + item { + var menu by remember { mutableStateOf(false) } + var edit by remember { mutableStateOf(false) } + TopAppBar(title = { Text(stringResource(R.string.entry_details)) }, actions = { + TextButton(enabled = !state.loading, onClick = { edit = true }) { + Text(stringResource(R.string.entry_edit)) + } + TextButton(enabled = !state.loading, onClick = { menu = true }) { + Text(stringResource(R.string.entry_more)) + } + DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) { + PasswordMutation.entries.forEach { action -> + DropdownMenuItem(text = { Text(stringResource(action.label())) }, onClick = { + menu = false + repository.prepareMutation(action) + }) + } + } + }) + TextButton(onClick = repository::lock) { Text(stringResource(R.string.password_lock)) } + if (edit) { + AlertDialog( + onDismissRequest = { edit = false }, + title = { Text(stringResource(R.string.entry_edit)) }, + text = { + Column(Modifier.verticalScroll(rememberScrollState())) { + state.sections?.flatMap { + it.fields + }?.filter { it.editable }?.forEach { field -> + TextButton(onClick = { + edit = false + repository.edit(field.id) + }) { Text(field.label) } + } + } + }, + confirmButton = { + TextButton(onClick = { + edit = false + }) { Text(stringResource(R.string.auth_cancel)) } + }, + properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn) + ) + } + } + state.notice?.let { item { Text(it) } } + state.totp?.let { totp -> + item { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(stringResource(R.string.entry_totp), Modifier.semantics { heading() }) + Text(totp.account) + Text(totp.code, style = MaterialTheme.typography.headlineMedium) + Text( + pluralStringResource( + R.plurals.entry_countdown, + totp.remainingSeconds.toInt(), + totp.remainingSeconds.toLong() + ) + ) + TextButton(enabled = !state.loading, onClick = repository::copyTotp) { + Text(stringResource(R.string.entry_copy_code)) + } + } + } + } + state.sections?.forEach { section -> + item { + Text( + section.title, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.semantics { heading() } + ) + } + items(section.fields, key = { "field-${it.id}" }) { field -> + Column( + Modifier.fillMaxWidth().padding(vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text(field.label, style = MaterialTheme.typography.labelLarge) + if (field.selectable && !field.sensitive) { + val clipboard = remember(repository, field.id) { + object : Clipboard { + override suspend fun getClipEntry(): ClipEntry? = null + override suspend fun setClipEntry(clipEntry: ClipEntry?) { + repository.copy(field.id) + } + } + } + // Selection copy uses the complete storage field, including its cleanup policy. + CompositionLocalProvider(LocalClipboard provides clipboard) { + SelectionContainer { Text(field.value) } + } + } else { + Text(field.value) + } + field.detail?.let { Text(it) } + field.diagnostic?.let { Text(it, color = MaterialTheme.colorScheme.error) } + FlowRow { + if (field.sensitive) { + val reveal = + stringResource( + if (field.revealed) R.string.entry_hide else R.string.entry_reveal + ) + TextButton( + enabled = !state.loading, + modifier = Modifier.semantics { + contentDescription = + "$reveal ${field.label}" + }, + onClick = { + if (field.revealed) { + repository.hide( + field.id + ) + } else { + repository.reveal(field.id) + } + } + ) { Text(reveal) } + } + val copy = stringResource(R.string.entry_copy) + TextButton( + enabled = !state.loading, + modifier = Modifier.semantics { + contentDescription = "$copy ${field.label}" + }, + onClick = { repository.copy(field.id) } + ) { Text(copy) } + if (field.editable) { + val edit = stringResource(R.string.entry_edit) + TextButton( + enabled = !state.loading, + modifier = Modifier.semantics { + contentDescription = + "$edit ${field.label}" + }, + onClick = { repository.edit(field.id) } + ) { Text(edit) } + } + } + } + } + } +} + +@Composable +fun EntryDialogs(state: PasswordState, repository: PasswordRepository) { + state.edit?.let { edit -> + var value by remember(edit) { mutableStateOf(edit.value) } + var revealed by remember(edit) { mutableStateOf(false) } + val systemClipboard = LocalClipboard.current + val clipboard = remember(repository, edit.id, systemClipboard) { + object : Clipboard { + override suspend fun getClipEntry() = systemClipboard.getClipEntry() + override suspend fun setClipEntry(clipEntry: ClipEntry?) { + repository.copy(edit.id) + } + } + } + AlertDialog( + onDismissRequest = repository::dismissEdit, + title = { Text(edit.label) }, + text = { + CompositionLocalProvider(LocalClipboard provides clipboard) { + Column { + OutlinedTextField( + value, + onValueChange = { + value = it + }, + label = { Text(edit.label) }, + singleLine = !edit.multiline, + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions( + imeAction = if (edit.multiline) { + ImeAction.Default + } else { + ImeAction.Done + }, + keyboardType = if (edit.sensitive) { + KeyboardType.Password + } else { + KeyboardType.Text + } + ), + keyboardActions = KeyboardActions(onDone = { + repository.saveEdit(value) + }), + visualTransformation = if (edit.sensitive && !revealed) { + PasswordVisualTransformation() + } else { + VisualTransformation.None + } + ) + Text(stringResource(R.string.entry_copy_stored_hint)) + if (edit.sensitive) { + TextButton(onClick = { revealed = !revealed }) { + Text( + stringResource( + if (revealed) R.string.entry_hide else R.string.entry_reveal + ) + ) + } + } + } + } + }, + confirmButton = { + TextButton(enabled = !state.loading, onClick = { repository.saveEdit(value) }) { + Text(stringResource(R.string.entry_save)) + } + }, + dismissButton = { + TextButton(onClick = repository::dismissEdit) { + Text(stringResource(R.string.auth_cancel)) + } + }, + properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn) + ) + } + state.mutation?.let { plan -> + var destination by remember(plan) { mutableStateOf(null) } + var overwrite by remember(plan, destination) { mutableStateOf(false) } + val selected = plan.destinations.find { it.path == destination } + AlertDialog( + onDismissRequest = repository::dismissMutation, + title = { Text(stringResource(plan.action.label())) }, + text = { + Column(Modifier.verticalScroll(rememberScrollState())) { + Text(plan.source) + if (plan.discardEditor) Text(stringResource(R.string.entry_discard_editor)) + if (plan.action == PasswordMutation.Delete) { + Text(stringResource(R.string.entry_delete_confirm)) + } else { + plan.destinations.forEach { candidate -> + TextButton(onClick = { destination = candidate.path }) { + Text(candidate.title + "\n" + candidate.detail) + } + } + selected?.let { Text(stringResource(R.string.entry_destination, it.path)) } + if (selected?.overwrite == true) { + TextButton(onClick = { overwrite = !overwrite }) { + Text( + stringResource( + if (overwrite) { + R.string.entry_overwrite_enabled + } else { + R.string.entry_overwrite + } + ) + ) + } + } + } + } + }, + confirmButton = { + TextButton( + enabled = !state.loading && ( + plan.action == PasswordMutation.Delete || + selected != null && (!selected.overwrite || overwrite) + ), + onClick = { repository.mutate(destination, overwrite) } + ) { + Text(stringResource(R.string.entry_confirm)) + } + }, + dismissButton = { + TextButton(onClick = repository::dismissMutation) { + Text(stringResource(R.string.auth_cancel)) + } + }, + properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn) + ) + } +} +private fun PasswordMutation.label() = when (this) { + PasswordMutation.Move -> R.string.entry_move + PasswordMutation.Copy -> R.string.entry_copy_entry + PasswordMutation.Delete -> R.string.entry_delete +} 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 2344650..2ed8c05 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 @@ -31,6 +31,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.CustomAccessibilityAction @@ -203,7 +204,7 @@ fun PasswordScreen( detailPane = { AnimatedPane { LazyColumn( - Modifier.fillMaxSize().padding(16.dp), + Modifier.fillMaxSize().padding(16.dp).testTag("entry-detail"), verticalArrangement = Arrangement.spacedBy(12.dp) ) { if (entry == null) { @@ -241,32 +242,7 @@ fun PasswordScreen( } } } else { - item { - TextButton(onClick = repository::lock) { - Text(stringResource(R.string.password_lock)) - } - } - sections.forEach { section -> - item { - Text( - section.title, - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.semantics { - heading() - } - ) - } - items(section.fields, key = { it.id.toString() }) { field -> - ListItem(headlineContent = { - Text(field.label) - }, supportingContent = { - Column { - Text(field.value) - field.diagnostic?.let { Text(it) } - } - }) - } - } + entryViewer(state, repository) } } } @@ -284,6 +260,7 @@ fun PasswordScreen( } } } + if (lease.unlocked && state.sections != null) EntryDialogs(state, repository) if (manual && entry != null) { PassphraseDialog( stringResource(R.string.auth_manual), diff --git a/android/features/src/main/res/values/strings.xml b/android/features/src/main/res/values/strings.xml index a4408e7..fb0e7cc 100644 --- a/android/features/src/main/res/values/strings.xml +++ b/android/features/src/main/res/values/strings.xml @@ -79,4 +79,27 @@ Unlocked: %1$d second remaining Unlocked: %1$d seconds remaining + Entry details + Edit + More + One-time password + + Valid for %1$d second + Valid for %1$d seconds + + Copy code + Hide + Reveal + Copy + Save + Move entry + Copy entry + Delete entry + This action discards the open editor. + Permanently delete this encrypted entry? + Destination: %1$s + Allow replacing the existing entry + Replacing the existing entry confirmed + Confirm + Selection copies the stored field. Save edits first to copy changes. diff --git a/android/gradle/verification-metadata.xml b/android/gradle/verification-metadata.xml index d5d86b9..cd26b04 100644 --- a/android/gradle/verification-metadata.xml +++ b/android/gradle/verification-metadata.xml @@ -884,6 +884,9 @@ + + + diff --git a/android/platform/src/main/kotlin/de/rfc1437/ironstorage/platform/AndroidSecretClipboard.kt b/android/platform/src/main/kotlin/de/rfc1437/ironstorage/platform/AndroidSecretClipboard.kt new file mode 100644 index 0000000..83acde9 --- /dev/null +++ b/android/platform/src/main/kotlin/de/rfc1437/ironstorage/platform/AndroidSecretClipboard.kt @@ -0,0 +1,29 @@ +package de.rfc1437.ironstorage.platform + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.os.Build +import android.os.PersistableBundle +import de.rfc1437.ironstorage.core.SecretClipboard + +class AndroidSecretClipboard(context: Context) : SecretClipboard { + private val manager = context.getSystemService(ClipboardManager::class.java) + override fun read(): ByteArray? = manager.primaryClip?.let { clip -> + if (clip.itemCount == 1) clip.getItemAt(0).text?.toString()?.toByteArray() else null + } + override fun write(value: ByteArray) { + val clip = ClipData.newPlainText("IronStorage", value.toString(Charsets.UTF_8)) + clip.description.extras = PersistableBundle().apply { + putBoolean("android.content.extra.IS_SENSITIVE", true) + } + manager.setPrimaryClip(clip) + } + override fun clear() { + if (Build.VERSION.SDK_INT >= 28) { + manager.clearPrimaryClip() + } else { + manager.setPrimaryClip(ClipData.newPlainText("", "")) + } + } +} diff --git a/crates/mobile-bridge/src/lib.rs b/crates/mobile-bridge/src/lib.rs index 22d8834..36705a8 100644 --- a/crates/mobile-bridge/src/lib.rs +++ b/crates/mobile-bridge/src/lib.rs @@ -1006,6 +1006,7 @@ pub struct MobileEntrySection { #[derive(Clone, uniffi::Record)] pub struct MobileEntryPage { pub id: String, + pub revision: String, pub title: String, pub sections: Vec, } @@ -1014,6 +1015,7 @@ impl From for MobileEntryPage { fn from(page: StorageEntryPage) -> Self { Self { id: page.id().to_owned(), + revision: page.revision().to_owned(), title: page.title().to_owned(), sections: page .sections() @@ -1221,6 +1223,7 @@ pub struct MobileTotpDetail { pub account: String, pub code: String, pub valid_until: u64, + pub remaining_seconds: u64, pub period: u64, #[cfg(feature = "watch")] pub shared_with_watch: bool, @@ -1237,6 +1240,7 @@ impl From for MobileTotpDetail { code: String::from_utf8(detail.code().expose().to_vec()) .expect("storage-generated TOTP codes are ASCII"), valid_until: detail.valid_until(), + remaining_seconds: detail.remaining_seconds(), period: detail.period(), #[cfg(feature = "watch")] shared_with_watch: detail.shared_with_watch(), @@ -1831,6 +1835,78 @@ impl MobileAuthentication { .map_err(Into::into) } + pub fn reveal_presented_entry_field( + &self, + path: String, + field_id: u64, + revision: String, + ) -> Result { + self.authentication + .reveal_presented_entry_field(&path, field_id, &revision) + .map_err(Into::into) + } + + pub fn copy_presented_entry_field( + &self, + path: String, + field_id: u64, + revision: String, + ) -> Result { + self.authentication + .copy_presented_entry_field(&path, field_id, &revision) + .map(Into::into) + .map_err(Into::into) + } + + pub fn replace_presented_entry_field( + &self, + path: String, + field_id: u64, + revision: String, + value: String, + ) -> Result { + self.authentication + .replace_presented_entry_field(&path, field_id, &revision, value) + .map(Into::into) + .map_err(Into::into) + } + + pub fn refresh_entry_presentation( + &self, + path: String, + revision: String, + unix_seconds: u64, + ) -> Result { + self.authentication + .refresh_entry_presentation(&path, &revision, unix_seconds) + .map(Into::into) + .map_err(Into::into) + } + + pub fn copy_presented_totp_code( + &self, + path: String, + revision: String, + unix_seconds: u64, + ) -> Result { + self.authentication + .copy_presented_totp_code(&path, &revision, unix_seconds) + .map(Into::into) + .map_err(Into::into) + } + + pub fn prepare_presented_entry_mutation( + &self, + path: String, + revision: String, + action: MobileMutationAction, + ) -> Result { + self.authentication + .prepare_presented_entry_mutation(&path, &revision, action.into()) + .map(Into::into) + .map_err(Into::into) + } + pub fn copy_entry_field( &self, path: String, diff --git a/crates/mobile-bridge/src/platform.rs b/crates/mobile-bridge/src/platform.rs index 487f328..eb8043e 100644 --- a/crates/mobile-bridge/src/platform.rs +++ b/crates/mobile-bridge/src/platform.rs @@ -391,19 +391,39 @@ impl ClipboardBackend for ClipboardAdapter { pub fn copy_mobile_entry( copy: MobileEntryCopy, clipboard: Arc, +) -> Result { + copy_entry(copy, clipboard, false) +} + +/// Android never retains or resurrects previous clipboard contents. +#[uniffi::export] +pub fn copy_mobile_entry_clearing( + copy: MobileEntryCopy, + clipboard: Arc, +) -> Result { + copy_entry(copy, clipboard, true) +} + +fn copy_entry( + copy: MobileEntryCopy, + clipboard: Arc, + clear: bool, ) -> Result { let timeout = presentation::ClipboardTimeout::new(Duration::from_secs(copy.timeout_seconds)) .map_err(|_| MobilePlatformFfiError::ClipboardFailed)?; let mut manager = presentation::ClipboardManager::new(ClipboardAdapter(Arc::clone(&clipboard)), timeout); - manager - .copy_with( - &SecretBytes::new(copy.value.into_bytes()), - |duration| match clipboard.wait_for_timeout(duration.as_millis() as u64) { - MobileClipboardWait::Elapsed => presentation::ClipboardWait::Elapsed, - MobileClipboardWait::Cancelled => presentation::ClipboardWait::Cancelled, - }, - ) + let wait = |duration: Duration| match clipboard.wait_for_timeout(duration.as_millis() as u64) { + MobileClipboardWait::Elapsed => presentation::ClipboardWait::Elapsed, + MobileClipboardWait::Cancelled => presentation::ClipboardWait::Cancelled, + }; + let value = SecretBytes::new(copy.value.into_bytes()); + let result = if clear { + manager.copy_clearing_with(&value, wait) + } else { + manager.copy_with(&value, wait) + }; + result .map(|result| match result { presentation::ClipboardDisposition::RestoredPrevious => { MobileClipboardDisposition::RestoredPrevious diff --git a/crates/storage/src/mobile_authentication.rs b/crates/storage/src/mobile_authentication.rs index ad1942d..34d95dc 100644 --- a/crates/storage/src/mobile_authentication.rs +++ b/crates/storage/src/mobile_authentication.rs @@ -21,7 +21,7 @@ use crate::{ mobile_entry::{ MobileEntryDraft, MobileEntryEditorError, MobileEntryEditorFieldKind, MobileEntryEditorInput, MobileEntryEditorPage, MobileEntryEditorSession, MobileEntryPage, - MobileEntryValueError, field_value, + MobileEntryValueError, document_revision, field_value, }, mobile_mutation::{ MobileMutationAction, MobileMutationError, MobileMutationOutcome, MobileMutationPlan, @@ -949,6 +949,98 @@ impl MobileAuthentication { field_value(&document, field).map_err(value_error) } + /// Field IDs are scoped to the exact document that the viewer presented. + pub fn reveal_presented_entry_field( + &self, + path: &str, + field_id: u64, + revision: &str, + ) -> Result { + let document = self.open_presented_document(path, revision)?; + field_value(&document, field_id).map_err(value_error) + } + + pub fn copy_presented_entry_field( + &self, + path: &str, + field_id: u64, + revision: &str, + ) -> Result { + Ok(MobileEntryCopy { + value: self.reveal_presented_entry_field(path, field_id, revision)?, + timeout_seconds: self.config.clipboard_timeout().duration().as_secs(), + }) + } + + pub fn refresh_entry_presentation( + &self, + path: &str, + revision: &str, + unix_seconds: u64, + ) -> Result { + let presentation = self.entry_page(path, unix_seconds)?; + self.check_document_revision(presentation.page().revision(), revision)?; + Ok(presentation) + } + + fn open_presented_document( + &self, + path: &str, + revision: &str, + ) -> Result { + let document = self.open_active_document(path)?; + self.check_document_revision(&document_revision(&document), revision)?; + Ok(document) + } + + fn check_document_revision( + &self, + current: &str, + expected: &str, + ) -> Result<(), MobileAuthenticationError> { + if current != expected { + self.manual_lock()?; + return Err(MobileAuthenticationError::new( + MobileAuthenticationErrorKind::Conflict, + "Entry Changed", + "Refresh and unlock the entry again before using its fields.", + )); + } + Ok(()) + } + + pub fn copy_presented_totp_code( + &self, + path: &str, + revision: &str, + unix_seconds: u64, + ) -> Result { + let presentation = self.refresh_entry_presentation(path, revision, unix_seconds)?; + let detail = presentation.totp().ok_or_else(|| { + entry_detail( + "TOTP Code Is Unavailable", + "The entry has no current TOTP code.", + ) + })?; + Ok(MobileEntryCopy { + value: String::from_utf8(detail.code().expose().to_vec()) + .map_err(|_| entry_detail("TOTP Code Is Unavailable", "The code is not UTF-8."))?, + timeout_seconds: self.config.clipboard_timeout().duration().as_secs(), + }) + } + + pub fn prepare_presented_entry_mutation( + &self, + path: &str, + revision: &str, + action: MobileMutationAction, + ) -> Result { + self.open_presented_document(path, revision)?; + let plan = self.prepare_entry_mutation(path, action)?; + self.open_presented_document(path, revision)?; + Ok(plan) + } + pub fn copy_entry_field( &self, path: &str, @@ -1234,17 +1326,42 @@ impl MobileAuthentication { ) -> Result { self.ensure_repository_idle()?; let mut document = self.open_active_document(path)?; + self.save_replaced_entry_field(&mut document, field, value) + } + + pub fn replace_presented_entry_field( + &self, + path: &str, + field_id: u64, + revision: &str, + value: String, + ) -> Result { + self.ensure_repository_idle()?; + let mut document = self.open_presented_document(path, revision)?; + self.save_replaced_entry_field(&mut document, field_id, value) + } + + fn save_replaced_entry_field( + &self, + document: &mut EntryDocument, + field_id: u64, + value: String, + ) -> Result { document - .replace_field_value(EntryFieldId::from_value(field), value.into_bytes()) + .replace_field_value(EntryFieldId::from_value(field_id), value.into_bytes()) .map_err(document_error)?; let identity = self.git_identity()?; - let mut committer = AutomaticEntryCommitter::for_entry(&self.repository, path, identity) - .map_err(|error| entry_detail("Password Entry Could Not Be Saved", error))?; + let mut committer = AutomaticEntryCommitter::for_entry( + &self.repository, + &document.path().to_string(), + identity, + ) + .map_err(|error| entry_detail("Password Entry Could Not Be Saved", error))?; EntryDocumentService::new(&self.repository, &self.keys) - .save_recoverable(&document, None, &mut committer) + .save_recoverable(document, None, &mut committer) .map_err(document_error)?; - self.reconcile_saved_document(&document); - Ok(MobileEntryPage::from_document(&document)) + self.reconcile_saved_document(document); + Ok(MobileEntryPage::from_document(document)) } pub fn begin_entry_editor( diff --git a/crates/storage/src/mobile_entry.rs b/crates/storage/src/mobile_entry.rs index b50640a..f4b4354 100644 --- a/crates/storage/src/mobile_entry.rs +++ b/crates/storage/src/mobile_entry.rs @@ -104,6 +104,7 @@ impl MobileEntrySection { #[derive(Clone, Debug, Eq, PartialEq)] pub struct MobileEntryPage { + revision: String, id: String, title: String, sections: Vec, @@ -518,11 +519,16 @@ impl MobileEntryPage { } Self { id: format!("entry:{}", document.path()), + revision: document_revision(document), title, sections, } } + pub fn revision(&self) -> &str { + &self.revision + } + pub fn id(&self) -> &str { &self.id } @@ -559,6 +565,10 @@ impl From for MobileEntryValueError { } } +pub(crate) fn document_revision(document: &EntryDocument) -> String { + data_encoding::HEXLOWER.encode(document.conflict_token().as_bytes()) +} + pub fn field_value(document: &EntryDocument, id: u64) -> Result { let value = document.copy_field_value(EntryFieldId::from_value(id))?; String::from_utf8(value.expose().to_vec()).map_err(|_| MobileEntryValueError::NonUtf8) diff --git a/crates/storage/src/mobile_totp.rs b/crates/storage/src/mobile_totp.rs index 5573e09..e8fabbb 100644 --- a/crates/storage/src/mobile_totp.rs +++ b/crates/storage/src/mobile_totp.rs @@ -198,6 +198,7 @@ pub struct MobileTotpDetail { account: String, code: SecretBytes, valid_until: u64, + remaining_seconds: u64, period: u64, shared_with_watch: bool, watch: MobileWatchSnapshotStatus, @@ -228,6 +229,10 @@ impl MobileTotpDetail { self.period } + pub fn remaining_seconds(&self) -> u64 { + self.remaining_seconds + } + pub fn shared_with_watch(&self) -> bool { self.shared_with_watch } @@ -556,6 +561,7 @@ fn detail_for_uri( account: uri.account().to_owned(), code: uri.code_at(unix_seconds)?, valid_until, + remaining_seconds: valid_until - unix_seconds, period, shared_with_watch: shared.contains(path), watch: snapshot_status(shared.len()), diff --git a/crates/storage/src/presentation.rs b/crates/storage/src/presentation.rs index a17cc95..2c21d29 100644 --- a/crates/storage/src/presentation.rs +++ b/crates/storage/src/presentation.rs @@ -147,9 +147,30 @@ impl ClipboardManager { return Err(ClipboardError::EmptySecret); } let previous = self.backend.read()?; + self.copy_and_cleanup(value, &previous, wait) + } + + /// Copy without retaining or later restoring previous clipboard contents. + pub fn copy_clearing_with( + &mut self, + value: &SecretBytes, + wait: impl FnOnce(Duration) -> ClipboardWait, + ) -> Result { + if value.expose().is_empty() { + return Err(ClipboardError::EmptySecret); + } + self.copy_and_cleanup(value, &ClipboardContent::EmptyOrNonText, wait) + } + + fn copy_and_cleanup( + &mut self, + value: &SecretBytes, + previous: &ClipboardContent, + wait: impl FnOnce(Duration) -> ClipboardWait, + ) -> Result { self.backend.write(value)?; let wait_result = wait(self.timeout.duration()); - let disposition = self.cleanup(value, &previous)?; + let disposition = self.cleanup(value, previous)?; if wait_result == ClipboardWait::Cancelled { return Err(ClipboardError::Cancelled); } diff --git a/crates/storage/tests/authentication_leases.rs b/crates/storage/tests/authentication_leases.rs index 02495d6..ab64319 100644 --- a/crates/storage/tests/authentication_leases.rs +++ b/crates/storage/tests/authentication_leases.rs @@ -740,6 +740,79 @@ fn mobile_expiry_and_user_input_never_reveal_old_editor_handles() -> TestResult Ok(()) } +#[test] +fn presented_field_ids_reject_replaced_documents_and_ticks_do_not_renew_the_lease() -> TestResult { + use ironstorage::{ + config::Config, + mobile_authentication::{MobileAuthentication, MobileAuthenticationErrorKind}, + }; + 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 authentication = MobileAuthentication::with_platform( + Config::load(Some(&source))?, + Arc::new(MemoryBackend::default()), + Arc::new(clock.clone()), + )?; + authentication.set_authentication_timeout(2)?; + authentication.unlock_entry( + "email/personal", + Some(SecretBytes::new( + fixture.key("alice")?.passphrase.as_bytes().to_vec(), + )), + )?; + let page = authentication.entry_page("email/personal", 59)?; + let id = page.page().sections()[0].fields()[0].id(); + assert!( + !authentication + .reveal_presented_entry_field("email/personal", id, page.page().revision())? + .is_empty() + ); + clock.advance(Duration::from_secs(1)); + authentication.refresh_entry_presentation("email/personal", page.page().revision(), 60)?; + assert_eq!(authentication.state()?.remaining_seconds(), 1); + authentication.replace_entry_field("email/personal", id, "changed password".to_owned())?; + let error = authentication + .replace_presented_entry_field( + "email/personal", + id, + page.page().revision(), + "must not overwrite".to_owned(), + ) + .unwrap_err(); + assert_eq!(error.kind(), MobileAuthenticationErrorKind::Conflict); + assert!(!authentication.state()?.unlocked()); + authentication.unlock_entry( + "email/personal", + Some(SecretBytes::new( + fixture.key("alice")?.passphrase.as_bytes().to_vec(), + )), + )?; + assert_eq!( + authentication.reveal_entry_field("email/personal", id)?, + "changed password" + ); + clock.advance(Duration::from_secs(2)); + assert!( + authentication + .refresh_entry_presentation("email/personal", page.page().revision(), 61) + .is_err() + ); + assert!(!authentication.state()?.unlocked()); + Ok(()) +} + #[test] fn locking_interrupts_an_os_prompt_before_waiting_for_the_lease_mutex() -> TestResult { struct PromptBackend { diff --git a/crates/storage/tests/mobile_totp.rs b/crates/storage/tests/mobile_totp.rs index e23222a..435c89f 100644 --- a/crates/storage/tests/mobile_totp.rs +++ b/crates/storage/tests/mobile_totp.rs @@ -145,6 +145,7 @@ fn mobile_totp_catalog_details_and_watch_selection_are_storage_owned() -> TestRe let detail = service.detail("otp/alice", 59, &BTreeSet::new(), &mut secrets)?; assert_eq!(detail.code().expose(), b"94287082"); assert_eq!(detail.valid_until(), 60); + assert_eq!(detail.remaining_seconds(), 1); assert_eq!(detail.period(), 30); assert!(!format!("{detail:?}").contains("94287082")); @@ -161,6 +162,7 @@ fn mobile_totp_catalog_details_and_watch_selection_are_storage_owned() -> TestRe let detail = service.detail("otp/alice", 60, &selected, &mut secrets)?; assert!(detail.shared_with_watch()); assert_eq!(detail.valid_until(), 90); + assert_eq!(detail.remaining_seconds(), 30); Ok(()) } diff --git a/crates/storage/tests/presentation.rs b/crates/storage/tests/presentation.rs index ccaac7f..cec10ee 100644 --- a/crates/storage/tests/presentation.rs +++ b/crates/storage/tests/presentation.rs @@ -139,6 +139,32 @@ fn clipboard_races_and_cancellation_never_overwrite_newer_user_content() -> Test Ok(()) } +#[test] +fn clearing_clipboard_never_reads_or_restores_previous_secrets() -> TestResult { + let backend = MemoryClipboard::with_text(b"previous secret"); + let mut manager = ClipboardManager::new(backend.clone(), ClipboardTimeout::default()); + let secret = SecretBytes::new(b"copied secret".to_vec()); + assert_eq!( + manager.copy_clearing_with(&secret, |_| ClipboardWait::Elapsed)?, + ClipboardDisposition::Cleared + ); + assert_eq!(backend.text(), None); + assert_eq!( + manager.copy_clearing_with(&secret, |_| { + backend.set_text(b"newer value"); + ClipboardWait::Elapsed + })?, + ClipboardDisposition::PreservedNewer + ); + assert_eq!(backend.text().as_deref(), Some(b"newer value".as_slice())); + assert_eq!( + manager.copy_clearing_with(&secret, |_| ClipboardWait::Cancelled), + Err(ClipboardError::Cancelled) + ); + assert_eq!(backend.text(), None); + Ok(()) +} + #[test] fn clipboard_validation_and_cleanup_failures_are_typed_and_redacted() -> TestResult { assert_eq!( diff --git a/docs/android/entry-viewer.md b/docs/android/entry-viewer.md new file mode 100644 index 0000000..4f10935 --- /dev/null +++ b/docs/android/entry-viewer.md @@ -0,0 +1,49 @@ +# Android structured entry viewer (#99) + +The viewer renders Rust-provided sections and fields without parsing or serializing +entries in Compose. Passwords, PINs, OTP URIs and other sensitive fields start masked. +Reveal, copy and edit use the field ID together with the exact document revision. +An external replacement rejects the old field IDs and locks the authentication +lease. Editing saves through Rust's recoverable encryption and automatic Git commit +workflow, then refreshes the checked navigation and document revisions. + +Rust supplies TOTP codes, account labels and remaining seconds. The presentation +refresh does not renew the lease. Expiry, backgrounding and manual lock remove +sections, codes, editor inputs and mutation dialogs. Decrypted values never enter +saved state; secret-bearing facade objects redact their string representation. + +The Material top bar offers an editable-field picker and Move, Copy and Delete +plans. Rust supplies destinations, overwrite requirements and checked mutation +revisions. Confirmation is explicit. Compact and adaptive list/detail panes share +the same viewer; wrapped values, multiline notes and repeated fields remain intact. +Headings, labelled actions and keyboard submission expose the same typed operations. + +Only fields marked selectable by Rust permit text selection. Selection-copy and +explicit Copy both request the complete stored field from Rust, preserving its +clipboard timeout. The editor explains that copying uses the saved value; saving +first makes edits available to copy. Android marks clips sensitive, and Rust clears +its own clip on timeout or lock without restoring previous clipboard contents or +erasing a newer clip. Screen capture is blocked for the activity and dialogs. + +## Validation + +Rust regressions cover revision-bound field actions, external edits, lease expiry, +TOTP countdown boundaries and clearing clipboard behavior. Existing document +fixtures cover dynamic, repeated, multiline, Unicode, malformed and OTP-only +entries without replacing their original format. Native Android instrumentation +creates an isolated GPG-encrypted store through Rust and exercises reveal, hide, +copy, save, copy/delete mutations, stale revisions and expiry during TOTP refresh. +Compose checks cover masking, field actions, diagnostics, dialog revocation, +mutation confirmation, clipboard sensitivity and screen-capture protection. +All four required Rust workspace gates and the complete Android build, lint, +dependency/license, module-boundary and native-package gates passed. Full app, +core and bridge instrumentation plus five cold starts passed on ARM64 API 26/4 KB, +API 37/4 KB and API 37/strict 16 KB. Both ARM64 and ARMv7 release artifacts were +built and audited. + +Computer use in Android Studio's embedded emulator checked the disposable viewer, +masked PIN editor, repeated fields, Unicode and contextual selection at 200% font +size. Native contextual Copy was observed dispatching the Username's field ID. +Manual TalkBack speech was not checked; labelled accessibility actions are covered +by instrumentation. ARMv7 is built and packaged but has no available emulator +image. Physical-device testing is outside this milestone's scope.