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
+2
-1
@@ -1,12 +1,13 @@
|
|||||||
# Native Android build
|
# Native Android build
|
||||||
|
|
||||||
Architecture and issue ownership: [ADR](../docs/android/architecture.md) and
|
Architecture and issue ownership: [ADR](../docs/android/architecture.md) and
|
||||||
[parity matrix](../docs/android/parity.md). The native foundation, onboarding, navigation and Home implement #89–#97, including private storage, Keystore
|
[parity matrix](../docs/android/parity.md). The native foundation, onboarding, navigation, Home and authentication implement #89–#98, including private storage, Keystore
|
||||||
callbacks and build gates. Both variants invoke Rust through the generated
|
callbacks and build gates. Both variants invoke Rust through the generated
|
||||||
UniFFI API. Local-first and HTTPS onboarding is described in
|
UniFFI API. Local-first and HTTPS onboarding is described in
|
||||||
[onboarding](../docs/android/onboarding.md). The Passwords tab uses Rust-owned,
|
[onboarding](../docs/android/onboarding.md). The Passwords tab uses Rust-owned,
|
||||||
revision-checked [adaptive navigation](../docs/android/password-navigation.md);
|
revision-checked [adaptive navigation](../docs/android/password-navigation.md);
|
||||||
Home renders Rust-owned [Git activity and HTTPS synchronization](../docs/android/home-sync.md).
|
Home renders Rust-owned [Git activity and HTTPS synchronization](../docs/android/home-sync.md).
|
||||||
|
Passwords and Preferences share [Rust-owned biometric authentication](../docs/android/authentication.md).
|
||||||
Remaining feature screens retain their milestone issues. The full [CI and emulator workflow](../docs/android/ci.md) documents the
|
Remaining feature screens retain their milestone issues. The full [CI and emulator workflow](../docs/android/ci.md) documents the
|
||||||
ARM-only runner/toolchain policy and artifact audits.
|
ARM-only runner/toolchain policy and artifact audits.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,231 @@
|
|||||||
|
package de.rfc1437.ironstorage
|
||||||
|
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.test.core.app.ActivityScenario
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import de.rfc1437.ironstorage.core.AndroidKeystore
|
||||||
|
import de.rfc1437.ironstorage.core.AuthenticationFailureKind
|
||||||
|
import de.rfc1437.ironstorage.core.KeystoreAuthorization
|
||||||
|
import de.rfc1437.ironstorage.core.KeystoreCiphertext
|
||||||
|
import de.rfc1437.ironstorage.core.KeystoreKeySpecification
|
||||||
|
import de.rfc1437.ironstorage.core.NativeAuthenticationRepository
|
||||||
|
import de.rfc1437.ironstorage.core.NativeFixtureShellRepository
|
||||||
|
import de.rfc1437.ironstorage.core.NativeOnboardingRepository
|
||||||
|
import de.rfc1437.ironstorage.core.NativePrivateSession
|
||||||
|
import de.rfc1437.ironstorage.core.PrivateDirectories
|
||||||
|
import de.rfc1437.ironstorage.features.AuthenticationControls
|
||||||
|
import de.rfc1437.ironstorage.platform.AndroidKeystoreAdapter
|
||||||
|
import java.io.File
|
||||||
|
import java.util.UUID
|
||||||
|
import java.util.concurrent.CountDownLatch
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import java.util.concurrent.atomic.AtomicReference
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
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.assertTrue
|
||||||
|
import org.junit.Assume.assumeTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/** Real OS/Rust integration using public fixtures and only production Kotlin facades. */
|
||||||
|
class AuthenticationLeaseTest {
|
||||||
|
private val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||||
|
private val context = instrumentation.targetContext
|
||||||
|
|
||||||
|
@Test fun strongBiometricLeaseCancellationBackgroundAndManualRecovery() = runBlocking {
|
||||||
|
assumeTrue(InstrumentationRegistry.getArguments().getString("biometricLease") == "true")
|
||||||
|
val os = AndroidKeystoreAdapter(context)
|
||||||
|
assertTrue(
|
||||||
|
"Enroll a synthetic strong fingerprint for this opt-in test",
|
||||||
|
os.deviceState().strongBiometric
|
||||||
|
)
|
||||||
|
val id = "auth-${UUID.randomUUID()}"
|
||||||
|
val roots = listOf(context.filesDir, context.noBackupFilesDir, context.cacheDir).map {
|
||||||
|
File(it, id).apply { mkdirs() }.canonicalPath
|
||||||
|
}
|
||||||
|
val isolated = FixtureKeystore(os, id)
|
||||||
|
val session =
|
||||||
|
NativePrivateSession(context, {
|
||||||
|
PrivateDirectories(roots[0], roots[1], roots[2])
|
||||||
|
}, isolated)
|
||||||
|
// Select the isolated Rust context before any activity creates its default runtime.
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
session.initialize()
|
||||||
|
}
|
||||||
|
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||||
|
val shell = NativeFixtureShellRepository(session, scope)
|
||||||
|
val setup = NativeOnboardingRepository(session, scope, shell)
|
||||||
|
val authentication = NativeAuthenticationRepository(session, scope, Dispatchers.IO)
|
||||||
|
val password = "public-test-passphrase"
|
||||||
|
suspend fun settled() = withTimeout(180000) {
|
||||||
|
authentication.state.first { !it.busy }
|
||||||
|
}
|
||||||
|
suspend fun request(action: () -> Unit) {
|
||||||
|
withContext(Dispatchers.Main.immediate) { action() }
|
||||||
|
}
|
||||||
|
val stage = MutableStateFlow("Authentication fixture")
|
||||||
|
val step = AtomicReference<CountDownLatch?>()
|
||||||
|
fun show(value: String) {
|
||||||
|
android.util.Log.i("IronStorage98", value)
|
||||||
|
stage.value = value
|
||||||
|
}
|
||||||
|
fun awaitStep(value: String): Boolean {
|
||||||
|
val latch = CountDownLatch(1)
|
||||||
|
step.set(latch)
|
||||||
|
show(value)
|
||||||
|
return try {
|
||||||
|
latch.await(180, TimeUnit.SECONDS)
|
||||||
|
} finally {
|
||||||
|
step.set(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ActivityScenario.launch(MainActivity::class.java).use { scenario ->
|
||||||
|
lateinit var host: MainActivity
|
||||||
|
scenario.onActivity {
|
||||||
|
host = it
|
||||||
|
os.attach(it)
|
||||||
|
it.setContent {
|
||||||
|
MaterialTheme {
|
||||||
|
Column {
|
||||||
|
Text(stage.collectAsState().value)
|
||||||
|
TextButton(onClick = {
|
||||||
|
step.get()?.countDown()
|
||||||
|
}) { Text("Continue fixture") }
|
||||||
|
AuthenticationControls(authentication)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
withTimeout(20000) { setup.setup.first { !it.busy } }
|
||||||
|
request { setup.generate("Authentication fixture", password) }
|
||||||
|
val generated = withTimeout(20000) {
|
||||||
|
setup.setup.first {
|
||||||
|
!it.busy &&
|
||||||
|
it.selectedKey.isNotEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
request { setup.createLocal(generated.selectedKey, password) }
|
||||||
|
withTimeout(20000) { setup.setup.first { !it.busy && it.configured } }
|
||||||
|
show("98:enroll")
|
||||||
|
request { authentication.enroll(password) }
|
||||||
|
assertTrue(settled().biometricEnabled)
|
||||||
|
request { authentication.lock() }
|
||||||
|
assertFalse(authentication.state.value.unlocked)
|
||||||
|
show("98:unlock")
|
||||||
|
request { authentication.unlock() }
|
||||||
|
assertTrue(settled().unlocked)
|
||||||
|
request { authentication.reload() }
|
||||||
|
assertTrue(settled().unlocked)
|
||||||
|
request { authentication.lock() }
|
||||||
|
show("98:cancel")
|
||||||
|
request { authentication.unlock() }
|
||||||
|
assertEquals(AuthenticationFailureKind.Cancelled, settled().failure?.kind)
|
||||||
|
assertFalse(authentication.state.value.unlocked)
|
||||||
|
assertTrue(awaitStep("98:background-start"))
|
||||||
|
request { authentication.unlock() }
|
||||||
|
instrumentation.waitForIdleSync()
|
||||||
|
scenario.moveToState(Lifecycle.State.CREATED)
|
||||||
|
request { authentication.lock() }
|
||||||
|
assertFalse(settled().unlocked)
|
||||||
|
scenario.moveToState(Lifecycle.State.RESUMED)
|
||||||
|
request { authentication.disable() }
|
||||||
|
assertFalse(settled().biometricEnabled)
|
||||||
|
show("98:reenroll")
|
||||||
|
request { authentication.enroll(password) }
|
||||||
|
assertTrue(settled().unlocked)
|
||||||
|
request { authentication.lock() }
|
||||||
|
assertTrue(awaitStep("98:invalidate-now"))
|
||||||
|
request { authentication.unlock() }
|
||||||
|
val invalid = settled()
|
||||||
|
assertFalse(invalid.unlocked)
|
||||||
|
assertTrue(
|
||||||
|
invalid.failure?.kind == AuthenticationFailureKind.PassphraseRequired ||
|
||||||
|
invalid.failure?.kind == AuthenticationFailureKind.BiometryUnavailable
|
||||||
|
)
|
||||||
|
request { authentication.disable() }
|
||||||
|
assertFalse(settled().biometricEnabled)
|
||||||
|
show("98:recover")
|
||||||
|
request { authentication.enroll(password) }
|
||||||
|
assertTrue(settled().unlocked)
|
||||||
|
request { authentication.lock() }
|
||||||
|
assertTrue(awaitStep("98:remove-lock"))
|
||||||
|
request { authentication.unlock() }
|
||||||
|
val removed = settled()
|
||||||
|
assertFalse(removed.unlocked)
|
||||||
|
assertTrue(
|
||||||
|
removed.failure?.kind == AuthenticationFailureKind.PassphraseRequired ||
|
||||||
|
removed.failure?.kind == AuthenticationFailureKind.BiometryUnavailable
|
||||||
|
)
|
||||||
|
request { authentication.disable() }
|
||||||
|
assertFalse(settled().biometricEnabled)
|
||||||
|
request { authentication.enroll(password) }
|
||||||
|
val unsupported = settled()
|
||||||
|
assertFalse(unsupported.unlocked)
|
||||||
|
assertFalse(unsupported.biometricEnabled)
|
||||||
|
assertEquals(
|
||||||
|
AuthenticationFailureKind.BiometryUnavailable,
|
||||||
|
unsupported.failure?.kind
|
||||||
|
)
|
||||||
|
assertTrue(awaitStep("98:restore-lock"))
|
||||||
|
show("98:recover-lock")
|
||||||
|
request { authentication.enroll(password) }
|
||||||
|
assertTrue(settled().unlocked)
|
||||||
|
request { authentication.disable() }
|
||||||
|
assertFalse(settled().unlocked)
|
||||||
|
show("98:complete")
|
||||||
|
} finally {
|
||||||
|
authentication.lock()
|
||||||
|
os.cancelPending()
|
||||||
|
os.detach(host)
|
||||||
|
scope.cancel()
|
||||||
|
isolated.aliases().forEach { isolated.deleteKey(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Only OS aliases are namespaced so orphan cleanup cannot touch the production store. */
|
||||||
|
private class FixtureKeystore(private val os: AndroidKeystore, id: String) :
|
||||||
|
AndroidKeystore by os {
|
||||||
|
private val prefix = "ironstorage.issue98.fixture.$id."
|
||||||
|
override fun createKey(specification: KeystoreKeySpecification) = os.createKey(
|
||||||
|
KeystoreKeySpecification(
|
||||||
|
prefix + specification.alias,
|
||||||
|
specification.authorization,
|
||||||
|
specification.unlockedDevice,
|
||||||
|
specification.invalidateOnEnrollment,
|
||||||
|
specification.strongbox
|
||||||
|
)
|
||||||
|
)
|
||||||
|
override fun encrypt(
|
||||||
|
alias: String,
|
||||||
|
authorization: KeystoreAuthorization,
|
||||||
|
aad: ByteArray,
|
||||||
|
value: ByteArray
|
||||||
|
): KeystoreCiphertext = os.encrypt(prefix + alias, authorization, aad, value)
|
||||||
|
override fun decrypt(
|
||||||
|
alias: String,
|
||||||
|
authorization: KeystoreAuthorization,
|
||||||
|
aad: ByteArray,
|
||||||
|
value: KeystoreCiphertext
|
||||||
|
): ByteArray = os.decrypt(prefix + alias, authorization, aad, value)
|
||||||
|
override fun deleteKey(alias: String) = os.deleteKey(prefix + alias)
|
||||||
|
override fun aliases(): List<String> = os.aliases().filter {
|
||||||
|
it.startsWith(prefix)
|
||||||
|
}.map { it.removePrefix(prefix) }
|
||||||
|
}
|
||||||
|
}
|
||||||
+112
@@ -0,0 +1,112 @@
|
|||||||
|
package de.rfc1437.ironstorage
|
||||||
|
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.assertIsNotEnabled
|
||||||
|
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.compose.ui.test.performClick
|
||||||
|
import androidx.compose.ui.test.performTextInput
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import de.rfc1437.ironstorage.core.AuthenticationFailure
|
||||||
|
import de.rfc1437.ironstorage.core.AuthenticationFailureKind
|
||||||
|
import de.rfc1437.ironstorage.core.AuthenticationRepository
|
||||||
|
import de.rfc1437.ironstorage.core.AuthenticationState
|
||||||
|
import de.rfc1437.ironstorage.features.AuthenticationControls
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class AuthenticationScreenTest {
|
||||||
|
@get:Rule val compose = createAndroidComposeRule<MainActivity>()
|
||||||
|
|
||||||
|
@Test fun enrollmentRequiresExplicitSubmissionAndTypedSuccessBeforeDisplayingEnabled() {
|
||||||
|
val repository = Fixture()
|
||||||
|
compose.activity.runOnUiThread {
|
||||||
|
compose.activity.setContent { MaterialTheme { AuthenticationControls(repository) } }
|
||||||
|
}
|
||||||
|
compose.onNodeWithText("Biometric unlock disabled").assertIsDisplayed()
|
||||||
|
compose.onNodeWithText("Enable biometric unlock").performClick()
|
||||||
|
compose.onNodeWithText("Continue").assertIsNotEnabled()
|
||||||
|
compose.onNodeWithText("GPG passphrase").performTextInput("public-test-passphrase")
|
||||||
|
compose.onNodeWithText("Continue").performClick()
|
||||||
|
assertEquals("public-test-passphrase", repository.submitted)
|
||||||
|
compose.onNodeWithText("Biometric unlock disabled").assertIsDisplayed()
|
||||||
|
compose.runOnIdle {
|
||||||
|
repository.state.value = AuthenticationState(
|
||||||
|
failure = AuthenticationFailure(
|
||||||
|
AuthenticationFailureKind.Cancelled,
|
||||||
|
"Authentication cancelled",
|
||||||
|
"No protected content was unlocked."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
compose.onNodeWithText("Biometric unlock disabled").assertIsDisplayed()
|
||||||
|
compose.runOnIdle { repository.state.value = AuthenticationState(true, true, 30u) }
|
||||||
|
compose.onNodeWithText("Biometric unlock enabled").assertIsDisplayed()
|
||||||
|
compose.onNodeWithText("Lock").performClick()
|
||||||
|
assertTrue(repository.locked)
|
||||||
|
compose.onNodeWithText("Unlock").performClick()
|
||||||
|
assertEquals(1, repository.unlocks)
|
||||||
|
compose.onNodeWithText("Disable biometric unlock").performClick()
|
||||||
|
assertEquals(1, repository.disabled)
|
||||||
|
compose.onNodeWithText("Biometric unlock disabled").assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun cancellationBackgroundAndRemountNeverRestoreThePassphrase() {
|
||||||
|
val repository = Fixture()
|
||||||
|
fun mount() = compose.activity.runOnUiThread {
|
||||||
|
compose.activity.setContent { MaterialTheme { AuthenticationControls(repository) } }
|
||||||
|
}
|
||||||
|
mount()
|
||||||
|
compose.onNodeWithText("Enable biometric unlock").performClick()
|
||||||
|
compose.onNodeWithText("GPG passphrase").performTextInput("public-test-passphrase")
|
||||||
|
compose.onNodeWithText("Cancel").performClick()
|
||||||
|
compose.onNodeWithText("Enable biometric unlock").performClick()
|
||||||
|
compose.onNodeWithText("Continue").assertIsNotEnabled()
|
||||||
|
compose.onNodeWithText("GPG passphrase").performTextInput("another-public-fixture")
|
||||||
|
compose.activityRule.scenario.moveToState(Lifecycle.State.CREATED)
|
||||||
|
compose.activityRule.scenario.moveToState(Lifecycle.State.RESUMED)
|
||||||
|
compose.onNodeWithText("GPG passphrase").assertDoesNotExist()
|
||||||
|
compose.onNodeWithText("Enable biometric unlock").performClick()
|
||||||
|
compose.onNodeWithText("Continue").assertIsNotEnabled()
|
||||||
|
compose.activity.runOnUiThread { compose.activity.setContent { MaterialTheme { } } }
|
||||||
|
mount()
|
||||||
|
compose.onNodeWithText("Enable biometric unlock").performClick()
|
||||||
|
compose.onNodeWithText("Continue").assertIsNotEnabled()
|
||||||
|
assertEquals(null, repository.submitted)
|
||||||
|
assertTrue(repository.cancelled > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
private class Fixture : AuthenticationRepository {
|
||||||
|
override val state = MutableStateFlow(AuthenticationState())
|
||||||
|
var submitted: String? = null
|
||||||
|
var locked = false
|
||||||
|
var unlocks = 0
|
||||||
|
var disabled = 0
|
||||||
|
var cancelled = 0
|
||||||
|
override fun reload() = Unit
|
||||||
|
override fun enroll(passphrase: String) {
|
||||||
|
submitted = passphrase
|
||||||
|
}
|
||||||
|
override fun unlock() {
|
||||||
|
unlocks++
|
||||||
|
}
|
||||||
|
override fun disable() {
|
||||||
|
disabled++
|
||||||
|
state.value = AuthenticationState()
|
||||||
|
}
|
||||||
|
override fun lock() {
|
||||||
|
locked = true
|
||||||
|
state.value = state.value.copy(unlocked = false)
|
||||||
|
}
|
||||||
|
override fun cancel() {
|
||||||
|
cancelled++
|
||||||
|
state.value = state.value.copy(unlocked = false)
|
||||||
|
}
|
||||||
|
override fun touch() = Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,10 @@ import androidx.compose.ui.test.performScrollTo
|
|||||||
import androidx.compose.ui.test.performSemanticsAction
|
import androidx.compose.ui.test.performSemanticsAction
|
||||||
import androidx.compose.ui.test.performTextInput
|
import androidx.compose.ui.test.performTextInput
|
||||||
import androidx.compose.ui.unit.Density
|
import androidx.compose.ui.unit.Density
|
||||||
|
import de.rfc1437.ironstorage.core.AuthenticationFailure
|
||||||
|
import de.rfc1437.ironstorage.core.AuthenticationFailureKind
|
||||||
|
import de.rfc1437.ironstorage.core.AuthenticationRepository
|
||||||
|
import de.rfc1437.ironstorage.core.AuthenticationState
|
||||||
import de.rfc1437.ironstorage.core.PasswordField
|
import de.rfc1437.ironstorage.core.PasswordField
|
||||||
import de.rfc1437.ironstorage.core.PasswordKind
|
import de.rfc1437.ironstorage.core.PasswordKind
|
||||||
import de.rfc1437.ironstorage.core.PasswordRepository
|
import de.rfc1437.ironstorage.core.PasswordRepository
|
||||||
@@ -54,7 +58,7 @@ class PasswordScreenTest {
|
|||||||
compose.activity.runOnUiThread {
|
compose.activity.runOnUiThread {
|
||||||
compose.activity.setContent {
|
compose.activity.setContent {
|
||||||
inputMode = LocalInputModeManager.current
|
inputMode = LocalInputModeManager.current
|
||||||
MaterialTheme { PasswordScreen(repository) }
|
MaterialTheme { PasswordScreen(repository, repository.authentication) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
compose.runOnIdle { assertTrue(inputMode.requestInputMode(InputMode.Keyboard)) }
|
compose.runOnIdle { assertTrue(inputMode.requestInputMode(InputMode.Keyboard)) }
|
||||||
@@ -84,12 +88,19 @@ class PasswordScreenTest {
|
|||||||
compose.onAllNodesWithText("Actions")[0].performClick()
|
compose.onAllNodesWithText("Actions")[0].performClick()
|
||||||
compose.onNodeWithText("Open").performClick()
|
compose.onNodeWithText("Open").performClick()
|
||||||
compose.onNodeWithText("Unlock").assertIsDisplayed()
|
compose.onNodeWithText("Unlock").assertIsDisplayed()
|
||||||
|
compose.onNodeWithText("Unlock").performClick()
|
||||||
compose.onNodeWithText("GPG passphrase").performTextInput("incorrect")
|
compose.onNodeWithText("GPG passphrase").performTextInput("incorrect")
|
||||||
compose.onNodeWithText("Unlock").performClick()
|
compose.onNodeWithText("Continue").performClick()
|
||||||
compose.onNodeWithText("Incorrect passphrase. Retry unlocking.").assertIsDisplayed()
|
compose.onNodeWithText("Incorrect passphrase. Retry unlocking.").assertIsDisplayed()
|
||||||
|
compose.onNodeWithText("Use GPG passphrase").performClick()
|
||||||
compose.onNodeWithText("GPG passphrase").performTextInput("public-test-passphrase")
|
compose.onNodeWithText("GPG passphrase").performTextInput("public-test-passphrase")
|
||||||
compose.onNodeWithText("Unlock").performClick()
|
compose.onNodeWithText("Continue").performClick()
|
||||||
compose.onNodeWithText("Fixture account").assertIsDisplayed()
|
compose.onNodeWithText("Fixture account").assertIsDisplayed()
|
||||||
|
// Revocation must mask even a page result that has not been cleared yet.
|
||||||
|
compose.runOnIdle { repository.lease.value = AuthenticationState() }
|
||||||
|
compose.onNodeWithText("Fixture account").assertDoesNotExist()
|
||||||
|
compose.onNodeWithText("Unlock").assertIsDisplayed()
|
||||||
|
compose.runOnIdle { repository.lease.value = AuthenticationState(unlocked = true) }
|
||||||
compose.onNodeWithText("Lock").performClick()
|
compose.onNodeWithText("Lock").performClick()
|
||||||
compose.onNodeWithText("Unlock").assertIsDisplayed()
|
compose.onNodeWithText("Unlock").assertIsDisplayed()
|
||||||
repository.replaceOnRefresh = true
|
repository.replaceOnRefresh = true
|
||||||
@@ -117,7 +128,7 @@ class PasswordScreenTest {
|
|||||||
compose.activity.setContent {
|
compose.activity.setContent {
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
CompositionLocalProvider(LocalDensity provides Density(density.density, 2f)) {
|
CompositionLocalProvider(LocalDensity provides Density(density.density, 2f)) {
|
||||||
MaterialTheme { PasswordScreen(repository) }
|
MaterialTheme { PasswordScreen(repository, repository.authentication) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -151,6 +162,19 @@ class PasswordScreenTest {
|
|||||||
)
|
)
|
||||||
override val state =
|
override val state =
|
||||||
MutableStateFlow(PasswordState(revision = "root", rows = listOf(folder)))
|
MutableStateFlow(PasswordState(revision = "root", rows = listOf(folder)))
|
||||||
|
val lease = MutableStateFlow(AuthenticationState())
|
||||||
|
val authentication = object : AuthenticationRepository {
|
||||||
|
override val state = lease
|
||||||
|
override fun reload() = Unit
|
||||||
|
override fun unlock() = error("Unexpected direct unlock")
|
||||||
|
override fun enroll(passphrase: String) = error("Unexpected enrollment")
|
||||||
|
override fun disable() = error("Unexpected disable")
|
||||||
|
override fun lock() {
|
||||||
|
state.value = state.value.copy(unlocked = false)
|
||||||
|
}
|
||||||
|
override fun cancel() = lock()
|
||||||
|
override fun touch() = Unit
|
||||||
|
}
|
||||||
var lastId: String? = null
|
var lastId: String? = null
|
||||||
var replaceOnRefresh = false
|
var replaceOnRefresh = false
|
||||||
override fun restore(path: String, selected: String?, revision: String?) = Unit
|
override fun restore(path: String, selected: String?, revision: String?) = Unit
|
||||||
@@ -190,10 +214,21 @@ class PasswordScreenTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
override fun unlock(passphrase: String) {
|
override fun unlock(passphrase: String) {
|
||||||
|
if (passphrase.isEmpty()) {
|
||||||
|
lease.value = AuthenticationState(
|
||||||
|
failure = AuthenticationFailure(
|
||||||
|
AuthenticationFailureKind.PassphraseRequired,
|
||||||
|
"Passphrase Required",
|
||||||
|
"Enter the passphrase."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (passphrase != "public-test-passphrase") {
|
if (passphrase != "public-test-passphrase") {
|
||||||
state.value = state.value.copy(error = "Incorrect passphrase. Retry unlocking.")
|
state.value = state.value.copy(error = "Incorrect passphrase. Retry unlocking.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
lease.value = AuthenticationState(unlocked = true)
|
||||||
assertEquals("public-test-passphrase", passphrase)
|
assertEquals("public-test-passphrase", passphrase)
|
||||||
state.value =
|
state.value =
|
||||||
state.value.copy(
|
state.value.copy(
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import androidx.activity.viewModels
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.fragment.app.FragmentActivity
|
import androidx.fragment.app.FragmentActivity
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import de.rfc1437.ironstorage.core.AuthenticationRepository
|
||||||
|
import de.rfc1437.ironstorage.core.PasswordRepository
|
||||||
import de.rfc1437.ironstorage.core.ShellRepository
|
import de.rfc1437.ironstorage.core.ShellRepository
|
||||||
import de.rfc1437.ironstorage.features.SetupFormViewModel
|
import de.rfc1437.ironstorage.features.SetupFormViewModel
|
||||||
import de.rfc1437.ironstorage.features.ShellViewModel
|
import de.rfc1437.ironstorage.features.ShellViewModel
|
||||||
@@ -19,10 +21,24 @@ class MainActivity : FragmentActivity() {
|
|||||||
|
|
||||||
@Inject lateinit var repository: ShellRepository
|
@Inject lateinit var repository: ShellRepository
|
||||||
|
|
||||||
|
@Inject lateinit var authentication: AuthenticationRepository
|
||||||
|
|
||||||
|
@Inject lateinit var passwords: PasswordRepository
|
||||||
|
|
||||||
private val setupForm: SetupFormViewModel by viewModels()
|
private val setupForm: SetupFormViewModel by viewModels()
|
||||||
|
|
||||||
private val viewModel: ShellViewModel by viewModels()
|
private val viewModel: ShellViewModel by viewModels()
|
||||||
|
|
||||||
|
override fun onStop() {
|
||||||
|
passwords.lock()
|
||||||
|
super.onStop()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onUserInteraction() {
|
||||||
|
authentication.touch()
|
||||||
|
super.onUserInteraction()
|
||||||
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
keystore.detach(this)
|
keystore.detach(this)
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import dagger.Provides
|
|||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import de.rfc1437.ironstorage.core.AuthenticationRepository
|
||||||
import de.rfc1437.ironstorage.core.HomeRepository
|
import de.rfc1437.ironstorage.core.HomeRepository
|
||||||
|
import de.rfc1437.ironstorage.core.NativeAuthenticationRepository
|
||||||
import de.rfc1437.ironstorage.core.NativeFixtureShellRepository
|
import de.rfc1437.ironstorage.core.NativeFixtureShellRepository
|
||||||
import de.rfc1437.ironstorage.core.NativeHomeRepository
|
import de.rfc1437.ironstorage.core.NativeHomeRepository
|
||||||
import de.rfc1437.ironstorage.core.NativeOnboardingRepository
|
import de.rfc1437.ironstorage.core.NativeOnboardingRepository
|
||||||
@@ -39,12 +41,29 @@ object ShellModule {
|
|||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun passwordRepository(session: NativePrivateSession): PasswordRepository =
|
fun passwordRepository(
|
||||||
NativePasswordRepository(
|
session: NativePrivateSession,
|
||||||
|
authentication: NativeAuthenticationRepository
|
||||||
|
): PasswordRepository = NativePasswordRepository(
|
||||||
|
session,
|
||||||
|
CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate),
|
||||||
|
authentication,
|
||||||
|
Dispatchers.IO
|
||||||
|
)
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun nativeAuthentication(session: NativePrivateSession): NativeAuthenticationRepository =
|
||||||
|
NativeAuthenticationRepository(
|
||||||
session,
|
session,
|
||||||
CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate),
|
||||||
|
Dispatchers.IO
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun authentication(repository: NativeAuthenticationRepository): AuthenticationRepository =
|
||||||
|
repository
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun privateSession(
|
fun privateSession(
|
||||||
|
|||||||
@@ -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
|
package de.rfc1437.ironstorage.core
|
||||||
|
|
||||||
import android.os.SystemClock
|
|
||||||
import de.rfc1437.ironstorage.bridge.NativeBridgeException
|
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.MobileAuthenticationFfiException
|
||||||
import de.rfc1437.ironstorage.bridge.generated.MobileClock
|
|
||||||
import de.rfc1437.ironstorage.bridge.generated.MobilePasswordFfiException
|
import de.rfc1437.ironstorage.bridge.generated.MobilePasswordFfiException
|
||||||
import de.rfc1437.ironstorage.bridge.generated.MobilePasswordNavigation
|
import de.rfc1437.ironstorage.bridge.generated.MobilePasswordNavigation
|
||||||
import de.rfc1437.ironstorage.bridge.generated.MobilePasswordRowKind
|
import de.rfc1437.ironstorage.bridge.generated.MobilePasswordRowKind
|
||||||
import de.rfc1437.ironstorage.bridge.generated.MobilePrivateStorageFfiException
|
import de.rfc1437.ironstorage.bridge.generated.MobilePrivateStorageFfiException
|
||||||
import java.util.concurrent.atomic.AtomicLong
|
import java.util.concurrent.atomic.AtomicLong
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -22,35 +18,18 @@ import kotlinx.coroutines.withContext
|
|||||||
|
|
||||||
class NativePasswordRepository(
|
class NativePasswordRepository(
|
||||||
private val session: NativePrivateSession,
|
private val session: NativePrivateSession,
|
||||||
private val scope: CoroutineScope
|
private val scope: CoroutineScope,
|
||||||
|
private val authentication: NativeAuthenticationRepository,
|
||||||
|
private val io: CoroutineDispatcher
|
||||||
) : PasswordRepository {
|
) : PasswordRepository {
|
||||||
private val mutable = MutableStateFlow(PasswordState())
|
private val mutable = MutableStateFlow(PasswordState())
|
||||||
override val state: StateFlow<PasswordState> = mutable
|
override val state: StateFlow<PasswordState> = mutable
|
||||||
private val sequence = AtomicLong()
|
private val sequence = AtomicLong()
|
||||||
private val mutex = Mutex()
|
private val mutex = Mutex()
|
||||||
private var authentication: MobileAuthentication? = null
|
|
||||||
private val clock = object : MobileClock {
|
|
||||||
override fun monotonicMilliseconds(): ULong = SystemClock.elapsedRealtime().toULong()
|
|
||||||
}
|
|
||||||
|
|
||||||
init {
|
init {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
while (true) {
|
authentication.state.collect { lease ->
|
||||||
delay(1000)
|
if (!lease.unlocked) mutable.value = mutable.value.copy(sections = null)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -81,8 +60,6 @@ class NativePasswordRepository(
|
|||||||
}
|
}
|
||||||
private fun navigate(path: String, selected: String?, revision: String?, activate: Boolean) =
|
private fun navigate(path: String, selected: String?, revision: String?, activate: Boolean) =
|
||||||
run {
|
run {
|
||||||
authentication?.manualLock()
|
|
||||||
authentication = null
|
|
||||||
session.storage().passwordNavigation(path, selected, revision, activate).toFacade()
|
session.storage().passwordNavigation(path, selected, revision, activate).toFacade()
|
||||||
}
|
}
|
||||||
override fun unlock(passphrase: String) {
|
override fun unlock(passphrase: String) {
|
||||||
@@ -97,19 +74,15 @@ class NativePasswordRepository(
|
|||||||
false
|
false
|
||||||
).toFacade()
|
).toFacade()
|
||||||
if (verified.selected == null) return@run verified
|
if (verified.selected == null) return@run verified
|
||||||
val auth =
|
val page = authentication.authenticated { auth ->
|
||||||
authentication
|
auth.navigationEntry(
|
||||||
?: session.storage().authentication(session.backend(), clock).also {
|
current.path,
|
||||||
authentication =
|
selected.id,
|
||||||
it
|
current.revision,
|
||||||
}
|
passphrase.ifEmpty { null },
|
||||||
val page = auth.navigationEntry(
|
(System.currentTimeMillis() / 1000).toULong()
|
||||||
current.path,
|
)
|
||||||
selected.id,
|
}
|
||||||
current.revision,
|
|
||||||
passphrase.ifEmpty { null },
|
|
||||||
(System.currentTimeMillis() / 1000).toULong()
|
|
||||||
)
|
|
||||||
verified.copy(
|
verified.copy(
|
||||||
sections = page.page.sections.map { section ->
|
sections = page.page.sections.map { section ->
|
||||||
PasswordSection(
|
PasswordSection(
|
||||||
@@ -130,26 +103,16 @@ class NativePasswordRepository(
|
|||||||
override fun lock() {
|
override fun lock() {
|
||||||
sequence.incrementAndGet()
|
sequence.incrementAndGet()
|
||||||
mutable.value = mutable.value.copy(sections = null, loading = false)
|
mutable.value = mutable.value.copy(sections = null, loading = false)
|
||||||
scope.launch {
|
authentication.lock()
|
||||||
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."
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
private fun run(action: () -> PasswordState) {
|
private fun run(action: suspend () -> PasswordState) {
|
||||||
val ticket = sequence.incrementAndGet()
|
val ticket = sequence.incrementAndGet()
|
||||||
mutable.value = mutable.value.copy(loading = true, error = null, sections = null)
|
mutable.value = mutable.value.copy(loading = true, error = null, sections = null)
|
||||||
scope.launch {
|
scope.launch {
|
||||||
mutex.withLock {
|
mutex.withLock {
|
||||||
if (sequence.get() != ticket) return@withLock
|
if (sequence.get() != ticket) return@withLock
|
||||||
val result = try {
|
val result = try {
|
||||||
withContext(Dispatchers.IO) { action() }
|
withContext(io) { action() }
|
||||||
} catch (error: MobilePasswordFfiException.Failed) {
|
} catch (error: MobilePasswordFfiException.Failed) {
|
||||||
mutable.value.copy(error = error.detail, selected = null)
|
mutable.value.copy(error = error.detail, selected = null)
|
||||||
} catch (error: MobileAuthenticationFfiException.Failed) {
|
} catch (error: MobileAuthenticationFfiException.Failed) {
|
||||||
@@ -167,9 +130,10 @@ class NativePasswordRepository(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (sequence.get() == ticket) {
|
if (sequence.get() == ticket) {
|
||||||
mutable.value = result.copy(loading = false)
|
mutable.value = result.copy(
|
||||||
} else {
|
loading = false,
|
||||||
withContext(Dispatchers.IO) { authentication?.manualLock() }
|
sections = result.sections.takeIf { authentication.state.value.unlocked }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,12 @@ class NativePrivateSession(
|
|||||||
private val keystore: AndroidKeystore
|
private val keystore: AndroidKeystore
|
||||||
) {
|
) {
|
||||||
val runtime = NativeRuntime(context)
|
val runtime = NativeRuntime(context)
|
||||||
|
|
||||||
|
fun cancelAuthentication() = keystore.cancelPending()
|
||||||
|
|
||||||
|
fun initialize() {
|
||||||
|
storage()
|
||||||
|
}
|
||||||
private var initialized: MobilePrivateRuntime? = null
|
private var initialized: MobilePrivateRuntime? = null
|
||||||
private var secrets: MobileSecretBackend? = null
|
private var secrets: MobileSecretBackend? = null
|
||||||
|
|
||||||
|
|||||||
+147
@@ -0,0 +1,147 @@
|
|||||||
|
package de.rfc1437.ironstorage.features
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
|
import androidx.compose.ui.window.SecureFlagPolicy
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.compose.LifecycleEventEffect
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import de.rfc1437.ironstorage.core.AuthenticationRepository
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AuthenticationControls(repository: AuthenticationRepository) {
|
||||||
|
val state by repository.state.collectAsStateWithLifecycle()
|
||||||
|
var enrollment by remember { mutableStateOf(false) }
|
||||||
|
LaunchedEffect(repository) { repository.reload() }
|
||||||
|
LifecycleEventEffect(Lifecycle.Event.ON_STOP) { enrollment = false }
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text(stringResource(R.string.auth_title))
|
||||||
|
Text(
|
||||||
|
stringResource(
|
||||||
|
if (state.biometricEnabled) R.string.auth_enabled else R.string.auth_disabled
|
||||||
|
)
|
||||||
|
)
|
||||||
|
state.failure?.let { Text(it.title + "\n" + it.detail) }
|
||||||
|
if (state.busy) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
TextButton(onClick = repository::cancel) { Text(stringResource(R.string.auth_cancel)) }
|
||||||
|
} else {
|
||||||
|
TextButton(onClick = {
|
||||||
|
if (state.biometricEnabled) repository.disable() else enrollment = true
|
||||||
|
}) {
|
||||||
|
Text(
|
||||||
|
stringResource(
|
||||||
|
if (state.biometricEnabled) R.string.auth_disable else R.string.auth_enable
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!state.unlocked && state.biometricEnabled) {
|
||||||
|
TextButton(onClick = repository::unlock) {
|
||||||
|
Text(stringResource(R.string.password_unlock))
|
||||||
|
}
|
||||||
|
TextButton(onClick = {
|
||||||
|
enrollment = true
|
||||||
|
}) { Text(stringResource(R.string.auth_manual)) }
|
||||||
|
}
|
||||||
|
if (state.unlocked) {
|
||||||
|
Text(
|
||||||
|
pluralStringResource(
|
||||||
|
R.plurals.auth_lease,
|
||||||
|
state.remainingSeconds.toInt(),
|
||||||
|
state.remainingSeconds.toLong()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
TextButton(onClick = repository::lock) {
|
||||||
|
Text(stringResource(R.string.password_lock))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (enrollment) {
|
||||||
|
PassphraseDialog(
|
||||||
|
stringResource(R.string.auth_enroll_title),
|
||||||
|
stringResource(R.string.auth_enroll_detail),
|
||||||
|
onCancel = {
|
||||||
|
enrollment = false
|
||||||
|
repository.cancel()
|
||||||
|
},
|
||||||
|
onSubmit = {
|
||||||
|
enrollment = false
|
||||||
|
repository.enroll(it)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun PassphraseDialog(
|
||||||
|
title: String,
|
||||||
|
detail: String,
|
||||||
|
onCancel: () -> Unit,
|
||||||
|
onSubmit: (String) -> Unit
|
||||||
|
) {
|
||||||
|
var secret by remember { mutableStateOf("") }
|
||||||
|
LifecycleEventEffect(Lifecycle.Event.ON_STOP) {
|
||||||
|
secret = ""
|
||||||
|
onCancel()
|
||||||
|
}
|
||||||
|
DisposableEffect(Unit) { onDispose { secret = "" } }
|
||||||
|
AlertDialog(
|
||||||
|
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
|
||||||
|
onDismissRequest = {
|
||||||
|
secret = ""
|
||||||
|
onCancel()
|
||||||
|
},
|
||||||
|
title = { Text(title) },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text(detail)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = secret,
|
||||||
|
onValueChange = { secret = it },
|
||||||
|
label = { Text(stringResource(R.string.setup_passphrase)) },
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
autoCorrectEnabled = false,
|
||||||
|
keyboardType = KeyboardType.Password
|
||||||
|
),
|
||||||
|
singleLine = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(enabled = secret.isNotEmpty(), onClick = {
|
||||||
|
val submitted = secret
|
||||||
|
secret = ""
|
||||||
|
onSubmit(submitted)
|
||||||
|
}) { Text(stringResource(R.string.auth_continue)) }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
secret = ""
|
||||||
|
onCancel()
|
||||||
|
}) {
|
||||||
|
Text(stringResource(R.string.auth_cancel))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
+57
-26
@@ -17,7 +17,6 @@ import androidx.compose.material3.HorizontalDivider
|
|||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.ListItem
|
import androidx.compose.material3.ListItem
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
|
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
|
||||||
@@ -26,7 +25,6 @@ import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold
|
|||||||
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole
|
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole
|
||||||
import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator
|
import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@@ -41,25 +39,33 @@ import androidx.compose.ui.semantics.customActions
|
|||||||
import androidx.compose.ui.semantics.heading
|
import androidx.compose.ui.semantics.heading
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.semantics.stateDescription
|
import androidx.compose.ui.semantics.stateDescription
|
||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.compose.LifecycleEventEffect
|
import androidx.lifecycle.compose.LifecycleEventEffect
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import de.rfc1437.ironstorage.core.AuthenticationFailureKind
|
||||||
|
import de.rfc1437.ironstorage.core.AuthenticationRepository
|
||||||
import de.rfc1437.ironstorage.core.PasswordKind
|
import de.rfc1437.ironstorage.core.PasswordKind
|
||||||
import de.rfc1437.ironstorage.core.PasswordRepository
|
import de.rfc1437.ironstorage.core.PasswordRepository
|
||||||
import de.rfc1437.ironstorage.core.ShellIcon
|
import de.rfc1437.ironstorage.core.ShellIcon
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
|
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun PasswordScreen(repository: PasswordRepository, modifier: Modifier = Modifier) {
|
fun PasswordScreen(
|
||||||
|
repository: PasswordRepository,
|
||||||
|
authentication: AuthenticationRepository,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
val state by repository.state.collectAsStateWithLifecycle()
|
val state by repository.state.collectAsStateWithLifecycle()
|
||||||
val navigator = rememberListDetailPaneScaffoldNavigator<String>()
|
val navigator = rememberListDetailPaneScaffoldNavigator<String>()
|
||||||
var actions by remember { mutableStateOf(false) }
|
var actions by remember { mutableStateOf(false) }
|
||||||
var passphrase by remember { mutableStateOf("") }
|
val lease by authentication.state.collectAsStateWithLifecycle()
|
||||||
|
var manual by remember { mutableStateOf(false) }
|
||||||
|
var requested by remember { mutableStateOf(false) }
|
||||||
val entry = state.selected?.takeIf { it.kind == PasswordKind.Entry }
|
val entry = state.selected?.takeIf { it.kind == PasswordKind.Entry }
|
||||||
LaunchedEffect(entry?.id) {
|
LaunchedEffect(entry?.id) {
|
||||||
passphrase = ""
|
manual = false
|
||||||
|
requested = false
|
||||||
navigator.navigateTo(
|
navigator.navigateTo(
|
||||||
if (entry ==
|
if (entry ==
|
||||||
null
|
null
|
||||||
@@ -72,13 +78,13 @@ fun PasswordScreen(repository: PasswordRepository, modifier: Modifier = Modifier
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
LifecycleEventEffect(Lifecycle.Event.ON_STOP) {
|
LifecycleEventEffect(Lifecycle.Event.ON_STOP) {
|
||||||
passphrase = ""
|
manual = false
|
||||||
|
requested = false
|
||||||
repository.lock()
|
repository.lock()
|
||||||
}
|
}
|
||||||
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
|
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
|
||||||
if (state.revision.isNotEmpty()) repository.refresh()
|
if (state.revision.isNotEmpty()) repository.refresh()
|
||||||
}
|
}
|
||||||
DisposableEffect(repository) { onDispose { repository.lock() } }
|
|
||||||
BackHandler(state.selected != null || state.parent != null) {
|
BackHandler(state.selected != null || state.parent != null) {
|
||||||
actions = false
|
actions = false
|
||||||
repository.back()
|
repository.back()
|
||||||
@@ -217,28 +223,22 @@ fun PasswordScreen(repository: PasswordRepository, modifier: Modifier = Modifier
|
|||||||
Text(it, color = MaterialTheme.colorScheme.error)
|
Text(it, color = MaterialTheme.colorScheme.error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val sections = state.sections
|
val sections = state.sections.takeIf { lease.unlocked }
|
||||||
if (sections == null) {
|
if (sections == null) {
|
||||||
item {
|
item {
|
||||||
Text(entry.detail)
|
Text(entry.detail)
|
||||||
OutlinedTextField(
|
Button(enabled = !state.loading && !lease.busy, onClick = {
|
||||||
passphrase,
|
requested = true
|
||||||
{
|
repository.unlock("")
|
||||||
passphrase = it
|
|
||||||
},
|
|
||||||
label = {
|
|
||||||
Text(stringResource(R.string.setup_passphrase))
|
|
||||||
},
|
|
||||||
visualTransformation = PasswordVisualTransformation(),
|
|
||||||
singleLine = true,
|
|
||||||
enabled = !state.loading
|
|
||||||
)
|
|
||||||
Button(enabled = !state.loading, onClick = {
|
|
||||||
val submitted = passphrase
|
|
||||||
passphrase =
|
|
||||||
""
|
|
||||||
repository.unlock(submitted)
|
|
||||||
}) { Text(stringResource(R.string.password_unlock)) }
|
}) { Text(stringResource(R.string.password_unlock)) }
|
||||||
|
TextButton(enabled = !state.loading && !lease.busy, onClick = {
|
||||||
|
manual = true
|
||||||
|
}) { Text(stringResource(R.string.auth_manual)) }
|
||||||
|
if (lease.busy) {
|
||||||
|
TextButton(onClick = authentication::cancel) {
|
||||||
|
Text(stringResource(R.string.auth_cancel))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
item {
|
item {
|
||||||
@@ -274,6 +274,37 @@ fun PasswordScreen(repository: PasswordRepository, modifier: Modifier = Modifier
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
LaunchedEffect(lease.failure, state.loading) {
|
||||||
|
if (requested && !state.loading && !lease.busy) {
|
||||||
|
requested = false
|
||||||
|
if (lease.failure?.kind == AuthenticationFailureKind.PassphraseRequired ||
|
||||||
|
lease.failure?.kind == AuthenticationFailureKind.BiometryUnavailable
|
||||||
|
) {
|
||||||
|
manual = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (manual && entry != null) {
|
||||||
|
PassphraseDialog(
|
||||||
|
stringResource(R.string.auth_manual),
|
||||||
|
stringResource(
|
||||||
|
if (lease.biometricEnabled) {
|
||||||
|
R.string.auth_recover_detail
|
||||||
|
} else {
|
||||||
|
R.string.auth_manual_detail
|
||||||
|
}
|
||||||
|
),
|
||||||
|
onCancel = {
|
||||||
|
manual = false
|
||||||
|
authentication.cancel()
|
||||||
|
},
|
||||||
|
onSubmit = {
|
||||||
|
manual = false
|
||||||
|
requested = true
|
||||||
|
repository.unlock(it)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
if (actions && state.selected != null && !state.loading) {
|
if (actions && state.selected != null && !state.loading) {
|
||||||
val selected = requireNotNull(state.selected)
|
val selected = requireNotNull(state.selected)
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
|
|||||||
@@ -91,10 +91,13 @@ fun ShellScreen(viewModel: ShellViewModel, setupForm: SetupFormViewModel) {
|
|||||||
snapshot.storageRecovery == null &&
|
snapshot.storageRecovery == null &&
|
||||||
(snapshot.state == ShellState.Empty || snapshot.selectedTab == ShellTab.Preferences)
|
(snapshot.state == ShellState.Empty || snapshot.selectedTab == ShellTab.Preferences)
|
||||||
) {
|
) {
|
||||||
|
if (ready && snapshot.selectedTab == ShellTab.Preferences) {
|
||||||
|
AuthenticationControls(viewModel.authentication)
|
||||||
|
}
|
||||||
OnboardingScreen(viewModel.onboarding, setupForm)
|
OnboardingScreen(viewModel.onboarding, setupForm)
|
||||||
} else if (browsing) {
|
} else if (browsing) {
|
||||||
LaunchedEffect(viewModel) { viewModel.openPasswords() }
|
LaunchedEffect(viewModel) { viewModel.openPasswords() }
|
||||||
PasswordScreen(viewModel.passwords, Modifier.weight(1f))
|
PasswordScreen(viewModel.passwords, viewModel.authentication, Modifier.weight(1f))
|
||||||
} else if (home) {
|
} else if (home) {
|
||||||
HomeScreen(viewModel.home, Modifier.weight(1f))
|
HomeScreen(viewModel.home, Modifier.weight(1f))
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import androidx.lifecycle.SavedStateHandle
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import de.rfc1437.ironstorage.core.AuthenticationRepository
|
||||||
import de.rfc1437.ironstorage.core.HomeRepository
|
import de.rfc1437.ironstorage.core.HomeRepository
|
||||||
import de.rfc1437.ironstorage.core.OnboardingRepository
|
import de.rfc1437.ironstorage.core.OnboardingRepository
|
||||||
import de.rfc1437.ironstorage.core.PasswordRepository
|
import de.rfc1437.ironstorage.core.PasswordRepository
|
||||||
@@ -18,6 +19,7 @@ class ShellViewModel @Inject constructor(
|
|||||||
val onboarding: OnboardingRepository,
|
val onboarding: OnboardingRepository,
|
||||||
val passwords: PasswordRepository,
|
val passwords: PasswordRepository,
|
||||||
val home: HomeRepository,
|
val home: HomeRepository,
|
||||||
|
val authentication: AuthenticationRepository,
|
||||||
private val saved: SavedStateHandle
|
private val saved: SavedStateHandle
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
val snapshot = repository.snapshot
|
val snapshot = repository.snapshot
|
||||||
@@ -47,11 +49,6 @@ class ShellViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
fun selectTab(tab: ShellTab) {
|
fun selectTab(tab: ShellTab) {
|
||||||
if (tab !=
|
|
||||||
ShellTab.Passwords
|
|
||||||
) {
|
|
||||||
passwords.lock()
|
|
||||||
}
|
|
||||||
repository.selectTab(tab)
|
repository.selectTab(tab)
|
||||||
}
|
}
|
||||||
override fun onCleared() {
|
override fun onCleared() {
|
||||||
|
|||||||
@@ -63,4 +63,20 @@
|
|||||||
<string name="password_close">Close</string>
|
<string name="password_close">Close</string>
|
||||||
<string name="password_unlock">Unlock</string>
|
<string name="password_unlock">Unlock</string>
|
||||||
<string name="password_lock">Lock</string>
|
<string name="password_lock">Lock</string>
|
||||||
|
<string name="auth_title">Authentication</string>
|
||||||
|
<string name="auth_enabled">Biometric unlock enabled</string>
|
||||||
|
<string name="auth_disabled">Biometric unlock disabled</string>
|
||||||
|
<string name="auth_enable">Enable biometric unlock</string>
|
||||||
|
<string name="auth_disable">Disable biometric unlock</string>
|
||||||
|
<string name="auth_manual">Use GPG passphrase</string>
|
||||||
|
<string name="auth_manual_detail">The passphrase is validated by Rust and retained only for the authentication lease.</string>
|
||||||
|
<string name="auth_recover_detail">After validating the passphrase, renew the enabled biometric record using a matching biometric.</string>
|
||||||
|
<string name="auth_enroll_title">Enroll biometric unlock</string>
|
||||||
|
<string name="auth_enroll_detail">Validate the GPG key passphrase, then use a strong biometric to protect it for future unlocks. A device PIN cannot substitute for a matching biometric.</string>
|
||||||
|
<string name="auth_continue">Continue</string>
|
||||||
|
<string name="auth_cancel">Cancel</string>
|
||||||
|
<plurals name="auth_lease">
|
||||||
|
<item quantity="one">Unlocked: %1$d second remaining</item>
|
||||||
|
<item quantity="other">Unlocked: %1$d seconds remaining</item>
|
||||||
|
</plurals>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -1762,6 +1762,27 @@ impl MobileAuthentication {
|
|||||||
.map_err(Into::into)
|
.map_err(Into::into)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn enroll_biometric_unlock(
|
||||||
|
&self,
|
||||||
|
passphrase: String,
|
||||||
|
) -> Result<MobileAuthenticationState, MobileAuthenticationFfiError> {
|
||||||
|
self.authentication
|
||||||
|
.enroll_biometric_unlock(ironstorage::repository::SecretBytes::new(
|
||||||
|
passphrase.into_bytes(),
|
||||||
|
))
|
||||||
|
.map(Into::into)
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn restore_biometric_lease(
|
||||||
|
&self,
|
||||||
|
) -> Result<MobileAuthenticationState, MobileAuthenticationFfiError> {
|
||||||
|
self.authentication
|
||||||
|
.restore_biometric_lease()
|
||||||
|
.map(Into::into)
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn touch_user_activity(&self) -> Result<(), MobileAuthenticationFfiError> {
|
pub fn touch_user_activity(&self) -> Result<(), MobileAuthenticationFfiError> {
|
||||||
self.authentication
|
self.authentication
|
||||||
.touch_user_activity()
|
.touch_user_activity()
|
||||||
|
|||||||
@@ -310,14 +310,18 @@ impl<B: SecretStoreBackend, C: AuthenticationClock> AuthenticationSession<B, C>
|
|||||||
|
|
||||||
/// Revoke every outstanding handle immediately.
|
/// Revoke every outstanding handle immediately.
|
||||||
pub fn manual_lock(&self) -> Result<(), AuthenticationError> {
|
pub fn manual_lock(&self) -> Result<(), AuthenticationError> {
|
||||||
|
let interrupted = self.shared.store.cancel_pending();
|
||||||
let _operation = self.shared.operation()?;
|
let _operation = self.shared.operation()?;
|
||||||
self.shared.revoke(RevocationReason::Manual)
|
self.shared.revoke(RevocationReason::Manual)?;
|
||||||
|
interrupted.map_err(Into::into)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cancel authentication and revoke any lease created by the pending interaction.
|
/// Cancel authentication and revoke any lease created by the pending interaction.
|
||||||
pub fn cancel(&self) -> Result<(), AuthenticationError> {
|
pub fn cancel(&self) -> Result<(), AuthenticationError> {
|
||||||
|
let interrupted = self.shared.store.cancel_pending();
|
||||||
let _operation = self.shared.operation()?;
|
let _operation = self.shared.operation()?;
|
||||||
self.shared.revoke(RevocationReason::Cancelled)
|
self.shared.revoke(RevocationReason::Cancelled)?;
|
||||||
|
interrupted.map_err(Into::into)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Apply a new shared inactivity timeout to this process and any active lease.
|
/// Apply a new shared inactivity timeout to this process and any active lease.
|
||||||
|
|||||||
@@ -481,10 +481,10 @@ impl MobileAuthentication {
|
|||||||
match self.keys.decrypt(ciphertext, &mut provider) {
|
match self.keys.decrypt(ciphertext, &mut provider) {
|
||||||
Ok(plaintext) => {
|
Ok(plaintext) => {
|
||||||
drop(plaintext);
|
drop(plaintext);
|
||||||
if biometric_enabled {
|
if biometric_enabled && let Err(error) = handle.persist_passphrase(key)
|
||||||
handle
|
{
|
||||||
.persist_passphrase(key)
|
let _ = self.manual_lock();
|
||||||
.map_err(MobileAuthenticationError::authentication)?;
|
return Err(MobileAuthenticationError::authentication(error));
|
||||||
}
|
}
|
||||||
self.set_active(handle, key.clone())?;
|
self.set_active(handle, key.clone())?;
|
||||||
return self.state();
|
return self.state();
|
||||||
@@ -494,7 +494,10 @@ impl MobileAuthentication {
|
|||||||
.manual_lock()
|
.manual_lock()
|
||||||
.map_err(MobileAuthenticationError::authentication)?;
|
.map_err(MobileAuthenticationError::authentication)?;
|
||||||
}
|
}
|
||||||
Err(error) => return Err(key_error(error)),
|
Err(error) => {
|
||||||
|
let _ = self.manual_lock();
|
||||||
|
return Err(key_error(error));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(MobileAuthenticationError::new(
|
Err(MobileAuthenticationError::new(
|
||||||
@@ -504,6 +507,7 @@ impl MobileAuthentication {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
None if biometric_enabled => {
|
None if biometric_enabled => {
|
||||||
|
let mut failure = None;
|
||||||
for key in &candidates {
|
for key in &candidates {
|
||||||
let handle = match self.session.authenticate(key) {
|
let handle = match self.session.authenticate(key) {
|
||||||
Ok(handle) => handle,
|
Ok(handle) => handle,
|
||||||
@@ -512,7 +516,10 @@ impl MobileAuthentication {
|
|||||||
AuthenticationError::Cancelled,
|
AuthenticationError::Cancelled,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
Err(_) => continue,
|
Err(error) => {
|
||||||
|
failure = Some(MobileAuthenticationError::authentication(error));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
let mut provider = KeyOnlyProvider::new(handle.clone(), key);
|
let mut provider = KeyOnlyProvider::new(handle.clone(), key);
|
||||||
if let Ok(plaintext) = self.keys.decrypt(ciphertext, &mut provider) {
|
if let Ok(plaintext) = self.keys.decrypt(ciphertext, &mut provider) {
|
||||||
@@ -523,9 +530,9 @@ impl MobileAuthentication {
|
|||||||
let _ = self.session.delete_key_passphrase(key);
|
let _ = self.session.delete_key_passphrase(key);
|
||||||
let _ = self.session.manual_lock();
|
let _ = self.session.manual_lock();
|
||||||
}
|
}
|
||||||
Err(MobileAuthenticationError::passphrase_required(
|
Err(failure.unwrap_or_else(|| MobileAuthenticationError::passphrase_required(
|
||||||
"Biometric unlock could not restore this key. Enter its GPG passphrase to recover.",
|
"Biometric unlock could not restore this key. Enter its GPG passphrase to recover.",
|
||||||
))
|
)))
|
||||||
}
|
}
|
||||||
None => Err(MobileAuthenticationError::passphrase_required(
|
None => Err(MobileAuthenticationError::passphrase_required(
|
||||||
"Enter the GPG key passphrase to continue.",
|
"Enter the GPG key passphrase to continue.",
|
||||||
@@ -533,6 +540,103 @@ impl MobileAuthentication {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Explicit enrollment also works before the store contains a password entry.
|
||||||
|
pub fn enroll_biometric_unlock(
|
||||||
|
&self,
|
||||||
|
passphrase: SecretBytes,
|
||||||
|
) -> Result<MobileAuthenticationState, MobileAuthenticationError> {
|
||||||
|
self.ensure_repository_idle()?;
|
||||||
|
self.keys
|
||||||
|
.validate_secret_passphrase(self.config.default_key().as_str(), Some(&passphrase))
|
||||||
|
.map_err(|error| match error {
|
||||||
|
CryptoError::InvalidKeyPassphrase => MobileAuthenticationError::new(
|
||||||
|
MobileAuthenticationErrorKind::InvalidPassphrase,
|
||||||
|
"Incorrect Passphrase",
|
||||||
|
"The GPG key could not be unlocked.",
|
||||||
|
),
|
||||||
|
error => key_error(error),
|
||||||
|
})?;
|
||||||
|
let fingerprint = self
|
||||||
|
.keys
|
||||||
|
.resolve(self.config.default_key().as_str())
|
||||||
|
.map_err(key_error)?;
|
||||||
|
let key = self
|
||||||
|
.keys
|
||||||
|
.infos()
|
||||||
|
.find(|key| key.fingerprint() == fingerprint.fingerprint())
|
||||||
|
.ok_or_else(|| key_error(CryptoError::MissingSecretKey))?;
|
||||||
|
let handle = self
|
||||||
|
.session
|
||||||
|
.authenticate_with_passphrase(&key, passphrase)
|
||||||
|
.map_err(MobileAuthenticationError::authentication)?;
|
||||||
|
if let Err(error) = self.persist_biometric_unlock(&handle, &key) {
|
||||||
|
let _ = self.manual_lock();
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
self.set_active(handle, key)?;
|
||||||
|
self.state()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restore the configured key's lease for an explicit protected-access request.
|
||||||
|
pub fn restore_biometric_lease(
|
||||||
|
&self,
|
||||||
|
) -> Result<MobileAuthenticationState, MobileAuthenticationError> {
|
||||||
|
self.ensure_repository_idle()?;
|
||||||
|
let state = self.state()?;
|
||||||
|
if state.unlocked() {
|
||||||
|
return Ok(state);
|
||||||
|
}
|
||||||
|
if !state.biometric_unlock_enabled() {
|
||||||
|
return Err(MobileAuthenticationError::passphrase_required(
|
||||||
|
"Enter the GPG key passphrase to continue.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let fingerprint = self
|
||||||
|
.keys
|
||||||
|
.resolve(self.config.default_key().as_str())
|
||||||
|
.map_err(key_error)?;
|
||||||
|
let key = self
|
||||||
|
.keys
|
||||||
|
.infos()
|
||||||
|
.find(|key| key.fingerprint() == fingerprint.fingerprint())
|
||||||
|
.ok_or_else(|| key_error(CryptoError::MissingSecretKey))?;
|
||||||
|
let mut handle = self
|
||||||
|
.session
|
||||||
|
.authenticate(&key)
|
||||||
|
.map_err(MobileAuthenticationError::authentication)?;
|
||||||
|
let passphrase = handle.secret_for(&key).map_err(|_| locked_error())?;
|
||||||
|
if self
|
||||||
|
.keys
|
||||||
|
.validate_secret_passphrase(key.fingerprint().as_str(), Some(&passphrase))
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
let _ = self.session.delete_key_passphrase(&key);
|
||||||
|
let _ = self.manual_lock();
|
||||||
|
return Err(MobileAuthenticationError::passphrase_required(
|
||||||
|
"The protected passphrase is unusable. Enter its GPG passphrase to recover.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
self.set_active(handle, key)?;
|
||||||
|
self.state()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn persist_biometric_unlock(
|
||||||
|
&self,
|
||||||
|
handle: &MobileAuthenticationHandle,
|
||||||
|
key: &KeyInfo,
|
||||||
|
) -> Result<(), MobileAuthenticationError> {
|
||||||
|
handle
|
||||||
|
.persist_passphrase(key)
|
||||||
|
.map_err(MobileAuthenticationError::authentication)?;
|
||||||
|
if let Err(error) = self.config.update_biometric_unlock(true) {
|
||||||
|
let _ = self.delete_all_key_passphrases();
|
||||||
|
let _ = self.manual_lock();
|
||||||
|
return Err(config_error(error));
|
||||||
|
}
|
||||||
|
self.status()?.biometric_unlock_enabled = true;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn set_biometric_unlock(
|
pub fn set_biometric_unlock(
|
||||||
&self,
|
&self,
|
||||||
enabled: bool,
|
enabled: bool,
|
||||||
@@ -548,17 +652,10 @@ impl MobileAuthentication {
|
|||||||
"Unlock a password entry before enabling biometric unlock.",
|
"Unlock a password entry before enabling biometric unlock.",
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
active
|
let handle = active.handle.clone();
|
||||||
.handle
|
let key = active.key.clone();
|
||||||
.persist_passphrase(&active.key)
|
|
||||||
.map_err(MobileAuthenticationError::authentication)?;
|
|
||||||
drop(status);
|
drop(status);
|
||||||
if let Err(error) = self.config.update_biometric_unlock(true) {
|
self.persist_biometric_unlock(&handle, &key)?;
|
||||||
let _ = self.delete_all_key_passphrases();
|
|
||||||
let _ = self.manual_lock();
|
|
||||||
return Err(config_error(error));
|
|
||||||
}
|
|
||||||
self.status()?.biometric_unlock_enabled = true;
|
|
||||||
} else {
|
} else {
|
||||||
self.delete_all_key_passphrases()?;
|
self.delete_all_key_passphrases()?;
|
||||||
self.config
|
self.config
|
||||||
@@ -1961,6 +2058,40 @@ mod tests {
|
|||||||
watch_preference,
|
watch_preference,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn explicit_biometric_enrollment_and_restore_work_without_a_password_entry() {
|
||||||
|
use crate::authentication::SystemAuthenticationClock;
|
||||||
|
use std::sync::Arc;
|
||||||
|
let (_root, private, backend) = crate::mobile_onboarding::android::tests::home_fixture();
|
||||||
|
let authentication = private
|
||||||
|
.authentication(backend, Arc::new(SystemAuthenticationClock::default()))
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
crate::mobile_passwords::navigation::navigate_repository(
|
||||||
|
&authentication.repository,
|
||||||
|
"",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
false
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.rows
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
authentication
|
||||||
|
.enroll_biometric_unlock(crate::repository::SecretBytes::new(
|
||||||
|
b"fixture-alice-passphrase".to_vec()
|
||||||
|
))
|
||||||
|
.unwrap()
|
||||||
|
.biometric_unlock_enabled()
|
||||||
|
);
|
||||||
|
authentication.manual_lock().unwrap();
|
||||||
|
assert!(authentication.restore_biometric_lease().unwrap().unlocked());
|
||||||
|
authentication.set_biometric_unlock(false).unwrap();
|
||||||
|
assert!(authentication.restore_biometric_lease().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn repository_actions_protect_editors_and_serialize_mutations() {
|
fn repository_actions_protect_editors_and_serialize_mutations() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -517,6 +517,11 @@ impl<B: SecretStoreBackend> SecretStore<B> {
|
|||||||
self.backend.lock()
|
self.backend.lock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Interrupt a platform prompt without waiting for the retrieval's state lock.
|
||||||
|
pub(crate) fn cancel_pending(&self) -> Result<(), SecretStoreError> {
|
||||||
|
self.backend.lock()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn create(
|
pub fn create(
|
||||||
&self,
|
&self,
|
||||||
reference: &SecretReference,
|
reference: &SecretReference,
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ fn mobile_context_uses_injected_os_backend_clock_and_lifecycle_without_global_co
|
|||||||
struct BackendState {
|
struct BackendState {
|
||||||
values: BTreeMap<SecretLocator, SecretBytes>,
|
values: BTreeMap<SecretLocator, SecretBytes>,
|
||||||
fault: Option<SecretStoreError>,
|
fault: Option<SecretStoreError>,
|
||||||
|
protected_fault: Option<SecretStoreError>,
|
||||||
retrieves: usize,
|
retrieves: usize,
|
||||||
locks: usize,
|
locks: usize,
|
||||||
unlocks: usize,
|
unlocks: usize,
|
||||||
@@ -112,6 +113,10 @@ impl MemoryBackend {
|
|||||||
self.0.lock().expect("test mutex").fault = Some(error);
|
self.0.lock().expect("test mutex").fault = Some(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn fail_protected(&self, error: SecretStoreError) {
|
||||||
|
self.0.lock().unwrap().protected_fault = Some(error);
|
||||||
|
}
|
||||||
|
|
||||||
fn retrieves(&self) -> usize {
|
fn retrieves(&self) -> usize {
|
||||||
self.0.lock().expect("test mutex").retrieves
|
self.0.lock().expect("test mutex").retrieves
|
||||||
}
|
}
|
||||||
@@ -145,6 +150,11 @@ impl SecretStoreBackend for MemoryBackend {
|
|||||||
) -> Result<(), SecretStoreError> {
|
) -> Result<(), SecretStoreError> {
|
||||||
let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?;
|
let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?;
|
||||||
Self::take_fault(&mut state)?;
|
Self::take_fault(&mut state)?;
|
||||||
|
if matches!(locator, SecretLocator::OpenPgpPassphrase { .. })
|
||||||
|
&& let Some(error) = state.protected_fault.take()
|
||||||
|
{
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
state.last_protection = Some(protection);
|
state.last_protection = Some(protection);
|
||||||
if state.values.contains_key(locator) {
|
if state.values.contains_key(locator) {
|
||||||
return Err(SecretStoreError::AlreadyExists);
|
return Err(SecretStoreError::AlreadyExists);
|
||||||
@@ -179,6 +189,11 @@ impl SecretStoreBackend for MemoryBackend {
|
|||||||
) -> Result<(), SecretStoreError> {
|
) -> Result<(), SecretStoreError> {
|
||||||
let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?;
|
let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?;
|
||||||
Self::take_fault(&mut state)?;
|
Self::take_fault(&mut state)?;
|
||||||
|
if matches!(locator, SecretLocator::OpenPgpPassphrase { .. })
|
||||||
|
&& let Some(error) = state.protected_fault.take()
|
||||||
|
{
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
state.last_protection = Some(protection);
|
state.last_protection = Some(protection);
|
||||||
let existing = state
|
let existing = state
|
||||||
.values
|
.values
|
||||||
@@ -529,3 +544,283 @@ fn verified_manual_recovery_enrolls_current_biometry_and_reestablishes_the_lease
|
|||||||
));
|
));
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mobile_enrollment_proves_the_key_commits_only_after_protection_and_recovers() -> TestResult {
|
||||||
|
use ironstorage::{
|
||||||
|
config::Config,
|
||||||
|
mobile_authentication::{MobileAuthentication, MobileAuthenticationErrorKind},
|
||||||
|
};
|
||||||
|
let fixture = FixtureSet::load()?;
|
||||||
|
let vault = fixture.materialize_store("basic")?;
|
||||||
|
let root = tempfile::tempdir()?;
|
||||||
|
let source = root.path().join("config.toml");
|
||||||
|
std::fs::write(
|
||||||
|
&source,
|
||||||
|
format!(
|
||||||
|
"vault = {:?}\nkey_material = {:?}\ndefault_key = {:?}\n",
|
||||||
|
vault.path().to_str().unwrap(),
|
||||||
|
fixture.path("keys").to_str().unwrap(),
|
||||||
|
fixture.key("alice")?.primary_fingerprint
|
||||||
|
),
|
||||||
|
)?;
|
||||||
|
let backend = MemoryBackend::default();
|
||||||
|
let clock = ManualClock::default();
|
||||||
|
let make = || -> TestResult<MobileAuthentication> {
|
||||||
|
Ok(MobileAuthentication::with_platform(
|
||||||
|
Config::load(Some(&source))?,
|
||||||
|
Arc::new(backend.clone()),
|
||||||
|
Arc::new(clock.clone()),
|
||||||
|
)?)
|
||||||
|
};
|
||||||
|
let authentication = make()?;
|
||||||
|
let password =
|
||||||
|
|| SecretBytes::new(fixture.key("alice").unwrap().passphrase.as_bytes().to_vec());
|
||||||
|
assert_eq!(
|
||||||
|
authentication
|
||||||
|
.enroll_biometric_unlock(SecretBytes::new(b"incorrect".to_vec()))
|
||||||
|
.unwrap_err()
|
||||||
|
.kind(),
|
||||||
|
MobileAuthenticationErrorKind::InvalidPassphrase
|
||||||
|
);
|
||||||
|
assert!(backend.0.lock().unwrap().values.is_empty());
|
||||||
|
for (fault, kind) in [
|
||||||
|
(
|
||||||
|
SecretStoreError::Cancelled,
|
||||||
|
MobileAuthenticationErrorKind::Cancelled,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
SecretStoreError::UnsupportedProtection,
|
||||||
|
MobileAuthenticationErrorKind::BiometryUnavailable,
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
backend.fail_protected(fault);
|
||||||
|
assert_eq!(
|
||||||
|
authentication
|
||||||
|
.enroll_biometric_unlock(password())
|
||||||
|
.unwrap_err()
|
||||||
|
.kind(),
|
||||||
|
kind
|
||||||
|
);
|
||||||
|
assert!(!authentication.state()?.unlocked());
|
||||||
|
assert!(!Config::load(Some(&source))?.biometric_unlock_enabled());
|
||||||
|
assert!(backend.0.lock().unwrap().values.is_empty());
|
||||||
|
}
|
||||||
|
let state = authentication.enroll_biometric_unlock(password())?;
|
||||||
|
assert!(state.unlocked() && state.biometric_unlock_enabled());
|
||||||
|
assert_eq!(
|
||||||
|
backend.last_protection(),
|
||||||
|
Some(SecretProtection::BiometryCurrentSet)
|
||||||
|
);
|
||||||
|
authentication.manual_lock()?;
|
||||||
|
assert!(
|
||||||
|
authentication
|
||||||
|
.unlock_entry("email/personal", None)?
|
||||||
|
.unlocked()
|
||||||
|
);
|
||||||
|
let reads = backend.retrieves();
|
||||||
|
authentication.unlock_entry("email/personal", None)?;
|
||||||
|
assert_eq!(
|
||||||
|
reads,
|
||||||
|
backend.retrieves(),
|
||||||
|
"an active shared lease needs no second prompt"
|
||||||
|
);
|
||||||
|
authentication.manual_lock()?;
|
||||||
|
backend.fail_protected(SecretStoreError::Cancelled);
|
||||||
|
assert_eq!(
|
||||||
|
authentication
|
||||||
|
.unlock_entry("email/personal", Some(password()))
|
||||||
|
.unwrap_err()
|
||||||
|
.kind(),
|
||||||
|
MobileAuthenticationErrorKind::Cancelled
|
||||||
|
);
|
||||||
|
let cancelled = authentication.state()?;
|
||||||
|
assert!(!cancelled.unlocked());
|
||||||
|
assert_eq!(cancelled.remaining_seconds(), 0);
|
||||||
|
for (fault, kind) in [
|
||||||
|
(
|
||||||
|
SecretStoreError::Lockout,
|
||||||
|
MobileAuthenticationErrorKind::BiometryUnavailable,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
SecretStoreError::Denied,
|
||||||
|
MobileAuthenticationErrorKind::BiometryUnavailable,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
SecretStoreError::Cancelled,
|
||||||
|
MobileAuthenticationErrorKind::Cancelled,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
SecretStoreError::Invalidated,
|
||||||
|
MobileAuthenticationErrorKind::PassphraseRequired,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
SecretStoreError::DeviceKeyMissing,
|
||||||
|
MobileAuthenticationErrorKind::PassphraseRequired,
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
backend.fail_next(fault);
|
||||||
|
assert_eq!(
|
||||||
|
authentication
|
||||||
|
.unlock_entry("email/personal", None)
|
||||||
|
.unwrap_err()
|
||||||
|
.kind(),
|
||||||
|
kind
|
||||||
|
);
|
||||||
|
assert!(!authentication.state()?.unlocked());
|
||||||
|
}
|
||||||
|
backend.invalidate_enrollment();
|
||||||
|
assert_eq!(
|
||||||
|
authentication
|
||||||
|
.unlock_entry("email/personal", None)
|
||||||
|
.unwrap_err()
|
||||||
|
.kind(),
|
||||||
|
MobileAuthenticationErrorKind::PassphraseRequired
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
authentication
|
||||||
|
.enroll_biometric_unlock(password())?
|
||||||
|
.unlocked()
|
||||||
|
);
|
||||||
|
authentication.manual_lock()?;
|
||||||
|
drop(authentication);
|
||||||
|
let recreated = make()?;
|
||||||
|
assert!(
|
||||||
|
!recreated.state()?.unlocked(),
|
||||||
|
"process recreation cannot restore a lease"
|
||||||
|
);
|
||||||
|
assert!(recreated.unlock_entry("email/personal", None)?.unlocked());
|
||||||
|
recreated.set_biometric_unlock(false)?;
|
||||||
|
assert!(!recreated.state()?.unlocked());
|
||||||
|
assert!(backend.0.lock().unwrap().values.is_empty());
|
||||||
|
assert!(!Config::load(Some(&source))?.biometric_unlock_enabled());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mobile_expiry_and_user_input_never_reveal_old_editor_handles() -> TestResult {
|
||||||
|
use ironstorage::{config::Config, mobile_authentication::MobileAuthentication};
|
||||||
|
let fixture = FixtureSet::load()?;
|
||||||
|
let vault = fixture.materialize_store("basic")?;
|
||||||
|
let root = tempfile::tempdir()?;
|
||||||
|
let source = root.path().join("config.toml");
|
||||||
|
std::fs::write(
|
||||||
|
&source,
|
||||||
|
format!(
|
||||||
|
"vault = {:?}\nkey_material = {:?}\ndefault_key = {:?}\n",
|
||||||
|
vault.path().to_str().unwrap(),
|
||||||
|
fixture.path("keys").to_str().unwrap(),
|
||||||
|
fixture.key("alice")?.primary_fingerprint
|
||||||
|
),
|
||||||
|
)?;
|
||||||
|
let backend = MemoryBackend::default();
|
||||||
|
let clock = ManualClock::default();
|
||||||
|
let authentication = MobileAuthentication::with_platform(
|
||||||
|
Config::load(Some(&source))?,
|
||||||
|
Arc::new(backend),
|
||||||
|
Arc::new(clock.clone()),
|
||||||
|
)?;
|
||||||
|
authentication.set_authentication_timeout(2)?;
|
||||||
|
authentication.unlock_entry(
|
||||||
|
"email/personal",
|
||||||
|
Some(SecretBytes::new(
|
||||||
|
fixture.key("alice")?.passphrase.as_bytes().to_vec(),
|
||||||
|
)),
|
||||||
|
)?;
|
||||||
|
let editor = authentication.begin_entry_editor("email/personal")?;
|
||||||
|
clock.advance(Duration::from_secs(1));
|
||||||
|
assert_eq!(authentication.state()?.remaining_seconds(), 1);
|
||||||
|
authentication.touch_user_activity()?;
|
||||||
|
assert_eq!(authentication.state()?.remaining_seconds(), 2);
|
||||||
|
clock.advance(Duration::from_secs(2));
|
||||||
|
assert!(!authentication.state()?.unlocked());
|
||||||
|
assert!(authentication.entry_editor(editor.id()).is_err());
|
||||||
|
assert!(authentication.entry_page("email/personal", 0).is_err());
|
||||||
|
assert!(authentication.touch_user_activity().is_err());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn locking_interrupts_an_os_prompt_before_waiting_for_the_lease_mutex() -> TestResult {
|
||||||
|
struct PromptBackend {
|
||||||
|
inner: MemoryBackend,
|
||||||
|
started: std::sync::mpsc::Sender<()>,
|
||||||
|
cancelled: (Mutex<bool>, std::sync::Condvar),
|
||||||
|
}
|
||||||
|
impl SecretStoreBackend for PromptBackend {
|
||||||
|
fn create(
|
||||||
|
&self,
|
||||||
|
locator: &SecretLocator,
|
||||||
|
protection: SecretProtection,
|
||||||
|
value: &[u8],
|
||||||
|
) -> Result<(), SecretStoreError> {
|
||||||
|
self.inner.create(locator, protection, value)
|
||||||
|
}
|
||||||
|
fn replace(
|
||||||
|
&self,
|
||||||
|
locator: &SecretLocator,
|
||||||
|
protection: SecretProtection,
|
||||||
|
value: &[u8],
|
||||||
|
) -> Result<(), SecretStoreError> {
|
||||||
|
self.inner.replace(locator, protection, value)
|
||||||
|
}
|
||||||
|
fn delete(
|
||||||
|
&self,
|
||||||
|
locator: &SecretLocator,
|
||||||
|
protection: SecretProtection,
|
||||||
|
) -> Result<(), SecretStoreError> {
|
||||||
|
self.inner.delete(locator, protection)
|
||||||
|
}
|
||||||
|
fn retrieve(
|
||||||
|
&self,
|
||||||
|
_: &SecretLocator,
|
||||||
|
_: SecretProtection,
|
||||||
|
) -> Result<SecretBytes, SecretStoreError> {
|
||||||
|
self.started.send(()).unwrap();
|
||||||
|
let cancelled = self.cancelled.0.lock().unwrap();
|
||||||
|
let (cancelled, wait) = self
|
||||||
|
.cancelled
|
||||||
|
.1
|
||||||
|
.wait_timeout_while(cancelled, Duration::from_secs(3), |cancelled| !*cancelled)
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
!wait.timed_out() && *cancelled,
|
||||||
|
"lock must interrupt the OS prompt"
|
||||||
|
);
|
||||||
|
Err(SecretStoreError::Cancelled)
|
||||||
|
}
|
||||||
|
fn lock(&self) -> Result<(), SecretStoreError> {
|
||||||
|
*self.cancelled.0.lock().unwrap() = true;
|
||||||
|
self.cancelled.1.notify_all();
|
||||||
|
self.inner.lock()
|
||||||
|
}
|
||||||
|
fn unlock(&self) -> Result<(), SecretStoreError> {
|
||||||
|
self.inner.unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let fixture = FixtureSet::load()?;
|
||||||
|
let keys = KeyStore::load(fixture.path("keys"))?;
|
||||||
|
let alice = fixture_key(&fixture, &keys, "alice")?;
|
||||||
|
let (started, ready) = std::sync::mpsc::channel();
|
||||||
|
let backend = Arc::new(PromptBackend {
|
||||||
|
inner: MemoryBackend::default(),
|
||||||
|
started,
|
||||||
|
cancelled: (Mutex::new(false), std::sync::Condvar::new()),
|
||||||
|
});
|
||||||
|
let session = AuthenticationSession::with_clock(
|
||||||
|
backend,
|
||||||
|
SecretProtectionPolicy::current_biometry_for_openpgp(),
|
||||||
|
AuthenticationTimeout::default(),
|
||||||
|
ManualClock::default(),
|
||||||
|
);
|
||||||
|
let worker_session = session.clone();
|
||||||
|
let worker = std::thread::spawn(move || worker_session.authenticate(&alice));
|
||||||
|
ready.recv_timeout(Duration::from_secs(2))?;
|
||||||
|
session.manual_lock()?;
|
||||||
|
assert!(matches!(
|
||||||
|
worker.join().unwrap(),
|
||||||
|
Err(AuthenticationError::Cancelled)
|
||||||
|
));
|
||||||
|
assert_eq!(session.remaining_time()?, None);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# Android biometric authentication (#98)
|
||||||
|
|
||||||
|
Passwords and Preferences use one core facade over the private runtime's shared
|
||||||
|
Rust authentication owner. Rust validates the key, chooses protected operations,
|
||||||
|
owns the lease generation and inactivity deadline, and revokes native entry and
|
||||||
|
editor handles. Kotlin publishes that typed state and reports actual user
|
||||||
|
interaction; polling never extends the lease. Moving between tabs or browsing
|
||||||
|
folders retains a valid lease without requesting another biometric operation.
|
||||||
|
Backgrounding, manual Lock and Cancel immediately mask protected presentation
|
||||||
|
and interrupt a pending OS prompt before waiting for Rust to revoke its handles.
|
||||||
|
Obsolete requests cannot publish plaintext or overwrite newer authentication state.
|
||||||
|
|
||||||
|
Preferences can explicitly enroll biometric unlock even in an empty store. A
|
||||||
|
secure Compose dialog collects the GPG passphrase without saved state, disables
|
||||||
|
keyboard correction, masks input and clears it on submission, dismissal,
|
||||||
|
backgrounding or disposal. Rust proves the configured secret key's passphrase,
|
||||||
|
then persists the passphrase through the existing current-biometric-set secure
|
||||||
|
store and commits the setting only after the OS-authenticated cipher succeeds.
|
||||||
|
Disabling deletes the protected passphrases and revokes the shared lease.
|
||||||
|
|
||||||
|
AndroidX BiometricPrompt authenticates the Keystore cipher supplied by the
|
||||||
|
platform adapter with BIOMETRIC_STRONG. Current-set records never request device
|
||||||
|
credential fallback. The explicit Unlock action restores and validates the
|
||||||
|
configured key's protected passphrase; password-entry Unlock retains Rust's
|
||||||
|
recipient selection. Missing or unusable records offer the manual dialog.
|
||||||
|
Manual recovery while biometric unlock is enabled explicitly renews the protected
|
||||||
|
record; if strong biometrics are unavailable, disable biometric unlock in
|
||||||
|
Preferences before using manual password-entry unlock. Kotlin stores neither the
|
||||||
|
passphrase nor a duplicate authentication deadline in preferences, saved state,
|
||||||
|
files or WorkManager.
|
||||||
|
|
||||||
|
Capability/no-enrollment, denied access, cancellation, temporary/permanent
|
||||||
|
lockout, missing or invalidated keys and storage failures retain typed Rust
|
||||||
|
outcomes. Lockout maps to BiometryUnavailable with manual recovery; cancellation
|
||||||
|
remains Cancelled. Process recreation starts locked and does not resurrect a
|
||||||
|
lease. Protected fields are rendered only while the shared lease is unlocked,
|
||||||
|
even if an older page result is still present. Ordinary folders and cached
|
||||||
|
non-secret TOTP discovery do not invoke an authentication prompt.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
Rust tests use a fake monotonic clock and injected secure-store outcomes to check
|
||||||
|
wrong passphrases, failed/cancelled enrollment without setting publication,
|
||||||
|
immediate lease revocation after cancelled manual biometric renewal,
|
||||||
|
current-set protection, one lease without repeated prompts, typed lockout and
|
||||||
|
invalidation, renewed enrollment, process recreation, timeout/activity touches,
|
||||||
|
editor-handle revocation and interruption of a blocked platform prompt. An empty
|
||||||
|
store regression proves enrollment does not require an existing password entry.
|
||||||
|
|
||||||
|
Compose tests check explicit dialog submission, typed enrollment success,
|
||||||
|
manual recovery, lease masking, enable/disable/lock actions and clearing input
|
||||||
|
on cancellation, backgrounding and remount. The opt-in app instrumentation test
|
||||||
|
uses the production core facades, actual Rust and Android Keystore with isolated
|
||||||
|
Rust directories and namespaced OS aliases. It exercises authenticated cipher
|
||||||
|
enrollment/unlock, cancellation, background revocation, changed fingerprint
|
||||||
|
invalidation, removal of device protection, rejection of unsupported enrollment
|
||||||
|
and recovery after restoring a synthetic test PIN/fingerprint. Its arguments are
|
||||||
|
`-e class de.rfc1437.ironstorage.AuthenticationLeaseTest -e biometricLease true`.
|
||||||
|
The fixture stages are driven by emulator fingerprint controls/test automation;
|
||||||
|
only public disposable credentials are used. This test deliberately changes
|
||||||
|
emulator enrollment and lock-screen protection, so use a disposable test device
|
||||||
|
and restore its synthetic setup before subsequent tests.
|
||||||
|
|
||||||
|
Validation scope is ARM64 API 26/4 KB, API 37/4 KB and strict API 37/16 KB.
|
||||||
|
ARMv7 is compiled and package-audited; no ARMv7 system image is available locally.
|
||||||
|
Physical devices are excluded by the milestone's explicit emulator-only scope.
|
||||||
|
No Apple project or frontend changes are required.
|
||||||
|
|
||||||
|
Final verification passed all four required root Rust gates (477 workspace tests),
|
||||||
|
the complete Android debug/release/AAB, lint, JVM, style, architecture, ELF/ZIP
|
||||||
|
16 KB, manifest/backup, dependency/license and SBOM gates, and app plus native
|
||||||
|
instrumentation on all three configurations. Each configuration passed five
|
||||||
|
native cold loads and five application cold starts. The real biometric lifecycle
|
||||||
|
and capability/recovery test passed on API 37/4 KB with synthetic credentials.
|
||||||
|
Computer use verified the active OS prompt, production Preferences and secure
|
||||||
|
manual enrollment dialog in Android Studio's embedded emulator; approved CLI
|
||||||
|
and test automation drove fingerprint events and disposable fixture actions.
|
||||||
Reference in new issue
Block a user