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