1pub mod blob;
8#[cfg(target_os = "macos")]
9pub mod keychain;
10
11use std::fmt;
12use std::str::FromStr;
13
14#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
16pub enum Gate {
17 #[default]
19 Off,
20 Signing,
22 All,
24}
25
26impl FromStr for Gate {
27 type Err = String;
28
29 fn from_str(s: &str) -> Result<Self, Self::Err> {
30 match s {
31 "off" | "false" => Ok(Self::Off),
32 "signing" => Ok(Self::Signing),
33 "all" | "true" => Ok(Self::All),
34 other => Err(format!(
35 "invalid touchid_gate value {other:?} (expected \
36 off/signing/all)"
37 )),
38 }
39 }
40}
41
42impl fmt::Display for Gate {
43 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
44 f.write_str(match self {
45 Self::Off => "off",
46 Self::Signing => "signing",
47 Self::All => "all",
48 })
49 }
50}
51
52#[derive(Copy, Clone, Debug, PartialEq, Eq)]
55pub enum Kind {
56 SshSign,
58 TotpCode,
60 VaultSecret,
63}
64
65#[must_use]
66pub fn gate_applies(gate: Gate, kind: Kind) -> bool {
67 match gate {
68 Gate::Off => false,
69 Gate::Signing => matches!(kind, Kind::SshSign | Kind::TotpCode),
70 Gate::All => true,
71 }
72}
73
74#[cfg(target_os = "macos")]
79pub async fn require_presence(reason: &str) -> Result<bool, Error> {
80 macos::require_presence(reason).await
81}
82
83#[cfg(not(target_os = "macos"))]
84#[allow(clippy::unused_async)]
85pub async fn require_presence(_reason: &str) -> Result<bool, Error> {
86 Ok(true)
87}
88
89#[derive(Debug)]
90pub enum Error {
91 Unavailable(String),
94 Os(String),
96}
97
98impl fmt::Display for Error {
99 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
100 match self {
101 Self::Unavailable(s) => {
102 write!(f, "biometry unavailable: {s}")
103 }
104 Self::Os(s) => write!(f, "LocalAuthentication error: {s}"),
105 }
106 }
107}
108
109impl std::error::Error for Error {}
110
111#[cfg(target_os = "macos")]
112mod macos {
113 use block2::RcBlock;
114 use objc2::rc::Retained;
115 use objc2::runtime::Bool;
116 use objc2_foundation::{NSError, NSString};
117 use objc2_local_authentication::{LAContext, LAPolicy};
118
119 use super::Error;
120
121 fn debug_bypass() -> Option<bool> {
125 if !cfg!(debug_assertions) {
126 return None;
127 }
128 match std::env::var("BWX_TOUCHID_TEST_BYPASS").ok().as_deref() {
129 Some("allow") => Some(true),
130 Some("deny") => Some(false),
131 _ => None,
132 }
133 }
134
135 fn begin_presence_check(
140 reason: &str,
141 ) -> Result<tokio::sync::oneshot::Receiver<Result<bool, Error>>, Error>
142 {
143 let ctx: Retained<LAContext> = unsafe { LAContext::new() };
145 let policy = LAPolicy::DeviceOwnerAuthenticationWithBiometrics;
146
147 if let Err(err) = unsafe { ctx.canEvaluatePolicy_error(policy) } {
148 return Err(Error::Unavailable(
149 err.localizedDescription().to_string(),
150 ));
151 }
152
153 let (tx, rx) = tokio::sync::oneshot::channel::<Result<bool, Error>>();
154 let tx = std::sync::Mutex::new(Some(tx));
155 let block = RcBlock::new(move |success: Bool, err: *mut NSError| {
156 let claimed = tx.lock().unwrap().take();
157 if let Some(tx) = claimed {
158 let res = if success.as_bool() {
159 Ok(true)
160 } else if err.is_null() {
161 Ok(false)
162 } else {
163 let desc =
166 unsafe { (*err).localizedDescription().to_string() };
167 let code = unsafe { (*err).code() };
168 if code == -2 || code == -4 {
169 Ok(false)
171 } else {
172 Err(Error::Os(format!("code={code}: {desc}")))
173 }
174 };
175 let _ = tx.send(res);
176 }
177 });
178
179 let reason_ns = NSString::from_str(reason);
180 unsafe {
181 ctx.evaluatePolicy_localizedReason_reply(
182 policy, &reason_ns, &block,
183 );
184 }
185 Ok(rx)
186 }
187
188 pub async fn require_presence(reason: &str) -> Result<bool, Error> {
189 if let Some(v) = debug_bypass() {
190 return Ok(v);
191 }
192 let rx = begin_presence_check(reason)?;
193 rx.await.map_err(|_| Error::Os("reply dropped".into()))?
194 }
195}
196
197#[cfg(test)]
198mod tests {
199 use super::{gate_applies, Gate, Kind};
200
201 #[test]
202 fn gate_off_never_applies() {
203 for k in [Kind::SshSign, Kind::TotpCode, Kind::VaultSecret] {
204 assert!(!gate_applies(Gate::Off, k));
205 }
206 }
207
208 #[test]
209 fn gate_signing_matches_only_signing_kinds() {
210 assert!(gate_applies(Gate::Signing, Kind::SshSign));
211 assert!(gate_applies(Gate::Signing, Kind::TotpCode));
212 assert!(!gate_applies(Gate::Signing, Kind::VaultSecret));
213 }
214
215 #[test]
216 fn gate_all_applies_everywhere() {
217 for k in [Kind::SshSign, Kind::TotpCode, Kind::VaultSecret] {
218 assert!(gate_applies(Gate::All, k));
219 }
220 }
221
222 #[test]
223 fn gate_parse_roundtrip() {
224 for g in [Gate::Off, Gate::Signing, Gate::All] {
225 let s = g.to_string();
226 let parsed: Gate = s.parse().expect("parse");
227 assert_eq!(g, parsed);
228 }
229 }
230
231 #[test]
232 fn gate_parse_rejects_garbage() {
233 assert!("maybe".parse::<Gate>().is_err());
234 }
235}