From 874c8139a4add6aba780c410c92e257fbbde8da6 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Thu, 8 Oct 2026 14:36:05 +0200 Subject: [PATCH] feat(android): implement private Rust storage runtime (#90) --- Cargo.lock | 1 + Cargo.toml | 1 + .../rfc1437/ironstorage/BackupPolicyTest.kt | 42 + .../de/rfc1437/ironstorage/ShellModule.kt | 4 +- android/app/src/main/res/xml/backup_rules.xml | 4 + .../main/res/xml/data_extraction_rules.xml | 8 + .../core/NativeFixtureShellRepository.kt | 81 +- .../ironstorage/core/PrivateDirectories.kt | 25 + .../ironstorage/core/ShellRepository.kt | 3 +- .../platform/PrivateDirectoryProvider.kt | 11 + android/rust-bridge/build.gradle.kts | 2 +- .../ironstorage/bridge/PrivateStorageTest.kt | 69 ++ .../ironstorage/bridge/NativeRuntime.kt | 11 + apple/Generated/ironstorage_mobile.swift | 733 +++++++++++- apple/Generated/ironstorage_mobileFFI.h | 109 ++ crates/mobile-bridge/src/lib.rs | 3 + crates/mobile-bridge/src/platform.rs | 4 +- crates/mobile-bridge/src/private_storage.rs | 221 ++++ crates/storage/Cargo.toml | 2 + crates/storage/src/atomic_file.rs | 109 ++ crates/storage/src/config.rs | 64 +- crates/storage/src/file_lock.rs | 61 + crates/storage/src/lib.rs | 6 + crates/storage/src/mobile.rs | 10 +- crates/storage/src/mobile_key_transfer.rs | 2 +- crates/storage/src/mobile_private_storage.rs | 1037 +++++++++++++++++ crates/storage/src/mobile_totp.rs | 62 +- crates/storage/src/mobile_watch.rs | 4 +- crates/storage/src/repository.rs | 2 +- crates/storage/src/secret_store.rs | 4 + crates/storage/src/ssh.rs | 2 +- .../storage/tests/android_private_context.rs | 58 + docs/android/mobile-contract.md | 6 +- docs/android/parity.md | 10 +- docs/android/private-storage.md | 87 ++ .../android-build/tests/mobile_contract.swift | 29 +- 36 files changed, 2848 insertions(+), 39 deletions(-) create mode 100644 android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/BackupPolicyTest.kt create mode 100644 android/core/src/main/kotlin/de/rfc1437/ironstorage/core/PrivateDirectories.kt create mode 100644 android/platform/src/main/kotlin/de/rfc1437/ironstorage/platform/PrivateDirectoryProvider.kt create mode 100644 android/rust-bridge/src/androidTest/kotlin/de/rfc1437/ironstorage/bridge/PrivateStorageTest.kt create mode 100644 crates/mobile-bridge/src/private_storage.rs create mode 100644 crates/storage/src/atomic_file.rs create mode 100644 crates/storage/src/file_lock.rs create mode 100644 crates/storage/src/mobile_private_storage.rs create mode 100644 crates/storage/tests/android_private_context.rs create mode 100644 docs/android/private-storage.md diff --git a/Cargo.lock b/Cargo.lock index 48ab6d7..b45edb4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5415,6 +5415,7 @@ dependencies = [ "reqwest 0.13.4", "rqrr", "russh", + "rustix 1.1.4", "rustls", "secret-service", "security-framework 3.7.0", diff --git a/Cargo.toml b/Cargo.toml index bc43490..1ddd5ac 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,7 @@ arboard = { version = "3.6", default-features = false, features = ["wayland-data ashpd = { version = "0.13", default-features = false, features = ["file_chooser", "tokio"] } cap-std = "4.0" cap-tempfile = "4.0" +rustix = { version = "1.1", features = ["fs"] } clap = { version = "4.6", features = ["derive"] } clap_complete = "4.6" crossterm = "0.29" diff --git a/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/BackupPolicyTest.kt b/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/BackupPolicyTest.kt new file mode 100644 index 0000000..a6777ee --- /dev/null +++ b/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/BackupPolicyTest.kt @@ -0,0 +1,42 @@ +package de.rfc1437.ironstorage + +import android.content.pm.ApplicationInfo +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Test +import org.xmlpull.v1.XmlPullParser + +class BackupPolicyTest { + @Test + fun installedResourcesExcludeCredentialAndDeviceProtectedDomains() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals(0, context.applicationInfo.flags and ApplicationInfo.FLAG_ALLOW_BACKUP) + val domains = setOf( + "root", "file", "database", "sharedpref", "external", + "device_root", "device_file", "device_database", "device_sharedpref" + ) + for ((resource, sections) in mapOf( + R.xml.backup_rules to setOf("full-backup-content"), + R.xml.data_extraction_rules to setOf("cloud-backup", "device-transfer") + )) { + context.resources.getXml(resource).use { xml -> + val excluded = mutableMapOf>() + var section = "" + while (xml.eventType != XmlPullParser.END_DOCUMENT) { + if (xml.eventType == XmlPullParser.START_TAG) { + if (xml.name in sections) section = xml.name + assertFalse(xml.name == "include") + if (xml.name == "exclude") { + assertEquals(".", xml.getAttributeValue(null, "path")) + excluded.getOrPut(section) { mutableSetOf() } + .add(xml.getAttributeValue(null, "domain")) + } + } + xml.next() + } + assertEquals(sections.associateWith { domains }, excluded) + } + } + } +} diff --git a/android/app/src/main/kotlin/de/rfc1437/ironstorage/ShellModule.kt b/android/app/src/main/kotlin/de/rfc1437/ironstorage/ShellModule.kt index 0ef94b1..ccc30f5 100644 --- a/android/app/src/main/kotlin/de/rfc1437/ironstorage/ShellModule.kt +++ b/android/app/src/main/kotlin/de/rfc1437/ironstorage/ShellModule.kt @@ -8,6 +8,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import de.rfc1437.ironstorage.core.NativeFixtureShellRepository import de.rfc1437.ironstorage.core.ShellRepository +import de.rfc1437.ironstorage.platform.privateDirectories import javax.inject.Singleton import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -21,6 +22,7 @@ object ShellModule { fun shellRepository(@ApplicationContext context: Context): ShellRepository = NativeFixtureShellRepository( context, - CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate), + { privateDirectories(context) } ) } diff --git a/android/app/src/main/res/xml/backup_rules.xml b/android/app/src/main/res/xml/backup_rules.xml index 8194d8f..c85e278 100644 --- a/android/app/src/main/res/xml/backup_rules.xml +++ b/android/app/src/main/res/xml/backup_rules.xml @@ -4,4 +4,8 @@ + + + + diff --git a/android/app/src/main/res/xml/data_extraction_rules.xml b/android/app/src/main/res/xml/data_extraction_rules.xml index 061447a..9ddbf21 100644 --- a/android/app/src/main/res/xml/data_extraction_rules.xml +++ b/android/app/src/main/res/xml/data_extraction_rules.xml @@ -5,6 +5,10 @@ + + + + @@ -12,5 +16,9 @@ + + + + diff --git a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativeFixtureShellRepository.kt b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativeFixtureShellRepository.kt index 070fa9b..a5db5e6 100644 --- a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativeFixtureShellRepository.kt +++ b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativeFixtureShellRepository.kt @@ -5,8 +5,13 @@ import de.rfc1437.ironstorage.bridge.NativeBridgeException import de.rfc1437.ironstorage.bridge.NativeFailureKind import de.rfc1437.ironstorage.bridge.NativeRuntime import de.rfc1437.ironstorage.bridge.generated.MobileIcon +import de.rfc1437.ironstorage.bridge.generated.MobilePrivateDirectories +import de.rfc1437.ironstorage.bridge.generated.MobilePrivateRuntime +import de.rfc1437.ironstorage.bridge.generated.MobilePrivateStorageErrorKind +import de.rfc1437.ironstorage.bridge.generated.MobilePrivateStorageFfiException import de.rfc1437.ironstorage.bridge.generated.MobileShellState import de.rfc1437.ironstorage.bridge.generated.MobileTab +import de.rfc1437.ironstorage.bridge.generated.mobilePrivateStorageFailure import java.util.concurrent.atomic.AtomicLong import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -17,12 +22,13 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -/** #92's native fixture integration; repository initialization is owned by #90. */ +/** Rust-owned private initialization; debug controls can switch to explicit fixtures. */ class NativeFixtureShellRepository internal constructor( private val scope: CoroutineScope, private val load: suspend (ShellState) -> ShellSnapshot ) : ShellRepository { - constructor(context: Context, scope: CoroutineScope) : this(scope, nativeLoader(context)) + constructor(context: Context, scope: CoroutineScope, directories: () -> PrivateDirectories) : + this(scope, nativeLoader(context, directories)) private val mutableSnapshot = MutableStateFlow(ShellSnapshot(ShellState.Loading, emptyList())) override val snapshot: StateFlow = mutableSnapshot private val revision = AtomicLong() @@ -46,17 +52,52 @@ class NativeFixtureShellRepository internal constructor( ShellSnapshot(ShellState.Error, emptyList(), nativeFailure = error.kind.toFacade()) } if (revision.get() == operation) { - mutableSnapshot.update { result.copy(selectedTab = it.selectedTab) } + mutableSnapshot.update { + result.copy( + selectedTab = if (it.pages.isEmpty()) result.selectedTab else it.selectedTab + ) + } } } } } -private fun nativeLoader(context: Context): suspend (ShellState) -> ShellSnapshot { +private fun nativeLoader( + context: Context, + directoryProvider: () -> PrivateDirectories +): suspend (ShellState) -> ShellSnapshot { val runtime = NativeRuntime(context) + var storage: MobilePrivateRuntime? = null + var firstLoad = true return { state -> withContext(Dispatchers.IO) { - val shell = runtime.fixture(state.toNative()) + var recovery: StorageRecovery? = null + val shell = if (firstLoad) { + firstLoad = false + try { + val directories = directoryProvider() + val initialized = storage ?: runtime.privateStorage( + MobilePrivateDirectories( + directories.files, + directories.noBackup, + directories.cache + ) + ) + storage = initialized + initialized.activate() + recovery = initialized.status().recovery?.toFacade() + initialized.shell() + } catch (error: MobilePrivateStorageFfiException.Failed) { + recovery = error.kind.toFacade() + mobilePrivateStorageFailure(error.kind) + } catch (_: java.io.IOException) { + recovery = StorageRecovery.InvalidRoots + runtime.fixture(MobileShellState.ERROR) + mobilePrivateStorageFailure(MobilePrivateStorageErrorKind.INVALID_ROOTS) + } + } else { + runtime.fixture(state.toNative()) + } val pages = shell.pages.map { page -> ShellPage( page.tab.toFacade(), @@ -68,11 +109,39 @@ private fun nativeLoader(context: Context): suspend (ShellState) -> ShellSnapsho } val actual = shell.pages.firstOrNull()?.state ?: throw NativeBridgeException(NativeFailureKind.RustFailure) - ShellSnapshot(actual.toFacade(), pages) + ShellSnapshot( + actual.toFacade(), + pages, + selectedTab = shell.selectedTab.toFacade(), + storageRecovery = recovery + ) } } } +private fun MobilePrivateStorageErrorKind.toFacade(): StorageRecovery = when (this) { + MobilePrivateStorageErrorKind.INVALID_ROOTS -> StorageRecovery.InvalidRoots + MobilePrivateStorageErrorKind.UNSAFE_PATH -> StorageRecovery.UnsafePath + MobilePrivateStorageErrorKind.MISSING_ROOT -> StorageRecovery.MissingRoot + MobilePrivateStorageErrorKind.ACCESS_DENIED -> StorageRecovery.AccessDenied + MobilePrivateStorageErrorKind.READ_ONLY -> StorageRecovery.ReadOnly + MobilePrivateStorageErrorKind.STORAGE_FULL -> StorageRecovery.StorageFull + MobilePrivateStorageErrorKind.BUSY -> StorageRecovery.Busy + MobilePrivateStorageErrorKind.IO -> StorageRecovery.Io + MobilePrivateStorageErrorKind.CORRUPT_LAYOUT -> StorageRecovery.CorruptLayout + MobilePrivateStorageErrorKind.MIGRATION_REQUIRED -> StorageRecovery.MigrationRequired + MobilePrivateStorageErrorKind.CORRUPT_CONFIGURATION -> StorageRecovery.CorruptConfiguration + MobilePrivateStorageErrorKind.CONFIGURATION_MISSING_WITH_DATA -> + StorageRecovery.ConfigurationMissingWithData + MobilePrivateStorageErrorKind.INTERRUPTED_SETUP -> StorageRecovery.InterruptedSetup + MobilePrivateStorageErrorKind.RESTORED_WITHOUT_KEYSTORE -> + StorageRecovery.RestoredWithoutKeystore + MobilePrivateStorageErrorKind.SECURE_STORAGE_LOCKED -> StorageRecovery.SecureStorageLocked + MobilePrivateStorageErrorKind.SECURE_STORAGE_UNAVAILABLE -> + StorageRecovery.SecureStorageUnavailable + MobilePrivateStorageErrorKind.INVALID_SECURE_BINDING -> StorageRecovery.InvalidSecureBinding +} + private fun MobileShellState.toFacade(): ShellState = when (this) { MobileShellState.LOADING -> ShellState.Loading MobileShellState.EMPTY -> ShellState.Empty diff --git a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/PrivateDirectories.kt b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/PrivateDirectories.kt new file mode 100644 index 0000000..28d5251 --- /dev/null +++ b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/PrivateDirectories.kt @@ -0,0 +1,25 @@ +package de.rfc1437.ironstorage.core + +data class PrivateDirectories(val files: String, val noBackup: String, val cache: String) { + override fun toString(): String = "PrivateDirectories([REDACTED])" +} + +enum class StorageRecovery { + InvalidRoots, + UnsafePath, + MissingRoot, + AccessDenied, + ReadOnly, + StorageFull, + Busy, + Io, + CorruptLayout, + MigrationRequired, + CorruptConfiguration, + ConfigurationMissingWithData, + InterruptedSetup, + RestoredWithoutKeystore, + SecureStorageLocked, + SecureStorageUnavailable, + InvalidSecureBinding +} 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 297524c..93b5ddf 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,7 +44,8 @@ data class ShellSnapshot( val state: ShellState, val pages: List, val selectedTab: ShellTab = ShellTab.Home, - val nativeFailure: NativeFailure? = null + val nativeFailure: NativeFailure? = null, + val storageRecovery: StorageRecovery? = null ) enum class NativeFailure { diff --git a/android/platform/src/main/kotlin/de/rfc1437/ironstorage/platform/PrivateDirectoryProvider.kt b/android/platform/src/main/kotlin/de/rfc1437/ironstorage/platform/PrivateDirectoryProvider.kt new file mode 100644 index 0000000..6ffaa61 --- /dev/null +++ b/android/platform/src/main/kotlin/de/rfc1437/ironstorage/platform/PrivateDirectoryProvider.kt @@ -0,0 +1,11 @@ +package de.rfc1437.ironstorage.platform + +import android.content.Context +import de.rfc1437.ironstorage.core.PrivateDirectories + +/** Only Context's OS directory facts; Rust owns all subordinate paths and files. */ +fun privateDirectories(context: Context): PrivateDirectories = PrivateDirectories( + context.filesDir.canonicalPath, + context.noBackupFilesDir.canonicalPath, + context.cacheDir.canonicalPath +) diff --git a/android/rust-bridge/build.gradle.kts b/android/rust-bridge/build.gradle.kts index f8e42d8..6d8bece 100644 --- a/android/rust-bridge/build.gradle.kts +++ b/android/rust-bridge/build.gradle.kts @@ -31,7 +31,7 @@ kotlin { } dependencies { - implementation("${libs.jna.get().module}:${libs.versions.jna.get()}@aar") + implementation(variantOf(libs.jna) { artifactType("aar") }) implementation(libs.annotation) testImplementation(libs.junit) androidTestImplementation(libs.android.test.runner) diff --git a/android/rust-bridge/src/androidTest/kotlin/de/rfc1437/ironstorage/bridge/PrivateStorageTest.kt b/android/rust-bridge/src/androidTest/kotlin/de/rfc1437/ironstorage/bridge/PrivateStorageTest.kt new file mode 100644 index 0000000..8d99a31 --- /dev/null +++ b/android/rust-bridge/src/androidTest/kotlin/de/rfc1437/ironstorage/bridge/PrivateStorageTest.kt @@ -0,0 +1,69 @@ +package de.rfc1437.ironstorage.bridge + +import androidx.test.platform.app.InstrumentationRegistry +import de.rfc1437.ironstorage.bridge.generated.MobileOnboardingFfiException +import de.rfc1437.ironstorage.bridge.generated.MobilePrivateDirectories +import de.rfc1437.ironstorage.bridge.generated.MobilePrivateStorageFfiException +import de.rfc1437.ironstorage.bridge.generated.MobileShellState +import de.rfc1437.ironstorage.bridge.generated.MobileTab +import de.rfc1437.ironstorage.bridge.generated.mobileLocalOnboardingOperation +import de.rfc1437.ironstorage.bridge.generated.setSelectedMobileTab +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class PrivateStorageTest { + @Test + fun canonicalSandboxInitializationAndRecreationKeepDurableLayout() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val directories = MobilePrivateDirectories( + context.filesDir.canonicalPath, + context.noBackupFilesDir.canonicalPath, + context.cacheDir.canonicalPath + ) + val native = NativeRuntime(context) + val initialized = try { + native.privateStorage(directories) + } catch (error: MobilePrivateStorageFfiException.Failed) { + throw AssertionError("Private initialization failed: ${error.kind}", error) + } + initialized.use { first -> + assertEquals(1u, first.status().version) + assertEquals(null, first.status().recovery) + val configured = first.status().configured + assertEquals( + if (configured) MobileShellState.READY else MobileShellState.EMPTY, + first.shell().pages.first().state + ) + val layout = first.layout() + assertEquals("${context.noBackupFilesDir.canonicalPath}/ironstorage", layout.data) + assertEquals("${layout.data}/totp-catalog.toml", layout.catalog) + assertEquals("${context.cacheDir.canonicalPath}/ironstorage", layout.cache) + first.activate() + if (!configured) { + mobileLocalOnboardingOperation().use { onboarding -> + try { + onboarding.generate( + "Sandbox fixture ", + "public-test-passphrase", + false + ) + } catch (error: MobileOnboardingFfiException.Failed) { + throw AssertionError("Private onboarding failed: ${error.kind}", error) + } + } + } + assertTrue(first.status().configured) + setSelectedMobileTab(MobileTab.PREFERENCES) + assertEquals(MobileTab.PREFERENCES, first.shell().selectedTab) + assertEquals(MobileShellState.READY, first.shell().pages.first().state) + first.clearDisposableCache() + native.privateStorage(directories).use { recreated -> + assertEquals(layout, recreated.layout()) + assertEquals(first.status(), recreated.status()) + } + assertFalse(layout.toString().contains(context.noBackupFilesDir.path)) + } + } +} 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 53090e4..ce32ca6 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 @@ -3,9 +3,13 @@ package de.rfc1437.ironstorage.bridge import android.content.Context import android.os.Build import android.os.Looper +import de.rfc1437.ironstorage.bridge.generated.MobilePrivateDirectories +import de.rfc1437.ironstorage.bridge.generated.MobilePrivateRuntime +import de.rfc1437.ironstorage.bridge.generated.MobilePrivateStorageFfiException import de.rfc1437.ironstorage.bridge.generated.MobileShell import de.rfc1437.ironstorage.bridge.generated.MobileShellState import de.rfc1437.ironstorage.bridge.generated.mobileShellFixture +import de.rfc1437.ironstorage.bridge.generated.openMobilePrivateRuntime import de.rfc1437.ironstorage.bridge.generated.uniffiEnsureInitialized import java.io.File import java.io.IOException @@ -24,6 +28,11 @@ class NativeRuntime(context: Context) { mobileShellFixture(state) } + fun privateStorage(directories: MobilePrivateDirectories): MobilePrivateRuntime = call { + initialize() + openMobilePrivateRuntime(directories) + } + private fun initialize() = synchronized(lock) { initializationFailure?.let { throw NativeBridgeException(it) } if (!initialized) { @@ -51,6 +60,8 @@ class NativeRuntime(context: Context) { fail(error.kind) } catch (error: CancellationException) { throw error + } catch (error: MobilePrivateStorageFfiException) { + throw error } catch (error: NativeBindingMismatchException) { fail(error.kind) } catch (_: IOException) { diff --git a/apple/Generated/ironstorage_mobile.swift b/apple/Generated/ironstorage_mobile.swift index 6382060..c3e3752 100644 --- a/apple/Generated/ironstorage_mobile.swift +++ b/apple/Generated/ironstorage_mobile.swift @@ -2524,6 +2524,189 @@ public func FfiConverterTypeMobileOnboardingOperation_lower(_ value: MobileOnboa +public protocol MobilePrivateRuntimeProtocol: AnyObject, Sendable { + + func activate() throws + + func authentication(backend: MobileSecretBackend, clock: MobileClock) throws -> MobileAuthentication + + func clearDisposableCache() throws + + func layout() -> MobilePrivateStorageLayout + + func shell() -> MobileShell + + func status() -> MobilePrivateStorageStatus + + func verifySecureStorage(backend: MobileSecretBackend) throws + +} +open class MobilePrivateRuntime: MobilePrivateRuntimeProtocol, @unchecked Sendable { + fileprivate let handle: UInt64 + + /// Used to instantiate a [FFIObject] without an actual handle, for fakes in tests, mostly. +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + public struct NoHandle { + public init() {} + } + + // TODO: We'd like this to be `private` but for Swifty reasons, + // we can't implement `FfiConverter` without making this `required` and we can't + // make it `required` without making it `public`. +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + required public init(unsafeFromHandle handle: UInt64) { + self.handle = handle + } + + // This constructor can be used to instantiate a fake object. + // - Parameter noHandle: Placeholder value so we can have a constructor separate from the default empty one that may be implemented for classes extending [FFIObject]. + // + // - Warning: + // Any object instantiated with this constructor cannot be passed to an actual Rust-backed object. Since there isn't a backing handle the FFI lower functions will crash. +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + public init(noHandle: NoHandle) { + self.handle = 0 + } + +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + public func uniffiCloneHandle() -> UInt64 { + return try! rustCall { uniffi_ironstorage_mobile_fn_clone_mobileprivateruntime(self.handle, $0) } + } + // No primary constructor declared for this class. + + deinit { + if handle == 0 { + // Mock objects have handle=0 don't try to free them + return + } + + try! rustCall { uniffi_ironstorage_mobile_fn_free_mobileprivateruntime(handle, $0) } + } + + + + +open func activate()throws {try rustCallWithError(FfiConverterTypeMobilePrivateStorageFfiError_lift) { + uniffiCallStatus in + uniffi_ironstorage_mobile_fn_method_mobileprivateruntime_activate( + self.uniffiCloneHandle(),uniffiCallStatus + ) +} +} + +open func authentication(backend: MobileSecretBackend, clock: MobileClock)throws -> MobileAuthentication { + return try FfiConverterTypeMobileAuthentication_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) { + uniffiCallStatus in + uniffi_ironstorage_mobile_fn_method_mobileprivateruntime_authentication( + self.uniffiCloneHandle(), + FfiConverterTypeMobileSecretBackend_lower(backend), + FfiConverterTypeMobileClock_lower(clock),uniffiCallStatus + ) +}) +} + +open func clearDisposableCache()throws {try rustCallWithError(FfiConverterTypeMobilePrivateStorageFfiError_lift) { + uniffiCallStatus in + uniffi_ironstorage_mobile_fn_method_mobileprivateruntime_clear_disposable_cache( + self.uniffiCloneHandle(),uniffiCallStatus + ) +} +} + +open func layout() -> MobilePrivateStorageLayout { + return try! FfiConverterTypeMobilePrivateStorageLayout_lift(try! rustCall() { + uniffiCallStatus in + uniffi_ironstorage_mobile_fn_method_mobileprivateruntime_layout( + self.uniffiCloneHandle(),uniffiCallStatus + ) +}) +} + +open func shell() -> MobileShell { + return try! FfiConverterTypeMobileShell_lift(try! rustCall() { + uniffiCallStatus in + uniffi_ironstorage_mobile_fn_method_mobileprivateruntime_shell( + self.uniffiCloneHandle(),uniffiCallStatus + ) +}) +} + +open func status() -> MobilePrivateStorageStatus { + return try! FfiConverterTypeMobilePrivateStorageStatus_lift(try! rustCall() { + uniffiCallStatus in + uniffi_ironstorage_mobile_fn_method_mobileprivateruntime_status( + self.uniffiCloneHandle(),uniffiCallStatus + ) +}) +} + +open func verifySecureStorage(backend: MobileSecretBackend)throws {try rustCallWithError(FfiConverterTypeMobilePrivateStorageFfiError_lift) { + uniffiCallStatus in + uniffi_ironstorage_mobile_fn_method_mobileprivateruntime_verify_secure_storage( + self.uniffiCloneHandle(), + FfiConverterTypeMobileSecretBackend_lower(backend),uniffiCallStatus + ) +} +} + + + +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeMobilePrivateRuntime: FfiConverter { + typealias FfiType = UInt64 + typealias SwiftType = MobilePrivateRuntime + + public static func lift(_ handle: UInt64) throws -> MobilePrivateRuntime { + return MobilePrivateRuntime(unsafeFromHandle: handle) + } + + public static func lower(_ value: MobilePrivateRuntime) -> UInt64 { + return value.uniffiCloneHandle() + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobilePrivateRuntime { + let handle: UInt64 = try readInt(&buf) + return try lift(handle) + } + + public static func write(_ value: MobilePrivateRuntime, into buf: inout [UInt8]) { + writeInt(&buf, lower(value)) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobilePrivateRuntime_lift(_ handle: UInt64) throws -> MobilePrivateRuntime { + return try FfiConverterTypeMobilePrivateRuntime.lift(handle) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobilePrivateRuntime_lower(_ value: MobilePrivateRuntime) -> UInt64 { + return FfiConverterTypeMobilePrivateRuntime.lower(value) +} + + + + + + /** * Receives storage-encoded opaque bytes; cannot select accounts, leases or protection. */ @@ -5519,6 +5702,208 @@ public func FfiConverterTypeMobilePreferences_lower(_ value: MobilePreferences) } +public struct MobilePrivateDirectories: Equatable, Hashable { + public var files: String + public var noBackup: String + public var cache: String + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(files: String, noBackup: String, cache: String) { + self.files = files + self.noBackup = noBackup + self.cache = cache + } + + + + +} + +#if compiler(>=6) +extension MobilePrivateDirectories: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeMobilePrivateDirectories: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobilePrivateDirectories { + return + try MobilePrivateDirectories( + files: FfiConverterString.read(from: &buf), + noBackup: FfiConverterString.read(from: &buf), + cache: FfiConverterString.read(from: &buf) + ) + } + + public static func write(_ value: MobilePrivateDirectories, into buf: inout [UInt8]) { + FfiConverterString.write(value.files, into: &buf) + FfiConverterString.write(value.noBackup, into: &buf) + FfiConverterString.write(value.cache, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobilePrivateDirectories_lift(_ buf: RustBuffer) throws -> MobilePrivateDirectories { + return try FfiConverterTypeMobilePrivateDirectories.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobilePrivateDirectories_lower(_ value: MobilePrivateDirectories) -> RustBuffer { + return FfiConverterTypeMobilePrivateDirectories.lower(value) +} + + +public struct MobilePrivateStorageLayout: Equatable, Hashable { + public var data: String + public var configuration: String + public var vault: String + public var keys: String + public var catalog: String + public var scratch: String + public var cache: String + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(data: String, configuration: String, vault: String, keys: String, catalog: String, scratch: String, cache: String) { + self.data = data + self.configuration = configuration + self.vault = vault + self.keys = keys + self.catalog = catalog + self.scratch = scratch + self.cache = cache + } + + + + +} + +#if compiler(>=6) +extension MobilePrivateStorageLayout: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeMobilePrivateStorageLayout: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobilePrivateStorageLayout { + return + try MobilePrivateStorageLayout( + data: FfiConverterString.read(from: &buf), + configuration: FfiConverterString.read(from: &buf), + vault: FfiConverterString.read(from: &buf), + keys: FfiConverterString.read(from: &buf), + catalog: FfiConverterString.read(from: &buf), + scratch: FfiConverterString.read(from: &buf), + cache: FfiConverterString.read(from: &buf) + ) + } + + public static func write(_ value: MobilePrivateStorageLayout, into buf: inout [UInt8]) { + FfiConverterString.write(value.data, into: &buf) + FfiConverterString.write(value.configuration, into: &buf) + FfiConverterString.write(value.vault, into: &buf) + FfiConverterString.write(value.keys, into: &buf) + FfiConverterString.write(value.catalog, into: &buf) + FfiConverterString.write(value.scratch, into: &buf) + FfiConverterString.write(value.cache, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobilePrivateStorageLayout_lift(_ buf: RustBuffer) throws -> MobilePrivateStorageLayout { + return try FfiConverterTypeMobilePrivateStorageLayout.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobilePrivateStorageLayout_lower(_ value: MobilePrivateStorageLayout) -> RustBuffer { + return FfiConverterTypeMobilePrivateStorageLayout.lower(value) +} + + +public struct MobilePrivateStorageStatus: Equatable, Hashable { + public var version: UInt32 + public var configured: Bool + public var recoveredTemporaryFiles: Bool + public var recoveredCatalog: Bool + public var secureBindingPresent: Bool + public var recovery: MobilePrivateStorageErrorKind? + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(version: UInt32, configured: Bool, recoveredTemporaryFiles: Bool, recoveredCatalog: Bool, secureBindingPresent: Bool, recovery: MobilePrivateStorageErrorKind?) { + self.version = version + self.configured = configured + self.recoveredTemporaryFiles = recoveredTemporaryFiles + self.recoveredCatalog = recoveredCatalog + self.secureBindingPresent = secureBindingPresent + self.recovery = recovery + } + + + + +} + +#if compiler(>=6) +extension MobilePrivateStorageStatus: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeMobilePrivateStorageStatus: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobilePrivateStorageStatus { + return + try MobilePrivateStorageStatus( + version: FfiConverterUInt32.read(from: &buf), + configured: FfiConverterBool.read(from: &buf), + recoveredTemporaryFiles: FfiConverterBool.read(from: &buf), + recoveredCatalog: FfiConverterBool.read(from: &buf), + secureBindingPresent: FfiConverterBool.read(from: &buf), + recovery: FfiConverterOptionTypeMobilePrivateStorageErrorKind.read(from: &buf) + ) + } + + public static func write(_ value: MobilePrivateStorageStatus, into buf: inout [UInt8]) { + FfiConverterUInt32.write(value.version, into: &buf) + FfiConverterBool.write(value.configured, into: &buf) + FfiConverterBool.write(value.recoveredTemporaryFiles, into: &buf) + FfiConverterBool.write(value.recoveredCatalog, into: &buf) + FfiConverterBool.write(value.secureBindingPresent, into: &buf) + FfiConverterOptionTypeMobilePrivateStorageErrorKind.write(value.recovery, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobilePrivateStorageStatus_lift(_ buf: RustBuffer) throws -> MobilePrivateStorageStatus { + return try FfiConverterTypeMobilePrivateStorageStatus.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobilePrivateStorageStatus_lower(_ value: MobilePrivateStorageStatus) -> RustBuffer { + return FfiConverterTypeMobilePrivateStorageStatus.lower(value) +} + + public struct MobileShell: Equatable, Hashable { public var selectedTab: MobileTab public var pages: [MobilePage] @@ -8521,6 +8906,252 @@ public func FfiConverterTypeMobilePreferenceError_lower(_ value: MobilePreferenc } + +public enum MobilePrivateStorageErrorKind: Equatable, Hashable { + + case invalidRoots + case unsafePath + case missingRoot + case accessDenied + case readOnly + case storageFull + case busy + case io + case corruptLayout + case migrationRequired + case corruptConfiguration + case configurationMissingWithData + case interruptedSetup + case restoredWithoutKeystore + case secureStorageLocked + case secureStorageUnavailable + case invalidSecureBinding + + + + + +} + +#if compiler(>=6) +extension MobilePrivateStorageErrorKind: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeMobilePrivateStorageErrorKind: FfiConverterRustBuffer { + typealias SwiftType = MobilePrivateStorageErrorKind + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobilePrivateStorageErrorKind { + let variant: Int32 = try readInt(&buf) + switch variant { + + case 1: return .invalidRoots + + case 2: return .unsafePath + + case 3: return .missingRoot + + case 4: return .accessDenied + + case 5: return .readOnly + + case 6: return .storageFull + + case 7: return .busy + + case 8: return .io + + case 9: return .corruptLayout + + case 10: return .migrationRequired + + case 11: return .corruptConfiguration + + case 12: return .configurationMissingWithData + + case 13: return .interruptedSetup + + case 14: return .restoredWithoutKeystore + + case 15: return .secureStorageLocked + + case 16: return .secureStorageUnavailable + + case 17: return .invalidSecureBinding + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: MobilePrivateStorageErrorKind, into buf: inout [UInt8]) { + switch value { + + + case .invalidRoots: + writeInt(&buf, Int32(1)) + + + case .unsafePath: + writeInt(&buf, Int32(2)) + + + case .missingRoot: + writeInt(&buf, Int32(3)) + + + case .accessDenied: + writeInt(&buf, Int32(4)) + + + case .readOnly: + writeInt(&buf, Int32(5)) + + + case .storageFull: + writeInt(&buf, Int32(6)) + + + case .busy: + writeInt(&buf, Int32(7)) + + + case .io: + writeInt(&buf, Int32(8)) + + + case .corruptLayout: + writeInt(&buf, Int32(9)) + + + case .migrationRequired: + writeInt(&buf, Int32(10)) + + + case .corruptConfiguration: + writeInt(&buf, Int32(11)) + + + case .configurationMissingWithData: + writeInt(&buf, Int32(12)) + + + case .interruptedSetup: + writeInt(&buf, Int32(13)) + + + case .restoredWithoutKeystore: + writeInt(&buf, Int32(14)) + + + case .secureStorageLocked: + writeInt(&buf, Int32(15)) + + + case .secureStorageUnavailable: + writeInt(&buf, Int32(16)) + + + case .invalidSecureBinding: + writeInt(&buf, Int32(17)) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobilePrivateStorageErrorKind_lift(_ buf: RustBuffer) throws -> MobilePrivateStorageErrorKind { + return try FfiConverterTypeMobilePrivateStorageErrorKind.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobilePrivateStorageErrorKind_lower(_ value: MobilePrivateStorageErrorKind) -> RustBuffer { + return FfiConverterTypeMobilePrivateStorageErrorKind.lower(value) +} + + + +public +enum MobilePrivateStorageFfiError: Swift.Error, Equatable, Hashable, Foundation.LocalizedError { + + + + case Failed(kind: MobilePrivateStorageErrorKind + ) + + + + + + + public var errorDescription: String? { + String(reflecting: self) + } + +} + +#if compiler(>=6) +extension MobilePrivateStorageFfiError: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeMobilePrivateStorageFfiError: FfiConverterRustBuffer { + typealias SwiftType = MobilePrivateStorageFfiError + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobilePrivateStorageFfiError { + let variant: Int32 = try readInt(&buf) + switch variant { + + + + + case 1: return .Failed( + kind: try FfiConverterTypeMobilePrivateStorageErrorKind.read(from: &buf) + ) + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: MobilePrivateStorageFfiError, into buf: inout [UInt8]) { + switch value { + + + + + + case let .Failed(kind): + writeInt(&buf, Int32(1)) + FfiConverterTypeMobilePrivateStorageErrorKind.write(kind, into: &buf) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobilePrivateStorageFfiError_lift(_ buf: RustBuffer) throws -> MobilePrivateStorageFfiError { + return try FfiConverterTypeMobilePrivateStorageFfiError.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobilePrivateStorageFfiError_lower(_ value: MobilePrivateStorageFfiError) -> RustBuffer { + return FfiConverterTypeMobilePrivateStorageFfiError.lower(value) +} + + public enum MobileSecretError: Swift.Error, Equatable, Hashable, Foundation.LocalizedError { @@ -8637,6 +9268,7 @@ public func FfiConverterTypeMobileSecretError_lower(_ value: MobileSecretError) public enum MobileSecretLocator: Equatable, Hashable { + case applicationBinding case openPgpPassphrase(fingerprint: String ) case httpsGitCredential(serverId: String, applicationId: String @@ -8664,13 +9296,15 @@ public struct FfiConverterTypeMobileSecretLocator: FfiConverterRustBuffer { let variant: Int32 = try readInt(&buf) switch variant { - case 1: return .openPgpPassphrase(fingerprint: try FfiConverterString.read(from: &buf) + case 1: return .applicationBinding + + case 2: return .openPgpPassphrase(fingerprint: try FfiConverterString.read(from: &buf) ) - case 2: return .httpsGitCredential(serverId: try FfiConverterString.read(from: &buf), applicationId: try FfiConverterString.read(from: &buf) + case 3: return .httpsGitCredential(serverId: try FfiConverterString.read(from: &buf), applicationId: try FfiConverterString.read(from: &buf) ) - case 3: return .sshKeyPassphrase(fingerprint: try FfiConverterString.read(from: &buf) + case 4: return .sshKeyPassphrase(fingerprint: try FfiConverterString.read(from: &buf) ) default: throw UniffiInternalError.unexpectedEnumCase @@ -8681,19 +9315,23 @@ public struct FfiConverterTypeMobileSecretLocator: FfiConverterRustBuffer { switch value { - case let .openPgpPassphrase(fingerprint): + case .applicationBinding: writeInt(&buf, Int32(1)) + + + case let .openPgpPassphrase(fingerprint): + writeInt(&buf, Int32(2)) FfiConverterString.write(fingerprint, into: &buf) case let .httpsGitCredential(serverId,applicationId): - writeInt(&buf, Int32(2)) + writeInt(&buf, Int32(3)) FfiConverterString.write(serverId, into: &buf) FfiConverterString.write(applicationId, into: &buf) case let .sshKeyPassphrase(fingerprint): - writeInt(&buf, Int32(3)) + writeInt(&buf, Int32(4)) FfiConverterString.write(fingerprint, into: &buf) } @@ -9492,6 +10130,30 @@ fileprivate struct FfiConverterOptionTypeMobileHomeActionKind: FfiConverterRustB } } +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterOptionTypeMobilePrivateStorageErrorKind: FfiConverterRustBuffer { + typealias SwiftType = MobilePrivateStorageErrorKind? + + public static func write(_ value: SwiftType, into buf: inout [UInt8]) { + guard let value = value else { + writeInt(&buf, Int8(0)) + return + } + writeInt(&buf, Int8(1)) + FfiConverterTypeMobilePrivateStorageErrorKind.write(value, into: &buf) + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SwiftType { + switch try readInt(&buf) as Int8 { + case 0: return nil + case 1: return try FfiConverterTypeMobilePrivateStorageErrorKind.read(from: &buf) + default: throw UniffiInternalError.unexpectedOptionalTag + } + } +} + #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -10016,6 +10678,22 @@ public func validateMobileCredentialRequest(request: MobileCredentialRequest)thr ) }) } +public func mobilePrivateStorageFailure(error: MobilePrivateStorageErrorKind) -> MobileShell { + return try! FfiConverterTypeMobileShell_lift(try! rustCall() { + uniffiCallStatus in + uniffi_ironstorage_mobile_fn_func_mobile_private_storage_failure( + FfiConverterTypeMobilePrivateStorageErrorKind_lower(error),uniffiCallStatus + ) +}) +} +public func openMobilePrivateRuntime(directories: MobilePrivateDirectories)throws -> MobilePrivateRuntime { + return try FfiConverterTypeMobilePrivateRuntime_lift(try rustCallWithError(FfiConverterTypeMobilePrivateStorageFfiError_lift) { + uniffiCallStatus in + uniffi_ironstorage_mobile_fn_func_open_mobile_private_runtime( + FfiConverterTypeMobilePrivateDirectories_lower(directories),uniffiCallStatus + ) +}) +} private enum InitializationResult { case ok @@ -10089,6 +10767,12 @@ private let initializationResult: InitializationResult = { if (uniffi_ironstorage_mobile_checksum_func_validate_mobile_credential_request() != 60738) { return InitializationResult.apiChecksumMismatch } + if (uniffi_ironstorage_mobile_checksum_func_mobile_private_storage_failure() != 38666) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_ironstorage_mobile_checksum_func_open_mobile_private_runtime() != 27207) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_ironstorage_mobile_checksum_method_mobileauthentication_acknowledge_watch_snapshot() != 21391) { return InitializationResult.apiChecksumMismatch } @@ -10323,6 +11007,27 @@ private let initializationResult: InitializationResult = { if (uniffi_ironstorage_mobile_checksum_method_mobilesecretbackend_unlock() != 2737) { return InitializationResult.apiChecksumMismatch } + if (uniffi_ironstorage_mobile_checksum_method_mobileprivateruntime_activate() != 64835) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_ironstorage_mobile_checksum_method_mobileprivateruntime_authentication() != 21237) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_ironstorage_mobile_checksum_method_mobileprivateruntime_clear_disposable_cache() != 35831) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_ironstorage_mobile_checksum_method_mobileprivateruntime_layout() != 33040) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_ironstorage_mobile_checksum_method_mobileprivateruntime_shell() != 42966) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_ironstorage_mobile_checksum_method_mobileprivateruntime_status() != 46293) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_ironstorage_mobile_checksum_method_mobileprivateruntime_verify_secure_storage() != 12909) { + return InitializationResult.apiChecksumMismatch + } uniffiCallbackInitMobileClipboard() uniffiCallbackInitMobileClock() @@ -10529,6 +11234,22 @@ extension MobilePreferences: CustomStringConvertible, CustomDebugStringConvertib public var description: String { "MobilePreferences([REDACTED])" } public var debugDescription: String { description } } +extension MobilePrivateDirectories: CustomStringConvertible, CustomDebugStringConvertible { + public var description: String { "MobilePrivateDirectories([REDACTED])" } + public var debugDescription: String { description } +} +extension MobilePrivateStorageFfiError: CustomStringConvertible, CustomDebugStringConvertible { + public var description: String { "MobilePrivateStorageFfiError([REDACTED])" } + public var debugDescription: String { description } +} +extension MobilePrivateStorageLayout: CustomStringConvertible, CustomDebugStringConvertible { + public var description: String { "MobilePrivateStorageLayout([REDACTED])" } + public var debugDescription: String { description } +} +extension MobilePrivateStorageStatus: CustomStringConvertible, CustomDebugStringConvertible { + public var description: String { "MobilePrivateStorageStatus([REDACTED])" } + public var debugDescription: String { description } +} extension MobileSecretError: CustomStringConvertible, CustomDebugStringConvertible { public var description: String { "MobileSecretError([REDACTED])" } public var debugDescription: String { description } diff --git a/apple/Generated/ironstorage_mobileFFI.h b/apple/Generated/ironstorage_mobileFFI.h index 74ee1b7..e543ff7 100644 --- a/apple/Generated/ironstorage_mobileFFI.h +++ b/apple/Generated/ironstorage_mobileFFI.h @@ -860,6 +860,51 @@ void uniffi_ironstorage_mobile_fn_method_mobilesecretbackend_lock(uint64_t ptr, void uniffi_ironstorage_mobile_fn_method_mobilesecretbackend_unlock(uint64_t ptr, RustCallStatus *_Nonnull out_status ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_CLONE_MOBILEPRIVATERUNTIME +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_CLONE_MOBILEPRIVATERUNTIME +uint64_t uniffi_ironstorage_mobile_fn_clone_mobileprivateruntime(uint64_t handle, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_FREE_MOBILEPRIVATERUNTIME +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_FREE_MOBILEPRIVATERUNTIME +void uniffi_ironstorage_mobile_fn_free_mobileprivateruntime(uint64_t handle, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEPRIVATERUNTIME_ACTIVATE +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEPRIVATERUNTIME_ACTIVATE +void uniffi_ironstorage_mobile_fn_method_mobileprivateruntime_activate(uint64_t ptr, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEPRIVATERUNTIME_AUTHENTICATION +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEPRIVATERUNTIME_AUTHENTICATION +uint64_t uniffi_ironstorage_mobile_fn_method_mobileprivateruntime_authentication(uint64_t ptr, uint64_t backend, uint64_t clock, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEPRIVATERUNTIME_CLEAR_DISPOSABLE_CACHE +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEPRIVATERUNTIME_CLEAR_DISPOSABLE_CACHE +void uniffi_ironstorage_mobile_fn_method_mobileprivateruntime_clear_disposable_cache(uint64_t ptr, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEPRIVATERUNTIME_LAYOUT +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEPRIVATERUNTIME_LAYOUT +RustBuffer uniffi_ironstorage_mobile_fn_method_mobileprivateruntime_layout(uint64_t ptr, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEPRIVATERUNTIME_SHELL +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEPRIVATERUNTIME_SHELL +RustBuffer uniffi_ironstorage_mobile_fn_method_mobileprivateruntime_shell(uint64_t ptr, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEPRIVATERUNTIME_STATUS +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEPRIVATERUNTIME_STATUS +RustBuffer uniffi_ironstorage_mobile_fn_method_mobileprivateruntime_status(uint64_t ptr, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEPRIVATERUNTIME_VERIFY_SECURE_STORAGE +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEPRIVATERUNTIME_VERIFY_SECURE_STORAGE +void uniffi_ironstorage_mobile_fn_method_mobileprivateruntime_verify_secure_storage(uint64_t ptr, uint64_t backend, RustCallStatus *_Nonnull out_status +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_FUNC_MOBILE_AUTHENTICATION #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_FUNC_MOBILE_AUTHENTICATION uint64_t uniffi_ironstorage_mobile_fn_func_mobile_authentication(RustCallStatus *_Nonnull out_status @@ -962,6 +1007,16 @@ RustBuffer uniffi_ironstorage_mobile_fn_func_mobile_platform_layout(RustBuffer p RustBuffer uniffi_ironstorage_mobile_fn_func_validate_mobile_credential_request(RustBuffer request, RustCallStatus *_Nonnull out_status ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_FUNC_MOBILE_PRIVATE_STORAGE_FAILURE +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_FUNC_MOBILE_PRIVATE_STORAGE_FAILURE +RustBuffer uniffi_ironstorage_mobile_fn_func_mobile_private_storage_failure(RustBuffer error, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_FUNC_OPEN_MOBILE_PRIVATE_RUNTIME +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_FUNC_OPEN_MOBILE_PRIVATE_RUNTIME +uint64_t uniffi_ironstorage_mobile_fn_func_open_mobile_private_runtime(RustBuffer directories, RustCallStatus *_Nonnull out_status +); +#endif #ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_MOBILE_RUSTBUFFER_ALLOC #define UNIFFI_FFIDEF_FFI_IRONSTORAGE_MOBILE_RUSTBUFFER_ALLOC RustBuffer ffi_ironstorage_mobile_rustbuffer_alloc(uint64_t size, RustCallStatus *_Nonnull out_status @@ -1334,6 +1389,18 @@ uint16_t uniffi_ironstorage_mobile_checksum_func_mobile_platform_layout(void #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_FUNC_VALIDATE_MOBILE_CREDENTIAL_REQUEST uint16_t uniffi_ironstorage_mobile_checksum_func_validate_mobile_credential_request(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_FUNC_MOBILE_PRIVATE_STORAGE_FAILURE +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_FUNC_MOBILE_PRIVATE_STORAGE_FAILURE +uint16_t uniffi_ironstorage_mobile_checksum_func_mobile_private_storage_failure(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_FUNC_OPEN_MOBILE_PRIVATE_RUNTIME +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_FUNC_OPEN_MOBILE_PRIVATE_RUNTIME +uint16_t uniffi_ironstorage_mobile_checksum_func_open_mobile_private_runtime(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_ACKNOWLEDGE_WATCH_SNAPSHOT @@ -1802,6 +1869,48 @@ uint16_t uniffi_ironstorage_mobile_checksum_method_mobilesecretbackend_lock(void #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILESECRETBACKEND_UNLOCK uint16_t uniffi_ironstorage_mobile_checksum_method_mobilesecretbackend_unlock(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEPRIVATERUNTIME_ACTIVATE +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEPRIVATERUNTIME_ACTIVATE +uint16_t uniffi_ironstorage_mobile_checksum_method_mobileprivateruntime_activate(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEPRIVATERUNTIME_AUTHENTICATION +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEPRIVATERUNTIME_AUTHENTICATION +uint16_t uniffi_ironstorage_mobile_checksum_method_mobileprivateruntime_authentication(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEPRIVATERUNTIME_CLEAR_DISPOSABLE_CACHE +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEPRIVATERUNTIME_CLEAR_DISPOSABLE_CACHE +uint16_t uniffi_ironstorage_mobile_checksum_method_mobileprivateruntime_clear_disposable_cache(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEPRIVATERUNTIME_LAYOUT +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEPRIVATERUNTIME_LAYOUT +uint16_t uniffi_ironstorage_mobile_checksum_method_mobileprivateruntime_layout(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEPRIVATERUNTIME_SHELL +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEPRIVATERUNTIME_SHELL +uint16_t uniffi_ironstorage_mobile_checksum_method_mobileprivateruntime_shell(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEPRIVATERUNTIME_STATUS +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEPRIVATERUNTIME_STATUS +uint16_t uniffi_ironstorage_mobile_checksum_method_mobileprivateruntime_status(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEPRIVATERUNTIME_VERIFY_SECURE_STORAGE +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEPRIVATERUNTIME_VERIFY_SECURE_STORAGE +uint16_t uniffi_ironstorage_mobile_checksum_method_mobileprivateruntime_verify_secure_storage(void + ); #endif #ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_MOBILE_UNIFFI_CONTRACT_VERSION diff --git a/crates/mobile-bridge/src/lib.rs b/crates/mobile-bridge/src/lib.rs index 6cfc402..d9f578b 100644 --- a/crates/mobile-bridge/src/lib.rs +++ b/crates/mobile-bridge/src/lib.rs @@ -3,6 +3,9 @@ //! Shared UniFFI contract for native phone presentation. Watch exports are opt-in. +mod private_storage; +pub use private_storage::*; + use std::{ error::Error, fmt, diff --git a/crates/mobile-bridge/src/platform.rs b/crates/mobile-bridge/src/platform.rs index c09f3c3..1aefe0b 100644 --- a/crates/mobile-bridge/src/platform.rs +++ b/crates/mobile-bridge/src/platform.rs @@ -70,6 +70,7 @@ pub fn mobile_platform_layout( #[derive(Clone, uniffi::Enum)] pub enum MobileSecretLocator { + ApplicationBinding, OpenPgpPassphrase { fingerprint: String, }, @@ -85,6 +86,7 @@ pub enum MobileSecretLocator { impl From<&SecretLocator> for MobileSecretLocator { fn from(locator: &SecretLocator) -> Self { match locator { + SecretLocator::ApplicationBinding => Self::ApplicationBinding, SecretLocator::OpenPgpPassphrase { fingerprint } => Self::OpenPgpPassphrase { fingerprint: fingerprint.clone(), }, @@ -232,7 +234,7 @@ impl SecretStoreBackend for SecretBackendAdapter { pub trait MobileClock: Send + Sync { fn monotonic_milliseconds(&self) -> u64; } -struct ClockAdapter(Arc); +pub(crate) struct ClockAdapter(pub Arc); impl AuthenticationClock for ClockAdapter { fn now(&self) -> Duration { Duration::from_millis(self.0.monotonic_milliseconds()) diff --git a/crates/mobile-bridge/src/private_storage.rs b/crates/mobile-bridge/src/private_storage.rs new file mode 100644 index 0000000..b001548 --- /dev/null +++ b/crates/mobile-bridge/src/private_storage.rs @@ -0,0 +1,221 @@ +//! Mechanical exports for the storage-owned Android private runtime. +use crate::{ + MobileAuthentication, MobileShell, + platform::{ClockAdapter, MobileClock, MobileSecretBackend, SecretBackendAdapter}, +}; +use ironstorage::mobile_private_storage::{ + PrivateDirectories, PrivateStorage, PrivateStorageError, +}; +use std::sync::Arc; + +#[derive(Clone, uniffi::Record)] +pub struct MobilePrivateDirectories { + pub files: String, + pub no_backup: String, + pub cache: String, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum MobilePrivateStorageErrorKind { + InvalidRoots, + UnsafePath, + MissingRoot, + AccessDenied, + ReadOnly, + StorageFull, + Busy, + Io, + CorruptLayout, + MigrationRequired, + CorruptConfiguration, + ConfigurationMissingWithData, + InterruptedSetup, + RestoredWithoutKeystore, + SecureStorageLocked, + SecureStorageUnavailable, + InvalidSecureBinding, +} +impl From for MobilePrivateStorageErrorKind { + fn from(value: PrivateStorageError) -> Self { + match value { + PrivateStorageError::InvalidRoots => Self::InvalidRoots, + PrivateStorageError::UnsafePath => Self::UnsafePath, + PrivateStorageError::MissingRoot => Self::MissingRoot, + PrivateStorageError::AccessDenied => Self::AccessDenied, + PrivateStorageError::ReadOnly => Self::ReadOnly, + PrivateStorageError::StorageFull => Self::StorageFull, + PrivateStorageError::Busy => Self::Busy, + PrivateStorageError::Io => Self::Io, + PrivateStorageError::CorruptLayout => Self::CorruptLayout, + PrivateStorageError::MigrationRequired => Self::MigrationRequired, + PrivateStorageError::CorruptConfiguration => Self::CorruptConfiguration, + PrivateStorageError::ConfigurationMissingWithData => Self::ConfigurationMissingWithData, + PrivateStorageError::InterruptedSetup => Self::InterruptedSetup, + PrivateStorageError::RestoredWithoutKeystore => Self::RestoredWithoutKeystore, + PrivateStorageError::SecureStorageLocked => Self::SecureStorageLocked, + PrivateStorageError::SecureStorageUnavailable => Self::SecureStorageUnavailable, + PrivateStorageError::InvalidSecureBinding => Self::InvalidSecureBinding, + } + } +} +#[derive(Debug, uniffi::Error)] +pub enum MobilePrivateStorageFfiError { + Failed { kind: MobilePrivateStorageErrorKind }, +} +impl std::fmt::Display for MobilePrivateStorageFfiError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("Private storage operation failed") + } +} +impl std::error::Error for MobilePrivateStorageFfiError {} +impl From for MobilePrivateStorageFfiError { + fn from(value: PrivateStorageError) -> Self { + Self::Failed { kind: value.into() } + } +} + +#[derive(Clone, uniffi::Record)] +pub struct MobilePrivateStorageStatus { + pub version: u32, + pub configured: bool, + pub recovered_temporary_files: bool, + pub recovered_catalog: bool, + pub secure_binding_present: bool, + pub recovery: Option, +} +#[derive(Clone, uniffi::Record)] +pub struct MobilePrivateStorageLayout { + pub data: String, + pub configuration: String, + pub vault: String, + pub keys: String, + pub catalog: String, + pub scratch: String, + pub cache: String, +} + +#[derive(uniffi::Object)] +pub struct MobilePrivateRuntime { + storage: PrivateStorage, +} +#[uniffi::export] +impl MobilePrivateRuntime { + pub fn activate(&self) -> Result<(), MobilePrivateStorageFfiError> { + self.storage.activate().map_err(Into::into) + } + pub fn shell(&self) -> MobileShell { + self.storage.shell().into() + } + pub fn status(&self) -> MobilePrivateStorageStatus { + let status = self.storage.status(); + MobilePrivateStorageStatus { + version: status.version, + configured: status.configured, + recovered_temporary_files: status.recovered_temporary_files, + recovered_catalog: status.recovered_catalog, + secure_binding_present: status.secure_binding_present, + recovery: status.recovery.map(Into::into), + } + } + pub fn layout(&self) -> MobilePrivateStorageLayout { + let root = self.storage.root(); + let path = |name| root.join(name).to_string_lossy().into_owned(); + MobilePrivateStorageLayout { + data: root.to_string_lossy().into_owned(), + configuration: path("config.toml"), + vault: path("vault"), + keys: path("keys"), + catalog: path("totp-catalog.toml"), + scratch: path("scratch"), + cache: self.storage.cache_path().to_string_lossy().into_owned(), + } + } + pub fn verify_secure_storage( + &self, + backend: Arc, + ) -> Result<(), MobilePrivateStorageFfiError> { + self.storage + .verify_secure_storage(&SecretBackendAdapter(backend)) + .map_err(Into::into) + } + pub fn clear_disposable_cache(&self) -> Result<(), MobilePrivateStorageFfiError> { + self.storage.clear_disposable_cache().map_err(Into::into) + } + pub fn authentication( + &self, + backend: Arc, + clock: Arc, + ) -> Result, crate::MobileAuthenticationFfiError> { + self.storage + .verify_secure_storage(&SecretBackendAdapter(Arc::clone(&backend))) + .map_err(private_auth_error)?; + let config = self + .storage + .configuration() + .map_err(private_auth_error)? + .ok_or_else(|| private_auth_error(PrivateStorageError::ConfigurationMissingWithData))?; + let authentication = + ironstorage::mobile_authentication::MobileAuthentication::with_platform( + config, + Arc::new(SecretBackendAdapter(backend)), + Arc::new(ClockAdapter(clock)), + )?; + Ok(Arc::new(MobileAuthentication { + authentication: Arc::new(authentication), + })) + } +} +fn private_auth_error(error: PrivateStorageError) -> crate::MobileAuthenticationFfiError { + crate::MobileAuthenticationFfiError::Failed { + kind: crate::MobileAuthenticationErrorKind::Configuration, + title: "Private Storage Recovery Required".into(), + detail: error.detail().into(), + } +} +#[uniffi::export] +pub fn open_mobile_private_runtime( + directories: MobilePrivateDirectories, +) -> Result, MobilePrivateStorageFfiError> { + let storage = PrivateStorage::open(PrivateDirectories { + files: directories.files.into(), + no_backup: directories.no_backup.into(), + cache: directories.cache.into(), + })?; + Ok(Arc::new(MobilePrivateRuntime { storage })) +} + +#[uniffi::export] +pub fn mobile_private_storage_failure(error: MobilePrivateStorageErrorKind) -> MobileShell { + let value = match error { + MobilePrivateStorageErrorKind::InvalidRoots => PrivateStorageError::InvalidRoots, + MobilePrivateStorageErrorKind::UnsafePath => PrivateStorageError::UnsafePath, + MobilePrivateStorageErrorKind::MissingRoot => PrivateStorageError::MissingRoot, + MobilePrivateStorageErrorKind::AccessDenied => PrivateStorageError::AccessDenied, + MobilePrivateStorageErrorKind::ReadOnly => PrivateStorageError::ReadOnly, + MobilePrivateStorageErrorKind::StorageFull => PrivateStorageError::StorageFull, + MobilePrivateStorageErrorKind::Busy => PrivateStorageError::Busy, + MobilePrivateStorageErrorKind::Io => PrivateStorageError::Io, + MobilePrivateStorageErrorKind::CorruptLayout => PrivateStorageError::CorruptLayout, + MobilePrivateStorageErrorKind::MigrationRequired => PrivateStorageError::MigrationRequired, + MobilePrivateStorageErrorKind::CorruptConfiguration => { + PrivateStorageError::CorruptConfiguration + } + MobilePrivateStorageErrorKind::ConfigurationMissingWithData => { + PrivateStorageError::ConfigurationMissingWithData + } + MobilePrivateStorageErrorKind::InterruptedSetup => PrivateStorageError::InterruptedSetup, + MobilePrivateStorageErrorKind::RestoredWithoutKeystore => { + PrivateStorageError::RestoredWithoutKeystore + } + MobilePrivateStorageErrorKind::SecureStorageLocked => { + PrivateStorageError::SecureStorageLocked + } + MobilePrivateStorageErrorKind::SecureStorageUnavailable => { + PrivateStorageError::SecureStorageUnavailable + } + MobilePrivateStorageErrorKind::InvalidSecureBinding => { + PrivateStorageError::InvalidSecureBinding + } + }; + ironstorage::mobile::MobileShell::storage_failure(value.detail().into()).into() +} diff --git a/crates/storage/Cargo.toml b/crates/storage/Cargo.toml index cd8763a..7c741c7 100644 --- a/crates/storage/Cargo.toml +++ b/crates/storage/Cargo.toml @@ -30,6 +30,7 @@ full = [ "dep:regex", "dep:reqwest", "dep:rustls", + "dep:rustix", "dep:rqrr", "dep:secret-service", "dep:security-framework", @@ -46,6 +47,7 @@ watch = [] [dependencies] cap-std = { workspace = true, optional = true } cap-tempfile = { workspace = true, optional = true } +rustix = { workspace = true, optional = true } clap = { workspace = true, optional = true } clap_complete = { workspace = true, optional = true } data-encoding.workspace = true diff --git a/crates/storage/src/atomic_file.rs b/crates/storage/src/atomic_file.rs new file mode 100644 index 0000000..67c22f2 --- /dev/null +++ b/crates/storage/src/atomic_file.rs @@ -0,0 +1,109 @@ +//! Android cannot link anonymous temporary files through /proc under app SELinux. +#[cfg(not(any(target_os = "android", test)))] +pub(crate) use cap_tempfile::TempFile; + +#[cfg(any(target_os = "android", test))] +mod named { + #[cfg(unix)] + use cap_std::fs::OpenOptionsExt; + use cap_std::fs::{Dir, File, OpenOptions}; + use std::{ + ffi::OsStr, + io::{self, Write}, + }; + + pub(crate) struct TempFile<'a> { + directory: &'a Dir, + file: File, + name: String, + } + impl<'a> TempFile<'a> { + pub(crate) fn new(directory: &'a Dir) -> io::Result { + #[cfg(unix)] + let _creation = crate::file_lock::directory(directory)?; + for _ in 0..128 { + let name = format!(".ironstorage-atomic-{:032x}", rand::random::()); + let mut options = OpenOptions::new(); + options.read(true).write(true).create_new(true); + #[cfg(unix)] + options.mode(0o600); + match directory.open_with(&name, &options) { + Ok(file) => { + let temporary = Self { + directory, + file, + name, + }; + crate::file_lock::lock(&temporary.file.try_clone()?.into_std())?; + return Ok(temporary); + } + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, + Err(error) => return Err(error), + } + } + Err(io::ErrorKind::AlreadyExists.into()) + } + pub(crate) fn as_file(&self) -> &File { + &self.file + } + pub(crate) fn replace(self, destination: impl AsRef) -> io::Result<()> { + self.directory + .rename(&self.name, self.directory, destination.as_ref()) + } + } + impl Write for TempFile<'_> { + fn write(&mut self, bytes: &[u8]) -> io::Result { + self.file.write(bytes) + } + fn flush(&mut self) -> io::Result<()> { + self.file.flush() + } + } + impl Drop for TempFile<'_> { + fn drop(&mut self) { + let _ = self.directory.remove_file(&self.name); + } + } +} +#[cfg(any(target_os = "android", test))] +pub(crate) use named::TempFile; + +pub(crate) fn is_temporary(name: &str) -> bool { + name.strip_prefix(".ironstorage-atomic-") + .is_some_and(|suffix| suffix.len() == 32 && suffix.bytes().all(|b| b.is_ascii_hexdigit())) +} + +#[cfg(test)] +mod tests { + use super::*; + use cap_std::{ambient_authority, fs::Dir}; + use std::io::Write; + #[test] + fn replacement_is_atomic_private_locked_and_failures_preserve_the_target() { + let root = tempfile::tempdir().unwrap(); + let directory = Dir::open_ambient_dir(root.path(), ambient_authority()).unwrap(); + directory.write("target", b"old").unwrap(); + let mut temporary = TempFile::new(&directory).unwrap(); + temporary.write_all(b"new").unwrap(); + let name = directory + .entries() + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .find(|name| is_temporary(&name.to_string_lossy())) + .unwrap(); + let other = directory.open(&name).unwrap().into_std(); + assert_eq!( + crate::file_lock::try_lock(&other).unwrap_err().kind(), + std::io::ErrorKind::WouldBlock + ); + temporary.as_file().sync_all().unwrap(); + temporary.replace("target").unwrap(); + assert_eq!(directory.read("target").unwrap(), b"new"); + assert!(!directory.exists(&name)); + let mut failed = TempFile::new(&directory).unwrap(); + failed.write_all(b"partial").unwrap(); + assert!(failed.replace("missing/target").is_err()); + assert_eq!(directory.read("target").unwrap(), b"new"); + assert_eq!(directory.entries().unwrap().count(), 1); + } +} diff --git a/crates/storage/src/config.rs b/crates/storage/src/config.rs index 6551e14..6844aa5 100644 --- a/crates/storage/src/config.rs +++ b/crates/storage/src/config.rs @@ -8,11 +8,12 @@ use std::{ fs::OpenOptions, io::{self, Write}, path::{Component, Path, PathBuf}, + sync::OnceLock, time::Duration, }; +use crate::atomic_file::TempFile; use cap_std::{ambient_authority, fs::Dir}; -use cap_tempfile::TempFile; #[cfg(not(any(target_os = "ios", target_os = "watchos")))] use gix::bstr::ByteSlice as _; use serde::Deserialize; @@ -31,6 +32,7 @@ use crate::{ const APPLICATION_DIRECTORY: &str = "ironstorage"; const CONFIG_FILE: &str = "config.toml"; const MAX_CONFIG_BYTES: u64 = 1024 * 1024; +static PRIVATE_CONFIG_CONTEXT: OnceLock = OnceLock::new(); /// Validated application configuration. #[derive(Clone, Debug, PartialEq)] @@ -131,6 +133,10 @@ impl ConfigSettings { impl Config { /// Load an explicit configuration file, or the native per-user default. pub fn load(explicit: Option<&Path>) -> Result { + if let Some(path) = explicit.filter(|path| path.is_absolute()) { + let parent = path.parent().unwrap_or(Path::new("/")); + return ConfigLoader::new(parent.to_owned(), parent.to_owned()).load(Some(path)); + } ConfigLoader::system()?.load(explicit) } @@ -713,6 +719,10 @@ impl Config { let contents = toml::to_string_pretty(&self.document).map_err(|_| ConfigError::Write { path: self.source.clone(), })?; + #[cfg(unix)] + let creation = crate::file_lock::directory(&directory).map_err(|_| ConfigError::Write { + path: self.source.clone(), + })?; let temporary = (0..128_u8) .find_map(|attempt| { let path = parent.join(format!( @@ -739,10 +749,29 @@ impl Config { path: self.source.clone(), })?; let (temporary_path, mut temporary_file) = temporary; + crate::file_lock::lock(&temporary_file).map_err(|_| ConfigError::Write { + path: self.source.clone(), + })?; + #[cfg(unix)] + drop(creation); let installed = temporary_file .write_all(contents.as_bytes()) .and_then(|()| temporary_file.sync_all()) - .and_then(|()| fs::hard_link(&temporary_path, parent.join(name))); + .and_then(|()| { + #[cfg(target_os = "android")] + return rustix::fs::renameat_with( + &directory, + temporary_path + .file_name() + .expect("temporary file has a name"), + &directory, + name, + rustix::fs::RenameFlags::NOREPLACE, + ) + .map_err(Into::into); + #[cfg(not(target_os = "android"))] + fs::hard_link(&temporary_path, parent.join(name)) + }); drop(temporary_file); let _ = fs::remove_file(&temporary_path); match installed { @@ -829,10 +858,28 @@ pub struct ConfigLoader { impl ConfigLoader { pub fn system() -> Result { - let current_directory = env::current_dir().map_err(|_| ConfigError::CurrentDirectory)?; - let native_config_directory = - native_config_directory().ok_or(ConfigError::NativeConfigDirectory)?; - Ok(Self::new(current_directory, native_config_directory)) + if let Some(context) = PRIVATE_CONFIG_CONTEXT.get() { + return Ok(context.clone()); + } + #[cfg(target_os = "android")] + return Err(ConfigError::NativeConfigDirectory); + #[cfg(not(target_os = "android"))] + { + let current_directory = + env::current_dir().map_err(|_| ConfigError::CurrentDirectory)?; + let native_config_directory = + native_config_directory().ok_or(ConfigError::NativeConfigDirectory)?; + Ok(Self::new(current_directory, native_config_directory)) + } + } + + pub(crate) fn activate_private_root(root: &Path) -> bool { + let Some(parent) = root.parent() else { + return false; + }; + let context = Self::new(root.to_owned(), parent.to_owned()); + let _ = PRIVATE_CONFIG_CONTEXT.set(context.clone()); + PRIVATE_CONFIG_CONTEXT.get() == Some(&context) } pub fn new(current_directory: PathBuf, native_config_directory: PathBuf) -> Self { @@ -2328,7 +2375,10 @@ fn native_config_directory() -> Option { .map(|home| home.join("Library/Application Support")) } -#[cfg(all(unix, not(any(target_os = "ios", target_os = "macos"))))] +#[cfg(all( + unix, + not(any(target_os = "ios", target_os = "macos", target_os = "android")) +))] fn native_config_directory() -> Option { match env::var_os("XDG_CONFIG_HOME") { Some(path) if !path.is_empty() && Path::new(&path).is_absolute() => { diff --git a/crates/storage/src/file_lock.rs b/crates/storage/src/file_lock.rs new file mode 100644 index 0000000..8a8973b --- /dev/null +++ b/crates/storage/src/file_lock.rs @@ -0,0 +1,61 @@ +//! Android's pinned std lacks file locks; retain identical advisory-lock semantics. +use std::{fs::File, io}; + +pub(crate) fn lock(file: &File) -> io::Result<()> { + #[cfg(target_os = "android")] + return rustix::fs::flock(file, rustix::fs::FlockOperation::LockExclusive).map_err(Into::into); + #[cfg(not(target_os = "android"))] + file.lock() +} + +pub(crate) fn try_lock(file: &File) -> io::Result<()> { + #[cfg(target_os = "android")] + return rustix::fs::flock(file, rustix::fs::FlockOperation::NonBlockingLockExclusive) + .map_err(Into::into); + #[cfg(not(target_os = "android"))] + file.try_lock().map_err(|error| match error { + std::fs::TryLockError::WouldBlock => io::ErrorKind::WouldBlock.into(), + std::fs::TryLockError::Error(error) => error, + }) +} + +#[cfg(unix)] +pub(crate) fn directory(directory: &cap_std::fs::Dir) -> io::Result { + let file = directory.open(".")?.into_std(); + lock(&file)?; + Ok(file) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn independent_owners_contend_until_the_lock_is_dropped() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("owner.lock"); + let first = File::create(&path).unwrap(); + let second = File::open(&path).unwrap(); + lock(&first).unwrap(); + assert_eq!( + try_lock(&second).unwrap_err().kind(), + io::ErrorKind::WouldBlock + ); + drop(first); + try_lock(&second).unwrap(); + } + #[cfg(unix)] + #[test] + fn directory_gate_covers_file_creation_before_its_owner_lock() { + let root = tempfile::tempdir().unwrap(); + let dir = + cap_std::fs::Dir::open_ambient_dir(root.path(), cap_std::ambient_authority()).unwrap(); + let first = directory(&dir).unwrap(); + let second = dir.open(".").unwrap().into_std(); + assert_eq!( + try_lock(&second).unwrap_err().kind(), + io::ErrorKind::WouldBlock + ); + drop(first); + try_lock(&second).unwrap(); + } +} diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index 74c6c2d..2478935 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -5,6 +5,8 @@ //! //! This crate is the sole owner of stored and derived password-store objects. +#[cfg(feature = "full")] +mod atomic_file; #[cfg(feature = "full")] pub mod authentication; #[cfg(feature = "full")] @@ -18,6 +20,8 @@ pub mod desktop; #[cfg(feature = "full")] pub mod document; #[cfg(feature = "full")] +mod file_lock; +#[cfg(feature = "full")] pub mod generate; #[cfg(feature = "full")] pub mod git; @@ -42,6 +46,8 @@ pub mod mobile_passwords; #[cfg(feature = "full")] pub mod mobile_platform; #[cfg(feature = "full")] +pub mod mobile_private_storage; +#[cfg(feature = "full")] pub mod mobile_totp; pub mod mobile_watch; #[cfg(feature = "full")] diff --git a/crates/storage/src/mobile.rs b/crates/storage/src/mobile.rs index e8e4f58..eeae6f6 100644 --- a/crates/storage/src/mobile.rs +++ b/crates/storage/src/mobile.rs @@ -151,7 +151,11 @@ pub struct MobileShell { impl MobileShell { pub fn load() -> Self { - match Config::load(None) { + Self::from_configuration(Config::load(None)) + } + + pub fn from_configuration(configuration: Result) -> Self { + match configuration { Ok(config) => match Repository::open(config.vault()) { Ok(_) => Self::new(config.mobile_tab(), MobileShellState::Ready, None), Err(RepositoryError::Io { @@ -179,6 +183,10 @@ impl MobileShell { Self::new(MobileTab::Home, state, None) } + pub fn storage_failure(detail: String) -> Self { + Self::new(MobileTab::Home, MobileShellState::Error, Some(detail)) + } + pub fn selected_tab(&self) -> MobileTab { self.selected_tab } diff --git a/crates/storage/src/mobile_key_transfer.rs b/crates/storage/src/mobile_key_transfer.rs index c8fe0cf..6aebf01 100644 --- a/crates/storage/src/mobile_key_transfer.rs +++ b/crates/storage/src/mobile_key_transfer.rs @@ -2,8 +2,8 @@ use std::{collections::BTreeMap, error::Error, fmt, fs, io::Write as _, path::Path}; +use crate::atomic_file::TempFile; use cap_std::{ambient_authority, fs::Dir}; -use cap_tempfile::TempFile; use data_encoding::{BASE64URL_NOPAD, HEXLOWER}; use sha2::{Digest as _, Sha256}; diff --git a/crates/storage/src/mobile_private_storage.rs b/crates/storage/src/mobile_private_storage.rs new file mode 100644 index 0000000..d085df7 --- /dev/null +++ b/crates/storage/src/mobile_private_storage.rs @@ -0,0 +1,1037 @@ +//! Android private capabilities, durable layout and non-destructive recovery. + +use crate::atomic_file::TempFile; +use crate::{ + config::Config, + mobile::MobileShell, + secret_store::{SecretLocator, SecretProtection, SecretStoreBackend, SecretStoreError}, +}; +use cap_std::{ + ambient_authority, + fs::{Dir, OpenOptions}, +}; +use rand::{RngCore, rngs::OsRng}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::{ + fmt, fs, + io::{self, Read, Write}, + path::{Component, Path, PathBuf}, + sync::Mutex, +}; + +const VERSION: u32 = 1; +const MARKER: &str = "layout.toml"; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PrivateStorageError { + InvalidRoots, + UnsafePath, + MissingRoot, + AccessDenied, + ReadOnly, + StorageFull, + Busy, + Io, + CorruptLayout, + MigrationRequired, + CorruptConfiguration, + ConfigurationMissingWithData, + InterruptedSetup, + RestoredWithoutKeystore, + SecureStorageLocked, + SecureStorageUnavailable, + InvalidSecureBinding, +} +impl PrivateStorageError { + pub fn detail(self) -> &'static str { + match self { + Self::InvalidRoots => "Private directories must be canonical, distinct OS roots.", + Self::UnsafePath => "A private storage object is a link or escapes its capability.", + Self::MissingRoot => "An OS private directory is missing.", + Self::AccessDenied => "Private storage access was denied. Existing data was retained.", + Self::ReadOnly => "Private storage is read-only. Existing data was retained.", + Self::StorageFull => "Free device storage and retry. Existing data was retained.", + Self::Busy => "Another process is initializing private storage. Retry later.", + Self::Io => "Private storage could not be accessed. Existing data was retained.", + Self::CorruptLayout => { + "Storage layout metadata is damaged. Do not reset the repository." + } + Self::MigrationRequired => { + "This storage layout requires a compatible application version." + } + Self::CorruptConfiguration => { + "Configuration is damaged or references an invalid private location. Recover configuration without deleting the repository." + } + Self::ConfigurationMissingWithData => { + "Configuration is missing but private repository or key data remains. Recover it instead of starting a new store." + } + Self::InterruptedSetup => { + "An interrupted setup transaction remains. Its repository and key data were retained for recovery." + } + Self::RestoredWithoutKeystore => { + "Protected device storage is missing after restore or key loss. Recover credentials and passphrases; the repository was retained." + } + Self::SecureStorageLocked => "Unlock the device before accessing protected storage.", + Self::SecureStorageUnavailable => { + "Protected device storage is unavailable. Existing data was retained." + } + Self::InvalidSecureBinding => { + "Private data does not match its protected device binding. Recover protected credentials without replacing the repository." + } + } + } +} +impl fmt::Display for PrivateStorageError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.detail()) + } +} +impl std::error::Error for PrivateStorageError {} +impl From for PrivateStorageError { + fn from(error: io::Error) -> Self { + match error.kind() { + io::ErrorKind::NotFound => Self::MissingRoot, + io::ErrorKind::PermissionDenied => Self::AccessDenied, + io::ErrorKind::ReadOnlyFilesystem => Self::ReadOnly, + io::ErrorKind::StorageFull => Self::StorageFull, + io::ErrorKind::WouldBlock => Self::Busy, + _ => Self::Io, + } + } +} + +/// Root paths come only from the platform's Context directory APIs. +pub struct PrivateDirectories { + pub files: PathBuf, + pub no_backup: PathBuf, + pub cache: PathBuf, +} +impl fmt::Debug for PrivateDirectories { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("PrivateDirectories([REDACTED])") + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PrivateStorageStatus { + pub version: u32, + pub configured: bool, + pub recovered_temporary_files: bool, + pub recovered_catalog: bool, + pub secure_binding_present: bool, + pub recovery: Option, +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Layout { + version: u32, + binding: Option, +} + +pub struct PrivateStorage { + root: PathBuf, + cache_path: PathBuf, + data: Dir, + cache: Dir, + status: Mutex, +} + +impl PrivateStorage { + pub fn open(roots: PrivateDirectories) -> Result { + let paths = [&roots.files, &roots.no_backup, &roots.cache]; + for path in paths { + if !path.is_absolute() + || path.parent().is_none() + || path + .to_str() + .is_none_or(|p| p.len() > 4096 || p.contains('\0')) + || path + .components() + .any(|p| !matches!(p, Component::RootDir | Component::Normal(_))) + { + return Err(PrivateStorageError::InvalidRoots); + } + let metadata = fs::symlink_metadata(path)?; + if !metadata.is_dir() + || metadata.file_type().is_symlink() + || fs::canonicalize(path)?.as_path() != path.as_path() + { + return Err(PrivateStorageError::InvalidRoots); + } + } + for (i, path) in paths.iter().enumerate() { + if paths + .iter() + .enumerate() + .any(|(j, other)| i != j && path.starts_with(other)) + { + return Err(PrivateStorageError::InvalidRoots); + } + } + let no_backup = Dir::open_ambient_dir(&roots.no_backup, ambient_authority())?; + let os_cache = Dir::open_ambient_dir(&roots.cache, ambient_authority())?; + let data = private_directory(&no_backup, "ironstorage")?; + let cache = private_directory(&os_cache, "ironstorage")?; + let _initialization = exclusive_lock(&data, "runtime.lock")?; + let layout = match regular_bytes(&data, MARKER, 16 * 1024)? { + Some(bytes) => toml::from_str::( + std::str::from_utf8(&bytes).map_err(|_| PrivateStorageError::CorruptLayout)?, + ) + .map_err(|_| PrivateStorageError::CorruptLayout)?, + None => { + for entry in data.entries()? { + let name = entry?.file_name(); + if data.symlink_metadata(&name)?.file_type().is_symlink() { + return Err(PrivateStorageError::UnsafePath); + } + if name != "runtime.lock" + && !crate::atomic_file::is_temporary(&name.to_string_lossy()) + { + return Err(PrivateStorageError::CorruptLayout); + } + } + let layout = Layout { + version: VERSION, + binding: None, + }; + save_layout(&data, &layout)?; + layout + } + }; + if layout.version != VERSION { + return Err(PrivateStorageError::MigrationRequired); + } + if layout + .binding + .as_ref() + .is_some_and(|b| b.len() != 64 || !b.bytes().all(|b| b.is_ascii_hexdigit())) + { + return Err(PrivateStorageError::CorruptLayout); + } + private_directory(&data, "vault")?; + private_directory(&data, "keys")?; + let scratch = private_directory(&data, "scratch")?; + let recovered_temporary_files = clean_scratch(&scratch)? | clean_config_temporaries(&data)?; + tighten_tree(&open_directory(&data, "vault")?, 0)?; + tighten_tree(&open_directory(&data, "keys")?, 0)?; + let interrupted = data + .entries()? + .collect::, _>>()? + .iter() + .any(|entry| { + entry + .file_name() + .to_string_lossy() + .starts_with(".local-setup-") + }); + let root = roots.no_backup.join("ironstorage"); + let mut storage = Self { + root, + cache_path: roots.cache.join("ironstorage"), + data, + cache, + status: Mutex::new(PrivateStorageStatus { + version: VERSION, + configured: false, + recovered_temporary_files, + recovered_catalog: false, + secure_binding_present: layout.binding.is_some(), + recovery: None, + }), + }; + let configuration = storage.configuration(); + let (configured, mut recovery) = if interrupted { + (false, Some(PrivateStorageError::InterruptedSetup)) + } else { + match configuration { + Ok(config) => (config.is_some(), None), + Err(PrivateStorageError::ConfigurationMissingWithData) => ( + false, + Some(PrivateStorageError::ConfigurationMissingWithData), + ), + Err(error) => (false, Some(error)), + } + }; + if layout.binding.is_some() && recovery.is_none() { + recovery = Some(PrivateStorageError::SecureStorageUnavailable); + } + let recovered_catalog = crate::mobile_totp::validate_private_catalog( + &storage.root.join("vault"), + &storage.root.join("totp-catalog.toml"), + )?; + *storage + .status + .get_mut() + .map_err(|_| PrivateStorageError::Io)? = PrivateStorageStatus { + version: VERSION, + configured, + recovered_temporary_files, + recovered_catalog, + secure_binding_present: layout.binding.is_some(), + recovery, + }; + Ok(storage) + } + + pub fn root(&self) -> &Path { + &self.root + } + /// Every existing default-path API now uses this explicit OS context, never HOME/cwd. + pub fn activate(&self) -> Result<(), PrivateStorageError> { + if crate::config::ConfigLoader::activate_private_root(&self.root) { + Ok(()) + } else { + Err(PrivateStorageError::InvalidRoots) + } + } + pub fn cache_path(&self) -> &Path { + &self.cache_path + } + pub fn status(&self) -> PrivateStorageStatus { + let mut status = self + .status + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone(); + let configuration_recovery = matches!( + status.recovery, + None | Some( + PrivateStorageError::CorruptConfiguration + | PrivateStorageError::ConfigurationMissingWithData + ) + ); + match self.configuration() { + Ok(config) => { + status.configured = config.is_some(); + if configuration_recovery { + status.recovery = None; + } + } + Err(error) => { + status.configured = false; + if configuration_recovery { + status.recovery = Some(error); + } + } + } + status + } + pub fn shell(&self) -> MobileShell { + if let Some(error) = self.status().recovery { + return MobileShell::storage_failure(error.detail().into()); + } + match self.configuration() { + Ok(Some(config)) => MobileShell::from_configuration(Ok(config)), + Ok(None) => MobileShell::fixture(crate::mobile::MobileShellState::Empty), + Err(error) => MobileShell::storage_failure(error.detail().into()), + } + } + pub fn configuration(&self) -> Result, PrivateStorageError> { + let Some(bytes) = regular_bytes(&self.data, "config.toml", 1024 * 1024)? else { + let has_data = open_directory(&self.data, "vault")? + .entries()? + .next() + .transpose()? + .is_some() + || open_directory(&self.data, "keys")? + .entries()? + .next() + .transpose()? + .is_some(); + return if has_data { + Err(PrivateStorageError::ConfigurationMissingWithData) + } else { + Ok(None) + }; + }; + let value: toml::Value = toml::from_str( + std::str::from_utf8(&bytes).map_err(|_| PrivateStorageError::CorruptConfiguration)?, + ) + .map_err(|_| PrivateStorageError::CorruptConfiguration)?; + let key = value + .get("key_material") + .and_then(toml::Value::as_str) + .ok_or(PrivateStorageError::CorruptConfiguration)?; + if value.get("vault").and_then(toml::Value::as_str) != Some("vault") + || !(key == "keys" || key.starts_with("keys/")) + || Path::new(key) + .components() + .any(|p| !matches!(p, Component::Normal(_))) + { + return Err(PrivateStorageError::CorruptConfiguration); + } + Config::load(Some(&self.root.join("config.toml"))) + .map(Some) + .map_err(|_| PrivateStorageError::CorruptConfiguration) + } + + /// Never recreate an absent binding for existing bound data: that would hide key loss. + pub fn verify_secure_storage( + &self, + backend: &dyn SecretStoreBackend, + ) -> Result<(), PrivateStorageError> { + let _lock = exclusive_lock(&self.data, "runtime.lock")?; + let bytes = regular_bytes(&self.data, MARKER, 16 * 1024)? + .ok_or(PrivateStorageError::CorruptLayout)?; + let mut layout: Layout = toml::from_str( + std::str::from_utf8(&bytes).map_err(|_| PrivateStorageError::CorruptLayout)?, + ) + .map_err(|_| PrivateStorageError::CorruptLayout)?; + if layout.version != VERSION { + return Err(PrivateStorageError::MigrationRequired); + } + if layout.binding.as_ref().is_some_and(|binding| { + binding.len() != 64 || !binding.bytes().all(|b| b.is_ascii_hexdigit()) + }) { + return Err(PrivateStorageError::CorruptLayout); + } + backend.unlock().map_err(secret_error)?; + let result = (|| { + let locator = SecretLocator::ApplicationBinding; + let protection = SecretProtection::DeviceUnlocked; + let value = match backend.retrieve(&locator, protection) { + Ok(value) => value, + Err(SecretStoreError::Missing) if layout.binding.is_none() => { + let mut bytes = vec![0; 32]; + OsRng.fill_bytes(&mut bytes); + let value = crate::repository::SecretBytes::new(bytes); + match backend.create(&locator, protection, value.expose()) { + Ok(()) => value, + Err(SecretStoreError::AlreadyExists) => backend + .retrieve(&locator, protection) + .map_err(secret_error)?, + Err(error) => return Err(secret_error(error)), + } + } + Err(SecretStoreError::Missing) => { + return Err(PrivateStorageError::RestoredWithoutKeystore); + } + Err(error) => return Err(secret_error(error)), + }; + if value.expose().len() != 32 { + return Err(PrivateStorageError::InvalidSecureBinding); + } + let hash = data_encoding::HEXLOWER.encode(&Sha256::digest(value.expose())); + if layout + .binding + .as_ref() + .is_some_and(|expected| expected != &hash) + { + return Err(PrivateStorageError::InvalidSecureBinding); + } + if layout.binding.is_none() { + layout.binding = Some(hash); + save_layout(&self.data, &layout)?; + } + Ok(()) + })(); + let locked = backend.lock().map_err(secret_error); + let result = result.and(locked); + let mut status = self.status.lock().map_err(|_| PrivateStorageError::Io)?; + status.secure_binding_present = layout.binding.is_some(); + if let Err(error) = result { + status.recovery = Some(error); + } else if matches!( + status.recovery, + Some( + PrivateStorageError::RestoredWithoutKeystore + | PrivateStorageError::SecureStorageLocked + | PrivateStorageError::SecureStorageUnavailable + | PrivateStorageError::InvalidSecureBinding + ) + ) { + status.recovery = None; + } + result + } + + /// Only the disposable cache capability is traversed; durable TOTP metadata stays in no-backup. + pub fn clear_disposable_cache(&self) -> Result<(), PrivateStorageError> { + clean_scratch(&self.cache).map(|_| ()) + } + + /// Scratch ownership and its lock survive throughout the transaction; startup skips active owners. + pub fn with_scratch( + &self, + action: impl FnOnce(&Dir) -> Result, + ) -> Result { + let initialization = exclusive_lock(&self.data, "runtime.lock")?; + let scratch = open_directory(&self.data, "scratch")?; + let name = format!("transaction-{:016x}", rand::random::()); + let dir = private_directory(&scratch, &name)?; + let lock = exclusive_lock(&dir, "owner.lock")?; + drop(initialization); + let result = action(&dir); + drop(lock); + drop(dir); + let cleanup = scratch.remove_dir_all(&name).map_err(Into::into); + result.and_then(|value| cleanup.map(|()| value)) + } +} + +fn secret_error(error: SecretStoreError) -> PrivateStorageError { + match error { + SecretStoreError::Locked | SecretStoreError::Denied | SecretStoreError::Cancelled => { + PrivateStorageError::SecureStorageLocked + } + SecretStoreError::Missing => PrivateStorageError::RestoredWithoutKeystore, + SecretStoreError::Corrupted => PrivateStorageError::InvalidSecureBinding, + _ => PrivateStorageError::SecureStorageUnavailable, + } +} +fn private_directory(parent: &Dir, name: &str) -> Result { + match parent.create_dir(name) { + Ok(()) => {} + Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {} + Err(e) => return Err(e.into()), + } + let metadata = parent.symlink_metadata(name)?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(PrivateStorageError::UnsafePath); + } + let dir = open_directory(parent, name)?; + private_permissions(&dir.open(".")?, true)?; + Ok(dir) +} +fn regular_bytes( + parent: &Dir, + name: &str, + limit: u64, +) -> Result>, PrivateStorageError> { + let metadata = match parent.symlink_metadata(name) { + Ok(m) => m, + Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(e.into()), + }; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(PrivateStorageError::UnsafePath); + } + if metadata.len() > limit { + return Err(PrivateStorageError::CorruptConfiguration); + } + let file = parent.open(name)?; + private_permissions(&file, false)?; + let mut bytes = Vec::new(); + file.take(limit + 1).read_to_end(&mut bytes)?; + if bytes.len() as u64 > limit { + return Err(PrivateStorageError::CorruptConfiguration); + } + Ok(Some(bytes)) +} +fn exclusive_lock(parent: &Dir, name: &str) -> Result { + if let Ok(metadata) = parent.symlink_metadata(name) + && (metadata.file_type().is_symlink() || !metadata.is_file()) + { + return Err(PrivateStorageError::UnsafePath); + } + let mut options = OpenOptions::new(); + options.read(true).write(true).create(true); + let file = parent.open_with(name, &options)?; + private_permissions(&file, false)?; + let file = file.into_std(); + crate::file_lock::try_lock(&file)?; + Ok(file) +} +fn save_layout(parent: &Dir, layout: &Layout) -> Result<(), PrivateStorageError> { + let bytes = toml::to_string(layout).map_err(|_| PrivateStorageError::CorruptLayout)?; + let mut temporary = TempFile::new(parent)?; + private_permissions(temporary.as_file(), false)?; + temporary.write_all(bytes.as_bytes())?; + temporary.as_file().sync_all()?; + temporary.replace(MARKER)?; + parent.open(".")?.sync_all()?; + Ok(()) +} +fn clean_scratch(parent: &Dir) -> Result { + let mut removed = false; + for entry in parent.entries()? { + let entry = entry?; + let name = entry.file_name(); + let metadata = parent.symlink_metadata(&name)?; + if metadata.is_dir() && !metadata.file_type().is_symlink() { + let dir = open_directory(parent, &name)?; + let lock = match exclusive_lock(&dir, "owner.lock") { + Ok(lock) => lock, + Err(PrivateStorageError::Busy) => continue, + Err(error) => return Err(error), + }; + parent.remove_dir_all(&name)?; + drop(lock); + } else { + parent.remove_file(&name)?; + } + removed = true; + } + Ok(removed) +} +fn clean_config_temporaries(parent: &Dir) -> Result { + #[cfg(unix)] + let _creation = crate::file_lock::directory(parent)?; + let mut removed = false; + for entry in parent.entries()? { + let entry = entry?; + let name = entry.file_name(); + if !name.to_string_lossy().starts_with(".ironstorage-config-") + && !crate::atomic_file::is_temporary(&name.to_string_lossy()) + { + continue; + } + let metadata = parent.symlink_metadata(&name)?; + if metadata.file_type().is_symlink() { + parent.remove_file(&name)?; + removed = true; + continue; + } + if !metadata.is_file() { + return Err(PrivateStorageError::UnsafePath); + } + let file = parent.open(&name)?.into_std(); + match crate::file_lock::try_lock(&file) { + Ok(()) => { + parent.remove_file(&name)?; + removed = true; + } + Err(error) if error.kind() == io::ErrorKind::WouldBlock => {} + Err(error) => return Err(error.into()), + } + } + Ok(removed) +} +fn tighten_tree(parent: &Dir, depth: usize) -> Result<(), PrivateStorageError> { + #[cfg(unix)] + let _creation = crate::file_lock::directory(parent)?; + if depth > 256 { + return Err(PrivateStorageError::UnsafePath); + } + for entry in parent.entries()? { + let entry = entry?; + let name = entry.file_name(); + let metadata = parent.symlink_metadata(&name)?; + if crate::atomic_file::is_temporary(&name.to_string_lossy()) && metadata.is_file() { + let file = parent.open(&name)?.into_std(); + match crate::file_lock::try_lock(&file) { + Ok(()) => { + parent.remove_file(&name)?; + continue; + } + Err(error) if error.kind() == io::ErrorKind::WouldBlock => continue, + Err(error) => return Err(error.into()), + } + } + if metadata.file_type().is_symlink() { + return Err(PrivateStorageError::UnsafePath); + } + if metadata.is_dir() { + let dir = open_directory(parent, &name)?; + private_permissions(&dir.open(".")?, true)?; + tighten_tree(&dir, depth + 1)?; + } else if metadata.is_file() { + private_permissions(&parent.open(&name)?, false)?; + } else { + return Err(PrivateStorageError::UnsafePath); + } + } + Ok(()) +} +fn open_directory( + parent: &Dir, + name: impl AsRef, +) -> Result { + let mut components = Path::new(name.as_ref()).components(); + if !matches!(components.next(), Some(Component::Normal(_))) || components.next().is_some() { + return Err(PrivateStorageError::UnsafePath); + } + #[cfg(unix)] + { + let fd = rustix::fs::openat( + parent, + name.as_ref(), + rustix::fs::OFlags::RDONLY + | rustix::fs::OFlags::DIRECTORY + | rustix::fs::OFlags::NOFOLLOW + | rustix::fs::OFlags::CLOEXEC, + rustix::fs::Mode::empty(), + ) + .map_err(|error| PrivateStorageError::from(io::Error::from(error)))?; + Ok(Dir::from_std_file(fs::File::from(fd))) + } + #[cfg(not(unix))] + { + let _ = (parent, name); + Err(PrivateStorageError::InvalidRoots) + } +} +pub(crate) fn private_permissions( + file: &cap_std::fs::File, + directory: bool, +) -> Result<(), PrivateStorageError> { + #[cfg(unix)] + { + use cap_std::fs::{Permissions, PermissionsExt}; + let mode = if directory { + 0o700 + } else { + 0o600 | (file.metadata()?.permissions().mode() & 0o100) + }; + file.set_permissions(Permissions::from_mode(mode))?; + } + #[cfg(not(unix))] + let _ = (file, directory); + Ok(()) +} + +#[cfg(all(test, unix))] +mod tests { + use super::*; + use crate::{mobile::MobileShellState, repository::SecretBytes}; + use std::os::unix::{fs::PermissionsExt, fs::symlink}; + + fn roots(root: &Path) -> PrivateDirectories { + for name in ["files", "no-backup", "cache"] { + fs::create_dir_all(root.join(name)).unwrap(); + } + PrivateDirectories { + files: fs::canonicalize(root.join("files")).unwrap(), + no_backup: fs::canonicalize(root.join("no-backup")).unwrap(), + cache: fs::canonicalize(root.join("cache")).unwrap(), + } + } + fn configured(storage: &PrivateStorage) { + fs::write( + storage.root.join("keys/alice.asc"), + include_bytes!("../tests/fixtures/compatibility/keys/alice-secret.asc"), + ) + .unwrap(); + let fingerprint = + include_str!("../tests/fixtures/compatibility/stores/basic/.gpg-id").trim(); + fs::write(storage.root.join("vault/.gpg-id"), fingerprint).unwrap(); + fs::write( + storage.root.join("vault/mail.gpg"), + include_bytes!("../tests/fixtures/compatibility/stores/basic/email/personal.gpg"), + ) + .unwrap(); + Config::create_mobile_local( + storage.root.join("config.toml"), + &storage.root.join("vault"), + &storage.root.join("keys"), + fingerprint, + ) + .unwrap(); + } + fn catalog(storage: &PrivateStorage) -> Vec { + let bytes = format!( + "version = 1\nstore = {:?}\n[entries.mail]\nciphertext_hash = {:?}\nis_totp = false\n", + data_encoding::HEXLOWER.encode(&Sha256::digest(b"vault")), + data_encoding::HEXLOWER.encode(&Sha256::digest(include_bytes!( + "../tests/fixtures/compatibility/stores/basic/email/personal.gpg" + ))) + ) + .into_bytes(); + fs::write(storage.root.join("totp-catalog.toml"), &bytes).unwrap(); + bytes + } + #[test] + fn updates_recreation_relocation_and_cache_pressure_keep_real_pass_format_data() { + let temporary = tempfile::tempdir().unwrap(); + let first = temporary.path().join("first"); + let storage = PrivateStorage::open(roots(&first)).unwrap(); + assert!(!storage.status().configured); + assert_eq!(storage.shell().pages()[0].state(), MobileShellState::Empty); + configured(&storage); + let cache = catalog(&storage); + let config = fs::read(storage.root.join("config.toml")).unwrap(); + let cipher = fs::read(storage.root.join("vault/mail.gpg")).unwrap(); + fs::write(storage.cache_path.join("disposable"), b"discard").unwrap(); + storage.clear_disposable_cache().unwrap(); + drop(storage); + let updated = PrivateStorage::open(roots(&first)).unwrap(); + assert!(updated.status().configured && !updated.status().recovered_catalog); + assert_eq!( + fs::read(updated.root.join("totp-catalog.toml")).unwrap(), + cache + ); + assert_eq!( + fs::metadata(updated.root.join("keys/alice.asc")) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o600 + ); + assert_eq!( + fs::metadata(&updated.root).unwrap().permissions().mode() & 0o777, + 0o700 + ); + drop(updated); + let second = temporary.path().join("second"); + fs::rename(first, &second).unwrap(); + let relocated = PrivateStorage::open(roots(&second)).unwrap(); + assert!(relocated.status().configured && !relocated.status().recovered_catalog); + assert_eq!( + fs::read(relocated.root.join("config.toml")).unwrap(), + config + ); + assert_eq!( + fs::read(relocated.root.join("vault/mail.gpg")).unwrap(), + cipher + ); + assert_eq!( + fs::read(relocated.root.join("totp-catalog.toml")).unwrap(), + cache + ); + } + #[test] + fn corrupt_cache_is_rebuilt_but_corrupt_missing_or_escaping_config_retains_data() { + let temp = tempfile::tempdir().unwrap(); + let storage = PrivateStorage::open(roots(temp.path())).unwrap(); + configured(&storage); + let cipher = fs::read(storage.root.join("vault/mail.gpg")).unwrap(); + fs::write(storage.root.join("totp-catalog.toml"), b"partial write").unwrap(); + fs::write( + storage.root.join(".ironstorage-config-orphan"), + b"partial write", + ) + .unwrap(); + drop(storage); + let reopened = PrivateStorage::open(roots(temp.path())).unwrap(); + assert!( + reopened.status().configured + && reopened.status().recovered_catalog + && reopened.status().recovered_temporary_files + ); + assert!(!reopened.root.join("totp-catalog.toml").exists()); + fs::write( + reopened.root.join("config.toml"), + "vault = '../outside'\nkey_material = 'keys'\ndefault_key = 'alice'\n", + ) + .unwrap(); + let invalid = PrivateStorage::open(roots(temp.path())).unwrap(); + assert_eq!( + invalid.status().recovery, + Some(PrivateStorageError::CorruptConfiguration) + ); + fs::remove_file(invalid.root.join("config.toml")).unwrap(); + let missing = PrivateStorage::open(roots(temp.path())).unwrap(); + assert_eq!( + missing.status().recovery, + Some(PrivateStorageError::ConfigurationMissingWithData) + ); + assert_eq!( + fs::read(missing.root.join("vault/mail.gpg")).unwrap(), + cipher + ); + fs::remove_file(missing.root.join(MARKER)).unwrap(); + assert!(matches!( + PrivateStorage::open(roots(temp.path())), + Err(PrivateStorageError::CorruptLayout) + )); + assert!(!missing.root.join(MARKER).exists()); + assert_eq!( + fs::read(missing.root.join("vault/mail.gpg")).unwrap(), + cipher + ); + } + #[test] + fn orphan_cleanup_skips_live_transactions_and_never_follows_links_to_the_vault() { + let temp = tempfile::tempdir().unwrap(); + let storage = PrivateStorage::open(roots(temp.path())).unwrap(); + configured(&storage); + storage + .with_scratch(|dir| { + dir.write("temporary-plaintext", b"test-only secret")?; + PrivateStorage::open(roots(temp.path()))?; + assert_eq!(dir.read("temporary-plaintext")?, b"test-only secret"); + Ok(()) + }) + .unwrap(); + let orphan = storage.root.join("scratch/orphan"); + fs::create_dir(&orphan).unwrap(); + fs::write(orphan.join("temporary-plaintext"), b"test-only secret").unwrap(); + symlink( + storage.root.join("vault"), + storage.root.join("scratch/vault-link"), + ) + .unwrap(); + symlink( + storage.root.join("vault"), + storage.cache_path.join("vault-link"), + ) + .unwrap(); + let reopened = PrivateStorage::open(roots(temp.path())).unwrap(); + assert!(reopened.status().recovered_temporary_files && !orphan.exists()); + reopened.clear_disposable_cache().unwrap(); + assert!(reopened.root.join("vault/mail.gpg").is_file()); + let active = fs::File::create(reopened.root.join(".ironstorage-config-active")).unwrap(); + active.lock().unwrap(); + PrivateStorage::open(roots(temp.path())).unwrap(); + assert!(reopened.root.join(".ironstorage-config-active").exists()); + drop(active); + PrivateStorage::open(roots(temp.path())).unwrap(); + assert!(!reopened.root.join(".ironstorage-config-active").exists()); + let temporary = crate::atomic_file::TempFile::new(&reopened.data).unwrap(); + PrivateStorage::open(roots(temp.path())).unwrap(); + assert!( + reopened + .data + .entries() + .unwrap() + .any(|entry| crate::atomic_file::is_temporary( + &entry.unwrap().file_name().to_string_lossy() + )) + ); + drop(temporary); + let orphan = ".ironstorage-atomic-00000000000000000000000000000000"; + fs::write(reopened.root.join(orphan), b"interrupted atomic write").unwrap(); + fs::write( + reopened.root.join("vault").join(orphan), + b"interrupted ciphertext", + ) + .unwrap(); + PrivateStorage::open(roots(temp.path())).unwrap(); + assert!(!reopened.root.join(orphan).exists()); + assert!(!reopened.root.join("vault").join(orphan).exists()); + assert!(reopened.root.join("vault/mail.gpg").is_file()); + } + #[derive(Default)] + struct BindingBackend(Mutex>); + impl SecretStoreBackend for BindingBackend { + fn create( + &self, + locator: &SecretLocator, + protection: SecretProtection, + bytes: &[u8], + ) -> Result<(), SecretStoreError> { + assert_eq!(locator, &SecretLocator::ApplicationBinding); + assert_eq!(protection, SecretProtection::DeviceUnlocked); + let mut value = self.0.lock().unwrap(); + if value.is_some() { + return Err(SecretStoreError::AlreadyExists); + } + *value = Some(SecretBytes::new(bytes.to_vec())); + Ok(()) + } + fn retrieve( + &self, + _: &SecretLocator, + _: SecretProtection, + ) -> Result { + self.0 + .lock() + .unwrap() + .as_ref() + .map(|v| SecretBytes::new(v.expose().to_vec())) + .ok_or(SecretStoreError::Missing) + } + fn replace( + &self, + _: &SecretLocator, + _: SecretProtection, + bytes: &[u8], + ) -> Result<(), SecretStoreError> { + let mut value = self.0.lock().unwrap(); + if value.is_none() { + return Err(SecretStoreError::Missing); + } + *value = Some(SecretBytes::new(bytes.to_vec())); + Ok(()) + } + fn delete(&self, _: &SecretLocator, _: SecretProtection) -> Result<(), SecretStoreError> { + self.0 + .lock() + .unwrap() + .take() + .map(drop) + .ok_or(SecretStoreError::Missing) + } + } + #[test] + fn restored_or_mismatched_keystore_binding_is_a_typed_non_destructive_recovery() { + let temp = tempfile::tempdir().unwrap(); + let storage = PrivateStorage::open(roots(temp.path())).unwrap(); + configured(&storage); + let backend = BindingBackend::default(); + storage.verify_secure_storage(&backend).unwrap(); + let layout = fs::read(storage.root.join(MARKER)).unwrap(); + let reopened = PrivateStorage::open(roots(temp.path())).unwrap(); + reopened.verify_secure_storage(&backend).unwrap(); + backend + .delete( + &SecretLocator::ApplicationBinding, + SecretProtection::DeviceUnlocked, + ) + .unwrap(); + assert_eq!( + reopened.verify_secure_storage(&backend), + Err(PrivateStorageError::RestoredWithoutKeystore) + ); + assert_eq!(fs::read(storage.root.join(MARKER)).unwrap(), layout); + assert!(storage.root.join("vault/mail.gpg").is_file()); + backend + .create( + &SecretLocator::ApplicationBinding, + SecretProtection::DeviceUnlocked, + &[4; 32], + ) + .unwrap(); + assert_eq!( + reopened.verify_secure_storage(&backend), + Err(PrivateStorageError::InvalidSecureBinding) + ); + } + #[test] + fn unsafe_roots_unknown_versions_and_interrupted_setup_are_not_reset() { + let temp = tempfile::tempdir().unwrap(); + let storage = PrivateStorage::open(roots(temp.path())).unwrap(); + let previous = storage.root.join(".local-setup-orphan/previous-vault"); + fs::create_dir_all(&previous).unwrap(); + fs::write( + previous.join("saved.gpg"), + include_bytes!("../tests/fixtures/compatibility/stores/basic/email/personal.gpg"), + ) + .unwrap(); + let reopened = PrivateStorage::open(roots(temp.path())).unwrap(); + assert_eq!( + reopened.status().recovery, + Some(PrivateStorageError::InterruptedSetup) + ); + assert!(previous.join("saved.gpg").is_file()); + fs::write(storage.root.join(MARKER), "version = 99\n").unwrap(); + let backend = BindingBackend::default(); + assert_eq!( + storage.verify_secure_storage(&backend), + Err(PrivateStorageError::MigrationRequired) + ); + assert!(matches!( + PrivateStorage::open(roots(temp.path())), + Err(PrivateStorageError::MigrationRequired) + )); + let mut bad = roots(temp.path()); + bad.cache = bad.no_backup.clone(); + assert!(matches!( + PrivateStorage::open(bad), + Err(PrivateStorageError::InvalidRoots) + )); + assert_eq!( + PrivateStorageError::from(io::Error::from(io::ErrorKind::StorageFull)), + PrivateStorageError::StorageFull + ); + assert_eq!( + PrivateStorageError::from(io::Error::from(io::ErrorKind::ReadOnlyFilesystem)), + PrivateStorageError::ReadOnly + ); + let other = tempfile::tempdir().unwrap(); + let other_roots = roots(other.path()); + fs::create_dir(other_roots.no_backup.join("ironstorage")).unwrap(); + symlink( + &other_roots.files, + other_roots.no_backup.join("ironstorage/scratch"), + ) + .unwrap(); + assert!(matches!( + PrivateStorage::open(other_roots), + Err(PrivateStorageError::UnsafePath) + )); + } +} diff --git a/crates/storage/src/mobile_totp.rs b/crates/storage/src/mobile_totp.rs index e4fcc23..785c844 100644 --- a/crates/storage/src/mobile_totp.rs +++ b/crates/storage/src/mobile_totp.rs @@ -4,7 +4,7 @@ use std::{ collections::{BTreeMap, BTreeSet}, error::Error, fmt, fs, - io::Write as _, + io::{Read as _, Write as _}, path::Path, sync::{ Mutex, @@ -12,8 +12,8 @@ use std::{ }, }; +use crate::atomic_file::TempFile; use cap_std::{ambient_authority, fs::Dir}; -use cap_tempfile::TempFile; use data_encoding::HEXLOWER; use serde::{Deserialize, Serialize}; use sha2::{Digest as _, Sha256}; @@ -27,6 +27,7 @@ use crate::{ }; const CACHE_VERSION: u32 = 1; +const MAX_CATALOG_BYTES: u64 = 32 * 1024 * 1024; // ponytail: cache writes are rare; use per-cache locks only if profiling shows contention. static CACHE_WRITE_LOCK: Mutex<()> = Mutex::new(()); @@ -613,7 +614,9 @@ fn inspect_entry( fn load_cache(path: &Path, store: &str) -> (Option, Option) { if let Ok(metadata) = fs::symlink_metadata(path) - && (metadata.file_type().is_symlink() || !metadata.is_file()) + && (metadata.file_type().is_symlink() + || !metadata.is_file() + || metadata.len() > MAX_CATALOG_BYTES) { return ( None, @@ -642,6 +645,59 @@ fn load_cache(path: &Path, store: &str) -> (Option, Option Result { + use crate::mobile_private_storage::PrivateStorageError; + let _lock = CACHE_WRITE_LOCK + .lock() + .map_err(|_| PrivateStorageError::Io)?; + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(error) => return Err(error.into()), + }; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(PrivateStorageError::UnsafePath); + } + let store = store_identity(root, path); + let parent = Dir::open_ambient_dir( + path.parent().ok_or(PrivateStorageError::UnsafePath)?, + ambient_authority(), + )?; + let name = path.file_name().ok_or(PrivateStorageError::UnsafePath)?; + let file = parent.open(name)?; + crate::mobile_private_storage::private_permissions(&file, false)?; + let mut bytes = Vec::new(); + if metadata.len() <= MAX_CATALOG_BYTES { + file.take(MAX_CATALOG_BYTES + 1).read_to_end(&mut bytes)?; + } + let source = std::str::from_utf8(&bytes).ok(); + let valid = metadata.len() <= MAX_CATALOG_BYTES + && bytes.len() as u64 <= MAX_CATALOG_BYTES + && source + .and_then(|source| toml::from_str::(source).ok()) + .is_some_and(|catalog| { + catalog.version == CACHE_VERSION + && catalog.store == store + && catalog.entries.iter().all(|(entry, record)| { + EntryPath::parse(entry).is_ok() + && record.ciphertext_hash.len() == 64 + && record + .ciphertext_hash + .bytes() + .all(|byte| byte.is_ascii_hexdigit()) + }) + }); + if valid { + return Ok(false); + } + parent.remove_file(name)?; + parent.open(".")?.sync_all()?; + Ok(true) +} + fn upsert_cache_record( path: &Path, store: &str, diff --git a/crates/storage/src/mobile_watch.rs b/crates/storage/src/mobile_watch.rs index d43dc24..f30c67a 100644 --- a/crates/storage/src/mobile_watch.rs +++ b/crates/storage/src/mobile_watch.rs @@ -3,9 +3,9 @@ use std::{error::Error, fmt, path::Path}; #[cfg(feature = "full")] -use cap_std::{ambient_authority, fs::Dir}; +use crate::atomic_file::TempFile; #[cfg(feature = "full")] -use cap_tempfile::TempFile; +use cap_std::{ambient_authority, fs::Dir}; use data_encoding::HEXLOWER; #[cfg(feature = "full")] use data_encoding::HEXLOWER_PERMISSIVE; diff --git a/crates/storage/src/repository.rs b/crates/storage/src/repository.rs index 2fadc45..6c57d76 100644 --- a/crates/storage/src/repository.rs +++ b/crates/storage/src/repository.rs @@ -14,9 +14,9 @@ use std::collections::BTreeSet; #[cfg(test)] use std::sync::atomic::{AtomicUsize, Ordering}; +use crate::atomic_file::TempFile; pub use crate::secret::SecretBytes; use cap_std::{ambient_authority, fs::Dir}; -use cap_tempfile::TempFile; const ENTRY_EXTENSION: &str = "gpg"; const RECIPIENT_FILE: &str = ".gpg-id"; diff --git a/crates/storage/src/secret_store.rs b/crates/storage/src/secret_store.rs index 6db2b6f..93b014a 100644 --- a/crates/storage/src/secret_store.rs +++ b/crates/storage/src/secret_store.rs @@ -149,6 +149,7 @@ impl fmt::Debug for SecretReference { /// server/application pair can retrieve its account without TOML metadata. #[derive(Clone, Eq, Ord, PartialEq, PartialOrd)] pub enum SecretLocator { + ApplicationBinding, OpenPgpPassphrase { fingerprint: String, }, @@ -164,6 +165,7 @@ pub enum SecretLocator { impl fmt::Debug for SecretLocator { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { + Self::ApplicationBinding => formatter.write_str("SecretLocator::ApplicationBinding"), Self::OpenPgpPassphrase { .. } => { formatter.write_str("SecretLocator::OpenPgpPassphrase([REDACTED])") } @@ -180,6 +182,7 @@ impl fmt::Debug for SecretLocator { impl SecretLocator { fn service_and_user(&self) -> (&'static str, String) { match self { + Self::ApplicationBinding => ("de.rfc1437.ironstorage.private-binding", "device".into()), Self::OpenPgpPassphrase { fingerprint } => { (OPENPGP_PASSPHRASE_SERVICE, fingerprint.clone()) } @@ -246,6 +249,7 @@ impl SecretProtectionPolicy { SecretLocator::OpenPgpPassphrase { .. } => self.openpgp, SecretLocator::HttpsGitCredential { .. } => self.git, SecretLocator::SshKeyPassphrase { .. } => self.git, + SecretLocator::ApplicationBinding => SecretProtection::DeviceUnlocked, } } } diff --git a/crates/storage/src/ssh.rs b/crates/storage/src/ssh.rs index 37f6a49..f277a14 100644 --- a/crates/storage/src/ssh.rs +++ b/crates/storage/src/ssh.rs @@ -10,8 +10,8 @@ use std::{ time::Duration, }; +use crate::atomic_file::TempFile; use cap_std::{ambient_authority, fs::Dir}; -use cap_tempfile::TempFile; use hmac::{Hmac, Mac as _}; use russh::{ ChannelMsg, Disconnect, Preferred, cipher, client, compression, kex, diff --git a/crates/storage/tests/android_private_context.rs b/crates/storage/tests/android_private_context.rs new file mode 100644 index 0000000..fee0407 --- /dev/null +++ b/crates/storage/tests/android_private_context.rs @@ -0,0 +1,58 @@ +#![forbid(unsafe_code)] +use ironstorage::{ + config::{Config, ConfigLoader}, + mobile::{MobileShell, MobileShellState}, + mobile_onboarding::MobileOnboardingOperation, + mobile_private_storage::{PrivateDirectories, PrivateStorage}, + repository::SecretBytes, +}; +use std::{error::Error, fs}; + +#[test] +fn activated_os_roots_drive_existing_local_setup_and_configuration_apis() +-> Result<(), Box> { + let temp = tempfile::tempdir()?; + for name in ["files", "no-backup", "cache"] { + fs::create_dir(temp.path().join(name))?; + } + let storage = PrivateStorage::open(PrivateDirectories { + files: fs::canonicalize(temp.path().join("files"))?, + no_backup: fs::canonicalize(temp.path().join("no-backup"))?, + cache: fs::canonicalize(temp.path().join("cache"))?, + })?; + storage.activate()?; + assert_eq!( + ConfigLoader::system()?.default_path(), + storage.root().join("config.toml") + ); + assert_eq!( + MobileShell::load().pages()[0].state(), + MobileShellState::Empty + ); + MobileOnboardingOperation::default().setup_local_generated( + "Android private fixture ", + SecretBytes::new(b"public-test-passphrase".to_vec()), + false, + )?; + let config = Config::load(None)?; + assert!(storage.status().configured); + assert_eq!(config.vault(), storage.root().join("vault")); + assert_eq!(config.key_material(), storage.root().join("keys")); + assert_eq!( + MobileShell::load().pages()[0].state(), + MobileShellState::Ready + ); + assert!( + config + .source() + .strip_prefix(temp.path().join("files")) + .is_err() + ); + assert!( + config + .source() + .strip_prefix(temp.path().join("cache")) + .is_err() + ); + Ok(()) +} diff --git a/docs/android/mobile-contract.md b/docs/android/mobile-contract.md index 6a77080..f049b99 100644 --- a/docs/android/mobile-contract.md +++ b/docs/android/mobile-contract.md @@ -31,8 +31,10 @@ selected variants belong to the native UI. Actions already use typed enums. - `MobilePlatformPaths` accepts absolute private data and cache roots, rejects traversal and overlapping roots, and lets storage derive config/vault/key - paths. Validation does not create or migrate files. #90 owns initialization, - permissions, relocation, backup and cache policy. + paths. This lightweight validator does not create or migrate files. #90's + `MobilePrivateRuntime` opens the three canonical OS directory capabilities, + activates the shared configuration context, and owns initialization, + permissions, relocation, backup and cache policy; see [private storage](private-storage.md). - `MobileSecretBackend` performs create/retrieve/replace/delete/lock/unlock on a typed locator and protection supplied by storage. Values are opaque encoded storage records. OS errors are enums. It cannot select accounts, protection, diff --git a/docs/android/parity.md b/docs/android/parity.md index 6555a35..5b7739d 100644 --- a/docs/android/parity.md +++ b/docs/android/parity.md @@ -6,10 +6,12 @@ apple/Sources/AutoFill/CredentialProviderViewController.swift, crates/apple/src/ and crates/storage/src/mobile*.rs. See [architecture.md](architecture.md) for support, dependency, security and update policy. -**Status:** all Android implementation rows below are pending. No Android app -exists at this decision. The table is a complete assignment of work, not a -claim of completed parity. #111 must attach screen/component/test evidence to -each row. Every issue linked here belongs to milestone 23. +**Status:** #88 records the architecture baseline. The Android shell (#89), +native packaging (#92), shared contract (#91), and private runtime (#90) are +implemented; feature presentation rows remain assigned below. Issue comments +record per-issue validation and outstanding test gaps. #111 must attach the +final screen/component/test evidence to each row before claiming full parity. +Every issue linked here belongs to milestone 23. ## Current iPhone and shared-mobile capabilities diff --git a/docs/android/private-storage.md b/docs/android/private-storage.md new file mode 100644 index 0000000..1de94df --- /dev/null +++ b/docs/android/private-storage.md @@ -0,0 +1,87 @@ +# Android private storage (#90) + +The platform supplies canonical `Context.filesDir`, `noBackupFilesDir` and +`cacheDir` facts off the UI thread. `PrivateStorage` validates existing, distinct +roots and opens directory capabilities. It derives all subordinate paths; +Kotlin never lists, reads or mutates repository contents. `activate()` installs +one immutable process path context so existing Rust configuration/onboarding, +Git, key-transfer and TOTP APIs use these roots. Android calls made before +activation fail instead of consulting HOME, cwd, XDG or public storage. + +| Object | Placement | Update / cleanup policy | +| --- | --- | --- | +| pass-format repository | `noBackupFilesDir/ironstorage/vault` | Retained; existing storage/Git atomic mutation rules | +| GPG key material | `noBackupFilesDir/ironstorage/keys` | Retained; Rust crypto import/export only | +| secret-free configuration | `noBackupFilesDir/ironstorage/config.toml` | Atomic Rust writes; relative vault/key paths survive relocation | +| TOTP discovery catalog | `noBackupFilesDir/ironstorage/totp-catalog.toml` | Valid catalogs survive updates and relocation; invalid/oversized/wrong-store catalogs are removed and rebuilt by discovery | +| version / device binding | `noBackupFilesDir/ironstorage/layout.toml` | Atomic version 1 marker; unknown versions require a compatible migration | +| transactional scratch | `noBackupFilesDir/ironstorage/scratch` | Per-owner file locks; startup removes orphaned transactions and skips live owners | +| disposable cache | `cacheDir/ironstorage` | Only this capability is cleared by `clearDisposableCache`; vault/key/catalog data is retained | +| protected binding and credentials | OS secure backend / no-backup ciphertext (#93) | No serialized token or passphrase in configuration or layout metadata | +| `filesDir` | OS root fact only | No durable password-store object is placed here | + +Directory opens use safe Rust `rustix` handles with `NOFOLLOW`; names are single +components. Existing vault/key trees reject links and special files. Directories +are 0700; files are 0600, preserving an existing owner executable bit. These are +Unix permission checks inside the Android UID/SELinux sandbox, not promises +about physical media erasure or an untrusted OS. No application process or +handwritten unsafe FFI is introduced. + +Configuration must refer to `vault` and key material below `keys`. A corrupt or +missing configuration with remaining data produces typed recovery, never a new +store or automatic repository deletion. Interrupted `.local-setup-*` +transactions are retained because they can contain the previous repository. +Owned orphan `.ironstorage-config-*` and `.ironstorage-atomic-*` files are removed only after taking their +file lock; active atomic writers are left alone. A directory gate protects the +creation-to-owner-lock interval from concurrent startup cleanup. Android atomic files use +private named capabilities and rename because SELinux denies anonymous-file +linking via `/proc`; safe `rustix` locks supply the semantics absent from the +pinned Rust standard library on Android. Missing layout metadata with existing +data is a recovery error, so a device binding cannot be silently discarded. Scratch/cache cleanup unlinks +links without following their targets and cannot descend into the vault. + +`verifySecureStorage` uses the OS-mechanics callback from #91 and a storage-owned +`ApplicationBinding` locator. Rust creates an opaque 32-byte device record and +persists only its SHA-256 in layout metadata. A missing or mismatched protected +record for an already bound layout reports `RestoredWithoutKeystore` or +`InvalidSecureBinding`; it never creates a replacement that would conceal key +loss. Locked/unavailable storage has separate recovery kinds. #93 supplies the +real Android Keystore adapter; the contract already executes through the +injected backend and is tested with loss/mismatch fixtures. + +Errors distinguish full storage, read-only storage, access denial, missing +roots, concurrent initialization, corrupt configuration/layout and unsupported +layout versions. Failed initialization or writes leave existing repository, +key and configuration data intact. Free space or repair the reported condition +and retry; destructive reset is not an update/recovery operation. + +Normal APK/Play updates and process recreation retain no-backup app data. +Uninstall deletes the sandbox and Keystore entries; recovery then requires the +upstream Git repository and GPG key/passphrase or an explicit encrypted export. +Backup/transfer is not an alternative recovery path. The manifest disables +backup, and both legacy backup rules and cloud/device-transfer rules exclude +all nine credential- and device-protected domains. No-backup and cache roots +are intrinsically excluded by Android. See the [Android backup contract](https://developer.android.com/identity/data/autobackup). + +Validation includes real GPG fixture byte preservation through updates and +directory relocation, valid/corrupt TOTP catalogs, interrupted writes/setup, +concurrent scratch ownership, link-safe cleanup, device-binding loss/mismatch, +and typed storage-pressure/read-only I/O faults. A separate integration test +activates isolated roots and uses existing local onboarding to create an actual +Git repository and protected GPG key without environment/path overrides. +Instrumented tests inspect installed backup XML/manifest resources and execute +private initialization/recreation/cache clearing on persistent 4 KB and strict +16 KB Android emulators. No physical-device testing is required. + +Delivery verification on 2026-10-08: all four required workspace gates passed +(431 Rust tests), Android ktlint/unit/lint gates passed, and each persistent +API 37 emulator (4 KB and strict 16 KB) passed eight native tests and two app +tests covering all 25 shell state/tab combinations and installed backup policy. +The native fixture creates a real protected GPG key and Git repository through +Rust, persists a preference atomically, and survives subsequent APK/test-process +updates. Debug and release install-over/startup tests preserve the layout on both +emulators. All three ABI payloads pass the APK/AAB native audit. Swift private +runtime/redaction tests and 588 shared contract entries pass; iPhone, Watch and +AutoFill simulator builds pass. The persistent iPhone app also launches with its +existing store intact. Backup verification reads installed policy and package +flags; it does not export sandbox data to a transport. diff --git a/tools/android-build/tests/mobile_contract.swift b/tools/android-build/tests/mobile_contract.swift index b6abb55..ecf69a3 100644 --- a/tools/android-build/tests/mobile_contract.swift +++ b/tools/android-build/tests/mobile_contract.swift @@ -61,6 +61,33 @@ enum MobileContractProbe { } let request = try validateMobileCredentialRequest(request: MobileCredentialRequest(requestId: "opaque", package: "de.example.app", signingCertificateSha256: String(repeating: "A", count: 64), origins: ["https://EXAMPLE.test:443/"])) precondition(request.origins == ["https://example.test"]) - print("Swift native contract: fixtures/icons, callbacks, large payload, redaction, cancellation and concurrency passed") + do { try privateStorageProbe() } + catch MobilePrivateStorageFfiError.Failed(let kind) { + print("Swift private storage failed: \(kind)") + exit(1) + } + print("Swift native contract: fixtures/icons, callbacks, large payload, redaction, cancellation, concurrency and private storage passed") } + static func privateStorageProbe() throws { + let root = URL(fileURLWithPath: "/private/tmp").appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + for name in ["files", "no-backup", "cache"] { + try FileManager.default.createDirectory(at: root.appendingPathComponent(name), withIntermediateDirectories: true) + } + let directories = MobilePrivateDirectories( + files: root.appendingPathComponent("files").path, + noBackup: root.appendingPathComponent("no-backup").path, + cache: root.appendingPathComponent("cache").path + ) + precondition(!String(reflecting: directories).contains(root.path)) + let storage = try openMobilePrivateRuntime(directories: directories) + precondition(storage.status().version == 1 && !storage.status().configured) + precondition(storage.shell().pages.first?.state == .empty) + precondition(!String(reflecting: storage.layout()).contains(root.path)) + try storage.clearDisposableCache() + let recreated = try openMobilePrivateRuntime(directories: directories) + precondition(recreated.status() == storage.status()) + precondition(recreated.layout() == storage.layout()) + } + }