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
+2994 -71

No files matched your search

Generated
+2
View File
@@ -5440,6 +5440,8 @@ name = "ironstorage-android"
version = "1.0.1" version = "1.0.1"
dependencies = [ dependencies = [
"ironstorage-mobile", "ironstorage-mobile",
"jni",
"rustls-platform-verifier",
] ]
[[package]] [[package]]
+6 -5
View File
@@ -1,10 +1,10 @@
# Native Android build # Native Android build
Architecture and issue ownership: [ADR](../docs/android/architecture.md) and Architecture and issue ownership: [ADR](../docs/android/architecture.md) and
[parity matrix](../docs/android/parity.md). The native foundation implements #89–#94, including private storage, Keystore [parity matrix](../docs/android/parity.md). The native foundation and onboarding implement #89–#95, including private storage, Keystore
callbacks and build gates. Both variants invoke Rust through the generated callbacks and build gates. Both variants invoke Rust through the generated
UniFFI API. Onboarding and the feature screens remain owned by their milestone UniFFI API. Local-first and HTTPS onboarding is described in
issues. The full [CI and emulator workflow](../docs/android/ci.md) documents the [onboarding](../docs/android/onboarding.md); feature screens remain owned by their milestone issues. The full [CI and emulator workflow](../docs/android/ci.md) documents the
ARM-only runner/toolchain policy and artifact audits. ARM-only runner/toolchain policy and artifact audits.
## Reproducible setup ## Reproducible setup
@@ -54,8 +54,9 @@ code cannot conceal a domain implementation.
Debug controls invoke the storage-owned MobileShell::fixture through the native Debug controls invoke the storage-owned MobileShell::fixture through the native
API for Loading/Empty/Ready/Locked/Error. A build-generated fixture inventory is API for Loading/Empty/Ready/Locked/Error. A build-generated fixture inventory is
used only as the test oracle. Release calls the same Rust Empty fixture without used only as the test oracle. Release loads the real Rust private-storage state without debug controls.
debug controls; repository initialization is owned by #90. Feature code consumes An unconfigured runtime opens staged onboarding; Preferences can add HTTPS
synchronization or replace its protected token. Feature code consumes
only the facade, including typed native recovery states. only the facade, including typed native recovery states.
## Identity, signing and update ## Identity, signing and update
+1
View File
@@ -311,4 +311,5 @@ org.ow2.asm:asm-tree:9.9.1=composeMappingProducerClasspath
org.ow2.asm:asm:9.9=androidLintTool org.ow2.asm:asm:9.9=androidLintTool
org.ow2.asm:asm:9.9.1=composeMappingProducerClasspath org.ow2.asm:asm:9.9.1=composeMappingProducerClasspath
org.slf4j:slf4j-api:2.0.7=ktlint org.slf4j:slf4j-api:2.0.7=ktlint
rustls:rustls-platform-verifier:0.1.1=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
empty=_agp_internal_debugAndroidTest_kspClasspath,_agp_internal_debugUnitTest_kspClasspath,androidApis,androidJdkImage,androidTestUtil,coreLibraryDesugaring,debugAndroidTestAnnotationProcessorClasspath,debugAndroidTestImplementationDependenciesMetadata,debugAnnotationProcessorClasspath,debugImplementationDependenciesMetadata,debugReverseMetadataValues,debugUnitTestAnnotationProcessorClasspath,debugUnitTestImplementationDependenciesMetadata,hiltCompileOnlyDebug,hiltCompileOnlyDebugAndroidTest,hiltCompileOnlyDebugUnitTest,hiltCompileOnlyRelease,kotlinCompilerPluginClasspath,kspDebugAndroidTestKotlinProcessorClasspath,kspDebugUnitTestKotlinProcessorClasspath,lintChecks,lintPublish,releaseAnnotationProcessorClasspath,releaseImplementationDependenciesMetadata,releaseReverseMetadataValues empty=_agp_internal_debugAndroidTest_kspClasspath,_agp_internal_debugUnitTest_kspClasspath,androidApis,androidJdkImage,androidTestUtil,coreLibraryDesugaring,debugAndroidTestAnnotationProcessorClasspath,debugAndroidTestImplementationDependenciesMetadata,debugAnnotationProcessorClasspath,debugImplementationDependenciesMetadata,debugReverseMetadataValues,debugUnitTestAnnotationProcessorClasspath,debugUnitTestImplementationDependenciesMetadata,hiltCompileOnlyDebug,hiltCompileOnlyDebugAndroidTest,hiltCompileOnlyDebugUnitTest,hiltCompileOnlyRelease,kotlinCompilerPluginClasspath,kspDebugAndroidTestKotlinProcessorClasspath,kspDebugUnitTestKotlinProcessorClasspath,lintChecks,lintPublish,releaseAnnotationProcessorClasspath,releaseImplementationDependenciesMetadata,releaseReverseMetadataValues
@@ -0,0 +1,99 @@
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.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performTextInput
import de.rfc1437.ironstorage.core.OnboardingRepository
import de.rfc1437.ironstorage.core.SetupKey
import de.rfc1437.ironstorage.core.SetupStatus
import de.rfc1437.ironstorage.features.OnboardingScreen
import de.rfc1437.ironstorage.features.SetupFormViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
class OnboardingScreenTest {
@get:Rule val compose = createAndroidComposeRule<MainActivity>()
@Test fun localAndRemoteFormsUseTypedFacadeAndClearSubmittedSecrets() {
val repository = FixtureRepository()
val secrets = SetupFormViewModel()
compose.activity.runOnUiThread {
compose.activity.setContent { MaterialTheme { OnboardingScreen(repository, secrets) } }
}
compose.waitForIdle()
compose.onNodeWithText("Create local store").performClick()
compose.onNodeWithText("Generate GPG key").performScrollTo().performClick()
compose.onNodeWithText(
"Key name and email"
).performTextInput("Fixture <fixture@example.test>")
compose.onNodeWithText("GPG passphrase").performTextInput("public-test-passphrase")
compose.onNodeWithText("Generate GPG key").performScrollTo().performClick()
compose.onNodeWithText("Create encrypted local store").performScrollTo().performClick()
compose.onNodeWithText("Add HTTPS synchronization").assertIsDisplayed().performClick()
compose.onNodeWithText("HTTPS server URL").performTextInput("https://example.test")
compose.onNodeWithText("Account").performTextInput("alice")
compose.onNodeWithText(
"Repository path (owner/repository)"
).performTextInput("team/passwords")
compose.onNodeWithText(
"Application token"
).performScrollTo().performTextInput("public-test-token")
compose.onNodeWithText("Connect local store").performScrollTo().performClick()
assertEquals(listOf("generate", "local", "connect"), repository.calls)
assertTrue(
secrets.passphrase.isEmpty() && secrets.token.isEmpty() && secrets.armor.isEmpty()
)
}
private class FixtureRepository : OnboardingRepository {
override val setup = MutableStateFlow(SetupStatus())
val calls = mutableListOf<String>()
override fun refresh() {
setup.value = setup.value.copy(branches = emptyList())
}
override fun generate(userId: String, passphrase: String) {
assertEquals("Fixture <fixture@example.test>", userId)
assertEquals("public-test-passphrase", passphrase)
calls.add("generate")
setup.value =
setup.value.copy(
keys = listOf(SetupKey("fixture-key", listOf(userId))),
selectedKey = "fixture-key"
)
}
override fun createLocal(fingerprint: String, passphrase: String) {
assertEquals("fixture-key", fingerprint)
assertEquals("public-test-passphrase", passphrase)
calls.add("local")
setup.value = setup.value.copy(configured = true)
}
override fun connect(server: String, account: String, repository: String, token: String) {
assertEquals("https://example.test", server)
assertEquals("alice", account)
assertEquals("team/passwords", repository)
assertEquals("public-test-token", token)
calls.add("connect")
setup.value = setup.value.copy(connected = true)
}
override fun importKey(armor: String, passphrase: String) = error("Unexpected import")
override fun discover(server: String, account: String, repository: String, token: String) =
error("Unexpected discovery")
override fun cloneStore(
branch: String,
fingerprint: String,
passphrase: String,
useExisting: Boolean
) = error("Unexpected clone")
override fun replaceToken(account: String, token: String) =
error("Unexpected token replacement")
override fun cancel() = Unit
}
}
@@ -12,11 +12,16 @@ import androidx.compose.ui.Modifier
import de.rfc1437.ironstorage.core.NativeFixtureShellRepository import de.rfc1437.ironstorage.core.NativeFixtureShellRepository
import de.rfc1437.ironstorage.core.ShellRepository import de.rfc1437.ironstorage.core.ShellRepository
import de.rfc1437.ironstorage.core.ShellState import de.rfc1437.ironstorage.core.ShellState
import de.rfc1437.ironstorage.features.SetupFormViewModel
import de.rfc1437.ironstorage.features.ShellScreen import de.rfc1437.ironstorage.features.ShellScreen
import de.rfc1437.ironstorage.features.ShellViewModel import de.rfc1437.ironstorage.features.ShellViewModel
@Composable @Composable
fun VariantContent(viewModel: ShellViewModel, repository: ShellRepository) { fun VariantContent(
viewModel: ShellViewModel,
repository: ShellRepository,
setupForm: SetupFormViewModel
) {
Column(Modifier.statusBarsPadding()) { Column(Modifier.statusBarsPadding()) {
Row(Modifier.horizontalScroll(rememberScrollState())) { Row(Modifier.horizontalScroll(rememberScrollState())) {
ShellState.entries.forEach { state -> ShellState.entries.forEach { state ->
@@ -27,6 +32,6 @@ fun VariantContent(viewModel: ShellViewModel, repository: ShellRepository) {
} }
} }
} }
ShellScreen(viewModel) ShellScreen(viewModel, setupForm)
} }
} }
+1
View File
@@ -1,4 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" /> <uses-permission android:name="android.permission.USE_BIOMETRIC" />
<application <application
android:name=".IronStorageApplication" android:name=".IronStorageApplication"
@@ -8,6 +8,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import de.rfc1437.ironstorage.core.ShellRepository import de.rfc1437.ironstorage.core.ShellRepository
import de.rfc1437.ironstorage.features.SetupFormViewModel
import de.rfc1437.ironstorage.features.ShellViewModel import de.rfc1437.ironstorage.features.ShellViewModel
import de.rfc1437.ironstorage.platform.AndroidKeystoreAdapter import de.rfc1437.ironstorage.platform.AndroidKeystoreAdapter
import javax.inject.Inject import javax.inject.Inject
@@ -18,6 +19,8 @@ class MainActivity : FragmentActivity() {
@Inject lateinit var repository: ShellRepository @Inject lateinit var repository: ShellRepository
private val setupForm: SetupFormViewModel by viewModels()
private val viewModel: ShellViewModel by viewModels() private val viewModel: ShellViewModel by viewModels()
override fun onDestroy() { override fun onDestroy() {
@@ -31,7 +34,7 @@ class MainActivity : FragmentActivity() {
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {
MaterialTheme { MaterialTheme {
VariantContent(viewModel, repository) VariantContent(viewModel, repository, setupForm)
} }
} }
} }
@@ -7,6 +7,9 @@ import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import de.rfc1437.ironstorage.core.NativeFixtureShellRepository import de.rfc1437.ironstorage.core.NativeFixtureShellRepository
import de.rfc1437.ironstorage.core.NativeOnboardingRepository
import de.rfc1437.ironstorage.core.NativePrivateSession
import de.rfc1437.ironstorage.core.OnboardingRepository
import de.rfc1437.ironstorage.core.ShellRepository import de.rfc1437.ironstorage.core.ShellRepository
import de.rfc1437.ironstorage.platform.AndroidKeystoreAdapter import de.rfc1437.ironstorage.platform.AndroidKeystoreAdapter
import de.rfc1437.ironstorage.platform.privateDirectories import de.rfc1437.ironstorage.platform.privateDirectories
@@ -18,6 +21,25 @@ import kotlinx.coroutines.SupervisorJob
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
object ShellModule { object ShellModule {
@Provides
@Singleton
fun privateSession(
@ApplicationContext context: Context,
keystore: AndroidKeystoreAdapter
): NativePrivateSession =
NativePrivateSession(context, { privateDirectories(context) }, keystore)
@Provides
@Singleton
fun onboardingRepository(
session: NativePrivateSession,
shell: ShellRepository
): OnboardingRepository = NativeOnboardingRepository(
session,
CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate),
shell
)
@Provides @Provides
@Singleton @Singleton
fun keystore(@ApplicationContext context: Context): AndroidKeystoreAdapter = fun keystore(@ApplicationContext context: Context): AndroidKeystoreAdapter =
@@ -25,13 +47,9 @@ object ShellModule {
@Provides @Provides
@Singleton @Singleton
fun shellRepository( fun shellRepository(session: NativePrivateSession): ShellRepository =
@ApplicationContext context: Context, NativeFixtureShellRepository(
keystore: AndroidKeystoreAdapter session,
): ShellRepository = NativeFixtureShellRepository( CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
context,
CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate),
{ privateDirectories(context) },
keystore
) )
} }
@@ -2,11 +2,16 @@ package de.rfc1437.ironstorage
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import de.rfc1437.ironstorage.core.ShellRepository import de.rfc1437.ironstorage.core.ShellRepository
import de.rfc1437.ironstorage.features.SetupFormViewModel
import de.rfc1437.ironstorage.features.ShellScreen import de.rfc1437.ironstorage.features.ShellScreen
import de.rfc1437.ironstorage.features.ShellViewModel import de.rfc1437.ironstorage.features.ShellViewModel
@Composable @Composable
@Suppress("UNUSED_PARAMETER") @Suppress("UNUSED_PARAMETER")
fun VariantContent(viewModel: ShellViewModel, repository: ShellRepository) { fun VariantContent(
ShellScreen(viewModel) viewModel: ShellViewModel,
repository: ShellRepository,
setupForm: SetupFormViewModel
) {
ShellScreen(viewModel, setupForm)
} }
+1
View File
@@ -154,4 +154,5 @@ org.ow2.asm:asm-commons:9.9=androidLintTool
org.ow2.asm:asm-tree:9.9=androidLintTool org.ow2.asm:asm-tree:9.9=androidLintTool
org.ow2.asm:asm:9.9=androidLintTool org.ow2.asm:asm:9.9=androidLintTool
org.slf4j:slf4j-api:2.0.7=ktlint 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 empty=androidApis,androidJdkImage,androidTestUtil,coreLibraryDesugaring,debugAndroidTestAnnotationProcessorClasspath,debugAndroidTestImplementationDependenciesMetadata,debugAnnotationProcessorClasspath,debugImplementationDependenciesMetadata,debugUnitTestAnnotationProcessorClasspath,debugUnitTestImplementationDependenciesMetadata,kotlinCompilerPluginClasspath,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease,lintChecks,lintPublish,releaseAnnotationProcessorClasspath,releaseImplementationDependenciesMetadata
@@ -1,12 +1,8 @@
package de.rfc1437.ironstorage.core package de.rfc1437.ironstorage.core
import android.content.Context
import de.rfc1437.ironstorage.bridge.NativeBridgeException import de.rfc1437.ironstorage.bridge.NativeBridgeException
import de.rfc1437.ironstorage.bridge.NativeFailureKind 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.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.MobilePrivateStorageErrorKind
import de.rfc1437.ironstorage.bridge.generated.MobilePrivateStorageFfiException import de.rfc1437.ironstorage.bridge.generated.MobilePrivateStorageFfiException
import de.rfc1437.ironstorage.bridge.generated.MobileShellState 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. */ /** Rust-owned private initialization; debug controls can switch to explicit fixtures. */
class NativeFixtureShellRepository internal constructor( class NativeFixtureShellRepository internal constructor(
private val scope: CoroutineScope, private val scope: CoroutineScope,
private val loadCurrent: (suspend () -> ShellSnapshot)? = null,
private val load: suspend (ShellState) -> ShellSnapshot private val load: suspend (ShellState) -> ShellSnapshot
) : ShellRepository { ) : ShellRepository {
constructor( constructor(session: NativePrivateSession, scope: CoroutineScope) : this(
context: Context, scope,
scope: CoroutineScope, { nativeLoader(session, true)(ShellState.Empty) },
directories: () -> PrivateDirectories, nativeLoader(session)
keystore: AndroidKeystore )
) :
this(scope, nativeLoader(context, directories, keystore))
private val mutableSnapshot = MutableStateFlow(ShellSnapshot(ShellState.Loading, emptyList())) private val mutableSnapshot = MutableStateFlow(ShellSnapshot(ShellState.Loading, emptyList()))
override val snapshot: StateFlow<ShellSnapshot> = mutableSnapshot override val snapshot: StateFlow<ShellSnapshot> = mutableSnapshot
private val revision = AtomicLong() private val revision = AtomicLong()
private var job: Job? = null private var job: Job? = null
init { init {
showFixture(ShellState.Empty) refresh()
} }
override fun selectTab(tab: ShellTab) { override fun selectTab(tab: ShellTab) {
@@ -48,17 +43,26 @@ class NativeFixtureShellRepository internal constructor(
} }
fun showFixture(state: ShellState) { 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() val operation = revision.incrementAndGet()
job?.cancel() job?.cancel()
job = scope.launch { job = scope.launch {
val result = try { val result = try {
load(state) loader()
} catch (error: NativeBridgeException) { } catch (error: NativeBridgeException) {
ShellSnapshot(ShellState.Error, emptyList(), nativeFailure = error.kind.toFacade()) ShellSnapshot(ShellState.Error, emptyList(), nativeFailure = error.kind.toFacade())
} }
if (revision.get() == operation) { if (revision.get() == operation) {
mutableSnapshot.update { mutableSnapshot.update {
result.copy( result.copy(
fixture = fixture,
selectedTab = if (it.pages.isEmpty()) result.selectedTab else it.selectedTab selectedTab = if (it.pages.isEmpty()) result.selectedTab else it.selectedTab
) )
} }
@@ -68,31 +72,16 @@ class NativeFixtureShellRepository internal constructor(
} }
private fun nativeLoader( private fun nativeLoader(
context: Context, session: NativePrivateSession,
directoryProvider: () -> PrivateDirectories, current: Boolean = false
keystore: AndroidKeystore
): suspend (ShellState) -> ShellSnapshot { ): suspend (ShellState) -> ShellSnapshot {
val runtime = NativeRuntime(context) val runtime = session.runtime
var storage: MobilePrivateRuntime? = null
var firstLoad = true
return { state -> return { state ->
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
var recovery: StorageRecovery? = null var recovery: StorageRecovery? = null
val shell = if (firstLoad) { val shell = if (current) {
firstLoad = false
try { try {
val directories = directoryProvider() val initialized = session.storage()
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)
recovery = initialized.status().recovery?.toFacade() recovery = initialized.status().recovery?.toFacade()
initialized.shell() initialized.shell()
} catch (error: MobilePrivateStorageFfiException.Failed) { } 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 state: ShellState,
val pages: List<ShellPage>, val pages: List<ShellPage>,
val selectedTab: ShellTab = ShellTab.Home, val selectedTab: ShellTab = ShellTab.Home,
val fixture: Boolean = false,
val nativeFailure: NativeFailure? = null, val nativeFailure: NativeFailure? = null,
val storageRecovery: StorageRecovery? = null val storageRecovery: StorageRecovery? = null
) )
@@ -61,4 +62,5 @@ enum class NativeFailure {
interface ShellRepository { interface ShellRepository {
val snapshot: StateFlow<ShellSnapshot> val snapshot: StateFlow<ShellSnapshot>
fun selectTab(tab: ShellTab) fun selectTab(tab: ShellTab)
fun refresh() {}
} }
+1
View File
@@ -1,5 +1,6 @@
{ {
"android": { "android": {
"rustls:rustls-platform-verifier:0.1.1": "MIT OR Apache-2.0",
"androidx.activity:activity-compose:1.12.0": "Apache-2.0", "androidx.activity:activity-compose:1.12.0": "Apache-2.0",
"androidx.activity:activity-compose:1.13.0": "Apache-2.0", "androidx.activity:activity-compose:1.13.0": "Apache-2.0",
"androidx.activity:activity-ktx:1.12.0": "Apache-2.0", "androidx.activity:activity-ktx:1.12.0": "Apache-2.0",
+1
View File
@@ -281,4 +281,5 @@ org.ow2.asm:asm-commons:9.9=androidLintTool
org.ow2.asm:asm-tree:9.9=androidLintTool org.ow2.asm:asm-tree:9.9=androidLintTool
org.ow2.asm:asm:9.9=androidLintTool org.ow2.asm:asm:9.9=androidLintTool
org.slf4j:slf4j-api:2.0.7=ktlint org.slf4j:slf4j-api:2.0.7=ktlint
rustls:rustls-platform-verifier:0.1.1=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
empty=_agp_internal_debugAndroidTest_kspClasspath,_agp_internal_debugUnitTest_kspClasspath,androidApis,androidJdkImage,androidTestUtil,coreLibraryDesugaring,debugAndroidTestAnnotationProcessorClasspath,debugAndroidTestImplementationDependenciesMetadata,debugAnnotationProcessorClasspath,debugImplementationDependenciesMetadata,debugUnitTestAnnotationProcessorClasspath,debugUnitTestImplementationDependenciesMetadata,hiltCompileOnlyDebugAndroidTest,hiltCompileOnlyDebugUnitTest,kotlinCompilerPluginClasspath,kspDebugAndroidTestKotlinProcessorClasspath,kspDebugUnitTestKotlinProcessorClasspath,lintChecks,lintPublish,releaseAnnotationProcessorClasspath,releaseImplementationDependenciesMetadata empty=_agp_internal_debugAndroidTest_kspClasspath,_agp_internal_debugUnitTest_kspClasspath,androidApis,androidJdkImage,androidTestUtil,coreLibraryDesugaring,debugAndroidTestAnnotationProcessorClasspath,debugAndroidTestImplementationDependenciesMetadata,debugAnnotationProcessorClasspath,debugImplementationDependenciesMetadata,debugUnitTestAnnotationProcessorClasspath,debugUnitTestImplementationDependenciesMetadata,hiltCompileOnlyDebugAndroidTest,hiltCompileOnlyDebugUnitTest,kotlinCompilerPluginClasspath,kspDebugAndroidTestKotlinProcessorClasspath,kspDebugUnitTestKotlinProcessorClasspath,lintChecks,lintPublish,releaseAnnotationProcessorClasspath,releaseImplementationDependenciesMetadata
@@ -0,0 +1,360 @@
package de.rfc1437.ironstorage.features
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dagger.hilt.android.lifecycle.HiltViewModel
import de.rfc1437.ironstorage.core.OnboardingRepository
import javax.inject.Inject
/** Secrets survive configuration changes in memory, never in a saved-state Bundle. */
@HiltViewModel
class SetupFormViewModel @Inject constructor() : ViewModel() {
var passphrase by mutableStateOf("")
var armor by mutableStateOf("")
var token by mutableStateOf("")
fun clearSecrets() {
passphrase = ""
armor = ""
token = ""
}
}
@Composable
fun OnboardingScreen(
repository: OnboardingRepository,
secrets: SetupFormViewModel,
modifier: Modifier = Modifier
) {
val state by repository.setup.collectAsStateWithLifecycle()
var step by rememberSaveable { mutableStateOf("choice") }
var clone by rememberSaveable { mutableStateOf(false) }
var userId by rememberSaveable { mutableStateOf("") }
var server by rememberSaveable { mutableStateOf("") }
var account by rememberSaveable { mutableStateOf("") }
var path by rememberSaveable { mutableStateOf("") }
var key by rememberSaveable { mutableStateOf("") }
var branch by rememberSaveable { mutableStateOf("") }
var reuse by rememberSaveable { mutableStateOf(false) }
val selectedKey = key.ifEmpty { state.selectedKey }
val selectedBranch = branch.ifEmpty { state.selectedBranch }
// A restarted process reconstructs prepared keys; transport secrets must be entered again.
val page = when {
state.connected && step == "remote" -> "ready"
state.configured && step !in setOf("remote", "token") -> "ready"
state.branches.isNotEmpty() -> "branch"
else -> step
}
Column(
modifier.widthIn(max = 640.dp).fillMaxWidth().verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
state.title,
modifier = Modifier.semantics {
heading()
},
style = MaterialTheme.typography.headlineSmall
)
Text(
state.detail,
color = if (state.failure) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurface
}
)
if (state.busy) {
CircularProgressIndicator()
TextButton(onClick = repository::cancel) { Text(stringResource(R.string.setup_cancel)) }
} else {
when (page) {
"choice" -> {
Text(stringResource(R.string.setup_manual_alternative))
Button(onClick = {
clone = false
step = "key"
}) { Text(stringResource(R.string.setup_local)) }
Button(onClick = {
clone = true
step = "key"
}) { Text(stringResource(R.string.setup_clone)) }
}
"key" -> {
Text(stringResource(R.string.setup_choose_key))
state.keys.forEach { item ->
Row(
Modifier.selectable(
selected = selectedKey == item.fingerprint,
role = Role.RadioButton,
onClick = { key = item.fingerprint }
)
) {
RadioButton(selected = selectedKey == item.fingerprint, onClick = null)
Column {
Text(item.userIds.joinToString())
Text(item.fingerprint)
}
}
}
if (state.keys.isNotEmpty()) {
if (!clone) {
SecretField(
stringResource(R.string.setup_passphrase),
secrets.passphrase
) {
secrets.passphrase =
it
}
}
Button(onClick = {
if (clone) {
step = "remote"
} else {
repository.createLocal(selectedKey, secrets.passphrase)
secrets.clearSecrets()
}
}) {
Text(
stringResource(
if (clone) R.string.setup_continue else R.string.setup_create
)
)
}
}
Button(onClick = {
step = "generate"
}) { Text(stringResource(R.string.setup_generate)) }
TextButton(onClick = {
step = "import"
}) { Text(stringResource(R.string.setup_import)) }
}
"generate" -> {
Field(stringResource(R.string.setup_user_id), userId, onChange = {
userId = it
})
SecretField(stringResource(R.string.setup_passphrase), secrets.passphrase) {
secrets.passphrase =
it
}
Button(onClick = {
repository.generate(userId, secrets.passphrase)
secrets.armor = ""
secrets.token = ""
step =
"key"
}) {
Text(stringResource(R.string.setup_generate))
}
}
"import" -> {
Text(stringResource(R.string.setup_import_help))
SecretField(
stringResource(R.string.setup_armor),
secrets.armor,
singleLine = false
) {
secrets.armor =
it
}
SecretField(stringResource(R.string.setup_passphrase), secrets.passphrase) {
secrets.passphrase =
it
}
Button(onClick = {
repository.importKey(secrets.armor, secrets.passphrase)
secrets.armor = ""
secrets.token = ""
step =
"key"
}) {
Text(stringResource(R.string.setup_import))
}
}
"remote" -> {
Field(stringResource(R.string.setup_server), server, KeyboardType.Uri) {
server =
it
}
Field(stringResource(R.string.setup_account), account, onChange = {
account = it
})
Field(stringResource(R.string.setup_repository), path, onChange = { path = it })
SecretField(stringResource(R.string.setup_token), secrets.token) {
secrets.token =
it
}
Button(onClick = {
if (state.configured) {
repository.connect(server, account, path, secrets.token)
} else {
repository.discover(server, account, path, secrets.token)
}
secrets.token = ""
secrets.armor = ""
}) {
Text(
stringResource(
if (state.configured) {
R.string.setup_connect
} else {
R.string.setup_discover
}
)
)
}
}
"branch" -> {
state.branches.forEach { item ->
Row(
Modifier.selectable(
selected = selectedBranch == item,
role = Role.RadioButton,
onClick = { branch = item }
)
) {
RadioButton(selected = selectedBranch == item, onClick = null)
Text(item)
}
}
SecretField(stringResource(R.string.setup_passphrase), secrets.passphrase) {
secrets.passphrase =
it
}
Row(
Modifier.toggleable(value = reuse, role = Role.Checkbox, onValueChange = {
reuse =
it
})
) {
Checkbox(checked = reuse, onCheckedChange = null)
Text(stringResource(R.string.setup_reuse))
}
Button(onClick = {
repository.cloneStore(
selectedBranch,
selectedKey,
secrets.passphrase,
reuse
)
secrets.clearSecrets()
}) {
Text(stringResource(R.string.setup_clone))
}
}
"ready" -> {
if (!state.connected) {
Button(onClick = {
step = "remote"
}) { Text(stringResource(R.string.setup_add_remote)) }
} else {
TextButton(onClick = {
step = "token"
}) { Text(stringResource(R.string.setup_replace_token)) }
}
}
"token" -> {
Field(stringResource(R.string.setup_account), account, onChange = {
account = it
})
SecretField(stringResource(R.string.setup_token), secrets.token) {
secrets.token =
it
}
Button(onClick = {
repository.replaceToken(account, secrets.token)
secrets.clearSecrets()
}) { Text(stringResource(R.string.setup_replace_token)) }
}
}
if (page !in setOf("choice", "ready")) {
TextButton(onClick = {
repository.refresh()
secrets.clearSecrets()
step = if (state.configured) "ready" else "choice"
clone = false
}) { Text(stringResource(R.string.setup_back)) }
}
if (state.failure) {
TextButton(onClick = repository::refresh) {
Text(stringResource(R.string.setup_retry))
}
}
}
}
}
@Composable
private fun Field(
label: String,
value: String,
keyboard: KeyboardType = KeyboardType.Text,
onChange: (String) -> Unit
) {
OutlinedTextField(
value,
onChange,
label = {
Text(label)
},
singleLine = true,
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(
keyboardType = keyboard
)
)
}
@Composable
private fun SecretField(
label: String,
value: String,
singleLine: Boolean = true,
onChange: (String) -> Unit
) {
OutlinedTextField(
value,
onChange,
label = {
Text(label)
},
singleLine = singleLine,
modifier = Modifier.fillMaxWidth(),
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
autoCorrectEnabled = false
),
maxLines = if (singleLine) 1 else 5
)
}
@@ -26,7 +26,7 @@ import de.rfc1437.ironstorage.core.ShellState
import de.rfc1437.ironstorage.core.ShellTab import de.rfc1437.ironstorage.core.ShellTab
@Composable @Composable
fun ShellScreen(viewModel: ShellViewModel) { fun ShellScreen(viewModel: ShellViewModel, setupForm: SetupFormViewModel) {
val snapshot by viewModel.snapshot.collectAsStateWithLifecycle() val snapshot by viewModel.snapshot.collectAsStateWithLifecycle()
Scaffold( Scaffold(
bottomBar = { bottomBar = {
@@ -75,6 +75,12 @@ fun ShellScreen(viewModel: ShellViewModel) {
} }
) )
} }
if (!snapshot.fixture && snapshot.nativeFailure == null &&
snapshot.storageRecovery == null &&
(snapshot.state == ShellState.Empty || snapshot.selectedTab == ShellTab.Preferences)
) {
OnboardingScreen(viewModel.onboarding, setupForm)
} else {
Text( Text(
title, title,
modifier = Modifier.semantics { heading() }, modifier = Modifier.semantics { heading() },
@@ -84,6 +90,7 @@ fun ShellScreen(viewModel: ShellViewModel) {
if (snapshot.state == ShellState.Loading) CircularProgressIndicator() if (snapshot.state == ShellState.Loading) CircularProgressIndicator()
} }
} }
}
} }
private fun navigationIcon(icon: ShellIcon): Int = when (icon) { private fun navigationIcon(icon: ShellIcon): Int = when (icon) {
@@ -2,12 +2,16 @@ package de.rfc1437.ironstorage.features
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import de.rfc1437.ironstorage.core.OnboardingRepository
import de.rfc1437.ironstorage.core.ShellRepository import de.rfc1437.ironstorage.core.ShellRepository
import de.rfc1437.ironstorage.core.ShellTab import de.rfc1437.ironstorage.core.ShellTab
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class ShellViewModel @Inject constructor(private val repository: ShellRepository) : ViewModel() { class ShellViewModel @Inject constructor(
private val repository: ShellRepository,
val onboarding: OnboardingRepository
) : ViewModel() {
val snapshot = repository.snapshot val snapshot = repository.snapshot
fun selectTab(tab: ShellTab) = repository.selectTab(tab) fun selectTab(tab: ShellTab) = repository.selectTab(tab)
@@ -6,4 +6,28 @@
<string name="native_unsupported_device">This device is not supported by this app build.</string> <string name="native_unsupported_device">This device is not supported by this app build.</string>
<string name="native_update_required">The installed app components could not be loaded or do not match. Install a complete update without clearing app data.</string> <string name="native_update_required">The installed app components could not be loaded or do not match. Install a complete update without clearing app data.</string>
<string name="native_operation_failed">The app could not complete this operation. Try again.</string> <string name="native_operation_failed">The app could not complete this operation. Try again.</string>
<string name="setup_cancel">Cancel setup</string>
<string name="setup_manual_alternative">No server, camera, or biometric enrollment is required for local setup. Import a private key manually or generate one here.</string>
<string name="setup_local">Create local store</string>
<string name="setup_clone">Clone existing store</string>
<string name="setup_choose_key">Select a private GPG encryption key</string>
<string name="setup_passphrase">GPG passphrase</string>
<string name="setup_continue">Continue</string>
<string name="setup_create">Create encrypted local store</string>
<string name="setup_generate">Generate GPG key</string>
<string name="setup_import">Import private GPG key</string>
<string name="setup_user_id">Key name and email</string>
<string name="setup_import_help">Paste one armored private key. It remains in app-private storage; the passphrase is not saved.</string>
<string name="setup_armor">Armored private key</string>
<string name="setup_server">HTTPS server URL</string>
<string name="setup_account">Account</string>
<string name="setup_repository">Repository path (owner/repository)</string>
<string name="setup_token">Application token</string>
<string name="setup_connect">Connect local store</string>
<string name="setup_discover">Discover branches</string>
<string name="setup_reuse">Use matching existing clone</string>
<string name="setup_add_remote">Add HTTPS synchronization</string>
<string name="setup_replace_token">Replace application token</string>
<string name="setup_back">Back to setup choices</string>
<string name="setup_retry">Reload setup state</string>
</resources> </resources>
+2
View File
@@ -21,11 +21,13 @@ rust = "1.97.1"
cargoNdk = "4.1.2" cargoNdk = "4.1.2"
jna = "5.19.1" jna = "5.19.1"
annotation = "1.11.0" annotation = "1.11.0"
rustlsVerifier = "0.1.1"
biometric = "1.1.0" biometric = "1.1.0"
fragment = "1.9.1" fragment = "1.9.1"
[libraries] [libraries]
rustls-verifier = { group = "rustls", name = "rustls-platform-verifier", version.ref = "rustlsVerifier" }
compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose" } compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose" }
compose-ui = { module = "androidx.compose.ui:ui" } compose-ui = { module = "androidx.compose.ui:ui" }
compose-material3 = { module = "androidx.compose.material3:material3" } compose-material3 = { module = "androidx.compose.material3:material3" }
+15 -7
View File
@@ -2284,6 +2284,11 @@
<sha256 value="7220ede61026596fbc720ee4b93246fa2d14f328058532b59ef053de397c7d83" origin="Generated by Gradle"/> <sha256 value="7220ede61026596fbc720ee4b93246fa2d14f328058532b59ef053de397c7d83" origin="Generated by Gradle"/>
</artifact> </artifact>
</component> </component>
<component group="com.google.guava" name="guava-parent" version="33.4.0-jre">
<artifact name="guava-parent-33.4.0-jre.pom">
<sha256 value="3a499ed34a0d9ee0f1bcc39230021a1cd4e2f7dd0426ab6844f585465d41dcd7" origin="Reviewed against Maven Central for Linux ARM64 CI"/>
</artifact>
</component>
<component group="com.google.guava" name="guava-parent" version="33.6.0-jre"> <component group="com.google.guava" name="guava-parent" version="33.6.0-jre">
<artifact name="guava-parent-33.6.0-jre.pom"> <artifact name="guava-parent-33.6.0-jre.pom">
<sha256 value="374bd31f61b1cf612bee9ab2e4d70bbdf77dd85a49b431f809d4fbdc901f2dd4" origin="Generated by Gradle"/> <sha256 value="374bd31f61b1cf612bee9ab2e4d70bbdf77dd85a49b431f809d4fbdc901f2dd4" origin="Generated by Gradle"/>
@@ -3774,6 +3779,11 @@
<sha256 value="4e5d1900e6379ef3f5970d04a8f30529adc82f859e8cc107c21ce8149ef666c4" origin="Generated by Gradle"/> <sha256 value="4e5d1900e6379ef3f5970d04a8f30529adc82f859e8cc107c21ce8149ef666c4" origin="Generated by Gradle"/>
</artifact> </artifact>
</component> </component>
<component group="org.jetbrains.kotlinx" name="kotlinx-coroutines-bom" version="1.8.0">
<artifact name="kotlinx-coroutines-bom-1.8.0.pom">
<sha256 value="1239e9dbe1397cd5971342956b2511bc3ace7b641842e4372a088dcfa8b9ad55" origin="Reviewed against Maven Central for Linux ARM64 CI"/>
</artifact>
</component>
<component group="org.jetbrains.kotlinx" name="kotlinx-coroutines-bom" version="1.8.1"> <component group="org.jetbrains.kotlinx" name="kotlinx-coroutines-bom" version="1.8.1">
<artifact name="kotlinx-coroutines-bom-1.8.1.pom"> <artifact name="kotlinx-coroutines-bom-1.8.1.pom">
<sha256 value="563e4aa29fa8fe09a6e1746d0a5b51308f7c7dedc468dc5a0ca810a485877030" origin="Generated by Gradle"/> <sha256 value="563e4aa29fa8fe09a6e1746d0a5b51308f7c7dedc468dc5a0ca810a485877030" origin="Generated by Gradle"/>
@@ -4114,14 +4124,12 @@
<sha256 value="0fe31326e83b7622cbcd9c75d467b291f01ffe3cc6e3e76651ac05a1c1c7a360" origin="Generated by Gradle"/> <sha256 value="0fe31326e83b7622cbcd9c75d467b291f01ffe3cc6e3e76651ac05a1c1c7a360" origin="Generated by Gradle"/>
</artifact> </artifact>
</component> </component>
<component group="com.google.guava" name="guava-parent" version="33.4.0-jre"> <component group="rustls" name="rustls-platform-verifier" version="0.1.1">
<artifact name="guava-parent-33.4.0-jre.pom"> <artifact name="rustls-platform-verifier-0.1.1.aar">
<sha256 value="3a499ed34a0d9ee0f1bcc39230021a1cd4e2f7dd0426ab6844f585465d41dcd7" origin="Reviewed against Maven Central for Linux ARM64 CI"/> <sha256 value="667292cadd8fa589229dd0f716541236a761f29b774930868d218175633830fd" origin="Generated by Gradle"/>
</artifact> </artifact>
</component> <artifact name="rustls-platform-verifier-0.1.1.pom">
<component group="org.jetbrains.kotlinx" name="kotlinx-coroutines-bom" version="1.8.0"> <sha256 value="5468629ab3793f4768c0527b8ecca25219a00b5d9503d1ad657cc664c87e9081" origin="Generated by Gradle"/>
<artifact name="kotlinx-coroutines-bom-1.8.0.pom">
<sha256 value="1239e9dbe1397cd5971342956b2511bc3ace7b641842e4372a088dcfa8b9ad55" origin="Reviewed against Maven Central for Linux ARM64 CI"/>
</artifact> </artifact>
</component> </component>
</components> </components>
+1
View File
@@ -216,4 +216,5 @@ org.ow2.asm:asm-commons:9.9=androidLintTool
org.ow2.asm:asm-tree:9.9=androidLintTool org.ow2.asm:asm-tree:9.9=androidLintTool
org.ow2.asm:asm:9.9=androidLintTool org.ow2.asm:asm:9.9=androidLintTool
org.slf4j:slf4j-api:2.0.7=ktlint 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 empty=androidApis,androidJdkImage,androidTestUtil,coreLibraryDesugaring,debugAndroidTestAnnotationProcessorClasspath,debugAndroidTestImplementationDependenciesMetadata,debugAnnotationProcessorClasspath,debugImplementationDependenciesMetadata,debugUnitTestAnnotationProcessorClasspath,debugUnitTestImplementationDependenciesMetadata,kotlinCompilerPluginClasspath,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease,lintChecks,lintPublish,releaseAnnotationProcessorClasspath,releaseImplementationDependenciesMetadata
+1
View File
@@ -36,6 +36,7 @@ kotlin {
} }
dependencies { dependencies {
implementation(libs.rustls.verifier)
implementation(variantOf(libs.jna) { artifactType("aar") }) implementation(variantOf(libs.jna) { artifactType("aar") })
implementation(libs.annotation) implementation(libs.annotation)
testImplementation(libs.junit) testImplementation(libs.junit)
+3
View File
@@ -5,3 +5,6 @@
-dontwarn java.awt.GraphicsEnvironment -dontwarn java.awt.GraphicsEnvironment
-dontwarn java.awt.HeadlessException -dontwarn java.awt.HeadlessException
-dontwarn java.awt.Window -dontwarn java.awt.Window
-keep class de.rfc1437.ironstorage.bridge.NativeTls { *; }
-keep, includedescriptorclasses class org.rustls.platformverifier.** { *; }
+1
View File
@@ -152,4 +152,5 @@ org.ow2.asm:asm-commons:9.9=androidLintTool
org.ow2.asm:asm-tree:9.9=androidLintTool org.ow2.asm:asm-tree:9.9=androidLintTool
org.ow2.asm:asm:9.9=androidLintTool org.ow2.asm:asm:9.9=androidLintTool
org.slf4j:slf4j-api:2.0.7=ktlint org.slf4j:slf4j-api:2.0.7=ktlint
rustls:rustls-platform-verifier:0.1.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
empty=androidApis,androidJdkImage,androidTestUtil,coreLibraryDesugaring,debugAndroidTestAnnotationProcessorClasspath,debugAndroidTestImplementationDependenciesMetadata,debugAnnotationProcessorClasspath,debugImplementationDependenciesMetadata,debugUnitTestAnnotationProcessorClasspath,debugUnitTestImplementationDependenciesMetadata,kotlinCompilerPluginClasspath,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease,lintChecks,lintPublish,releaseAnnotationProcessorClasspath,releaseImplementationDependenciesMetadata empty=androidApis,androidJdkImage,androidTestUtil,coreLibraryDesugaring,debugAndroidTestAnnotationProcessorClasspath,debugAndroidTestImplementationDependenciesMetadata,debugAnnotationProcessorClasspath,debugImplementationDependenciesMetadata,debugUnitTestAnnotationProcessorClasspath,debugUnitTestImplementationDependenciesMetadata,kotlinCompilerPluginClasspath,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease,lintChecks,lintPublish,releaseAnnotationProcessorClasspath,releaseImplementationDependenciesMetadata
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
</manifest>
@@ -0,0 +1,160 @@
package de.rfc1437.ironstorage.bridge
import androidx.test.platform.app.InstrumentationRegistry
import de.rfc1437.ironstorage.bridge.generated.MobileOnboardingErrorKind
import de.rfc1437.ironstorage.bridge.generated.MobileOnboardingFfiException
import de.rfc1437.ironstorage.bridge.generated.MobilePrivateDirectories
import de.rfc1437.ironstorage.bridge.generated.MobileSecretBackend
import de.rfc1437.ironstorage.bridge.generated.MobileSecretException
import de.rfc1437.ironstorage.bridge.generated.MobileSecretLocator
import de.rfc1437.ironstorage.bridge.generated.MobileSecretProtection
import java.io.File
import java.util.UUID
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Assume.assumeTrue
import org.junit.Test
class AndroidOnboardingTest {
@Test fun realPublicHttpsDiscoveryAndConnectionUseThePlatformVerifier() {
assumeTrue(
InstrumentationRegistry.getArguments().getString("ironstorage_https_integration") ==
"true"
)
val context = InstrumentationRegistry.getInstrumentation().targetContext
val id = "https-onboarding-${UUID.randomUUID()}"
val directories = MobilePrivateDirectories(
File(context.filesDir, id).apply { mkdirs() }.canonicalPath,
File(context.noBackupFilesDir, id).apply { mkdirs() }.canonicalPath,
File(context.cacheDir, id).apply { mkdirs() }.canonicalPath
)
val backend = MemoryBackend()
NativeRuntime(context).privateStorage(directories).use { runtime ->
runtime.androidOnboarding(backend).use { setup ->
setup.remote(
"https://github.com",
"public-fixture",
"octocat/Hello-World",
"public-test-token"
).use { remote ->
assertTrue(remote.discover().branches.isNotEmpty())
}
assertFalse(setup.status().configured)
val key =
requireNotNull(
setup.generate(
"HTTPS fixture <fixture@example.test>",
"public-test-passphrase"
).selectedKey
)
setup.setupLocal(key, "public-test-passphrase")
setup.remote(
"https://github.com",
"public-fixture",
"octocat/Hello-World",
"public-test-token"
).use {
it.connect()
}
assertTrue(setup.status().connected)
setup.replaceToken("public-fixture", "public-replacement-token")
assertEquals(2, backend.records.size)
val config = File(runtime.layout().configuration).readText()
assertFalse(config.contains("public-test-token"))
assertFalse(config.contains("public-replacement-token"))
}
}
}
@Test fun realRustSetupSurvivesRecreationAndKeepsTokensOutOfConfiguration() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val id = "onboarding-${UUID.randomUUID()}"
val directories = MobilePrivateDirectories(
File(context.filesDir, id).apply { mkdirs() }.canonicalPath,
File(context.noBackupFilesDir, id).apply { mkdirs() }.canonicalPath,
File(context.cacheDir, id).apply { mkdirs() }.canonicalPath
)
val native = NativeRuntime(context)
val backend = MemoryBackend()
native.privateStorage(directories).use { runtime ->
runtime.androidOnboarding(backend).use { setup ->
assertFalse(setup.status().configured)
val prepared = setup.generate(
"Emulator fixture <fixture@example.test>",
"public-test-passphrase"
)
assertEquals(1, prepared.keys.size)
assertFalse(prepared.configured)
}
}
native.privateStorage(directories).use { runtime ->
runtime.androidOnboarding(backend).use { setup ->
val key = requireNotNull(setup.status().selectedKey)
setup.setupLocal(key, "public-test-passphrase")
assertTrue(setup.status().configured)
assertFalse(setup.status().connected)
val unsupported = assertThrows(MobileOnboardingFfiException.Failed::class.java) {
setup.remote(
"ssh://example.test",
"alice",
"team/passwords",
"public-test-token"
)
}
assertEquals(MobileOnboardingErrorKind.UNSUPPORTED_REMOTE, unsupported.kind)
setup.remote(
"https://example.test",
"alice",
"team/passwords",
"public-test-token"
).use {
assertThrows(MobileOnboardingFfiException.Failed::class.java) { it.connect() }
}
assertFalse(setup.status().connected)
val config = File(runtime.layout().configuration).readText()
assertFalse(config.contains("public-test-passphrase"))
assertFalse(config.contains("public-test-token"))
assertFalse(config.contains("public-replacement-token"))
assertTrue(File(runtime.layout().vault, ".gpg-id").readText().contains(key))
assertEquals(1, backend.records.size)
}
}
native.privateStorage(directories).use { runtime ->
runtime.androidOnboarding(backend).use { assertTrue(it.status().configured) }
}
}
private class MemoryBackend : MobileSecretBackend {
val records = mutableMapOf<MobileSecretLocator, ByteArray>()
override fun create(
locator: MobileSecretLocator,
protection: MobileSecretProtection,
value: ByteArray
) {
if (records.containsKey(locator)) throw MobileSecretException.AlreadyExists()
records[locator] = value.copyOf()
value.fill(0)
}
override fun retrieve(
locator: MobileSecretLocator,
protection: MobileSecretProtection
): ByteArray = records[locator]?.copyOf() ?: throw MobileSecretException.Missing()
override fun replace(
locator: MobileSecretLocator,
protection: MobileSecretProtection,
value: ByteArray
) {
if (!records.containsKey(locator)) throw MobileSecretException.Missing()
records[locator]?.fill(0)
records[locator] = value.copyOf()
value.fill(0)
}
override fun delete(locator: MobileSecretLocator, protection: MobileSecretProtection) {
(records.remove(locator) ?: throw MobileSecretException.Missing()).fill(0)
}
override fun lock() = Unit
override fun unlock() = Unit
}
}
@@ -18,7 +18,8 @@ import java.util.zip.ZipFile
/** OS library loading only. Callers schedule all native calls off the main thread. */ /** OS library loading only. Callers schedule all native calls off the main thread. */
class NativeRuntime(context: Context) { class NativeRuntime(context: Context) {
private val applicationInfo = context.applicationContext.applicationInfo private val applicationContext = context.applicationContext
private val applicationInfo = applicationContext.applicationInfo
private val lock = Any() private val lock = Any()
private var initialized = false private var initialized = false
private var initializationFailure: NativeFailureKind? = null private var initializationFailure: NativeFailureKind? = null
@@ -45,6 +46,7 @@ class NativeRuntime(context: Context) {
} }
if (!packaged) throw NativeBridgeException(NativeFailureKind.MissingLibrary) if (!packaged) throw NativeBridgeException(NativeFailureKind.MissingLibrary)
System.loadLibrary(LIBRARY) System.loadLibrary(LIBRARY)
NativeTls.initialize(applicationContext)
uniffiEnsureInitialized() uniffiEnsureInitialized()
initialized = true initialized = true
} }
@@ -0,0 +1,6 @@
package de.rfc1437.ironstorage.bridge
/** Pass the OS Application to the native verifier; no trust decisions live here. */
internal object NativeTls {
external fun initialize(context: Any)
}
+22
View File
@@ -1,3 +1,6 @@
import groovy.json.JsonSlurper
import java.io.File
pluginManagement { pluginManagement {
repositories { repositories {
google() google()
@@ -10,6 +13,25 @@ dependencyResolutionManagement {
repositories { repositories {
google() google()
mavenCentral() mavenCentral()
// The official verifier JVM support is shipped in the checksum-pinned Cargo crate.
val cargo = providers.environmentVariable("CARGO").orElse(
providers.environmentVariable("PATH").map { path ->
path.split(File.pathSeparatorChar).map { File(it, "cargo") }
.firstOrNull { it.isFile && it.canExecute() }?.absolutePath
?: error("Cargo is required for the Android TLS verifier")
}
)
val metadata = providers.exec {
workingDir = file("..")
commandLine(cargo.get(), "+1.97.1", "metadata", "--locked", "--offline", "--format-version", "1", "--filter-platform", "aarch64-linux-android")
}.standardOutput.asText
val packages = (JsonSlurper().parseText(metadata.get()) as Map<*, *>)["packages"] as List<*>
val verifier = packages.map { it as Map<*, *> }.single { it["name"] == "rustls-platform-verifier-android" }
check(verifier["version"] == "0.1.1") { "Update the verifier catalog pin with its Cargo component" }
maven {
url = uri(File(verifier["manifest_path"] as String).parentFile.resolve("maven"))
content { includeModule("rustls", "rustls-platform-verifier") }
}
} }
} }
rootProject.name = "IronStorage" rootProject.name = "IronStorage"
+4
View File
@@ -14,3 +14,7 @@ crate-type = ["lib", "cdylib"]
[dependencies] [dependencies]
ironstorage-mobile = { path = "../mobile-bridge" } ironstorage-mobile = { path = "../mobile-bridge" }
[target.'cfg(target_os = "android")'.dependencies]
jni = "=0.22.4"
rustls-platform-verifier = "=0.7.0"
+3
View File
@@ -4,3 +4,6 @@
//! The shared component owns every conversion; no interface is copied here. //! The shared component owns every conversion; no interface is copied here.
ironstorage_mobile::uniffi_reexport_scaffolding!(); ironstorage_mobile::uniffi_reexport_scaffolding!();
#[cfg(target_os = "android")]
mod tls;
+17
View File
@@ -0,0 +1,17 @@
//! OS certificate-verifier initialization only; Git/TLS policy stays in storage.
//! The JNI macro supplies a scoped valid environment and catches boundary panics;
//! rustls retains global Context/class-loader references before this scope ends.
use jni::{Env, objects::JObject};
const _: jni::NativeMethod = jni::native_method! {
java_type = "de.rfc1437.ironstorage.bridge.NativeTls",
extern fn initialize(context: JObject),
};
fn initialize<'local>(
env: &mut Env<'local>,
_this: JObject<'local>,
context: JObject<'local>,
) -> Result<(), jni::errors::Error> {
rustls_platform_verifier::android::init_with_env(env, context)
}
@@ -0,0 +1,187 @@
//! Mechanical Android onboarding exports; all setup decisions remain in storage.
use crate::{
MobileOnboardingDiscovery, MobileOnboardingFfiError, MobileOnboardingOutcome,
MobileOnboardingProgress, MobilePrivateRuntime, MobileSecretBackend,
platform::SecretBackendAdapter,
};
use ironstorage::{
mobile_onboarding::{
MobileOnboardingRequest,
android::{AndroidOnboarding, AndroidOnboardingStatus},
},
repository::SecretBytes,
};
use std::sync::Arc;
#[derive(Clone, uniffi::Record)]
pub struct MobileAndroidSetupKey {
pub fingerprint: String,
pub user_ids: Vec<String>,
}
#[derive(Clone, uniffi::Record)]
pub struct MobileAndroidSetupStatus {
pub configured: bool,
pub connected: bool,
pub existing_clone: bool,
pub keys: Vec<MobileAndroidSetupKey>,
pub selected_key: Option<String>,
pub title: String,
pub detail: String,
}
impl From<AndroidOnboardingStatus> for MobileAndroidSetupStatus {
fn from(status: AndroidOnboardingStatus) -> Self {
Self {
title: status.title,
detail: status.detail,
configured: status.configured,
connected: status.connected,
existing_clone: status.existing_clone,
selected_key: status.selected_key,
keys: status
.keys
.into_iter()
.map(|key| MobileAndroidSetupKey {
fingerprint: key.fingerprint().as_str().into(),
user_ids: key.user_ids().to_vec(),
})
.collect(),
}
}
}
#[derive(uniffi::Object)]
pub struct MobileAndroidOnboarding {
setup: Arc<AndroidOnboarding>,
}
impl MobileAndroidOnboarding {
pub(crate) fn new(
runtime: &MobilePrivateRuntime,
backend: Arc<dyn MobileSecretBackend>,
) -> Result<Arc<Self>, MobileOnboardingFfiError> {
Ok(Arc::new(Self {
setup: Arc::new(AndroidOnboarding::new(
&runtime.storage,
Arc::new(SecretBackendAdapter(backend)),
)?),
}))
}
}
fn secret(value: Option<String>) -> Option<SecretBytes> {
value.map(|value| SecretBytes::new(value.into_bytes()))
}
fn outcome(
value: ironstorage::mobile_onboarding::MobileOnboardingOutcome,
) -> MobileOnboardingOutcome {
MobileOnboardingOutcome {
title: value.title().into(),
detail: value.detail().into(),
}
}
#[uniffi::export]
impl MobileAndroidOnboarding {
pub fn status(&self) -> Result<MobileAndroidSetupStatus, MobileOnboardingFfiError> {
Ok(self.setup.status()?.into())
}
pub fn generate(
&self,
user_id: String,
passphrase: String,
) -> Result<MobileAndroidSetupStatus, MobileOnboardingFfiError> {
Ok(self
.setup
.generate(&user_id, SecretBytes::new(passphrase.into_bytes()))?
.into())
}
pub fn import_key(
&self,
armor: String,
passphrase: Option<String>,
) -> Result<MobileAndroidSetupStatus, MobileOnboardingFfiError> {
Ok(self
.setup
.import(SecretBytes::new(armor.into_bytes()), secret(passphrase))?
.into())
}
pub fn setup_local(
&self,
fingerprint: String,
passphrase: Option<String>,
) -> Result<MobileOnboardingOutcome, MobileOnboardingFfiError> {
Ok(outcome(
self.setup.setup_local(&fingerprint, secret(passphrase))?,
))
}
pub fn remote(
&self,
server_url: String,
account: String,
repository_path: String,
application_token: String,
) -> Result<Arc<MobileAndroidRemoteOnboarding>, MobileOnboardingFfiError> {
Ok(Arc::new(MobileAndroidRemoteOnboarding {
setup: Arc::clone(&self.setup),
request: MobileOnboardingRequest::new(
server_url,
account,
repository_path,
application_token.into_bytes(),
)?,
}))
}
pub fn replace_token(
&self,
account: String,
application_token: String,
) -> Result<(), MobileOnboardingFfiError> {
Ok(self
.setup
.replace_token(account, SecretBytes::new(application_token.into_bytes()))?)
}
pub fn cancel(&self) {
self.setup.cancel();
}
pub fn progress(&self) -> MobileOnboardingProgress {
let progress = self.setup.progress();
MobileOnboardingProgress {
phase: progress.phase().into(),
title: progress.title().into(),
detail: progress.detail().into(),
}
}
}
#[derive(uniffi::Object)]
pub struct MobileAndroidRemoteOnboarding {
setup: Arc<AndroidOnboarding>,
request: MobileOnboardingRequest,
}
#[uniffi::export]
impl MobileAndroidRemoteOnboarding {
pub fn discover(&self) -> Result<MobileOnboardingDiscovery, MobileOnboardingFfiError> {
let value = self.setup.discover(&self.request)?;
Ok(MobileOnboardingDiscovery {
branches: value.branches().to_vec(),
selected_branch: value.selected_branch() as u32,
})
}
pub fn clone_store(
&self,
branch: String,
fingerprint: String,
passphrase: Option<String>,
use_existing: bool,
) -> Result<MobileOnboardingOutcome, MobileOnboardingFfiError> {
Ok(outcome(self.setup.clone_store(
&self.request,
&branch,
&fingerprint,
secret(passphrase),
use_existing,
)?))
}
pub fn connect(&self) -> Result<MobileOnboardingOutcome, MobileOnboardingFfiError> {
Ok(outcome(self.setup.connect(&self.request)?))
}
pub fn cancel(&self) {
self.setup.cancel();
}
}
+2
View File
@@ -81,6 +81,8 @@ uniffi::setup_scaffolding!();
mod android_keystore; mod android_keystore;
pub use android_keystore::*; pub use android_keystore::*;
mod android_onboarding;
pub use android_onboarding::*;
mod platform; mod platform;
pub use platform::*; pub use platform::*;
+7 -1
View File
@@ -96,10 +96,16 @@ pub struct MobilePrivateStorageLayout {
#[derive(uniffi::Object)] #[derive(uniffi::Object)]
pub struct MobilePrivateRuntime { pub struct MobilePrivateRuntime {
storage: PrivateStorage, pub(crate) storage: PrivateStorage,
} }
#[uniffi::export] #[uniffi::export]
impl MobilePrivateRuntime { impl MobilePrivateRuntime {
pub fn android_onboarding(
&self,
backend: Arc<dyn MobileSecretBackend>,
) -> Result<Arc<crate::MobileAndroidOnboarding>, crate::MobileOnboardingFfiError> {
crate::MobileAndroidOnboarding::new(self, backend)
}
pub fn android_secret_backend( pub fn android_secret_backend(
&self, &self,
keystore: Arc<dyn crate::MobileAndroidKeystore>, keystore: Arc<dyn crate::MobileAndroidKeystore>,
+25
View File
@@ -266,6 +266,31 @@ impl KeyStore {
Ok(store) Ok(store)
} }
/// Merge prepared key material while retaining existing private keys.
pub(crate) fn extend_from_path(&mut self, path: &Path) -> Result<(), CryptoError> {
let loaded = match Self::load(path) {
Ok(loaded) => loaded,
Err(CryptoError::NoKeyMaterial) => return Ok(()),
Err(error) => return Err(error),
};
if self.keys.len() + loaded.keys.len() > MAX_IMPORTED_KEYS {
return Err(CryptoError::TooManyKeys);
}
for (fingerprint, material) in loaded.keys {
if let Some(existing) = self.keys.get_mut(&fingerprint) {
if existing.public.primary_key != material.public.primary_key {
return Err(CryptoError::DuplicateFingerprint { fingerprint });
}
if existing.secret.is_none() {
existing.secret = material.secret;
}
} else {
self.keys.insert(fingerprint, material);
}
}
Ok(())
}
/// Transactionally import one armored or binary stream containing public and/or secret keys. /// Transactionally import one armored or binary stream containing public and/or secret keys.
pub fn import(&mut self, bytes: &[u8]) -> Result<Vec<KeyInfo>, CryptoError> { pub fn import(&mut self, bytes: &[u8]) -> Result<Vec<KeyInfo>, CryptoError> {
if bytes.len() as u64 > MAX_KEY_FILE_BYTES { if bytes.len() as u64 > MAX_KEY_FILE_BYTES {
+44 -5
View File
@@ -1466,6 +1466,50 @@ impl GitRepository {
credentials: &impl GitRemoteCredentialProvider, credentials: &impl GitRemoteCredentialProvider,
transport: &impl GitFetchTransport, transport: &impl GitFetchTransport,
control: &GitOperationControl, control: &GitOperationControl,
) -> Result<Vec<String>, GitError> {
let branches = Self::probe_remote_with_transport(
parent,
identity,
configured,
credentials,
transport,
control,
)?;
if branches.is_empty() {
return Err(GitError::InvalidRepository(
"the remote has no branches".to_owned(),
));
}
Ok(branches)
}
/// Validate HTTPS authentication before saving onboarding state, including an empty remote.
pub(crate) fn validate_remote_with_transport(
parent: &Path,
identity: GitIdentity,
configured: &GitRemote,
credentials: &impl GitRemoteCredentialProvider,
transport: &impl GitFetchTransport,
control: &GitOperationControl,
) -> Result<(), GitError> {
Self::probe_remote_with_transport(
parent,
identity,
configured,
credentials,
transport,
control,
)
.map(|_| ())
}
fn probe_remote_with_transport(
parent: &Path,
identity: GitIdentity,
configured: &GitRemote,
credentials: &impl GitRemoteCredentialProvider,
transport: &impl GitFetchTransport,
control: &GitOperationControl,
) -> Result<Vec<String>, GitError> { ) -> Result<Vec<String>, GitError> {
control.report(GitProgressPhase::Validating)?; control.report(GitProgressPhase::Validating)?;
ensure_remote_transport_available(configured.endpoint())?; ensure_remote_transport_available(configured.endpoint())?;
@@ -1648,11 +1692,6 @@ impl GitRepository {
.collect::<Result<Vec<_>, GitError>>()?; .collect::<Result<Vec<_>, GitError>>()?;
branches.sort(); branches.sort();
branches.dedup(); branches.dedup();
if branches.is_empty() {
return Err(GitError::InvalidRepository(
"the remote has no branches".to_owned(),
));
}
Ok(branches) Ok(branches)
} }
+2
View File
@@ -1,5 +1,7 @@
//! Storage-owned first-run setup for the native mobile applications. //! Storage-owned first-run setup for the native mobile applications.
pub mod android;
use std::{ use std::{
error::Error, error::Error,
fmt, fs, fmt, fs,
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -532,7 +532,7 @@ fn regular_bytes(
} }
Ok(Some(bytes)) Ok(Some(bytes))
} }
fn exclusive_lock(parent: &Dir, name: &str) -> Result<fs::File, PrivateStorageError> { pub(crate) fn exclusive_lock(parent: &Dir, name: &str) -> Result<fs::File, PrivateStorageError> {
if let Ok(metadata) = parent.symlink_metadata(name) if let Ok(metadata) = parent.symlink_metadata(name)
&& (metadata.file_type().is_symlink() || !metadata.is_file()) && (metadata.file_type().is_symlink() || !metadata.is_file())
{ {
+38 -2
View File
@@ -1010,6 +1010,32 @@ struct SecretRecord {
fn encode_record( fn encode_record(
reference: &SecretReference, reference: &SecretReference,
value: &SecretBytes, value: &SecretBytes,
) -> Result<SecretBytes, SecretStoreError> {
encode_record_with_owner(reference, value, None)
}
/// A unique protected owner distinguishes newly created credentials during crash rollback.
pub(crate) fn encode_onboarding_record(
reference: &SecretReference,
value: &SecretBytes,
owner: &[u8],
) -> Result<SecretBytes, SecretStoreError> {
if owner.is_empty()
|| owner.len() > 64
|| !matches!(
reference.kind,
SecretReferenceKind::HttpsGitCredential { .. }
)
{
return Err(SecretStoreError::InvalidReference);
}
encode_record_with_owner(reference, value, Some(owner))
}
fn encode_record_with_owner(
reference: &SecretReference,
value: &SecretBytes,
owner: Option<&[u8]>,
) -> Result<SecretBytes, SecretStoreError> { ) -> Result<SecretBytes, SecretStoreError> {
validate_secret(value)?; validate_secret(value)?;
let mut encoded = Vec::with_capacity(RECORD_MAGIC.len() + value.expose().len() + 1024); let mut encoded = Vec::with_capacity(RECORD_MAGIC.len() + value.expose().len() + 1024);
@@ -1025,7 +1051,10 @@ fn encode_record(
application_id, application_id,
account, account,
} => { } => {
encoded.push(2); encoded.push(if owner.is_some() { 4 } else { 2 });
if let Some(owner) = owner {
write_field(&mut encoded, owner)?;
}
write_field(&mut encoded, server_id.as_bytes())?; write_field(&mut encoded, server_id.as_bytes())?;
write_field(&mut encoded, application_id.as_bytes())?; write_field(&mut encoded, application_id.as_bytes())?;
write_field(&mut encoded, account.as_bytes())?; write_field(&mut encoded, account.as_bytes())?;
@@ -1063,7 +1092,14 @@ fn decode_record(encoded: SecretBytes) -> Result<SecretRecord, SecretStoreError>
SecretReference::openpgp_passphrase(read_text(fingerprint)?) SecretReference::openpgp_passphrase(read_text(fingerprint)?)
.map_err(|_| SecretStoreError::Corrupted)? .map_err(|_| SecretStoreError::Corrupted)?
} }
2 => { 2 | 4 => {
if kind == 4 {
let (owner, rest) = read_field(remainder)?;
if owner.is_empty() || owner.len() > 64 {
return Err(SecretStoreError::Corrupted);
}
remainder = rest;
}
let (server_id, rest) = read_field(remainder)?; let (server_id, rest) = read_field(remainder)?;
let (application_id, rest) = read_field(rest)?; let (application_id, rest) = read_field(rest)?;
let (account, rest) = read_field(rest)?; let (account, rest) = read_field(rest)?;
+69
View File
@@ -0,0 +1,69 @@
# Android onboarding (#95)
Compose collects inputs and renders the typed Rust setup state. `NativePrivateSession`
shares one private runtime and Android Keystore callback between the shell and setup.
Blocking calls and progress polling run on IO workers. Internet access is allowed;
cleartext connections remain disabled. The storage API rejects every non-HTTPS
onboarding endpoint and credential-bearing URL before opening transport.
Native loading initializes Rust's Android platform certificate verifier with the
application Context before HTTPS can run. Its official JVM support comes from the
checksum-pinned Cargo component, is verified and locked by Gradle, and is retained
by Release shrinking. Certificate validation uses the operating system trust store.
Local setup generates or imports a private GPG key, verifies its passphrase, creates
Git and the upstream `.gpg-id`, and commits the configuration. No server, camera,
or biometric permission is needed. Encrypted prepared keys survive process death;
passphrases and tokens remain in memory only. Form secrets survive rotation through
a ViewModel and are excluded from saved-state Bundles. After process death transport
and passphrase inputs must be entered again.
HTTPS setup discovers branches with a temporary Rust repository, selects the branch,
and checks the selected private key against the cloned root recipient policy. Reusing
an existing clone requires the same authoritative origin and current branch and
preserves its contents. Adding a remote to a local store validates network/TLS/token
access before saving anything, and accepts an authenticated empty remote.
Setup serializes against the private runtime lock. Its private transaction journal
contains only paths, credential identities and an ownership identifier. Replacement
vault/key directories are synced before installation; the configuration is the commit
point. Recovery restores previous directories and removes only a newly created origin
and a credential matching the protected ownership record. Missing or corrupt journals
with backups retain those backups and report recovery. Cancellation before the commit
boundary discards staging; once installation starts, the transaction finishes or rolls
back. Existing data and credentials are never replaced by initial setup.
Tokens and the temporary credential ownership record use the injected Android
Keystore backend. Explicit token replacement preserves the configured identities and
never returns the previous token to Kotlin. Neither Git URLs nor configuration files
contain credentials. No live repository is opened through SAF and no application
component launches a subprocess.
Rust regression fixtures cover pass-compatible encrypted entries, process restart,
authentication/TLS/network/cancellation failures, mismatched recipients, existing
clones/remotes/tokens, protected-store failure, interrupted installation, credential
ownership and low-storage recovery. Emulator tests exercise the generated native API
and staged Compose forms; runtime and packaging checks use the three documented ARM
emulator configurations. Real private-server access still depends on supplied account
credentials; synthetic fixtures establish failure and rollback contracts independently.
## Validation (2026-10-09)
The required Rust formatting, warnings-as-errors workspace check, Clippy and all
workspace tests passed (459 tests). Android verification passed lint, JVM tests,
Kotlin formatting, module boundaries, both ARM ABI builds, Debug/Release APK and
Release AAB audits, 16 KB alignment, UniFFI parity, backup/manifest checks and
dependency/license inventories. Instrumentation and five cold starts passed on
ARM64 API 26/4 KB, API 37/4 KB and API 37/strict 16 KB emulators. ARMv7 was
built and audited; a compatible ARMv7 emulator image was not available.
Computer use verified blank generation rejection, manual import availability,
key selection and masked passphrase retention through rotation, successful
encrypted local-store creation, and the optional HTTPS action in Preferences.
The user entered the disposable GPG passphrase directly into the emulator.
No private-server credential was requested or used for this validation; native
transport fixtures cover clone success, token replacement and rollback boundaries.
An opt-in native integration test also fetched branches from the public
`octocat/Hello-World` HTTPS repository, connected an isolated local store and replaced
its synthetic token on all three emulators. Run that test with instrumentation
argument `ironstorage_https_integration=true`; routine CI remains network independent.
+9 -1
View File
@@ -59,6 +59,7 @@ fn manifest_text(source: &str) -> Result<(), Box<dyn Error>> {
"android.permission.USE_BIOMETRIC", "android.permission.USE_BIOMETRIC",
"android.permission.USE_FINGERPRINT", "android.permission.USE_FINGERPRINT",
"de.rfc1437.ironstorage.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION", "de.rfc1437.ironstorage.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION",
"android.permission.INTERNET",
]; ];
for node in root.children().filter(|node| node.is_element()) { for node in root.children().filter(|node| node.is_element()) {
if node.tag_name().name().starts_with("uses-permission") if node.tag_name().name().starts_with("uses-permission")
@@ -440,6 +441,13 @@ mod tests {
r#"<manifest xmlns:android="{ANDROID}" package="de.rfc1437.ironstorage"><application android:allowBackup="false" android:usesCleartextTraffic="false" android:fullBackupContent="@xml/backup_rules" android:dataExtractionRules="@xml/data_extraction_rules"><activity android:name="de.rfc1437.ironstorage.MainActivity" android:exported="true"><intent-filter><action android:name="android.intent.action.MAIN"/><category android:name="android.intent.category.LAUNCHER"/></intent-filter></activity><provider android:name="fixture" android:exported="false"/></application></manifest>"# r#"<manifest xmlns:android="{ANDROID}" package="de.rfc1437.ironstorage"><application android:allowBackup="false" android:usesCleartextTraffic="false" android:fullBackupContent="@xml/backup_rules" android:dataExtractionRules="@xml/data_extraction_rules"><activity android:name="de.rfc1437.ironstorage.MainActivity" android:exported="true"><intent-filter><action android:name="android.intent.action.MAIN"/><category android:name="android.intent.category.LAUNCHER"/></intent-filter></activity><provider android:name="fixture" android:exported="false"/></application></manifest>"#
); );
assert!(manifest_text(&source).is_ok()); assert!(manifest_text(&source).is_ok());
assert!(
manifest_text(&source.replace(
"<application",
"<uses-permission android:name=\"android.permission.INTERNET\"/><application"
))
.is_ok()
);
for bad in [ for bad in [
source.replace("allowBackup=\"false\"", "allowBackup=\"true\""), source.replace("allowBackup=\"false\"", "allowBackup=\"true\""),
source.replace( source.replace(
@@ -448,7 +456,7 @@ mod tests {
), ),
source.replace( source.replace(
"<application", "<application",
"<uses-permission android:name=\"android.permission.INTERNET\"/><application", "<uses-permission android:name=\"android.permission.CAMERA\"/><application",
), ),
] { ] {
assert!(manifest_text(&bad).is_err()); assert!(manifest_text(&bad).is_err());