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

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

No files matched your search

+6 -5
View File
@@ -1,10 +1,10 @@
# Native Android build
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
UniFFI API. Onboarding and the feature screens remain owned by their milestone
issues. The full [CI and emulator workflow](../docs/android/ci.md) documents the
UniFFI API. Local-first and HTTPS onboarding is described in
[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.
## Reproducible setup
@@ -54,8 +54,9 @@ code cannot conceal a domain implementation.
Debug controls invoke the storage-owned MobileShell::fixture through the native
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
debug controls; repository initialization is owned by #90. Feature code consumes
used only as the test oracle. Release loads the real Rust private-storage state without debug controls.
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.
## 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.1=composeMappingProducerClasspath
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
@@ -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.ShellRepository
import de.rfc1437.ironstorage.core.ShellState
import de.rfc1437.ironstorage.features.SetupFormViewModel
import de.rfc1437.ironstorage.features.ShellScreen
import de.rfc1437.ironstorage.features.ShellViewModel
@Composable
fun VariantContent(viewModel: ShellViewModel, repository: ShellRepository) {
fun VariantContent(
viewModel: ShellViewModel,
repository: ShellRepository,
setupForm: SetupFormViewModel
) {
Column(Modifier.statusBarsPadding()) {
Row(Modifier.horizontalScroll(rememberScrollState())) {
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">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<application
android:name=".IronStorageApplication"
@@ -8,6 +8,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.fragment.app.FragmentActivity
import dagger.hilt.android.AndroidEntryPoint
import de.rfc1437.ironstorage.core.ShellRepository
import de.rfc1437.ironstorage.features.SetupFormViewModel
import de.rfc1437.ironstorage.features.ShellViewModel
import de.rfc1437.ironstorage.platform.AndroidKeystoreAdapter
import javax.inject.Inject
@@ -18,6 +19,8 @@ class MainActivity : FragmentActivity() {
@Inject lateinit var repository: ShellRepository
private val setupForm: SetupFormViewModel by viewModels()
private val viewModel: ShellViewModel by viewModels()
override fun onDestroy() {
@@ -31,7 +34,7 @@ class MainActivity : FragmentActivity() {
enableEdgeToEdge()
setContent {
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.components.SingletonComponent
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.platform.AndroidKeystoreAdapter
import de.rfc1437.ironstorage.platform.privateDirectories
@@ -18,6 +21,25 @@ import kotlinx.coroutines.SupervisorJob
@Module
@InstallIn(SingletonComponent::class)
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
@Singleton
fun keystore(@ApplicationContext context: Context): AndroidKeystoreAdapter =
@@ -25,13 +47,9 @@ object ShellModule {
@Provides
@Singleton
fun shellRepository(
@ApplicationContext context: Context,
keystore: AndroidKeystoreAdapter
): ShellRepository = NativeFixtureShellRepository(
context,
CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate),
{ privateDirectories(context) },
keystore
)
fun shellRepository(session: NativePrivateSession): ShellRepository =
NativeFixtureShellRepository(
session,
CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
)
}
@@ -2,11 +2,16 @@ package de.rfc1437.ironstorage
import androidx.compose.runtime.Composable
import de.rfc1437.ironstorage.core.ShellRepository
import de.rfc1437.ironstorage.features.SetupFormViewModel
import de.rfc1437.ironstorage.features.ShellScreen
import de.rfc1437.ironstorage.features.ShellViewModel
@Composable
@Suppress("UNUSED_PARAMETER")
fun VariantContent(viewModel: ShellViewModel, repository: ShellRepository) {
ShellScreen(viewModel)
fun VariantContent(
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: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
@@ -1,12 +1,8 @@
package de.rfc1437.ironstorage.core
import android.content.Context
import de.rfc1437.ironstorage.bridge.NativeBridgeException
import de.rfc1437.ironstorage.bridge.NativeFailureKind
import de.rfc1437.ironstorage.bridge.NativeRuntime
import de.rfc1437.ironstorage.bridge.generated.MobileIcon
import de.rfc1437.ironstorage.bridge.generated.MobilePrivateDirectories
import de.rfc1437.ironstorage.bridge.generated.MobilePrivateRuntime
import de.rfc1437.ironstorage.bridge.generated.MobilePrivateStorageErrorKind
import de.rfc1437.ironstorage.bridge.generated.MobilePrivateStorageFfiException
import de.rfc1437.ironstorage.bridge.generated.MobileShellState
@@ -25,22 +21,21 @@ import kotlinx.coroutines.withContext
/** Rust-owned private initialization; debug controls can switch to explicit fixtures. */
class NativeFixtureShellRepository internal constructor(
private val scope: CoroutineScope,
private val loadCurrent: (suspend () -> ShellSnapshot)? = null,
private val load: suspend (ShellState) -> ShellSnapshot
) : ShellRepository {
constructor(
context: Context,
scope: CoroutineScope,
directories: () -> PrivateDirectories,
keystore: AndroidKeystore
) :
this(scope, nativeLoader(context, directories, keystore))
constructor(session: NativePrivateSession, scope: CoroutineScope) : this(
scope,
{ nativeLoader(session, true)(ShellState.Empty) },
nativeLoader(session)
)
private val mutableSnapshot = MutableStateFlow(ShellSnapshot(ShellState.Loading, emptyList()))
override val snapshot: StateFlow<ShellSnapshot> = mutableSnapshot
private val revision = AtomicLong()
private var job: Job? = null
init {
showFixture(ShellState.Empty)
refresh()
}
override fun selectTab(tab: ShellTab) {
@@ -48,17 +43,26 @@ class NativeFixtureShellRepository internal constructor(
}
fun showFixture(state: ShellState) {
update(true) { load(state) }
}
override fun refresh() {
update(false, loadCurrent ?: { load(ShellState.Empty) })
}
private fun update(fixture: Boolean, loader: suspend () -> ShellSnapshot) {
val operation = revision.incrementAndGet()
job?.cancel()
job = scope.launch {
val result = try {
load(state)
loader()
} catch (error: NativeBridgeException) {
ShellSnapshot(ShellState.Error, emptyList(), nativeFailure = error.kind.toFacade())
}
if (revision.get() == operation) {
mutableSnapshot.update {
result.copy(
fixture = fixture,
selectedTab = if (it.pages.isEmpty()) result.selectedTab else it.selectedTab
)
}
@@ -68,31 +72,16 @@ class NativeFixtureShellRepository internal constructor(
}
private fun nativeLoader(
context: Context,
directoryProvider: () -> PrivateDirectories,
keystore: AndroidKeystore
session: NativePrivateSession,
current: Boolean = false
): suspend (ShellState) -> ShellSnapshot {
val runtime = NativeRuntime(context)
var storage: MobilePrivateRuntime? = null
var firstLoad = true
val runtime = session.runtime
return { state ->
withContext(Dispatchers.IO) {
var recovery: StorageRecovery? = null
val shell = if (firstLoad) {
firstLoad = false
val shell = if (current) {
try {
val directories = directoryProvider()
val initialized = storage ?: runtime.privateStorage(
MobilePrivateDirectories(
directories.files,
directories.noBackup,
directories.cache
)
)
storage = initialized
initialized.activate()
val secrets = initialized.androidSecretBackend(KeystoreCallback(keystore))
initialized.verifySecureStorage(secrets)
val initialized = session.storage()
recovery = initialized.status().recovery?.toFacade()
initialized.shell()
} catch (error: MobilePrivateStorageFfiException.Failed) {
@@ -0,0 +1,208 @@
package de.rfc1437.ironstorage.core
import de.rfc1437.ironstorage.bridge.NativeBridgeException
import de.rfc1437.ironstorage.bridge.generated.MobileAndroidOnboarding
import de.rfc1437.ironstorage.bridge.generated.MobileAndroidRemoteOnboarding
import de.rfc1437.ironstorage.bridge.generated.MobileAndroidSetupStatus
import de.rfc1437.ironstorage.bridge.generated.MobileOnboardingErrorKind
import de.rfc1437.ironstorage.bridge.generated.MobileOnboardingFfiException
import de.rfc1437.ironstorage.bridge.generated.MobileOnboardingOutcome
import de.rfc1437.ironstorage.bridge.generated.MobilePrivateStorageFfiException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class NativeOnboardingRepository(
private val session: NativePrivateSession,
private val scope: CoroutineScope,
private val shell: ShellRepository
) : OnboardingRepository {
private val mutableSetup = MutableStateFlow(SetupStatus())
override val setup: StateFlow<SetupStatus> = mutableSetup
@Volatile private var operation: MobileAndroidOnboarding? = null
private var remote: MobileAndroidRemoteOnboarding? = null
@Volatile private var pollProgress = false
init {
refresh()
}
override fun refresh() = runSetup {
mutableSetup.update { it.copy(branches = emptyList(), selectedBranch = "") }
remote?.close()
remote = null
operation = session.storage().androidOnboarding(session.backend())
updateStatus(requireNotNull(operation).status())
shell.refresh()
}
private fun newOperation(): MobileAndroidOnboarding {
remote?.close()
remote = null
return session.storage().androidOnboarding(session.backend()).also { operation = it }
}
override fun generate(userId: String, passphrase: String) = runSetup {
updateStatus(newOperation().generate(userId, passphrase))
}
override fun importKey(armor: String, passphrase: String) = runSetup {
updateStatus(newOperation().importKey(armor, passphrase.ifEmpty { null }))
}
override fun createLocal(fingerprint: String, passphrase: String) = runSetup {
completed(newOperation().setupLocal(fingerprint, passphrase.ifEmpty { null }))
}
override fun discover(server: String, account: String, repository: String, token: String) =
runSetup {
val setup = newOperation()
val request = setup.remote(server, account, repository, token).also { remote = it }
val discovery = request.discover()
pollProgress = false
mutableSetup.update {
it.copy(
branches = discovery.branches,
selectedBranch = discovery.branches.getOrNull(
discovery.selectedBranch.toInt()
).orEmpty(),
title = "Select a branch",
detail = "Choose the branch and private GPG key for this store."
)
}
}
override fun cloneStore(
branch: String,
fingerprint: String,
passphrase: String,
useExisting: Boolean
) = runSetup {
completed(
requireNotNull(remote).cloneStore(
branch,
fingerprint,
passphrase.ifEmpty {
null
},
useExisting
)
)
}
override fun connect(server: String, account: String, repository: String, token: String) =
runSetup {
val setup = newOperation()
setup.remote(server, account, repository, token).use { completed(it.connect()) }
}
override fun replaceToken(account: String, token: String) = runSetup {
newOperation().replaceToken(account, token)
pollProgress = false
mutableSetup.update {
it.copy(
title = "Token updated",
detail = "The previous token was replaced in protected device storage."
)
}
}
override fun cancel() {
if (!mutableSetup.value.busy) return
scope.launch(Dispatchers.IO) { operation?.cancel() }
}
private fun completed(outcome: MobileOnboardingOutcome) {
updateStatus(requireNotNull(operation).status())
mutableSetup.update {
it.copy(title = outcome.title, detail = outcome.detail, branches = emptyList())
}
remote?.close()
remote = null
shell.refresh()
}
private fun updateStatus(status: MobileAndroidSetupStatus) {
pollProgress = false
mutableSetup.update {
it.copy(
title = status.title,
detail = status.detail,
configured = status.configured,
connected = status.connected,
existingClone = status.existingClone,
selectedKey = status.selectedKey.orEmpty(),
keys = status.keys.map { key -> SetupKey(key.fingerprint, key.userIds) }
)
}
}
private fun runSetup(action: () -> Unit) {
if (mutableSetup.value.busy) return
pollProgress = true
mutableSetup.update { it.copy(busy = true, failure = false) }
scope.launch {
val progress = launch {
while (mutableSetup.value.busy) {
delay(200)
val value = withContext(Dispatchers.IO) { operation?.progress() }
value?.takeIf { mutableSetup.value.busy }?.let { phase ->
mutableSetup.update {
if (pollProgress) {
it.copy(
title = phase.title,
detail = phase.detail
)
} else {
it
}
}
}
}
}
try {
withContext(Dispatchers.IO) { action() }
} catch (error: MobileOnboardingFfiException.Failed) {
pollProgress = false
if (error.kind == MobileOnboardingErrorKind.INTERRUPTED) {
withContext(Dispatchers.IO) {
remote?.close()
remote = null
}
mutableSetup.update { it.copy(branches = emptyList()) }
}
mutableSetup.update {
it.copy(title = error.title, detail = error.detail, failure = true)
}
} catch (_: MobilePrivateStorageFfiException.Failed) {
pollProgress = false
mutableSetup.update {
it.copy(
title = "Private storage unavailable",
detail = "Unlock the device and retry. Existing data was retained.",
failure = true
)
}
} catch (_: NativeBridgeException) {
pollProgress = false
mutableSetup.update {
it.copy(
title = "Application update required",
detail = "The native storage service could not be loaded.",
failure = true
)
}
} catch (_: java.io.IOException) {
pollProgress = false
mutableSetup.update {
it.copy(
title = "Private storage unavailable",
detail = "The app-private directories could not be opened.",
failure = true
)
}
} finally {
pollProgress = false
progress.cancel()
mutableSetup.update { it.copy(busy = false) }
}
}
}
}
@@ -0,0 +1,39 @@
package de.rfc1437.ironstorage.core
import android.content.Context
import de.rfc1437.ironstorage.bridge.NativeRuntime
import de.rfc1437.ironstorage.bridge.generated.MobilePrivateDirectories
import de.rfc1437.ironstorage.bridge.generated.MobilePrivateRuntime
import de.rfc1437.ironstorage.bridge.generated.MobileSecretBackend
/** Both facades share one Rust owner and retain its Keystore callbacks for its lifetime. */
class NativePrivateSession(
context: Context,
private val directories: () -> PrivateDirectories,
private val keystore: AndroidKeystore
) {
val runtime = NativeRuntime(context)
private var initialized: MobilePrivateRuntime? = null
private var secrets: MobileSecretBackend? = null
@Synchronized
fun storage(): MobilePrivateRuntime {
initialized?.let { return it }
val roots = directories()
val storage = runtime.privateStorage(
MobilePrivateDirectories(roots.files, roots.noBackup, roots.cache)
)
storage.activate()
val backend = storage.androidSecretBackend(KeystoreCallback(keystore))
storage.verifySecureStorage(backend)
secrets = backend
initialized = storage
return storage
}
@Synchronized
fun backend(): MobileSecretBackend {
storage()
return requireNotNull(secrets)
}
}
@@ -0,0 +1,31 @@
package de.rfc1437.ironstorage.core
import kotlinx.coroutines.flow.StateFlow
data class SetupKey(val fingerprint: String, val userIds: List<String>)
data class SetupStatus(
val configured: Boolean = false,
val connected: Boolean = false,
val existingClone: Boolean = false,
val keys: List<SetupKey> = emptyList(),
val selectedKey: String = "",
val branches: List<String> = emptyList(),
val selectedBranch: String = "",
val busy: Boolean = false,
val title: String = "Set up your password store",
val detail: String = "Create a local store or clone an existing HTTPS repository.",
val failure: Boolean = false
)
interface OnboardingRepository {
val setup: StateFlow<SetupStatus>
fun refresh()
fun generate(userId: String, passphrase: String)
fun importKey(armor: String, passphrase: String)
fun createLocal(fingerprint: String, passphrase: String)
fun discover(server: String, account: String, repository: String, token: String)
fun cloneStore(branch: String, fingerprint: String, passphrase: String, useExisting: Boolean)
fun connect(server: String, account: String, repository: String, token: String)
fun replaceToken(account: String, token: String)
fun cancel()
}
@@ -44,6 +44,7 @@ data class ShellSnapshot(
val state: ShellState,
val pages: List<ShellPage>,
val selectedTab: ShellTab = ShellTab.Home,
val fixture: Boolean = false,
val nativeFailure: NativeFailure? = null,
val storageRecovery: StorageRecovery? = null
)
@@ -61,4 +62,5 @@ enum class NativeFailure {
interface ShellRepository {
val snapshot: StateFlow<ShellSnapshot>
fun selectTab(tab: ShellTab)
fun refresh() {}
}
+1
View File
@@ -1,5 +1,6 @@
{
"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.13.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: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=_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
@Composable
fun ShellScreen(viewModel: ShellViewModel) {
fun ShellScreen(viewModel: ShellViewModel, setupForm: SetupFormViewModel) {
val snapshot by viewModel.snapshot.collectAsStateWithLifecycle()
Scaffold(
bottomBar = {
@@ -75,13 +75,20 @@ fun ShellScreen(viewModel: ShellViewModel) {
}
)
}
Text(
title,
modifier = Modifier.semantics { heading() },
style = MaterialTheme.typography.headlineSmall
)
Text(detail)
if (snapshot.state == ShellState.Loading) CircularProgressIndicator()
if (!snapshot.fixture && snapshot.nativeFailure == null &&
snapshot.storageRecovery == null &&
(snapshot.state == ShellState.Empty || snapshot.selectedTab == ShellTab.Preferences)
) {
OnboardingScreen(viewModel.onboarding, setupForm)
} else {
Text(
title,
modifier = Modifier.semantics { heading() },
style = MaterialTheme.typography.headlineSmall
)
Text(detail)
if (snapshot.state == ShellState.Loading) CircularProgressIndicator()
}
}
}
}
@@ -2,12 +2,16 @@ package de.rfc1437.ironstorage.features
import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import de.rfc1437.ironstorage.core.OnboardingRepository
import de.rfc1437.ironstorage.core.ShellRepository
import de.rfc1437.ironstorage.core.ShellTab
import javax.inject.Inject
@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
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_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="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>
+2
View File
@@ -21,11 +21,13 @@ rust = "1.97.1"
cargoNdk = "4.1.2"
jna = "5.19.1"
annotation = "1.11.0"
rustlsVerifier = "0.1.1"
biometric = "1.1.0"
fragment = "1.9.1"
[libraries]
rustls-verifier = { group = "rustls", name = "rustls-platform-verifier", version.ref = "rustlsVerifier" }
compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose" }
compose-ui = { module = "androidx.compose.ui:ui" }
compose-material3 = { module = "androidx.compose.material3:material3" }
+15 -7
View File
@@ -2284,6 +2284,11 @@
<sha256 value="7220ede61026596fbc720ee4b93246fa2d14f328058532b59ef053de397c7d83" origin="Generated by Gradle"/>
</artifact>
</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">
<artifact name="guava-parent-33.6.0-jre.pom">
<sha256 value="374bd31f61b1cf612bee9ab2e4d70bbdf77dd85a49b431f809d4fbdc901f2dd4" origin="Generated by Gradle"/>
@@ -3774,6 +3779,11 @@
<sha256 value="4e5d1900e6379ef3f5970d04a8f30529adc82f859e8cc107c21ce8149ef666c4" origin="Generated by Gradle"/>
</artifact>
</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">
<artifact name="kotlinx-coroutines-bom-1.8.1.pom">
<sha256 value="563e4aa29fa8fe09a6e1746d0a5b51308f7c7dedc468dc5a0ca810a485877030" origin="Generated by Gradle"/>
@@ -4114,14 +4124,12 @@
<sha256 value="0fe31326e83b7622cbcd9c75d467b291f01ffe3cc6e3e76651ac05a1c1c7a360" origin="Generated by Gradle"/>
</artifact>
</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"/>
<component group="rustls" name="rustls-platform-verifier" version="0.1.1">
<artifact name="rustls-platform-verifier-0.1.1.aar">
<sha256 value="667292cadd8fa589229dd0f716541236a761f29b774930868d218175633830fd" origin="Generated by Gradle"/>
</artifact>
</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 name="rustls-platform-verifier-0.1.1.pom">
<sha256 value="5468629ab3793f4768c0527b8ecca25219a00b5d9503d1ad657cc664c87e9081" origin="Generated by Gradle"/>
</artifact>
</component>
</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: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
+1
View File
@@ -36,6 +36,7 @@ kotlin {
}
dependencies {
implementation(libs.rustls.verifier)
implementation(variantOf(libs.jna) { artifactType("aar") })
implementation(libs.annotation)
testImplementation(libs.junit)
+3
View File
@@ -5,3 +5,6 @@
-dontwarn java.awt.GraphicsEnvironment
-dontwarn java.awt.HeadlessException
-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:9.9=androidLintTool
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
@@ -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. */
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 var initialized = false
private var initializationFailure: NativeFailureKind? = null
@@ -45,6 +46,7 @@ class NativeRuntime(context: Context) {
}
if (!packaged) throw NativeBridgeException(NativeFailureKind.MissingLibrary)
System.loadLibrary(LIBRARY)
NativeTls.initialize(applicationContext)
uniffiEnsureInitialized()
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 {
repositories {
google()
@@ -10,6 +13,25 @@ dependencyResolutionManagement {
repositories {
google()
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"