Implement structured Android entry viewer (#99)
Android ARM verification / rust (push) Failing after 47m18s
Android ARM verification / android (push) Failing after 8s
Dependency security audit / rustsec (push) Successful in 22s

This commit is contained in:
hugo committed 2026-10-09 17:36:38 +02:00
1 parent 647dc1bb4b
commit c0ba6d43d8
29 files changed
+1768 -78

No files matched your search

+3 -2
View File
@@ -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",))
+2
View File
@@ -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
@@ -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<MainActivity>()
@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()
}
}
}
@@ -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
@@ -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 {
@@ -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
+1 -1
View File
@@ -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")
}
+4
View File
@@ -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(
+13 -2
View File
@@ -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
@@ -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<String>()
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<List<Byte>, 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
}
}
@@ -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<PasswordState> = 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
@@ -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<PasswordField>)
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<PasswordDestination>,
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<PasswordSection>? = null
val sections: List<PasswordSection>? = 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<PasswordState>
@@ -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)
}
@@ -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()
}
+2 -1
View File
@@ -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",
@@ -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<String?>(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
}
@@ -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),
@@ -79,4 +79,27 @@
<item quantity="one">Unlocked: %1$d second remaining</item>
<item quantity="other">Unlocked: %1$d seconds remaining</item>
</plurals>
<string name="entry_details">Entry details</string>
<string name="entry_edit">Edit</string>
<string name="entry_more">More</string>
<string name="entry_totp">One-time password</string>
<plurals name="entry_countdown">
<item quantity="one">Valid for %1$d second</item>
<item quantity="other">Valid for %1$d seconds</item>
</plurals>
<string name="entry_copy_code">Copy code</string>
<string name="entry_hide">Hide</string>
<string name="entry_reveal">Reveal</string>
<string name="entry_copy">Copy</string>
<string name="entry_save">Save</string>
<string name="entry_move">Move entry</string>
<string name="entry_copy_entry">Copy entry</string>
<string name="entry_delete">Delete entry</string>
<string name="entry_discard_editor">This action discards the open editor.</string>
<string name="entry_delete_confirm">Permanently delete this encrypted entry?</string>
<string name="entry_destination">Destination: %1$s</string>
<string name="entry_overwrite">Allow replacing the existing entry</string>
<string name="entry_overwrite_enabled">Replacing the existing entry confirmed</string>
<string name="entry_confirm">Confirm</string>
<string name="entry_copy_stored_hint">Selection copies the stored field. Save edits first to copy changes.</string>
</resources>
+3
View File
@@ -884,6 +884,9 @@
</artifact>
</component>
<component group="androidx.lifecycle" name="lifecycle-common" version="2.3.1">
<artifact name="lifecycle-common-2.3.1.jar">
<sha256 value="15848fb56db32f4c7cdc72b324003183d52a4884d6bf09be708ac7f587d139b5" origin="Generated by Gradle"/>
</artifact>
<artifact name="lifecycle-common-2.3.1.module">
<sha256 value="5fb7c8514d8c56cada5e29ef89dc0289e71942ab4cb0b2e6dca137b9dcb8fdd4" origin="Generated by Gradle"/>
</artifact>
@@ -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("", ""))
}
}
}
+76
View File
@@ -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<MobileEntrySection>,
}
@@ -1014,6 +1015,7 @@ impl From<StorageEntryPage> 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<StorageTotpDetail> 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<String, MobileAuthenticationFfiError> {
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<MobileEntryCopy, MobileAuthenticationFfiError> {
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<MobileEntryPage, MobileAuthenticationFfiError> {
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<MobileEntryPresentation, MobileAuthenticationFfiError> {
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<MobileEntryCopy, MobileAuthenticationFfiError> {
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<MobileMutationPlan, MobileAuthenticationFfiError> {
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,
+28 -8
View File
@@ -391,19 +391,39 @@ impl ClipboardBackend for ClipboardAdapter {
pub fn copy_mobile_entry(
copy: MobileEntryCopy,
clipboard: Arc<dyn MobileClipboard>,
) -> Result<MobileClipboardDisposition, MobilePlatformFfiError> {
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<dyn MobileClipboard>,
) -> Result<MobileClipboardDisposition, MobilePlatformFfiError> {
copy_entry(copy, clipboard, true)
}
fn copy_entry(
copy: MobileEntryCopy,
clipboard: Arc<dyn MobileClipboard>,
clear: bool,
) -> Result<MobileClipboardDisposition, MobilePlatformFfiError> {
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
+124 -7
View File
@@ -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<String, MobileAuthenticationError> {
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<MobileEntryCopy, MobileAuthenticationError> {
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<MobileEntryPresentation, MobileAuthenticationError> {
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<EntryDocument, MobileAuthenticationError> {
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<MobileEntryCopy, MobileAuthenticationError> {
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<MobileMutationPlan, MobileAuthenticationError> {
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<MobileEntryPage, MobileAuthenticationError> {
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<MobileEntryPage, MobileAuthenticationError> {
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<MobileEntryPage, MobileAuthenticationError> {
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(
+10
View File
@@ -104,6 +104,7 @@ impl MobileEntrySection {
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MobileEntryPage {
revision: String,
id: String,
title: String,
sections: Vec<MobileEntrySection>,
@@ -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<DocumentError> 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<String, MobileEntryValueError> {
let value = document.copy_field_value(EntryFieldId::from_value(id))?;
String::from_utf8(value.expose().to_vec()).map_err(|_| MobileEntryValueError::NonUtf8)
+6
View File
@@ -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()),
+22 -1
View File
@@ -147,9 +147,30 @@ impl<B: ClipboardBackend> ClipboardManager<B> {
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<ClipboardDisposition, ClipboardError> {
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<ClipboardDisposition, ClipboardError> {
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);
}
@@ -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 {
+2
View File
@@ -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(())
}
+26
View File
@@ -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!(
+49
View File
@@ -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.