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

@@ -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()
}