Skip to main content

bwx/touchid/
mod.rs

1//! Touch ID / biometric authorization gate.
2//!
3//! On macOS, calls `LAContext::evaluate_policy` via
4//! `objc2-local-authentication`. On other platforms `require_presence` is a
5//! stub that always returns `Ok(true)`, so callers need no cfg gating.
6
7pub mod blob;
8#[cfg(target_os = "macos")]
9pub mod keychain;
10
11use std::fmt;
12use std::str::FromStr;
13
14/// Which categories of operation should require biometric confirmation.
15#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
16pub enum Gate {
17    /// No biometric prompt. Always the value on non-macOS builds.
18    #[default]
19    Off,
20    /// Only ssh-agent sign requests and `bwx code` TOTP generation.
21    Signing,
22    /// Every response carrying plaintext secret material.
23    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/// Category of operation a call site represents. Used with a `Gate` to
53/// decide whether a biometric prompt is required.
54#[derive(Copy, Clone, Debug, PartialEq, Eq)]
55pub enum Kind {
56    /// SSH-agent sign request.
57    SshSign,
58    /// `bwx code` TOTP generation.
59    TotpCode,
60    /// Agent `Decrypt` / `Encrypt` / clipboard response carrying vault
61    /// secret material.
62    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/// Await a biometric confirmation from the user.
75///
76/// `Ok(true)` on success, `Ok(false)` on cancel, `Err(..)` for unexpected
77/// failures. On non-macOS builds always returns `Ok(true)`.
78#[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    /// Biometry is not available on this machine (no hardware, lid
92    /// closed, or the user has disabled Touch ID for this app).
93    Unavailable(String),
94    /// Something else went wrong talking to the OS.
95    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    /// Test bypass for e2e scenarios. If `BWX_TOUCHID_TEST_BYPASS` is
122    /// "allow"/"deny" AND debug assertions are enabled, the FFI call is
123    /// skipped. Ignored in release builds.
124    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    /// Synchronous setup: create the `LAContext`, install the completion
136    /// handler, kick off `evaluatePolicy`. All objc types are confined to
137    /// this function so they never cross an `.await`, keeping the outer
138    /// async future `Send`.
139    fn begin_presence_check(
140        reason: &str,
141    ) -> Result<tokio::sync::oneshot::Receiver<Result<bool, Error>>, Error>
142    {
143        // SAFETY: LAContext::new is a +1-retain convenience constructor.
144        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                    // SAFETY: the framework passes a retained NSError live
164                    // for the duration of the callback.
165                    let desc =
166                        unsafe { (*err).localizedDescription().to_string() };
167                    let code = unsafe { (*err).code() };
168                    if code == -2 || code == -4 {
169                        // LAError.userCancel = -2; LAError.systemCancel = -4
170                        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}