Implement complete Android settings and recovery controls (#104)
Android ARM verification / rust (push) Failing after 46m46s
Android ARM verification / android (push) Failing after 7s
Dependency security audit / rustsec (push) Successful in 22s

This commit is contained in:
hugo committed 2026-10-10 12:07:28 +02:00
1 parent 06575363a2
commit 43f660ace3
34 files changed
+2720 -64

No files matched your search

@@ -0,0 +1,13 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----
xjMEZZIAgBYJKwYBBAHaRw8BAQdANd08TxPj0ZuIl1cMvEf0FMCxubDwlzXDLX6w
9M+959XNJUJvYiBGaXh0dXJlIDxib2JAaXJvbnN0b3JhZ2UuaW52YWxpZD7CiQQT
FggAMQUCanjntBYhBLNwJ7VvxAa9P2piKyrANJK5ktBvAhsDAh4BAwsJBwIVCAEW
AScCGQEACgkQKsA0krmS0G9BCwD/daZy2BdmNQX8TldPBtoLTrugeaFmtrcVDQZ3
AzWEMcMA/16q87Zkaw2ozanLSBcRiq4iZnBTAmU/7bUSlA2myZoHzjgEZZIAgBIK
KwYBBAGXVQEFAQEHQByJpci1aMxEcUVlw9mByZUPyaQ7kI1emdJOuN1hcLEKAwEI
B8J4BBgWCAAgBQJqeOe1AhsMFiEEs3AntW/EBr0/amIrKsA0krmS0G8ACgkQKsA0
krmS0G+2lwEAvD9R7o52H37GsfeMS15UJBeIHBaYHjpsIyfoOWq0vsEA/j0oL4IG
SIakL3z1C1KHU+4iz3rzz2vNnhgGQd7+DS8H
=vS0q
-----END PGP PUBLIC KEY BLOCK-----
@@ -1,6 +1,7 @@
package de.rfc1437.ironstorage.core
import androidx.test.platform.app.InstrumentationRegistry
import de.rfc1437.ironstorage.bridge.generated.MobileClock
import de.rfc1437.ironstorage.bridge.generated.MobileEntryEditorFieldKind
import de.rfc1437.ironstorage.bridge.generated.MobileEntryEditorInput
import java.io.File
@@ -29,9 +30,10 @@ class NativeEntryViewerTest {
val roots = listOf(context.filesDir, context.noBackupFilesDir, context.cacheDir).map {
File(it, name).apply { mkdirs() }.canonicalPath
}
val keystore = MemoryKeystore()
val session = NativePrivateSession(context, {
PrivateDirectories(roots[0], roots[1], roots[2])
}, MemoryKeystore())
}, keystore)
withContext(Dispatchers.IO) { session.initialize() }
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
val clipboard = MemoryClipboard()
@@ -250,6 +252,13 @@ class NativeEntryViewerTest {
scope,
password
)
settingsUseTheSameNativeOwnerAndProtectedTransfer(
session,
authentication,
scope,
password,
keystore
)
} finally {
withContext(Dispatchers.Main.immediate) { repository.lock() }
scope.cancel()
@@ -364,8 +373,8 @@ class NativeEntryViewerTest {
authentication.authenticated { it.setAuthenticationTimeout(2u) }
withTimeout(10000) { editor.state.first { it.locked } }
assertTrue(editor.state.value.fields.isEmpty())
assertNull(request { editor.resume(passphrase) }.error)
authentication.authenticated { it.setAuthenticationTimeout(300u) }
assertNull(request { editor.resume(passphrase) }.error)
request { editor.requestClose() }
if (editor.state.value.discardRequested) request { editor.confirmDiscard() }
assertFalse(editor.state.value.open)
@@ -373,6 +382,137 @@ class NativeEntryViewerTest {
withContext(Dispatchers.Main.immediate) { authentication.lock() }
}
}
private suspend fun settingsUseTheSameNativeOwnerAndProtectedTransfer(
session: NativePrivateSession,
authentication: NativeAuthenticationRepository,
scope: CoroutineScope,
passphrase: String,
keystore: MemoryKeystore
) {
var image = ByteArray(0)
val platform = object : SettingsPlatform {
override fun autofill() = ProviderStatus(true, false, false)
override fun credentials() =
ProviderStatus(android.os.Build.VERSION.SDK_INT >= 34, false, false)
override fun biometricsAvailable() = false
override fun version() = "native-settings-fixture"
override fun openAutofill() = true
override fun openCredentials() = true
override fun chooseQrImages() = true
override suspend fun readQrImage(uri: String) = image.copyOf()
}
val settings = NativeSettingsRepository(session, authentication, platform, scope)
suspend fun request(action: () -> Unit): SettingsState {
withContext(Dispatchers.Main.immediate) { action() }
return withTimeout(30000) { settings.state.first { !it.busy } }
}
val initial = request { settings.reload() }
assertNull(initial.error)
assertEquals("native-settings-fixture", initial.page!!.version)
assertFalse(initial.page.biometricAvailable)
assertNull(
request {
settings.setGitIdentity("Android settings author", "android-settings@example.test")
}.error
)
assertEquals("Android settings author", settings.state.value.page!!.gitName)
assertNotNull(request { settings.setGitIdentity("candidate", "invalid\nemail") }.error)
assertEquals("Android settings author", settings.state.value.page!!.gitName)
assertNull(request { settings.setTimeout("45") }.error)
assertEquals(45uL, settings.state.value.page!!.timeout)
assertNotNull(request { settings.setTimeout("0") }.error)
assertEquals(45uL, settings.state.value.page!!.timeout)
assertNotNull(request { settings.removeToken() }.error)
assertNull(settings.state.value.page!!.account)
assertNull(request { settings.rebuildCache(passphrase) }.error)
assertTrue(settings.state.value.page!!.cacheDetail.contains("current TOTP entries"))
val beforeEnrollment = keystore.keyCount
authentication.authenticated { it.enrollBiometricUnlock(passphrase) }
assertTrue(authentication.state.value.biometricEnabled)
assertEquals(beforeEnrollment + 1, keystore.keyCount)
withContext(Dispatchers.IO) {
session.storage().androidHomeOperation(
session.backend(),
object : MobileClock {
override fun monotonicMilliseconds(): ULong =
android.os.SystemClock.elapsedRealtime().toULong()
}
).use { home ->
assertEquals("Android settings author", home.identity().name)
home.cached()
}
}
assertTrue(authentication.authenticated { it.state().unlocked })
keystore.invalidated = true
authentication.authenticated { it.setBiometricUnlock(false) }
assertFalse(authentication.state.value.biometricEnabled)
assertFalse(authentication.state.value.unlocked)
assertEquals(beforeEnrollment, keystore.keyCount)
keystore.invalidated = false
assertNull(request { settings.beginTransfer() }.error)
val fingerprint = settings.state.value.transfer.keys.first { it.privateKey }.fingerprint
assertNotNull(request { settings.exportKey(fingerprint, true, "incorrect") }.error)
assertTrue(settings.state.value.transfer.frames.isEmpty())
assertNull(request { settings.exportKey(fingerprint, false, "") }.error)
val frame = settings.state.value.transfer.frames.single()
val size = (frame.width.toInt() + 8) * 4
val pixels = IntArray(size * size) { android.graphics.Color.WHITE }
for (row in 0 until frame.width.toInt()) {
for (column in 0 until frame.width.toInt()) {
if (frame.modules[row * frame.width.toInt() + column].toInt() == 1) {
for (dy in 0 until 4) {
for (dx in 0 until 4) {
pixels[((row + 4) * 4 + dy) * size + (column + 4) * 4 + dx] =
android.graphics.Color.BLACK
}
}
}
}
}
val bitmap = android.graphics.Bitmap.createBitmap(
pixels,
size,
size,
android.graphics.Bitmap.Config.ARGB_8888
)
val output = java.io.ByteArrayOutputStream()
assertTrue(bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, output))
bitmap.recycle()
image = output.toByteArray()
request { settings.closeTransfer() }
assertTrue(frame.modules.all { it.toInt() == 0 })
assertNull(request { settings.addQrImages(listOf("content://public-fixture/qr")) }.error)
assertEquals(fingerprint, settings.state.value.transfer.ready!!.fingerprint)
assertNotNull(request { settings.importKey("", false, false) }.error)
assertEquals(fingerprint, settings.state.value.transfer.ready!!.fingerprint)
request { settings.closeTransfer() }
assertNull(request { settings.beginTransfer() }.error)
val armor = InstrumentationRegistry.getInstrumentation().context.assets.open(
"bob-public.asc"
).bufferedReader().use {
it.readText()
}
assertNull(request { settings.addQrPayload(armor) }.error)
assertFalse(settings.state.value.transfer.ready!!.privateKey)
assertNull(request { settings.importKey("", false, false) }.error)
assertFalse(settings.state.value.transfer.open)
assertFalse(authentication.state.value.unlocked)
assertEquals(fingerprint, settings.state.value.page!!.defaultKeyFingerprint)
assertNull(request { settings.beginTransfer() }.error)
val bob = settings.state.value.transfer.keys.single { it.title.contains("Bob") }
assertNotNull(request { settings.setDefaultKey(bob.fingerprint) }.error)
assertEquals(fingerprint, settings.state.value.page!!.defaultKeyFingerprint)
assertNull(request { settings.beginTransfer() }.error)
assertNull(request { settings.exportKey(fingerprint, false, "") }.error)
withContext(Dispatchers.Main.immediate) { authentication.presentation.clear() }
assertFalse(settings.state.value.transfer.open)
assertTrue(settings.state.value.transfer.frames.isEmpty())
assertNull(request { settings.setDefaultKey(fingerprint) }.error)
assertFalse(authentication.state.value.unlocked)
assertEquals(fingerprint, settings.state.value.page!!.defaultKeyFingerprint)
request { settings.setTimeout("300") }
}
private class MemoryClipboard : SecretClipboard {
override fun beginCopy(): SecretClipboard = this
@@ -387,8 +527,10 @@ class NativeEntryViewerTest {
}
private class MemoryKeystore : AndroidKeystore {
private val keys = mutableSetOf<String>()
val keyCount get() = keys.size
var invalidated = false
override fun deviceState() =
KeystoreDeviceState(android.os.Build.VERSION.SDK_INT, false, true, false, false)
KeystoreDeviceState(android.os.Build.VERSION.SDK_INT, false, true, true, false)
override fun createKey(specification: KeystoreKeySpecification) {
keys.add(specification.alias)
}
@@ -412,6 +554,9 @@ class NativeEntryViewerTest {
aad: ByteArray,
value: KeystoreCiphertext
): ByteArray {
if (invalidated && authorization == KeystoreAuthorization.StrongBiometric) {
throw KeystoreException(KeystoreFailure.Invalidated)
}
if (alias !in keys) throw KeystoreException(KeystoreFailure.MissingKey)
val record =
ciphertexts[value.bytes.toList()]
@@ -0,0 +1,323 @@
package de.rfc1437.ironstorage.core
import de.rfc1437.ironstorage.bridge.NativeBridgeException
import de.rfc1437.ironstorage.bridge.generated.MobileAuthenticationFfiException
import de.rfc1437.ironstorage.bridge.generated.MobileKeyTransfer
import de.rfc1437.ironstorage.bridge.generated.MobileKeyTransferFfiException
import de.rfc1437.ironstorage.bridge.generated.MobileKeyTransferImport
import de.rfc1437.ironstorage.bridge.generated.MobileKeyTransferKind
import de.rfc1437.ironstorage.bridge.generated.MobileKeyTransferProgress
import de.rfc1437.ironstorage.bridge.generated.MobilePrivateStorageFfiException
import de.rfc1437.ironstorage.bridge.generated.MobileTotpOperation
import de.rfc1437.ironstorage.bridge.generated.mobileKeyTransfer
import de.rfc1437.ironstorage.bridge.generated.mobileTotpOperation
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
class NativeSettingsRepository(
private val session: NativePrivateSession,
private val authentication: NativeAuthenticationRepository,
private val platform: SettingsPlatform,
private val scope: CoroutineScope
) : SettingsRepository {
private val mutable = MutableStateFlow(SettingsState())
override val state: StateFlow<SettingsState> = mutable
private val operation = Mutex()
@Volatile private var epoch = 0L
@Volatile private var discovery: MobileTotpOperation? = null
private var transfer: MobileKeyTransfer? = null
private var importer: MobileKeyTransferImport? = null
init {
val registration = authentication.presentation.register {
if (mutable.value.transfer.open) closeTransfer()
}
scope.coroutineContext[Job]?.invokeOnCompletion { registration.close() }
}
private suspend fun page(): SettingsPage {
val values = authentication.authenticated {
Triple(it.preferences(), it.gitIdentity(), it.totpCacheStatus())
}
val (preferences, identity, cache) = values
return SettingsPage(
preferences.repositoryTitle, preferences.repositoryUrl,
preferences.serverTitle, preferences.serverIdentity, preferences.applicationAccount,
preferences.defaultKeyTitle, preferences.defaultKeyFingerprint,
identity.name, identity.email, preferences.authenticationTimeoutSeconds,
cache.detail, platform.biometricsAvailable(), platform.autofill(),
platform.credentials(), platform.version()
)
}
override fun reload() = run { mutable.value = mutable.value.copy(page = page()) }
override fun setDefaultKey(fingerprint: String) = run {
mutable.value.transfer.frames.forEach { it.modules.fill(0) }
mutable.value = mutable.value.copy(transfer = KeyTransferState())
releaseTransfer()
authentication.authenticated { it.setDefaultKey(fingerprint) }
authentication.lock()
mutable.value = mutable.value.copy(page = page(), notice = "Default GPG key updated.")
}
override fun setGitIdentity(name: String, email: String) = run {
authentication.authenticated { it.setGitIdentity(name, email) }
mutable.value = mutable.value.copy(page = page(), notice = "Git author updated.")
}
override fun setTimeout(seconds: String) = run {
authentication.authenticated { it.setAuthenticationTimeoutText(seconds) }
mutable.value = mutable.value.copy(page = page(), notice = "Inactivity timeout updated.")
}
override fun removeToken() = run {
authentication.authenticated { it.removeApplicationToken() }
mutable.value =
mutable.value.copy(
page = page(),
notice = "Application token removed from protected storage."
)
}
override fun rebuildCache(passphrase: String) = run {
val active = withContext(Dispatchers.IO) { mobileTotpOperation() }
discovery = active
try {
val result = authentication.authenticated {
it.unlockTotp(passphrase.ifEmpty { null })
it.rebuildTotpCache(active)
}
mutable.value =
mutable.value.copy(
page = page(),
notice =
result.cacheNotice ?: "TOTP cache rebuilt."
)
} finally {
discovery = null
active.close()
}
}
override fun openAutofill() {
if (!platform.openAutofill()) {
mutable.value =
mutable.value.copy(
error = "Autofill provider settings are unavailable on this device."
)
}
}
override fun openCredentials() {
if (!platform.openCredentials()) {
mutable.value =
mutable.value.copy(
error = "Credential provider settings are unavailable on this device."
)
}
}
override fun beginTransfer() = run { ticket ->
withContext(Dispatchers.IO) {
session.storage()
transfer = mobileKeyTransfer()
importer = requireNotNull(transfer).importer()
}
val keys = withContext(Dispatchers.IO) { requireNotNull(transfer).keys() }
if (ticket ==
epoch
) {
mutable.value = mutable.value.copy(
transfer = KeyTransferState(
open = true,
keys = keys.map {
TransferKey(
it.fingerprint,
it.title,
it.kind == MobileKeyTransferKind.PRIVATE
)
}
)
)
}
}
override fun exportKey(fingerprint: String, privateKey: Boolean, passphrase: String) =
run { ticket ->
val result = withContext(Dispatchers.IO) {
requireNotNull(transfer).export(
fingerprint,
if (privateKey) MobileKeyTransferKind.PRIVATE else MobileKeyTransferKind.PUBLIC,
passphrase.ifEmpty { null }
)
}
if (ticket ==
epoch
) {
mutable.value.transfer.frames.forEach { it.modules.fill(0) }
mutable.value = mutable.value.copy(
transfer = mutable.value.transfer.copy(
frames = result.frames.map {
TransferFrame(it.sequence, it.total, it.width, it.modules)
}
)
)
} else {
result.frames.forEach { it.modules.fill(0) }
}
}
private fun progress(ticket: Long, progress: MobileKeyTransferProgress) {
if (ticket != epoch) return
mutable.value = mutable.value.copy(
transfer = mutable.value.transfer.copy(
received = progress.received,
total = progress.total,
ready = progress.key?.let {
TransferKey(
it.fingerprint,
it.title,
it.kind == MobileKeyTransferKind.PRIVATE
)
}
)
)
}
override fun chooseQrImages() {
if (!platform.chooseQrImages()) {
mutable.value =
mutable.value.copy(error = "The image picker is unavailable on this device.")
}
}
override fun addQrImages(uris: List<String>) {
scope.launch {
mutable.first { !it.busy }
run { ticket ->
if (importer == null) {
val keys = withContext(Dispatchers.IO) {
session.storage()
transfer = mobileKeyTransfer()
importer = requireNotNull(transfer).importer()
requireNotNull(transfer).keys()
}
if (ticket ==
epoch
) {
mutable.value =
mutable.value.copy(
transfer = KeyTransferState(
open = true,
keys = keys.map {
TransferKey(
it.fingerprint,
it.title,
it.kind == MobileKeyTransferKind.PRIVATE
)
}
)
)
}
}
for (uri in uris) {
if (ticket != epoch) break
val bytes = withContext(Dispatchers.IO) { platform.readQrImage(uri) }
try {
progress(
ticket,
withContext(Dispatchers.IO) {
requireNotNull(importer).addImage(bytes)
}
)
} finally {
bytes.fill(0)
}
}
}
}
}
override fun addQrPayload(payload: String) = run { ticket ->
progress(ticket, withContext(Dispatchers.IO) { requireNotNull(importer).addFrame(payload) })
}
override fun importKey(passphrase: String, makeDefault: Boolean, overwrite: Boolean) = run {
val result = withContext(Dispatchers.IO) {
requireNotNull(importer).import(passphrase.ifEmpty { null }, makeDefault, overwrite)
}
mutable.value.transfer.frames.forEach { it.modules.fill(0) }
mutable.value = mutable.value.copy(transfer = KeyTransferState())
releaseTransfer()
authentication.lock()
withContext(Dispatchers.IO) { session.storage().keyMaterialChanged() }
mutable.value =
mutable.value.copy(page = page(), notice = result.title + "\n" + result.detail)
}
private fun releaseTransfer() {
importer?.close()
transfer?.close()
importer = null
transfer = null
}
override fun closeTransfer() {
epoch += 1
mutable.value.transfer.frames.forEach { it.modules.fill(0) }
mutable.value = mutable.value.copy(transfer = KeyTransferState(), busy = false)
scope.launch { operation.withLock { releaseTransfer() } }
}
override fun cancel() {
epoch += 1
discovery?.cancel()
closeTransfer()
}
private fun run(action: suspend (Long) -> Unit) {
if (mutable.value.busy) return
val ticket = epoch
mutable.value = mutable.value.copy(busy = true, error = null, notice = null)
scope.launch {
operation.withLock {
if (ticket != epoch) return@withLock
try {
action(ticket)
if (ticket != epoch) {
mutable.value.transfer.frames.forEach { it.modules.fill(0) }
mutable.value = mutable.value.copy(transfer = KeyTransferState())
}
} catch (error: MobileAuthenticationFfiException.Failed) {
if (ticket ==
epoch
) {
mutable.value =
mutable.value.copy(error = error.title + "\n" + error.detail)
}
} catch (error: MobileKeyTransferFfiException.Failed) {
if (ticket == epoch) mutable.value = mutable.value.copy(error = error.message)
} catch (_: MobilePrivateStorageFfiException) {
if (ticket ==
epoch
) {
mutable.value =
mutable.value.copy(
error =
"Private storage recovery required. " +
"Unlock the device and reload; " +
"missing or invalidated Keystore data requires " +
"reimporting the GPG key."
)
}
} catch (_: NativeBridgeException) {
if (ticket ==
epoch
) {
mutable.value =
mutable.value.copy(error = "Update the application and retry.")
}
} catch (_: java.io.IOException) {
if (ticket ==
epoch
) {
mutable.value =
mutable.value.copy(error = "The selected QR image could not be read.")
}
} finally {
if (ticket == epoch) mutable.value = mutable.value.copy(busy = false)
}
}
}
}
}
@@ -0,0 +1,79 @@
package de.rfc1437.ironstorage.core
import kotlinx.coroutines.flow.StateFlow
data class ProviderStatus(val supported: Boolean, val installed: Boolean, val enabled: Boolean)
interface SettingsPlatform {
fun autofill(): ProviderStatus
fun credentials(): ProviderStatus
fun biometricsAvailable(): Boolean
fun version(): String
fun openAutofill(): Boolean
fun openCredentials(): Boolean
fun chooseQrImages(): Boolean
suspend fun readQrImage(uri: String): ByteArray
}
data class SettingsPage(
val repositoryTitle: String,
val repositoryUrl: String,
val serverTitle: String,
val serverIdentity: String,
val account: String?,
val defaultKeyTitle: String,
val defaultKeyFingerprint: String,
val gitName: String,
val gitEmail: String,
val timeout: ULong,
val cacheDetail: String,
val biometricAvailable: Boolean,
val autofill: ProviderStatus,
val credentials: ProviderStatus,
val version: String
)
data class TransferKey(val fingerprint: String, val title: String, val privateKey: Boolean)
data class TransferFrame(
val sequence: UInt,
val total: UInt,
val width: UInt,
val modules: ByteArray
) {
override fun toString() = "TransferFrame([REDACTED])"
}
data class KeyTransferState(
val open: Boolean = false,
val keys: List<TransferKey> = emptyList(),
val frames: List<TransferFrame> = emptyList(),
val received: UInt = 0u,
val total: UInt = 0u,
val ready: TransferKey? = null
) {
override fun toString() = "KeyTransferState([REDACTED])"
}
data class SettingsState(
val busy: Boolean = false,
val page: SettingsPage? = null,
val error: String? = null,
val notice: String? = null,
val transfer: KeyTransferState = KeyTransferState()
) {
override fun toString() = "SettingsState(busy=$busy, [REDACTED])"
}
interface SettingsRepository {
val state: StateFlow<SettingsState>
fun reload()
fun setDefaultKey(fingerprint: String)
fun setGitIdentity(name: String, email: String)
fun setTimeout(seconds: String)
fun removeToken()
fun rebuildCache(passphrase: String)
fun openAutofill()
fun openCredentials()
fun beginTransfer()
fun exportKey(fingerprint: String, privateKey: Boolean, passphrase: String)
fun chooseQrImages()
fun addQrImages(uris: List<String>)
fun addQrPayload(payload: String)
fun importKey(passphrase: String, makeDefault: Boolean, overwrite: Boolean)
fun closeTransfer()
fun cancel()
}