Complete Android parity and security audit (#111)
Android ARM verification / rust (push) Failing after 50m14s
Android ARM verification / android (push) Failing after 8s
Dependency security audit / rustsec (push) Failing after 23s

This commit is contained in:
hugo committed 2026-10-15 13:40:50 +02:00
1 parent 45d78aa5b3
commit b54636b7bc
14 files changed
+498 -75

No files matched your search

+2
View File
@@ -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)
Generated
+27 -19
View File
@@ -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",
+2 -2
View File
@@ -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"] }
@@ -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() {
@@ -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) {
+2 -3
View File
@@ -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()
@@ -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
@@ -538,7 +538,7 @@ impl AndroidOnboarding {
}
fn lock(&self) -> Result<fs::File, MobileOnboardingError> {
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<SecretBytes> {
+122 -10
View File
@@ -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::<Layout>(
std::str::from_utf8(&bytes).map_err(|_| PrivateStorageError::CorruptLayout)?,
@@ -321,11 +321,18 @@ impl PrivateStorage {
clock: Arc<dyn crate::authentication::AuthenticationClock>,
) -> Result<Arc<MobileAuthentication>, 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<dyn SecretStoreBackend> = 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<T, PrivateStorageError>,
) -> Result<T, PrivateStorageError> {
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::<u64>());
let dir = private_directory(&scratch, &name)?;
@@ -634,6 +645,19 @@ pub(crate) fn exclusive_lock(parent: &Dir, name: &str) -> Result<fs::File, Priva
crate::file_lock::try_lock(&file)?;
Ok(file)
}
pub(crate) fn runtime_lock(parent: &Dir) -> Result<fs::File, PrivateStorageError> {
// 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();
+6 -4
View File
@@ -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
+206
View File
@@ -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.
+30 -30
View File
@@ -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
+56
View File
@@ -30,6 +30,33 @@ pub fn manifest(manifest: &Path, backup: &Path, extraction: &Path) -> Result<(),
Ok(())
}
pub fn release_manifest(path: &Path) -> Result<(), Box<dyn Error>> {
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<dyn Error>> {
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<dyn Error>> {
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#"<manifest xmlns:android="{ANDROID}"><application/></manifest>"#);
assert!(release_manifest_text(&source).is_ok());
for flag in ["debuggable", "testOnly"] {
assert!(
release_manifest_text(&source.replace(
"<application/>",
&format!(r#"<application android:{flag}="false"/>"#)
))
.is_ok()
);
assert!(
release_manifest_text(&source.replace(
"<application/>",
&format!(r#"<application android:{flag}="true"/>"#)
))
.is_err()
);
}
assert!(
release_manifest_text(&source.replace(
"<application/>",
r#"<application><profileable android:shell="true"/></application>"#
))
.is_err()
);
assert!(release_manifest_text("<manifest/>").is_err());
}
#[test]
fn manifest_rejects_backup_permissions_and_exports() {
let source = format!(
r#"<manifest xmlns:android="{ANDROID}" package="de.rfc1437.ironstorage"><application android:allowBackup="false" android:usesCleartextTraffic="false" android:fullBackupContent="@xml/backup_rules" android:dataExtractionRules="@xml/data_extraction_rules"><activity android:name="de.rfc1437.ironstorage.MainActivity" android:exported="true"><intent-filter><action android:name="android.intent.action.MAIN"/><category android:name="android.intent.category.LAUNCHER"/></intent-filter></activity><provider android:name="fixture" android:exported="false"/></application></manifest>"#
+4 -1
View File
@@ -52,6 +52,9 @@ fn main() -> Result<(), Box<dyn Error>> {
[_, 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<dyn Error>> {
}
_ => {
return Err(
"usage: ironstorage-android-build <fixtures output.kt | check android-dir | prepare-bindings input output | package-native input output | audit-elf path abi | audit-native jniLibs | audit-apk-split apk abi | audit-package apk-or-aab | audit-release apk symbols | audit-manifest manifest.xml backup.xml extraction.xml | sbom android.json rust.txt Cargo.lock policy.json output.json | safe-reports input-dir output.json | check-toolchain cargo rust ndk ndk-version cargo-ndk-version>".into(),
"usage: ironstorage-android-build <fixtures output.kt | check android-dir | prepare-bindings input output | package-native input output | audit-elf path abi | audit-native jniLibs | audit-apk-split apk abi | audit-package apk-or-aab | audit-release apk symbols | audit-release-manifest manifest.xml | audit-manifest manifest.xml backup.xml extraction.xml | sbom android.json rust.txt Cargo.lock policy.json output.json | safe-reports input-dir output.json | check-toolchain cargo rust ndk ndk-version cargo-ndk-version>".into(),
);
}
}