app

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

commit 6ce588675cbbf11a8c137fcf7b711076f56cae5f
parent 580651c5218e9d19f5efd83d68da06baa142c679
Author: triesap <tyson@radroots.org>
Date:   Sat, 21 Mar 2026 18:38:15 +0000

desktop: require auth before revealing recovery key

- add an Apple user-presence FFI entrypoint on top of the shared LocalAuthentication wrapper
- require native user authentication before the desktop backup action reveals the current nsec
- embed a macos Info.plist into the desktop binary so Apple prompts show Rad Roots as the app name
- tighten the recovery-key prompt reason so the system modal reads cleanly with a single period

Diffstat:
Mcrates/apple/security/src/lib.rs | 2+-
Mcrates/apple/security/src/security.rs | 41+++++++++++++++++++++++++++++++++++++++++
Mcrates/desktop/build.rs | 6++++++
Acrates/desktop/macos/Info.plist | 16++++++++++++++++
Mcrates/desktop/src/main.rs | 7++++++-
Mnative/apple/swift/RadRootsAppleSecurity/Sources/RadRootsAppleSecurity/RadRootsAppleUserPresence.swift | 38++++++++++++++++++++++++++++++++++++++
Mnative/apple/swift/RadRootsAppleSecurity/Sources/RadRootsAppleSecurityFFI/RadRootsAppleSecurityFFI.swift | 25+++++++++++++++++++++++++
7 files changed, 133 insertions(+), 2 deletions(-)

