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:
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:"));
+ }
+}