Skip to main content

bwx/touchid/
mod.rs

1//! Touch ID / biometric authorization gate.
2//!
3//! macOS-only feature; see `SPIKE_TOUCHID.md` for the architecture
4//! discussion. On macOS the gate calls `LAContext::evaluate_policy` via
5//! `objc2-local-authentication`. On other platforms `require_presence`
6//! is a stub that always returns `Ok(true)`, so callers can use the
7//! same API everywhere without cfg gating at every site.
8
9pub mod blob;
10#[cfg(target_os = "macos")]
11pub mod keychain;
12
13use std::fmt;
14use std::str::FromStr;
15
16/// Which categories of operation should require biometric confirmation.
17#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
18pub enum Gate {
19    /// No biometric prompt. Always the value on non-macOS builds.
20    #[default]
21    Off,
22    /// Only ssh-agent sign requests and `bwx code` TOTP generation.
23    Signing,
24    /// Every response that carries plaintext secret material.
25    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/// Category of operation a call site represents. Used with a `Gate` to
55/// decide whether a biometric prompt is required.
56#[derive(Copy, Clone, Debug, PartialEq, Eq)]
57pub enum Kind {
58    /// SSH-agent sign request.
59    SshSign,
60    /// `bwx code` TOTP generation.
61    TotpCode,
62    /// Agent `Decrypt` / `Encrypt` / clipboard response carrying vault
63    /// secret material.
64    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/// Await a biometric confirmation from the user.
77///
78/// Returns `Ok(true)` if the user authenticated, `Ok(false)` if they
79/// cancelled, `Err(..)` only for unexpected failures (hardware missing,
80/// permissions, etc.). On non-macOS builds this always returns `Ok(true)`.
81#[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    /// Biometry is not available on this machine (no hardware, lid
95    /// closed, or the user has disabled Touch ID for this app).
96    Unavailable(String),
97    /// Something else went wrong talking to the OS.
98    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    /// Test bypass for e2e scenarios: if set to "allow"/"deny" AND the
125    /// binary was built with debug assertions, the FFI call is skipped and
126    /// the bypass value is used. Ignored in release builds.
127    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    /// Synchronous setup: create the `LAContext`, install the completion
139    /// handler, kick off `evaluatePolicy`. Returns a `Receiver` that will
140    /// be signalled when the OS callback fires. All objc types are
141    /// confined to this function so they never cross an `.await`, keeping
142    /// the outer async future `Send`.
143    fn begin_presence_check(
144        reason: &str,
145    ) -> Result<tokio::sync::oneshot::Receiver<Result<bool, Error>>, Error>
146    {
147        // SAFETY: LAContext::new is a +1-retain convenience constructor.
148        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                    // SAFETY: the framework hands us a retained NSError
168                    // that's live for the duration of the callback.
169                    let desc =
170                        unsafe { (*err).localizedDescription().to_string() };
171                    let code = unsafe { (*err).code() };
172                    if code == -2 || code == -4 {
173                        // LAError.userCancel = -2; LAError.systemCancel = -4
174                        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}