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:
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>?,