diff --git a/.gitea/scripts/android_ci.py b/.gitea/scripts/android_ci.py index fde3675..7e4c390 100644 --- a/.gitea/scripts/android_ci.py +++ b/.gitea/scripts/android_ci.py @@ -222,6 +222,8 @@ def build(): decoded.write_text(run(analyzer, "resources", "xml", "--file", resource, path, capture=True).stdout) rules.append(decoded) tool("audit-manifest", manifest, *rules) + if variant == "release": + tool("audit-release-manifest", manifest) trees = [] for target in ("aarch64-linux-android", "armv7-linux-androideabi"): trees.append(run(CARGO, "+1.97.1", "tree", "--locked", "-p", "ironstorage-android", "--target", target, "--edges", "normal", "--prefix", "none", "--format", "{p}|{l}|{r}", capture=True, cwd=ROOT).stdout) diff --git a/Cargo.lock b/Cargo.lock index b589899..2c3b441 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1439,9 +1439,9 @@ checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] name = "chacha20" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", "cipher 0.5.2", @@ -2271,6 +2271,15 @@ dependencies = [ "generic-array 0.14.7", ] +[[package]] +name = "defmt" +version = "0.3.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0963443817029b2024136fc4dd07a5107eb8f977eaf18fcd1fdeb11306b64ad" +dependencies = [ + "defmt 1.1.1", +] + [[package]] name = "defmt" version = "1.1.1" @@ -2883,10 +2892,12 @@ checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" [[package]] name = "faster-hex" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7223ae2d2f179b803433d9c830478527e92b8117eab39460edae7f1614d9fb73" +checksum = "04839bdf9d8c10f66806fad16b852fc72aab80873aebc3cb69d85b4fa41543ed" dependencies = [ + "autocfg", + "defmt 0.3.100", "heapless", "serde", ] @@ -3844,7 +3855,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a791e6620676a875f362f3156ed213e73ca099a09bf992c18812abe65cc37b1" dependencies = [ "bstr", - "hashbrown 0.17.1", + "hashbrown 0.15.5", ] [[package]] @@ -5588,7 +5599,7 @@ version = "0.2.35" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" dependencies = [ - "defmt", + "defmt 1.1.1", "jiff-core", "jiff-static", "jiff-tzdb-platform", @@ -5605,7 +5616,7 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" dependencies = [ - "defmt", + "defmt 1.1.1", ] [[package]] @@ -6005,15 +6016,12 @@ name = "lru" version = "0.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" -dependencies = [ - "hashbrown 0.16.1", -] [[package]] name = "lru" -version = "0.18.1" +version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b6180140927ee907000b0aa540091f6ea512ead4447c92b8fc35bc72788a5a6" +checksum = "5d2f2f9b4ba7e6b24d95e7e899329d35be83bcded72c8540cdd5368932d1d90a" dependencies = [ "hashbrown 0.17.1", ] @@ -8147,7 +8155,7 @@ dependencies = [ "hashbrown 0.17.1", "itertools 0.14.0", "kasuari", - "lru 0.18.1", + "lru 0.18.2", "palette", "serde", "strum", @@ -8523,12 +8531,12 @@ dependencies = [ [[package]] name = "rqrr" -version = "0.10.1" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffbe87d9e8db95652c25ded2418150e00b08c2fde09e23ec15896d2c470c6631" +checksum = "8d084c50c5cfa397a081850da329566c8667b0b6c4e81da16abff1fef27de93b" dependencies = [ "g2p", - "lru 0.16.4", + "lru 0.18.2", ] [[package]] @@ -8755,9 +8763,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.45" +version = "0.23.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ "aws-lc-rs", "once_cell", @@ -9886,7 +9894,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix 1.1.4", "windows-sys 0.61.2", diff --git a/Cargo.toml b/Cargo.toml index 1ddd5ac..fdf1e9e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,9 +54,9 @@ qrcode = { version = "0.14", default-features = false } rand = "0.8" regex = "1.13" reqwest = { version = "0.13", default-features = false, features = ["blocking", "rustls"] } -rustls = { version = "0.23", default-features = false } +rustls = { version = "0.23.46", default-features = false } rfd = { version = "0.17", default-features = false } -rqrr = { version = "0.10", default-features = false } +rqrr = { version = "0.11", default-features = false } rpassword = "7.5" russh = { version = "=0.63.1", default-features = false, features = ["ring", "rsa"] } ratatui = { version = "0.30", default-features = false, features = ["crossterm_0_29", "layout-cache", "macros", "underline-color", "unstable-rendered-line-info"] } diff --git a/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/TotpScreenTest.kt b/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/TotpScreenTest.kt index f4221f2..26088aa 100644 --- a/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/TotpScreenTest.kt +++ b/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/TotpScreenTest.kt @@ -86,10 +86,10 @@ class TotpScreenTest { compose.onNodeWithText("Unlock").performClick() compose.waitUntil(5000) { fixture.ticks > 0 } compose.onNodeWithText("Back to TOTP entries").performClick() - val stopped = fixture.ticks + compose.onNodeWithTag("otp-code").assertDoesNotExist() + val stopped = compose.runOnIdle { fixture.ticks } Thread.sleep(1200) - compose.waitForIdle() - assertEquals(stopped, fixture.ticks) + compose.runOnIdle { assertEquals(stopped, fixture.ticks) } } @Test fun largeTextShowsEveryDigitWithoutClipping() { diff --git a/android/core/src/androidTest/kotlin/de/rfc1437/ironstorage/core/NativeEntryViewerTest.kt b/android/core/src/androidTest/kotlin/de/rfc1437/ironstorage/core/NativeEntryViewerTest.kt index 2a37793..78e4c79 100644 --- a/android/core/src/androidTest/kotlin/de/rfc1437/ironstorage/core/NativeEntryViewerTest.kt +++ b/android/core/src/androidTest/kotlin/de/rfc1437/ironstorage/core/NativeEntryViewerTest.kt @@ -9,7 +9,9 @@ import java.util.UUID import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async import kotlinx.coroutines.cancel +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking @@ -66,6 +68,24 @@ class NativeEntryViewerTest { val key = setup.generate("Viewer fixture", password).selectedKey setup.setupLocal(requireNotNull(key), password) } + repeat(12) { + coroutineScope { + val onboarding = async(Dispatchers.IO) { + session.storage().androidOnboarding(session.backend()).use { it.status() } + } + val home = async(Dispatchers.IO) { + session.storage().androidHomeOperation( + session.backend(), + object : MobileClock { + override fun monotonicMilliseconds(): ULong = + android.os.SystemClock.elapsedRealtime().toULong() + } + ).use { it.cached() } + } + assertTrue(onboarding.await().configured) + home.await() + } + } for (appearance in Appearance.entries) { withContext(Dispatchers.Main.immediate) { authentication.setAppearance(appearance) } withTimeout(30000) { diff --git a/crates/storage/src/mobile_home.rs b/crates/storage/src/mobile_home.rs index 991f2ff..ef21c47 100644 --- a/crates/storage/src/mobile_home.rs +++ b/crates/storage/src/mobile_home.rs @@ -24,7 +24,7 @@ use crate::{ mobile_authentication::{ MobileAuthentication, MobileRepositoryOperation, MobileRepositoryOperationError, }, - mobile_private_storage::{PrivateStorage, PrivateStorageError, exclusive_lock}, + mobile_private_storage::{PrivateStorage, PrivateStorageError, runtime_lock}, repository::{Repository, RepositoryError}, secret_store::{ NativeSecretBackend, SecretCachePolicy, SecretProtectionPolicy, SecretStore, @@ -582,8 +582,7 @@ impl MobileHomeStorage { cap_std::ambient_authority(), ) .map_err(|_| MobileHomeError::from_private(PrivateStorageError::Io))?; - let lock = - exclusive_lock(&root, "runtime.lock").map_err(MobileHomeError::from_private)?; + let lock = runtime_lock(&root).map_err(MobileHomeError::from_private)?; let config = private .storage .configuration() diff --git a/crates/storage/src/mobile_home/android_tests.rs b/crates/storage/src/mobile_home/android_tests.rs index f23d8c6..c07e4d9 100644 --- a/crates/storage/src/mobile_home/android_tests.rs +++ b/crates/storage/src/mobile_home/android_tests.rs @@ -3,6 +3,7 @@ use crate::{ authentication::SystemAuthenticationClock, git::{GitCredential, GitFetchTransport, GitRemoteCredentialProvider, GitSmartHttpTransport}, mobile_onboarding::android::tests::{Backend, home_fixture}, + mobile_private_storage::exclusive_lock, repository::SecretBytes, }; use std::{fs, sync::mpsc, thread, time::Duration}; @@ -66,7 +67,7 @@ fn private_home_keeps_transient_private_storage_retry_guidance() { ) .err() .unwrap(); - assert_eq!(error.kind(), MobileHomeErrorKind::Configuration); + assert_eq!(error.kind(), MobileHomeErrorKind::Conflict); assert!(error.detail().contains("Retry later")); assert!( !error diff --git a/crates/storage/src/mobile_onboarding/android.rs b/crates/storage/src/mobile_onboarding/android.rs index bea2cea..985b0a8 100644 --- a/crates/storage/src/mobile_onboarding/android.rs +++ b/crates/storage/src/mobile_onboarding/android.rs @@ -538,7 +538,7 @@ impl AndroidOnboarding { } fn lock(&self) -> Result { let dir = Dir::open_ambient_dir(&self.root, ambient_authority()).map_err(io_error)?; - crate::mobile_private_storage::exclusive_lock(&dir, "runtime.lock").map_err(private_error) + crate::mobile_private_storage::runtime_lock(&dir).map_err(private_error) } fn begin( &self, @@ -878,7 +878,11 @@ fn io_error(error: io::Error) -> MobileOnboardingError { fn private_error(error: PrivateStorageError) -> MobileOnboardingError { MobileOnboardingError { kind: MobileOnboardingErrorKind::Configuration, - title: "Setup Recovery Required", + title: if error == PrivateStorageError::Busy { + "Private Storage Busy" + } else { + "Setup Recovery Required" + }, detail: error.detail().into(), } } @@ -912,6 +916,16 @@ pub(crate) mod tests { }; use std::collections::BTreeMap; use std::sync::atomic::{AtomicBool, Ordering}; + #[test] + fn private_contention_does_not_claim_setup_recovery() { + let busy = private_error(PrivateStorageError::Busy); + assert_eq!(busy.title(), "Private Storage Busy"); + assert_eq!(busy.detail(), PrivateStorageError::Busy.detail()); + assert_eq!( + private_error(PrivateStorageError::CorruptLayout).title(), + "Setup Recovery Required" + ); + } const ALICE: &str = "7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30"; const BOB: &str = "B37027B56FC406BD3F6A622B2AC03492B992D06F"; fn password() -> Option { diff --git a/crates/storage/src/mobile_private_storage.rs b/crates/storage/src/mobile_private_storage.rs index 7710b12..4ad2745 100644 --- a/crates/storage/src/mobile_private_storage.rs +++ b/crates/storage/src/mobile_private_storage.rs @@ -55,7 +55,7 @@ impl PrivateStorageError { Self::AccessDenied => "Private storage access was denied. Existing data was retained.", Self::ReadOnly => "Private storage is read-only. Existing data was retained.", Self::StorageFull => "Free device storage and retry. Existing data was retained.", - Self::Busy => "Another process is initializing private storage. Retry later.", + Self::Busy => "Another private storage operation is in progress. Retry later.", Self::Io => "Private storage could not be accessed. Existing data was retained.", Self::CorruptLayout => { "Storage layout metadata is damaged. Do not reset the repository." @@ -179,7 +179,7 @@ impl PrivateStorage { let os_cache = Dir::open_ambient_dir(&roots.cache, ambient_authority())?; let data = private_directory(&no_backup, "ironstorage")?; let cache = private_directory(&os_cache, "ironstorage")?; - let _initialization = exclusive_lock(&data, "runtime.lock")?; + let _initialization = runtime_lock(&data)?; let layout = match regular_bytes(&data, MARKER, 16 * 1024)? { Some(bytes) => toml::from_str::( std::str::from_utf8(&bytes).map_err(|_| PrivateStorageError::CorruptLayout)?, @@ -321,11 +321,18 @@ impl PrivateStorage { clock: Arc, ) -> Result, MobileAuthenticationError> { let recovery = |error: PrivateStorageError| { - MobileAuthenticationError::new( - MobileAuthenticationErrorKind::Configuration, - "Private Storage Recovery Required", - error.detail(), - ) + let (kind, title) = if error == PrivateStorageError::Busy { + ( + MobileAuthenticationErrorKind::Conflict, + "Private Storage Busy", + ) + } else { + ( + MobileAuthenticationErrorKind::Configuration, + "Private Storage Recovery Required", + ) + }; + MobileAuthenticationError::new(kind, title, error.detail()) }; // Retain the native backend's encrypted transactions across the FFI callback adapter. let backend: Arc = match self @@ -344,7 +351,11 @@ impl PrivateStorage { let config = self .configuration() .map_err(recovery)? - .ok_or_else(|| recovery(PrivateStorageError::ConfigurationMissingWithData))?; + .ok_or_else(|| MobileAuthenticationError::new( + MobileAuthenticationErrorKind::Configuration, + "Set Up Password Store", + "Complete local or HTTPS password-store setup before opening protected settings or entries.", + ))?; if let Some(current) = owner.as_ref() { if current.matches_configuration(&config)? { return Ok(Arc::clone(current)); @@ -468,7 +479,7 @@ impl PrivateStorage { &self, backend: &dyn SecretStoreBackend, ) -> Result<(), PrivateStorageError> { - let _lock = exclusive_lock(&self.data, "runtime.lock")?; + let _lock = runtime_lock(&self.data)?; let bytes = regular_bytes(&self.data, MARKER, 16 * 1024)? .ok_or(PrivateStorageError::CorruptLayout)?; let mut layout: Layout = toml::from_str( @@ -553,7 +564,7 @@ impl PrivateStorage { &self, action: impl FnOnce(&Dir) -> Result, ) -> Result { - let initialization = exclusive_lock(&self.data, "runtime.lock")?; + let initialization = runtime_lock(&self.data)?; let scratch = open_directory(&self.data, "scratch")?; let name = format!("transaction-{:016x}", rand::random::()); let dir = private_directory(&scratch, &name)?; @@ -634,6 +645,19 @@ pub(crate) fn exclusive_lock(parent: &Dir, name: &str) -> Result Result { + // Startup's binding/setup/Home reads share this gate; absorb brief overlap, not long operations. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(1); + loop { + match exclusive_lock(parent, "runtime.lock") { + Err(PrivateStorageError::Busy) if std::time::Instant::now() < deadline => { + std::thread::sleep(std::time::Duration::from_millis(10)); + } + result => return result, + } + } +} fn save_layout(parent: &Dir, layout: &Layout) -> Result<(), PrivateStorageError> { let bytes = toml::to_string(layout).map_err(|_| PrivateStorageError::CorruptLayout)?; let mut temporary = TempFile::new(parent)?; @@ -1066,6 +1090,94 @@ mod tests { .ok_or(SecretStoreError::Missing) } } + #[test] + fn authentication_distinguishes_first_run_from_missing_configuration_with_data() { + let temp = tempfile::tempdir().unwrap(); + let storage = PrivateStorage::open(roots(temp.path())).unwrap(); + let backend = Arc::new(BindingBackend::default()); + let clock = Arc::new(crate::authentication::SystemAuthenticationClock::default()); + let error = storage + .authentication(backend.clone(), clock.clone()) + .err() + .unwrap(); + assert_eq!(error.kind(), MobileAuthenticationErrorKind::Configuration); + assert_eq!(error.title(), "Set Up Password Store"); + assert!(backend.0.lock().unwrap().is_none()); + assert_eq!(storage.status().recovery, None); + configured(&storage); + let ciphertext = fs::read(storage.root.join("vault/mail.gpg")).unwrap(); + fs::remove_file(storage.root.join("config.toml")).unwrap(); + let error = storage + .authentication(backend.clone(), clock) + .err() + .unwrap(); + assert_eq!(error.title(), "Private Storage Recovery Required"); + assert_eq!( + error.detail(), + PrivateStorageError::ConfigurationMissingWithData.detail() + ); + assert_eq!( + storage.status().recovery, + Some(PrivateStorageError::ConfigurationMissingWithData) + ); + assert_eq!( + fs::read(storage.root.join("vault/mail.gpg")).unwrap(), + ciphertext + ); + assert!(backend.0.lock().unwrap().is_none()); + } + + #[test] + fn startup_waits_for_a_short_lived_runtime_owner_without_losing_data() { + let temp = tempfile::tempdir().unwrap(); + let storage = PrivateStorage::open(roots(temp.path())).unwrap(); + configured(&storage); + let ciphertext = fs::read(storage.root.join("vault/mail.gpg")).unwrap(); + let lock = exclusive_lock(&storage.data, "runtime.lock").unwrap(); + let release = std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(50)); + drop(lock); + }); + let backend = Arc::new(BindingBackend::default()); + let owner = storage + .authentication( + backend, + Arc::new(crate::authentication::SystemAuthenticationClock::default()), + ) + .unwrap(); + release.join().unwrap(); + assert!(!owner.state().unwrap().unlocked()); + assert_eq!( + fs::read(storage.root.join("vault/mail.gpg")).unwrap(), + ciphertext + ); + assert_eq!(storage.status().recovery, None); + } + + #[test] + fn authentication_contention_is_retryable_without_claiming_data_recovery() { + let temp = tempfile::tempdir().unwrap(); + let storage = PrivateStorage::open(roots(temp.path())).unwrap(); + configured(&storage); + let backend = Arc::new(BindingBackend::default()); + let clock = Arc::new(crate::authentication::SystemAuthenticationClock::default()); + let lock = exclusive_lock(&storage.data, "runtime.lock").unwrap(); + let error = storage + .authentication(backend.clone(), clock.clone()) + .err() + .unwrap(); + assert_eq!(error.kind(), MobileAuthenticationErrorKind::Conflict); + assert_eq!(error.title(), "Private Storage Busy"); + assert_eq!(error.detail(), PrivateStorageError::Busy.detail()); + assert!(backend.0.lock().unwrap().is_none()); + assert_eq!(storage.status().recovery, None); + drop(lock); + let owner = storage.authentication(backend, clock).unwrap(); + assert!(!owner.state().unwrap().unlocked()); + assert!(storage.status().configured); + assert_eq!(storage.status().recovery, None); + } + #[test] fn restored_or_mismatched_keystore_binding_is_a_typed_non_destructive_recovery() { let temp = tempfile::tempdir().unwrap(); diff --git a/docs/android/architecture.md b/docs/android/architecture.md index 8ad8f34..8065bc9 100644 --- a/docs/android/architecture.md +++ b/docs/android/architecture.md @@ -80,7 +80,7 @@ At #89 pin a mutually supported stable AGP/Gradle/Kotlin/KSP/Hilt set, JDK, SDK build tools and NDK, plus wrapper checksum. The current [AGP 9.4 contract](https://developer.android.com/build/releases/agp-9-4-0-release-notes) requires Gradle 9.6.0 and JDK 17 and supports API 37. This is a candidate build baseline, subject to actual artifact availability and plugin compatibility. -The #89 scaffold pins AGP 9.4.1, Gradle 9.8.1, Kotlin 2.4.20, KSP 2.3.12, +The #89 scaffold pins AGP 9.4.1, Gradle 9.8.1, Kotlin 2.4.21, KSP 2.3.12, Hilt 2.60.1 and JDK 25 (JVM output 17); its catalog and build instructions live in [android/README.md](../../android/README.md). These pins are verified by the scaffold build gates rather than inferred from minimum-version requirements. @@ -198,9 +198,11 @@ a remote clone. Do not seed app data or commit credentials to prove recovery. ## Verification and acceptance The [parity matrix](parity.md) assigns every current non-Watch mobile capability -and every Android issue to a Rust owner and Android API. Its pending rows are -implementation obligations, not accepted gaps. #111 must replace pending status -with evidence and have no unexplained gap before #112 distribution. +and every Android issue to a Rust owner, current Android component and executable +checks. The [#111 audit](audit.md) records current results, runtime limits and the +unpatched/deprecated upstream dependency findings tracked outside this milestone +in [#125](https://git.rfc1437.de/hugo/IronStorage/issues/125). This traceability does +not claim that cargo-audit is clean or grant unrestricted release approval. Every issue runs the AGENTS.md Rust gates before commit. Android changes also run build/lint/unit/instrumented checks appropriate to the issue and computer-use diff --git a/docs/android/audit.md b/docs/android/audit.md new file mode 100644 index 0000000..6a9467d --- /dev/null +++ b/docs/android/audit.md @@ -0,0 +1,206 @@ +# Android parity, architecture and security audit (#111) + +Audit date: 2026-10-15. Scope is milestone 23, the current non-Watch iPhone +surface and the Android release graph. [parity.md](parity.md) maps every +capability to its Rust owner, concrete Android component and executable checks. +[architecture.md](architecture.md) remains the support/threat-model contract. +A test listed as a coverage pointer is not evidence that an opt-in journey ran. +The current iPhone AutoFill extension controller has no implemented selection +flow. The Android provider requirement is nevertheless fully implemented through +Rust and the native OS services; the matrix does not attribute that capability +to the empty iPhone controller. + +## Findings corrected in this audit + +- First-run Preferences previously claimed that configuration had been lost while + repository/key data remained. `PrivateStorage::authentication` now distinguishes + the validated empty configuration from actual `ConfigurationMissingWithData`. + Genuine recovery still retains its typed error and ciphertext. Rust regressions + first failed against the old implementation and now pass for both cases. +- Startup onboarding, binding verification and Home independently used the same + nonblocking runtime gate. Brief legitimate overlap could display a storage + failure on a cold start. Rust now tolerates up to one second of contention, + checking the same capability-validated lock every 10 ms. Longer operations + still fail with retry guidance. Scratch-owner locks remain nonblocking so + startup cleanup never deletes active scratch. Tests cover transient release, + a retained long-lived lock, unchanged encrypted bytes and successful retry. +- Busy authentication is a typed `Conflict` titled `Private Storage Busy`, rather + than a claim of corruption/recovery. The Home facade preserves the typed + conflict. Setup also reports contention as `Private Storage Busy` while genuine + setup corruption retains `Setup Recovery Required`. No frontend derives state + from error strings. +- API 26 exposed a test synchronization race in the TOTP disposal check: the + baseline was read immediately after a click, before Compose removed the code + view, and one legitimate pre-disposal tick could land afterward. The regression + now asserts that the code node is absent and reads/checks the counter on the UI + thread. It retains the full 1.2-second no-further-ticks assertion; no production + timer or assertion was disabled to obtain a pass. The corrected focused check + passed three isolated API 26 runs, then passed in the full 53-case app runner. + The failed original attempt is not counted as passing evidence. +- The release artifact gate now rejects `debuggable`, `testOnly` and `profileable` + access in the packaged production manifest. Debug/profile/benchmark packages + remain separately identifiable test artifacts. Manifest regression tests prove + rejection and the release CI path invokes the gate on decoded APK contents. +- Updated Android-reachable Rustls 0.23.45 to 0.23.46 (and raised its minimum), + faster-hex 0.10.0 to 0.10.1, rqrr 0.10.1 to 0.11.0 / lru 0.18.2, and the yanked + chacha20 0.10.1 to 0.10.2. QR fixtures and native decode tests exercise the + decoder change; both release ABIs were rebuilt from the resulting lockfile. + +## Boundary and lifetime review + +Reviewed the connected `crates/storage` mobile/authentication/onboarding/Git/QR/ +credential/secret-store implementations, `crates/mobile-bridge`, `crates/android`, +and Android app/core/features/platform/native-loader modules. Rust owns directory +capabilities, repository operations, parsing, generation, OTP, QR transfer/decode, +secret records, authentication, endpoint and caller/site policy. UniFFI exports +mechanical values and OS callbacks. Kotlin maps typed state, schedules blocking +calls off the main thread, collects input and presents Android UI/OS services. + +No Android-reachable application component launches a process. Project-authored +Rust forbids unsafe code; generated/dependency JNI/JNA/Keystore FFI is outside that +claim. `std::process::id()` obtains a transaction owner ID and does not launch a +process. Build/release tools legitimately invoke host commands. The desktop CLI's +external-editor support is outside the Android runtime graph and this audit; this +report does not claim that every unrelated workspace executable avoids processes. + +NativeRuntime's `File`/`ZipFile` checks inspect installed APK/native-library facts, +not the repository. Provider package-certificate hashing obtains OS identity facts; +Rust compares caller/site identities and decides grants/matches. Kotlin never +parses pass documents, `.gpg-id`, GPG packets, OTP payloads or Git transport errors. +The source/dependency gate complements this connected-code review. + +Git endpoints are validated typed HTTPS on Android (SSH feature disabled), without +embedded account/token userinfo. Repository remote configuration is authoritative. +Tokens stay in Rust-owned sealed records with OS wrapping keys. TLS uses the +Android platform verifier and redirects cannot forward credentials. There is no +cleartext transport or certificate-validation bypass to make fixtures pass. + +Ordinary persistent state contains encrypted entries, encrypted private keys, +configuration, encrypted secret envelopes and private TOTP metadata, not decrypted +entry fields, passphrases, tokens or OTP seeds. Editor recovery is encrypted and +revision/generation-bound. Android saved state retains non-secret selection/query +facts only. Logs and reports use bounded typed diagnostics and sanitized metrics; +there is no app crash-upload service. Kotlin/Java memory cannot promise complete +erasure, and accessibility/IME/clipboard consumers can observe deliberately +revealed or handed-off data under OS control. + +## Threat-test coverage and limits + +| Threat | Enforcement and executable evidence | Interpretation | +| --- | --- | --- | +| Keystore record tamper, missing keys, restore/device binding | Rust Android backend/private-storage tests; real `AndroidKeystoreTest.deviceUnlockedRoundtripReplaceDeleteAndTamper` | Failure retains existing encrypted repository; no silent identity/key recreation for bound data | +| Biometric cancellation, rejection, invalidation/lock removal | Rust authentication/backend tests; opt-in platform `AndroidKeystoreTest` and `AuthenticationLeaseTest` | OS-interactive checks require separate recorded runs; do not count standard-suite exclusions as passes or change enrollment on the retained app phone | +| Backup/transfer and update | Packaged XML reference and all-domain exclusion gates, `BackupPolicyTest`, private-context/update Rust tests | Install over the same ID/signing lineage; emulator data retained. OEM transfer tools remain outside available emulator evidence | +| Screen capture/recents, focus loss, logs and plaintext saved state | Secure-window/recents platform wiring; `PresentationSecurityTest`, source audit, generation/lease Rust tests | Computer-use visibility is test observation, not proof that an arbitrary compromised OS cannot capture pixels | +| Clipboard and accessibility | OS ownership/expiry adapter, Rust copy policy, `PresentationSecurityTest`; manual hardening evidence | Deliberate reveal/copy is observable by authorized platform consumers. TalkBack focus/activation is verified; speech output and actual external switch scanning are not claimed | +| Background, process death, dirty edits and stale callbacks | `AuthenticationLeaseTest`, `PresentationSecurityTest`, `NativeEntryViewerTest`; Rust lease/editor checkpoint/cancellation tests | No stored lease, no plaintext draft restore, no replay of a completed mutation | +| Git dirty tree/conflict, TLS/auth/offline/cancellation | Rust `mobile_home::android_tests`, Git transport/embedded tests, Compose Home tests | Injected transports prove failure ordering without sending fixture tokens to external servers; runtime local Git journey is separate from public-server availability | +| Camera interruption, malicious/oversized QR/image | `AndroidQrCameraTest`, Rust QR/key-transfer tests and bounded Y-plane adapter | Camera restart and real frame delivery are distinct from decoding known QR vectors in Rust | +| Malicious providers, forged/stale/cancelled intents, preauth disclosure | Rust `mobile_credentials`/authentication tests, `ProviderIntentSecurityTest`, `NativeCredentialProviderTest`, system `provider-client` journeys | Paths/candidates may be listed while locked; secret fill requires exact live request, explicit consent/selection and valid lease | +| Offline, doze/battery, memory/storage pressure | Rust fault/rollback tests; native environment/hardening checks | No physical disk filling or physical-device coverage is implied by the low-storage broadcast | +| Root/debug assumptions | Non-debuggable packaged release gate, private capabilities/backup/Keystore policy | Rooted/fully compromised devices and local hostile timing observers are outside the accepted sandbox threat model; a debug APK is not the production security boundary | + +## Dependency exceptions + +RustSec database: 1,329 advisories, commit +`edb58f5974b83eb8302332821cb6d389777aa351`, updated 2026-10-14T22:18:32+02:00. +After the updates, the Android normal/build dependency graph has two remaining +findings, explicitly tracked outside this milestone in +[#125](https://git.rfc1437.de/hugo/IronStorage/issues/125): + +- `pgp -> rsa 0.9.10`: [RUSTSEC-2023-0071](https://rustsec.org/advisories/RUSTSEC-2023-0071.html) + has **no patched release**. OpenPGP decryption is local and follows explicit + authorization; Git/HTTPS does not expose a remote RSA-decryption endpoint. + This limits the ordinary remote oracle but does not establish constant-time + RSA or eliminate local timing risk. Retaining existing pass RSA-key compatibility + does not justify claiming the advisory fixed. Reassess an upstream-reviewed, + compatible pure-Rust mitigation under #125. +- `gix-protocol -> bisync 0.3.0`: yanked/deprecated, with no replacement release. + Its [upstream notice](https://github.com/JM4ier/bisync) says the author retired + the tiny utility to avoid maintenance/supply-chain responsibility. The existing + lockfile checksum remains required; track a reviewed transport update removing + it under #125 rather than hiding the warning or adding an unreviewed fork. + +Workspace `cargo audit` still exits nonzero: besides RSA it reports host/desktop +packages outside the Android graph (including quinn-proto and ruzstd). They were +not silently updated as Android work. No clean whole-workspace audit or unrestricted +release approval is claimed. License/SBOM policy and vulnerability status are +separate checks. + +## Run record + +Required Rust gates passed: formatting, warnings-as-errors workspace/all-target +check, Clippy with warnings denied, and the full workspace test run (524 passed, +zero failed). The final Android verification/build passed debug/release/AAB, +Lint including release, ktlint, JVM tests, both ARM native builds/metadata/16-KB +package audits, decoded backup/transfer/manifest checks, separate native symbols, +R8 mapping and deterministic CycloneDX/license policy (1,839 inventory components). +The CI orchestration self-test run passed all 11 tests. + +The final API 37 / 4-KB ARM64 run passed app/core/bridge/platform instrumentation +and five cold starts. The native core suite exercised the new 12-pair concurrent +onboarding/Home regression along with real encrypted edit/mutation/OTP/provider +and catalog/recovery tests. Native QR analysis was 84 ms, authenticated fill +1,249 ms, and 10,000-entry navigation/search were 90/273 ms. Debug cold starts +were 840–1,220 ms; five native-load measurements were 40.8–58.4 ms. + +The final API 26 / 4-KB ARM64 run likewise passed all four instrumentation suites +and five cold starts after the TOTP test synchronization fix. Native QR analysis +was 80 ms, authenticated fill 1,210 ms, and 10,000-entry navigation/search were +76/227 ms. Debug cold starts were 210–249 ms. Computer use opened actual first-run +Preferences and verified `Set Up Password Store` with local/HTTPS setup guidance, +replacing the old false data-recovery claim without creating or deleting a store. + +API 26 release-equivalent Macrobenchmark passed five cold and five warm starts: +initial display 149.8–171.0 ms cold and 44.1–95.4 ms warm; full cold display +189.7–219.5 ms. The measured artifact retained its app data and baseline profile. + +The explicit `biometricSecurity=true` real-OS fixture passed on the existing +API 37 phone fingerprint enrollment. Computer use observed the CryptoObject +prompts and cancellation result; emulator sensor events supplied matching/nonmatching +synthetic fingerprints. OS counters confirmed one rejected crypto attempt before +a matching unlock. The test asserted enrollment, shared reload while unlocked, +manual lock, cancellation, real activity background interruption, fresh post-background +unlock, immediate memory-pressure relock and biometric disable. Its namespace, +public passphrase and generated store were isolated from the retained app. Fixture +aliases and runner were cleaned up; device enrollment and app data were retained. + +Strict API 37 / 16-KB ARM64 passed all four suites and five cold starts with +both compatibility paths disabled. Native QR analysis was 82 ms, authenticated +fill 1,216 ms, and 10,000-entry navigation/search were 82/251 ms. Cached TOTP, +warm refresh and provider preparation p95 were 5,349/8,241/4,195 us. Five debug +cold starts were 832–971 ms and native loads 66.4–75.8 ms. Computer use verified +the actual retained six-commit store and clean worktree loaded after the final +cold starts, with no startup busy/recovery failure. + +Strict API 37 release-equivalent Macrobenchmark also passed five cold and five +warm starts: initial display 192.3–335.9 ms cold and 77.7–134.5 ms warm; full cold +display 399.0–510.1 ms. Computer use completed real framework Autofill and Android +Credential Manager selection/consent/unlock against the separate provider client; +both returned the exact expected public fixture username/password. The initial +requests expired during slow inspection; fresh requests passed within the +unchanged request lifetime. Pre-authentication selectors exposed entry paths, +not the secret fields. + +Real API 35 ARM64 Keystore enrollment-change invalidation passed in an isolated +platform test app: the current-set biometric key became `Invalidated`, while a +separate DeviceUnlocked key still decrypted its public token. The first attempt +timed out while driving OS setup and is not counted as a pass; the fresh test +completed in 124.4 seconds, within the unchanged 180-second fixture window. +Computer use observed the encryption prompt, OS enrollment and return to the +fixture. Only synthetic enrollment on that dedicated device changed; no ordinary +IronStorage app was installed there. Test keys were deleted by the fixture. +The device was shut down gracefully afterward. Its existing synthetic PIN was +retained; no data or enrollment reset was used to recover the cold boot. + +The updated fold emulator also loaded real first-run setup and Preferences via +computer use, with readable adaptive navigation and the corrected setup guidance. +The final connected-code review checked the diff, callers, error mappings, +fixtures and acceptance matrix. No issue is closed on an expired/failed run. + +After the final setup-contention title correction, all four Rust gates passed +again (524 tests) and the full Android build/audit passed again for both ARM ABIs. +The final native viewer regression, including concurrent onboarding/Home, passed +on API 37 in 54.9 seconds. A fresh app cold launch took 743 ms and computer use +verified the retained three-commit vault and clean worktree. ARM64 emulator runtime evidence stays distinct from +ARMv7 packaging and historical per-issue evidence. diff --git a/docs/android/parity.md b/docs/android/parity.md index c6c140d..c17463c 100644 --- a/docs/android/parity.md +++ b/docs/android/parity.md @@ -1,43 +1,43 @@ # Android parity and issue ownership Contract for [#88](https://git.rfc1437.de/hugo/IronStorage/issues/88), reviewed -2026-10-08 against apple/Sources/App/IronStorageApp.swift, +2026-10-15 against apple/Sources/App/IronStorageApp.swift, apple/Sources/AutoFill/CredentialProviderViewController.swift, crates/apple/src/lib.rs, and crates/storage/src/mobile*.rs. See [architecture.md](architecture.md) for support, dependency, security and update policy. -**Status:** #88 records the architecture baseline. The Android shell (#89), -native packaging (#92), shared contract (#91), and private runtime (#90) are -implemented; feature presentation rows remain assigned below. Issue comments -record per-issue validation and outstanding test gaps. #111 must attach the -final screen/component/test evidence to each row before claiming full parity. -Every issue linked here belongs to milestone 23. +**Status:** implementation issues #89–#110 are delivered. The #111 audit +maps each capability to current Android components and executable checks below. +[audit.md](audit.md) records current results, dependency decisions and explicit +runtime limits. Test names identify coverage; opt-in tests need separate runtime +evidence and are not counted as exercised by a normal suite. +Every implementation issue linked here belongs to milestone 23. ## Current iPhone and shared-mobile capabilities | Existing capability / source | Owning Rust API/module | Android presentation / approved API | Implementation and required evidence | | --- | --- | --- | --- | -| Five destinations: Home, Passwords, Search, TOTP, Preferences; loading/empty/ready/locked/error fixtures (AppContext, ShellViewController) | mobile::MobileShell, MobileTab, MobileShellState; config selected tab | Single Activity, Compose Material 3 Adaptive, Navigation 3, ViewModel/StateFlow | #89/#91/#102; shell fixtures, startup, back/navigation and recreation | -| Local-first setup, generated/imported key, passphrase confirmation, staged QR import (SetupChoiceViewController, LocalSetupViewController) | mobile_onboarding::MobileOnboardingOperation, crypto; mobile_key_transfer import | Compose forms and core facade; OS input/camera adapters | #95/#106; real local init, key export/import, canceled/failed setup rollback | -| Optional HTTPS clone, server/account/token, repository/branch discovery; attach remote to local store (OnboardingViewController) | mobile_onboarding::{MobileOnboardingRequest, MobileOnboardingOperation}; git/config/secret_store | Forms, typed incremental progress and cancel | #95; TLS/auth/branch/cancel/offline errors; repository remote authority | -| Home history, ahead/behind, local/remote change status, commit activity (ShellViewController, MobileCommitActivityViewController) | mobile_home::MobileHomePage/Operation and git | Compose Home/list/detail | #97; summaries and stale/error state, Git identity, author/history | -| Commit, Fetch, Pull, Push and pull-then-push Sync | mobile_home::MobileHomeOperation; git sync/merge/conflicts/signing | Typed actions, progress/cancel, conflict presentation | #97; pull failure prevents push, conflict/data-loss cases and signing | -| Directory hierarchy, breadcrumbs, folder and entry rows (PasswordDirectoryViewController) | mobile_passwords::MobilePasswordPage/Row/RowKind; repository/read | Adaptive list/detail with stable IDs | #96; deep tree, empty/error, keyboard and size changes | -| Password-store name search (PasswordSearchViewController) | mobile_passwords::MobilePasswordPage::search | Search ViewModel, Flow latest-query-wins | #101; large-store responsiveness and canceled/stale results | -| Unlock entry, explicit user activity, timeout, manual/background lock; shared entry/TOTP/editor lease (LockedPasswordViewController, AppContext) | mobile_authentication::MobileAuthentication; authentication generation/leases | AndroidX BiometricPrompt/CryptoObject, KeyguardManager, lifecycle | #93/#98/#100; wrong passphrase, cancel, expiry, generation race, changed enrollment and process death | -| Structured fields/sections, masked secrets, explicit reveal/select/copy; OTP field diagnostics (MobileEntryFieldCell) | mobile_entry::MobileEntryPage/Field; mobile_authentication::{entry_page,reveal_entry_field,copy_entry_field} | Compose viewer, sensitive controls and accessibility semantics | #99/#100; no pre-unlock values, relock clears all views, copy expiry/ownership | -| Create entry/folder, structured multiline editing, add/remove/reorder fields, dirty discard confirmation (MobileEntryEditorViewController) | mobile_entry::MobileEntryDraft; mobile_authentication editor session/create/save/discard; write | Compose editor/forms, stable field IDs | #103; actual encrypted saves, conflict tokens, interrupted saves and dirty navigation | -| Password length/generation and replacement | generate; mobile_entry::MobileEntryDraft::generate_password | Generator controls call Rust | #103; Rust validation and unbiased generated value; no Kotlin generator | -| OTP URI input, camera/image import, review before Save (OtpImportViewController; TOTP Add action) | mobile_entry::MobileEntryDraft::add_otp_image; document/otp/presentation | CameraX frames or Android photo picker bytes; Rust decode and field construction | #103/#106/#107; URI/image/scan parity, invalid/oversized input, cancel; no payload parsing in Kotlin | -| Move/copy/delete entries and folders, destination browse, overwrite and dirty-editor handling (PasswordMutationCoordinator) | mobile_mutation::MobileMutationService/Plan/Request; mutation | Compose dialogs/destination picker | #108; revisions, destination conflicts, recipient reencryption and rollback | -| TOTP discovery catalog, incremental progress, warm cache, refresh, cancel/resume, cached search (TotpListViewController) | mobile_totp discovery/catalog/operation; mobile_authentication cached/search APIs | Compose list/progress/search; private no-backup catalog | #90/#110; [discovery behavior and evidence](totp-discovery.md), cold/warm/change/add/remove/corrupt/wrong-store/concurrent-change tests | -| TOTP rows, large detail code, countdown/expiry, copying (TotpDetailViewController, TotpCodeView) | mobile_totp::MobileOtpCodePresentation; otp; mobile_authentication::{presented_otp_code,copy_presented_otp_code,advance_presented_hotp} | Compose code/detail; OS clock facts, Rust timing | #107/#100; [OTP behavior and evidence](otp.md), RFC vectors, boundary countdown, stale callback/relock and clipboard | -| Repository/server identity, local-only/add-remote Settings, token replace/remove (PreferencesViewController, TokenUpdateViewController) | mobile_authentication::MobilePreferences; mobile_onboarding::replace_configured_application_token; config/secret_store | Compose Settings/facade | #104/#95; no token display; protected replacement/removal and offline operation | -| Key identity/fingerprint, GPG recovery import and public/private QR export (KeyImportScannerViewController, KeyExportListViewController, KeyQrExportViewController) | mobile_key_transfer::{MobileKeyTransferService,MobileKeyTransferImport}; crypto/presentation | CameraX Preview/ImageAnalysis; animated Rust-provided matrices | #106/#104; multi-frame order/duplicates/interruption, bounded frames, private export authorization and camera denial | -| Inactivity timeout, biometric enable/disable, Lock Now, appearance and Git commit name/email | mobile_authentication preference setters, git_identity; config | Compose Settings, BiometricPrompt, system/light/dark theme | #98/#104; persistent settings, validation, Keystore enrollment/recovery and lease revocation | -| 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 | +| Five destinations: Home, Passwords, Search, TOTP, Preferences; loading/empty/ready/locked/error fixtures (AppContext, ShellViewController) | mobile::MobileShell, MobileTab, MobileShellState; config selected tab | `ShellScreen / ShellViewModel / MainActivity` | #89/#91/#102; shell fixtures, startup, back/navigation and recreation; **Checks:** ShellScreenTest, AdaptiveShellTest, Tier2WindowTest, HingeWindowTest | +| Local-first setup, generated/imported key, passphrase confirmation, staged QR import (SetupChoiceViewController, LocalSetupViewController) | mobile_onboarding::MobileOnboardingOperation, crypto; mobile_key_transfer import | `OnboardingScreen / NativeOnboardingRepository` | #95/#106; real local init, key export/import, canceled/failed setup rollback; **Checks:** OnboardingScreenTest; Rust mobile_onboarding::android::tests | +| Optional HTTPS clone, server/account/token, repository/branch discovery; attach remote to local store (OnboardingViewController) | mobile_onboarding::{MobileOnboardingRequest, MobileOnboardingOperation}; git/config/secret_store | `OnboardingScreen and SettingsScreen HTTPS actions` | #95; TLS/auth/branch/cancel/offline errors; repository remote authority; **Checks:** OnboardingScreenTest; Rust onboarding and git TLS/auth/remote-authority tests | +| Home history, ahead/behind, local/remote change status, commit activity (ShellViewController, MobileCommitActivityViewController) | mobile_home::MobileHomePage/Operation and git | `HomeScreen / NativeHomeRepository` | #97; summaries and stale/error state, Git identity, author/history; **Checks:** HomeScreenTest; Rust mobile_home and tests/git_embedded.rs | +| Commit, Fetch, Pull, Push and pull-then-push Sync | mobile_home::MobileHomeOperation; git sync/merge/conflicts/signing | `HomeScreen typed actions and progress` | #97; pull failure prevents push, conflict/data-loss cases and signing; **Checks:** HomeScreenTest; Rust mobile_home::android_tests and Git conflict/cancellation tests | +| Directory hierarchy, breadcrumbs, folder and entry rows (PasswordDirectoryViewController) | mobile_passwords::MobilePasswordPage/Row/RowKind; repository/read | `PasswordScreen / NativePasswordRepository` | #96; deep tree, empty/error, keyboard and size changes; **Checks:** PasswordScreenTest, NativeEntryViewerTest, NativeTreeMutationTest | +| Password-store name search (PasswordSearchViewController) | mobile_passwords::MobilePasswordPage::search | `SearchScreen / NativeSearchRepository` | #101; large-store responsiveness and canceled/stale results; **Checks:** SearchScreenTest; Rust mobile_passwords and large-store native instrumentation | +| Unlock entry, explicit user activity, timeout, manual/background lock; shared entry/TOTP/editor lease (LockedPasswordViewController, AppContext) | mobile_authentication::MobileAuthentication; authentication generation/leases | `AuthenticationControls / AndroidKeystoreAdapter / MainActivity lifecycle` | #93/#98/#100; wrong passphrase, cancel, expiry, generation race, changed enrollment and process death; **Checks:** AuthenticationScreenTest, opt-in AuthenticationLeaseTest, AndroidKeystoreTest; tests/authentication_leases.rs | +| Structured fields/sections, masked secrets, explicit reveal/select/copy; OTP field diagnostics (MobileEntryFieldCell) | mobile_entry::MobileEntryPage/Field; mobile_authentication::{entry_page,reveal_entry_field,copy_entry_field} | `EntryViewer / NativePasswordRepository` | #99/#100; no pre-unlock values, relock clears all views, copy expiry/ownership; **Checks:** EntryViewerTest, NativeEntryViewerTest, NativeOtpPresentationTest; tests/entry_documents.rs | +| Create entry/folder, structured multiline editing, add/remove/reorder fields, dirty discard confirmation (MobileEntryEditorViewController) | mobile_entry::MobileEntryDraft; mobile_authentication editor session/create/save/discard; write | `EntryEditorScreen / NativeEditorRepository` | #103; actual encrypted saves, conflict tokens, interrupted saves and dirty navigation; **Checks:** EntryEditorScreenTest, NativeEntryViewerTest; Rust editor conflict/checkpoint/atomic-save tests | +| Password length/generation and replacement | generate; mobile_entry::MobileEntryDraft::generate_password | `EntryEditorScreen generator controls` | #103; Rust validation and unbiased generated value; no Kotlin generator; **Checks:** EntryEditorScreenTest; tests/password_generation.rs | +| OTP URI input, camera/image import, review before Save (OtpImportViewController; TOTP Add action) | mobile_entry::MobileEntryDraft::add_otp_image; document/otp/presentation | `EntryEditorScreen / QrCameraPane / AndroidQrCamera` | #103/#106/#107; URI/image/scan parity, invalid/oversized input, cancel; no payload parsing in Kotlin; **Checks:** EntryEditorScreenTest, AndroidQrCameraTest; Rust qr and tests/otp.rs | +| Move/copy/delete entries and folders, destination browse, overwrite and dirty-editor handling (PasswordMutationCoordinator) | mobile_mutation::MobileMutationService/Plan/Request; mutation | `MutationDialog / NativePasswordRepository` | #108; revisions, destination conflicts, recipient reencryption and rollback; **Checks:** MutationWorkflowTest, NativeTreeMutationTest; tests/tree_mutations.rs and recipient_policy.rs | +| TOTP discovery catalog, incremental progress, warm cache, refresh, cancel/resume, cached search (TotpListViewController) | mobile_totp discovery/catalog/operation; mobile_authentication cached/search APIs | `TotpScreen / NativeTotpRepository` | #90/#110; [discovery behavior and evidence](totp-discovery.md), cold/warm/change/add/remove/corrupt/wrong-store/concurrent-change tests; **Checks:** TotpDiscoveryFixtureTest, NativeTotpDiscoveryTest, NativeTotpPerformanceTest; tests/mobile_totp.rs | +| TOTP rows, large detail code, countdown/expiry, copying (TotpDetailViewController, TotpCodeView) | mobile_totp::MobileOtpCodePresentation; otp; mobile_authentication::{presented_otp_code,copy_presented_otp_code,advance_presented_hotp} | `TotpScreen / OtpCodeView / EntryViewer` | #107/#100; [OTP behavior and evidence](otp.md), RFC vectors, boundary countdown, stale callback/relock and clipboard; **Checks:** TotpScreenTest, NativeOtpPresentationTest; Rust OTP boundary/relock/HOTP tests | +| Repository/server identity, local-only/add-remote Settings, token replace/remove (PreferencesViewController, TokenUpdateViewController) | mobile_authentication::MobilePreferences; mobile_onboarding::replace_configured_application_token; config/secret_store | `SettingsScreen / NativeSettingsRepository` | #104/#95; no token display; protected replacement/removal and offline operation; **Checks:** SettingsScreenTest; Rust preference/token and Android onboarding tests | +| Key identity/fingerprint, GPG recovery import and public/private QR export (KeyImportScannerViewController, KeyExportListViewController, KeyQrExportViewController) | mobile_key_transfer::{MobileKeyTransferService,MobileKeyTransferImport}; crypto/presentation | `SettingsScreen key transfer / QrCameraPane / AndroidQrCamera` | #106/#104; multi-frame order/duplicates/interruption, bounded frames, private export authorization and camera denial; **Checks:** SettingsScreenTest, AndroidQrCameraTest; Rust mobile_key_transfer frame/order/cancel/authorization tests | +| Inactivity timeout, biometric enable/disable, Lock Now, appearance and Git commit name/email | mobile_authentication preference setters, git_identity; config | `SettingsScreen / AuthenticationControls / ApplicationTheme` | #98/#104; persistent settings, validation, Keystore enrollment/recovery and lease revocation; **Checks:** SettingsScreenTest, NativeEntryViewerTest, opt-in AuthenticationLeaseTest; Rust authenticated preference tests | +| AutoFill extension boundary (current iPhone controller is empty), shared identity/access domain and Android provider requirement | mobile_credentials; mobile_authentication provider request/select/fill/save APIs | `IronCredentialProviderService / IronAutofillService / CredentialSelectionActivity` | #109; typed caller/site identity, non-secret candidates, explicit selection/auth, forged/stale requests, permissions and cross-process lifetime; **Checks:** ProviderIntentSecurityTest, CredentialSelectionScreenTest, NativeCredentialProviderTest; provider-client OS journeys | +| 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 | `AndroidSecretClipboard / ProtectedInput / ProtectedPresentation / MainActivity` | #90/#93/#98/#100; backup inspection, sandbox paths, pressure/update, screen/clipboard/lifecycle tests; **Checks:** PresentationSecurityTest, BackupPolicyTest, AndroidQrCameraTest; Rust secret-store and editor recovery tests | +| Accessible labels/actions, large text, keyboard, responsive layout and localization | Typed Rust state/actions; UI accessibility is Kotlin | `Compose semantics/resources / HingeSafeLayout / HingeSafeAlertDialog` | #105; measured keyboard/TalkBack/large-font/multi-window/RTL/rotation coverage; **Checks:** Tier2WindowTest, HingeWindowTest; hardening.md records manual accessibility and exact performance evidence | | 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 @@ -49,8 +49,8 @@ an Android workflow invokes them; platform presentation may not replace them. Current iPhone camera adapters pass OS-decoded QR bytes. Android #106 supplies bounded Y-plane/stride/orientation facts through the shared native contract; storage decodes and reassembles the transfer. See [key-transfer.md](key-transfer.md) for interruption/security behavior and evidence. -The external-credential matching/grant API required by #109 must be implemented -in storage, rather than copying frontend selection assumptions from AutoFill. +The external-credential matching/grant API is implemented in `mobile_credentials` +and `mobile_authentication`; both Android providers delegate to it. ## Complete milestone issue-to-layer map diff --git a/tools/android-build/src/gates.rs b/tools/android-build/src/gates.rs index 1a483db..dd43a8c 100644 --- a/tools/android-build/src/gates.rs +++ b/tools/android-build/src/gates.rs @@ -30,6 +30,33 @@ pub fn manifest(manifest: &Path, backup: &Path, extraction: &Path) -> Result<(), Ok(()) } +pub fn release_manifest(path: &Path) -> Result<(), Box> { + release_manifest_text(&fs::read_to_string(path)?)?; + println!("Verified release cannot be debugged, profiled or installed as test-only"); + Ok(()) +} + +fn release_manifest_text(source: &str) -> Result<(), Box> { + let document = Document::parse(source)?; + let application = document + .root_element() + .children() + .find(|node| node.has_tag_name("application")) + .ok_or("application missing")?; + for name in ["debuggable", "testOnly"] { + if !matches!(application.attribute((ANDROID, name)), None | Some("false")) { + return Err(format!("unsafe release application flag: {name}").into()); + } + } + if application + .children() + .any(|node| node.has_tag_name("profileable")) + { + return Err("profiling is restricted to benchmark/profile builds".into()); + } + Ok(()) +} + fn manifest_text(source: &str) -> Result<(), Box> { let document = Document::parse(source)?; let root = document.root_element(); @@ -489,6 +516,35 @@ fn android_license<'a>( mod tests { use super::*; #[test] + fn release_rejects_debug_test_and_profile_access() { + let source = format!(r#""#); + assert!(release_manifest_text(&source).is_ok()); + for flag in ["debuggable", "testOnly"] { + assert!( + release_manifest_text(&source.replace( + "", + &format!(r#""#) + )) + .is_ok() + ); + assert!( + release_manifest_text(&source.replace( + "", + &format!(r#""#) + )) + .is_err() + ); + } + assert!( + release_manifest_text(&source.replace( + "", + r#""# + )) + .is_err() + ); + assert!(release_manifest_text("").is_err()); + } + #[test] fn manifest_rejects_backup_permissions_and_exports() { let source = format!( r#""# diff --git a/tools/android-build/src/main.rs b/tools/android-build/src/main.rs index 20dcc06..6020e3c 100644 --- a/tools/android-build/src/main.rs +++ b/tools/android-build/src/main.rs @@ -52,6 +52,9 @@ fn main() -> Result<(), Box> { [_, command, directory] if command == "audit-native" => { native::audit_directory(Path::new(directory))? } + [_, command, manifest] if command == "audit-release-manifest" => { + gates::release_manifest(Path::new(manifest))?; + } [_, command, manifest, backup, extraction] if command == "audit-manifest" => { gates::manifest( Path::new(manifest), @@ -85,7 +88,7 @@ fn main() -> Result<(), Box> { } _ => { return Err( - "usage: ironstorage-android-build ".into(), + "usage: ironstorage-android-build ".into(), ); } }