Enforce Android lifecycle and presentation security (#100)
Android ARM verification / rust (push) Failing after 47m6s
Android ARM verification / android (push) Failing after 8s
Dependency security audit / rustsec (push) Successful in 23s

This commit is contained in:
hugo committed 2026-10-09 18:59:18 +02:00
1 parent c0ba6d43d8
commit a06f39af23
37 files changed
+1129 -110

No files matched your search

@@ -113,6 +113,27 @@ class NativeEntryViewerTest {
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)
for (event in listOf(
AuthenticationLifecycle.Background,
AuthenticationLifecycle.DeviceLocked,
AuthenticationLifecycle.MemoryPressure,
AuthenticationLifecycle.ProcessRecreated
)) {
val field = fields().first()
request { repository.edit(field.id) }
request { repository.copy(field.id) }
withTimeout(10000) { while (clipboard.value == null) delay(20) }
withContext(Dispatchers.Main.immediate) {
authentication.lifecycle(event)
assertNull(repository.state.value.sections)
assertNull(repository.state.value.edit)
assertNull(repository.state.value.totp)
assertNull(clipboard.value)
assertFalse(authentication.state.value.unlocked)
}
request { repository.unlock(password) }
assertNotNull(repository.state.value.sections)
}
val pin = sensitive.single { it.label == "PIN" }
request { repository.reveal(pin.id) }
assertEquals("123493", fields().single { it.id == pin.id }.value)
@@ -172,6 +193,8 @@ class NativeEntryViewerTest {
}
}
private class MemoryClipboard : SecretClipboard {
override fun beginCopy(): SecretClipboard = this
@Volatile var value: ByteArray? = null
override fun read() = value?.copyOf()
override fun write(value: ByteArray) {
@@ -27,6 +27,7 @@ data class AuthenticationState(
val failure: AuthenticationFailure? = null
)
interface AuthenticationRepository {
val presentation: ProtectedPresentation
val state: StateFlow<AuthenticationState>
fun reload()
fun unlock()
@@ -35,4 +36,5 @@ interface AuthenticationRepository {
fun lock()
fun cancel()
fun touch()
fun lifecycle(event: AuthenticationLifecycle)
}
@@ -7,6 +7,7 @@ import de.rfc1437.ironstorage.bridge.generated.MobileAuthenticationErrorKind
import de.rfc1437.ironstorage.bridge.generated.MobileAuthenticationFfiException
import de.rfc1437.ironstorage.bridge.generated.MobileAuthenticationState
import de.rfc1437.ironstorage.bridge.generated.MobileClock
import de.rfc1437.ironstorage.bridge.generated.MobileLifecycleEvent
import de.rfc1437.ironstorage.bridge.generated.MobilePrivateStorageFfiException
import java.util.concurrent.atomic.AtomicLong
import kotlinx.coroutines.CoroutineDispatcher
@@ -26,6 +27,7 @@ class NativeAuthenticationRepository(
private val scope: CoroutineScope,
private val io: CoroutineDispatcher
) : AuthenticationRepository {
override val presentation = ProtectedPresentation()
private val mutable = MutableStateFlow(AuthenticationState())
override val state: StateFlow<AuthenticationState> = mutable
private val sequence = AtomicLong()
@@ -37,6 +39,7 @@ class NativeAuthenticationRepository(
override fun monotonicMilliseconds(): ULong = SystemClock.elapsedRealtime().toULong()
}
init {
presentation.onWindowFocusLost = { lifecycle(AuthenticationLifecycle.Background) }
scope.launch {
while (true) {
delay(250)
@@ -45,7 +48,7 @@ class NativeAuthenticationRepository(
try {
val value = withContext(io) { current?.state() }
if (ticket == sequence.get() && value != null) {
mutable.value = value.toFacade()
publish(value.toFacade())
}
} catch (_: MobileAuthenticationFfiException) {
if (ticket == sequence.get()) lock()
@@ -57,7 +60,10 @@ class NativeAuthenticationRepository(
}
}
private fun owner(): MobileAuthentication =
session.storage().authentication(session.backend(), clock).also { current = it }
session.storage().authentication(session.backend(), clock).also {
if (current == null) it.lifecycle(MobileLifecycleEvent.PROCESS_RECREATED)
current = it
}
internal suspend fun <T> authenticated(block: (MobileAuthentication) -> T): T =
withContext(kotlinx.coroutines.Dispatchers.Main.immediate) {
@@ -78,13 +84,15 @@ class NativeAuthenticationRepository(
result
}
val value = withContext(io) { current!!.state() }
if (ticket == sequence.get()) mutable.value = value.toFacade()
if (ticket == sequence.get()) publish(value.toFacade())
result
} catch (error: MobileAuthenticationFfiException.Failed) {
if (ticket == sequence.get()) {
val value = withContext(io) { current?.state() }
mutable.value = (value?.toFacade() ?: AuthenticationState()).copy(
failure = error.toFacade()
publish(
(value?.toFacade() ?: AuthenticationState()).copy(
failure = error.toFacade()
)
)
}
throw error
@@ -122,19 +130,26 @@ class NativeAuthenticationRepository(
}
}
}
private fun publish(value: AuthenticationState) {
if (mutable.value.unlocked && !value.unlocked) presentation.clear()
mutable.value = value
}
private fun unavailable() {
mutable.value = AuthenticationState(
failure = AuthenticationFailure(
AuthenticationFailureKind.Configuration,
"Authentication is unavailable",
"Unlock the device or recover private storage and reload."
publish(
AuthenticationState(
failure = AuthenticationFailure(
AuthenticationFailureKind.Configuration,
"Authentication is unavailable",
"Unlock the device or recover private storage and reload."
)
)
)
}
override fun lock() = revoke(false)
override fun cancel() = revoke(true)
private fun revoke(cancel: Boolean) {
private fun revoke(cancel: Boolean, event: MobileLifecycleEvent? = null) {
val ticket = sequence.incrementAndGet()
presentation.clear()
mutable.value = mutable.value.copy(
unlocked = false,
remainingSeconds = 0u,
@@ -151,9 +166,15 @@ class NativeAuthenticationRepository(
)
session.cancelAuthentication()
val authentication = current
val previous = revocation
revocation = scope.launch(io) {
previous?.join()
try {
if (cancel) authentication?.cancel() else authentication?.manualLock()
when {
event != null -> authentication?.lifecycle(event)
cancel -> authentication?.cancel()
else -> authentication?.manualLock()
}
} catch (_: MobileAuthenticationFfiException) {
withContext(kotlinx.coroutines.Dispatchers.Main.immediate) {
if (ticket == sequence.get()) unavailable()
@@ -161,12 +182,26 @@ class NativeAuthenticationRepository(
}
}
}
override fun lifecycle(event: AuthenticationLifecycle) {
val native = when (event) {
AuthenticationLifecycle.Foreground -> MobileLifecycleEvent.FOREGROUND
AuthenticationLifecycle.Background -> MobileLifecycleEvent.BACKGROUND
AuthenticationLifecycle.DeviceLocked -> MobileLifecycleEvent.DEVICE_LOCKED
AuthenticationLifecycle.MemoryPressure -> MobileLifecycleEvent.MEMORY_PRESSURE
AuthenticationLifecycle.ProcessRecreated -> MobileLifecycleEvent.PROCESS_RECREATED
}
if (event == AuthenticationLifecycle.Foreground) {
run { it.lifecycle(native) }
} else {
revoke(false, native)
}
}
override fun touch() {
if (!mutable.value.unlocked || mutable.value.busy) return
val ticket = sequence.get()
scope.launch(io) {
try {
current?.touchUserActivity()
current?.lifecycle(MobileLifecycleEvent.USER_ACTIVITY)
} catch (_: MobileAuthenticationFfiException) {
withContext(kotlinx.coroutines.Dispatchers.Main.immediate) {
if (ticket == sequence.get()) lock()
@@ -8,6 +8,7 @@ import de.rfc1437.ironstorage.bridge.generated.MobileOnboardingErrorKind
import de.rfc1437.ironstorage.bridge.generated.MobileOnboardingFfiException
import de.rfc1437.ironstorage.bridge.generated.MobileOnboardingOutcome
import de.rfc1437.ironstorage.bridge.generated.MobilePrivateStorageFfiException
import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
@@ -26,7 +27,9 @@ class NativeOnboardingRepository(
override val setup: StateFlow<SetupStatus> = mutableSetup
@Volatile private var operation: MobileAndroidOnboarding? = null
private var remote: MobileAndroidRemoteOnboarding? = null
@Volatile private var remote: MobileAndroidRemoteOnboarding? = null
private val revoked = AtomicBoolean()
@Volatile private var pollProgress = false
@@ -46,7 +49,13 @@ class NativeOnboardingRepository(
private fun newOperation(): MobileAndroidOnboarding {
remote?.close()
remote = null
return session.storage().androidOnboarding(session.backend()).also { operation = it }
return session.storage().androidOnboarding(session.backend()).also {
operation = it
if (revoked.get()) {
it.cancel()
throw interrupted()
}
}
}
override fun generate(userId: String, passphrase: String) = runSetup {
@@ -61,7 +70,15 @@ class NativeOnboardingRepository(
override fun discover(server: String, account: String, repository: String, token: String) =
runSetup {
val setup = newOperation()
val request = setup.remote(server, account, repository, token).also { remote = it }
val request = setup.remote(server, account, repository, token).also {
remote = it
if (revoked.get()) {
it.cancel()
it.close()
remote = null
throw interrupted()
}
}
val discovery = request.discover()
pollProgress = false
mutableSetup.update {
@@ -82,7 +99,7 @@ class NativeOnboardingRepository(
useExisting: Boolean
) = runSetup {
completed(
requireNotNull(remote).cloneStore(
(remote ?: throw interrupted()).cloneStore(
branch,
fingerprint,
passphrase.ifEmpty {
@@ -108,8 +125,22 @@ class NativeOnboardingRepository(
}
}
override fun cancel() {
if (!mutableSetup.value.busy) return
scope.launch(Dispatchers.IO) { operation?.cancel() }
revoked.set(true)
pollProgress = false
mutableSetup.update { it.copy(branches = emptyList(), selectedBranch = "") }
val pending = remote
remote = null
val active = operation
scope.launch(Dispatchers.IO) {
try {
active?.cancel()
pending?.cancel()
} catch (_: IllegalStateException) {
// An operation that finished concurrently already released its handle.
} finally {
pending?.close()
}
}
}
private fun completed(outcome: MobileOnboardingOutcome) {
updateStatus(requireNotNull(operation).status())
@@ -136,13 +167,18 @@ class NativeOnboardingRepository(
}
private fun runSetup(action: () -> Unit) {
if (mutableSetup.value.busy) return
revoked.set(false)
pollProgress = true
mutableSetup.update { it.copy(busy = true, failure = false) }
scope.launch {
val progress = launch {
while (mutableSetup.value.busy) {
delay(200)
val value = withContext(Dispatchers.IO) { operation?.progress() }
val value = try {
withContext(Dispatchers.IO) { operation?.progress() }
} catch (_: IllegalStateException) {
null
}
value?.takeIf { mutableSetup.value.busy }?.let { phase ->
mutableSetup.update {
if (pollProgress) {
@@ -198,11 +234,31 @@ class NativeOnboardingRepository(
failure = true
)
}
} catch (error: IllegalStateException) {
if (!revoked.get()) throw error
val failure = interrupted()
mutableSetup.update {
it.copy(title = failure.title, detail = failure.detail, failure = true)
}
} finally {
pollProgress = false
progress.cancel()
if (revoked.get()) {
withContext(Dispatchers.IO) {
remote?.close()
remote = null
operation?.close()
operation = null
}
}
mutableSetup.update { it.copy(busy = false) }
}
}
}
}
private fun interrupted() = MobileOnboardingFfiException.Failed(
MobileOnboardingErrorKind.INTERRUPTED,
"Setup interrupted",
"Return to setup and enter its secrets again."
)
@@ -14,8 +14,10 @@ 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.clearMobileEntryCopy
import de.rfc1437.ironstorage.bridge.generated.copyMobileEntryClearing
import java.util.concurrent.atomic.AtomicLong
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -40,11 +42,15 @@ class NativePasswordRepository(
private val sequence = AtomicLong()
private val mutex = Mutex()
init {
scope.launch {
authentication.state.collect { lease ->
if (!lease.unlocked) mutable.value = mutable.value.cleared()
val registration = authentication.presentation.register {
if (mutable.value.sections != null) {
sequence.incrementAndGet()
mutable.value = mutable.value.cleared().copy(loading = false)
} else {
mutable.value = mutable.value.cleared()
}
}
scope.coroutineContext[kotlinx.coroutines.Job]?.invokeOnCompletion { registration.close() }
scope.launch {
while (true) {
delay(1000)
@@ -53,7 +59,7 @@ class NativePasswordRepository(
authentication.state.value.unlocked &&
!authentication.state.value.busy
) {
content { current ->
content(false) { current ->
val detail = authentication.authenticated { auth ->
auth.refreshEntryPresentation(
requireNotNull(current.selected).path,
@@ -231,59 +237,72 @@ class NativePasswordRepository(
) = content { current ->
val copy = authentication.authenticated { get(it, current) }
val ticket = sequence.get()
val owned = clipboard.beginCopy()
val written = CompletableDeferred<Boolean>()
val callback = object : MobileClipboard {
override fun read(): MobileClipboardContent = runBlocking(Dispatchers.Main.immediate) {
clipboardAccess { owned.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()
}
clipboardAccess { owned.write(value) }
written.complete(true)
Unit
}
override fun clear() = runBlocking(Dispatchers.Main.immediate) {
clipboardAccess { owned.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
}
}
val registration = withContext(Dispatchers.Main.immediate) {
authentication.presentation.register {
try {
clearMobileEntryCopy(copy, callback)
} catch (_: MobilePlatformFfiException) {
mutable.value = mutable.value.copy(error = "Clipboard cleanup is unavailable.")
}
}
}
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
}
}
)
copyMobileEntryClearing(copy, callback)
} catch (_: MobilePlatformFfiException.Cancelled) {
written.complete(false)
// Rust cleans up before returning cancellation.
} catch (_: MobilePlatformFfiException) {
written.complete(false)
withContext(Dispatchers.Main.immediate) {
if (ticket == sequence.get()) {
mutable.value =
mutable.value.copy(error = "Copy is unavailable.", notice = null)
}
}
} finally {
withContext(kotlinx.coroutines.NonCancellable + Dispatchers.Main.immediate) {
registration.close()
}
}
}
current
if (written.await()) {
current.copy(notice = "Copied. The clipboard will be cleared automatically.")
} else {
current.copy(error = "Copy is unavailable.", notice = null)
}
}
override fun prepareMutation(action: PasswordMutation) = content { current ->
val plan = authentication.authenticated {
@@ -326,7 +345,10 @@ class NativePasswordRepository(
}
session.storage().passwordNavigation(current.path, null, null, false).toFacade()
}
private fun content(action: suspend (PasswordState) -> PasswordState) {
private fun content(
userActivity: Boolean = true,
action: suspend (PasswordState) -> PasswordState
) {
val current = mutable.value
if (current.sections == null || current.selected == null ||
current.documentRevision == null || !authentication.state.value.unlocked ||
@@ -334,6 +356,7 @@ class NativePasswordRepository(
) {
return
}
if (userActivity) authentication.touch()
run(false) { action(current) }
}
private fun run(clear: Boolean = true, action: suspend () -> PasswordState) {
@@ -435,3 +458,9 @@ private fun PasswordMutation.native() = when (this) {
}
private fun PasswordState.entryPath() = requireNotNull(selected).path
private fun <T> clipboardAccess(action: () -> T): T = try {
action()
} catch (_: java.io.IOException) {
throw MobilePlatformFfiException.ClipboardFailed()
}
@@ -0,0 +1,34 @@
package de.rfc1437.ironstorage.core
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/** Main-thread presentation cleanup; Rust still owns authentication decisions. */
class ProtectedPresentation {
private var windows = 0
val hasOwnedWindow: Boolean get() = windows > 0
var onWindowFocusLost: () -> Unit = { clear() }
fun registerWindow(): AutoCloseable {
windows += 1
return AutoCloseable { windows -= 1 }
}
private val listeners = linkedSetOf<() -> Unit>()
private val mutableGeneration = MutableStateFlow(0L)
val generation: StateFlow<Long> = mutableGeneration
fun register(clear: () -> Unit): AutoCloseable {
synchronized(listeners) { listeners.add(clear) }
return AutoCloseable { synchronized(listeners) { listeners.remove(clear) } }
}
fun clear() {
synchronized(listeners) { listeners.toList() }.forEach { it() }
mutableGeneration.value += 1
}
}
enum class AuthenticationLifecycle {
Foreground,
Background,
DeviceLocked,
MemoryPressure,
ProcessRecreated
}
@@ -2,6 +2,7 @@ package de.rfc1437.ironstorage.core
/** OS clipboard capability; Rust owns timeout, cancellation cleanup and content comparison. */
interface SecretClipboard {
fun beginCopy(): SecretClipboard
fun read(): ByteArray?
fun write(value: ByteArray)
fun clear()
@@ -0,0 +1,32 @@
package de.rfc1437.ironstorage.core
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class ProtectedPresentationTest {
@Test fun tracksOwnedWindowsUntilEveryWindowCloses() {
val presentation = ProtectedPresentation()
val first = presentation.registerWindow()
val second = presentation.registerWindow()
first.close()
assertTrue(presentation.hasOwnedWindow)
second.close()
assertFalse(presentation.hasOwnedWindow)
}
@Test fun clearsSynchronouslyAndDropsDisposedRegistrations() {
val presentation = ProtectedPresentation()
var secret = "public-fixture"
val registration = presentation.register { secret = "" }
presentation.clear()
assertEquals("", secret)
assertEquals(1L, presentation.generation.value)
registration.close()
secret = "disposed"
presentation.clear()
assertEquals("disposed", secret)
assertEquals(2L, presentation.generation.value)
}
}