solana_keyring_biometric/
lib.rs

1//! Biometric authentication library for macOS TouchID
2//!
3//! This crate provides biometric authentication capabilities using macOS TouchID
4//! via the LocalAuthentication framework. On non-macOS platforms, authentication
5//! is a no-op that always succeeds.
6//!
7//! # Example
8//!
9//! ```no_run
10//! use solana_keyring_biometric::{is_available, authenticate, AuthResult};
11//!
12//! // Check if biometric authentication is available
13//! if is_available() {
14//!     // Request authentication with a reason
15//!     match authenticate("Confirm your identity") {
16//!         Ok(AuthResult::Authenticated) => println!("Success!"),
17//!         Ok(AuthResult::Denied) => println!("Authentication denied"),
18//!         Ok(AuthResult::NotAvailable) => println!("Biometrics not available"),
19//!         Err(e) => eprintln!("Error: {}", e),
20//!     }
21//! }
22//! ```
23
24use std::fmt;
25
26/// Errors that can occur during biometric authentication
27#[derive(Debug, thiserror::Error)]
28pub enum Error {
29    /// Swift execution failed
30    #[error("Swift execution failed: {0}")]
31    SwiftExecution(String),
32
33    /// IO error during command execution
34    #[error("IO error: {0}")]
35    Io(#[from] std::io::Error),
36
37    /// Invalid response from authentication
38    #[error("Invalid authentication response: {0}")]
39    InvalidResponse(String),
40}
41
42/// Result type for biometric operations
43pub type Result<T> = std::result::Result<T, Error>;
44
45/// Result of an authentication attempt
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum AuthResult {
48    /// User successfully authenticated
49    Authenticated,
50    /// User denied/cancelled authentication
51    Denied,
52    /// Biometric authentication is not available on this system
53    NotAvailable,
54}
55
56impl fmt::Display for AuthResult {
57    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
58        match self {
59            AuthResult::Authenticated => write!(f, "authenticated"),
60            AuthResult::Denied => write!(f, "denied"),
61            AuthResult::NotAvailable => write!(f, "not available"),
62        }
63    }
64}
65
66/// Configuration for biometric authentication
67#[derive(Debug, Clone)]
68pub struct BiometricConfig {
69    /// Whether to allow fallback to device passcode
70    pub allow_passcode_fallback: bool,
71}
72
73impl Default for BiometricConfig {
74    fn default() -> Self {
75        Self {
76            allow_passcode_fallback: true,
77        }
78    }
79}
80
81/// Path to the swift binary
82const SWIFT_PATH: &str = "/usr/bin/swift";
83
84/// Execute a swift command with clean environment
85#[cfg(target_os = "macos")]
86fn run_swift(code: &str) -> Result<std::process::Output> {
87    use std::process::Command;
88
89    Command::new(SWIFT_PATH)
90        .arg("-e")
91        .arg(code)
92        // Clear these env vars to prevent nix/devenv from redirecting swift
93        // to an incompatible SDK
94        .env_remove("DEVELOPER_DIR")
95        .env_remove("SDKROOT")
96        .output()
97        .map_err(Error::Io)
98}
99
100/// Check if biometric authentication is available on this system
101///
102/// On macOS, this checks if TouchID hardware is present and configured.
103/// On other platforms, this always returns `false`.
104///
105/// # Example
106///
107/// ```no_run
108/// use solana_keyring_biometric::is_available;
109///
110/// if is_available() {
111///     println!("TouchID is available!");
112/// } else {
113///     println!("TouchID is not available");
114/// }
115/// ```
116#[cfg(target_os = "macos")]
117pub fn is_available() -> bool {
118    let swift_code = r#"
119import LocalAuthentication
120let context = LAContext()
121var error: NSError?
122let available = context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
123print(available ? "yes" : "no")
124"#;
125
126    run_swift(swift_code)
127        .map(|o| {
128            String::from_utf8_lossy(&o.stdout)
129                .trim()
130                .eq_ignore_ascii_case("yes")
131        })
132        .unwrap_or(false)
133}
134
135#[cfg(not(target_os = "macos"))]
136pub fn is_available() -> bool {
137    false
138}
139
140/// Check if device passcode authentication is available
141///
142/// This checks if the device has a passcode set, which can be used as a
143/// fallback when biometric authentication is not available.
144#[cfg(target_os = "macos")]
145pub fn is_passcode_available() -> bool {
146    let swift_code = r#"
147import LocalAuthentication
148let context = LAContext()
149var error: NSError?
150let available = context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error)
151print(available ? "yes" : "no")
152"#;
153
154    run_swift(swift_code)
155        .map(|o| {
156            String::from_utf8_lossy(&o.stdout)
157                .trim()
158                .eq_ignore_ascii_case("yes")
159        })
160        .unwrap_or(false)
161}
162
163#[cfg(not(target_os = "macos"))]
164pub fn is_passcode_available() -> bool {
165    false
166}
167
168/// Escape a string for use in Swift code
169fn escape_swift_string(s: &str) -> String {
170    s.replace('\\', "\\\\")
171        .replace('"', "\\\"")
172        .replace('\n', "\\n")
173        .replace('\r', "\\r")
174        .replace('\t', "\\t")
175}
176
177/// Request biometric authentication with a reason message
178///
179/// On macOS, this triggers the TouchID prompt with the given reason.
180/// If TouchID is not available but passcode is, it will fall back to passcode.
181/// On other platforms, this always returns `Ok(AuthResult::Authenticated)`.
182///
183/// # Arguments
184///
185/// * `reason` - The reason shown to the user explaining why authentication is needed
186///
187/// # Errors
188///
189/// Returns an error if:
190/// - The Swift runtime fails to execute
191/// - An IO error occurs during command execution
192/// - The authentication response is invalid or unexpected
193///
194/// # Example
195///
196/// ```no_run
197/// use solana_keyring_biometric::{authenticate, AuthResult};
198///
199/// match authenticate("Sign transaction with wallet 'main'") {
200///     Ok(AuthResult::Authenticated) => println!("User authenticated!"),
201///     Ok(AuthResult::Denied) => println!("User cancelled"),
202///     Ok(AuthResult::NotAvailable) => println!("No authentication available"),
203///     Err(e) => eprintln!("Error: {}", e),
204/// }
205/// ```
206#[cfg(target_os = "macos")]
207pub fn authenticate(reason: &str) -> Result<AuthResult> {
208    authenticate_with_config(reason, &BiometricConfig::default())
209}
210
211#[cfg(not(target_os = "macos"))]
212pub fn authenticate(_reason: &str) -> Result<AuthResult> {
213    // On non-macOS platforms, always succeed
214    Ok(AuthResult::Authenticated)
215}
216
217/// Request biometric authentication with custom configuration
218///
219/// # Arguments
220///
221/// * `reason` - The reason shown to the user
222/// * `config` - Configuration options for the authentication
223///
224/// # Errors
225///
226/// Returns an error if:
227/// - The Swift runtime fails to execute
228/// - An IO error occurs during command execution
229/// - The authentication response is invalid or unexpected
230#[cfg(target_os = "macos")]
231pub fn authenticate_with_config(reason: &str, config: &BiometricConfig) -> Result<AuthResult> {
232    let escaped_reason = escape_swift_string(reason);
233
234    let fallback_code = if config.allow_passcode_fallback {
235        format!(
236            r#"
237    // Fall back to device passcode if biometrics not available
238    guard context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) else {{
239        print("not_available")
240        exit(0)
241    }}
242    let semaphore = DispatchSemaphore(value: 0)
243    var success = false
244    context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: "{reason}") {{ result, authError in
245        success = result
246        semaphore.signal()
247    }}
248    semaphore.wait()
249    print(success ? "authenticated" : "denied")
250    exit(0)
251"#,
252            reason = escaped_reason
253        )
254    } else {
255        r#"
256    print("not_available")
257    exit(0)
258"#
259        .to_string()
260    };
261
262    let swift_code = format!(
263        r#"
264import Foundation
265import LocalAuthentication
266
267let context = LAContext()
268var error: NSError?
269
270guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else {{
271{fallback_code}
272}}
273
274let semaphore = DispatchSemaphore(value: 0)
275var success = false
276
277context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: "{reason}") {{ result, authError in
278    success = result
279    semaphore.signal()
280}}
281
282semaphore.wait()
283print(success ? "authenticated" : "denied")
284"#,
285        reason = escaped_reason,
286        fallback_code = fallback_code
287    );
288
289    let output = run_swift(&swift_code)?;
290    let stdout = String::from_utf8_lossy(&output.stdout);
291    let result = stdout.trim();
292
293    match result {
294        "authenticated" => Ok(AuthResult::Authenticated),
295        "denied" => Ok(AuthResult::Denied),
296        "not_available" => Ok(AuthResult::NotAvailable),
297        _ => {
298            // Check stderr for additional error info
299            let stderr = String::from_utf8_lossy(&output.stderr);
300            if !stderr.is_empty() {
301                Err(Error::SwiftExecution(stderr.to_string()))
302            } else if output.status.success() {
303                // Unknown output but success status
304                Ok(AuthResult::Authenticated)
305            } else {
306                Err(Error::InvalidResponse(format!(
307                    "Unexpected response: '{}', exit code: {:?}",
308                    result,
309                    output.status.code()
310                )))
311            }
312        }
313    }
314}
315
316#[cfg(not(target_os = "macos"))]
317pub fn authenticate_with_config(_reason: &str, _config: &BiometricConfig) -> Result<AuthResult> {
318    Ok(AuthResult::Authenticated)
319}
320
321/// Request confirmation for a signing operation
322///
323/// This is a convenience function that formats a reason message for
324/// transaction signing and requests biometric authentication.
325///
326/// # Arguments
327///
328/// * `signer_label` - Label of the wallet/signer being used
329/// * `transaction_summary` - Human-readable summary of the transaction
330///
331/// # Errors
332///
333/// Returns an error if the underlying authentication fails. See [`authenticate`]
334/// for details on possible error conditions.
335///
336/// # Example
337///
338/// ```no_run
339/// use solana_keyring_biometric::{confirm_signing, AuthResult};
340///
341/// let result = confirm_signing("main-wallet", "Transfer 1.5 SOL to ABC...")?;
342/// match result {
343///     AuthResult::Authenticated => println!("User approved signing"),
344///     AuthResult::Denied => println!("User rejected"),
345///     AuthResult::NotAvailable => println!("No auth available"),
346/// }
347/// # Ok::<(), solana_keyring_biometric::Error>(())
348/// ```
349pub fn confirm_signing(signer_label: &str, transaction_summary: &str) -> Result<AuthResult> {
350    let reason = format!(
351        "Sign transaction with '{}':\n{}",
352        signer_label, transaction_summary
353    );
354    authenticate(&reason)
355}
356
357/// Request confirmation with custom configuration.
358///
359/// # Errors
360///
361/// Returns an error if the underlying authentication fails. See [`authenticate_with_config`]
362/// for details on possible error conditions.
363pub fn confirm_signing_with_config(
364    signer_label: &str,
365    transaction_summary: &str,
366    config: &BiometricConfig,
367) -> Result<AuthResult> {
368    let reason = format!(
369        "Sign transaction with '{}':\n{}",
370        signer_label, transaction_summary
371    );
372    authenticate_with_config(&reason, config)
373}
374
375#[cfg(test)]
376mod tests {
377    use super::*;
378
379    #[test]
380    fn test_escape_swift_string_empty() {
381        assert_eq!(escape_swift_string(""), "");
382    }
383
384    #[test]
385    fn test_escape_swift_string_simple() {
386        assert_eq!(escape_swift_string("hello world"), "hello world");
387    }
388
389    #[test]
390    fn test_escape_swift_string_quotes() {
391        assert_eq!(escape_swift_string(r#"say "hello""#), r#"say \"hello\""#);
392    }
393
394    #[test]
395    fn test_escape_swift_string_backslash() {
396        assert_eq!(escape_swift_string(r"path\to\file"), r"path\\to\\file");
397    }
398
399    #[test]
400    fn test_escape_swift_string_newlines() {
401        assert_eq!(escape_swift_string("line1\nline2"), "line1\\nline2");
402    }
403
404    #[test]
405    fn test_escape_swift_string_tabs() {
406        assert_eq!(escape_swift_string("col1\tcol2"), "col1\\tcol2");
407    }
408
409    #[test]
410    fn test_escape_swift_string_carriage_return() {
411        assert_eq!(escape_swift_string("line1\r\nline2"), "line1\\r\\nline2");
412    }
413
414    #[test]
415    fn test_escape_swift_string_complex() {
416        let input = "Sign \"transfer\" to:\n  Address: ABC\\DEF";
417        let expected = "Sign \\\"transfer\\\" to:\\n  Address: ABC\\\\DEF";
418        assert_eq!(escape_swift_string(input), expected);
419    }
420
421    #[test]
422    fn test_auth_result_display() {
423        assert_eq!(AuthResult::Authenticated.to_string(), "authenticated");
424        assert_eq!(AuthResult::Denied.to_string(), "denied");
425        assert_eq!(AuthResult::NotAvailable.to_string(), "not available");
426    }
427
428    #[test]
429    fn test_auth_result_equality() {
430        assert_eq!(AuthResult::Authenticated, AuthResult::Authenticated);
431        assert_eq!(AuthResult::Denied, AuthResult::Denied);
432        assert_eq!(AuthResult::NotAvailable, AuthResult::NotAvailable);
433        assert_ne!(AuthResult::Authenticated, AuthResult::Denied);
434    }
435
436    #[test]
437    fn test_biometric_config_default() {
438        let config = BiometricConfig::default();
439        assert!(config.allow_passcode_fallback);
440    }
441
442    #[test]
443    fn test_error_display() {
444        let err = Error::SwiftExecution("test error".to_string());
445        assert!(err.to_string().contains("Swift execution failed"));
446        assert!(err.to_string().contains("test error"));
447    }
448
449    #[test]
450    fn test_error_invalid_response() {
451        let err = Error::InvalidResponse("bad response".to_string());
452        assert!(err.to_string().contains("Invalid authentication response"));
453    }
454
455    // Platform-specific tests
456    #[cfg(target_os = "macos")]
457    mod macos_tests {
458        use super::*;
459
460        #[test]
461        fn test_is_available_returns_bool() {
462            // Just verify it returns without panicking
463            let _ = is_available();
464        }
465
466        #[test]
467        fn test_is_passcode_available_returns_bool() {
468            let _ = is_passcode_available();
469        }
470
471        #[test]
472        fn test_swift_path_exists() {
473            use std::path::Path;
474            assert!(
475                Path::new(SWIFT_PATH).exists(),
476                "Swift binary should exist at {}",
477                SWIFT_PATH
478            );
479        }
480
481        #[test]
482        fn test_run_swift_simple() {
483            let result = run_swift(r#"print("hello")"#);
484            assert!(result.is_ok(), "Swift should execute successfully");
485            let output = result.unwrap();
486            assert!(output.status.success());
487            assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "hello");
488        }
489
490        #[test]
491        fn test_run_swift_with_import() {
492            let result = run_swift(
493                r#"
494import Foundation
495print("foundation works")
496"#,
497            );
498            assert!(result.is_ok());
499            let output = result.unwrap();
500            assert!(output.status.success());
501        }
502
503        #[test]
504        fn test_run_swift_local_authentication_import() {
505            // Test that LocalAuthentication framework can be imported
506            let result = run_swift(
507                r#"
508import LocalAuthentication
509print("la imported")
510"#,
511            );
512            assert!(
513                result.is_ok(),
514                "Should be able to import LocalAuthentication"
515            );
516            let output = result.unwrap();
517            assert!(output.status.success());
518        }
519
520        #[test]
521        fn test_run_swift_la_context_creation() {
522            // Test that we can create an LAContext
523            let result = run_swift(
524                r#"
525import LocalAuthentication
526let context = LAContext()
527print("context created")
528"#,
529            );
530            assert!(result.is_ok());
531            let output = result.unwrap();
532            assert!(output.status.success());
533            assert_eq!(
534                String::from_utf8_lossy(&output.stdout).trim(),
535                "context created"
536            );
537        }
538
539        #[test]
540        fn test_run_swift_can_evaluate_policy() {
541            // Test the canEvaluatePolicy call
542            let result = run_swift(
543                r#"
544import LocalAuthentication
545let context = LAContext()
546var error: NSError?
547let biometrics = context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
548let passcode = context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error)
549print("biometrics: \(biometrics), passcode: \(passcode)")
550"#,
551            );
552            assert!(result.is_ok());
553            let output = result.unwrap();
554            assert!(output.status.success());
555            let stdout = String::from_utf8_lossy(&output.stdout);
556            assert!(stdout.contains("biometrics:"));
557            assert!(stdout.contains("passcode:"));
558        }
559    }
560
561    #[cfg(not(target_os = "macos"))]
562    mod non_macos_tests {
563        use super::*;
564
565        #[test]
566        fn test_is_available_returns_false() {
567            assert!(!is_available());
568        }
569
570        #[test]
571        fn test_is_passcode_available_returns_false() {
572            assert!(!is_passcode_available());
573        }
574
575        #[test]
576        fn test_authenticate_returns_authenticated() {
577            let result = authenticate("test reason");
578            assert!(result.is_ok());
579            assert_eq!(result.unwrap(), AuthResult::Authenticated);
580        }
581
582        #[test]
583        fn test_authenticate_with_config_returns_authenticated() {
584            let config = BiometricConfig::default();
585            let result = authenticate_with_config("test reason", &config);
586            assert!(result.is_ok());
587            assert_eq!(result.unwrap(), AuthResult::Authenticated);
588        }
589
590        #[test]
591        fn test_confirm_signing_returns_authenticated() {
592            let result = confirm_signing("wallet", "summary");
593            assert!(result.is_ok());
594            assert_eq!(result.unwrap(), AuthResult::Authenticated);
595        }
596    }
597}