Enforce Android lifecycle and presentation security (#100)
This commit is contained in:
1 parent
c0ba6d43d8
commit
a06f39af23
37 files changed
+1129
-110
No files matched your search
+23
@@ -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)
|
||||
}
|
||||
+48
-13
@@ -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()
|
||||
|
||||
+63
-7
@@ -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."
|
||||
)
|
||||
+74
-45
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user