Implement Rust-owned Android local and HTTPS onboarding (#95)
This commit is contained in:
1 parent
66b1d4a4c4
commit
36e5f55283
46 files changed
+3002
-79
No files matched your search
@@ -154,4 +154,5 @@ org.ow2.asm:asm-commons:9.9=androidLintTool
|
||||
org.ow2.asm:asm-tree:9.9=androidLintTool
|
||||
org.ow2.asm:asm:9.9=androidLintTool
|
||||
org.slf4j:slf4j-api:2.0.7=ktlint
|
||||
rustls:rustls-platform-verifier:0.1.1=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||
empty=androidApis,androidJdkImage,androidTestUtil,coreLibraryDesugaring,debugAndroidTestAnnotationProcessorClasspath,debugAndroidTestImplementationDependenciesMetadata,debugAnnotationProcessorClasspath,debugImplementationDependenciesMetadata,debugUnitTestAnnotationProcessorClasspath,debugUnitTestImplementationDependenciesMetadata,kotlinCompilerPluginClasspath,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease,lintChecks,lintPublish,releaseAnnotationProcessorClasspath,releaseImplementationDependenciesMetadata
|
||||
+22
-33
@@ -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) {
|
||||
|
||||
+208
@@ -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() {}
|
||||
}
|
||||
Reference in new issue
Block a user