app

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

commit 40279340e601ef15e3c1a32d61573cce3441a183
parent 4a78d2656014bc528a93205490f6f3c755ce700a
Author: triesap <tyson@radroots.org>
Date:   Sat, 21 Mar 2026 01:15:15 +0000

desktop: share apple keychain vault across apple targets

- add the shared apple security crate under crates/apple/security
- switch the macos desktop launcher to the shared apple keychain vault
- rewire the ios launcher to the shared apple security crate
- link the desktop target against the shared swift apple security package

Diffstat:
MCONTRIBUTING.md | 13++++++++++++-
MCargo.lock | 12++++++++++++
MCargo.toml | 2++
Acrates/apple/security/Cargo.toml | 19+++++++++++++++++++
Acrates/apple/security/src/lib.rs | 7+++++++
Acrates/apple/security/src/security.rs | 366+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/apple/security/src/vault.rs | 116+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/desktop/Cargo.toml | 3+++
Acrates/desktop/build.rs | 117+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/desktop/src/main.rs | 41++++++++++++++++++++++++++++++++++++-----
Mcrates/ios/Cargo.toml | 1+
Mcrates/ios/src/lib.rs | 4----
Dcrates/ios/src/security.rs | 368-------------------------------------------------------------------------------
Mcrates/ios/src/storage.rs | 6++----
Dcrates/ios/src/vault.rs | 118-------------------------------------------------------------------------------
Mnative/apple/swift/RadRootsAppleSecurity/Package.swift | 6++++++
16 files changed, 699 insertions(+), 500 deletions(-)

diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md @@ -21,6 +21,8 @@ Install Trunk for the wasm target: cargo install trunk ``` +On macOS, ensure the Apple Swift toolchain is available. The desktop target links the shared Apple native security package during build. + Confirm your environment: ```bash @@ -29,6 +31,12 @@ rustc --version trunk --version ``` +On macOS, also confirm: + +```bash +swift --version +``` + ## Getting Started Clone your fork and enter the repository root: @@ -105,8 +113,11 @@ swift test - Prefer small, reviewable commits. - Update tests when behavior changes. - Update documentation when commands, structure, or contributor workflow changes. +- Use repo-relative paths in docs, comments, and contributor-facing text. +- Keep documentation path references relative to this repository root. +- Do not use absolute filesystem paths or home-directory path forms in repository docs. - Remove obsolete code and dependencies when they are clearly replaced. -- Use workspace-managed dependency versions from the root [Cargo.toml](/Users/treesap/dev/radroots/radroots-platform-v1/domains/community/apps/app/Cargo.toml). +- Use workspace-managed dependency versions from the root `Cargo.toml`. ## Reporting Issues diff --git a/Cargo.lock b/Cargo.lock @@ -2736,6 +2736,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" [[package]] +name = "radroots-app-apple-security" +version = "0.1.0" +dependencies = [ + "radroots-identity", + "radroots-nostr-accounts", + "zeroize", +] + +[[package]] name = "radroots-app-core" version = "0.1.0" dependencies = [ @@ -2751,7 +2760,9 @@ dependencies = [ "eframe", "egui", "objc2-foundation 0.3.2", + "radroots-app-apple-security", "radroots-app-core", + "radroots-identity", "radroots-nostr-accounts", "wgpu", ] @@ -2761,6 +2772,7 @@ name = "radroots-app-ios" version = "0.1.0" dependencies = [ "eframe", + "radroots-app-apple-security", "radroots-app-core", "radroots-identity", "radroots-nostr-accounts", diff --git a/Cargo.toml b/Cargo.toml @@ -1,5 +1,6 @@ [workspace] members = [ + "crates/apple/security", "crates/core", "crates/desktop", "crates/ios", @@ -25,6 +26,7 @@ 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-app-apple-security = { path = "crates/apple/security" } 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" diff --git a/crates/apple/security/Cargo.toml b/crates/apple/security/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "radroots-app-apple-security" +authors.workspace = true +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +repository.workspace = true +homepage.workspace = true +description = "Rad Roots Apple security bridge" +publish = false + +[dependencies] +radroots-identity.workspace = true +radroots-nostr-accounts.workspace = true +zeroize.workspace = true + +[lints.rust] +unsafe_code = { level = "allow", priority = 1 } diff --git a/crates/apple/security/src/lib.rs b/crates/apple/security/src/lib.rs @@ -0,0 +1,7 @@ +#![allow(unsafe_code)] + +mod security; +mod vault; + +pub use security::{APPLE_NOSTR_NAMESPACE, APPLE_NOSTR_SERVICE}; +pub use vault::RadrootsAppleKeychainVault; diff --git a/crates/apple/security/src/security.rs b/crates/apple/security/src/security.rs @@ -0,0 +1,366 @@ +use radroots_nostr_accounts::prelude::RadrootsNostrAccountsError; +#[cfg(any(target_os = "ios", target_os = "macos"))] +use std::ffi::CStr; +use std::ffi::CString; +#[cfg(any(target_os = "ios", target_os = "macos"))] +use std::os::raw::{c_char, c_int}; +#[cfg(any(target_os = "ios", target_os = "macos"))] +use std::ptr; + +pub const APPLE_NOSTR_SERVICE: &str = "org.radroots.app.nostr"; +pub const APPLE_NOSTR_NAMESPACE: &str = "nostr"; + +#[cfg(any(target_os = "ios", target_os = "macos"))] +#[repr(i32)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum AppleSecretStatus { + Success = 0, + NotFound = 1, + InvalidInput = 2, + Error = 3, +} + +#[cfg(any(target_os = "ios", target_os = "macos"))] +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(any(target_os = "ios", target_os = "macos"))] +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(any(target_os = "ios", target_os = "macos"))] +struct FfiErrorString { + ptr: *mut c_char, +} + +#[cfg(any(target_os = "ios", target_os = "macos"))] +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(any(target_os = "ios", target_os = "macos"))] +impl Drop for FfiErrorString { + fn drop(&mut self) { + if self.ptr.is_null() { + return; + } + // SAFETY: the pointer originated from the Swift FFI string allocator. + unsafe { + radroots_apple_c_string_free(self.ptr); + } + } +} + +#[cfg(any(target_os = "ios", target_os = "macos"))] +struct FfiDataBuffer { + ptr: *mut u8, + len: isize, +} + +#[cfg(any(target_os = "ios", target_os = "macos"))] +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(any(target_os = "ios", target_os = "macos"))] +impl Drop for FfiDataBuffer { + fn drop(&mut self) { + if self.ptr.is_null() { + return; + } + // SAFETY: the pointer originated from the Swift FFI buffer allocator. + unsafe { + radroots_apple_buffer_free(self.ptr, self.len); + } + } +} + +#[derive(Debug, Clone, Copy)] +pub enum AppleSecretAccessibility { + WhenUnlocked = 0, +} + +#[derive(Debug, Clone, Copy)] +pub struct AppleSecretAccessPolicy { + pub accessibility: AppleSecretAccessibility, + pub device_local_only: bool, + pub user_presence_required: bool, +} + +impl AppleSecretAccessPolicy { + pub const SECURE_LOCAL_SECRET: Self = Self { + accessibility: AppleSecretAccessibility::WhenUnlocked, + device_local_only: true, + user_presence_required: false, + }; +} + +pub fn store_secret( + service: &str, + namespace: &str, + name: &str, + value: &[u8], + policy: AppleSecretAccessPolicy, +) -> Result<(), RadrootsNostrAccountsError> { + #[cfg(any(target_os = "ios", target_os = "macos"))] + { + 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(any(target_os = "ios", target_os = "macos")))] + { + let _ = (service, namespace, name, value, policy); + Err(RadrootsNostrAccountsError::Vault( + "apple keychain storage is only available on ios and macos".to_owned(), + )) + } +} + +pub fn load_secret( + service: &str, + namespace: &str, + name: &str, +) -> Result<Option<Vec<u8>>, RadrootsNostrAccountsError> { + #[cfg(any(target_os = "ios", target_os = "macos"))] + { + 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(any(target_os = "ios", target_os = "macos")))] + { + let _ = (service, namespace, name); + Err(RadrootsNostrAccountsError::Vault( + "apple keychain storage is only available on ios and macos".to_owned(), + )) + } +} + +pub fn remove_secret( + service: &str, + namespace: &str, + name: &str, +) -> Result<(), RadrootsNostrAccountsError> { + #[cfg(any(target_os = "ios", target_os = "macos"))] + { + 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(any(target_os = "ios", target_os = "macos")))] + { + let _ = (service, namespace, name); + Err(RadrootsNostrAccountsError::Vault( + "apple keychain storage is only available on ios and macos".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(any(target_os = "ios", target_os = "macos"))] +fn bool_to_c_int(value: bool) -> c_int { + if value { 1 } else { 0 } +} + +#[cfg(any(target_os = "ios", target_os = "macos"))] +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/apple/security/src/vault.rs b/crates/apple/security/src/vault.rs @@ -0,0 +1,116 @@ +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 struct RadrootsAppleKeychainVault { + service_name: String, +} + +impl RadrootsAppleKeychainVault { + pub 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 RadrootsAppleKeychainVault { + 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::*; + + #[test] + fn account_name_uses_account_id_string() { + let account_id = RadrootsIdentityId::parse( + "3bf0c63f0f4478a288f6b67f0429dbf7f5119d4fa7218a4c40ef1378f80f7606", + ) + .expect("account id"); + + assert_eq!( + RadrootsAppleKeychainVault::account_name(&account_id), + "3bf0c63f0f4478a288f6b67f0429dbf7f5119d4fa7218a4c40ef1378f80f7606" + ); + } + + #[cfg(not(any(target_os = "ios", target_os = "macos")))] + #[test] + fn vault_operations_report_unavailable_off_apple() { + let vault = RadrootsAppleKeychainVault::new(crate::APPLE_NOSTR_SERVICE); + let account_id = RadrootsIdentityId::parse( + "3bf0c63f0f4478a288f6b67f0429dbf7f5119d4fa7218a4c40ef1378f80f7606", + ) + .expect("account id"); + + let load = vault + .load_secret_hex(&account_id) + .expect_err("load off apple"); + assert!(load.to_string().starts_with("vault error:")); + + let store = vault + .store_secret_hex(&account_id, "deadbeef") + .expect_err("store off apple"); + assert!(store.to_string().starts_with("vault error:")); + + let remove = vault + .remove_secret(&account_id) + .expect_err("remove off apple"); + assert!(remove.to_string().starts_with("vault error:")); + } +} diff --git a/crates/desktop/Cargo.toml b/crates/desktop/Cargo.toml @@ -9,6 +9,7 @@ repository.workspace = true homepage.workspace = true description = "Rad Roots desktop launcher" publish = false +build = "build.rs" [lints] workspace = true @@ -25,6 +26,8 @@ wgpu = { workspace = true, features = ["metal", "wgsl"] } [target.'cfg(target_os = "macos")'.dependencies] objc2-foundation = { workspace = true, features = ["NSProcessInfo", "NSString"] } +radroots-app-apple-security.workspace = true +radroots-identity.workspace = true [target.'cfg(target_os = "windows")'.dependencies] wgpu = { workspace = true, features = ["dx12", "wgsl"] } diff --git a/crates/desktop/build.rs b/crates/desktop/build.rs @@ -0,0 +1,117 @@ +use std::env; +use std::path::{Path, PathBuf}; +use std::process::Command; + +fn main() { + println!("cargo:rerun-if-changed=build.rs"); + + if env::var("CARGO_CFG_TARGET_OS").ok().as_deref() != Some("macos") { + return; + } + + let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").expect("manifest dir")); + let package_dir = manifest_dir.join("../../native/apple/swift/RadRootsAppleSecurity"); + + emit_rerun_paths(&package_dir); + + let configuration = if env::var("PROFILE").ok().as_deref() == Some("release") { + "release" + } else { + "debug" + }; + let arch = env::var("CARGO_CFG_TARGET_ARCH").expect("target arch"); + + run_swift_build(&package_dir, configuration, &arch); + let bin_path = swift_bin_path(&package_dir, configuration, &arch); + + let dylib_path = bin_path.join("libRadRootsAppleSecurityFFIDynamic.dylib"); + if !dylib_path.is_file() { + panic!( + "swift package did not produce expected dynamic library at {}", + dylib_path.display() + ); + } + + println!("cargo:rustc-link-search=native={}", bin_path.display()); + println!("cargo:rustc-link-lib=dylib=RadRootsAppleSecurityFFIDynamic"); + println!("cargo:rustc-link-lib=framework=Foundation"); + println!("cargo:rustc-link-lib=framework=Security"); + println!("cargo:rustc-link-lib=framework=LocalAuthentication"); + println!("cargo:rustc-link-arg=-Wl,-rpath,{}", bin_path.display()); +} + +fn emit_rerun_paths(package_dir: &Path) { + println!( + "cargo:rerun-if-changed={}", + package_dir.join("Package.swift").display() + ); + emit_rerun_dir(&package_dir.join("Sources")); +} + +fn emit_rerun_dir(dir: &Path) { + if !dir.is_dir() { + return; + } + + let mut entries = std::fs::read_dir(dir) + .unwrap_or_else(|err| panic!("failed to read {}: {err}", dir.display())) + .map(|entry| entry.unwrap().path()) + .collect::<Vec<_>>(); + entries.sort(); + + for path in entries { + if path.is_dir() { + emit_rerun_dir(&path); + } else { + println!("cargo:rerun-if-changed={}", path.display()); + } + } +} + +fn run_swift_build(package_dir: &Path, configuration: &str, arch: &str) { + let status = Command::new("swift") + .arg("build") + .arg("--package-path") + .arg(package_dir) + .arg("--product") + .arg("RadRootsAppleSecurityFFIDynamic") + .arg("--configuration") + .arg(configuration) + .arg("--arch") + .arg(arch) + .status() + .unwrap_or_else(|err| panic!("failed to run swift build: {err}")); + + if !status.success() { + panic!("swift build failed for RadRootsAppleSecurityFFIDynamic"); + } +} + +fn swift_bin_path(package_dir: &Path, configuration: &str, arch: &str) -> PathBuf { + let output = Command::new("swift") + .arg("build") + .arg("--package-path") + .arg(package_dir) + .arg("--product") + .arg("RadRootsAppleSecurityFFIDynamic") + .arg("--configuration") + .arg(configuration) + .arg("--arch") + .arg(arch) + .arg("--show-bin-path") + .output() + .unwrap_or_else(|err| panic!("failed to resolve swift bin path: {err}")); + + if !output.status.success() { + panic!( + "swift build --show-bin-path failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + PathBuf::from( + String::from_utf8(output.stdout) + .expect("swift bin path utf-8") + .trim(), + ) +} diff --git a/crates/desktop/src/main.rs b/crates/desktop/src/main.rs @@ -3,13 +3,14 @@ use directories::BaseDirs; use eframe::egui; +#[cfg(target_os = "macos")] +use radroots_app_apple_security::{APPLE_NOSTR_SERVICE, RadrootsAppleKeychainVault}; use radroots_app_core::{ APP_NAME, IdentityGateState, RadrootsApp, RadrootsAppBackend, SetupActionState, }; #[cfg(target_os = "macos")] use radroots_nostr_accounts::prelude::{ - RadrootsNostrAccountsManager, RadrootsNostrFileAccountStore, RadrootsNostrSecretVaultOsKeyring, - RadrootsNostrSelectedAccountStatus, + RadrootsNostrAccountsManager, RadrootsNostrFileAccountStore, RadrootsNostrSelectedAccountStatus, }; #[cfg(target_os = "macos")] use std::path::{Path, PathBuf}; @@ -80,9 +81,7 @@ impl DesktopBackend { } let store = Arc::new(RadrootsNostrFileAccountStore::new(accounts_path)); - let vault = Arc::new(RadrootsNostrSecretVaultOsKeyring::new( - "org.radroots.app.nostr", - )); + let vault = Arc::new(RadrootsAppleKeychainVault::new(APPLE_NOSTR_SERVICE)); RadrootsNostrAccountsManager::new(store, vault).map_err(|source| source.to_string()) } @@ -179,6 +178,9 @@ fn main() -> eframe::Result<()> { #[cfg(all(test, target_os = "macos"))] mod tests { use super::DesktopBackend; + use radroots_app_apple_security::RadrootsAppleKeychainVault; + use radroots_identity::RadrootsIdentityId; + use radroots_nostr_accounts::prelude::RadrootsNostrSecretVault; use std::path::PathBuf; #[test] @@ -198,4 +200,33 @@ mod tests { ] ); } + + #[test] + fn apple_keychain_vault_round_trips_secret_hex() { + let vault = RadrootsAppleKeychainVault::new("org.radroots.app.tests.desktop.roundtrip"); + let account_id = RadrootsIdentityId::parse( + "3bf0c63f0f4478a288f6b67f0429dbf7f5119d4fa7218a4c40ef1378f80f7606", + ) + .expect("account id"); + + let _ = vault.remove_secret(&account_id); + + vault + .store_secret_hex( + &account_id, + "a0468b0f2f5de9db868fb563b13632eb92ec4697dd4fddbdca0488f1a1b2c3d4", + ) + .expect("store secret"); + + assert_eq!( + vault.load_secret_hex(&account_id).expect("load secret"), + Some("a0468b0f2f5de9db868fb563b13632eb92ec4697dd4fddbdca0488f1a1b2c3d4".to_owned()) + ); + + vault.remove_secret(&account_id).expect("remove secret"); + assert_eq!( + vault.load_secret_hex(&account_id).expect("load missing"), + None + ); + } } diff --git a/crates/ios/Cargo.toml b/crates/ios/Cargo.toml @@ -16,6 +16,7 @@ crate-type = ["staticlib", "rlib"] [dependencies] eframe.workspace = true +radroots-app-apple-security.workspace = true radroots-app-core = { path = "../core" } radroots-identity.workspace = true radroots-nostr-accounts.workspace = true diff --git a/crates/ios/src/lib.rs b/crates/ios/src/lib.rs @@ -12,11 +12,7 @@ use radroots_nostr_accounts::prelude::{ }; #[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(any(target_os = "ios", test))] struct IosBackend; diff --git a/crates/ios/src/security.rs b/crates/ios/src/security.rs @@ -1,368 +0,0 @@ -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 @@ -1,7 +1,5 @@ #[cfg(target_os = "ios")] -use crate::security::APPLE_NOSTR_SERVICE; -#[cfg(target_os = "ios")] -use crate::vault::IosAppleKeychainVault; +use radroots_app_apple_security::{APPLE_NOSTR_SERVICE, RadrootsAppleKeychainVault}; #[cfg(target_os = "ios")] use radroots_nostr_accounts::prelude::{ RadrootsNostrAccountsManager, RadrootsNostrFileAccountStore, @@ -26,7 +24,7 @@ pub(crate) fn accounts_path() -> Result<PathBuf, String> { #[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)); + let vault = Arc::new(RadrootsAppleKeychainVault::new(APPLE_NOSTR_SERVICE)); RadrootsNostrAccountsManager::new(store, vault).map_err(|source| source.to_string()) } diff --git a/crates/ios/src/vault.rs b/crates/ios/src/vault.rs @@ -1,118 +0,0 @@ -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:")); - } -} diff --git a/native/apple/swift/RadRootsAppleSecurity/Package.swift b/native/apple/swift/RadRootsAppleSecurity/Package.swift @@ -14,6 +14,12 @@ let package = Package( ), .library( name: "RadRootsAppleSecurityFFI", + type: .static, + targets: ["RadRootsAppleSecurityFFI"] + ), + .library( + name: "RadRootsAppleSecurityFFIDynamic", + type: .dynamic, targets: ["RadRootsAppleSecurityFFI"] ) ],