diff --git a/apple/Resources/App/PrivacyInfo.xcprivacy b/apple/Resources/App/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..7d9edf4 --- /dev/null +++ b/apple/Resources/App/PrivacyInfo.xcprivacy @@ -0,0 +1,31 @@ + + + + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + NSPrivacyCollectedDataTypes + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryFileTimestamp + NSPrivacyAccessedAPITypeReasons + + C617.1 + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategorySystemBootTime + NSPrivacyAccessedAPITypeReasons + + 35F9.1 + + + + + diff --git a/apple/Resources/AutoFill/PrivacyInfo.xcprivacy b/apple/Resources/AutoFill/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..7d9edf4 --- /dev/null +++ b/apple/Resources/AutoFill/PrivacyInfo.xcprivacy @@ -0,0 +1,31 @@ + + + + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + NSPrivacyCollectedDataTypes + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryFileTimestamp + NSPrivacyAccessedAPITypeReasons + + C617.1 + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategorySystemBootTime + NSPrivacyAccessedAPITypeReasons + + 35F9.1 + + + + + diff --git a/apple/Resources/Watch/PrivacyInfo.xcprivacy b/apple/Resources/Watch/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..e08a130 --- /dev/null +++ b/apple/Resources/Watch/PrivacyInfo.xcprivacy @@ -0,0 +1,14 @@ + + + + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + NSPrivacyCollectedDataTypes + + NSPrivacyAccessedAPITypes + + + diff --git a/apple/TESTING.md b/apple/TESTING.md new file mode 100644 index 0000000..2c35e16 --- /dev/null +++ b/apple/TESTING.md @@ -0,0 +1,89 @@ +# Apple mobile integration audit + +This is the source-of-truth coverage and release-test checklist for the iPhone +and Apple Watch milestone. Automated evidence is required on every change; a +manual result is evidence only when the named device or simulator flow was +actually exercised. + +## Coverage matrix + +| Area | Implemented boundary | Automated evidence | Manual release evidence | +| --- | --- | --- | --- | +| Home tab, remote activity, pull-to-refresh | UIKit renders Rust `MobileHomePage` and typed Commit/Fetch/Pull/Push actions | `mobile_home` unit tests, `git_embedded`, and `crates/apple` bridge tests | Exercise refresh and all four actions against a disposable HTTPS remote, including expired application-token recovery | +| Passwords tab, navigation, view, and edit | Rust supplies typed paths, fields, drafts, revisions, and mutations; UIKit presents them | `mobile_passwords`, `entry_documents`, `mobile_mutation`, and `mobile_authentication` tests | Open nested entries, edit/save/cancel, and confirm stale-edit recovery | +| Swipe move/copy/delete | Rust plans destinations, collisions, dirty-editor handling, and commits; UIKit also exposes accessibility actions | `plans_destinations_collisions_hidden_paths_and_stale_revisions` and authentication serialization tests | Exercise swipe and VoiceOver alternatives; confirm destructive prompts | +| TOTP tab and sharing | Rust discovers, caches, parses, selects, and generates OTP data | `mobile_totp`, `otp`, and `mobile_watch` integration tests | Discover entries, read a code, change Watch selection, and verify countdown | +| Preferences and biometric unlock | Rust owns preference/authentication state; LocalAuthentication and Keychain remain isolated Apple adapters | `authentication_leases`, `secret_store`, and mobile authentication tests | Enroll once, then test match, cancel, rejection, and changed enrollment using the documented sequence below | +| GPG key QR transfer | Rust frames, validates, reconstructs, authenticates, and atomically stores keys; UIKit only scans/displays frames | `mobile_key_transfer` unit tests and `crypto_compatibility` | Test public/private transfer, interruption, duplicates, and capture shielding | +| Watch synchronization | Rust owns selected snapshots, pairing identity, revisions, replay rejection, receipts, and revocation | `replacement_snapshots_reject_replays_conflicts_and_pairing_changes` | On a paired device, verify background delivery, replacement, replay rejection, revoke, and pairing change | +| Watch display | Rust owns OTP calculation, timing, and ready/empty/syncing/stale/locked/error presentation; SwiftUI renders it | `watch_runtime_generates_view_ready_totp_and_clears_secrets_when_locked` plus ARM64 Watch target build | Test offline and stale-snapshot behavior, wrist-down privacy, VoiceOver, Dynamic Type, and supported Watch sizes | +| Lifecycle and disclosure controls | Rust drops protected state; UIKit/SwiftUI clear views, time-limit pasteboard values, and shield private QR/captured content | authentication, presentation, TOTP, and Watch lifecycle tests; source audit in `apple_mobile_audit` | Background/foreground, lock, screenshot/capture, pasteboard expiry, and protected-data loss | +| iPhone layouts and input | Native UIKit navigation, tables, alerts, refresh controls, semantic text styles, and hardware-keyboard behavior | iPhone simulator compile for the supported target | Exercise supported iPhone sizes, largest Dynamic Type, VoiceOver, contrast, and hardware keyboard | + +The following are intentionally outside this milestone and must not be stubbed +into the Apple apps: full CLI/TUI parity, command mode or command palette, SSH +Git, non-TOTP Watch features, and production AutoFill behavior. + +## Security boundary audit + +- `crates/storage` owns repository access, parsing, Git, OTP, authentication, + snapshot transfer, conflict handling, and secret policy. +- The Apple Rust crates are mechanical UniFFI projections. Swift may collect + input and call those APIs; it does not open a password-store repository, + calculate OTP, parse snapshots, or launch processes. +- Git URLs are rejected unless they use HTTPS before transport work. The Watch + stores only the selected opaque snapshot in a passcode-protected, device-only + Keychain item. +- Apple production sources contain no logging calls. Secret values use typed + redaction/zeroization in Rust; pasteboard values are removed after their Rust + deadline; private QR and authenticated content are hidden during capture or + lifecycle loss. +- The iPhone and AutoFill privacy manifests declare in-container file metadata + access (`C617.1`) and elapsed-time/timer use (`35F9.1`). The Watch manifest + declares no required-reason API because its minimal Rust core does not open + files or use a monotonic timer. No target declares tracking or developer data + collection. +- Face ID and camera access have purpose strings in `project.yml`. No custom + entitlement is currently required: default signing supplies each target's + application identifier and Keychain group, WatchConnectivity needs no added + capability, and no App Group is used. Alternative-distribution entitlements + belong to the final distribution issue. + +`cargo test -p ironstorage --test apple_mobile_audit` enforces the source and privacy-manifest +parts of this review so those boundaries cannot silently regress. + +## Required automated gates + +From the repository root: + +```sh +cargo fmt --all -- --check +RUSTFLAGS="-D warnings" cargo check --workspace --all-targets +cargo clippy --workspace --all-targets -- -D warnings +cargo test --workspace +``` + +For Apple changes, regenerate the project and compile the exact affected iPhone +and ARM64 Watch simulator targets. Confirm each built app bundle contains its +own `PrivacyInfo.xcprivacy`. + +## Manual biometric sequence + +Keep the existing booted simulator, app container, bundle identifier, and Face +ID enrollment. For first setup only, choose **Features > Face ID > Enrolled**, +enable Biometric Unlock in IronStorage, unlock one entry, and enter the GPG +passphrase once. Do not toggle enrollment again during normal validation. + +For each normal check, tap the entry's **Unlock** control first and then choose +**Features > Face ID > Matching Face**. Verify cancellation and rejection +without changing enrollment. A changed-enrollment check is destructive because +`biometryCurrentSet` invalidates the protected Keychain item; run it only when +the tester is ready to enter the passphrase again. + +## Manual evidence still required + +Record the device model/OS, build commit, and pass/fail result for each manual +row above. Physical paired-Watch deployment and WatchConnectivity testing are +currently skipped by project direction, so issues closed without that evidence +must carry the `untested` label. Do not describe a simulator build or an opened +Xcode project as device validation. diff --git a/apple/project.yml b/apple/project.yml index 36bc887..529bde3 100644 --- a/apple/project.yml +++ b/apple/project.yml @@ -29,6 +29,7 @@ targets: - UIInterfaceOrientationPortrait sources: - Assets.xcassets + - Resources/App/PrivacyInfo.xcprivacy - Sources/App - Generated/ironstorage_apple.swift dependencies: @@ -60,6 +61,7 @@ targets: NSExtensionPointIdentifier: com.apple.authentication-services-credential-provider-ui NSExtensionPrincipalClass: $(PRODUCT_MODULE_NAME).CredentialProviderViewController sources: + - Resources/AutoFill/PrivacyInfo.xcprivacy - Sources/AutoFill - Generated/ironstorage_apple.swift preBuildScripts: @@ -89,6 +91,7 @@ targets: WKCompanionAppBundleIdentifier: de.rfc1437.ironstorage sources: - Assets.xcassets + - Resources/Watch/PrivacyInfo.xcprivacy - Sources/Watch - Generated/ironstorage_watch.swift preBuildScripts: diff --git a/crates/storage/tests/apple_mobile_audit.rs b/crates/storage/tests/apple_mobile_audit.rs new file mode 100644 index 0000000..a3ada3d --- /dev/null +++ b/crates/storage/tests/apple_mobile_audit.rs @@ -0,0 +1,93 @@ +#![forbid(unsafe_code)] + +use std::{fs, path::PathBuf}; + +fn workspace_file(path: &str) -> String { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(std::path::Path::parent) + .expect("workspace root") + .to_path_buf(); + fs::read_to_string(root.join(path)).unwrap_or_else(|error| panic!("read {path}: {error}")) +} + +#[test] +fn apple_sources_preserve_the_mobile_security_boundary() { + let app = workspace_file("apple/Sources/App/IronStorageApp.swift"); + let watch = workspace_file("apple/Sources/Watch/IronStorageWatchApp.swift"); + let autofill = workspace_file("apple/Sources/AutoFill/CredentialProviderViewController.swift"); + let bridges = format!( + "{}\n{}", + workspace_file("crates/apple/src/lib.rs"), + workspace_file("crates/watch-apple/src/lib.rs") + ); + + for (name, source) in [ + ("iPhone", app.as_str()), + ("Watch", watch.as_str()), + ("AutoFill", autofill.as_str()), + ("Apple Rust bridges", bridges.as_str()), + ] { + for forbidden in [ + "Process(", + "NSTask", + concat!("Command", "::new("), + "posix_spawn(", + ] { + assert!(!source.contains(forbidden), "{name} launches {forbidden}"); + } + } + + for (name, source) in [ + ("iPhone", app.as_str()), + ("Watch", watch.as_str()), + ("AutoFill", autofill.as_str()), + ] { + for forbidden in ["print(", "NSLog(", "Logger(", "os_log("] { + assert!( + !source.contains(forbidden), + "{name} logs through {forbidden}" + ); + } + } + + assert!(!app.contains("FileManager.default")); + for forbidden in ["otpauth://", "HMAC", "SHA1", "SHA256", "SHA512", ".gpg-id"] { + assert!( + !watch.contains(forbidden), + "Watch implements storage or OTP domain behavior: {forbidden}" + ); + } + assert!(watch.contains("core.presentationAt")); + assert!(watch.contains("SecItemCopyMatching")); +} + +#[test] +fn every_apple_bundle_has_an_audited_privacy_manifest() { + let project = workspace_file("apple/project.yml"); + for path in [ + "Resources/App/PrivacyInfo.xcprivacy", + "Resources/AutoFill/PrivacyInfo.xcprivacy", + "Resources/Watch/PrivacyInfo.xcprivacy", + ] { + assert!(project.contains(path), "Xcode target omits {path}"); + } + + for path in [ + "apple/Resources/App/PrivacyInfo.xcprivacy", + "apple/Resources/AutoFill/PrivacyInfo.xcprivacy", + "apple/Resources/Watch/PrivacyInfo.xcprivacy", + ] { + let manifest = workspace_file(path); + assert!(manifest.contains("NSPrivacyTracking\n\t")); + assert!(manifest.contains("NSPrivacyCollectedDataTypes\n\t")); + } + + let app = workspace_file("apple/Resources/App/PrivacyInfo.xcprivacy"); + assert!(app.contains("NSPrivacyAccessedAPICategoryFileTimestamp")); + assert!(app.contains("C617.1")); + assert!(app.contains("NSPrivacyAccessedAPICategorySystemBootTime")); + assert!(app.contains("35F9.1")); + assert!(project.contains("NSFaceIDUsageDescription")); + assert!(project.contains("NSCameraUsageDescription")); +}