Integrate Android biometrics with the shared Rust lease (#98)
This commit is contained in:
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()
|
||||
}
|
||||
+206
@@ -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
|
||||
)
|
||||
+22
-58
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user