Integrate Android biometrics with the shared Rust lease (#98)
Android ARM verification / rust (push) Failing after 47m23s
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 16:32:01 +02:00
1 parent 5c933a99a3
commit 647dc1bb4b
21 files changed
+1473 -118

No files matched your search

@@ -0,0 +1,38 @@
package de.rfc1437.ironstorage.core
import kotlinx.coroutines.flow.StateFlow
enum class AuthenticationFailureKind {
PassphraseRequired,
InvalidPassphrase,
Cancelled,
BiometryUnavailable,
Configuration,
KeyMaterial,
Entry,
SecureStorage,
Expired,
Conflict
}
data class AuthenticationFailure(
val kind: AuthenticationFailureKind,
val title: String,
val detail: String
)
data class AuthenticationState(
val unlocked: Boolean = false,
val biometricEnabled: Boolean = false,
val remainingSeconds: ULong = 0u,
val busy: Boolean = false,
val failure: AuthenticationFailure? = null
)
interface AuthenticationRepository {
val state: StateFlow<AuthenticationState>
fun reload()
fun unlock()
fun enroll(passphrase: String)
fun disable()
fun lock()
fun cancel()
fun touch()
}
@@ -0,0 +1,206 @@
package de.rfc1437.ironstorage.core
import android.os.SystemClock
import de.rfc1437.ironstorage.bridge.NativeBridgeException
import de.rfc1437.ironstorage.bridge.generated.MobileAuthentication
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.MobilePrivateStorageFfiException
import java.util.concurrent.atomic.AtomicLong
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
/** Mirrors the single Rust lease; no timeout or biometric policy lives here. */
class NativeAuthenticationRepository(
private val session: NativePrivateSession,
private val scope: CoroutineScope,
private val io: CoroutineDispatcher
) : AuthenticationRepository {
private val mutable = MutableStateFlow(AuthenticationState())
override val state: StateFlow<AuthenticationState> = mutable
private val sequence = AtomicLong()
private val operation = Mutex()
private var revocation: Job? = null
@Volatile private var current: MobileAuthentication? = null
private val clock = object : MobileClock {
override fun monotonicMilliseconds(): ULong = SystemClock.elapsedRealtime().toULong()
}
init {
scope.launch {
while (true) {
delay(250)
if (mutable.value.unlocked && !mutable.value.busy) {
val ticket = sequence.get()
try {
val value = withContext(io) { current?.state() }
if (ticket == sequence.get() && value != null) {
mutable.value = value.toFacade()
}
} catch (_: MobileAuthenticationFfiException) {
if (ticket == sequence.get()) lock()
} catch (_: NativeBridgeException) {
if (ticket == sequence.get()) lock()
}
}
}
}
}
private fun owner(): MobileAuthentication =
session.storage().authentication(session.backend(), clock).also { current = it }
internal suspend fun <T> authenticated(block: (MobileAuthentication) -> T): T =
withContext(kotlinx.coroutines.Dispatchers.Main.immediate) {
val ticket = sequence.incrementAndGet()
mutable.value = mutable.value.copy(busy = true, failure = null)
operation.withLock {
revocation?.join()
if (ticket != sequence.get()) throw cancelled()
try {
val result = withContext(io) {
val authentication = owner()
if (ticket != sequence.get()) throw cancelled()
val result = block(authentication)
if (ticket != sequence.get()) {
authentication.manualLock()
throw cancelled()
}
result
}
val value = withContext(io) { current!!.state() }
if (ticket == sequence.get()) mutable.value = 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()
)
}
throw error
} catch (error: MobilePrivateStorageFfiException) {
if (ticket == sequence.get()) unavailable()
throw error
} catch (error: NativeBridgeException) {
if (ticket == sequence.get()) unavailable()
throw error
} catch (error: java.io.IOException) {
if (ticket == sequence.get()) unavailable()
throw error
} finally {
if (ticket == sequence.get()) mutable.value = mutable.value.copy(busy = false)
}
}
}
override fun reload() = run { it.state() }
override fun unlock() = run { it.restoreBiometricLease() }
override fun enroll(passphrase: String) = run { it.enrollBiometricUnlock(passphrase) }
override fun disable() = run { it.setBiometricUnlock(false) }
private fun run(block: (MobileAuthentication) -> Unit) {
if (mutable.value.busy) return
scope.launch {
try {
authenticated(block)
} catch (_: MobileAuthenticationFfiException) {
// The typed failure is published by authenticated().
} catch (_: MobilePrivateStorageFfiException) {
// The failure is published before releasing the operation.
} catch (_: NativeBridgeException) {
// The failure is published before releasing the operation.
} catch (_: java.io.IOException) {
// The failure is published before releasing the operation.
}
}
}
private fun unavailable() {
mutable.value = 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) {
val ticket = sequence.incrementAndGet()
mutable.value = mutable.value.copy(
unlocked = false,
remainingSeconds = 0u,
busy = false,
failure = if (cancel) {
AuthenticationFailure(
AuthenticationFailureKind.Cancelled,
"Authentication cancelled",
"No protected content was unlocked."
)
} else {
null
}
)
session.cancelAuthentication()
val authentication = current
revocation = scope.launch(io) {
try {
if (cancel) authentication?.cancel() else authentication?.manualLock()
} catch (_: MobileAuthenticationFfiException) {
withContext(kotlinx.coroutines.Dispatchers.Main.immediate) {
if (ticket == sequence.get()) unavailable()
}
}
}
}
override fun touch() {
if (!mutable.value.unlocked || mutable.value.busy) return
val ticket = sequence.get()
scope.launch(io) {
try {
current?.touchUserActivity()
} catch (_: MobileAuthenticationFfiException) {
withContext(kotlinx.coroutines.Dispatchers.Main.immediate) {
if (ticket == sequence.get()) lock()
}
}
}
}
}
private fun cancelled() = MobileAuthenticationFfiException.Failed(
MobileAuthenticationErrorKind.CANCELLED,
"Authentication cancelled",
"No protected content was unlocked."
)
private fun MobileAuthenticationState.toFacade() = AuthenticationState(
unlocked,
biometricUnlockEnabled,
remainingSeconds
)
private fun MobileAuthenticationFfiException.Failed.toFacade() = AuthenticationFailure(
when (kind) {
MobileAuthenticationErrorKind.PASSPHRASE_REQUIRED ->
AuthenticationFailureKind.PassphraseRequired
MobileAuthenticationErrorKind.INVALID_PASSPHRASE ->
AuthenticationFailureKind.InvalidPassphrase
MobileAuthenticationErrorKind.CANCELLED -> AuthenticationFailureKind.Cancelled
MobileAuthenticationErrorKind.BIOMETRY_UNAVAILABLE ->
AuthenticationFailureKind.BiometryUnavailable
MobileAuthenticationErrorKind.CONFIGURATION -> AuthenticationFailureKind.Configuration
MobileAuthenticationErrorKind.KEY_MATERIAL -> AuthenticationFailureKind.KeyMaterial
MobileAuthenticationErrorKind.ENTRY -> AuthenticationFailureKind.Entry
MobileAuthenticationErrorKind.SECURE_STORAGE -> AuthenticationFailureKind.SecureStorage
MobileAuthenticationErrorKind.EXPIRED -> AuthenticationFailureKind.Expired
MobileAuthenticationErrorKind.CONFLICT -> AuthenticationFailureKind.Conflict
},
title,
detail
)
@@ -1,18 +1,14 @@
package de.rfc1437.ironstorage.core
import android.os.SystemClock
import de.rfc1437.ironstorage.bridge.NativeBridgeException
import de.rfc1437.ironstorage.bridge.generated.MobileAuthentication
import de.rfc1437.ironstorage.bridge.generated.MobileAuthenticationFfiException
import de.rfc1437.ironstorage.bridge.generated.MobileClock
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.MobilePrivateStorageFfiException
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
@@ -22,35 +18,18 @@ import kotlinx.coroutines.withContext
class NativePasswordRepository(
private val session: NativePrivateSession,
private val scope: CoroutineScope
private val scope: CoroutineScope,
private val authentication: NativeAuthenticationRepository,
private val io: CoroutineDispatcher
) : PasswordRepository {
private val mutable = MutableStateFlow(PasswordState())
override val state: StateFlow<PasswordState> = mutable
private val sequence = AtomicLong()
private val mutex = Mutex()
private var authentication: MobileAuthentication? = null
private val clock = object : MobileClock {
override fun monotonicMilliseconds(): ULong = SystemClock.elapsedRealtime().toULong()
}
init {
scope.launch {
while (true) {
delay(1000)
if (mutable.value.sections != null) {
mutex.withLock {
val active = try {
withContext(Dispatchers.IO) {
authentication?.state()?.unlocked == true
}
} catch (_: MobileAuthenticationFfiException) {
false
} catch (_: NativeBridgeException) {
false
}
if (!active) mutable.value = mutable.value.copy(sections = null)
}
}
authentication.state.collect { lease ->
if (!lease.unlocked) mutable.value = mutable.value.copy(sections = null)
}
}
}
@@ -81,8 +60,6 @@ class NativePasswordRepository(
}
private fun navigate(path: String, selected: String?, revision: String?, activate: Boolean) =
run {
authentication?.manualLock()
authentication = null
session.storage().passwordNavigation(path, selected, revision, activate).toFacade()
}
override fun unlock(passphrase: String) {
@@ -97,19 +74,15 @@ class NativePasswordRepository(
false
).toFacade()
if (verified.selected == null) return@run verified
val auth =
authentication
?: session.storage().authentication(session.backend(), clock).also {
authentication =
it
}
val page = auth.navigationEntry(
current.path,
selected.id,
current.revision,
passphrase.ifEmpty { null },
(System.currentTimeMillis() / 1000).toULong()
)
val page = authentication.authenticated { auth ->
auth.navigationEntry(
current.path,
selected.id,
current.revision,
passphrase.ifEmpty { null },
(System.currentTimeMillis() / 1000).toULong()
)
}
verified.copy(
sections = page.page.sections.map { section ->
PasswordSection(
@@ -130,26 +103,16 @@ class NativePasswordRepository(
override fun lock() {
sequence.incrementAndGet()
mutable.value = mutable.value.copy(sections = null, loading = false)
scope.launch {
mutex.withLock {
try {
withContext(Dispatchers.IO) { authentication?.manualLock() }
} catch (_: MobileAuthenticationFfiException) {
mutable.value = mutable.value.copy(
error = "Protected content was cleared. Retry locking the device storage."
)
}
}
}
authentication.lock()
}
private fun run(action: () -> PasswordState) {
private fun run(action: suspend () -> PasswordState) {
val ticket = sequence.incrementAndGet()
mutable.value = mutable.value.copy(loading = true, error = null, sections = null)
scope.launch {
mutex.withLock {
if (sequence.get() != ticket) return@withLock
val result = try {
withContext(Dispatchers.IO) { action() }
withContext(io) { action() }
} catch (error: MobilePasswordFfiException.Failed) {
mutable.value.copy(error = error.detail, selected = null)
} catch (error: MobileAuthenticationFfiException.Failed) {
@@ -167,9 +130,10 @@ class NativePasswordRepository(
)
}
if (sequence.get() == ticket) {
mutable.value = result.copy(loading = false)
} else {
withContext(Dispatchers.IO) { authentication?.manualLock() }
mutable.value = result.copy(
loading = false,
sections = result.sections.takeIf { authentication.state.value.unlocked }
)
}
}
}
@@ -13,6 +13,12 @@ class NativePrivateSession(
private val keystore: AndroidKeystore
) {
val runtime = NativeRuntime(context)
fun cancelAuthentication() = keystore.cancelPending()
fun initialize() {
storage()
}
private var initialized: MobilePrivateRuntime? = null
private var secrets: MobileSecretBackend? = null