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:
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"]
)
],