Implement Rust-owned Android local and HTTPS onboarding (#95)
Android ARM verification / rust (push) Failing after 46m9s
Android ARM verification / android (push) Failing after 8s
Dependency security audit / rustsec (push) Successful in 21s

This commit is contained in:
hugo committed 2026-10-09 11:31:50 +02:00
1 parent 66b1d4a4c4
commit 36e5f55283
46 files changed
+3002 -79

No files matched your search

@@ -1,12 +1,8 @@
package de.rfc1437.ironstorage.core
import android.content.Context
import de.rfc1437.ironstorage.bridge.NativeBridgeException
import de.rfc1437.ironstorage.bridge.NativeFailureKind
import de.rfc1437.ironstorage.bridge.NativeRuntime
import de.rfc1437.ironstorage.bridge.generated.MobileIcon
import de.rfc1437.ironstorage.bridge.generated.MobilePrivateDirectories
import de.rfc1437.ironstorage.bridge.generated.MobilePrivateRuntime
import de.rfc1437.ironstorage.bridge.generated.MobilePrivateStorageErrorKind
import de.rfc1437.ironstorage.bridge.generated.MobilePrivateStorageFfiException
import de.rfc1437.ironstorage.bridge.generated.MobileShellState
@@ -25,22 +21,21 @@ import kotlinx.coroutines.withContext
/** Rust-owned private initialization; debug controls can switch to explicit fixtures. */
class NativeFixtureShellRepository internal constructor(
private val scope: CoroutineScope,
private val loadCurrent: (suspend () -> ShellSnapshot)? = null,
private val load: suspend (ShellState) -> ShellSnapshot
) : ShellRepository {
constructor(
context: Context,
scope: CoroutineScope,
directories: () -> PrivateDirectories,
keystore: AndroidKeystore
) :
this(scope, nativeLoader(context, directories, keystore))
constructor(session: NativePrivateSession, scope: CoroutineScope) : this(
scope,
{ nativeLoader(session, true)(ShellState.Empty) },
nativeLoader(session)
)
private val mutableSnapshot = MutableStateFlow(ShellSnapshot(ShellState.Loading, emptyList()))
override val snapshot: StateFlow<ShellSnapshot> = mutableSnapshot
private val revision = AtomicLong()
private var job: Job? = null
init {
showFixture(ShellState.Empty)
refresh()
}
override fun selectTab(tab: ShellTab) {
@@ -48,17 +43,26 @@ class NativeFixtureShellRepository internal constructor(
}
fun showFixture(state: ShellState) {
update(true) { load(state) }
}
override fun refresh() {
update(false, loadCurrent ?: { load(ShellState.Empty) })
}
private fun update(fixture: Boolean, loader: suspend () -> ShellSnapshot) {
val operation = revision.incrementAndGet()
job?.cancel()
job = scope.launch {
val result = try {
load(state)
loader()
} catch (error: NativeBridgeException) {
ShellSnapshot(ShellState.Error, emptyList(), nativeFailure = error.kind.toFacade())
}
if (revision.get() == operation) {
mutableSnapshot.update {
result.copy(
fixture = fixture,
selectedTab = if (it.pages.isEmpty()) result.selectedTab else it.selectedTab
)
}
@@ -68,31 +72,16 @@ class NativeFixtureShellRepository internal constructor(
}
private fun nativeLoader(
context: Context,
directoryProvider: () -> PrivateDirectories,
keystore: AndroidKeystore
session: NativePrivateSession,
current: Boolean = false
): suspend (ShellState) -> ShellSnapshot {
val runtime = NativeRuntime(context)
var storage: MobilePrivateRuntime? = null
var firstLoad = true
val runtime = session.runtime
return { state ->
withContext(Dispatchers.IO) {
var recovery: StorageRecovery? = null
val shell = if (firstLoad) {
firstLoad = false
val shell = if (current) {
try {
val directories = directoryProvider()
val initialized = storage ?: runtime.privateStorage(
MobilePrivateDirectories(
directories.files,
directories.noBackup,
directories.cache
)
)
storage = initialized
initialized.activate()
val secrets = initialized.androidSecretBackend(KeystoreCallback(keystore))
initialized.verifySecureStorage(secrets)
val initialized = session.storage()
recovery = initialized.status().recovery?.toFacade()
initialized.shell()
} catch (error: MobilePrivateStorageFfiException.Failed) {
@@ -0,0 +1,208 @@
package de.rfc1437.ironstorage.core
import de.rfc1437.ironstorage.bridge.NativeBridgeException
import de.rfc1437.ironstorage.bridge.generated.MobileAndroidOnboarding
import de.rfc1437.ironstorage.bridge.generated.MobileAndroidRemoteOnboarding
import de.rfc1437.ironstorage.bridge.generated.MobileAndroidSetupStatus
import de.rfc1437.ironstorage.bridge.generated.MobileOnboardingErrorKind
import de.rfc1437.ironstorage.bridge.generated.MobileOnboardingFfiException
import de.rfc1437.ironstorage.bridge.generated.MobileOnboardingOutcome
import de.rfc1437.ironstorage.bridge.generated.MobilePrivateStorageFfiException
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.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class NativeOnboardingRepository(
private val session: NativePrivateSession,
private val scope: CoroutineScope,
private val shell: ShellRepository
) : OnboardingRepository {
private val mutableSetup = MutableStateFlow(SetupStatus())
override val setup: StateFlow<SetupStatus> = mutableSetup
@Volatile private var operation: MobileAndroidOnboarding? = null
private var remote: MobileAndroidRemoteOnboarding? = null
@Volatile private var pollProgress = false
init {
refresh()
}
override fun refresh() = runSetup {
mutableSetup.update { it.copy(branches = emptyList(), selectedBranch = "") }
remote?.close()
remote = null
operation = session.storage().androidOnboarding(session.backend())
updateStatus(requireNotNull(operation).status())
shell.refresh()
}
private fun newOperation(): MobileAndroidOnboarding {
remote?.close()
remote = null
return session.storage().androidOnboarding(session.backend()).also { operation = it }
}
override fun generate(userId: String, passphrase: String) = runSetup {
updateStatus(newOperation().generate(userId, passphrase))
}
override fun importKey(armor: String, passphrase: String) = runSetup {
updateStatus(newOperation().importKey(armor, passphrase.ifEmpty { null }))
}
override fun createLocal(fingerprint: String, passphrase: String) = runSetup {
completed(newOperation().setupLocal(fingerprint, passphrase.ifEmpty { null }))
}
override fun discover(server: String, account: String, repository: String, token: String) =
runSetup {
val setup = newOperation()
val request = setup.remote(server, account, repository, token).also { remote = it }
val discovery = request.discover()
pollProgress = false
mutableSetup.update {
it.copy(
branches = discovery.branches,
selectedBranch = discovery.branches.getOrNull(
discovery.selectedBranch.toInt()
).orEmpty(),
title = "Select a branch",
detail = "Choose the branch and private GPG key for this store."
)
}
}
override fun cloneStore(
branch: String,
fingerprint: String,
passphrase: String,
useExisting: Boolean
) = runSetup {
completed(
requireNotNull(remote).cloneStore(
branch,
fingerprint,
passphrase.ifEmpty {
null
},
useExisting
)
)
}
override fun connect(server: String, account: String, repository: String, token: String) =
runSetup {
val setup = newOperation()
setup.remote(server, account, repository, token).use { completed(it.connect()) }
}
override fun replaceToken(account: String, token: String) = runSetup {
newOperation().replaceToken(account, token)
pollProgress = false
mutableSetup.update {
it.copy(
title = "Token updated",
detail = "The previous token was replaced in protected device storage."
)
}
}
override fun cancel() {
if (!mutableSetup.value.busy) return
scope.launch(Dispatchers.IO) { operation?.cancel() }
}
private fun completed(outcome: MobileOnboardingOutcome) {
updateStatus(requireNotNull(operation).status())
mutableSetup.update {
it.copy(title = outcome.title, detail = outcome.detail, branches = emptyList())
}
remote?.close()
remote = null
shell.refresh()
}
private fun updateStatus(status: MobileAndroidSetupStatus) {
pollProgress = false
mutableSetup.update {
it.copy(
title = status.title,
detail = status.detail,
configured = status.configured,
connected = status.connected,
existingClone = status.existingClone,
selectedKey = status.selectedKey.orEmpty(),
keys = status.keys.map { key -> SetupKey(key.fingerprint, key.userIds) }
)
}
}
private fun runSetup(action: () -> Unit) {
if (mutableSetup.value.busy) return
pollProgress = true
mutableSetup.update { it.copy(busy = true, failure = false) }
scope.launch {
val progress = launch {
while (mutableSetup.value.busy) {
delay(200)
val value = withContext(Dispatchers.IO) { operation?.progress() }
value?.takeIf { mutableSetup.value.busy }?.let { phase ->
mutableSetup.update {
if (pollProgress) {
it.copy(
title = phase.title,
detail = phase.detail
)
} else {
it
}
}
}
}
}
try {
withContext(Dispatchers.IO) { action() }
} catch (error: MobileOnboardingFfiException.Failed) {
pollProgress = false
if (error.kind == MobileOnboardingErrorKind.INTERRUPTED) {
withContext(Dispatchers.IO) {
remote?.close()
remote = null
}
mutableSetup.update { it.copy(branches = emptyList()) }
}
mutableSetup.update {
it.copy(title = error.title, detail = error.detail, failure = true)
}
} catch (_: MobilePrivateStorageFfiException.Failed) {
pollProgress = false
mutableSetup.update {
it.copy(
title = "Private storage unavailable",
detail = "Unlock the device and retry. Existing data was retained.",
failure = true
)
}
} catch (_: NativeBridgeException) {
pollProgress = false
mutableSetup.update {
it.copy(
title = "Application update required",
detail = "The native storage service could not be loaded.",
failure = true
)
}
} catch (_: java.io.IOException) {
pollProgress = false
mutableSetup.update {
it.copy(
title = "Private storage unavailable",
detail = "The app-private directories could not be opened.",
failure = true
)
}
} finally {
pollProgress = false
progress.cancel()
mutableSetup.update { it.copy(busy = false) }
}
}
}
}
@@ -0,0 +1,39 @@
package de.rfc1437.ironstorage.core
import android.content.Context
import de.rfc1437.ironstorage.bridge.NativeRuntime
import de.rfc1437.ironstorage.bridge.generated.MobilePrivateDirectories
import de.rfc1437.ironstorage.bridge.generated.MobilePrivateRuntime
import de.rfc1437.ironstorage.bridge.generated.MobileSecretBackend
/** Both facades share one Rust owner and retain its Keystore callbacks for its lifetime. */
class NativePrivateSession(
context: Context,
private val directories: () -> PrivateDirectories,
private val keystore: AndroidKeystore
) {
val runtime = NativeRuntime(context)
private var initialized: MobilePrivateRuntime? = null
private var secrets: MobileSecretBackend? = null
@Synchronized
fun storage(): MobilePrivateRuntime {
initialized?.let { return it }
val roots = directories()
val storage = runtime.privateStorage(
MobilePrivateDirectories(roots.files, roots.noBackup, roots.cache)
)
storage.activate()
val backend = storage.androidSecretBackend(KeystoreCallback(keystore))
storage.verifySecureStorage(backend)
secrets = backend
initialized = storage
return storage
}
@Synchronized
fun backend(): MobileSecretBackend {
storage()
return requireNotNull(secrets)
}
}
@@ -0,0 +1,31 @@
package de.rfc1437.ironstorage.core
import kotlinx.coroutines.flow.StateFlow
data class SetupKey(val fingerprint: String, val userIds: List<String>)
data class SetupStatus(
val configured: Boolean = false,
val connected: Boolean = false,
val existingClone: Boolean = false,
val keys: List<SetupKey> = emptyList(),
val selectedKey: String = "",
val branches: List<String> = emptyList(),
val selectedBranch: String = "",
val busy: Boolean = false,
val title: String = "Set up your password store",
val detail: String = "Create a local store or clone an existing HTTPS repository.",
val failure: Boolean = false
)
interface OnboardingRepository {
val setup: StateFlow<SetupStatus>
fun refresh()
fun generate(userId: String, passphrase: String)
fun importKey(armor: String, passphrase: String)
fun createLocal(fingerprint: String, passphrase: String)
fun discover(server: String, account: String, repository: String, token: String)
fun cloneStore(branch: String, fingerprint: String, passphrase: String, useExisting: Boolean)
fun connect(server: String, account: String, repository: String, token: String)
fun replaceToken(account: String, token: String)
fun cancel()
}
@@ -44,6 +44,7 @@ data class ShellSnapshot(
val state: ShellState,
val pages: List<ShellPage>,
val selectedTab: ShellTab = ShellTab.Home,
val fixture: Boolean = false,
val nativeFailure: NativeFailure? = null,
val storageRecovery: StorageRecovery? = null
)
@@ -61,4 +62,5 @@ enum class NativeFailure {
interface ShellRepository {
val snapshot: StateFlow<ShellSnapshot>
fun selectTab(tab: ShellTab)
fun refresh() {}
}