diff --git a/crates/apple/security/src/lib.rs b/crates/apple/security/src/lib.rs @@ -3,5 +3,5 @@ mod security; mod vault; -pub use security::{APPLE_NOSTR_NAMESPACE, APPLE_NOSTR_SERVICE}; +pub use security::{APPLE_NOSTR_NAMESPACE, APPLE_NOSTR_SERVICE, verify_user_presence}; pub use vault::RadrootsAppleKeychainVault; diff --git a/crates/apple/security/src/security.rs b/crates/apple/security/src/security.rs @@ -65,6 +65,11 @@ unsafe extern "C" { error_out: *mut *mut c_char, ) -> i32; + fn radroots_apple_user_presence_verify( + reason: *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); } @@ -319,6 +324,42 @@ pub fn remove_secret( } } +pub fn verify_user_presence(reason: &str) -> Result<(), RadrootsNostrAccountsError> { + #[cfg(any(target_os = "ios", target_os = "macos"))] + { + let reason = c_string(reason)?; + let mut ffi_error = FfiErrorString::new(); + let status = unsafe { + // SAFETY: the reason pointer is derived from a live CString and the error output + // references live local storage for the duration of the call. + radroots_apple_user_presence_verify(reason.as_ptr(), 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 user presence verification", + )), + AppleSecretStatus::InvalidInput => Err(vault_error( + ffi_error, + "apple security ffi rejected the user presence request", + )), + AppleSecretStatus::Error => Err(vault_error( + ffi_error, + "apple user presence verification failed", + )), + }; + } + + #[cfg(not(any(target_os = "ios", target_os = "macos")))] + { + let _ = reason; + Err(RadrootsNostrAccountsError::Vault( + "apple user presence verification is only available on ios and macos".to_owned(), + )) + } +} + fn c_string(value: &str) -> Result<CString, RadrootsNostrAccountsError> { CString::new(value).map_err(|_| { RadrootsNostrAccountsError::Vault( diff --git a/crates/desktop/build.rs b/crates/desktop/build.rs @@ -11,8 +11,10 @@ fn main() { let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").expect("manifest dir")); let package_dir = manifest_dir.join("../../native/apple/swift/RadRootsAppleSecurity"); + let info_plist_path = manifest_dir.join("macos/Info.plist"); emit_rerun_paths(&package_dir); + println!("cargo:rerun-if-changed={}", info_plist_path.display()); let configuration = if env::var("PROFILE").ok().as_deref() == Some("release") { "release" @@ -38,6 +40,10 @@ fn main() { println!("cargo:rustc-link-lib=framework=Security"); println!("cargo:rustc-link-lib=framework=LocalAuthentication"); println!("cargo:rustc-link-arg=-Wl,-rpath,{}", bin_path.display()); + println!( + "cargo:rustc-link-arg-bin=radroots-app-desktop=-Wl,-sectcreate,__TEXT,__info_plist,{}", + info_plist_path.display() + ); } fn emit_rerun_paths(package_dir: &Path) { diff --git a/crates/desktop/macos/Info.plist b/crates/desktop/macos/Info.plist @@ -0,0 +1,16 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>CFBundleIdentifier</key> + <string>org.radroots.app.desktop</string> + <key>CFBundleName</key> + <string>Rad Roots</string> + <key>CFBundleDisplayName</key> + <string>Rad Roots</string> + <key>CFBundlePackageType</key> + <string>APPL</string> + <key>CFBundleInfoDictionaryVersion</key> + <string>6.0</string> +</dict> +</plist> diff --git a/crates/desktop/src/main.rs b/crates/desktop/src/main.rs @@ -5,7 +5,9 @@ use directories::BaseDirs; use eframe::egui; use image::ImageFormat; #[cfg(target_os = "macos")] -use radroots_app_apple_security::{APPLE_NOSTR_SERVICE, RadrootsAppleKeychainVault}; +use radroots_app_apple_security::{ + APPLE_NOSTR_SERVICE, RadrootsAppleKeychainVault, verify_user_presence, +}; use radroots_app_core::{ APP_NAME, HomeActionKind, HomeActionResult, HomeActionState, IdentityGateState, RadrootsApp, RadrootsAppBackend, SetupActionState, @@ -146,6 +148,9 @@ impl DesktopBackend { fn export_selected_local_recovery_key( manager: &RadrootsNostrAccountsManager, ) -> Result<String, String> { + verify_user_presence("reveal the current recovery key") + .map_err(|source| source.to_string())?; + let Some(account_id) = manager .selected_account_id() .map_err(|source| source.to_string())? diff --git a/native/apple/swift/RadRootsAppleSecurity/Sources/RadRootsAppleSecurity/RadRootsAppleUserPresence.swift b/native/apple/swift/RadRootsAppleSecurity/Sources/RadRootsAppleSecurity/RadRootsAppleUserPresence.swift @@ -45,6 +45,44 @@ public struct RadRootsAppleUserPresenceStatus: Sendable { public actor RadRootsAppleUserPresence { public init() {} + public static func verifySync( + reason: String, + policy: RadRootsAppleUserPresencePolicy = .deviceOwnerAuthentication + ) throws -> Bool { + #if canImport(LocalAuthentication) + let context = LAContext() + let lock = NSLock() + let semaphore = DispatchSemaphore(value: 0) + var result: Result<Bool, Error>? + + context.evaluatePolicy( + Self.makePolicy(policy), + localizedReason: reason + ) { success, error in + lock.lock() + if let error { + result = .failure(Self.adapt(error: error)) + } else { + result = .success(success) + } + lock.unlock() + semaphore.signal() + } + + semaphore.wait() + + lock.lock() + defer { lock.unlock() } + return try result?.get() ?? { + throw RadRootsAppleSecurityError.transientFailure( + "local authentication did not return a result" + ) + }() + #else + throw RadRootsAppleSecurityError.unavailable("local authentication is unavailable") + #endif + } + public func currentStatus() -> RadRootsAppleUserPresenceStatus { #if canImport(LocalAuthentication) let context = LAContext() diff --git a/native/apple/swift/RadRootsAppleSecurity/Sources/RadRootsAppleSecurityFFI/RadRootsAppleSecurityFFI.swift b/native/apple/swift/RadRootsAppleSecurity/Sources/RadRootsAppleSecurityFFI/RadRootsAppleSecurityFFI.swift @@ -114,6 +114,31 @@ public func radroots_apple_secret_store_delete( } } +@_cdecl("radroots_apple_user_presence_verify") +public func radroots_apple_user_presence_verify( + _ reason: UnsafePointer<CChar>?, + _ errorOut: UnsafeMutablePointer<UnsafeMutablePointer<CChar>?>? +) -> Int32 { + do { + guard let reasonPointer = reason else { + throw RadRootsAppleSecurityError.invalidRequest("verification reason is required") + } + let reason = String(cString: reasonPointer) + guard !reason.isEmpty else { + throw RadRootsAppleSecurityError.invalidRequest("verification reason cannot be empty") + } + guard try RadRootsAppleUserPresence.verifySync(reason: reason) else { + throw RadRootsAppleSecurityError.permissionDenied( + "local authentication did not authorize access" + ) + } + return RadRootsAppleFFIStatus.success.rawValue + } catch { + setError(error, into: errorOut) + return statusForError(error) + } +} + @_cdecl("radroots_apple_buffer_free") public func radroots_apple_buffer_free( _ buffer: UnsafeMutablePointer<UInt8>?,