diff --git a/Cargo.lock b/Cargo.lock index de4cab1..b589899 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5440,6 +5440,8 @@ name = "ironstorage-android" version = "1.0.1" dependencies = [ "ironstorage-mobile", + "jni", + "rustls-platform-verifier", ] [[package]] diff --git a/android/README.md b/android/README.md index 3e41f2b..154efae 100644 --- a/android/README.md +++ b/android/README.md @@ -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 diff --git a/android/app/gradle.lockfile b/android/app/gradle.lockfile index 7ba2c08..f712eb2 100644 --- a/android/app/gradle.lockfile +++ b/android/app/gradle.lockfile @@ -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 diff --git a/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/OnboardingScreenTest.kt b/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/OnboardingScreenTest.kt new file mode 100644 index 0000000..fcb0c9c --- /dev/null +++ b/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/OnboardingScreenTest.kt @@ -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() + + @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 ") + 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() + override fun refresh() { + setup.value = setup.value.copy(branches = emptyList()) + } + override fun generate(userId: String, passphrase: String) { + assertEquals("Fixture ", 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 + } +} diff --git a/android/app/src/debug/kotlin/de/rfc1437/ironstorage/VariantContent.kt b/android/app/src/debug/kotlin/de/rfc1437/ironstorage/VariantContent.kt index da625c1..e05b5b8 100644 --- a/android/app/src/debug/kotlin/de/rfc1437/ironstorage/VariantContent.kt +++ b/android/app/src/debug/kotlin/de/rfc1437/ironstorage/VariantContent.kt @@ -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) } } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 0f6a938..01407c8 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,5 @@ + 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 = 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) { diff --git a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativeOnboardingRepository.kt b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativeOnboardingRepository.kt new file mode 100644 index 0000000..89f0254 --- /dev/null +++ b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativeOnboardingRepository.kt @@ -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 = 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) } + } + } + } +} diff --git a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativePrivateSession.kt b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativePrivateSession.kt new file mode 100644 index 0000000..6de1edc --- /dev/null +++ b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativePrivateSession.kt @@ -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) + } +} diff --git a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/OnboardingRepository.kt b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/OnboardingRepository.kt new file mode 100644 index 0000000..c9f251a --- /dev/null +++ b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/OnboardingRepository.kt @@ -0,0 +1,31 @@ +package de.rfc1437.ironstorage.core + +import kotlinx.coroutines.flow.StateFlow + +data class SetupKey(val fingerprint: String, val userIds: List) +data class SetupStatus( + val configured: Boolean = false, + val connected: Boolean = false, + val existingClone: Boolean = false, + val keys: List = emptyList(), + val selectedKey: String = "", + val branches: List = 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 + 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() +} diff --git a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/ShellRepository.kt b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/ShellRepository.kt index 93b5ddf..8943b48 100644 --- a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/ShellRepository.kt +++ b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/ShellRepository.kt @@ -44,6 +44,7 @@ data class ShellSnapshot( val state: ShellState, val pages: List, 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 fun selectTab(tab: ShellTab) + fun refresh() {} } diff --git a/android/dependency-policy.json b/android/dependency-policy.json index e4db954..731b9ac 100644 --- a/android/dependency-policy.json +++ b/android/dependency-policy.json @@ -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", diff --git a/android/features/gradle.lockfile b/android/features/gradle.lockfile index a40b5a2..b2f7ccf 100644 --- a/android/features/gradle.lockfile +++ b/android/features/gradle.lockfile @@ -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 diff --git a/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/OnboardingScreen.kt b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/OnboardingScreen.kt new file mode 100644 index 0000000..f4c5d3d --- /dev/null +++ b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/OnboardingScreen.kt @@ -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 + ) +} diff --git a/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/ShellScreen.kt b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/ShellScreen.kt index 0fd2464..f64578f 100644 --- a/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/ShellScreen.kt +++ b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/ShellScreen.kt @@ -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() + } } } } diff --git a/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/ShellViewModel.kt b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/ShellViewModel.kt index 2339a10..159a4f2 100644 --- a/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/ShellViewModel.kt +++ b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/ShellViewModel.kt @@ -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) diff --git a/android/features/src/main/res/values/strings.xml b/android/features/src/main/res/values/strings.xml index 8b78fe0..6181734 100644 --- a/android/features/src/main/res/values/strings.xml +++ b/android/features/src/main/res/values/strings.xml @@ -6,4 +6,28 @@ This device is not supported by this app build. The installed app components could not be loaded or do not match. Install a complete update without clearing app data. The app could not complete this operation. Try again. + Cancel setup + No server, camera, or biometric enrollment is required for local setup. Import a private key manually or generate one here. + Create local store + Clone existing store + Select a private GPG encryption key + GPG passphrase + Continue + Create encrypted local store + Generate GPG key + Import private GPG key + Key name and email + Paste one armored private key. It remains in app-private storage; the passphrase is not saved. + Armored private key + HTTPS server URL + Account + Repository path (owner/repository) + Application token + Connect local store + Discover branches + Use matching existing clone + Add HTTPS synchronization + Replace application token + Back to setup choices + Reload setup state diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index a24747f..c3916bb 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -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" } diff --git a/android/gradle/verification-metadata.xml b/android/gradle/verification-metadata.xml index 7d9d06d..a9d1966 100644 --- a/android/gradle/verification-metadata.xml +++ b/android/gradle/verification-metadata.xml @@ -2284,6 +2284,11 @@ + + + + + @@ -3774,6 +3779,11 @@ + + + + + @@ -4114,14 +4124,12 @@ - - - + + + - - - - + + diff --git a/android/platform/gradle.lockfile b/android/platform/gradle.lockfile index 8d3de4b..64da619 100644 --- a/android/platform/gradle.lockfile +++ b/android/platform/gradle.lockfile @@ -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 diff --git a/android/rust-bridge/build.gradle.kts b/android/rust-bridge/build.gradle.kts index 86b03a7..4ac7120 100644 --- a/android/rust-bridge/build.gradle.kts +++ b/android/rust-bridge/build.gradle.kts @@ -36,6 +36,7 @@ kotlin { } dependencies { + implementation(libs.rustls.verifier) implementation(variantOf(libs.jna) { artifactType("aar") }) implementation(libs.annotation) testImplementation(libs.junit) diff --git a/android/rust-bridge/consumer-rules.pro b/android/rust-bridge/consumer-rules.pro index 9957897..a2053ba 100644 --- a/android/rust-bridge/consumer-rules.pro +++ b/android/rust-bridge/consumer-rules.pro @@ -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.** { *; } diff --git a/android/rust-bridge/gradle.lockfile b/android/rust-bridge/gradle.lockfile index 3308a42..cc148f8 100644 --- a/android/rust-bridge/gradle.lockfile +++ b/android/rust-bridge/gradle.lockfile @@ -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 diff --git a/android/rust-bridge/src/androidTest/AndroidManifest.xml b/android/rust-bridge/src/androidTest/AndroidManifest.xml new file mode 100644 index 0000000..19d2638 --- /dev/null +++ b/android/rust-bridge/src/androidTest/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/android/rust-bridge/src/androidTest/kotlin/de/rfc1437/ironstorage/bridge/AndroidOnboardingTest.kt b/android/rust-bridge/src/androidTest/kotlin/de/rfc1437/ironstorage/bridge/AndroidOnboardingTest.kt new file mode 100644 index 0000000..a916642 --- /dev/null +++ b/android/rust-bridge/src/androidTest/kotlin/de/rfc1437/ironstorage/bridge/AndroidOnboardingTest.kt @@ -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 ", + "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 ", + "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() + 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 + } +} diff --git a/android/rust-bridge/src/main/kotlin/de/rfc1437/ironstorage/bridge/NativeRuntime.kt b/android/rust-bridge/src/main/kotlin/de/rfc1437/ironstorage/bridge/NativeRuntime.kt index bf4dc2b..e99f21d 100644 --- a/android/rust-bridge/src/main/kotlin/de/rfc1437/ironstorage/bridge/NativeRuntime.kt +++ b/android/rust-bridge/src/main/kotlin/de/rfc1437/ironstorage/bridge/NativeRuntime.kt @@ -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 } diff --git a/android/rust-bridge/src/main/kotlin/de/rfc1437/ironstorage/bridge/NativeTls.kt b/android/rust-bridge/src/main/kotlin/de/rfc1437/ironstorage/bridge/NativeTls.kt new file mode 100644 index 0000000..e0a9ca5 --- /dev/null +++ b/android/rust-bridge/src/main/kotlin/de/rfc1437/ironstorage/bridge/NativeTls.kt @@ -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) +} diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index a49a7d5..9ceefc3 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -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" diff --git a/crates/android/Cargo.toml b/crates/android/Cargo.toml index 28e7226..4fdba76 100644 --- a/crates/android/Cargo.toml +++ b/crates/android/Cargo.toml @@ -14,3 +14,7 @@ crate-type = ["lib", "cdylib"] [dependencies] ironstorage-mobile = { path = "../mobile-bridge" } + +[target.'cfg(target_os = "android")'.dependencies] +jni = "=0.22.4" +rustls-platform-verifier = "=0.7.0" diff --git a/crates/android/src/lib.rs b/crates/android/src/lib.rs index 9787e72..1115b90 100644 --- a/crates/android/src/lib.rs +++ b/crates/android/src/lib.rs @@ -4,3 +4,6 @@ //! The shared component owns every conversion; no interface is copied here. ironstorage_mobile::uniffi_reexport_scaffolding!(); + +#[cfg(target_os = "android")] +mod tls; diff --git a/crates/android/src/tls.rs b/crates/android/src/tls.rs new file mode 100644 index 0000000..4345887 --- /dev/null +++ b/crates/android/src/tls.rs @@ -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) +} diff --git a/crates/mobile-bridge/src/android_onboarding.rs b/crates/mobile-bridge/src/android_onboarding.rs new file mode 100644 index 0000000..12a38b5 --- /dev/null +++ b/crates/mobile-bridge/src/android_onboarding.rs @@ -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, +} +#[derive(Clone, uniffi::Record)] +pub struct MobileAndroidSetupStatus { + pub configured: bool, + pub connected: bool, + pub existing_clone: bool, + pub keys: Vec, + pub selected_key: Option, + pub title: String, + pub detail: String, +} +impl From 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, +} +impl MobileAndroidOnboarding { + pub(crate) fn new( + runtime: &MobilePrivateRuntime, + backend: Arc, + ) -> Result, MobileOnboardingFfiError> { + Ok(Arc::new(Self { + setup: Arc::new(AndroidOnboarding::new( + &runtime.storage, + Arc::new(SecretBackendAdapter(backend)), + )?), + })) + } +} +fn secret(value: Option) -> Option { + 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 { + Ok(self.setup.status()?.into()) + } + pub fn generate( + &self, + user_id: String, + passphrase: String, + ) -> Result { + Ok(self + .setup + .generate(&user_id, SecretBytes::new(passphrase.into_bytes()))? + .into()) + } + pub fn import_key( + &self, + armor: String, + passphrase: Option, + ) -> Result { + Ok(self + .setup + .import(SecretBytes::new(armor.into_bytes()), secret(passphrase))? + .into()) + } + pub fn setup_local( + &self, + fingerprint: String, + passphrase: Option, + ) -> Result { + 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, 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, + request: MobileOnboardingRequest, +} + +#[uniffi::export] +impl MobileAndroidRemoteOnboarding { + pub fn discover(&self) -> Result { + 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, + use_existing: bool, + ) -> Result { + Ok(outcome(self.setup.clone_store( + &self.request, + &branch, + &fingerprint, + secret(passphrase), + use_existing, + )?)) + } + pub fn connect(&self) -> Result { + Ok(outcome(self.setup.connect(&self.request)?)) + } + pub fn cancel(&self) { + self.setup.cancel(); + } +} diff --git a/crates/mobile-bridge/src/lib.rs b/crates/mobile-bridge/src/lib.rs index 8f7489b..bdd36ae 100644 --- a/crates/mobile-bridge/src/lib.rs +++ b/crates/mobile-bridge/src/lib.rs @@ -81,6 +81,8 @@ uniffi::setup_scaffolding!(); mod android_keystore; pub use android_keystore::*; +mod android_onboarding; +pub use android_onboarding::*; mod platform; pub use platform::*; diff --git a/crates/mobile-bridge/src/private_storage.rs b/crates/mobile-bridge/src/private_storage.rs index 2c6f7f0..cc6ad4b 100644 --- a/crates/mobile-bridge/src/private_storage.rs +++ b/crates/mobile-bridge/src/private_storage.rs @@ -96,10 +96,16 @@ pub struct MobilePrivateStorageLayout { #[derive(uniffi::Object)] pub struct MobilePrivateRuntime { - storage: PrivateStorage, + pub(crate) storage: PrivateStorage, } #[uniffi::export] impl MobilePrivateRuntime { + pub fn android_onboarding( + &self, + backend: Arc, + ) -> Result, crate::MobileOnboardingFfiError> { + crate::MobileAndroidOnboarding::new(self, backend) + } pub fn android_secret_backend( &self, keystore: Arc, diff --git a/crates/storage/src/crypto.rs b/crates/storage/src/crypto.rs index 538c241..59bca71 100644 --- a/crates/storage/src/crypto.rs +++ b/crates/storage/src/crypto.rs @@ -266,6 +266,31 @@ impl KeyStore { 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. pub fn import(&mut self, bytes: &[u8]) -> Result, CryptoError> { if bytes.len() as u64 > MAX_KEY_FILE_BYTES { diff --git a/crates/storage/src/git.rs b/crates/storage/src/git.rs index 0c22384..bc68849 100644 --- a/crates/storage/src/git.rs +++ b/crates/storage/src/git.rs @@ -1466,6 +1466,50 @@ impl GitRepository { credentials: &impl GitRemoteCredentialProvider, transport: &impl GitFetchTransport, control: &GitOperationControl, + ) -> Result, 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, GitError> { control.report(GitProgressPhase::Validating)?; ensure_remote_transport_available(configured.endpoint())?; @@ -1648,11 +1692,6 @@ impl GitRepository { .collect::, GitError>>()?; branches.sort(); branches.dedup(); - if branches.is_empty() { - return Err(GitError::InvalidRepository( - "the remote has no branches".to_owned(), - )); - } Ok(branches) } diff --git a/crates/storage/src/mobile_onboarding.rs b/crates/storage/src/mobile_onboarding.rs index 50e6fd6..2187c66 100644 --- a/crates/storage/src/mobile_onboarding.rs +++ b/crates/storage/src/mobile_onboarding.rs @@ -1,5 +1,7 @@ //! Storage-owned first-run setup for the native mobile applications. +pub mod android; + use std::{ error::Error, fmt, fs, diff --git a/crates/storage/src/mobile_onboarding/android.rs b/crates/storage/src/mobile_onboarding/android.rs new file mode 100644 index 0000000..5c19838 --- /dev/null +++ b/crates/storage/src/mobile_onboarding/android.rs @@ -0,0 +1,1513 @@ +//! Android setup uses explicit private capabilities and the injected Keystore backend. +use super::*; +use crate::{ + mobile_private_storage::{PrivateStorage, PrivateStorageError}, + secret_store::{ + SecretLocator, SecretProtection, SecretReference, SecretStore, SecretStoreBackend, + }, +}; +use cap_std::{ambient_authority, fs::Dir}; +use serde::{Deserialize, Serialize}; +use std::io::{self, Write}; + +const PENDING: &str = ".android-onboarding"; +const COMMIT: &str = ".android-commit"; + +pub struct AndroidOnboarding { + root: PathBuf, + backend: Arc, + operation: MobileOnboardingOperation, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AndroidOnboardingStatus { + pub configured: bool, + pub connected: bool, + pub existing_clone: bool, + pub keys: Vec, + pub selected_key: Option, + pub title: String, + pub detail: String, +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Journal { + connect: bool, + reuse_vault: bool, + had_vault: bool, + had_keys: bool, + remote_url: Option, + token: Option<(String, String)>, + pending_token: Option, +} + +impl AndroidOnboarding { + pub fn new( + storage: &PrivateStorage, + backend: Arc, + ) -> Result { + storage + .verify_secure_storage(backend.as_ref()) + .map_err(private_error)?; + let setup = Self { + root: storage.root().to_owned(), + backend, + operation: MobileOnboardingOperation::default(), + }; + let _lock = setup.lock()?; + setup.recover()?; + Ok(setup) + } + + pub fn cancel(&self) { + self.operation.cancel(); + } + pub fn progress(&self) -> MobileOnboardingProgress { + self.operation.progress() + } + + pub fn status(&self) -> Result { + let _lock = self.lock()?; + self.status_unlocked() + } + + fn status_unlocked(&self) -> Result { + let config = self.configuration()?; + let vault = self.root.join("vault"); + let existing_clone = vault.join(".git").exists(); + let connected = if existing_clone { + let repository = Repository::open(&vault) + .map_err(|_| MobileOnboardingError::inaccessible_repository())?; + !GitRepository::open(&repository, GitIdentity::ironstorage()) + .map_err(MobileOnboardingError::from_git)? + .remotes() + .is_empty() + } else { + false + }; + let mut keys = self + .keys()? + .infos() + .filter(|key| key.has_secret() && key.can_encrypt()) + .collect::>(); + keys.sort_by(|left, right| { + left.fingerprint() + .as_str() + .cmp(right.fingerprint().as_str()) + }); + let (title, detail) = if config.is_some() { + ( + "Password Store Ready", + if connected { + "Your store is connected to HTTPS Git." + } else { + "Your encrypted local store is ready. HTTPS synchronization can be added later." + }, + ) + } else if keys.is_empty() { + ( + "Set up your password store", + "Create a local store or clone an existing HTTPS repository.", + ) + } else { + ( + "Select Your GPG Key", + "Prepared keys are retained. Select a key to create or clone your store.", + ) + }; + Ok(AndroidOnboardingStatus { + title: title.into(), + detail: detail.into(), + configured: config.is_some(), + connected, + existing_clone, + selected_key: config + .as_ref() + .map(|config| config.default_key().as_str().to_owned()) + .or_else(|| { + keys.first() + .map(|key| key.fingerprint().as_str().to_owned()) + }), + keys, + }) + } + + pub fn generate( + &self, + user_id: &str, + passphrase: SecretBytes, + ) -> Result { + let _lock = self.lock()?; + self.require_unconfigured()?; + self.checkpoint()?; + let generated = + KeyStore::generate(user_id, &passphrase).map_err(MobileOnboardingError::from_crypto)?; + let fingerprint = generated.info().fingerprint().as_str().to_owned(); + self.checkpoint()?; + self.stage_key(&fingerprint, generated.into_armor())?; + self.status_unlocked() + } + + pub fn import( + &self, + armor: SecretBytes, + passphrase: Option, + ) -> Result { + let _lock = self.lock()?; + self.require_unconfigured()?; + self.checkpoint()?; + let fingerprint = validate_imported_local_key(&armor, passphrase.as_ref())?; + self.checkpoint()?; + self.stage_key(&fingerprint, armor)?; + self.status_unlocked() + } + + fn stage_key( + &self, + fingerprint: &str, + armor: SecretBytes, + ) -> Result<(), MobileOnboardingError> { + let pending = self.root.join(PENDING); + private_dir(&pending)?; + let path = pending.join("keys"); + private_dir(&path)?; + let existing = self.keys()?; + if existing.resolve(fingerprint).is_ok() { + return Err(MobileOnboardingError::existing_key()); + } + persist_armored_key(&path, fingerprint, true, armor.expose(), false).map_err(|_| { + MobileOnboardingError { + kind: MobileOnboardingErrorKind::Configuration, + title: "Unable to Save GPG Key", + detail: "Check available device storage and retry. Existing keys were retained." + .into(), + } + })?; + sync_dir(&pending)?; + sync_dir(&self.root) + } + + pub fn setup_local( + &self, + fingerprint: &str, + passphrase: Option, + ) -> Result { + let _lock = self.lock()?; + self.require_unconfigured()?; + self.checkpoint()?; + require_empty_directory(&self.root.join("vault"), false)?; + let keys = self.keys()?; + let selected = keys + .infos() + .find(|key| key.fingerprint().as_str() == fingerprint) + .ok_or_else(MobileOnboardingError::invalid_gpg_key)?; + validate_local_key(&keys, &selected, passphrase.as_ref())?; + let staging = self.begin(None, false, false)?; + let result = (|| { + self.copy_keys(&staging.join("keys"))?; + let vault = staging.join("vault"); + private_dir(&vault)?; + let repository = Repository::open(&vault) + .map_err(|_| MobileOnboardingError::inaccessible_repository())?; + let mut git = GitRepository::init(&repository, GitIdentity::ironstorage()) + .map_err(MobileOnboardingError::from_git)?; + RecipientPolicyManager::new(&repository, &keys) + .apply_init( + &InitRequest { + path: None, + key_identities: vec![fingerprint.to_owned()], + }, + None, + &mut NoSetupSecrets, + &mut git, + ) + .map_err(|_| MobileOnboardingError::configuration())?; + self.checkpoint()?; + self.install(false)?; + Config::create_mobile_local( + self.root.join("config.toml"), + &self.root.join("vault"), + &self.root.join("keys"), + fingerprint, + ) + .map_err(MobileOnboardingError::from_config)?; + Ok(()) + })(); + self.finish(result)?; + Ok(MobileOnboardingOutcome { + title: "Local Password Store Ready".into(), + detail: + "Your encrypted local store is ready. HTTPS synchronization can be added later." + .into(), + }) + } + + pub fn discover( + &self, + request: &MobileOnboardingRequest, + ) -> Result { + let _lock = self.lock()?; + self.checkpoint()?; + let scratch = self.root.join(PENDING); + private_dir(&scratch)?; + let branches = GitRepository::discover_remote_branches( + &scratch, + GitIdentity::ironstorage(), + request.remote(), + request, + &self.operation.control, + ) + .map_err(MobileOnboardingError::from_git)?; + let selected_branch = branches + .iter() + .position(|name| name == "main") + .unwrap_or_default(); + Ok(MobileOnboardingDiscovery { + branches, + selected_branch, + }) + } + + pub fn clone_store( + &self, + request: &MobileOnboardingRequest, + branch: &str, + fingerprint: &str, + passphrase: Option, + use_existing: bool, + ) -> Result { + self.clone_with_transport( + request, + branch, + fingerprint, + passphrase, + use_existing, + &crate::git::EmbeddedFetchTransport, + ) + } + + #[allow( + clippy::too_many_arguments, + reason = "The transport is injected only to exercise clone failure boundaries" + )] + fn clone_with_transport( + &self, + request: &MobileOnboardingRequest, + branch: &str, + fingerprint: &str, + passphrase: Option, + use_existing: bool, + transport: &impl crate::git::GitFetchTransport, + ) -> Result { + let _lock = self.lock()?; + self.require_unconfigured()?; + self.checkpoint()?; + let keys = self.keys()?; + let selected = keys + .infos() + .find(|key| key.fingerprint().as_str() == fingerprint) + .ok_or_else(MobileOnboardingError::invalid_gpg_key)?; + validate_local_key(&keys, &selected, passphrase.as_ref())?; + let current = self.root.join("vault"); + if use_existing { + let repository = Repository::open(¤t) + .map_err(|_| MobileOnboardingError::inaccessible_repository())?; + let git = GitRepository::open(&repository, GitIdentity::ironstorage()) + .map_err(MobileOnboardingError::from_git)?; + if git + .remote_url("origin") + .map_err(MobileOnboardingError::from_git)? + != request.remote().url() + || git.current_branch().ok().as_deref() != Some(branch) + { + return Err(MobileOnboardingError::different_existing_clone()); + } + } else { + require_empty_directory(¤t, false)?; + } + let staging = self.begin(Some(request), false, use_existing)?; + let result = (|| { + self.copy_keys(&staging.join("keys"))?; + let vault = if use_existing { + current.clone() + } else { + staging.join("vault") + }; + if !use_existing { + GitRepository::clone_into_with_transport_controlled( + &vault, + GitIdentity::ironstorage(), + request.remote(), + Some(branch), + request, + transport, + &self.operation.control, + ) + .map_err(MobileOnboardingError::from_git)?; + } + let repository = Repository::open(&vault) + .map_err(|_| MobileOnboardingError::inaccessible_repository())?; + let root = DirectoryPath::parse("") + .map_err(|_| MobileOnboardingError::inaccessible_repository())?; + let policy = repository + .read_policy_file(&root, false) + .map_err(|_| MobileOnboardingError::inaccessible_repository())? + .ok_or_else(MobileOnboardingError::invalid_gpg_key)?; + let text = std::str::from_utf8(&policy) + .map_err(|_| MobileOnboardingError::invalid_gpg_key())?; + if !text.lines().any(|line| { + keys.resolve(line.split('#').next().unwrap_or_default().trim()) + .is_ok_and(|key| key.fingerprint().as_str() == fingerprint) + }) { + return Err(MobileOnboardingError::invalid_gpg_key()); + } + self.checkpoint()?; + self.create_token(request)?; + self.install(use_existing)?; + Config::create_mobile_clone( + self.root.join("config.toml"), + ¤t, + &self.root.join("keys"), + fingerprint, + request.remote(), + ) + .map_err(MobileOnboardingError::from_config)?; + Ok(()) + })(); + self.finish(result)?; + Ok(MobileOnboardingOutcome { + title: "Password Store Ready".into(), + detail: format!("The {branch} branch is available on this device."), + }) + } + + pub fn connect( + &self, + request: &MobileOnboardingRequest, + ) -> Result { + self.connect_with_transport(request, &crate::git::EmbeddedFetchTransport) + } + + fn connect_with_transport( + &self, + request: &MobileOnboardingRequest, + transport: &impl crate::git::GitFetchTransport, + ) -> Result { + let _lock = self.lock()?; + self.checkpoint()?; + let config = self + .configuration()? + .ok_or_else(MobileOnboardingError::token_configuration)?; + let repository = Repository::open(config.vault()) + .map_err(|_| MobileOnboardingError::inaccessible_repository())?; + let mut git = GitRepository::open(&repository, config.git_identity().clone()) + .map_err(MobileOnboardingError::from_git)?; + // Repository remotes are authoritative even when configuration is stale. + if !git.remotes().is_empty() || !config.git_remotes().is_empty() { + return Err(MobileOnboardingError::already_configured()); + } + let staging = self.begin(Some(request), true, true)?; + let result = (|| { + GitRepository::validate_remote_with_transport( + &staging, + config.git_identity().clone(), + request.remote(), + request, + transport, + &self.operation.control, + ) + .map_err(MobileOnboardingError::from_git)?; + self.checkpoint()?; + self.create_token(request)?; + self.checkpoint()?; + git.add_remote("origin", request.remote().url()) + .map_err(MobileOnboardingError::from_git)?; + config + .update_mobile_remote(Some(request.remote())) + .map_err(MobileOnboardingError::from_config) + })(); + self.finish(result)?; + Ok(MobileOnboardingOutcome { + title: "Git Sync Configured".into(), + detail: "Your local store is connected to the HTTPS repository.".into(), + }) + } + + pub fn replace_token( + &self, + account: String, + token: SecretBytes, + ) -> Result<(), MobileOnboardingError> { + let _lock = self.lock()?; + self.checkpoint()?; + let config = self + .configuration()? + .ok_or_else(MobileOnboardingError::token_configuration)?; + let remote = config + .git_remote(Some("origin")) + .ok_or_else(MobileOnboardingError::token_configuration)?; + let repository = Repository::open(config.vault()) + .map_err(|_| MobileOnboardingError::inaccessible_repository())?; + let git = GitRepository::open(&repository, config.git_identity().clone()) + .map_err(MobileOnboardingError::from_git)?; + if git + .remote_url("origin") + .map_err(MobileOnboardingError::from_git)? + != remote.url() + { + return Err(MobileOnboardingError::different_existing_clone()); + } + let credential = GitCredential::new(account, token.expose().to_vec()) + .map_err(MobileOnboardingError::from_git)?; + let (server, app) = remote + .https_credentials() + .ok_or_else(MobileOnboardingError::token_configuration)?; + let store = SecretStore::new( + Arc::clone(&self.backend), + SecretCachePolicy::Disabled, + SecretProtectionPolicy::device_unlocked(), + ); + store.unlock().map_err(MobileOnboardingError::from_secret)?; + let saved = store + .store_https_git_credential( + server, + app, + credential.username(), + SecretBytes::new(credential.password().to_vec()), + ) + .map_err(MobileOnboardingError::from_secret); + let locked = store.lock().map_err(MobileOnboardingError::from_secret); + saved.and(locked) + } + + fn configuration(&self) -> Result, MobileOnboardingError> { + let path = self.root.join("config.toml"); + match Config::load(Some(&path)) { + Ok(config) => Ok(Some(config)), + Err(ConfigError::NotFound { .. }) => Ok(None), + Err(error) => Err(MobileOnboardingError::from_config(error)), + } + } + + fn require_unconfigured(&self) -> Result<(), MobileOnboardingError> { + if self.configuration()?.is_some() { + Err(MobileOnboardingError::already_configured()) + } else { + Ok(()) + } + } + fn checkpoint(&self) -> Result<(), MobileOnboardingError> { + self.operation + .control + .report(GitProgressPhase::Refreshing) + .map_err(MobileOnboardingError::from_git) + } + fn keys(&self) -> Result { + let mut keys = KeyStore::new(); + if self.root.join(PENDING).exists() { + check_tree(&self.root.join(PENDING))?; + } + for path in [self.root.join("keys"), self.root.join(PENDING).join("keys")] { + if !path.exists() { + continue; + } + check_tree(&path)?; + keys.extend_from_path(&path) + .map_err(MobileOnboardingError::from_crypto)?; + } + Ok(keys) + } + fn copy_keys(&self, destination: &Path) -> Result<(), MobileOnboardingError> { + private_dir(destination)?; + for path in [self.root.join("keys"), self.root.join(PENDING).join("keys")] { + if path.exists() { + copy_tree(&path, destination)?; + } + } + Ok(()) + } + fn lock(&self) -> Result { + let dir = Dir::open_ambient_dir(&self.root, ambient_authority()).map_err(io_error)?; + crate::mobile_private_storage::exclusive_lock(&dir, "runtime.lock").map_err(private_error) + } + fn begin( + &self, + request: Option<&MobileOnboardingRequest>, + connect: bool, + reuse_vault: bool, + ) -> Result { + let staging = self.root.join(COMMIT); + if staging.exists() { + return Err(MobileOnboardingError::configuration()); + } + let token = if let Some(request) = request { + let (server, app) = request + .remote() + .https_credentials() + .ok_or_else(MobileOnboardingError::token_configuration)?; + let locator = SecretLocator::HttpsGitCredential { + server_id: server.as_str().into(), + application_id: app.as_str().into(), + }; + self.backend + .unlock() + .map_err(MobileOnboardingError::from_secret)?; + let existing = self + .backend + .retrieve(&locator, SecretProtection::DeviceUnlocked); + let locked = self + .backend + .lock() + .map_err(MobileOnboardingError::from_secret); + locked?; + match existing { + Err(SecretStoreError::Missing) => {} + Ok(_) => { + return Err(MobileOnboardingError::from_secret( + SecretStoreError::AlreadyExists, + )); + } + Err(error) => return Err(MobileOnboardingError::from_secret(error)), + } + Some((server.as_str().into(), app.as_str().into())) + } else { + None + }; + private_dir(&staging)?; + let journal = Journal { + connect, + reuse_vault, + had_vault: self.root.join("vault").exists(), + had_keys: self.root.join("keys").exists(), + remote_url: request.map(|r| r.remote().url().to_owned()), + pending_token: token + .as_ref() + .map(|_| format!("setup-{:032x}", rand::random::())), + token, + }; + let dir = Dir::open_ambient_dir(&staging, ambient_authority()).map_err(io_error)?; + let mut temp = crate::atomic_file::TempFile::new(&dir).map_err(io_error)?; + temp.write_all( + toml::to_string(&journal) + .map_err(|_| MobileOnboardingError::configuration())? + .as_bytes(), + ) + .map_err(io_error)?; + temp.as_file().sync_all().map_err(io_error)?; + temp.replace("journal.toml").map_err(io_error)?; + dir.open(".") + .and_then(|file| file.sync_all()) + .map_err(io_error)?; + sync_dir(&self.root)?; + Ok(staging) + } + fn create_token(&self, request: &MobileOnboardingRequest) -> Result<(), MobileOnboardingError> { + let (server, app) = request + .remote() + .https_credentials() + .ok_or_else(MobileOnboardingError::token_configuration)?; + self.backend + .unlock() + .map_err(MobileOnboardingError::from_secret)?; + let result = (|| { + let reference = SecretReference::https_git_credential( + server.as_str(), + app.as_str(), + &request.account, + ) + .map_err(MobileOnboardingError::from_secret)?; + let journal = self.journal()?; + let owner = journal + .pending_token + .as_ref() + .ok_or_else(MobileOnboardingError::configuration)?; + let encoded = crate::secret_store::encode_onboarding_record( + &reference, + &request.token, + owner.as_bytes(), + ) + .map_err(MobileOnboardingError::from_secret)?; + let pending = + pending_locator(&journal).ok_or_else(MobileOnboardingError::configuration)?; + // Keep an encrypted ownership record across a crash before configuration commit. + self.backend + .create(&pending, SecretProtection::DeviceUnlocked, encoded.expose()) + .map_err(MobileOnboardingError::from_secret)?; + self.backend + .create( + &SecretLocator::HttpsGitCredential { + server_id: server.as_str().into(), + application_id: app.as_str().into(), + }, + SecretProtection::DeviceUnlocked, + encoded.expose(), + ) + .map_err(MobileOnboardingError::from_secret) + })(); + let locked = self + .backend + .lock() + .map_err(MobileOnboardingError::from_secret); + result.and(locked) + } + fn journal(&self) -> Result { + let path = self.root.join(COMMIT).join("journal.toml"); + let metadata = fs::symlink_metadata(&path).map_err(io_error)?; + if !metadata.is_file() || metadata.file_type().is_symlink() || metadata.len() > 16 * 1024 { + return Err(MobileOnboardingError::configuration()); + } + toml::from_str(&fs::read_to_string(path).map_err(io_error)?) + .map_err(|_| MobileOnboardingError::configuration()) + } + fn cleanup_token( + &self, + journal: &Journal, + committed: bool, + ) -> Result<(), MobileOnboardingError> { + let Some(pending) = pending_locator(journal) else { + return Ok(()); + }; + self.backend + .unlock() + .map_err(MobileOnboardingError::from_secret)?; + let result = (|| { + if !committed { + match self + .backend + .retrieve(&pending, SecretProtection::DeviceUnlocked) + { + Ok(expected) => { + let (server_id, application_id) = journal + .token + .clone() + .ok_or_else(MobileOnboardingError::configuration)?; + let locator = SecretLocator::HttpsGitCredential { + server_id, + application_id, + }; + match self + .backend + .retrieve(&locator, SecretProtection::DeviceUnlocked) + { + Ok(actual) if actual.expose() == expected.expose() => self + .backend + .delete(&locator, SecretProtection::DeviceUnlocked) + .map_err(MobileOnboardingError::from_secret)?, + Ok(_) | Err(SecretStoreError::Missing) => {} + Err(error) => return Err(MobileOnboardingError::from_secret(error)), + } + } + Err(SecretStoreError::Missing) => {} + Err(error) => return Err(MobileOnboardingError::from_secret(error)), + } + } + match self + .backend + .delete(&pending, SecretProtection::DeviceUnlocked) + { + Ok(()) | Err(SecretStoreError::Missing) => Ok(()), + Err(error) => Err(MobileOnboardingError::from_secret(error)), + } + })(); + let locked = self + .backend + .lock() + .map_err(MobileOnboardingError::from_secret); + result.and(locked) + } + fn install(&self, reuse_vault: bool) -> Result<(), MobileOnboardingError> { + let staging = self.root.join(COMMIT); + for name in ["vault", "keys"] { + if name == "vault" && reuse_vault { + continue; + } + let current = self.root.join(name); + if current.exists() { + fs::rename(¤t, staging.join(format!("previous-{name}"))).map_err(io_error)?; + sync_dir(&staging)?; + sync_dir(&self.root)?; + } + sync_tree(&staging.join(name))?; + fs::rename(staging.join(name), current).map_err(io_error)?; + sync_dir(&self.root)?; + } + Ok(()) + } + fn finish( + &self, + result: Result<(), MobileOnboardingError>, + ) -> Result<(), MobileOnboardingError> { + self.recover()?; + result + } + fn recover(&self) -> Result<(), MobileOnboardingError> { + let staging = self.root.join(COMMIT); + if !staging.exists() { + return Ok(()); + } + check_tree(&staging)?; + let journal_path = staging.join("journal.toml"); + if !journal_path.exists() { + if staging.join("previous-vault").exists() || staging.join("previous-keys").exists() { + return Err(MobileOnboardingError::configuration()); + } + fs::remove_dir_all(staging).map_err(io_error)?; + return Ok(()); + } + let journal = self.journal()?; + let configured = self.configuration()?; + let committed = if journal.connect { + configured.as_ref().is_some_and(|config| { + config + .git_remote(Some("origin")) + .is_some_and(|remote| Some(remote.url()) == journal.remote_url.as_deref()) + }) + } else { + configured.is_some() + }; + if !committed { + if journal.connect { + let config = configured.ok_or_else(MobileOnboardingError::configuration)?; + let repository = Repository::open(config.vault()) + .map_err(|_| MobileOnboardingError::inaccessible_repository())?; + let mut git = GitRepository::open(&repository, config.git_identity().clone()) + .map_err(MobileOnboardingError::from_git)?; + if git.remotes().iter().any(|name| name == "origin") { + if Some( + git.remote_url("origin") + .map_err(MobileOnboardingError::from_git)? + .as_str(), + ) != journal.remote_url.as_deref() + { + return Err(MobileOnboardingError::different_existing_clone()); + } + git.remove_remote("origin") + .map_err(MobileOnboardingError::from_git)?; + } + } else { + for (name, had) in [("vault", journal.had_vault), ("keys", journal.had_keys)] { + if name == "vault" && journal.reuse_vault { + continue; + } + let current = self.root.join(name); + let previous = staging.join(format!("previous-{name}")); + if previous.exists() || (!had && !staging.join(name).exists()) { + if current.exists() { + check_tree(¤t)?; + fs::remove_dir_all(¤t).map_err(io_error)?; + } + if previous.exists() { + fs::rename(previous, current).map_err(io_error)?; + } + } + } + } + } + sync_dir(&self.root)?; + self.cleanup_token(&journal, committed)?; + fs::remove_dir_all(&staging).map_err(io_error)?; + if committed && !journal.connect && self.root.join(PENDING).exists() { + check_tree(&self.root.join(PENDING))?; + fs::remove_dir_all(self.root.join(PENDING)).map_err(io_error)?; + } + sync_dir(&self.root) + } +} +fn sync_dir(path: &Path) -> Result<(), MobileOnboardingError> { + fs::File::open(path) + .and_then(|file| file.sync_all()) + .map_err(io_error) +} +fn private_dir(path: &Path) -> Result<(), MobileOnboardingError> { + match fs::create_dir(path) { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {} + Err(error) => return Err(io_error(error)), + } + check_tree(path)?; + set_private_directory(path).map_err(io_error) +} +fn check_tree(path: &Path) -> Result<(), MobileOnboardingError> { + fn check(path: &Path, depth: usize) -> io::Result<()> { + let metadata = fs::symlink_metadata(path)?; + if metadata.file_type().is_symlink() + || depth > 64 + || (!metadata.is_dir() && !metadata.is_file()) + { + return Err(io::ErrorKind::InvalidInput.into()); + } + if metadata.is_dir() { + for entry in fs::read_dir(path)? { + check(&entry?.path(), depth + 1)?; + } + } + Ok(()) + } + check(path, 0).map_err(io_error) +} +fn copy_tree(source: &Path, destination: &Path) -> Result<(), MobileOnboardingError> { + check_tree(source)?; + for entry in fs::read_dir(source).map_err(io_error)? { + let entry = entry.map_err(io_error)?; + let target = destination.join(entry.file_name()); + if entry.file_type().map_err(io_error)?.is_dir() { + private_dir(&target)?; + copy_tree(&entry.path(), &target)?; + } else { + if target.exists() { + return Err(MobileOnboardingError::existing_key()); + } + fs::copy(entry.path(), target).map_err(io_error)?; + } + } + Ok(()) +} +fn io_error(error: io::Error) -> MobileOnboardingError { + private_error(error.into()) +} +fn private_error(error: PrivateStorageError) -> MobileOnboardingError { + MobileOnboardingError { + kind: MobileOnboardingErrorKind::Configuration, + title: "Setup Recovery Required", + detail: error.detail().into(), + } +} + +fn sync_tree(path: &Path) -> Result<(), MobileOnboardingError> { + check_tree(path)?; + if path.is_dir() { + for entry in fs::read_dir(path).map_err(io_error)? { + sync_tree(&entry.map_err(io_error)?.path())?; + } + } + sync_dir(path) +} + +fn pending_locator(journal: &Journal) -> Option { + journal + .pending_token + .as_ref() + .map(|application_id| SecretLocator::HttpsGitCredential { + server_id: "android-onboarding".into(), + application_id: application_id.clone(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + git::{GitFetchTransport, GitRemoteCredentialProvider}, + mobile_private_storage::PrivateDirectories, + }; + use std::collections::BTreeMap; + use std::sync::atomic::{AtomicBool, Ordering}; + const ALICE: &str = "7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30"; + const BOB: &str = "B37027B56FC406BD3F6A622B2AC03492B992D06F"; + fn password() -> Option { + Some(SecretBytes::new(b"fixture-alice-passphrase".to_vec())) + } + #[derive(Default)] + struct Backend { + records: Mutex>>, + fail_tokens: AtomicBool, + } + impl SecretStoreBackend for Backend { + fn create( + &self, + locator: &SecretLocator, + _: SecretProtection, + value: &[u8], + ) -> Result<(), SecretStoreError> { + if matches!(locator, SecretLocator::HttpsGitCredential { .. }) + && self.fail_tokens.load(Ordering::Relaxed) + { + return Err(SecretStoreError::Unavailable); + } + let mut map = self.records.lock().unwrap(); + if map.contains_key(locator) { + return Err(SecretStoreError::AlreadyExists); + } + map.insert(locator.clone(), value.to_vec()); + Ok(()) + } + fn retrieve( + &self, + locator: &SecretLocator, + _: SecretProtection, + ) -> Result { + self.records + .lock() + .unwrap() + .get(locator) + .cloned() + .map(SecretBytes::new) + .ok_or(SecretStoreError::Missing) + } + fn replace( + &self, + locator: &SecretLocator, + _: SecretProtection, + value: &[u8], + ) -> Result<(), SecretStoreError> { + let mut map = self.records.lock().unwrap(); + if !map.contains_key(locator) { + return Err(SecretStoreError::Missing); + } + map.insert(locator.clone(), value.to_vec()); + Ok(()) + } + fn delete( + &self, + locator: &SecretLocator, + _: SecretProtection, + ) -> Result<(), SecretStoreError> { + self.records + .lock() + .unwrap() + .remove(locator) + .map(|_| ()) + .ok_or(SecretStoreError::Missing) + } + } + fn storage(root: &Path) -> PrivateStorage { + for name in ["files", "no-backup", "cache"] { + fs::create_dir_all(root.join(name)).unwrap(); + } + PrivateStorage::open(PrivateDirectories { + files: fs::canonicalize(root.join("files")).unwrap(), + no_backup: fs::canonicalize(root.join("no-backup")).unwrap(), + cache: fs::canonicalize(root.join("cache")).unwrap(), + }) + .unwrap() + } + fn imported(storage: &PrivateStorage, backend: &Arc) -> AndroidOnboarding { + let setup = AndroidOnboarding::new(storage, backend.clone()).unwrap(); + setup + .import( + SecretBytes::new( + include_bytes!("../../tests/fixtures/compatibility/keys/alice-secret.asc") + .to_vec(), + ), + password(), + ) + .unwrap(); + setup + } + fn request() -> MobileOnboardingRequest { + MobileOnboardingRequest::new( + "https://example.test".into(), + "alice".into(), + "team/passwords".into(), + b"test-token".to_vec(), + ) + .unwrap() + } + struct Fetch { + recipient: &'static str, + failure: Option, + } + impl GitFetchTransport for Fetch { + fn fetch( + &self, + repository: &GitRepository, + configured: &GitRemote, + credentials: &dyn GitRemoteCredentialProvider, + ) -> Result { + if let Some(error) = &self.failure { + return Err(error.clone()); + } + assert_eq!( + credentials.credential_for(configured)?.password(), + b"test-token" + ); + fs::write( + repository.root().join(".gpg-id"), + format!("{}\n", self.recipient), + ) + .unwrap(); + repository.stage(&[".gpg-id".into()])?; + let tip = repository.commit("Upstream-compatible fixture")?; + let git = gix::open(repository.root()).unwrap(); + git.reference( + "refs/remotes/origin/main", + gix::hash::ObjectId::from_hex(tip.as_bytes()).unwrap(), + gix::refs::transaction::PreviousValue::MustNotExist, + "fixture", + ) + .unwrap(); + git.find_reference("refs/heads/main") + .unwrap() + .delete() + .unwrap(); + let mut index = git.index_from_tree(&git.empty_tree().id).unwrap(); + index.write(Default::default()).unwrap(); + fs::remove_file(repository.root().join(".gpg-id")).unwrap(); + Ok(true) + } + } + #[test] + fn imported_key_restarts_then_creates_a_pass_compatible_local_store() { + let temp = tempfile::tempdir().unwrap(); + let backend = Arc::new(Backend::default()); + let private = storage(temp.path()); + let setup = imported(&private, &backend); + assert_eq!( + setup.status().unwrap().keys[0].fingerprint().as_str(), + ALICE + ); + drop(setup); + drop(private); + let private = storage(temp.path()); + let setup = AndroidOnboarding::new(&private, backend.clone()).unwrap(); + assert_eq!(setup.status().unwrap().keys.len(), 1); + setup.setup_local(ALICE, password()).unwrap(); + let config = setup.configuration().unwrap().unwrap(); + assert_eq!( + fs::read_to_string(config.vault().join(".gpg-id")).unwrap(), + format!("{ALICE}\n") + ); + assert!(config.git_remotes().is_empty()); + let keys = KeyStore::load(config.key_material()).unwrap(); + struct FixtureSecrets; + impl SecretProvider for FixtureSecrets { + fn secret_for( + &mut self, + _: &KeyInfo, + ) -> Result { + Ok(password().unwrap()) + } + } + let entry = crate::repository::EntryPath::parse("offline-fixture").unwrap(); + let encrypted = keys + .encrypt( + SecretBytes::new( + b"public-fixture-password\notpauth://totp/Test?secret=JBSWY3DPEHPK3PXP\n" + .to_vec(), + ), + &[keys.resolve(ALICE).unwrap()], + ) + .unwrap(); + let local = Repository::open(config.vault()).unwrap(); + local.write_entry(&entry, &encrypted).unwrap(); + let reopened = Repository::open(config.vault()).unwrap(); + let decrypted = keys + .decrypt(&reopened.read_entry(&entry).unwrap(), &mut FixtureSecrets) + .unwrap(); + assert_eq!( + decrypted.expose(), + b"public-fixture-password\notpauth://totp/Test?secret=JBSWY3DPEHPK3PXP\n" + ); + let git = GitRepository::open(&reopened, GitIdentity::ironstorage()).unwrap(); + git.stage(&["offline-fixture.gpg".into()]).unwrap(); + git.commit("Offline pass-compatible entry").unwrap(); + keys.validate_secret_passphrase(ALICE, password().as_ref()) + .unwrap(); + let repository = Repository::open(config.vault()).unwrap(); + assert!( + GitRepository::open(&repository, GitIdentity::ironstorage()) + .unwrap() + .log(None) + .unwrap() + .len() + >= 2 + ); + assert!(!setup.root.join(PENDING).exists()); + assert!( + backend + .records + .lock() + .unwrap() + .keys() + .all(|key| matches!(key, SecretLocator::ApplicationBinding)) + ); + } + #[test] + fn cancellation_and_wrong_passphrase_leave_prepared_keys_and_existing_data_unchanged() { + let temp = tempfile::tempdir().unwrap(); + let backend = Arc::new(Backend::default()); + let private = storage(temp.path()); + let setup = imported(&private, &backend); + let key = fs::read_dir(setup.root.join(PENDING).join("keys")) + .unwrap() + .next() + .unwrap() + .unwrap() + .path(); + let original = fs::read(&key).unwrap(); + assert!( + setup + .setup_local(ALICE, Some(SecretBytes::new(b"wrong".to_vec()))) + .is_err() + ); + setup.cancel(); + assert_eq!( + setup.setup_local(ALICE, password()).unwrap_err().kind(), + MobileOnboardingErrorKind::Interrupted + ); + assert_eq!(fs::read(key).unwrap(), original); + assert!(!setup.root.join("config.toml").exists()); + assert_eq!(fs::read_dir(setup.root.join("vault")).unwrap().count(), 0); + } + #[test] + fn clone_failures_recipient_mismatch_and_keystore_failure_preserve_every_original() { + for failure in [ + Some(GitError::AuthenticationFailed), + Some(GitError::TlsFailed), + Some(GitError::NetworkUnavailable), + Some(GitError::Cancelled), + None, + ] { + let temp = tempfile::tempdir().unwrap(); + let backend = Arc::new(Backend::default()); + let private = storage(temp.path()); + let setup = imported(&private, &backend); + assert!( + setup + .clone_with_transport( + &request(), + "main", + ALICE, + password(), + false, + &Fetch { + recipient: BOB, + failure + } + ) + .is_err() + ); + assert!(!setup.root.join("config.toml").exists()); + assert_eq!(fs::read_dir(setup.root.join("vault")).unwrap().count(), 0); + assert_eq!(setup.status().unwrap().keys.len(), 1); + assert_eq!(backend.records.lock().unwrap().len(), 1); + assert!(!setup.root.join(COMMIT).exists()); + } + let temp = tempfile::tempdir().unwrap(); + let backend = Arc::new(Backend::default()); + let private = storage(temp.path()); + let setup = imported(&private, &backend); + backend.fail_tokens.store(true, Ordering::Relaxed); + assert!( + setup + .clone_with_transport( + &request(), + "main", + ALICE, + password(), + false, + &Fetch { + recipient: ALICE, + failure: None + } + ) + .is_err() + ); + assert_eq!(fs::read_dir(setup.root.join("vault")).unwrap().count(), 0); + assert!(!setup.root.join("config.toml").exists()); + } + #[test] + fn clone_success_and_explicit_token_replacement_use_only_the_injected_backend() { + let temp = tempfile::tempdir().unwrap(); + let backend = Arc::new(Backend::default()); + let private = storage(temp.path()); + let setup = imported(&private, &backend); + setup + .clone_with_transport( + &request(), + "main", + ALICE, + password(), + false, + &Fetch { + recipient: ALICE, + failure: None, + }, + ) + .unwrap(); + let config = setup.configuration().unwrap().unwrap(); + let (server, app) = config + .git_remote(None) + .unwrap() + .https_credentials() + .unwrap(); + let store = SecretStore::new( + backend.clone(), + SecretCachePolicy::Disabled, + SecretProtectionPolicy::device_unlocked(), + ); + store.unlock().unwrap(); + assert_eq!( + store.credential(server, app).unwrap().password(), + b"test-token" + ); + setup + .replace_token("alice".into(), SecretBytes::new(b"replacement".to_vec())) + .unwrap(); + assert_eq!( + store.credential(server, app).unwrap().password(), + b"replacement" + ); + assert!( + !fs::read_to_string(config.source()) + .unwrap() + .contains("replacement") + ); + assert!(setup.status().unwrap().configured && setup.status().unwrap().connected); + } + #[test] + fn existing_repository_remotes_and_tokens_are_never_overwritten() { + let temp = tempfile::tempdir().unwrap(); + let backend = Arc::new(Backend::default()); + let private = storage(temp.path()); + let setup = imported(&private, &backend); + setup.setup_local(ALICE, password()).unwrap(); + let config = setup.configuration().unwrap().unwrap(); + let before = fs::read(config.source()).unwrap(); + let remote = request(); + let (server, app) = remote.remote().https_credentials().unwrap(); + let locator = SecretLocator::HttpsGitCredential { + server_id: server.as_str().into(), + application_id: app.as_str().into(), + }; + backend + .create(&locator, SecretProtection::DeviceUnlocked, b"old-record") + .unwrap(); + assert!( + setup + .connect_with_transport( + &remote, + &Fetch { + recipient: ALICE, + failure: None + } + ) + .is_err() + ); + assert_eq!( + backend + .retrieve(&locator, SecretProtection::DeviceUnlocked) + .unwrap() + .expose(), + b"old-record" + ); + assert_eq!(fs::read(config.source()).unwrap(), before); + backend + .delete(&locator, SecretProtection::DeviceUnlocked) + .unwrap(); + let repo = Repository::open(config.vault()).unwrap(); + let mut git = GitRepository::open(&repo, GitIdentity::ironstorage()).unwrap(); + git.add_remote("authoritative", "https://other.test/team/store.git") + .unwrap(); + assert!( + setup + .connect_with_transport( + &remote, + &Fetch { + recipient: ALICE, + failure: None + } + ) + .is_err() + ); + assert_eq!(git.remotes(), vec!["authoritative"]); + assert_eq!(fs::read(config.source()).unwrap(), before); + } + #[test] + fn local_connect_validates_authentication_before_mutation_and_accepts_an_empty_remote() { + let temp = tempfile::tempdir().unwrap(); + let backend = Arc::new(Backend::default()); + let private = storage(temp.path()); + let setup = imported(&private, &backend); + setup.setup_local(ALICE, password()).unwrap(); + let config = setup.configuration().unwrap().unwrap(); + let before = fs::read(config.source()).unwrap(); + for failure in [ + GitError::AuthenticationFailed, + GitError::TlsFailed, + GitError::NetworkUnavailable, + GitError::Cancelled, + ] { + assert!( + setup + .connect_with_transport( + &request(), + &Fetch { + recipient: ALICE, + failure: Some(failure) + } + ) + .is_err() + ); + assert_eq!(fs::read(config.source()).unwrap(), before); + assert!(!setup.status().unwrap().connected); + assert_eq!(backend.records.lock().unwrap().len(), 1); + assert!(!setup.root.join(COMMIT).exists()); + } + struct EmptyRemote; + impl GitFetchTransport for EmptyRemote { + fn fetch( + &self, + _: &GitRepository, + remote: &GitRemote, + credentials: &dyn GitRemoteCredentialProvider, + ) -> Result { + assert_eq!( + credentials.credential_for(remote)?.password(), + b"test-token" + ); + Ok(false) + } + } + setup + .connect_with_transport(&request(), &EmptyRemote) + .unwrap(); + assert!(setup.status().unwrap().connected); + assert_eq!(backend.records.lock().unwrap().len(), 2); + assert!(!setup.root.join(COMMIT).exists()); + assert!( + !fs::read_to_string(config.source()) + .unwrap() + .contains("test-token") + ); + } + #[test] + fn process_death_during_install_restores_the_previous_vault_and_keys() { + let temp = tempfile::tempdir().unwrap(); + let backend = Arc::new(Backend::default()); + let private = storage(temp.path()); + let setup = imported(&private, &backend); + fs::write(setup.root.join("vault/keep"), b"existing repository data").unwrap(); + fs::write(setup.root.join("keys/keep"), b"existing key data").unwrap(); + let staging = setup.begin(None, false, false).unwrap(); + private_dir(&staging.join("vault")).unwrap(); + private_dir(&staging.join("keys")).unwrap(); + fs::write(staging.join("vault/new"), b"uncommitted").unwrap(); + setup.install(false).unwrap(); + drop(setup); + drop(private); + let private = storage(temp.path()); + AndroidOnboarding::new(&private, backend).unwrap(); + assert_eq!( + fs::read(private.root().join("vault/keep")).unwrap(), + b"existing repository data" + ); + assert_eq!( + fs::read(private.root().join("keys/keep")).unwrap(), + b"existing key data" + ); + assert!(!private.root().join("vault/new").exists()); + } + #[test] + fn token_ownership_survives_process_death_and_never_deletes_a_different_record() { + for changed in [false, true] { + let temp = tempfile::tempdir().unwrap(); + let backend = Arc::new(Backend::default()); + let private = storage(temp.path()); + let setup = imported(&private, &backend); + let request = request(); + setup.begin(Some(&request), false, false).unwrap(); + setup.create_token(&request).unwrap(); + let (server, app) = request.remote().https_credentials().unwrap(); + let locator = SecretLocator::HttpsGitCredential { + server_id: server.as_str().into(), + application_id: app.as_str().into(), + }; + let unrelated = crate::secret_store::encode_onboarding_record( + &SecretReference::https_git_credential( + server.as_str(), + app.as_str(), + &request.account, + ) + .unwrap(), + &request.token, + b"independent-transaction-with-the-same-token", + ) + .unwrap(); + if changed { + backend + .replace( + &locator, + SecretProtection::DeviceUnlocked, + unrelated.expose(), + ) + .unwrap(); + } + drop(setup); + let setup = AndroidOnboarding::new(&private, backend.clone()).unwrap(); + assert!(!setup.root.join(COMMIT).exists()); + if changed { + assert_eq!( + backend + .retrieve(&locator, SecretProtection::DeviceUnlocked) + .unwrap() + .expose(), + unrelated.expose() + ); + } else { + assert!(matches!( + backend.retrieve(&locator, SecretProtection::DeviceUnlocked), + Err(SecretStoreError::Missing) + )); + } + assert_eq!( + backend.records.lock().unwrap().len(), + if changed { 2 } else { 1 } + ); + } + } + #[test] + fn matching_existing_clone_is_opened_without_replacing_repository_data() { + let temp = tempfile::tempdir().unwrap(); + let backend = Arc::new(Backend::default()); + let private = storage(temp.path()); + let setup = imported(&private, &backend); + let request = request(); + let vault = setup.root.join("vault"); + let repository = Repository::open(&vault).unwrap(); + let mut git = GitRepository::init(&repository, GitIdentity::ironstorage()).unwrap(); + fs::write(vault.join(".gpg-id"), format!("{ALICE}\n")).unwrap(); + git.stage(&[".gpg-id".into()]).unwrap(); + git.commit("Existing clone fixture").unwrap(); + git.add_remote("origin", request.remote().url()).unwrap(); + let git_config = fs::read(vault.join(".git/config")).unwrap(); + let policy = fs::read(vault.join(".gpg-id")).unwrap(); + assert!( + setup + .clone_store(&request, "other-branch", ALICE, password(), true) + .is_err() + ); + assert_eq!(fs::read(vault.join(".git/config")).unwrap(), git_config); + setup + .clone_store(&request, "main", ALICE, password(), true) + .unwrap(); + assert_eq!(fs::read(vault.join(".git/config")).unwrap(), git_config); + assert_eq!(fs::read(vault.join(".gpg-id")).unwrap(), policy); + } + #[test] + fn low_storage_is_actionable_and_corrupt_journal_backups_are_retained() { + assert!( + io_error(io::ErrorKind::StorageFull.into()) + .detail() + .contains("Free device storage") + ); + let temp = tempfile::tempdir().unwrap(); + let backend = Arc::new(Backend::default()); + let private = storage(temp.path()); + let setup = imported(&private, &backend); + let commit = setup.root.join(COMMIT); + private_dir(&commit).unwrap(); + private_dir(&commit.join("previous-vault")).unwrap(); + fs::write( + commit.join("previous-vault/keep"), + b"retained password store", + ) + .unwrap(); + assert!(setup.recover().is_err()); + assert_eq!( + fs::read(commit.join("previous-vault/keep")).unwrap(), + b"retained password store" + ); + } +} diff --git a/crates/storage/src/mobile_private_storage.rs b/crates/storage/src/mobile_private_storage.rs index cef92ba..cf0c482 100644 --- a/crates/storage/src/mobile_private_storage.rs +++ b/crates/storage/src/mobile_private_storage.rs @@ -532,7 +532,7 @@ fn regular_bytes( } Ok(Some(bytes)) } -fn exclusive_lock(parent: &Dir, name: &str) -> Result { +pub(crate) fn exclusive_lock(parent: &Dir, name: &str) -> Result { if let Ok(metadata) = parent.symlink_metadata(name) && (metadata.file_type().is_symlink() || !metadata.is_file()) { diff --git a/crates/storage/src/secret_store.rs b/crates/storage/src/secret_store.rs index 9a921c8..e95e1e4 100644 --- a/crates/storage/src/secret_store.rs +++ b/crates/storage/src/secret_store.rs @@ -1010,6 +1010,32 @@ struct SecretRecord { fn encode_record( reference: &SecretReference, value: &SecretBytes, +) -> Result { + 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 { + 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 { validate_secret(value)?; let mut encoded = Vec::with_capacity(RECORD_MAGIC.len() + value.expose().len() + 1024); @@ -1025,7 +1051,10 @@ fn encode_record( application_id, 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, application_id.as_bytes())?; write_field(&mut encoded, account.as_bytes())?; @@ -1063,7 +1092,14 @@ fn decode_record(encoded: SecretBytes) -> Result SecretReference::openpgp_passphrase(read_text(fingerprint)?) .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 (application_id, rest) = read_field(rest)?; let (account, rest) = read_field(rest)?; diff --git a/docs/android/onboarding.md b/docs/android/onboarding.md new file mode 100644 index 0000000..1e887d4 --- /dev/null +++ b/docs/android/onboarding.md @@ -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. diff --git a/tools/android-build/src/gates.rs b/tools/android-build/src/gates.rs index a79894a..f9f6e07 100644 --- a/tools/android-build/src/gates.rs +++ b/tools/android-build/src/gates.rs @@ -59,6 +59,7 @@ fn manifest_text(source: &str) -> Result<(), Box> { "android.permission.USE_BIOMETRIC", "android.permission.USE_FINGERPRINT", "de.rfc1437.ironstorage.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION", + "android.permission.INTERNET", ]; for node in root.children().filter(|node| node.is_element()) { if node.tag_name().name().starts_with("uses-permission") @@ -440,6 +441,13 @@ mod tests { r#""# ); assert!(manifest_text(&source).is_ok()); + assert!( + manifest_text(&source.replace( + "