solana_keyring_biometric/
lib.rs1use std::fmt;
25
26#[derive(Debug, thiserror::Error)]
28pub enum Error {
29 #[error("Swift execution failed: {0}")]
31 SwiftExecution(String),
32
33 #[error("IO error: {0}")]
35 Io(#[from] std::io::Error),
36
37 #[error("Invalid authentication response: {0}")]
39 InvalidResponse(String),
40}
41
42pub type Result<T> = std::result::Result<T, Error>;
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum AuthResult {
48 Authenticated,
50 Denied,
52 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#[derive(Debug, Clone)]
68pub struct BiometricConfig {
69 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
81const SWIFT_PATH: &str = "/usr/bin/swift";
83
84#[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 .env_remove("DEVELOPER_DIR")
95 .env_remove("SDKROOT")
96 .output()
97 .map_err(Error::Io)
98}
99
100#[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#[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
168fn 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#[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 Ok(AuthResult::Authenticated)
215}
216
217#[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 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 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
321pub 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
357pub 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 #[cfg(target_os = "macos")]
457 mod macos_tests {
458 use super::*;
459
460 #[test]
461 fn test_is_available_returns_bool() {
462 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 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 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 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}