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
|
||||
|
||||
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
|
||||
UniFFI API. Local-first and HTTPS onboarding is described in
|
||||
[onboarding](../docs/android/onboarding.md). The Passwords tab uses Rust-owned,
|
||||
revision-checked [adaptive navigation](../docs/android/password-navigation.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
|
||||
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.performTextInput
|
||||
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.PasswordKind
|
||||
import de.rfc1437.ironstorage.core.PasswordRepository
|
||||
@@ -54,7 +58,7 @@ class PasswordScreenTest {
|
||||
compose.activity.runOnUiThread {
|
||||
compose.activity.setContent {
|
||||
inputMode = LocalInputModeManager.current
|
||||
MaterialTheme { PasswordScreen(repository) }
|
||||
MaterialTheme { PasswordScreen(repository, repository.authentication) }
|
||||
}
|
||||
}
|
||||
compose.runOnIdle { assertTrue(inputMode.requestInputMode(InputMode.Keyboard)) }
|
||||
@@ -84,12 +88,19 @@ class PasswordScreenTest {
|
||||
compose.onAllNodesWithText("Actions")[0].performClick()
|
||||
compose.onNodeWithText("Open").performClick()
|
||||
compose.onNodeWithText("Unlock").assertIsDisplayed()
|
||||
compose.onNodeWithText("Unlock").performClick()
|
||||
compose.onNodeWithText("GPG passphrase").performTextInput("incorrect")
|
||||
compose.onNodeWithText("Unlock").performClick()
|
||||
compose.onNodeWithText("Continue").performClick()
|
||||
compose.onNodeWithText("Incorrect passphrase. Retry unlocking.").assertIsDisplayed()
|
||||
compose.onNodeWithText("Use GPG passphrase").performClick()
|
||||
compose.onNodeWithText("GPG passphrase").performTextInput("public-test-passphrase")
|
||||
compose.onNodeWithText("Unlock").performClick()
|
||||
compose.onNodeWithText("Continue").performClick()
|
||||
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("Unlock").assertIsDisplayed()
|
||||
repository.replaceOnRefresh = true
|
||||
@@ -117,7 +128,7 @@ class PasswordScreenTest {
|
||||
compose.activity.setContent {
|
||||
val density = LocalDensity.current
|
||||
CompositionLocalProvider(LocalDensity provides Density(density.density, 2f)) {
|
||||
MaterialTheme { PasswordScreen(repository) }
|
||||
MaterialTheme { PasswordScreen(repository, repository.authentication) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -151,6 +162,19 @@ class PasswordScreenTest {
|
||||
)
|
||||
override val state =
|
||||
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 replaceOnRefresh = false
|
||||
override fun restore(path: String, selected: String?, revision: String?) = Unit
|
||||
@@ -190,10 +214,21 @@ class PasswordScreenTest {
|
||||
}
|
||||
}
|
||||
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") {
|
||||
state.value = state.value.copy(error = "Incorrect passphrase. Retry unlocking.")
|
||||
return
|
||||
}
|
||||
lease.value = AuthenticationState(unlocked = true)
|
||||
assertEquals("public-test-passphrase", passphrase)
|
||||
state.value =
|
||||
state.value.copy(
|
||||
|
||||
@@ -7,6 +7,8 @@ import androidx.activity.viewModels
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
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.features.SetupFormViewModel
|
||||
import de.rfc1437.ironstorage.features.ShellViewModel
|
||||
@@ -19,10 +21,24 @@ class MainActivity : FragmentActivity() {
|
||||
|
||||
@Inject lateinit var repository: ShellRepository
|
||||
|
||||
@Inject lateinit var authentication: AuthenticationRepository
|
||||
|
||||
@Inject lateinit var passwords: PasswordRepository
|
||||
|
||||
private val setupForm: SetupFormViewModel 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() {
|
||||
keystore.detach(this)
|
||||
super.onDestroy()
|
||||
|
||||
@@ -6,7 +6,9 @@ import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import de.rfc1437.ironstorage.core.AuthenticationRepository
|
||||
import de.rfc1437.ironstorage.core.HomeRepository
|
||||
import de.rfc1437.ironstorage.core.NativeAuthenticationRepository
|
||||
import de.rfc1437.ironstorage.core.NativeFixtureShellRepository
|
||||
import de.rfc1437.ironstorage.core.NativeHomeRepository
|
||||
import de.rfc1437.ironstorage.core.NativeOnboardingRepository
|
||||
@@ -39,12 +41,29 @@ object ShellModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun passwordRepository(session: NativePrivateSession): PasswordRepository =
|
||||
NativePasswordRepository(
|
||||
fun passwordRepository(
|
||||
session: NativePrivateSession,
|
||||
authentication: NativeAuthenticationRepository
|
||||
): PasswordRepository = NativePasswordRepository(
|
||||
session,
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate),
|
||||
authentication,
|
||||
Dispatchers.IO
|
||||
)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun nativeAuthentication(session: NativePrivateSession): NativeAuthenticationRepository =
|
||||
NativeAuthenticationRepository(
|
||||
session,
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate),
|
||||
Dispatchers.IO
|
||||
)
|
||||
|
||||
@Provides
|
||||
fun authentication(repository: NativeAuthenticationRepository): AuthenticationRepository =
|
||||
repository
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
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
|
||||
|
||||
import android.os.SystemClock
|
||||
import de.rfc1437.ironstorage.bridge.NativeBridgeException
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileAuthentication
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileAuthenticationFfiException
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileClock
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobilePasswordFfiException
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobilePasswordNavigation
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobilePasswordRowKind
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobilePrivateStorageFfiException
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -22,35 +18,18 @@ import kotlinx.coroutines.withContext
|
||||
|
||||
class NativePasswordRepository(
|
||||
private val session: NativePrivateSession,
|
||||
private val scope: CoroutineScope
|
||||
private val scope: CoroutineScope,
|
||||
private val authentication: NativeAuthenticationRepository,
|
||||
private val io: CoroutineDispatcher
|
||||
) : PasswordRepository {
|
||||
private val mutable = MutableStateFlow(PasswordState())
|
||||
override val state: StateFlow<PasswordState> = mutable
|
||||
private val sequence = AtomicLong()
|
||||
private val mutex = Mutex()
|
||||
private var authentication: MobileAuthentication? = null
|
||||
private val clock = object : MobileClock {
|
||||
override fun monotonicMilliseconds(): ULong = SystemClock.elapsedRealtime().toULong()
|
||||
}
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
while (true) {
|
||||
delay(1000)
|
||||
if (mutable.value.sections != null) {
|
||||
mutex.withLock {
|
||||
val active = try {
|
||||
withContext(Dispatchers.IO) {
|
||||
authentication?.state()?.unlocked == true
|
||||
}
|
||||
} catch (_: MobileAuthenticationFfiException) {
|
||||
false
|
||||
} catch (_: NativeBridgeException) {
|
||||
false
|
||||
}
|
||||
if (!active) mutable.value = mutable.value.copy(sections = null)
|
||||
}
|
||||
}
|
||||
authentication.state.collect { lease ->
|
||||
if (!lease.unlocked) mutable.value = mutable.value.copy(sections = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -81,8 +60,6 @@ class NativePasswordRepository(
|
||||
}
|
||||
private fun navigate(path: String, selected: String?, revision: String?, activate: Boolean) =
|
||||
run {
|
||||
authentication?.manualLock()
|
||||
authentication = null
|
||||
session.storage().passwordNavigation(path, selected, revision, activate).toFacade()
|
||||
}
|
||||
override fun unlock(passphrase: String) {
|
||||
@@ -97,19 +74,15 @@ class NativePasswordRepository(
|
||||
false
|
||||
).toFacade()
|
||||
if (verified.selected == null) return@run verified
|
||||
val auth =
|
||||
authentication
|
||||
?: session.storage().authentication(session.backend(), clock).also {
|
||||
authentication =
|
||||
it
|
||||
}
|
||||
val page = auth.navigationEntry(
|
||||
current.path,
|
||||
selected.id,
|
||||
current.revision,
|
||||
passphrase.ifEmpty { null },
|
||||
(System.currentTimeMillis() / 1000).toULong()
|
||||
)
|
||||
val page = authentication.authenticated { auth ->
|
||||
auth.navigationEntry(
|
||||
current.path,
|
||||
selected.id,
|
||||
current.revision,
|
||||
passphrase.ifEmpty { null },
|
||||
(System.currentTimeMillis() / 1000).toULong()
|
||||
)
|
||||
}
|
||||
verified.copy(
|
||||
sections = page.page.sections.map { section ->
|
||||
PasswordSection(
|
||||
@@ -130,26 +103,16 @@ class NativePasswordRepository(
|
||||
override fun lock() {
|
||||
sequence.incrementAndGet()
|
||||
mutable.value = mutable.value.copy(sections = null, loading = false)
|
||||
scope.launch {
|
||||
mutex.withLock {
|
||||
try {
|
||||
withContext(Dispatchers.IO) { authentication?.manualLock() }
|
||||
} catch (_: MobileAuthenticationFfiException) {
|
||||
mutable.value = mutable.value.copy(
|
||||
error = "Protected content was cleared. Retry locking the device storage."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
authentication.lock()
|
||||
}
|
||||
private fun run(action: () -> PasswordState) {
|
||||
private fun run(action: suspend () -> PasswordState) {
|
||||
val ticket = sequence.incrementAndGet()
|
||||
mutable.value = mutable.value.copy(loading = true, error = null, sections = null)
|
||||
scope.launch {
|
||||
mutex.withLock {
|
||||
if (sequence.get() != ticket) return@withLock
|
||||
val result = try {
|
||||
withContext(Dispatchers.IO) { action() }
|
||||
withContext(io) { action() }
|
||||
} catch (error: MobilePasswordFfiException.Failed) {
|
||||
mutable.value.copy(error = error.detail, selected = null)
|
||||
} catch (error: MobileAuthenticationFfiException.Failed) {
|
||||
@@ -167,9 +130,10 @@ class NativePasswordRepository(
|
||||
)
|
||||
}
|
||||
if (sequence.get() == ticket) {
|
||||
mutable.value = result.copy(loading = false)
|
||||
} else {
|
||||
withContext(Dispatchers.IO) { authentication?.manualLock() }
|
||||
mutable.value = result.copy(
|
||||
loading = false,
|
||||
sections = result.sections.takeIf { authentication.state.value.unlocked }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,12 @@ class NativePrivateSession(
|
||||
private val keystore: AndroidKeystore
|
||||
) {
|
||||
val runtime = NativeRuntime(context)
|
||||
|
||||
fun cancelAuthentication() = keystore.cancelPending()
|
||||
|
||||
fun initialize() {
|
||||
storage()
|
||||
}
|
||||
private var initialized: MobilePrivateRuntime? = null
|
||||
private var secrets: MobileSecretBackend? = null
|
||||
|
||||
|
||||
+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.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
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.navigation.rememberListDetailPaneScaffoldNavigator
|
||||
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
|
||||
@@ -41,25 +39,33 @@ import androidx.compose.ui.semantics.customActions
|
||||
import androidx.compose.ui.semantics.heading
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.stateDescription
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.LifecycleEventEffect
|
||||
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.PasswordRepository
|
||||
import de.rfc1437.ironstorage.core.ShellIcon
|
||||
|
||||
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
|
||||
@Composable
|
||||
fun PasswordScreen(repository: PasswordRepository, modifier: Modifier = Modifier) {
|
||||
fun PasswordScreen(
|
||||
repository: PasswordRepository,
|
||||
authentication: AuthenticationRepository,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val state by repository.state.collectAsStateWithLifecycle()
|
||||
val navigator = rememberListDetailPaneScaffoldNavigator<String>()
|
||||
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 }
|
||||
LaunchedEffect(entry?.id) {
|
||||
passphrase = ""
|
||||
manual = false
|
||||
requested = false
|
||||
navigator.navigateTo(
|
||||
if (entry ==
|
||||
null
|
||||
@@ -72,13 +78,13 @@ fun PasswordScreen(repository: PasswordRepository, modifier: Modifier = Modifier
|
||||
)
|
||||
}
|
||||
LifecycleEventEffect(Lifecycle.Event.ON_STOP) {
|
||||
passphrase = ""
|
||||
manual = false
|
||||
requested = false
|
||||
repository.lock()
|
||||
}
|
||||
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
|
||||
if (state.revision.isNotEmpty()) repository.refresh()
|
||||
}
|
||||
DisposableEffect(repository) { onDispose { repository.lock() } }
|
||||
BackHandler(state.selected != null || state.parent != null) {
|
||||
actions = false
|
||||
repository.back()
|
||||
@@ -217,28 +223,22 @@ fun PasswordScreen(repository: PasswordRepository, modifier: Modifier = Modifier
|
||||
Text(it, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
val sections = state.sections
|
||||
val sections = state.sections.takeIf { lease.unlocked }
|
||||
if (sections == null) {
|
||||
item {
|
||||
Text(entry.detail)
|
||||
OutlinedTextField(
|
||||
passphrase,
|
||||
{
|
||||
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)
|
||||
Button(enabled = !state.loading && !lease.busy, onClick = {
|
||||
requested = true
|
||||
repository.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 {
|
||||
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) {
|
||||
val selected = requireNotNull(state.selected)
|
||||
AlertDialog(
|
||||
|
||||
@@ -91,10 +91,13 @@ fun ShellScreen(viewModel: ShellViewModel, setupForm: SetupFormViewModel) {
|
||||
snapshot.storageRecovery == null &&
|
||||
(snapshot.state == ShellState.Empty || snapshot.selectedTab == ShellTab.Preferences)
|
||||
) {
|
||||
if (ready && snapshot.selectedTab == ShellTab.Preferences) {
|
||||
AuthenticationControls(viewModel.authentication)
|
||||
}
|
||||
OnboardingScreen(viewModel.onboarding, setupForm)
|
||||
} else if (browsing) {
|
||||
LaunchedEffect(viewModel) { viewModel.openPasswords() }
|
||||
PasswordScreen(viewModel.passwords, Modifier.weight(1f))
|
||||
PasswordScreen(viewModel.passwords, viewModel.authentication, Modifier.weight(1f))
|
||||
} else if (home) {
|
||||
HomeScreen(viewModel.home, Modifier.weight(1f))
|
||||
} else {
|
||||
|
||||
@@ -4,6 +4,7 @@ import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.rfc1437.ironstorage.core.AuthenticationRepository
|
||||
import de.rfc1437.ironstorage.core.HomeRepository
|
||||
import de.rfc1437.ironstorage.core.OnboardingRepository
|
||||
import de.rfc1437.ironstorage.core.PasswordRepository
|
||||
@@ -18,6 +19,7 @@ class ShellViewModel @Inject constructor(
|
||||
val onboarding: OnboardingRepository,
|
||||
val passwords: PasswordRepository,
|
||||
val home: HomeRepository,
|
||||
val authentication: AuthenticationRepository,
|
||||
private val saved: SavedStateHandle
|
||||
) : ViewModel() {
|
||||
val snapshot = repository.snapshot
|
||||
@@ -47,11 +49,6 @@ class ShellViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
fun selectTab(tab: ShellTab) {
|
||||
if (tab !=
|
||||
ShellTab.Passwords
|
||||
) {
|
||||
passwords.lock()
|
||||
}
|
||||
repository.selectTab(tab)
|
||||
}
|
||||
override fun onCleared() {
|
||||
|
||||
@@ -63,4 +63,20 @@
|
||||
<string name="password_close">Close</string>
|
||||
<string name="password_unlock">Unlock</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>
|
||||
Reference in new issue
Block a user