app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit 39d1494d7b1e72971f21128932764427e7dced2f
parent d4f23ffc62ee929a5711edf73e9fcc68481c289e
Author: triesap <tyson@radroots.org>
Date:   Sat, 21 Mar 2026 00:01:17 +0000

ios: add apple keychain vault adapter

- add the Rust-side Apple security ffi wrapper for keychain secret storage
- add the iOS keychain vault and app-container accounts manager wiring
- load selected account readiness through the iOS backend while keeping setup action disabled
- validate the adapter with unit tests, target checks, and host build

Diffstat:
MCargo.lock | 3+++
MCargo.toml | 2++
Mcrates/ios/Cargo.toml | 3+++
Mcrates/ios/src/lib.rs | 47+++++++++++++++++++++++++++++++++++++++++++----
Acrates/ios/src/security.rs | 368+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/ios/src/storage.rs | 69+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/ios/src/vault.rs | 118+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
7 files changed, 606 insertions(+), 4 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -2762,7 +2762,10 @@ version = "0.1.0" dependencies = [ "eframe", "radroots-app-core", + "radroots-identity", + "radroots-nostr-accounts", "wgpu", + "zeroize", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml @@ -25,10 +25,12 @@ log = "0.4.28" nostr = { version = "0.44.1", default-features = false, features = ["std"] } nostr-browser-signer = "0.44.1" objc2-foundation = { version = "0.3.2", default-features = false, features = ["std"] } +radroots-identity = { path = "../lib/crates/identity", default-features = false, features = ["std"] } radroots-nostr-accounts = { path = "../lib/crates/nostr-accounts", default-features = false, features = ["std", "file-store", "os-keyring"] } wasm-bindgen-futures = "0.4.50" web-sys = { version = "0.3.91", features = ["Document", "HtmlCanvasElement", "Window"] } wgpu = { version = "27.0.1", default-features = false } +zeroize = "1.8.2" [workspace.lints.rust] unsafe_code = "forbid" diff --git a/crates/ios/Cargo.toml b/crates/ios/Cargo.toml @@ -17,6 +17,9 @@ crate-type = ["staticlib", "rlib"] [dependencies] eframe.workspace = true radroots-app-core = { path = "../core" } +radroots-identity.workspace = true +radroots-nostr-accounts.workspace = true +zeroize.workspace = true [target.'cfg(target_os = "ios")'.dependencies] wgpu = { workspace = true, features = ["metal", "wgsl"] } diff --git a/crates/ios/src/lib.rs b/crates/ios/src/lib.rs @@ -6,16 +6,55 @@ use eframe::egui::ViewportBuilder; use radroots_app_core::{ APP_NAME, IdentityGateState, RadrootsApp, RadrootsAppBackend, SetupActionState, }; +#[cfg(target_os = "ios")] +use radroots_nostr_accounts::prelude::{ + RadrootsNostrAccountsManager, RadrootsNostrSelectedAccountStatus, +}; + +#[cfg(any(target_os = "ios", test))] +mod security; +#[cfg(any(target_os = "ios", test))] +mod storage; +#[cfg(any(target_os = "ios", test))] +mod vault; #[cfg(target_os = "ios")] struct IosBackend; #[cfg(target_os = "ios")] +impl IosBackend { + fn unsupported_reason() -> String { + "Secure onboarding is not yet available on iOS.".to_owned() + } + + fn accounts_manager() -> Result<RadrootsNostrAccountsManager, String> { + storage::accounts_manager() + } + + fn map_status(status: RadrootsNostrSelectedAccountStatus) -> IdentityGateState { + match status { + RadrootsNostrSelectedAccountStatus::Ready { account } => IdentityGateState::Ready { + account_id: account.account_id.to_string(), + npub: account.public_identity.public_key_npub, + }, + RadrootsNostrSelectedAccountStatus::NotConfigured + | RadrootsNostrSelectedAccountStatus::PublicOnly { .. } => { + IdentityGateState::Unsupported { + reason: Self::unsupported_reason(), + } + } + } + } +} + +#[cfg(target_os = "ios")] impl RadrootsAppBackend for IosBackend { fn load_identity_state(&self) -> Result<IdentityGateState, String> { - Ok(IdentityGateState::Unsupported { - reason: "Secure onboarding is not yet available on iOS.".to_owned(), - }) + let manager = Self::accounts_manager()?; + let status = manager + .selected_account_status() + .map_err(|source| source.to_string())?; + Ok(Self::map_status(status)) } fn setup_action_state(&self) -> SetupActionState { @@ -28,7 +67,7 @@ impl RadrootsAppBackend for IosBackend { fn request_setup_action(&self) -> Result<Option<IdentityGateState>, String> { Ok(Some(IdentityGateState::Unsupported { - reason: "Secure onboarding is not yet available on iOS.".to_owned(), + reason: Self::unsupported_reason(), })) } } diff --git a/crates/ios/src/security.rs b/crates/ios/src/security.rs @@ -0,0 +1,368 @@ +use radroots_nostr_accounts::prelude::RadrootsNostrAccountsError; +#[cfg(target_os = "ios")] +use std::ffi::CStr; +use std::ffi::CString; +#[cfg(target_os = "ios")] +use std::os::raw::{c_char, c_int}; +#[cfg(target_os = "ios")] +use std::ptr; + +pub(crate) const APPLE_NOSTR_SERVICE: &str = "org.radroots.app.nostr"; +pub(crate) const APPLE_NOSTR_NAMESPACE: &str = "nostr"; + +#[cfg(target_os = "ios")] +#[repr(i32)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum AppleSecretStatus { + Success = 0, + NotFound = 1, + InvalidInput = 2, + Error = 3, +} + +#[cfg(target_os = "ios")] +impl AppleSecretStatus { + fn from_raw(value: i32) -> Result<Self, RadrootsNostrAccountsError> { + match value { + 0 => Ok(Self::Success), + 1 => Ok(Self::NotFound), + 2 => Ok(Self::InvalidInput), + 3 => Ok(Self::Error), + other => Err(RadrootsNostrAccountsError::Vault(format!( + "unknown apple security ffi status {other}" + ))), + } + } +} + +#[cfg(target_os = "ios")] +unsafe extern "C" { + fn radroots_apple_secret_store_put( + service_prefix: *const c_char, + namespace: *const c_char, + name: *const c_char, + value_ptr: *const u8, + value_len: isize, + accessibility_raw: i32, + device_local_only_raw: i32, + user_presence_required_raw: i32, + error_out: *mut *mut c_char, + ) -> i32; + + fn radroots_apple_secret_store_get( + service_prefix: *const c_char, + namespace: *const c_char, + name: *const c_char, + value_out: *mut *mut u8, + value_len_out: *mut isize, + error_out: *mut *mut c_char, + ) -> i32; + + fn radroots_apple_secret_store_delete( + service_prefix: *const c_char, + namespace: *const c_char, + name: *const c_char, + error_out: *mut *mut c_char, + ) -> i32; + + fn radroots_apple_buffer_free(buffer: *mut u8, length: isize); + fn radroots_apple_c_string_free(string: *mut c_char); +} + +#[cfg(target_os = "ios")] +struct FfiErrorString { + ptr: *mut c_char, +} + +#[cfg(target_os = "ios")] +impl FfiErrorString { + fn new() -> Self { + Self { + ptr: ptr::null_mut(), + } + } + + fn as_mut_ptr(&mut self) -> *mut *mut c_char { + &mut self.ptr + } + + fn message(&self) -> Option<String> { + if self.ptr.is_null() { + return None; + } + // SAFETY: the Swift FFI returns a null-terminated string pointer that remains valid + // until released through the paired free function. + unsafe { Some(CStr::from_ptr(self.ptr).to_string_lossy().into_owned()) } + } +} + +#[cfg(target_os = "ios")] +impl Drop for FfiErrorString { + fn drop(&mut self) { + if self.ptr.is_null() { + return; + } + #[cfg(target_os = "ios")] + // SAFETY: the pointer originated from the Swift FFI string allocator. + unsafe { + radroots_apple_c_string_free(self.ptr); + } + } +} + +#[cfg(target_os = "ios")] +struct FfiDataBuffer { + ptr: *mut u8, + len: isize, +} + +#[cfg(target_os = "ios")] +impl FfiDataBuffer { + fn new() -> Self { + Self { + ptr: ptr::null_mut(), + len: 0, + } + } + + fn as_mut_ptr(&mut self) -> *mut *mut u8 { + &mut self.ptr + } + + fn len_mut_ptr(&mut self) -> *mut isize { + &mut self.len + } + + fn to_vec(&self) -> Result<Vec<u8>, RadrootsNostrAccountsError> { + if self.len < 0 { + return Err(RadrootsNostrAccountsError::Vault( + "apple security ffi returned a negative buffer length".to_owned(), + )); + } + if self.ptr.is_null() { + if self.len == 0 { + return Ok(Vec::new()); + } + return Err(RadrootsNostrAccountsError::Vault( + "apple security ffi returned a null buffer pointer".to_owned(), + )); + } + // SAFETY: the pointer and length pair came from the Swift FFI and stays valid until + // released by the paired free function. We copy into an owned Vec before dropping. + unsafe { Ok(std::slice::from_raw_parts(self.ptr, self.len as usize).to_vec()) } + } +} + +#[cfg(target_os = "ios")] +impl Drop for FfiDataBuffer { + fn drop(&mut self) { + if self.ptr.is_null() { + return; + } + #[cfg(target_os = "ios")] + // SAFETY: the pointer originated from the Swift FFI buffer allocator. + unsafe { + radroots_apple_buffer_free(self.ptr, self.len); + } + } +} + +#[derive(Debug, Clone, Copy)] +pub(crate) enum AppleSecretAccessibility { + WhenUnlocked = 0, +} + +#[derive(Debug, Clone, Copy)] +pub(crate) struct AppleSecretAccessPolicy { + pub accessibility: AppleSecretAccessibility, + pub device_local_only: bool, + pub user_presence_required: bool, +} + +impl AppleSecretAccessPolicy { + pub(crate) const SECURE_LOCAL_SECRET: Self = Self { + accessibility: AppleSecretAccessibility::WhenUnlocked, + device_local_only: true, + user_presence_required: false, + }; +} + +pub(crate) fn store_secret( + service: &str, + namespace: &str, + name: &str, + value: &[u8], + policy: AppleSecretAccessPolicy, +) -> Result<(), RadrootsNostrAccountsError> { + #[cfg(target_os = "ios")] + { + let service = c_string(service)?; + let namespace = c_string(namespace)?; + let name = c_string(name)?; + let mut ffi_error = FfiErrorString::new(); + let status = unsafe { + // SAFETY: all pointers are derived from live CString values and valid slices. + radroots_apple_secret_store_put( + service.as_ptr(), + namespace.as_ptr(), + name.as_ptr(), + value.as_ptr(), + value.len() as isize, + policy.accessibility as i32, + bool_to_c_int(policy.device_local_only), + bool_to_c_int(policy.user_presence_required), + ffi_error.as_mut_ptr(), + ) + }; + return match AppleSecretStatus::from_raw(status)? { + AppleSecretStatus::Success => Ok(()), + AppleSecretStatus::NotFound => Err(vault_error( + ffi_error, + "apple security ffi reported not found during store", + )), + AppleSecretStatus::InvalidInput => Err(vault_error( + ffi_error, + "apple security ffi rejected the store request", + )), + AppleSecretStatus::Error => Err(vault_error(ffi_error, "apple keychain store failed")), + }; + } + + #[cfg(not(target_os = "ios"))] + { + let _ = (service, namespace, name, value, policy); + Err(RadrootsNostrAccountsError::Vault( + "apple keychain storage is only available on ios".to_owned(), + )) + } +} + +pub(crate) fn load_secret( + service: &str, + namespace: &str, + name: &str, +) -> Result<Option<Vec<u8>>, RadrootsNostrAccountsError> { + #[cfg(target_os = "ios")] + { + let service = c_string(service)?; + let namespace = c_string(namespace)?; + let name = c_string(name)?; + let mut ffi_error = FfiErrorString::new(); + let mut ffi_buffer = FfiDataBuffer::new(); + let status = unsafe { + // SAFETY: all output pointers reference live local storage for the duration + // of the call, and all input strings are backed by live CString values. + radroots_apple_secret_store_get( + service.as_ptr(), + namespace.as_ptr(), + name.as_ptr(), + ffi_buffer.as_mut_ptr(), + ffi_buffer.len_mut_ptr(), + ffi_error.as_mut_ptr(), + ) + }; + return match AppleSecretStatus::from_raw(status)? { + AppleSecretStatus::Success => ffi_buffer.to_vec().map(Some), + AppleSecretStatus::NotFound => Ok(None), + AppleSecretStatus::InvalidInput => Err(vault_error( + ffi_error, + "apple security ffi rejected the load request", + )), + AppleSecretStatus::Error => Err(vault_error(ffi_error, "apple keychain load failed")), + }; + } + + #[cfg(not(target_os = "ios"))] + { + let _ = (service, namespace, name); + Err(RadrootsNostrAccountsError::Vault( + "apple keychain storage is only available on ios".to_owned(), + )) + } +} + +pub(crate) fn remove_secret( + service: &str, + namespace: &str, + name: &str, +) -> Result<(), RadrootsNostrAccountsError> { + #[cfg(target_os = "ios")] + { + let service = c_string(service)?; + let namespace = c_string(namespace)?; + let name = c_string(name)?; + let mut ffi_error = FfiErrorString::new(); + let status = unsafe { + // SAFETY: all pointers are backed by live CString values for the duration + // of the call. + radroots_apple_secret_store_delete( + service.as_ptr(), + namespace.as_ptr(), + name.as_ptr(), + ffi_error.as_mut_ptr(), + ) + }; + return match AppleSecretStatus::from_raw(status)? { + AppleSecretStatus::Success | AppleSecretStatus::NotFound => Ok(()), + AppleSecretStatus::InvalidInput => Err(vault_error( + ffi_error, + "apple security ffi rejected the delete request", + )), + AppleSecretStatus::Error => Err(vault_error(ffi_error, "apple keychain delete failed")), + }; + } + + #[cfg(not(target_os = "ios"))] + { + let _ = (service, namespace, name); + Err(RadrootsNostrAccountsError::Vault( + "apple keychain storage is only available on ios".to_owned(), + )) + } +} + +fn c_string(value: &str) -> Result<CString, RadrootsNostrAccountsError> { + CString::new(value).map_err(|_| { + RadrootsNostrAccountsError::Vault( + "apple security ffi input contained an interior nul".into(), + ) + }) +} + +#[cfg(target_os = "ios")] +fn bool_to_c_int(value: bool) -> c_int { + if value { 1 } else { 0 } +} + +#[cfg(target_os = "ios")] +fn vault_error( + ffi_error: FfiErrorString, + fallback: impl Into<String>, +) -> RadrootsNostrAccountsError { + let fallback = fallback.into(); + let message = ffi_error.message().unwrap_or(fallback); + RadrootsNostrAccountsError::Vault(message) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn secure_local_secret_policy_defaults_to_when_unlocked_device_local() { + let policy = AppleSecretAccessPolicy::SECURE_LOCAL_SECRET; + + assert!(matches!( + policy.accessibility, + AppleSecretAccessibility::WhenUnlocked + )); + assert!(policy.device_local_only); + assert!(!policy.user_presence_required); + } + + #[test] + fn c_string_rejects_interior_nul() { + let err = c_string("bad\0value").expect_err("interior nul"); + assert!(err.to_string().starts_with("vault error:")); + } +} diff --git a/crates/ios/src/storage.rs b/crates/ios/src/storage.rs @@ -0,0 +1,69 @@ +#[cfg(target_os = "ios")] +use crate::security::APPLE_NOSTR_SERVICE; +#[cfg(target_os = "ios")] +use crate::vault::IosAppleKeychainVault; +#[cfg(target_os = "ios")] +use radroots_nostr_accounts::prelude::{ + RadrootsNostrAccountsManager, RadrootsNostrFileAccountStore, +}; +use std::path::Path; +use std::path::PathBuf; +#[cfg(target_os = "ios")] +use std::sync::Arc; + +#[cfg(target_os = "ios")] +pub(crate) fn accounts_path() -> Result<PathBuf, String> { + let home = std::env::var_os("HOME") + .map(PathBuf::from) + .ok_or_else(|| "failed to resolve ios app container home directory".to_owned())?; + let accounts_path = accounts_path_from_home(home.as_path()); + if let Some(parent) = accounts_path.parent() { + ensure_private_directory_tree(parent)?; + } + Ok(accounts_path) +} + +#[cfg(target_os = "ios")] +pub(crate) fn accounts_manager() -> Result<RadrootsNostrAccountsManager, String> { + let store = Arc::new(RadrootsNostrFileAccountStore::new(accounts_path()?)); + let vault = Arc::new(IosAppleKeychainVault::new(APPLE_NOSTR_SERVICE)); + RadrootsNostrAccountsManager::new(store, vault).map_err(|source| source.to_string()) +} + +fn accounts_path_from_home(home: &Path) -> PathBuf { + home.join("Library") + .join("Application Support") + .join("RadRoots") + .join("app") + .join("ios") + .join("nostr") + .join("accounts.json") +} + +#[cfg(target_os = "ios")] +fn ensure_private_directory_tree(leaf: &Path) -> Result<(), String> { + use std::os::unix::fs::PermissionsExt; + + std::fs::create_dir_all(leaf) + .map_err(|source| format!("failed to create ios accounts directory: {source}"))?; + std::fs::set_permissions(leaf, std::fs::Permissions::from_mode(0o700)) + .map_err(|source| format!("failed to set ios accounts directory permissions: {source}"))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accounts_path_uses_ios_application_support_layout() { + let home = PathBuf::from("/var/mobile/Containers/Data/Application/example"); + + assert_eq!( + accounts_path_from_home(home.as_path()), + PathBuf::from( + "/var/mobile/Containers/Data/Application/example/Library/Application Support/RadRoots/app/ios/nostr/accounts.json" + ) + ); + } +} diff --git a/crates/ios/src/vault.rs b/crates/ios/src/vault.rs @@ -0,0 +1,118 @@ +use crate::security::{ + APPLE_NOSTR_NAMESPACE, AppleSecretAccessPolicy, load_secret, remove_secret, store_secret, +}; +use radroots_identity::RadrootsIdentityId; +use radroots_nostr_accounts::prelude::{RadrootsNostrAccountsError, RadrootsNostrSecretVault}; +use zeroize::Zeroizing; + +#[derive(Debug, Clone)] +pub(crate) struct IosAppleKeychainVault { + service_name: String, +} + +impl IosAppleKeychainVault { + pub(crate) fn new(service_name: impl Into<String>) -> Self { + Self { + service_name: service_name.into(), + } + } + + fn account_name(account_id: &RadrootsIdentityId) -> &str { + account_id.as_str() + } +} + +impl RadrootsNostrSecretVault for IosAppleKeychainVault { + fn store_secret_hex( + &self, + account_id: &RadrootsIdentityId, + secret_key_hex: &str, + ) -> Result<(), RadrootsNostrAccountsError> { + let secret_key_hex = Zeroizing::new(secret_key_hex.to_owned()); + store_secret( + self.service_name.as_str(), + APPLE_NOSTR_NAMESPACE, + Self::account_name(account_id), + secret_key_hex.as_bytes(), + AppleSecretAccessPolicy::SECURE_LOCAL_SECRET, + ) + } + + fn load_secret_hex( + &self, + account_id: &RadrootsIdentityId, + ) -> Result<Option<String>, RadrootsNostrAccountsError> { + let Some(secret) = load_secret( + self.service_name.as_str(), + APPLE_NOSTR_NAMESPACE, + Self::account_name(account_id), + )? + else { + return Ok(None); + }; + + let secret = Zeroizing::new(secret); + let secret = std::str::from_utf8(secret.as_slice()).map_err(|source| { + RadrootsNostrAccountsError::Vault(format!( + "apple keychain secret was not valid utf-8: {source}" + )) + })?; + Ok(Some(secret.to_owned())) + } + + fn remove_secret( + &self, + account_id: &RadrootsIdentityId, + ) -> Result<(), RadrootsNostrAccountsError> { + remove_secret( + self.service_name.as_str(), + APPLE_NOSTR_NAMESPACE, + Self::account_name(account_id), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::security::APPLE_NOSTR_SERVICE; + use radroots_nostr_accounts::prelude::RadrootsNostrSecretVault; + + #[test] + fn account_name_uses_account_id_string() { + let account_id = RadrootsIdentityId::parse( + "3bf0c63f0f4478a288f6b67f0429dbf7f5119d4fa7218a4c40ef1378f80f7606", + ) + .expect("account id"); + + assert_eq!( + IosAppleKeychainVault::account_name(&account_id), + "3bf0c63f0f4478a288f6b67f0429dbf7f5119d4fa7218a4c40ef1378f80f7606" + ); + } + + #[cfg(not(target_os = "ios"))] + #[test] + fn vault_operations_report_unavailable_off_ios() { + let vault = IosAppleKeychainVault::new(APPLE_NOSTR_SERVICE); + let account_id = RadrootsIdentityId::parse( + "3bf0c63f0f4478a288f6b67f0429dbf7f5119d4fa7218a4c40ef1378f80f7606", + ) + .expect("account id"); + + let load = vault + .load_secret_hex(&account_id) + .expect_err("load off ios"); + assert!(load.to_string().starts_with("vault error:")); + + let store = vault + .store_secret_hex(&account_id, "deadbeef") + .expect_err("store off ios"); + assert!(store.to_string().starts_with("vault error:")); + + let remove = vault + .remove_secret(&account_id) + .expect_err("remove off ios"); + assert!(remove.to_string().starts_with("vault error:")); + } +}