Share the platform-neutral Rust mobile contract (#91)
Dependency security audit / rustsec (push) Failing after 22s
Dependency security audit / rustsec (push) Failing after 22s
This commit is contained in:
1 parent
fcb36a0684
commit
180851087b
52 files changed
+8932
-4263
No files matched your search
Generated
+12
-3
@@ -5437,17 +5437,19 @@ dependencies = [
|
||||
name = "ironstorage-android"
|
||||
version = "1.0.1"
|
||||
dependencies = [
|
||||
"ironstorage-apple",
|
||||
"ironstorage-mobile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ironstorage-android-build"
|
||||
version = "1.0.1"
|
||||
dependencies = [
|
||||
"camino",
|
||||
"ironstorage",
|
||||
"object",
|
||||
"regex",
|
||||
"uniffi",
|
||||
"uniffi_bindgen",
|
||||
"zip",
|
||||
]
|
||||
|
||||
@@ -5455,8 +5457,7 @@ dependencies = [
|
||||
name = "ironstorage-apple"
|
||||
version = "1.0.1"
|
||||
dependencies = [
|
||||
"ironstorage",
|
||||
"uniffi",
|
||||
"ironstorage-mobile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5506,6 +5507,14 @@ dependencies = [
|
||||
"simple-file-manifest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ironstorage-mobile"
|
||||
version = "1.0.1"
|
||||
dependencies = [
|
||||
"ironstorage",
|
||||
"uniffi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ironstorage-tui"
|
||||
version = "1.0.1"
|
||||
|
||||
@@ -5,6 +5,7 @@ members = [
|
||||
"apps/tui",
|
||||
"crates/apple",
|
||||
"crates/android",
|
||||
"crates/mobile-bridge",
|
||||
"crates/storage",
|
||||
"crates/watch-apple",
|
||||
"tools/android-build",
|
||||
|
||||
@@ -21,10 +21,10 @@ class ShellScreenTest {
|
||||
ShellState.entries.forEach { state ->
|
||||
compose.onNodeWithText("Fixture: ${state.name}").performScrollTo().performClick()
|
||||
ShellTab.entries.forEach { tab ->
|
||||
compose.onNode(
|
||||
hasText(tab.name) and androidx.compose.ui.test.isSelectable()
|
||||
).performClick()
|
||||
val page = ShellFixtures.all.getValue(state).first { it.tab == tab }
|
||||
compose.onNode(
|
||||
hasText(page.title) and androidx.compose.ui.test.isSelectable()
|
||||
).performClick()
|
||||
compose.waitUntil(timeoutMillis = 10_000) {
|
||||
compose.onAllNodes(hasText(page.stateTitle) and isHeading())
|
||||
.fetchSemanticsNodes().size == 1
|
||||
|
||||
+36
-1
@@ -4,6 +4,7 @@ import android.content.Context
|
||||
import de.rfc1437.ironstorage.bridge.NativeBridgeException
|
||||
import de.rfc1437.ironstorage.bridge.NativeFailureKind
|
||||
import de.rfc1437.ironstorage.bridge.NativeRuntime
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileIcon
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileShellState
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileTab
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
@@ -57,7 +58,13 @@ private fun nativeLoader(context: Context): suspend (ShellState) -> ShellSnapsho
|
||||
withContext(Dispatchers.IO) {
|
||||
val shell = runtime.fixture(state.toNative())
|
||||
val pages = shell.pages.map { page ->
|
||||
ShellPage(page.tab.toFacade(), page.title, page.stateTitle, page.stateDetail)
|
||||
ShellPage(
|
||||
page.tab.toFacade(),
|
||||
page.title,
|
||||
page.stateTitle,
|
||||
page.stateDetail,
|
||||
page.icon.toFacade()
|
||||
)
|
||||
}
|
||||
val actual = shell.pages.firstOrNull()?.state
|
||||
?: throw NativeBridgeException(NativeFailureKind.RustFailure)
|
||||
@@ -99,3 +106,31 @@ private fun NativeFailureKind.toFacade(): NativeFailure = when (this) {
|
||||
NativeFailureKind.RustFailure -> NativeFailure.RustFailure
|
||||
NativeFailureKind.MainThreadCall -> NativeFailure.MainThreadCall
|
||||
}
|
||||
|
||||
private fun MobileIcon.toFacade(): ShellIcon = when (this) {
|
||||
MobileIcon.HOME -> ShellIcon.Home
|
||||
MobileIcon.KEY -> ShellIcon.Key
|
||||
MobileIcon.SEARCH -> ShellIcon.Search
|
||||
MobileIcon.TIMER -> ShellIcon.Timer
|
||||
MobileIcon.SETTINGS -> ShellIcon.Settings
|
||||
MobileIcon.WARNING -> ShellIcon.Warning
|
||||
MobileIcon.BRANCH -> ShellIcon.Branch
|
||||
MobileIcon.SUCCESS -> ShellIcon.Success
|
||||
MobileIcon.SYNC -> ShellIcon.Sync
|
||||
MobileIcon.PROTECTED -> ShellIcon.Protected
|
||||
MobileIcon.REPOSITORY -> ShellIcon.Repository
|
||||
MobileIcon.HISTORY -> ShellIcon.History
|
||||
MobileIcon.INCOMING -> ShellIcon.Incoming
|
||||
MobileIcon.OUTGOING -> ShellIcon.Outgoing
|
||||
MobileIcon.RECIPIENTS -> ShellIcon.Recipients
|
||||
MobileIcon.SIGNATURE -> ShellIcon.Signature
|
||||
MobileIcon.FILE -> ShellIcon.File
|
||||
MobileIcon.MERGE -> ShellIcon.Merge
|
||||
MobileIcon.REFRESH -> ShellIcon.Refresh
|
||||
MobileIcon.FOLDER -> ShellIcon.Folder
|
||||
MobileIcon.USER -> ShellIcon.User
|
||||
MobileIcon.EMAIL -> ShellIcon.Email
|
||||
MobileIcon.WEBSITE -> ShellIcon.Website
|
||||
MobileIcon.TEXT -> ShellIcon.Text
|
||||
MobileIcon.NOTE -> ShellIcon.Note
|
||||
}
|
||||
@@ -5,11 +5,40 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
enum class ShellState { Loading, Empty, Ready, Locked, Error }
|
||||
enum class ShellTab { Home, Passwords, Search, Totp, Preferences }
|
||||
|
||||
enum class ShellIcon {
|
||||
Home,
|
||||
Key,
|
||||
Search,
|
||||
Timer,
|
||||
Settings,
|
||||
Warning,
|
||||
Branch,
|
||||
Success,
|
||||
Sync,
|
||||
Protected,
|
||||
Repository,
|
||||
History,
|
||||
Incoming,
|
||||
Outgoing,
|
||||
Recipients,
|
||||
Signature,
|
||||
File,
|
||||
Merge,
|
||||
Refresh,
|
||||
Folder,
|
||||
User,
|
||||
Email,
|
||||
Website,
|
||||
Text,
|
||||
Note
|
||||
}
|
||||
|
||||
data class ShellPage(
|
||||
val tab: ShellTab,
|
||||
val title: String,
|
||||
val stateTitle: String,
|
||||
val detail: String
|
||||
val detail: String,
|
||||
val icon: ShellIcon
|
||||
)
|
||||
data class ShellSnapshot(
|
||||
val state: ShellState,
|
||||
|
||||
+4
-1
@@ -15,7 +15,10 @@ class NativeFixtureShellRepositoryTest {
|
||||
val pending = CompletableDeferred<Unit>()
|
||||
val repository = NativeFixtureShellRepository(backgroundScope) { state ->
|
||||
if (state == ShellState.Ready) withContext(NonCancellable) { pending.await() }
|
||||
ShellSnapshot(state, listOf(ShellPage(ShellTab.Home, "Home", state.name, "Fixture")))
|
||||
ShellSnapshot(
|
||||
state,
|
||||
listOf(ShellPage(ShellTab.Home, "Home", state.name, "Fixture", ShellIcon.Home))
|
||||
)
|
||||
}
|
||||
runCurrent()
|
||||
repository.showFixture(ShellState.Ready)
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
@@ -13,12 +14,14 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.heading
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.rfc1437.ironstorage.core.NativeFailure
|
||||
import de.rfc1437.ironstorage.core.ShellIcon
|
||||
import de.rfc1437.ironstorage.core.ShellState
|
||||
import de.rfc1437.ironstorage.core.ShellTab
|
||||
|
||||
@@ -28,12 +31,18 @@ fun ShellScreen(viewModel: ShellViewModel) {
|
||||
Scaffold(
|
||||
bottomBar = {
|
||||
NavigationBar {
|
||||
ShellTab.entries.forEach { tab ->
|
||||
snapshot.pages.forEach { page ->
|
||||
val tab = page.tab
|
||||
NavigationBarItem(
|
||||
selected = snapshot.selectedTab == tab,
|
||||
onClick = { viewModel.selectTab(tab) },
|
||||
icon = {},
|
||||
label = { Text(tab.name) }
|
||||
icon = {
|
||||
Icon(
|
||||
painterResource(navigationIcon(page.icon)),
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
label = { Text(page.title) }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -76,3 +85,12 @@ fun ShellScreen(viewModel: ShellViewModel) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun navigationIcon(icon: ShellIcon): Int = when (icon) {
|
||||
ShellIcon.Home -> R.drawable.ic_home
|
||||
ShellIcon.Key -> R.drawable.ic_key
|
||||
ShellIcon.Search -> R.drawable.ic_search
|
||||
ShellIcon.Timer -> R.drawable.ic_timer
|
||||
ShellIcon.Settings -> R.drawable.ic_settings
|
||||
else -> error("Rust returned a non-navigation icon for a navigation page")
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#FF000000" android:pathData="M10,20v-6h4v6h5v-8h3L12,3 2,12h3v8z" android:fillType="evenOdd" /></vector>
|
||||
@@ -0,0 +1 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#FF000000" android:pathData="M7,14a5,5 0,1 1,4.9,-6H22v4h-2v3h-3v-3h-5.1A5,5 0,0 1,7,14zM5,9a2,2 0,1 0,4,0a2,2 0,1 0,-4,0" android:fillType="evenOdd" /></vector>
|
||||
@@ -0,0 +1 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#FF000000" android:pathData="M9.5,3a6.5,6.5 0,1 0,4.1,11.6L20,21l1,-1 -6.4,-6.4A6.5,6.5 0,0 0,9.5,3zM9.5,5a4.5,4.5 0,1 1,0,9a4.5,4.5 0,1 1,0,-9" android:fillType="evenOdd" /></vector>
|
||||
@@ -0,0 +1 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#FF000000" android:pathData="M19.4,13a7,7 0,0 0,0,-2l2,-1.5 -2,-3.5 -2.3,1a7,7 0,0 0,-1.7,-1L15,3H9l-0.4,3a7,7 0,0 0,-1.7,1l-2.3,-1 -2,3.5 2,1.5a7,7 0,0 0,0,2l-2,1.5 2,3.5 2.3,-1a7,7 0,0 0,1.7,1L9,21h6l0.4,-3a7,7 0,0 0,1.7,-1l2.3,1 2,-3.5zM12,8a4,4 0,1 1,0,8a4,4 0,1 1,0,-8" android:fillType="evenOdd" /></vector>
|
||||
@@ -0,0 +1 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#FF000000" android:pathData="M9,1h6v2H9zM12,4a9,9 0,1 0,0,18a9,9 0,1 0,0,-18zM12,6a7,7 0,1 1,0,14a7,7 0,1 1,0,-14zM11,8h2v6h-2z" android:fillType="evenOdd" /></vector>
|
||||
@@ -46,6 +46,9 @@ val cargoExecutable = providers.environmentVariable("CARGO").orElse(
|
||||
}
|
||||
)
|
||||
val rustVersion = libs.versions.rust.get()
|
||||
val rustcExecutable = providers.exec {
|
||||
commandLine("rustup", "which", "--toolchain", rustVersion, "rustc")
|
||||
}.standardOutput.asText.map { it.trim() }
|
||||
val ndkDirectory = androidComponents.sdkComponents.ndkDirectory
|
||||
val sdkDirectory = androidComponents.sdkComponents.sdkDirectory
|
||||
val rustInputs = files(
|
||||
@@ -79,6 +82,8 @@ androidComponents.onVariants { variant ->
|
||||
inputs.property("ndkVersion", libs.versions.ndk.get())
|
||||
inputs.property("minSdk", libs.versions.minSdk.get())
|
||||
outputs.dir(rawNative)
|
||||
environment("RUSTUP_TOOLCHAIN", rustVersion)
|
||||
environment("RUSTC", rustcExecutable.get())
|
||||
environment("ANDROID_NDK_HOME", ndkDirectory.get().asFile.absolutePath)
|
||||
environment("ANDROID_HOME", sdkDirectory.get().asFile.absolutePath)
|
||||
environment(
|
||||
@@ -128,16 +133,10 @@ androidComponents.onVariants { variant ->
|
||||
outputs.dir(rawBindings)
|
||||
commandLine(
|
||||
cargoExecutable.get(), "+$rustVersion", "run", "--locked",
|
||||
"--package", "ironstorage-android-build", "--bin", "ironstorage-uniffi-bindgen",
|
||||
"--", "generate", "--library",
|
||||
nativeOutput.get().file(
|
||||
"arm64-v8a/libironstorage_android.so"
|
||||
).asFile.absolutePath,
|
||||
"--language", "kotlin", "--config",
|
||||
rootProject.file(
|
||||
"uniffi.toml"
|
||||
).absolutePath,
|
||||
"--out-dir", rawBindings.get().asFile.absolutePath, "--no-format"
|
||||
"--package", "ironstorage-android-build", "--", "contract-bindings",
|
||||
nativeOutput.get().file("arm64-v8a/libironstorage_android.so").asFile.absolutePath,
|
||||
rootProject.file("uniffi.toml").absolutePath,
|
||||
rawBindings.get().asFile.absolutePath, "phone"
|
||||
)
|
||||
}
|
||||
val prepare = tasks.register<Exec>("prepare${title}RustBindings") {
|
||||
@@ -153,7 +152,7 @@ androidComponents.onVariants { variant ->
|
||||
commandLine(
|
||||
cargoExecutable.get(), "+$rustVersion", "run", "--locked",
|
||||
"--package", "ironstorage-android-build", "--", "prepare-bindings",
|
||||
rawBindings.get().asFile.absolutePath, bindings.get().asFile.absolutePath
|
||||
rawBindings.get().dir("kotlin").asFile.absolutePath, bindings.get().asFile.absolutePath
|
||||
)
|
||||
}
|
||||
variant.sources.kotlin!!.addStaticSourceDirectory(bindings.get().asFile.absolutePath)
|
||||
@@ -162,3 +161,8 @@ androidComponents.onVariants { variant ->
|
||||
it.name == "pre${title}Build" || it.name == "compile${title}Kotlin"
|
||||
}.configureEach { dependsOn(prepare) }
|
||||
}
|
||||
|
||||
// Instrumentation suites share persistent emulators and must not compete for input focus.
|
||||
tasks.matching { it.name == "connectedDebugAndroidTest" }.configureEach {
|
||||
mustRunAfter(":app:connectedDebugAndroidTest")
|
||||
}
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
package de.rfc1437.ironstorage.bridge
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileCameraFrame
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileClipboard
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileClipboardContent
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileClipboardDisposition
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileClipboardWait
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileCredentialRequest
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileEntryCopy
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobilePlatformFfiException
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobilePlatformPaths
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileSecretLocator
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileShellState
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileTotpDiscoveryPhase
|
||||
import de.rfc1437.ironstorage.bridge.generated.copyMobileEntry
|
||||
import de.rfc1437.ironstorage.bridge.generated.decodeMobileCameraFrame
|
||||
import de.rfc1437.ironstorage.bridge.generated.mobilePlatformLayout
|
||||
import de.rfc1437.ironstorage.bridge.generated.mobileTotpOperation
|
||||
import de.rfc1437.ironstorage.bridge.generated.validateMobileCredentialRequest
|
||||
import java.util.concurrent.Executors
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Test
|
||||
|
||||
class MobileContractTest {
|
||||
private val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
|
||||
private fun initialize() {
|
||||
NativeRuntime(context).fixture(MobileShellState.EMPTY)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun privatePathsAndProviderFactsAreValidatedInRust() {
|
||||
initialize()
|
||||
val layout = mobilePlatformLayout(
|
||||
MobilePlatformPaths(context.noBackupFilesDir.path, context.cacheDir.path)
|
||||
)
|
||||
assertEquals("${context.noBackupFilesDir.path}/config.toml", layout.configuration)
|
||||
assertThrows(MobilePlatformFfiException.InvalidPaths::class.java) {
|
||||
mobilePlatformLayout(MobilePlatformPaths("relative", context.cacheDir.path))
|
||||
}
|
||||
val request = validateMobileCredentialRequest(
|
||||
MobileCredentialRequest(
|
||||
"opaque-request",
|
||||
"de.example.app",
|
||||
"A".repeat(64),
|
||||
listOf("https://EXAMPLE.test:443/")
|
||||
)
|
||||
)
|
||||
assertEquals(listOf("https://example.test"), request.origins)
|
||||
assertThrows(MobilePlatformFfiException.InvalidCredentialRequest::class.java) {
|
||||
validateMobileCredentialRequest(request.copy(origins = listOf("file:///tmp")))
|
||||
}
|
||||
assertFalse(request.toString().contains("opaque-request"))
|
||||
assertFalse(
|
||||
MobileSecretLocator.OpenPgpPassphrase("SecretReferenceMarker")
|
||||
.toString().contains("SecretReferenceMarker")
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun foreignClipboardCallbackTransfersLargeSecretsAndPreservesNewerContent() {
|
||||
initialize()
|
||||
val secret = "binding-payload-".repeat(70_000)
|
||||
val clipboard = Clipboard()
|
||||
assertEquals(
|
||||
MobileClipboardDisposition.RESTORED_PREVIOUS,
|
||||
copyMobileEntry(MobileEntryCopy(secret, 45uL), clipboard)
|
||||
)
|
||||
assertEquals("previous", clipboard.text)
|
||||
assertEquals(secret.length, clipboard.writtenBytes)
|
||||
assertEquals(45_000uL, clipboard.timeout)
|
||||
clipboard.newer = "newer content"
|
||||
assertEquals(
|
||||
MobileClipboardDisposition.PRESERVED_NEWER,
|
||||
copyMobileEntry(MobileEntryCopy(secret, 45uL), clipboard)
|
||||
)
|
||||
assertEquals("newer content", clipboard.text)
|
||||
clipboard.newer = null
|
||||
clipboard.cancelled = true
|
||||
assertThrows(MobilePlatformFfiException.Cancelled::class.java) {
|
||||
copyMobileEntry(MobileEntryCopy(secret, 45uL), clipboard)
|
||||
}
|
||||
assertEquals("newer content", clipboard.text)
|
||||
assertFalse(MobileClipboardContent.Text(secret.toByteArray()).toString().contains(secret))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cameraPlaneBoundsAndConcurrentOperationCancellationSurviveNativeOwnership() {
|
||||
initialize()
|
||||
assertThrows(MobilePlatformFfiException.InvalidCameraFrame::class.java) {
|
||||
decodeMobileCameraFrame(MobileCameraFrame(UInt.MAX_VALUE, 1u, 1u, 1u, byteArrayOf(0)))
|
||||
}
|
||||
assertThrows(MobilePlatformFfiException.QrNotFound::class.java) {
|
||||
decodeMobileCameraFrame(
|
||||
MobileCameraFrame(1024u, 1024u, 2048u, 1u, ByteArray(2 * 1024 * 1024) { -1 })
|
||||
)
|
||||
}
|
||||
val executor = Executors.newFixedThreadPool(4)
|
||||
try {
|
||||
val operations = List(16) { mobileTotpOperation() }
|
||||
val tasks = operations.map { operation ->
|
||||
executor.submit {
|
||||
operation.cancel()
|
||||
assertEquals(MobileTotpDiscoveryPhase.CANCELLED, operation.progress().phase)
|
||||
}
|
||||
}
|
||||
tasks.forEach { it.get() }
|
||||
operations.forEach { it.close() }
|
||||
} finally {
|
||||
executor.shutdownNow()
|
||||
}
|
||||
}
|
||||
|
||||
private class Clipboard : MobileClipboard {
|
||||
var text = "previous"
|
||||
var writtenBytes = 0
|
||||
var timeout = 0uL
|
||||
var newer: String? = null
|
||||
var cancelled = false
|
||||
override fun read(): MobileClipboardContent =
|
||||
MobileClipboardContent.Text(text.toByteArray())
|
||||
override fun write(value: ByteArray) {
|
||||
writtenBytes = maxOf(writtenBytes, value.size)
|
||||
text = value.decodeToString()
|
||||
}
|
||||
override fun clear() {
|
||||
text = ""
|
||||
}
|
||||
override fun waitForTimeout(milliseconds: ULong): MobileClipboardWait {
|
||||
timeout = milliseconds
|
||||
newer?.let { text = it }
|
||||
return if (cancelled) MobileClipboardWait.CANCELLED else MobileClipboardWait.ELAPSED
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -55,9 +55,9 @@ class NativeRuntimeTest {
|
||||
fun wrongNativeContractAndChecksumFailWithTypedRedactedErrors() {
|
||||
NativeRuntime(context).fixture(MobileShellState.EMPTY)
|
||||
val version = IntegrityCheckingUniffiLib
|
||||
.ffi_ironstorage_apple_uniffi_contract_version().toLong()
|
||||
.ffi_ironstorage_mobile_uniffi_contract_version().toLong()
|
||||
val checksum = IntegrityCheckingUniffiLib
|
||||
.uniffi_ironstorage_apple_checksum_func_mobile_shell_fixture().toLong() and 0xffffL
|
||||
.uniffi_ironstorage_mobile_checksum_func_mobile_shell_fixture().toLong() and 0xffffL
|
||||
NativeBindingChecks.requireMatch(-27278, 38258, NativeFailureKind.ChecksumMismatch)
|
||||
for ((actual, kind) in listOf(
|
||||
version to NativeFailureKind.ContractMismatch,
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+2607
-236
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+2
-2
@@ -1,5 +1,5 @@
|
||||
module ironstorage_appleFFI {
|
||||
header "ironstorage_appleFFI.h"
|
||||
module ironstorage_mobileFFI {
|
||||
header "ironstorage_mobileFFI.h"
|
||||
export *
|
||||
use "Darwin"
|
||||
use "_Builtin_stdbool"
|
||||
@@ -1 +1 @@
|
||||
#include "Generated/ironstorage_appleFFI.h"
|
||||
#include "Generated/ironstorage_mobileFFI.h"
|
||||
@@ -0,0 +1,82 @@
|
||||
import UIKit
|
||||
|
||||
extension MobileIcon {
|
||||
var systemImage: String {
|
||||
switch self {
|
||||
case .home: return "house"
|
||||
case .key: return "key"
|
||||
case .search: return "magnifyingglass"
|
||||
case .timer: return "timer"
|
||||
case .settings: return "gearshape"
|
||||
case .warning: return "exclamationmark.triangle"
|
||||
case .branch: return "point.3.connected.trianglepath.dotted"
|
||||
case .success: return "checkmark.circle"
|
||||
case .sync: return "arrow.triangle.2.circlepath"
|
||||
case .protected: return "checkmark.shield"
|
||||
case .repository: return "externaldrive.fill"
|
||||
case .history: return "clock.arrow.circlepath"
|
||||
case .incoming: return "arrow.down.circle"
|
||||
case .outgoing: return "arrow.up.circle"
|
||||
case .recipients: return "person.2"
|
||||
case .signature: return "signature"
|
||||
case .file: return "doc"
|
||||
case .merge: return "arrow.triangle.merge"
|
||||
case .refresh: return "arrow.clockwise.circle"
|
||||
case .folder: return "folder"
|
||||
case .user: return "person.fill"
|
||||
case .email: return "envelope.fill"
|
||||
case .website: return "globe"
|
||||
case .text: return "text.alignleft"
|
||||
case .note: return "note.text"
|
||||
}
|
||||
}
|
||||
|
||||
var selectedSystemImage: String {
|
||||
switch self {
|
||||
case .home: return "house.fill"
|
||||
case .key: return "key.fill"
|
||||
case .settings: return "gearshape.fill"
|
||||
default: return systemImage
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension MobilePage {
|
||||
var systemImage: String { icon.systemImage }
|
||||
}
|
||||
|
||||
extension MobileHomeAction {
|
||||
var systemImage: String { icon.systemImage }
|
||||
}
|
||||
|
||||
extension MobileHomeSummaryRow {
|
||||
var systemImage: String { icon.systemImage }
|
||||
}
|
||||
|
||||
extension MobileHomeChange {
|
||||
var systemImage: String { icon.systemImage }
|
||||
}
|
||||
|
||||
extension MobileHomeCommit {
|
||||
var systemImage: String { icon.systemImage }
|
||||
}
|
||||
|
||||
extension MobileHomeNotice {
|
||||
var systemImage: String { icon.systemImage }
|
||||
}
|
||||
|
||||
extension MobilePasswordRow {
|
||||
var systemImage: String { icon.systemImage }
|
||||
}
|
||||
|
||||
extension MobileEntryField {
|
||||
var systemImage: String { icon.systemImage }
|
||||
}
|
||||
|
||||
extension MobileEntryEditorField {
|
||||
var systemImage: String { icon.systemImage }
|
||||
}
|
||||
|
||||
extension MobilePage {
|
||||
var selectedSystemImage: String { icon.selectedSystemImage }
|
||||
}
|
||||
+2
-2
@@ -37,7 +37,7 @@ targets:
|
||||
- Resources/App/PrivacyInfo.xcprivacy
|
||||
- Resources/App/ThirdPartyLicenses.txt
|
||||
- Sources/App
|
||||
- Generated/ironstorage_apple.swift
|
||||
- Generated/ironstorage_mobile.swift
|
||||
dependencies:
|
||||
- target: IronStorageWatch
|
||||
embed: true
|
||||
@@ -72,7 +72,7 @@ targets:
|
||||
sources:
|
||||
- Resources/AutoFill/PrivacyInfo.xcprivacy
|
||||
- Sources/AutoFill
|
||||
- Generated/ironstorage_apple.swift
|
||||
- Generated/ironstorage_mobile.swift
|
||||
preBuildScripts:
|
||||
- name: Build Rust core
|
||||
basedOnDependencyAnalysis: false
|
||||
|
||||
@@ -13,4 +13,4 @@ name = "ironstorage_android"
|
||||
crate-type = ["lib", "cdylib"]
|
||||
|
||||
[dependencies]
|
||||
ironstorage-apple = { path = "../apple" }
|
||||
ironstorage-mobile = { path = "../mobile-bridge" }
|
||||
@@ -1,6 +1,6 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
//! Android packaging of the existing mobile UniFFI component.
|
||||
//! Android packaging of the shared phone UniFFI component.
|
||||
//! The shared component owns every conversion; no interface is copied here.
|
||||
|
||||
ironstorage_apple::uniffi_reexport_scaffolding!();
|
||||
ironstorage_mobile::uniffi_reexport_scaffolding!();
|
||||
@@ -13,5 +13,4 @@ name = "ironstorage_apple"
|
||||
crate-type = ["lib", "staticlib", "cdylib"]
|
||||
|
||||
[dependencies]
|
||||
ironstorage = { path = "../storage", default-features = false, features = ["full"] }
|
||||
uniffi.workspace = true
|
||||
ironstorage-mobile = { path = "../mobile-bridge", features = ["watch"] }
|
||||
+3
-2375
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "ironstorage-mobile"
|
||||
description = "Shared platform-neutral mobile UniFFI contract"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
publish = false
|
||||
|
||||
[features]
|
||||
default = []
|
||||
watch = []
|
||||
|
||||
[dependencies]
|
||||
ironstorage = { path = "../storage", default-features = false, features = ["full"] }
|
||||
uniffi.workspace = true
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,441 @@
|
||||
//! Mechanical adapters: foreign code performs OS calls, storage owns behavior.
|
||||
|
||||
use std::{fmt, sync::Arc, time::Duration};
|
||||
|
||||
use ironstorage::{
|
||||
authentication::AuthenticationClock,
|
||||
config::Config,
|
||||
mobile_platform as storage,
|
||||
presentation::{self, ClipboardBackend},
|
||||
repository::SecretBytes,
|
||||
secret_store::{SecretLocator, SecretProtection, SecretStoreBackend, SecretStoreError},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
MobileAuthentication, MobileAuthenticationFfiError, MobileAuthenticationState, MobileEntryCopy,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Error)]
|
||||
pub enum MobilePlatformFfiError {
|
||||
InvalidPaths,
|
||||
InvalidCameraFrame,
|
||||
QrNotFound,
|
||||
InvalidQrPayload,
|
||||
InvalidCredentialRequest,
|
||||
ClipboardFailed,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
impl fmt::Display for MobilePlatformFfiError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "Mobile platform operation failed: {self:?}")
|
||||
}
|
||||
}
|
||||
impl std::error::Error for MobilePlatformFfiError {}
|
||||
|
||||
#[derive(Clone, uniffi::Record)]
|
||||
pub struct MobilePlatformPaths {
|
||||
pub data_root: String,
|
||||
pub cache_root: String,
|
||||
}
|
||||
|
||||
impl TryFrom<MobilePlatformPaths> for storage::MobilePlatformPaths {
|
||||
type Error = MobilePlatformFfiError;
|
||||
fn try_from(paths: MobilePlatformPaths) -> Result<Self, Self::Error> {
|
||||
Self::new(paths.data_root.into(), paths.cache_root.into())
|
||||
.map_err(|_| MobilePlatformFfiError::InvalidPaths)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, uniffi::Record)]
|
||||
pub struct MobilePlatformLayout {
|
||||
pub configuration: String,
|
||||
pub vault: String,
|
||||
pub keys: String,
|
||||
pub cache: String,
|
||||
}
|
||||
|
||||
#[uniffi::export]
|
||||
pub fn mobile_platform_layout(
|
||||
paths: MobilePlatformPaths,
|
||||
) -> Result<MobilePlatformLayout, MobilePlatformFfiError> {
|
||||
let paths = storage::MobilePlatformPaths::try_from(paths)?;
|
||||
Ok(MobilePlatformLayout {
|
||||
configuration: paths.configuration().to_string_lossy().into_owned(),
|
||||
vault: paths.vault().to_string_lossy().into_owned(),
|
||||
keys: paths.keys().to_string_lossy().into_owned(),
|
||||
cache: paths.cache().to_string_lossy().into_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Clone, uniffi::Enum)]
|
||||
pub enum MobileSecretLocator {
|
||||
OpenPgpPassphrase {
|
||||
fingerprint: String,
|
||||
},
|
||||
HttpsGitCredential {
|
||||
server_id: String,
|
||||
application_id: String,
|
||||
},
|
||||
SshKeyPassphrase {
|
||||
fingerprint: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl From<&SecretLocator> for MobileSecretLocator {
|
||||
fn from(locator: &SecretLocator) -> Self {
|
||||
match locator {
|
||||
SecretLocator::OpenPgpPassphrase { fingerprint } => Self::OpenPgpPassphrase {
|
||||
fingerprint: fingerprint.clone(),
|
||||
},
|
||||
SecretLocator::HttpsGitCredential {
|
||||
server_id,
|
||||
application_id,
|
||||
} => Self::HttpsGitCredential {
|
||||
server_id: server_id.clone(),
|
||||
application_id: application_id.clone(),
|
||||
},
|
||||
SecretLocator::SshKeyPassphrase { fingerprint } => Self::SshKeyPassphrase {
|
||||
fingerprint: fingerprint.to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
|
||||
pub enum MobileSecretProtection {
|
||||
DeviceUnlocked,
|
||||
RequireUserPresence,
|
||||
BiometryCurrentSet,
|
||||
}
|
||||
impl From<SecretProtection> for MobileSecretProtection {
|
||||
fn from(value: SecretProtection) -> Self {
|
||||
match value {
|
||||
SecretProtection::DeviceUnlocked => Self::DeviceUnlocked,
|
||||
SecretProtection::RequireUserPresence => Self::RequireUserPresence,
|
||||
SecretProtection::BiometryCurrentSet => Self::BiometryCurrentSet,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Error)]
|
||||
pub enum MobileSecretError {
|
||||
AlreadyExists,
|
||||
Missing,
|
||||
Locked,
|
||||
Denied,
|
||||
Cancelled,
|
||||
Corrupted,
|
||||
UnsupportedProtection,
|
||||
Unavailable,
|
||||
}
|
||||
impl fmt::Display for MobileSecretError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "OS secret operation failed: {self:?}")
|
||||
}
|
||||
}
|
||||
impl std::error::Error for MobileSecretError {}
|
||||
impl From<MobileSecretError> for SecretStoreError {
|
||||
fn from(value: MobileSecretError) -> Self {
|
||||
match value {
|
||||
MobileSecretError::AlreadyExists => Self::AlreadyExists,
|
||||
MobileSecretError::Missing => Self::Missing,
|
||||
MobileSecretError::Locked => Self::Locked,
|
||||
MobileSecretError::Denied => Self::Denied,
|
||||
MobileSecretError::Cancelled => Self::Cancelled,
|
||||
MobileSecretError::Corrupted => Self::Corrupted,
|
||||
MobileSecretError::UnsupportedProtection => Self::UnsupportedProtection,
|
||||
MobileSecretError::Unavailable => Self::Unavailable,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Receives storage-encoded opaque bytes; cannot select accounts, leases or protection.
|
||||
#[uniffi::export(rust, foreign)]
|
||||
pub trait MobileSecretBackend: Send + Sync {
|
||||
fn create(
|
||||
&self,
|
||||
locator: MobileSecretLocator,
|
||||
protection: MobileSecretProtection,
|
||||
value: Vec<u8>,
|
||||
) -> Result<(), MobileSecretError>;
|
||||
fn retrieve(
|
||||
&self,
|
||||
locator: MobileSecretLocator,
|
||||
protection: MobileSecretProtection,
|
||||
) -> Result<Vec<u8>, MobileSecretError>;
|
||||
fn replace(
|
||||
&self,
|
||||
locator: MobileSecretLocator,
|
||||
protection: MobileSecretProtection,
|
||||
value: Vec<u8>,
|
||||
) -> Result<(), MobileSecretError>;
|
||||
fn delete(
|
||||
&self,
|
||||
locator: MobileSecretLocator,
|
||||
protection: MobileSecretProtection,
|
||||
) -> Result<(), MobileSecretError>;
|
||||
fn lock(&self) -> Result<(), MobileSecretError>;
|
||||
fn unlock(&self) -> Result<(), MobileSecretError>;
|
||||
}
|
||||
|
||||
pub(crate) struct SecretBackendAdapter(pub Arc<dyn MobileSecretBackend>);
|
||||
impl SecretStoreBackend for SecretBackendAdapter {
|
||||
fn create(
|
||||
&self,
|
||||
locator: &SecretLocator,
|
||||
protection: SecretProtection,
|
||||
value: &[u8],
|
||||
) -> Result<(), SecretStoreError> {
|
||||
self.0
|
||||
.create(locator.into(), protection.into(), value.to_vec())
|
||||
.map_err(Into::into)
|
||||
}
|
||||
fn retrieve(
|
||||
&self,
|
||||
locator: &SecretLocator,
|
||||
protection: SecretProtection,
|
||||
) -> Result<SecretBytes, SecretStoreError> {
|
||||
self.0
|
||||
.retrieve(locator.into(), protection.into())
|
||||
.map(SecretBytes::new)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
fn replace(
|
||||
&self,
|
||||
locator: &SecretLocator,
|
||||
protection: SecretProtection,
|
||||
value: &[u8],
|
||||
) -> Result<(), SecretStoreError> {
|
||||
self.0
|
||||
.replace(locator.into(), protection.into(), value.to_vec())
|
||||
.map_err(Into::into)
|
||||
}
|
||||
fn delete(
|
||||
&self,
|
||||
locator: &SecretLocator,
|
||||
protection: SecretProtection,
|
||||
) -> Result<(), SecretStoreError> {
|
||||
self.0
|
||||
.delete(locator.into(), protection.into())
|
||||
.map_err(Into::into)
|
||||
}
|
||||
fn lock(&self) -> Result<(), SecretStoreError> {
|
||||
self.0.lock().map_err(Into::into)
|
||||
}
|
||||
fn unlock(&self) -> Result<(), SecretStoreError> {
|
||||
self.0.unlock().map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
#[uniffi::export(rust, foreign)]
|
||||
pub trait MobileClock: Send + Sync {
|
||||
fn monotonic_milliseconds(&self) -> u64;
|
||||
}
|
||||
struct ClockAdapter(Arc<dyn MobileClock>);
|
||||
impl AuthenticationClock for ClockAdapter {
|
||||
fn now(&self) -> Duration {
|
||||
Duration::from_millis(self.0.monotonic_milliseconds())
|
||||
}
|
||||
}
|
||||
|
||||
#[uniffi::export]
|
||||
pub fn mobile_authentication_with_platform(
|
||||
paths: MobilePlatformPaths,
|
||||
backend: Arc<dyn MobileSecretBackend>,
|
||||
clock: Arc<dyn MobileClock>,
|
||||
) -> Result<Arc<MobileAuthentication>, MobileAuthenticationFfiError> {
|
||||
let paths = storage::MobilePlatformPaths::try_from(paths).map_err(|_| {
|
||||
MobileAuthenticationFfiError::Failed {
|
||||
kind: crate::MobileAuthenticationErrorKind::Configuration,
|
||||
title: "Invalid Private Paths".into(),
|
||||
detail: "The OS private roots are invalid.".into(),
|
||||
}
|
||||
})?;
|
||||
let config = Config::load(Some(&paths.configuration())).map_err(|error| {
|
||||
MobileAuthenticationFfiError::Failed {
|
||||
kind: crate::MobileAuthenticationErrorKind::Configuration,
|
||||
title: "Configuration Is Unavailable".into(),
|
||||
detail: error.to_string(),
|
||||
}
|
||||
})?;
|
||||
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),
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
|
||||
pub enum MobileLifecycleEvent {
|
||||
Foreground,
|
||||
Background,
|
||||
DeviceLocked,
|
||||
UserActivity,
|
||||
Cancel,
|
||||
}
|
||||
impl From<MobileLifecycleEvent> for storage::MobileLifecycleEvent {
|
||||
fn from(event: MobileLifecycleEvent) -> Self {
|
||||
match event {
|
||||
MobileLifecycleEvent::Foreground => Self::Foreground,
|
||||
MobileLifecycleEvent::Background => Self::Background,
|
||||
MobileLifecycleEvent::DeviceLocked => Self::DeviceLocked,
|
||||
MobileLifecycleEvent::UserActivity => Self::UserActivity,
|
||||
MobileLifecycleEvent::Cancel => Self::Cancel,
|
||||
}
|
||||
}
|
||||
}
|
||||
#[uniffi::export]
|
||||
impl MobileAuthentication {
|
||||
pub fn lifecycle(
|
||||
&self,
|
||||
event: MobileLifecycleEvent,
|
||||
) -> Result<MobileAuthenticationState, MobileAuthenticationFfiError> {
|
||||
self.authentication
|
||||
.lifecycle(event.into())
|
||||
.map(Into::into)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, uniffi::Record)]
|
||||
pub struct MobileCameraFrame {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub row_stride: u32,
|
||||
pub pixel_stride: u32,
|
||||
pub luminance: Vec<u8>,
|
||||
}
|
||||
#[uniffi::export]
|
||||
pub fn decode_mobile_camera_frame(
|
||||
frame: MobileCameraFrame,
|
||||
) -> Result<Vec<u8>, MobilePlatformFfiError> {
|
||||
storage::MobileCameraFrame {
|
||||
width: frame.width,
|
||||
height: frame.height,
|
||||
row_stride: frame.row_stride,
|
||||
pixel_stride: frame.pixel_stride,
|
||||
luminance: SecretBytes::new(frame.luminance),
|
||||
}
|
||||
.decode()
|
||||
.map(|payload| payload.expose().to_vec())
|
||||
.map_err(|error| match error {
|
||||
presentation::QrError::NotFound => MobilePlatformFfiError::QrNotFound,
|
||||
presentation::QrError::InvalidPayload => MobilePlatformFfiError::InvalidQrPayload,
|
||||
_ => MobilePlatformFfiError::InvalidCameraFrame,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Clone, uniffi::Enum)]
|
||||
pub enum MobileClipboardContent {
|
||||
Text { value: Vec<u8> },
|
||||
EmptyOrNonText,
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
|
||||
pub enum MobileClipboardWait {
|
||||
Elapsed,
|
||||
Cancelled,
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
|
||||
pub enum MobileClipboardDisposition {
|
||||
RestoredPrevious,
|
||||
Cleared,
|
||||
PreservedNewer,
|
||||
}
|
||||
|
||||
#[uniffi::export(rust, foreign)]
|
||||
pub trait MobileClipboard: Send + Sync {
|
||||
fn read(&self) -> Result<MobileClipboardContent, MobilePlatformFfiError>;
|
||||
fn write(&self, value: Vec<u8>) -> Result<(), MobilePlatformFfiError>;
|
||||
fn clear(&self) -> Result<(), MobilePlatformFfiError>;
|
||||
fn wait_for_timeout(&self, milliseconds: u64) -> MobileClipboardWait;
|
||||
}
|
||||
struct ClipboardAdapter(Arc<dyn MobileClipboard>);
|
||||
impl ClipboardBackend for ClipboardAdapter {
|
||||
fn read(&mut self) -> Result<presentation::ClipboardContent, presentation::ClipboardError> {
|
||||
self.0
|
||||
.read()
|
||||
.map(|value| match value {
|
||||
MobileClipboardContent::Text { value } => {
|
||||
presentation::ClipboardContent::text(value)
|
||||
}
|
||||
MobileClipboardContent::EmptyOrNonText => {
|
||||
presentation::ClipboardContent::EmptyOrNonText
|
||||
}
|
||||
})
|
||||
.map_err(|_| presentation::ClipboardError::ReadFailed)
|
||||
}
|
||||
fn write(&mut self, value: &SecretBytes) -> Result<(), presentation::ClipboardError> {
|
||||
self.0
|
||||
.write(value.expose().to_vec())
|
||||
.map_err(|_| presentation::ClipboardError::WriteFailed)
|
||||
}
|
||||
fn clear(&mut self) -> Result<(), presentation::ClipboardError> {
|
||||
self.0
|
||||
.clear()
|
||||
.map_err(|_| presentation::ClipboardError::CleanupFailed)
|
||||
}
|
||||
}
|
||||
#[uniffi::export]
|
||||
pub fn copy_mobile_entry(
|
||||
copy: MobileEntryCopy,
|
||||
clipboard: Arc<dyn MobileClipboard>,
|
||||
) -> Result<MobileClipboardDisposition, MobilePlatformFfiError> {
|
||||
let timeout = presentation::ClipboardTimeout::new(Duration::from_secs(copy.timeout_seconds))
|
||||
.map_err(|_| MobilePlatformFfiError::ClipboardFailed)?;
|
||||
let mut manager =
|
||||
presentation::ClipboardManager::new(ClipboardAdapter(Arc::clone(&clipboard)), timeout);
|
||||
manager
|
||||
.copy_with(
|
||||
&SecretBytes::new(copy.value.into_bytes()),
|
||||
|duration| match clipboard.wait_for_timeout(duration.as_millis() as u64) {
|
||||
MobileClipboardWait::Elapsed => presentation::ClipboardWait::Elapsed,
|
||||
MobileClipboardWait::Cancelled => presentation::ClipboardWait::Cancelled,
|
||||
},
|
||||
)
|
||||
.map(|result| match result {
|
||||
presentation::ClipboardDisposition::RestoredPrevious => {
|
||||
MobileClipboardDisposition::RestoredPrevious
|
||||
}
|
||||
presentation::ClipboardDisposition::Cleared => MobileClipboardDisposition::Cleared,
|
||||
presentation::ClipboardDisposition::PreservedNewer => {
|
||||
MobileClipboardDisposition::PreservedNewer
|
||||
}
|
||||
})
|
||||
.map_err(|error| {
|
||||
if error == presentation::ClipboardError::Cancelled {
|
||||
MobilePlatformFfiError::Cancelled
|
||||
} else {
|
||||
MobilePlatformFfiError::ClipboardFailed
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Clone, uniffi::Record)]
|
||||
pub struct MobileCredentialRequest {
|
||||
pub request_id: String,
|
||||
pub package: String,
|
||||
pub signing_certificate_sha256: String,
|
||||
pub origins: Vec<String>,
|
||||
}
|
||||
#[uniffi::export]
|
||||
pub fn validate_mobile_credential_request(
|
||||
request: MobileCredentialRequest,
|
||||
) -> Result<MobileCredentialRequest, MobilePlatformFfiError> {
|
||||
let request = storage::MobileCredentialRequest::new(
|
||||
request.request_id,
|
||||
request.package,
|
||||
request.signing_certificate_sha256,
|
||||
request.origins,
|
||||
)
|
||||
.map_err(|_| MobilePlatformFfiError::InvalidCredentialRequest)?;
|
||||
Ok(MobileCredentialRequest {
|
||||
request_id: request.request_id().to_owned(),
|
||||
package: request.package().to_owned(),
|
||||
signing_certificate_sha256: request.signing_certificate_sha256().to_owned(),
|
||||
origins: request.origins().to_vec(),
|
||||
})
|
||||
}
|
||||
@@ -55,6 +55,12 @@ pub trait AuthenticationClock: Send + Sync + 'static {
|
||||
fn now(&self) -> Duration;
|
||||
}
|
||||
|
||||
impl<T: AuthenticationClock + ?Sized> AuthenticationClock for Arc<T> {
|
||||
fn now(&self) -> Duration {
|
||||
(**self).now()
|
||||
}
|
||||
}
|
||||
|
||||
/// Process-local monotonic clock used by production sessions.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SystemAuthenticationClock {
|
||||
|
||||
@@ -40,6 +40,8 @@ pub mod mobile_onboarding;
|
||||
#[cfg(feature = "full")]
|
||||
pub mod mobile_passwords;
|
||||
#[cfg(feature = "full")]
|
||||
pub mod mobile_platform;
|
||||
#[cfg(feature = "full")]
|
||||
pub mod mobile_totp;
|
||||
pub mod mobile_watch;
|
||||
#[cfg(feature = "full")]
|
||||
|
||||
@@ -5,6 +5,36 @@ use crate::{
|
||||
repository::{Repository, RepositoryError},
|
||||
};
|
||||
|
||||
/// Presentation intent. Native UIs choose their own assets and selected variants.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum MobileIcon {
|
||||
Home,
|
||||
Key,
|
||||
Search,
|
||||
Timer,
|
||||
Settings,
|
||||
Warning,
|
||||
Branch,
|
||||
Success,
|
||||
Sync,
|
||||
Protected,
|
||||
Repository,
|
||||
History,
|
||||
Incoming,
|
||||
Outgoing,
|
||||
Recipients,
|
||||
Signature,
|
||||
File,
|
||||
Merge,
|
||||
Refresh,
|
||||
Folder,
|
||||
User,
|
||||
Email,
|
||||
Website,
|
||||
Text,
|
||||
Note,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
pub enum MobileTab {
|
||||
#[default]
|
||||
@@ -34,23 +64,13 @@ impl MobileTab {
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn system_image(self) -> &'static str {
|
||||
pub const fn icon(self) -> MobileIcon {
|
||||
match self {
|
||||
Self::Home => "house",
|
||||
Self::Passwords => "key",
|
||||
Self::Search => "magnifyingglass",
|
||||
Self::Totp => "timer",
|
||||
Self::Preferences => "gearshape",
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn selected_system_image(self) -> &'static str {
|
||||
match self {
|
||||
Self::Home => "house.fill",
|
||||
Self::Passwords => "key.fill",
|
||||
Self::Search => "magnifyingglass",
|
||||
Self::Totp => "timer",
|
||||
Self::Preferences => "gearshape.fill",
|
||||
Self::Home => MobileIcon::Home,
|
||||
Self::Passwords => MobileIcon::Key,
|
||||
Self::Search => MobileIcon::Search,
|
||||
Self::Totp => MobileIcon::Timer,
|
||||
Self::Preferences => MobileIcon::Settings,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,8 +111,7 @@ pub enum MobileShellState {
|
||||
pub struct MobilePage {
|
||||
tab: MobileTab,
|
||||
title: String,
|
||||
system_image: String,
|
||||
selected_system_image: String,
|
||||
icon: MobileIcon,
|
||||
state: MobileShellState,
|
||||
state_title: String,
|
||||
state_detail: String,
|
||||
@@ -107,12 +126,8 @@ impl MobilePage {
|
||||
&self.title
|
||||
}
|
||||
|
||||
pub fn system_image(&self) -> &str {
|
||||
&self.system_image
|
||||
}
|
||||
|
||||
pub fn selected_system_image(&self) -> &str {
|
||||
&self.selected_system_image
|
||||
pub fn icon(&self) -> MobileIcon {
|
||||
self.icon
|
||||
}
|
||||
|
||||
pub fn state(&self) -> MobileShellState {
|
||||
@@ -180,8 +195,7 @@ impl MobileShell {
|
||||
MobilePage {
|
||||
tab,
|
||||
title: tab.title().to_owned(),
|
||||
system_image: tab.system_image().to_owned(),
|
||||
selected_system_image: tab.selected_system_image().to_owned(),
|
||||
icon: tab.icon(),
|
||||
state,
|
||||
state_title,
|
||||
state_detail,
|
||||
@@ -268,8 +282,7 @@ mod tests {
|
||||
assert!(shell.pages().iter().all(|page| {
|
||||
page.state() == state
|
||||
&& !page.title().is_empty()
|
||||
&& !page.system_image().is_empty()
|
||||
&& !page.selected_system_image().is_empty()
|
||||
&& page.icon() == page.tab().icon()
|
||||
&& !page.state_title().is_empty()
|
||||
&& !page.state_detail().is_empty()
|
||||
}));
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
//! Shared mobile authentication state; Swift only supplies input and presents results.
|
||||
//! Shared mobile authentication state; native frontends supply input and present results.
|
||||
|
||||
use std::{collections::BTreeMap, error::Error, fmt, sync::Mutex, time::Duration};
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
error::Error,
|
||||
fmt,
|
||||
sync::{Arc, Mutex},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
authentication::{
|
||||
AuthenticationError, AuthenticationTimeout, NativeAuthenticationHandle,
|
||||
NativeAuthenticationSession,
|
||||
AuthenticationClock, AuthenticationError, AuthenticationHandle, AuthenticationSession,
|
||||
AuthenticationTimeout, SystemAuthenticationClock,
|
||||
},
|
||||
config::{Config, ConfigError, GitRemote, MobileAppearance},
|
||||
crypto::{CryptoError, KeyInfo, KeyStore, SecretProvider, SecretProviderError},
|
||||
@@ -33,7 +39,8 @@ use crate::{
|
||||
DirectoryPath, EncryptedEntry, EntryPath, Repository, RepositoryError, SecretBytes,
|
||||
},
|
||||
secret_store::{
|
||||
NativeSecretStore, SecretCachePolicy, SecretProtectionPolicy, SecretStoreError,
|
||||
NativeSecretBackend, SecretCachePolicy, SecretProtectionPolicy, SecretStore,
|
||||
SecretStoreBackend, SecretStoreError,
|
||||
},
|
||||
write::{VaultWriter, WriteError},
|
||||
};
|
||||
@@ -279,7 +286,7 @@ impl MobileAuthenticationState {
|
||||
}
|
||||
|
||||
struct ActiveMobileLease {
|
||||
handle: NativeAuthenticationHandle,
|
||||
handle: MobileAuthenticationHandle,
|
||||
key: KeyInfo,
|
||||
}
|
||||
|
||||
@@ -326,11 +333,17 @@ impl Drop for MobileRepositoryOperationGuard<'_> {
|
||||
}
|
||||
|
||||
/// One process-wide mobile authentication lease shared by every tab and viewer.
|
||||
type MobileAuthenticationSession =
|
||||
AuthenticationSession<Arc<dyn SecretStoreBackend>, Arc<dyn AuthenticationClock>>;
|
||||
type MobileAuthenticationHandle =
|
||||
AuthenticationHandle<Arc<dyn SecretStoreBackend>, Arc<dyn AuthenticationClock>>;
|
||||
|
||||
pub struct MobileAuthentication {
|
||||
config: Config,
|
||||
repository: Repository,
|
||||
keys: KeyStore,
|
||||
session: NativeAuthenticationSession,
|
||||
session: MobileAuthenticationSession,
|
||||
backend: Arc<dyn SecretStoreBackend>,
|
||||
status: Mutex<MobileAuthenticationStatus>,
|
||||
totp_discovery: Mutex<()>,
|
||||
}
|
||||
@@ -344,13 +357,28 @@ impl MobileAuthentication {
|
||||
error.to_string(),
|
||||
)
|
||||
})?;
|
||||
let backend = Arc::new(NativeSecretBackend::new().map_err(preference_secret_error)?);
|
||||
Self::with_platform(
|
||||
config,
|
||||
backend,
|
||||
Arc::new(SystemAuthenticationClock::default()),
|
||||
)
|
||||
}
|
||||
|
||||
/// OS mechanics are injected; configuration, protection, leases and storage stay here.
|
||||
pub fn with_platform(
|
||||
config: Config,
|
||||
backend: Arc<dyn SecretStoreBackend>,
|
||||
clock: Arc<dyn AuthenticationClock>,
|
||||
) -> Result<Self, MobileAuthenticationError> {
|
||||
let repository = Repository::open(config.vault()).map_err(entry_error)?;
|
||||
let keys = KeyStore::load(config.key_material()).map_err(key_error)?;
|
||||
let session = NativeAuthenticationSession::system(
|
||||
let session = MobileAuthenticationSession::with_clock(
|
||||
Arc::clone(&backend),
|
||||
SecretProtectionPolicy::current_biometry_for_openpgp(),
|
||||
config.authentication_timeout(),
|
||||
)
|
||||
.map_err(MobileAuthenticationError::authentication)?;
|
||||
clock,
|
||||
);
|
||||
let watch_snapshot =
|
||||
WatchSnapshotSender::load(config.source().with_file_name("watch-snapshot.toml"));
|
||||
Ok(Self {
|
||||
@@ -371,6 +399,7 @@ impl MobileAuthentication {
|
||||
repository,
|
||||
keys,
|
||||
session,
|
||||
backend,
|
||||
totp_discovery: Mutex::new(()),
|
||||
})
|
||||
}
|
||||
@@ -599,7 +628,7 @@ impl MobileAuthentication {
|
||||
sync_configured: remote.is_some(),
|
||||
repository_title,
|
||||
repository_url: remote.map_or_else(
|
||||
|| "Stored and versioned on this iPhone".to_owned(),
|
||||
|| "Stored and versioned on this device".to_owned(),
|
||||
|remote| remote.url().to_string(),
|
||||
),
|
||||
server_title: remote
|
||||
@@ -621,7 +650,10 @@ impl MobileAuthentication {
|
||||
)
|
||||
},
|
||||
),
|
||||
application_account: remote.map(application_account).transpose()?.flatten(),
|
||||
application_account: remote
|
||||
.map(|remote| application_account(remote, Arc::clone(&self.backend)))
|
||||
.transpose()?
|
||||
.flatten(),
|
||||
default_key_title: key
|
||||
.user_ids()
|
||||
.first()
|
||||
@@ -674,7 +706,7 @@ impl MobileAuthentication {
|
||||
.find(|remote| remote.name().as_str() == "origin")
|
||||
.or_else(|| self.config.git_remotes().first())
|
||||
.ok_or_else(|| config_detail("No HTTPS Git remote is configured."))?;
|
||||
let store = preference_secret_store()?;
|
||||
let store = preference_secret_store(Arc::clone(&self.backend));
|
||||
store.unlock().map_err(preference_secret_error)?;
|
||||
let (server_id, application_id) = remote.https_credentials().ok_or_else(|| {
|
||||
config_detail("The configured Git remote does not use HTTPS credentials.")
|
||||
@@ -1276,6 +1308,26 @@ impl MobileAuthentication {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn lifecycle(
|
||||
&self,
|
||||
event: crate::mobile_platform::MobileLifecycleEvent,
|
||||
) -> Result<MobileAuthenticationState, MobileAuthenticationError> {
|
||||
use crate::mobile_platform::MobileLifecycleEvent;
|
||||
match event {
|
||||
MobileLifecycleEvent::Foreground => {}
|
||||
MobileLifecycleEvent::Background | MobileLifecycleEvent::DeviceLocked => {
|
||||
self.manual_lock()?
|
||||
}
|
||||
MobileLifecycleEvent::UserActivity => {
|
||||
if self.state()?.unlocked() {
|
||||
self.touch_user_activity()?;
|
||||
}
|
||||
}
|
||||
MobileLifecycleEvent::Cancel => self.cancel()?,
|
||||
}
|
||||
self.state()
|
||||
}
|
||||
|
||||
pub fn cancel(&self) -> Result<(), MobileAuthenticationError> {
|
||||
self.session
|
||||
.cancel()
|
||||
@@ -1319,7 +1371,7 @@ impl MobileAuthentication {
|
||||
|
||||
fn set_active(
|
||||
&self,
|
||||
handle: NativeAuthenticationHandle,
|
||||
handle: MobileAuthenticationHandle,
|
||||
key: KeyInfo,
|
||||
) -> Result<(), MobileAuthenticationError> {
|
||||
self.status()?.active = Some(ActiveMobileLease { handle, key });
|
||||
@@ -1529,12 +1581,12 @@ fn repository_operation_conflict(
|
||||
}
|
||||
|
||||
struct KeyOnlyProvider<'a> {
|
||||
handle: NativeAuthenticationHandle,
|
||||
handle: MobileAuthenticationHandle,
|
||||
fingerprint: &'a str,
|
||||
}
|
||||
|
||||
impl<'a> KeyOnlyProvider<'a> {
|
||||
fn new(handle: NativeAuthenticationHandle, key: &'a KeyInfo) -> Self {
|
||||
fn new(handle: MobileAuthenticationHandle, key: &'a KeyInfo) -> Self {
|
||||
Self {
|
||||
handle,
|
||||
fingerprint: key.fingerprint().as_str(),
|
||||
@@ -1567,19 +1619,24 @@ fn config_detail(detail: &str) -> MobileAuthenticationError {
|
||||
)
|
||||
}
|
||||
|
||||
fn preference_secret_store() -> Result<NativeSecretStore, MobileAuthenticationError> {
|
||||
NativeSecretStore::system(
|
||||
fn preference_secret_store(
|
||||
backend: Arc<dyn SecretStoreBackend>,
|
||||
) -> SecretStore<Arc<dyn SecretStoreBackend>> {
|
||||
SecretStore::new(
|
||||
backend,
|
||||
SecretCachePolicy::Disabled,
|
||||
SecretProtectionPolicy::device_unlocked(),
|
||||
)
|
||||
.map_err(preference_secret_error)
|
||||
}
|
||||
|
||||
fn application_account(remote: &GitRemote) -> Result<Option<String>, MobileAuthenticationError> {
|
||||
fn application_account(
|
||||
remote: &GitRemote,
|
||||
backend: Arc<dyn SecretStoreBackend>,
|
||||
) -> Result<Option<String>, MobileAuthenticationError> {
|
||||
let Some((server_id, application_id)) = remote.https_credentials() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let store = preference_secret_store()?;
|
||||
let store = preference_secret_store(backend);
|
||||
store.unlock().map_err(preference_secret_error)?;
|
||||
let result = store.https_git_credential_account(server_id, application_id);
|
||||
let lock_result = store.lock();
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! Storage-owned projection of lossless entry documents for native mobile viewers.
|
||||
|
||||
use crate::mobile::MobileIcon;
|
||||
|
||||
use std::{collections::BTreeSet, error::Error, fmt, num::NonZeroUsize};
|
||||
|
||||
use crate::document::{
|
||||
@@ -22,7 +24,7 @@ pub enum MobileEntrySectionKind {
|
||||
pub struct MobileEntryField {
|
||||
id: u64,
|
||||
label: String,
|
||||
system_image: String,
|
||||
icon: MobileIcon,
|
||||
value: Option<String>,
|
||||
masked_value: String,
|
||||
detail: Option<String>,
|
||||
@@ -42,8 +44,8 @@ impl MobileEntryField {
|
||||
&self.label
|
||||
}
|
||||
|
||||
pub fn system_image(&self) -> &str {
|
||||
&self.system_image
|
||||
pub fn icon(&self) -> MobileIcon {
|
||||
self.icon
|
||||
}
|
||||
|
||||
pub fn value(&self) -> Option<&str> {
|
||||
@@ -121,7 +123,7 @@ pub struct MobileEntryEditorField {
|
||||
kind: MobileEntryEditorFieldKind,
|
||||
name: Option<String>,
|
||||
label: String,
|
||||
system_image: String,
|
||||
icon: MobileIcon,
|
||||
value: Option<String>,
|
||||
masked_value: String,
|
||||
diagnostic: Option<String>,
|
||||
@@ -149,8 +151,8 @@ impl MobileEntryEditorField {
|
||||
&self.label
|
||||
}
|
||||
|
||||
pub fn system_image(&self) -> &str {
|
||||
&self.system_image
|
||||
pub fn icon(&self) -> MobileIcon {
|
||||
self.icon
|
||||
}
|
||||
|
||||
pub fn value(&self) -> Option<&str> {
|
||||
@@ -573,7 +575,7 @@ fn mobile_field(field: &EntryField) -> MobileEntryField {
|
||||
MobileEntryField {
|
||||
id: field.id().value(),
|
||||
label: label(field),
|
||||
system_image: system_image(metadata.kind()).to_owned(),
|
||||
icon: icon(metadata.kind()),
|
||||
value,
|
||||
masked_value: if metadata.sensitivity() == EntrySensitivity::Empty {
|
||||
"Empty"
|
||||
@@ -614,7 +616,7 @@ fn editor_field(index: usize, field: &EntryField) -> MobileEntryEditorField {
|
||||
kind,
|
||||
name: metadata.name().map(str::to_owned),
|
||||
label: label(field),
|
||||
system_image: system_image(metadata.kind()).to_owned(),
|
||||
icon: icon(metadata.kind()),
|
||||
value: String::from_utf8(field.value().to_vec()).ok(),
|
||||
masked_value: if metadata.sensitivity() == EntrySensitivity::Empty {
|
||||
"Empty"
|
||||
@@ -705,15 +707,15 @@ fn display_name(name: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn system_image(kind: EntryFieldKind) -> &'static str {
|
||||
fn icon(kind: EntryFieldKind) -> MobileIcon {
|
||||
match kind {
|
||||
EntryFieldKind::Password => "key.fill",
|
||||
EntryFieldKind::Username => "person.fill",
|
||||
EntryFieldKind::Email => "envelope.fill",
|
||||
EntryFieldKind::Url => "globe",
|
||||
EntryFieldKind::OtpUri => "timer",
|
||||
EntryFieldKind::Field => "text.alignleft",
|
||||
EntryFieldKind::Note => "note.text",
|
||||
EntryFieldKind::Password => MobileIcon::Key,
|
||||
EntryFieldKind::Username => MobileIcon::User,
|
||||
EntryFieldKind::Email => MobileIcon::Email,
|
||||
EntryFieldKind::Url => MobileIcon::Website,
|
||||
EntryFieldKind::OtpUri => MobileIcon::Timer,
|
||||
EntryFieldKind::Field => MobileIcon::Text,
|
||||
EntryFieldKind::Note => MobileIcon::Note,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
//! Storage-owned remote activity and pull-then-push refresh for iPhone Home.
|
||||
//! Storage-owned remote activity and pull-then-push refresh for mobile Home.
|
||||
|
||||
use crate::mobile::MobileIcon;
|
||||
|
||||
use std::{
|
||||
collections::BTreeSet,
|
||||
@@ -62,7 +64,7 @@ pub enum MobileHomeActionKind {
|
||||
pub struct MobileHomeAction {
|
||||
kind: MobileHomeActionKind,
|
||||
title: &'static str,
|
||||
system_image: &'static str,
|
||||
icon: MobileIcon,
|
||||
}
|
||||
|
||||
impl MobileHomeAction {
|
||||
@@ -74,8 +76,8 @@ impl MobileHomeAction {
|
||||
self.title
|
||||
}
|
||||
|
||||
pub fn system_image(&self) -> &str {
|
||||
self.system_image
|
||||
pub fn icon(&self) -> MobileIcon {
|
||||
self.icon
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,7 +96,7 @@ pub struct MobileHomeSummaryRow {
|
||||
id: String,
|
||||
title: String,
|
||||
detail: String,
|
||||
system_image: String,
|
||||
icon: MobileIcon,
|
||||
actions: Vec<MobileHomeAction>,
|
||||
}
|
||||
|
||||
@@ -111,8 +113,8 @@ impl MobileHomeSummaryRow {
|
||||
&self.detail
|
||||
}
|
||||
|
||||
pub fn system_image(&self) -> &str {
|
||||
&self.system_image
|
||||
pub fn icon(&self) -> MobileIcon {
|
||||
self.icon
|
||||
}
|
||||
|
||||
pub fn actions(&self) -> &[MobileHomeAction] {
|
||||
@@ -125,7 +127,7 @@ pub struct MobileHomeChange {
|
||||
id: String,
|
||||
title: String,
|
||||
detail: String,
|
||||
system_image: String,
|
||||
icon: MobileIcon,
|
||||
kind: MobileHomeChangeKind,
|
||||
status: MobileHomeChangeStatus,
|
||||
}
|
||||
@@ -143,8 +145,8 @@ impl MobileHomeChange {
|
||||
&self.detail
|
||||
}
|
||||
|
||||
pub fn system_image(&self) -> &str {
|
||||
&self.system_image
|
||||
pub fn icon(&self) -> MobileIcon {
|
||||
self.icon
|
||||
}
|
||||
|
||||
pub fn kind(&self) -> MobileHomeChangeKind {
|
||||
@@ -161,7 +163,7 @@ pub struct MobileHomeCommit {
|
||||
id: String,
|
||||
title: String,
|
||||
detail: String,
|
||||
system_image: String,
|
||||
icon: MobileIcon,
|
||||
timestamp: i64,
|
||||
changes: Vec<MobileHomeChange>,
|
||||
actions: Vec<MobileHomeAction>,
|
||||
@@ -180,8 +182,8 @@ impl MobileHomeCommit {
|
||||
&self.detail
|
||||
}
|
||||
|
||||
pub fn system_image(&self) -> &str {
|
||||
&self.system_image
|
||||
pub fn icon(&self) -> MobileIcon {
|
||||
self.icon
|
||||
}
|
||||
|
||||
pub fn timestamp(&self) -> i64 {
|
||||
@@ -201,7 +203,7 @@ impl MobileHomeCommit {
|
||||
pub struct MobileHomeNotice {
|
||||
title: String,
|
||||
detail: String,
|
||||
system_image: String,
|
||||
icon: MobileIcon,
|
||||
}
|
||||
|
||||
impl MobileHomeNotice {
|
||||
@@ -213,8 +215,8 @@ impl MobileHomeNotice {
|
||||
&self.detail
|
||||
}
|
||||
|
||||
pub fn system_image(&self) -> &str {
|
||||
&self.system_image
|
||||
pub fn icon(&self) -> MobileIcon {
|
||||
self.icon
|
||||
}
|
||||
}
|
||||
|
||||
@@ -615,7 +617,7 @@ impl MobileHomeStorage {
|
||||
.to_owned(),
|
||||
detail: "The repository was updated, but the refresh time could not be cached."
|
||||
.to_owned(),
|
||||
system_image: "exclamationmark.triangle".to_owned(),
|
||||
icon: MobileIcon::Warning,
|
||||
})
|
||||
};
|
||||
self.page(MobileHomeFreshness::Current, Some(now), notice)
|
||||
@@ -938,7 +940,7 @@ fn page_from_divergence(
|
||||
id: "tracked-branch".to_owned(),
|
||||
title: format!("{}/{}", divergence.remote().name(), divergence.branch()),
|
||||
detail: "Tracked HTTPS branch".to_owned(),
|
||||
system_image: "point.3.connected.trianglepath.dotted".to_owned(),
|
||||
icon: MobileIcon::Branch,
|
||||
actions: vec![
|
||||
mobile_action(MobileHomeActionKind::Fetch),
|
||||
mobile_action(MobileHomeActionKind::Pull),
|
||||
@@ -949,10 +951,10 @@ fn page_from_divergence(
|
||||
id: "divergence".to_owned(),
|
||||
title: divergence_title,
|
||||
detail: divergence_detail,
|
||||
system_image: if ahead == 0 && behind == 0 {
|
||||
"checkmark.circle".to_owned()
|
||||
icon: if ahead == 0 && behind == 0 {
|
||||
MobileIcon::Success
|
||||
} else {
|
||||
"arrow.triangle.2.circlepath".to_owned()
|
||||
MobileIcon::Sync
|
||||
},
|
||||
actions: [
|
||||
(behind > 0).then(|| mobile_action(MobileHomeActionKind::Pull)),
|
||||
@@ -974,10 +976,10 @@ fn page_from_divergence(
|
||||
} else {
|
||||
"Commit or discard these changes before pulling".to_owned()
|
||||
},
|
||||
system_image: if local_count == 0 {
|
||||
"checkmark.shield".to_owned()
|
||||
icon: if local_count == 0 {
|
||||
MobileIcon::Protected
|
||||
} else {
|
||||
"exclamationmark.triangle".to_owned()
|
||||
MobileIcon::Warning
|
||||
},
|
||||
actions: if local_count > 0 {
|
||||
vec![mobile_action(MobileHomeActionKind::Commit)]
|
||||
@@ -1040,7 +1042,7 @@ fn page_from_local_snapshot(
|
||||
id: "local-branch".to_owned(),
|
||||
title: snapshot.branch().to_owned(),
|
||||
detail: "Local Git branch · no sync remote configured".to_owned(),
|
||||
system_image: "externaldrive.fill".to_owned(),
|
||||
icon: MobileIcon::Repository,
|
||||
actions: Vec::new(),
|
||||
},
|
||||
MobileHomeSummaryRow {
|
||||
@@ -1051,7 +1053,7 @@ fn page_from_local_snapshot(
|
||||
if commit_count == 1 { "" } else { "s" }
|
||||
),
|
||||
detail: latest.unwrap_or_else(|| "No local history yet".to_owned()),
|
||||
system_image: "clock.arrow.circlepath".to_owned(),
|
||||
icon: MobileIcon::History,
|
||||
actions: Vec::new(),
|
||||
},
|
||||
working_tree_summary(local_paths.len()),
|
||||
@@ -1077,10 +1079,10 @@ fn working_tree_summary(local_count: usize) -> MobileHomeSummaryRow {
|
||||
} else {
|
||||
"Commit these local password-store changes".to_owned()
|
||||
},
|
||||
system_image: if local_count == 0 {
|
||||
"checkmark.shield".to_owned()
|
||||
icon: if local_count == 0 {
|
||||
MobileIcon::Protected
|
||||
} else {
|
||||
"exclamationmark.triangle".to_owned()
|
||||
MobileIcon::Warning
|
||||
},
|
||||
actions: if local_count > 0 {
|
||||
vec![mobile_action(MobileHomeActionKind::Commit)]
|
||||
@@ -1124,10 +1126,10 @@ fn mobile_commit(activity: &GitCommitActivity, incoming: bool) -> MobileHomeComm
|
||||
},
|
||||
change_count(changes.len())
|
||||
),
|
||||
system_image: if incoming {
|
||||
"arrow.down.circle".to_owned()
|
||||
icon: if incoming {
|
||||
MobileIcon::Incoming
|
||||
} else {
|
||||
"arrow.up.circle".to_owned()
|
||||
MobileIcon::Outgoing
|
||||
},
|
||||
timestamp: commit.timestamp(),
|
||||
changes,
|
||||
@@ -1147,33 +1149,33 @@ fn mobile_change(id: String, path: &Path, kind: GitChangeKind) -> MobileHomeChan
|
||||
MobileHomeChangeStatus::Deleted => "Deleted",
|
||||
};
|
||||
let file_name = path.file_name().and_then(|name| name.to_str());
|
||||
let (title, detail, system_image, change_kind) =
|
||||
let (title, detail, icon, change_kind) =
|
||||
if path.extension().is_some_and(|extension| extension == "gpg") {
|
||||
(
|
||||
display_path(&path.with_extension("")),
|
||||
format!("{status_name} password entry"),
|
||||
"key".to_owned(),
|
||||
MobileIcon::Key,
|
||||
MobileHomeChangeKind::PasswordEntry,
|
||||
)
|
||||
} else if file_name == Some(".gpg-id") {
|
||||
(
|
||||
policy_title(path, "recipients"),
|
||||
format!("{status_name} recipient policy"),
|
||||
"person.2".to_owned(),
|
||||
MobileIcon::Recipients,
|
||||
MobileHomeChangeKind::RecipientPolicy,
|
||||
)
|
||||
} else if file_name == Some(".gpg-id.sig") {
|
||||
(
|
||||
policy_title(path, "recipient signature"),
|
||||
format!("{status_name} recipient signature"),
|
||||
"signature".to_owned(),
|
||||
MobileIcon::Signature,
|
||||
MobileHomeChangeKind::RecipientSignature,
|
||||
)
|
||||
} else {
|
||||
(
|
||||
display_path(path),
|
||||
format!("{status_name} repository file"),
|
||||
"doc".to_owned(),
|
||||
MobileIcon::File,
|
||||
MobileHomeChangeKind::RepositoryFile,
|
||||
)
|
||||
};
|
||||
@@ -1181,7 +1183,7 @@ fn mobile_change(id: String, path: &Path, kind: GitChangeKind) -> MobileHomeChan
|
||||
id,
|
||||
title,
|
||||
detail,
|
||||
system_image,
|
||||
icon,
|
||||
kind: change_kind,
|
||||
status,
|
||||
}
|
||||
@@ -1228,27 +1230,27 @@ fn change_count(count: usize) -> String {
|
||||
}
|
||||
|
||||
fn pull_notice(outcome: PullOutcome) -> MobileHomeNotice {
|
||||
let (title, detail, system_image) = match outcome {
|
||||
let (title, detail, icon) = match outcome {
|
||||
PullOutcome::UpToDate => (
|
||||
"Already Up to Date",
|
||||
"The local password store already contains every fetched commit.",
|
||||
"checkmark.circle",
|
||||
MobileIcon::Success,
|
||||
),
|
||||
PullOutcome::FastForward => (
|
||||
"Password Store Updated",
|
||||
"Remote commits were applied without changing local history.",
|
||||
"arrow.down.circle",
|
||||
MobileIcon::Incoming,
|
||||
),
|
||||
PullOutcome::Merged => (
|
||||
"Remote Changes Merged",
|
||||
"Remote and local commits were merged by storage.",
|
||||
"arrow.triangle.merge",
|
||||
MobileIcon::Merge,
|
||||
),
|
||||
};
|
||||
MobileHomeNotice {
|
||||
title: title.to_owned(),
|
||||
detail: detail.to_owned(),
|
||||
system_image: system_image.to_owned(),
|
||||
icon,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1256,7 +1258,7 @@ fn commit_notice(commit: &str) -> MobileHomeNotice {
|
||||
MobileHomeNotice {
|
||||
title: "Changes Committed".to_owned(),
|
||||
detail: format!("Created commit {}.", &commit[..commit.len().min(12)]),
|
||||
system_image: "checkmark.circle".to_owned(),
|
||||
icon: MobileIcon::Success,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1276,7 +1278,7 @@ fn fetch_notice(outcome: &FetchOutcome) -> MobileHomeNotice {
|
||||
} else {
|
||||
format!("{} has no new objects for this clone.", outcome.remote())
|
||||
},
|
||||
system_image: "arrow.clockwise.circle".to_owned(),
|
||||
icon: MobileIcon::Refresh,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1302,22 +1304,18 @@ fn push_notice(outcome: &PushOutcome) -> MobileHomeNotice {
|
||||
outcome.branch()
|
||||
)
|
||||
},
|
||||
system_image: "arrow.up.circle".to_owned(),
|
||||
icon: MobileIcon::Outgoing,
|
||||
}
|
||||
}
|
||||
|
||||
fn mobile_action(kind: MobileHomeActionKind) -> MobileHomeAction {
|
||||
let (title, system_image) = match kind {
|
||||
MobileHomeActionKind::Commit => ("Commit", "checkmark.circle"),
|
||||
MobileHomeActionKind::Fetch => ("Fetch", "arrow.clockwise"),
|
||||
MobileHomeActionKind::Pull => ("Pull", "arrow.down.circle"),
|
||||
MobileHomeActionKind::Push => ("Push", "arrow.up.circle"),
|
||||
let (title, icon) = match kind {
|
||||
MobileHomeActionKind::Commit => ("Commit", MobileIcon::Success),
|
||||
MobileHomeActionKind::Fetch => ("Fetch", MobileIcon::Refresh),
|
||||
MobileHomeActionKind::Pull => ("Pull", MobileIcon::Incoming),
|
||||
MobileHomeActionKind::Push => ("Push", MobileIcon::Outgoing),
|
||||
};
|
||||
MobileHomeAction {
|
||||
kind,
|
||||
title,
|
||||
system_image,
|
||||
}
|
||||
MobileHomeAction { kind, title, icon }
|
||||
}
|
||||
|
||||
fn repository_operation(action: MobileHomeActionKind) -> MobileRepositoryOperation {
|
||||
@@ -1393,7 +1391,10 @@ fn is_stale(refreshed_at: Option<i64>, now: i64) -> bool {
|
||||
mod tests {
|
||||
use std::path::Path;
|
||||
|
||||
use crate::git::{GitChangeKind, GitError};
|
||||
use crate::{
|
||||
git::{GitChangeKind, GitError},
|
||||
mobile::MobileIcon,
|
||||
};
|
||||
|
||||
use super::{
|
||||
MobileHomeActionKind, MobileHomeChangeKind, MobileHomeChangeStatus, MobileHomeErrorKind,
|
||||
@@ -1446,7 +1447,7 @@ mod tests {
|
||||
fn actions_and_progress_are_typed_before_swift_presentation() {
|
||||
let push = mobile_action(MobileHomeActionKind::Push);
|
||||
assert_eq!(push.title(), "Push");
|
||||
assert_eq!(push.system_image(), "arrow.up.circle");
|
||||
assert_eq!(push.icon(), MobileIcon::Outgoing);
|
||||
let progress = progress_copy(Some(MobileHomeActionKind::Push), MobileHomePhase::Sending);
|
||||
assert_eq!(progress.action(), Some(MobileHomeActionKind::Push));
|
||||
assert_eq!(progress.title(), "Pushing Changes");
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Storage-owned first-run setup for the native iPhone application.
|
||||
//! Storage-owned first-run setup for the native mobile applications.
|
||||
|
||||
use std::{
|
||||
error::Error,
|
||||
@@ -363,7 +363,7 @@ impl MobileOnboardingOperation {
|
||||
result?;
|
||||
Ok(MobileOnboardingOutcome {
|
||||
title: "Local Password Store Ready".to_owned(),
|
||||
detail: "A local Git repository and protected GPG key are ready on this iPhone."
|
||||
detail: "A local Git repository and protected GPG key are ready on this device."
|
||||
.to_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! Storage-owned, locked password navigation for native mobile frontends.
|
||||
|
||||
use crate::mobile::MobileIcon;
|
||||
|
||||
use std::{error::Error, fmt};
|
||||
|
||||
use crate::{
|
||||
@@ -20,7 +22,7 @@ pub struct MobilePasswordRow {
|
||||
path: String,
|
||||
title: String,
|
||||
detail: String,
|
||||
system_image: String,
|
||||
icon: MobileIcon,
|
||||
kind: MobilePasswordRowKind,
|
||||
}
|
||||
|
||||
@@ -41,8 +43,8 @@ impl MobilePasswordRow {
|
||||
&self.detail
|
||||
}
|
||||
|
||||
pub fn system_image(&self) -> &str {
|
||||
&self.system_image
|
||||
pub fn icon(&self) -> MobileIcon {
|
||||
self.icon
|
||||
}
|
||||
|
||||
pub fn kind(&self) -> MobilePasswordRowKind {
|
||||
@@ -168,25 +170,25 @@ fn search_row(path: &crate::repository::EntryPath, text: &str) -> MobilePassword
|
||||
path: text.to_owned(),
|
||||
title,
|
||||
detail,
|
||||
system_image: "key.fill".to_owned(),
|
||||
icon: MobileIcon::Key,
|
||||
kind: MobilePasswordRowKind::Entry,
|
||||
}
|
||||
}
|
||||
|
||||
fn mobile_row(node: &TreeNode) -> MobilePasswordRow {
|
||||
let path = node.path().to_owned();
|
||||
let (kind, prefix, detail, system_image) = match node.kind() {
|
||||
let (kind, prefix, detail, icon) = match node.kind() {
|
||||
TreeNodeKind::Directory => (
|
||||
MobilePasswordRowKind::Directory,
|
||||
"directory",
|
||||
entry_count_detail(node),
|
||||
"folder",
|
||||
MobileIcon::Folder,
|
||||
),
|
||||
TreeNodeKind::Entry => (
|
||||
MobilePasswordRowKind::Entry,
|
||||
"entry",
|
||||
"Locked password".to_owned(),
|
||||
"key.fill",
|
||||
MobileIcon::Key,
|
||||
),
|
||||
};
|
||||
MobilePasswordRow {
|
||||
@@ -194,7 +196,7 @@ fn mobile_row(node: &TreeNode) -> MobilePasswordRow {
|
||||
path,
|
||||
title: node.name().to_owned(),
|
||||
detail,
|
||||
system_image: system_image.to_owned(),
|
||||
icon,
|
||||
kind,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
//! OS facts accepted by mobile frontends. Storage validates and interprets them.
|
||||
|
||||
use std::{
|
||||
error::Error,
|
||||
fmt,
|
||||
path::{Component, Path, PathBuf},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
presentation::{QrError, QrMatrix},
|
||||
repository::SecretBytes,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum MobilePlatformError {
|
||||
InvalidPaths,
|
||||
InvalidCredentialRequest,
|
||||
}
|
||||
|
||||
impl fmt::Display for MobilePlatformError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(match self {
|
||||
Self::InvalidPaths => "invalid mobile platform paths",
|
||||
Self::InvalidCredentialRequest => "invalid credential-provider request",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for MobilePlatformError {}
|
||||
|
||||
/// The OS supplies private roots; the repository layout is selected here.
|
||||
#[derive(Clone, Eq, PartialEq)]
|
||||
pub struct MobilePlatformPaths {
|
||||
data: PathBuf,
|
||||
cache: PathBuf,
|
||||
}
|
||||
|
||||
impl MobilePlatformPaths {
|
||||
pub fn new(data: PathBuf, cache: PathBuf) -> Result<Self, MobilePlatformError> {
|
||||
for path in [&data, &cache] {
|
||||
if !path.is_absolute()
|
||||
|| path.parent().is_none()
|
||||
|| path.components().any(|component| {
|
||||
!matches!(component, Component::RootDir | Component::Normal(_))
|
||||
})
|
||||
|| path
|
||||
.to_str()
|
||||
.is_none_or(|value| value.len() > 4096 || value.contains('\0'))
|
||||
{
|
||||
return Err(MobilePlatformError::InvalidPaths);
|
||||
}
|
||||
}
|
||||
if data.starts_with(&cache) || cache.starts_with(&data) {
|
||||
return Err(MobilePlatformError::InvalidPaths);
|
||||
}
|
||||
Ok(Self { data, cache })
|
||||
}
|
||||
|
||||
pub fn data(&self) -> &Path {
|
||||
&self.data
|
||||
}
|
||||
pub fn cache(&self) -> &Path {
|
||||
&self.cache
|
||||
}
|
||||
pub fn configuration(&self) -> PathBuf {
|
||||
self.data.join("config.toml")
|
||||
}
|
||||
pub fn vault(&self) -> PathBuf {
|
||||
self.data.join("vault")
|
||||
}
|
||||
pub fn keys(&self) -> PathBuf {
|
||||
self.data.join("keys")
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for MobilePlatformPaths {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str("MobilePlatformPaths([REDACTED])")
|
||||
}
|
||||
}
|
||||
|
||||
/// A Y plane, with OS-provided row/pixel strides. No camera or QR policy lives in the UI.
|
||||
pub struct MobileCameraFrame {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub row_stride: u32,
|
||||
pub pixel_stride: u32,
|
||||
pub luminance: SecretBytes,
|
||||
}
|
||||
|
||||
impl MobileCameraFrame {
|
||||
pub fn decode(&self) -> Result<SecretBytes, QrError> {
|
||||
let width = self.width as usize;
|
||||
let height = self.height as usize;
|
||||
let row = self.row_stride as usize;
|
||||
let pixel = self.pixel_stride as usize;
|
||||
let bytes = self.luminance.expose();
|
||||
if !(1..=4096).contains(&width)
|
||||
|| !(1..=4096).contains(&height)
|
||||
|| !(1..=4).contains(&pixel)
|
||||
|| bytes.len() > 32 * 1024 * 1024
|
||||
{
|
||||
return Err(QrError::InvalidImage);
|
||||
}
|
||||
let last_column = (width - 1)
|
||||
.checked_mul(pixel)
|
||||
.ok_or(QrError::InvalidImage)?;
|
||||
let last = (height - 1)
|
||||
.checked_mul(row)
|
||||
.and_then(|offset| offset.checked_add(last_column))
|
||||
.ok_or(QrError::InvalidImage)?;
|
||||
if row <= last_column || last >= bytes.len() {
|
||||
return Err(QrError::InvalidImage);
|
||||
}
|
||||
QrMatrix::decode_luminance(width, height, |x, y| bytes[y * row + x * pixel])
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for MobileCameraFrame {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str("MobileCameraFrame([REDACTED])")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum MobileLifecycleEvent {
|
||||
Foreground,
|
||||
Background,
|
||||
DeviceLocked,
|
||||
UserActivity,
|
||||
Cancel,
|
||||
}
|
||||
|
||||
/// A caller identity is an OS assertion, never an authorization grant.
|
||||
#[derive(Clone, Eq, PartialEq)]
|
||||
pub struct MobileCredentialRequest {
|
||||
request_id: String,
|
||||
package: String,
|
||||
signing_certificate_sha256: String,
|
||||
origins: Vec<String>,
|
||||
}
|
||||
|
||||
impl MobileCredentialRequest {
|
||||
pub fn new(
|
||||
request_id: String,
|
||||
package: String,
|
||||
certificate: String,
|
||||
origins: Vec<String>,
|
||||
) -> Result<Self, MobilePlatformError> {
|
||||
let invalid = MobilePlatformError::InvalidCredentialRequest;
|
||||
if request_id.is_empty()
|
||||
|| request_id.len() > 256
|
||||
|| request_id.chars().any(char::is_control)
|
||||
|| package.len() > 255
|
||||
|| !package.contains('.')
|
||||
|| package.split('.').any(|part| {
|
||||
part.is_empty()
|
||||
|| !part.bytes().enumerate().all(|(i, ch)| {
|
||||
ch == b'_' || ch.is_ascii_alphabetic() || (i > 0 && ch.is_ascii_digit())
|
||||
})
|
||||
})
|
||||
|| certificate.len() != 64
|
||||
|| !certificate.bytes().all(|ch| ch.is_ascii_hexdigit())
|
||||
|| origins.len() > 64
|
||||
{
|
||||
return Err(invalid);
|
||||
}
|
||||
let mut validated = Vec::new();
|
||||
for origin in origins {
|
||||
if origin.len() > 4096
|
||||
|| origin.chars().any(char::is_whitespace)
|
||||
|| origin.chars().any(char::is_control)
|
||||
{
|
||||
return Err(invalid);
|
||||
}
|
||||
let url = url::Url::parse(&origin).map_err(|_| invalid)?;
|
||||
if !matches!(url.scheme(), "https" | "http")
|
||||
|| url.host_str().is_none()
|
||||
|| !url.username().is_empty()
|
||||
|| url.password().is_some()
|
||||
|| url.query().is_some()
|
||||
|| url.fragment().is_some()
|
||||
|| url.path() != "/"
|
||||
{
|
||||
return Err(invalid);
|
||||
}
|
||||
let origin = url.origin().ascii_serialization();
|
||||
if !validated.contains(&origin) {
|
||||
validated.push(origin);
|
||||
}
|
||||
}
|
||||
Ok(Self {
|
||||
request_id,
|
||||
package,
|
||||
signing_certificate_sha256: certificate.to_ascii_lowercase(),
|
||||
origins: validated,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn request_id(&self) -> &str {
|
||||
&self.request_id
|
||||
}
|
||||
pub fn package(&self) -> &str {
|
||||
&self.package
|
||||
}
|
||||
pub fn signing_certificate_sha256(&self) -> &str {
|
||||
&self.signing_certificate_sha256
|
||||
}
|
||||
pub fn origins(&self) -> &[String] {
|
||||
&self.origins
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for MobileCredentialRequest {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str("MobileCredentialRequest([REDACTED])")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn roots_and_caller_facts_are_validated_without_creating_data_or_grants() {
|
||||
let paths =
|
||||
MobilePlatformPaths::new("/private/app/no-backup".into(), "/private/app/cache".into())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
paths.configuration(),
|
||||
Path::new("/private/app/no-backup/config.toml")
|
||||
);
|
||||
for (data, cache) in [
|
||||
("relative", "/cache"),
|
||||
("/app/../other", "/cache"),
|
||||
("/app", "/app/cache"),
|
||||
("/", "/cache"),
|
||||
] {
|
||||
assert!(MobilePlatformPaths::new(data.into(), cache.into()).is_err());
|
||||
}
|
||||
let request = MobileCredentialRequest::new(
|
||||
"opaque-id".into(),
|
||||
"de.example.app".into(),
|
||||
"A".repeat(64),
|
||||
vec!["https://EXAMPLE.test:443/".into()],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(request.origins(), ["https://example.test"]);
|
||||
for origin in [
|
||||
"https://user:secret@example.test",
|
||||
"file:///tmp",
|
||||
"https://example.test/path",
|
||||
"https://example.test/?token=secret",
|
||||
] {
|
||||
assert!(
|
||||
MobileCredentialRequest::new(
|
||||
"id".into(),
|
||||
"de.example.app".into(),
|
||||
"A".repeat(64),
|
||||
vec![origin.into()]
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
assert!(!format!("{request:?}").contains("example"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strided_camera_frame_decodes_in_rust_and_rejects_truncated_or_oversized_planes() {
|
||||
let secret = SecretBytes::new(b"otpauth://totp/Test?secret=JBSWY3DPEHPK3PXP".to_vec());
|
||||
let qr = QrMatrix::encode(&secret).unwrap();
|
||||
let side = (qr.width() + 8) * 8;
|
||||
let row = side * 2 + 7;
|
||||
let mut plane = vec![255; side * row];
|
||||
for y in 0..side {
|
||||
for x in 0..side {
|
||||
if x / 8 >= 4 && y / 8 >= 4 && qr.is_dark(x / 8 - 4, y / 8 - 4) == Some(true) {
|
||||
plane[y * row + x * 2] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut frame = MobileCameraFrame {
|
||||
width: side as u32,
|
||||
height: side as u32,
|
||||
row_stride: row as u32,
|
||||
pixel_stride: 2,
|
||||
luminance: SecretBytes::new(plane),
|
||||
};
|
||||
assert_eq!(frame.decode().unwrap().expose(), secret.expose());
|
||||
frame.row_stride = 1;
|
||||
assert!(frame.decode().is_err());
|
||||
frame.width = 4097;
|
||||
assert!(frame.decode().is_err());
|
||||
}
|
||||
}
|
||||
@@ -257,11 +257,19 @@ impl QrMatrix {
|
||||
.decode()
|
||||
.map_err(|_| QrError::InvalidImage)?
|
||||
.into_luma8();
|
||||
let mut prepared = rqrr::PreparedImage::prepare_from_greyscale(
|
||||
Self::decode_luminance(
|
||||
grayscale.width() as usize,
|
||||
grayscale.height() as usize,
|
||||
|x, y| grayscale.get_pixel(x as u32, y as u32).0[0],
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn decode_luminance(
|
||||
width: usize,
|
||||
height: usize,
|
||||
pixel: impl Fn(usize, usize) -> u8,
|
||||
) -> Result<SecretBytes, QrError> {
|
||||
let mut prepared = rqrr::PreparedImage::prepare_from_greyscale(width, height, pixel);
|
||||
let grid = prepared
|
||||
.detect_grids()
|
||||
.into_iter()
|
||||
|
||||
@@ -369,6 +369,51 @@ pub trait SecretStoreBackend: Send + Sync {
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: SecretStoreBackend + ?Sized> SecretStoreBackend for std::sync::Arc<T> {
|
||||
fn create(
|
||||
&self,
|
||||
locator: &SecretLocator,
|
||||
protection: SecretProtection,
|
||||
value: &[u8],
|
||||
) -> Result<(), SecretStoreError> {
|
||||
(**self).create(locator, protection, value)
|
||||
}
|
||||
fn retrieve(
|
||||
&self,
|
||||
locator: &SecretLocator,
|
||||
protection: SecretProtection,
|
||||
) -> Result<SecretBytes, SecretStoreError> {
|
||||
(**self).retrieve(locator, protection)
|
||||
}
|
||||
fn replace(
|
||||
&self,
|
||||
locator: &SecretLocator,
|
||||
protection: SecretProtection,
|
||||
value: &[u8],
|
||||
) -> Result<(), SecretStoreError> {
|
||||
(**self).replace(locator, protection, value)
|
||||
}
|
||||
fn delete(
|
||||
&self,
|
||||
locator: &SecretLocator,
|
||||
protection: SecretProtection,
|
||||
) -> Result<(), SecretStoreError> {
|
||||
(**self).delete(locator, protection)
|
||||
}
|
||||
fn platform_https_git_credential(
|
||||
&self,
|
||||
endpoint: &url::Url,
|
||||
) -> Result<Option<(String, SecretBytes)>, SecretStoreError> {
|
||||
(**self).platform_https_git_credential(endpoint)
|
||||
}
|
||||
fn lock(&self) -> Result<(), SecretStoreError> {
|
||||
(**self).lock()
|
||||
}
|
||||
fn unlock(&self) -> Result<(), SecretStoreError> {
|
||||
(**self).unlock()
|
||||
}
|
||||
}
|
||||
|
||||
struct CachedSecret {
|
||||
value: SecretBytes,
|
||||
expires_at: Instant,
|
||||
|
||||
@@ -17,9 +17,11 @@ fn apple_sources_preserve_the_mobile_security_boundary() {
|
||||
let watch = workspace_file("apple/Sources/Watch/IronStorageWatchApp.swift");
|
||||
let autofill = workspace_file("apple/Sources/AutoFill/CredentialProviderViewController.swift");
|
||||
let bridges = format!(
|
||||
"{}\n{}",
|
||||
"{}\n{}\n{}\n{}",
|
||||
workspace_file("crates/apple/src/lib.rs"),
|
||||
workspace_file("crates/watch-apple/src/lib.rs")
|
||||
workspace_file("crates/watch-apple/src/lib.rs"),
|
||||
workspace_file("crates/mobile-bridge/src/lib.rs"),
|
||||
workspace_file("crates/mobile-bridge/src/platform.rs")
|
||||
);
|
||||
|
||||
for (name, source) in [
|
||||
|
||||
@@ -26,6 +26,74 @@ use support::compatibility::FixtureSet;
|
||||
|
||||
type TestResult<T = ()> = Result<T, Box<dyn Error>>;
|
||||
|
||||
#[test]
|
||||
fn mobile_context_uses_injected_os_backend_clock_and_lifecycle_without_global_configuration()
|
||||
-> TestResult {
|
||||
use ironstorage::{
|
||||
config::Config, mobile_authentication::MobileAuthentication,
|
||||
mobile_platform::MobileLifecycleEvent,
|
||||
};
|
||||
let fixture = FixtureSet::load()?;
|
||||
let vault = fixture.materialize_store("basic")?;
|
||||
let root = tempfile::tempdir()?;
|
||||
let source = root.path().join("config.toml");
|
||||
std::fs::write(
|
||||
&source,
|
||||
format!(
|
||||
"vault = {:?}\nkey_material = {:?}\ndefault_key = {:?}\n[[git.remotes]]\nname = \"origin\"\nurl = \"https://example.test/team/passwords.git\"\nserver_id = \"server-example\"\napplication_id = \"repository-example\"\n",
|
||||
vault.path().to_str().expect("fixture UTF8"),
|
||||
fixture.path("keys").to_str().expect("fixture UTF8"),
|
||||
fixture.key("alice")?.primary_fingerprint,
|
||||
),
|
||||
)?;
|
||||
let backend = MemoryBackend::default();
|
||||
let clock = ManualClock::default();
|
||||
let authentication = MobileAuthentication::with_platform(
|
||||
Config::load(Some(&source))?,
|
||||
Arc::new(backend.clone()),
|
||||
Arc::new(clock.clone()),
|
||||
)?;
|
||||
assert!(
|
||||
!authentication
|
||||
.lifecycle(MobileLifecycleEvent::Foreground)?
|
||||
.unlocked()
|
||||
);
|
||||
assert_eq!(
|
||||
authentication
|
||||
.preferences(false, false, false)?
|
||||
.application_account(),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
backend.retrieves(),
|
||||
1,
|
||||
"preferences must use the injected backend"
|
||||
);
|
||||
authentication.set_authentication_timeout(30)?;
|
||||
authentication.unlock_entry(
|
||||
"email/personal",
|
||||
Some(SecretBytes::new(
|
||||
fixture.key("alice")?.passphrase.as_bytes().to_vec(),
|
||||
)),
|
||||
)?;
|
||||
assert!(authentication.state()?.unlocked());
|
||||
clock.advance(Duration::from_secs(20));
|
||||
assert_eq!(authentication.state()?.remaining_seconds(), 10);
|
||||
authentication.lifecycle(MobileLifecycleEvent::UserActivity)?;
|
||||
assert_eq!(authentication.state()?.remaining_seconds(), 30);
|
||||
let editor = authentication.begin_entry_editor("email/personal")?;
|
||||
authentication.lifecycle(MobileLifecycleEvent::Background)?;
|
||||
assert!(!authentication.state()?.unlocked());
|
||||
assert!(authentication.entry_editor(editor.id()).is_err());
|
||||
let baseline_locks = backend.locks();
|
||||
drop(authentication);
|
||||
assert!(
|
||||
backend.locks() > baseline_locks,
|
||||
"final owner locks the OS backend"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct BackendState {
|
||||
values: BTreeMap<SecretLocator, SecretBytes>,
|
||||
|
||||
@@ -183,7 +183,7 @@ fn complex_documents_round_trip_with_storage_owned_metadata() -> TestResult {
|
||||
assert_eq!(mobile_custom[1].value(), Some(""));
|
||||
let mobile_otp = mobile_fields
|
||||
.iter()
|
||||
.find(|field| field.system_image() == "timer")
|
||||
.find(|field| field.icon() == ironstorage::mobile::MobileIcon::Timer)
|
||||
.expect("OTP field");
|
||||
assert!(mobile_otp.sensitive());
|
||||
assert_eq!(mobile_otp.value(), None);
|
||||
@@ -192,7 +192,7 @@ fn complex_documents_round_trip_with_storage_owned_metadata() -> TestResult {
|
||||
}));
|
||||
let mobile_note = mobile_fields
|
||||
.iter()
|
||||
.find(|field| field.system_image() == "note.text")
|
||||
.find(|field| field.icon() == ironstorage::mobile::MobileIcon::Note)
|
||||
.expect("multiline note");
|
||||
assert!(!mobile_note.sensitive());
|
||||
assert!(mobile_note.multiline());
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
const WORKSPACE: &str = include_str!("../../../Cargo.toml");
|
||||
const STORAGE_MANIFEST: &str = include_str!("../Cargo.toml");
|
||||
const APPLE_MANIFEST: &str = include_str!("../../apple/Cargo.toml");
|
||||
const ANDROID_MANIFEST: &str = include_str!("../../android/Cargo.toml");
|
||||
const MOBILE_MANIFEST: &str = include_str!("../../mobile-bridge/Cargo.toml");
|
||||
const CLI_MANIFEST: &str = include_str!("../../../apps/cli/Cargo.toml");
|
||||
const TUI_MANIFEST: &str = include_str!("../../../apps/tui/Cargo.toml");
|
||||
const DESKTOP_MANIFEST: &str = include_str!("../../../apps/desktop/Cargo.toml");
|
||||
@@ -18,8 +20,16 @@ fn release_feature_and_advisory_boundaries_stay_explicit() {
|
||||
for manifest in [CLI_MANIFEST, TUI_MANIFEST, DESKTOP_MANIFEST] {
|
||||
assert!(manifest.contains("features = [\"ssh\"]"));
|
||||
}
|
||||
assert!(APPLE_MANIFEST.contains("default-features = false, features = [\"full\"]"));
|
||||
assert!(!APPLE_MANIFEST.contains("features = [\"ssh\"]"));
|
||||
assert!(MOBILE_MANIFEST.contains("default-features = false, features = [\"full\"]"));
|
||||
assert!(
|
||||
APPLE_MANIFEST.contains(
|
||||
"ironstorage-mobile = { path = \"../mobile-bridge\", features = [\"watch\"] }"
|
||||
)
|
||||
);
|
||||
assert!(ANDROID_MANIFEST.contains("ironstorage-mobile = { path = \"../mobile-bridge\" }"));
|
||||
for manifest in [APPLE_MANIFEST, ANDROID_MANIFEST, MOBILE_MANIFEST] {
|
||||
assert!(!manifest.contains("features = [\"ssh\"]"));
|
||||
}
|
||||
assert!(AUDIT_WORKFLOW.contains("tool: rust@1.92.0,cargo-audit@0.22.2"));
|
||||
assert!(AUDIT_WORKFLOW.contains("fallback: none"));
|
||||
assert!(!AUDIT_WORKFLOW.contains("cargo install"));
|
||||
|
||||
@@ -25,10 +25,10 @@ reads repository files, parses entries/URIs or error labels, generates passwords
|
||||
or codes, or independently decides credential matches or authentication policy.
|
||||
|
||||
Use typed semantic icons/actions, stable IDs, generation/revision tokens,
|
||||
view-ready records and typed errors. #91 removes SF Symbol and Watch assumptions
|
||||
from shared phone contracts. Keep Swift compatibility through mechanical
|
||||
adapters. #92 generates Kotlin and Swift from one shared UniFFI surface rather
|
||||
than copying crates/apple conversions. A thin Android bridge may adapt platform
|
||||
view-ready records and typed errors. #91's `crates/mobile-bridge` removes SF Symbol
|
||||
and Watch assumptions from shared phone contracts and generates Swift and Kotlin
|
||||
from one source. `crates/apple` opts into Watch; `crates/android` packages the
|
||||
phone surface through #92's native build gates. A thin Android bridge may adapt platform
|
||||
callbacks; it must contain no password-store decisions or handwritten unsafe
|
||||
Rust. Generated/dependency FFI stays isolated from project-authored safe Rust.
|
||||
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
# Shared mobile contract (#91)
|
||||
|
||||
`crates/mobile-bridge` is the single UniFFI source for phone records, enums,
|
||||
conversions, objects, operations and OS callbacks. `crates/android` packages it
|
||||
without features. `crates/apple` packages it with the explicit `watch` feature.
|
||||
Neither packaging crate copies converters. The component namespace is
|
||||
`ironstorage_mobile`; the native library names remain `ironstorage_android` and
|
||||
`ironstorage_apple`. Swift source/header names follow the shared namespace.
|
||||
|
||||
## Public workflow inventory
|
||||
|
||||
| Storage owner | Shared phone exports | Identity / concurrency boundary |
|
||||
| --- | --- | --- |
|
||||
| mobile | shell, fixtures, tab selection | typed tabs/states/semantic icons |
|
||||
| mobile_home / git | Home page, commits/changes/actions, controlled operations/progress | typed actions, repository operation serialization and cancellation |
|
||||
| mobile_passwords | browse, search, folder/entry rows | storage entry paths and row IDs |
|
||||
| mobile_authentication / authentication | unlock, reveal/copy, preferences, identity/appearance/timeout, lifecycle | generation-scoped authentication handles, monotonic expiry |
|
||||
| mobile_entry / document / generate | structured fields, editor sessions, OTP image import, generation, save/discard | session u64 and field IDs; original ciphertext/conflict checks |
|
||||
| mobile_mutation / mutation | preparation, request, commit, outcome | opaque revision token validated at execution; explicit overwrite decisions |
|
||||
| mobile_totp / otp | discovery/progress/cancel, cached search, detail/code/copy | storage-owned catalog, Unix-time inputs and expiry |
|
||||
| mobile_onboarding / config | server discovery, clone and local setup, token replacement | typed transport/phase errors, controlled cancellation |
|
||||
| mobile_key_transfer / presentation | export/import, frames, progress, key selection | storage-owned QR envelopes and bounded decoding |
|
||||
| mobile_watch | **Apple opt-in only:** preferences, selection, snapshots/status/receipts | no Watch enum, field, method or control in the Android contract |
|
||||
|
||||
`MobileIcon` expresses intent, never an asset name. UIKit maps it to SF Symbols
|
||||
in `MobileIcons.swift`. Android's core maps it exhaustively to its presentation
|
||||
enum; Compose maps navigation intents to vector drawables. Asset names and
|
||||
selected variants belong to the native UI. Actions already use typed enums.
|
||||
|
||||
## OS boundaries
|
||||
|
||||
- `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.
|
||||
- `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,
|
||||
enrollment recovery, cache lifetime or authentication policy. Its adapter is
|
||||
usable by `mobileAuthenticationWithPlatform`; #93 implements Android Keystore.
|
||||
Preferences use that same injected backend, never a hidden native fallback.
|
||||
- `MobileClock` returns elapsed monotonic milliseconds from a stable origin.
|
||||
It cannot mint or refresh leases. Rust uses the existing authentication clock
|
||||
and generation checks. Unix timestamps required by TOTP/Git remain explicit
|
||||
inputs, not values parsed from rendered countdowns.
|
||||
- `MobileLifecycleEvent` reports foreground, background, device lock, real user
|
||||
activity, or cancellation. Storage revokes leases/editors on background/lock,
|
||||
checks expiry on foreground, and extends activity only for an active lease.
|
||||
- `MobileClipboard` reads/writes/clears OS text and waits for a requested timeout
|
||||
or cancellation. Rust's existing manager restores the prior text or clears
|
||||
only if the copied value is still current; newer clipboard content survives.
|
||||
Call off the UI thread; the facade serializes access to one OS clipboard.
|
||||
- `MobileCameraFrame` supplies luminance bytes, dimensions and row/pixel stride.
|
||||
Storage validates bounds (4096 pixels per dimension, 32 MiB plane, pixel
|
||||
stride 1–4) and decodes QR payloads. Native UI never interprets envelopes.
|
||||
- `MobileCredentialRequest` carries an opaque request ID, OS-asserted package,
|
||||
signing-certificate SHA-256 and web origins. Rust validates/canonicalizes the
|
||||
facts. A validated request is **not** an authorization grant. #109 implements
|
||||
matching, selected-candidate authentication and scoped grants in storage.
|
||||
|
||||
## Ownership, cancellation and diagnostics
|
||||
|
||||
UniFFI objects are reference-counted Rust owners. Operation objects retain their
|
||||
authentication owner while work runs. Kotlin `close()` / Swift deinitialization
|
||||
release native handles; the final storage authentication owner destroys the
|
||||
lease and locks its store. Frontends cancel the exact operation on navigation
|
||||
or coroutine cancellation before releasing it. `MobileAuthentication.cancel`
|
||||
revokes its generation and discards editor state; it is not interchangeable
|
||||
with cancelling an individual discovery/Git operation. Late results are
|
||||
discarded by facade revision IDs. Polling, repaint and progress never count as
|
||||
user activity. Editor IDs and field IDs are stable within their owning session;
|
||||
revision tokens are opaque and must be returned unchanged to storage.
|
||||
|
||||
Secrets and payloads are owned values, not persisted view state. Rust secret
|
||||
buffers zeroize on drop. Swift/Kotlin cannot promise complete managed-memory
|
||||
erasure, so callers release revealed/copy/OTP/frame values when their operation
|
||||
ends and never log or save them. Generated records, associated-value enums and
|
||||
errors have redacted diagnostic rendering; typed fields remain available for
|
||||
deliberate UI presentation. Unexpected native/callback errors redact their
|
||||
payloads. Mechanical generator hardening is version-checked and preserves wire
|
||||
layouts and every UniFFI contract/API checksum.
|
||||
|
||||
## Contract gates
|
||||
|
||||
Every Android binding generation generates both Swift and Kotlin from the
|
||||
compiled phone component. The build tool verifies all records/fields, types,
|
||||
functions and operation checksums against metadata, writes `inventory.tsv`, and
|
||||
rejects any Watch item in the phone surface. The Apple opt-in build generates
|
||||
both languages too. `compare-contract` compares complete shared signatures;
|
||||
only Watch items and the opt-in Watch inputs to `preferences` differ.
|
||||
|
||||
```sh
|
||||
cargo build -p ironstorage-apple --lib
|
||||
cargo run -p ironstorage-android-build -- contract-bindings \
|
||||
target/debug/libironstorage_apple.dylib android/uniffi.toml /tmp/ironstorage-watch-contract watch
|
||||
cargo run -p ironstorage-android-build -- compare-contract \
|
||||
android/rust-bridge/build/generated/bindings-raw/debug/inventory.tsv \
|
||||
/tmp/ironstorage-watch-contract/inventory.tsv
|
||||
```
|
||||
|
||||
On non-macOS build hosts, use the host shared-library extension. Apple generation
|
||||
copies the verified/hardened Swift source and header into `apple/Generated`.
|
||||
Contract tests exercise typed errors, checksum failure, callback ownership,
|
||||
cancellation/concurrency, camera bounds, large foreign payloads and redaction.
|
||||
All app validation uses persistent emulator devices; no physical-device gate is
|
||||
part of this milestone's testing scope.
|
||||
|
||||
The native Swift probe in `tools/android-build/tests/mobile_contract.swift` runs
|
||||
against the generated source and the host Apple library:
|
||||
|
||||
```sh
|
||||
xcrun swiftc -swift-version 5 -module-cache-path /tmp/ironstorage-swift-cache \
|
||||
-I apple/Generated \
|
||||
-Xcc -fmodule-map-file=apple/Generated/ironstorage_mobileFFI.modulemap \
|
||||
-L target/debug -lironstorage_apple \
|
||||
-Xlinker -rpath -Xlinker "$PWD/target/debug" \
|
||||
apple/Generated/ironstorage_mobile.swift \
|
||||
tools/android-build/tests/mobile_contract.swift -o /tmp/ironstorage-swift-contract
|
||||
/tmp/ironstorage-swift-contract
|
||||
```
|
||||
|
||||
The Android native probe is `MobileContractTest`: run it with the existing
|
||||
`NativeRuntimeTest` on persistent 4 KB and strict 16 KB ARM64 emulators. UI and
|
||||
native instrumentation suites run sequentially to preserve input focus. The
|
||||
storage integration test exercises a real encrypted fixture with the injected
|
||||
secret backend/clock, activity renewal, background revocation, editor invalidation
|
||||
and final-owner cleanup without global configuration.
|
||||
@@ -36,7 +36,7 @@ each row. Every issue linked here belongs to milestone 23.
|
||||
| iPhone AutoFill credential selection (CredentialProviderViewController) and shared identity/access domain | desktop/read/document/secret_store today; storage-owned provider contract to add | AndroidX CredentialProviderService (API 34+) and framework AutofillService (API 26+) | #109; typed caller/site identity, non-secret candidates, explicit selection/auth, forged/stale requests, permissions and cross-process lifetime |
|
||||
| Camera permissions, private paths, secret-store mechanics, clipboard, scene/process events, screen privacy | Rust repository/config/secret_store/authentication/presentation; platform callbacks contain facts only | Android framework adapters, FLAG_SECURE, backup rules, ClipboardManager | #90/#93/#98/#100; backup inspection, sandbox paths, pressure/update, screen/clipboard/lifecycle tests |
|
||||
| Accessible labels/actions, large text, keyboard, responsive layout and localization | Typed Rust state/actions; UI accessibility is Kotlin | Compose semantics, TalkBack, adaptive layout, resources | #105; measured keyboard/TalkBack/large-font/multi-window/RTL/rotation coverage |
|
||||
| Watch pairing/install state in Settings; TOTP Watch selection, snapshots, receipts, transfer and watchOS detail | mobile_watch; Watch fields currently in mobile_totp/mobile_authentication and Apple bridge | Excluded from Android records and UI by #91 | Deliberate Watch-only exclusion; no unsupported placeholder controls or Wear implementation |
|
||||
| Watch pairing/install state in Settings; TOTP Watch selection, snapshots, receipts, transfer and watchOS detail | mobile_watch; shared bridge's explicit `watch` feature enabled only by crates/apple | Absent from Android records and UI (#91) | Deliberate Watch-only exclusion; no unsupported placeholder controls or Wear implementation |
|
||||
|
||||
Name search matches current mobile behavior; CLI/TUI decrypted grep is not an
|
||||
iPhone parity claim. Android has no separate filesystem-based search. The
|
||||
|
||||
@@ -13,6 +13,8 @@ publish = false
|
||||
ironstorage.workspace = true
|
||||
regex.workspace = true
|
||||
uniffi = { workspace = true, features = ["cli"] }
|
||||
uniffi_bindgen = "=0.32.0"
|
||||
camino = "1.2"
|
||||
object = { version = "0.36.7", default-features = false, features = ["read", "elf"] }
|
||||
zip = { version = "2.4.2", default-features = false, features = ["deflate"] }
|
||||
|
||||
|
||||
@@ -61,14 +61,14 @@ fn harden_source(source: &str) -> Result<String, Box<dyn Error>> {
|
||||
}
|
||||
let source = source.replace(internal, "class InternalException(@Suppress(\"UNUSED_PARAMETER\") message: String) : kotlin.Exception(\"Native Rust failure ([REDACTED])\")")
|
||||
.replace(callback, "val err = \"Native callback failed ([REDACTED])\"");
|
||||
let records = Regex::new(r"(?ms)^data class (\w+) \((.*?)\n\)([^\{]*)\{\n")?;
|
||||
let records = Regex::new(r"(?ms)^([ \t]*)data class (\w+)\s*\((.*?)\)[^\{]*\{\n")?;
|
||||
if !records.is_match(&source) {
|
||||
return Err("UniFFI record template changed; review diagnostic hardening".into());
|
||||
}
|
||||
let source = records.replace_all(&source, |captures: ®ex::Captures<'_>| {
|
||||
format!(
|
||||
"{} override fun toString(): String = \"{}([REDACTED])\"\n",
|
||||
&captures[0], &captures[1]
|
||||
&captures[0], &captures[2]
|
||||
)
|
||||
});
|
||||
let errors = Regex::new(r#"(?m)(override val message\n\s*)get\(\) = ".*"$"#)?;
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
//! Generate both languages from compiled metadata and verify their whole public inventory.
|
||||
|
||||
use std::{collections::BTreeSet, error::Error, fs, path::Path};
|
||||
|
||||
use camino::Utf8PathBuf;
|
||||
use regex::Regex;
|
||||
use uniffi_bindgen::{EmptyCrateConfigSupplier, interface::AsType};
|
||||
|
||||
fn utf8(path: &Path) -> Result<Utf8PathBuf, Box<dyn Error>> {
|
||||
Ok(Utf8PathBuf::from_path_buf(path.to_owned()).map_err(|_| "non-UTF8 binding path")?)
|
||||
}
|
||||
|
||||
pub fn generate(
|
||||
library: &Path,
|
||||
config: &Path,
|
||||
output: &Path,
|
||||
watch: bool,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
use uniffi::{GenerateOptions, TargetLanguage};
|
||||
let library = utf8(library)?;
|
||||
let config = utf8(config)?;
|
||||
let out = utf8(output)?;
|
||||
fs::create_dir_all(output)?;
|
||||
for (language, directory) in [
|
||||
(TargetLanguage::Swift, "swift"),
|
||||
(TargetLanguage::Kotlin, "kotlin"),
|
||||
] {
|
||||
uniffi::generate(GenerateOptions {
|
||||
languages: vec![language],
|
||||
source: library.clone(),
|
||||
out_dir: out.join(directory),
|
||||
config_override: Some(config.clone()),
|
||||
format: false,
|
||||
..Default::default()
|
||||
})?;
|
||||
}
|
||||
let cis = uniffi_bindgen::library_mode::find_cis(&library, &EmptyCrateConfigSupplier)?;
|
||||
if cis.len() != 1 || cis[0].namespace() != "ironstorage_mobile" {
|
||||
return Err("expected one shared mobile UniFFI component".into());
|
||||
}
|
||||
let ci = &cis[0];
|
||||
let swift = fs::read_to_string(output.join("swift/ironstorage_mobile.swift"))?;
|
||||
let kotlin = read_kotlin(&output.join("kotlin"))?;
|
||||
let mut inventory = BTreeSet::new();
|
||||
let mut names = BTreeSet::new();
|
||||
for record in ci.record_definitions() {
|
||||
names.insert(record.name().to_owned());
|
||||
inventory.insert(format!("record\t{}", record.name()));
|
||||
for field in record.fields() {
|
||||
let name = camel(field.name());
|
||||
if !swift.contains(&format!("var {name}:")) || !kotlin.contains(&format!("`{name}`:")) {
|
||||
return Err(format!(
|
||||
"record field missing in a generated language: {}.{name}",
|
||||
record.name()
|
||||
)
|
||||
.into());
|
||||
}
|
||||
inventory.insert(format!(
|
||||
"field\t{}.{}\t{:?}",
|
||||
record.name(),
|
||||
field.name(),
|
||||
field.as_type()
|
||||
));
|
||||
}
|
||||
}
|
||||
for value in ci.enum_definitions() {
|
||||
names.insert(value.name().to_owned());
|
||||
inventory.insert(format!("enum\t{}", value.name()));
|
||||
for variant in value.variants() {
|
||||
inventory.insert(format!(
|
||||
"variant\t{}.{}\t{:?}",
|
||||
value.name(),
|
||||
variant.name(),
|
||||
variant
|
||||
.fields()
|
||||
.iter()
|
||||
.map(|field| field.as_type())
|
||||
.collect::<Vec<_>>()
|
||||
));
|
||||
}
|
||||
}
|
||||
for object in ci.object_definitions() {
|
||||
names.insert(object.name().to_owned());
|
||||
inventory.insert(format!("object\t{}", object.name()));
|
||||
for method in object.methods() {
|
||||
inventory.insert(format!(
|
||||
"method\t{}.{}\t{:?}\t{:?}",
|
||||
object.name(),
|
||||
method.name(),
|
||||
method
|
||||
.arguments()
|
||||
.iter()
|
||||
.map(|argument| argument.as_type())
|
||||
.collect::<Vec<_>>(),
|
||||
method.return_type()
|
||||
));
|
||||
}
|
||||
}
|
||||
for function in ci.function_definitions() {
|
||||
let name = camel(function.name());
|
||||
if !swift.contains(&format!("func {name}("))
|
||||
|| !kotlin.contains(&format!("fun `{name}`("))
|
||||
&& !kotlin.contains(&format!("fun {name}("))
|
||||
{
|
||||
return Err(format!("function missing from generated language: {name}").into());
|
||||
}
|
||||
inventory.insert(format!(
|
||||
"function\t{}\t{:?}\t{:?}",
|
||||
function.name(),
|
||||
function
|
||||
.arguments()
|
||||
.iter()
|
||||
.map(|argument| argument.as_type())
|
||||
.collect::<Vec<_>>(),
|
||||
function.return_type()
|
||||
));
|
||||
}
|
||||
for name in names {
|
||||
let kotlin_name = if ci.is_name_used_as_error(&name) {
|
||||
format!("{}Exception", name.strip_suffix("Error").unwrap_or(&name))
|
||||
} else {
|
||||
name.clone()
|
||||
};
|
||||
if !swift.contains(&name) || !kotlin.contains(&kotlin_name) {
|
||||
return Err(format!("type missing from generated language: {name}").into());
|
||||
}
|
||||
}
|
||||
// Both generated languages must verify every native operation checksum, not just its name.
|
||||
for checksum in ci.iter_checksum_ffi_functions() {
|
||||
if !swift.contains(checksum.name()) || !kotlin.contains(checksum.name()) {
|
||||
return Err(format!("missing integrity check: {}", checksum.name()).into());
|
||||
}
|
||||
}
|
||||
if !watch
|
||||
&& inventory
|
||||
.iter()
|
||||
.any(|line| line.to_ascii_lowercase().contains("watch"))
|
||||
{
|
||||
return Err("Watch data leaked into the phone contract".into());
|
||||
}
|
||||
if watch
|
||||
&& !inventory
|
||||
.iter()
|
||||
.any(|line| line.contains("MobileWatchSnapshotTransfer"))
|
||||
{
|
||||
return Err("Apple contract lost Watch transfer".into());
|
||||
}
|
||||
fs::write(
|
||||
output.join("inventory.tsv"),
|
||||
inventory.into_iter().collect::<Vec<_>>().join("\n") + "\n",
|
||||
)?;
|
||||
prepare_swift(
|
||||
&output.join("swift/ironstorage_mobile.swift"),
|
||||
&output.join("swift/ironstorage_mobile.swift"),
|
||||
)?;
|
||||
let header = output.join("swift/ironstorage_mobileFFI.h");
|
||||
fs::write(&header, normalized_lines(&fs::read_to_string(&header)?))?;
|
||||
println!("Verified Swift/Kotlin inventory and operation checksums (watch={watch})");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn compare(phone: &Path, watch: &Path) -> Result<(), Box<dyn Error>> {
|
||||
let phone = fs::read_to_string(phone)?;
|
||||
let watch = fs::read_to_string(watch)?;
|
||||
let shared = |source: &str| -> BTreeSet<String> {
|
||||
source
|
||||
.lines()
|
||||
.filter(|line| {
|
||||
!line.to_ascii_lowercase().contains("watch")
|
||||
&& !line.starts_with("method\tMobileAuthentication.preferences\t")
|
||||
})
|
||||
.map(str::to_owned)
|
||||
.collect()
|
||||
};
|
||||
let phone = shared(&phone);
|
||||
let watch = shared(&watch);
|
||||
if phone != watch {
|
||||
return Err(format!(
|
||||
"phone/Apple public API drift: phone-only={:?}, Apple-only={:?}",
|
||||
phone.difference(&watch).collect::<Vec<_>>(),
|
||||
watch.difference(&phone).collect::<Vec<_>>()
|
||||
)
|
||||
.into());
|
||||
}
|
||||
println!(
|
||||
"Verified {} identical shared phone feature/signature entries",
|
||||
phone.len()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn camel(name: &str) -> String {
|
||||
let mut parts = name.split('_');
|
||||
let mut output = parts.next().unwrap_or_default().to_owned();
|
||||
for part in parts {
|
||||
let mut chars = part.chars();
|
||||
if let Some(first) = chars.next() {
|
||||
output.extend(first.to_uppercase());
|
||||
output.extend(chars);
|
||||
}
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
fn read_kotlin(path: &Path) -> Result<String, Box<dyn Error>> {
|
||||
let mut source = String::new();
|
||||
for entry in fs::read_dir(path)? {
|
||||
let entry = entry?;
|
||||
if entry.file_type()?.is_dir() {
|
||||
source.push_str(&read_kotlin(&entry.path())?);
|
||||
} else if entry.path().extension().is_some_and(|ext| ext == "kt") {
|
||||
source.push_str(&fs::read_to_string(entry.path())?);
|
||||
}
|
||||
}
|
||||
Ok(source)
|
||||
}
|
||||
|
||||
fn normalized_lines(source: &str) -> String {
|
||||
source
|
||||
.lines()
|
||||
.map(str::trim_end)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
.trim_end()
|
||||
.to_owned()
|
||||
+ "\n"
|
||||
}
|
||||
|
||||
pub fn prepare_swift(input: &Path, output: &Path) -> Result<(), Box<dyn Error>> {
|
||||
let source = fs::read_to_string(input)?;
|
||||
if source.contains("// IronStorage diagnostic redaction") {
|
||||
return Err("Swift diagnostics already prepared".into());
|
||||
}
|
||||
let records = Regex::new(r"(?m)^public struct (Mobile\w+)[ :]")?;
|
||||
let mut names = records
|
||||
.captures_iter(&source)
|
||||
.map(|capture| capture[1].to_owned())
|
||||
.collect::<BTreeSet<_>>();
|
||||
// Error/associated-value enums also expose payloads through Swift's default reflection.
|
||||
let error_names = Regex::new(r"(?m)^enum (Mobile\w+Error):")?;
|
||||
names.extend(
|
||||
error_names
|
||||
.captures_iter(&source)
|
||||
.map(|capture| capture[1].to_owned()),
|
||||
);
|
||||
for name in ["MobileSecretLocator", "MobileClipboardContent"] {
|
||||
if source.contains(&format!("enum {name}")) {
|
||||
names.insert(name.into());
|
||||
}
|
||||
}
|
||||
if names.is_empty() {
|
||||
return Err("Swift record template changed".into());
|
||||
}
|
||||
let panic_payload =
|
||||
"throw UniffiInternalError.rustPanic(try FfiConverterString.lift(callStatus.errorBuf))";
|
||||
let callback_payload = "FfiConverterString.lower(String(describing: error))";
|
||||
if source.contains("fileprivate enum UniffiInternalError")
|
||||
&& (!source.contains(panic_payload) || source.matches(callback_payload).count() != 2)
|
||||
{
|
||||
return Err("UniFFI Swift unexpected-error template changed".into());
|
||||
}
|
||||
let source = source.replace(panic_payload, "_ = try FfiConverterString.lift(callStatus.errorBuf)\n throw UniffiInternalError.rustPanic(\"Native Rust failure ([REDACTED])\")")
|
||||
.replace(callback_payload, "FfiConverterString.lower(\"Native callback failed ([REDACTED])\")")
|
||||
.replace("} catch let error {\n callStatus.pointee.code = CALL_UNEXPECTED_ERROR", "} catch {\n callStatus.pointee.code = CALL_UNEXPECTED_ERROR")
|
||||
.replace("case let .rustPanic(message): return message", "case .rustPanic: return \"Native Rust failure ([REDACTED])\"");
|
||||
let mut output_source = source;
|
||||
output_source.push_str(
|
||||
"\n// IronStorage diagnostic redaction; wire values and checksums remain generated.\n",
|
||||
);
|
||||
for name in names {
|
||||
output_source.push_str(&format!("extension {name}: CustomStringConvertible, CustomDebugStringConvertible {{\n public var description: String {{ \"{name}([REDACTED])\" }}\n public var debugDescription: String {{ description }}\n}}\n"));
|
||||
}
|
||||
fs::write(output, normalized_lines(&output_source))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn inventory_comparison_rejects_missing_methods_and_type_changes() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("ironstorage-contract-{}", std::process::id()));
|
||||
fs::create_dir_all(&root).unwrap();
|
||||
let phone = root.join("phone.tsv");
|
||||
let watch = root.join("watch.tsv");
|
||||
fs::write(&phone, "method\tMobileAuthentication.state\t[]\tBool\n").unwrap();
|
||||
fs::write(&watch, "method\tMobileAuthentication.state\t[]\tString\n").unwrap();
|
||||
assert!(compare(&phone, &watch).is_err());
|
||||
fs::write(
|
||||
&watch,
|
||||
"method\tMobileAuthentication.state\t[]\tBool\nfield\tMobileTotpPage.watch\tWatch\n",
|
||||
)
|
||||
.unwrap();
|
||||
compare(&phone, &watch).unwrap();
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
#[test]
|
||||
fn swift_record_and_error_reflection_are_redacted() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"ironstorage-swift-redaction-{}",
|
||||
std::process::id()
|
||||
));
|
||||
fs::create_dir_all(&root).unwrap();
|
||||
let input = root.join("raw.swift");
|
||||
let output = root.join("prepared.swift");
|
||||
fs::write(&input, "public struct MobileEntryCopy: Equatable { public var value: String }\npublic\nenum MobileAuthenticationFfiError: Swift.Error {}\n").unwrap();
|
||||
prepare_swift(&input, &output).unwrap();
|
||||
let hardened = fs::read_to_string(output).unwrap();
|
||||
assert!(hardened.contains("MobileEntryCopy([REDACTED])"));
|
||||
assert!(hardened.contains("MobileAuthenticationFfiError([REDACTED])"));
|
||||
assert!(hardened.contains("public var value: String"));
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ use ironstorage::mobile::{MobileShell, MobileShellState};
|
||||
use std::{env, error::Error, fs, path::Path};
|
||||
|
||||
mod bindings;
|
||||
mod contract;
|
||||
mod native;
|
||||
mod toolchain;
|
||||
|
||||
@@ -16,6 +17,20 @@ fn main() -> Result<(), Box<dyn Error>> {
|
||||
fs::write(path, fixtures())?;
|
||||
}
|
||||
[_, command, path] if command == "check" => check(Path::new(path))?,
|
||||
[_, command, library, config, output, mode] if command == "contract-bindings" => {
|
||||
contract::generate(
|
||||
Path::new(library),
|
||||
Path::new(config),
|
||||
Path::new(output),
|
||||
mode == "watch",
|
||||
)?;
|
||||
}
|
||||
[_, command, phone, watch] if command == "compare-contract" => {
|
||||
contract::compare(Path::new(phone), Path::new(watch))?;
|
||||
}
|
||||
[_, command, input, output] if command == "prepare-swift" => {
|
||||
contract::prepare_swift(Path::new(input), Path::new(output))?;
|
||||
}
|
||||
[_, command, input, output] if command == "prepare-bindings" => {
|
||||
bindings::prepare(Path::new(input), Path::new(output))?;
|
||||
}
|
||||
@@ -68,11 +83,12 @@ fn fixtures() -> String {
|
||||
output.push_str(&format!(" ShellState.{state:?} to listOf(\n"));
|
||||
for page in MobileShell::fixture(state).pages() {
|
||||
output.push_str(&format!(
|
||||
" ShellPage(ShellTab.{:?}, {}, {}, {}),\n",
|
||||
" ShellPage(ShellTab.{:?}, {}, {}, {}, ShellIcon.{:?}),\n",
|
||||
page.tab(),
|
||||
kotlin_string(page.title()),
|
||||
kotlin_string(page.state_title()),
|
||||
kotlin_string(page.state_detail()),
|
||||
page.icon(),
|
||||
));
|
||||
}
|
||||
output.push_str(" ),\n");
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import Foundation
|
||||
|
||||
// Runs against the generated Swift component and host Rust library.
|
||||
final class ContractClipboard: MobileClipboard, @unchecked Sendable {
|
||||
var text = "previous"
|
||||
var writtenBytes = 0
|
||||
var timeout: UInt64 = 0
|
||||
var newer: String?
|
||||
var cancelled = false
|
||||
|
||||
func read() throws -> MobileClipboardContent { .text(value: Data(text.utf8)) }
|
||||
func write(value: Data) throws {
|
||||
writtenBytes = max(writtenBytes, value.count)
|
||||
text = String(decoding: value, as: UTF8.self)
|
||||
}
|
||||
func clear() throws { text = "" }
|
||||
func waitForTimeout(milliseconds: UInt64) -> MobileClipboardWait {
|
||||
timeout = milliseconds
|
||||
if let newer { text = newer }
|
||||
return cancelled ? .cancelled : .elapsed
|
||||
}
|
||||
}
|
||||
|
||||
@main
|
||||
enum MobileContractProbe {
|
||||
static func main() throws {
|
||||
let secret = String(repeating: "contract-payload-", count: 70_000)
|
||||
let copy = MobileEntryCopy(value: secret, timeoutSeconds: 45)
|
||||
for diagnostic in [String(describing: copy), String(reflecting: copy)] {
|
||||
precondition(!diagnostic.contains(secret))
|
||||
}
|
||||
let error = MobileAuthenticationFfiError.Failed(kind: .entry, title: secret, detail: secret)
|
||||
precondition(!String(reflecting: error).contains(secret))
|
||||
precondition(!error.localizedDescription.contains(secret))
|
||||
precondition(!String(reflecting: MobileClipboardContent.text(value: Data(secret.utf8))).contains(secret))
|
||||
precondition(!String(reflecting: MobileSecretLocator.openPgpPassphrase(fingerprint: secret)).contains(secret))
|
||||
for state in [MobileShellState.loading, .empty, .ready, .locked, .error] {
|
||||
let shell = mobileShellFixture(state: state)
|
||||
precondition(shell.pages.count == 5)
|
||||
precondition(shell.pages.allSatisfy { $0.state == state })
|
||||
precondition(shell.pages.map(\.icon) == [.home, .key, .search, .timer, .settings])
|
||||
}
|
||||
let clipboard = ContractClipboard()
|
||||
let restored = try copyMobileEntry(copy: copy, clipboard: clipboard)
|
||||
precondition(restored == .restoredPrevious)
|
||||
precondition(clipboard.text == "previous" && clipboard.writtenBytes == secret.utf8.count)
|
||||
precondition(clipboard.timeout == 45_000)
|
||||
clipboard.newer = "newer"
|
||||
let preserved = try copyMobileEntry(copy: copy, clipboard: clipboard)
|
||||
precondition(preserved == .preservedNewer)
|
||||
precondition(clipboard.text == "newer")
|
||||
clipboard.newer = nil
|
||||
clipboard.cancelled = true
|
||||
do { _ = try copyMobileEntry(copy: copy, clipboard: clipboard); preconditionFailure("cancellation must fail") }
|
||||
catch MobilePlatformFfiError.Cancelled {}
|
||||
precondition(clipboard.text == "newer")
|
||||
DispatchQueue.concurrentPerform(iterations: 16) { _ in
|
||||
let operation = mobileTotpOperation()
|
||||
operation.cancel()
|
||||
precondition(operation.progress().phase == .cancelled)
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user