Skip to main content

bwx/
protocol.rs

1use std::os::unix::ffi::{OsStrExt as _, OsStringExt as _};
2
3pub const VERSION: u32 = {
4    const fn unwrap(res: &Result<u32, std::num::ParseIntError>) -> u32 {
5        match res {
6            Ok(t) => *t,
7            Err(_) => panic!("failed to parse cargo version"),
8        }
9    }
10
11    let major = env!("CARGO_PKG_VERSION_MAJOR");
12    let minor = env!("CARGO_PKG_VERSION_MINOR");
13    let patch = env!("CARGO_PKG_VERSION_PATCH");
14
15    unwrap(&u32::from_str_radix(major, 10)) * 1_000_000
16        + unwrap(&u32::from_str_radix(minor, 10)) * 1_000_000
17        + unwrap(&u32::from_str_radix(patch, 10)) * 1_000_000
18};
19
20#[derive(serde::Serialize, serde::Deserialize, Debug)]
21pub struct Request {
22    tty: Option<String>,
23    environment: Option<Environment>,
24    action: Action,
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    session_id: Option<String>,
27    /// Human-readable description of what the user ran (e.g. `get
28    /// google.com`). Used only to enrich agent-side UI prompts (Touch ID
29    /// dialog, pinentry CONFIRM); not authentication-relevant.
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    purpose: Option<String>,
32}
33
34impl Request {
35    pub fn new(environment: Environment, action: Action) -> Self {
36        Self {
37            tty: None,
38            environment: Some(environment),
39            action,
40            session_id: None,
41            purpose: None,
42        }
43    }
44
45    /// Like `new`, but tags the request with a per-CLI-process session
46    /// token and a human-readable purpose string. The agent coalesces
47    /// Touch ID prompts by session so a single `bwx <command>` invocation
48    /// only pops one biometric dialog regardless of how many
49    /// `Decrypt`/`Encrypt` IPCs it fires; the purpose is shown on the
50    /// prompt itself.
51    pub fn new_with_session(
52        environment: Environment,
53        action: Action,
54        session_id: String,
55        purpose: Option<String>,
56    ) -> Self {
57        Self {
58            tty: None,
59            environment: Some(environment),
60            action,
61            session_id: Some(session_id),
62            purpose,
63        }
64    }
65
66    pub fn into_parts(
67        self,
68    ) -> (Action, Environment, Option<String>, Option<String>) {
69        (
70            self.action,
71            self.environment.unwrap_or_else(|| Environment {
72                tty: self.tty.map(|tty| SerializableOsString(tty.into())),
73                env_vars: vec![],
74            }),
75            self.session_id,
76            self.purpose,
77        )
78    }
79}
80
81// Taken from https://github.com/gpg/gnupg/blob/36dbca3e6944d13e75e96eace634e58a7d7e201d/common/session-env.c#L62-L91
82pub const ENVIRONMENT_VARIABLES: &[&str] = &[
83    // Used to set ttytype
84    "TERM",
85    // The X display
86    "DISPLAY",
87    // Xlib Authentication
88    "XAUTHORITY",
89    // Used by Xlib to select X input modules (e.g. "@im=SCIM")
90    "XMODIFIERS",
91    // For the Wayland display engine.
92    "WAYLAND_DISPLAY",
93    // Used by Qt and other non-GTK toolkits to check for X11 or Wayland
94    "XDG_SESSION_TYPE",
95    // Used by Qt to explicitly request X11 or Wayland; in particular, needed to
96    // make Qt use Wayland on GNOME
97    "QT_QPA_PLATFORM",
98    // Used by GTK to select GTK input modules (e.g. "scim-bridge")
99    "GTK_IM_MODULE",
100    // Used by GNOME 3 to talk to gcr over dbus
101    "DBUS_SESSION_BUS_ADDRESS",
102    // Used by Qt to select Qt input modules (e.g. "xim")
103    "QT_IM_MODULE",
104    // Used for communication with non-standard Pinentries
105    "PINENTRY_USER_DATA",
106    // Used to pass window information
107    "PINENTRY_GEOM_HINT",
108];
109
110pub static ENVIRONMENT_VARIABLES_OS: std::sync::LazyLock<
111    Vec<std::ffi::OsString>,
112> = std::sync::LazyLock::new(|| {
113    ENVIRONMENT_VARIABLES
114        .iter()
115        .map(std::ffi::OsString::from)
116        .collect()
117});
118
119#[derive(Hash, PartialEq, Eq, Debug, Clone)]
120struct SerializableOsString(std::ffi::OsString);
121
122impl serde::Serialize for SerializableOsString {
123    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
124    where
125        S: serde::Serializer,
126    {
127        serializer.serialize_str(&crate::base64::encode(self.0.as_bytes()))
128    }
129}
130
131impl<'de> serde::Deserialize<'de> for SerializableOsString {
132    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
133    where
134        D: serde::Deserializer<'de>,
135    {
136        struct Visitor;
137
138        impl serde::de::Visitor<'_> for Visitor {
139            type Value = SerializableOsString;
140
141            fn expecting(
142                &self,
143                formatter: &mut std::fmt::Formatter,
144            ) -> std::fmt::Result {
145                formatter.write_str("base64 encoded os string")
146            }
147
148            fn visit_str<E>(self, s: &str) -> Result<Self::Value, E>
149            where
150                E: serde::de::Error,
151            {
152                Ok(SerializableOsString(std::ffi::OsString::from_vec(
153                    crate::base64::decode(s).map_err(|_| {
154                        E::invalid_value(serde::de::Unexpected::Str(s), &self)
155                    })?,
156                )))
157            }
158        }
159
160        deserializer.deserialize_str(Visitor)
161    }
162}
163
164#[derive(serde::Serialize, serde::Deserialize, Debug, Default, Clone)]
165pub struct Environment {
166    tty: Option<SerializableOsString>,
167    env_vars: Vec<(SerializableOsString, SerializableOsString)>,
168}
169
170impl Environment {
171    pub fn new(
172        tty: Option<std::ffi::OsString>,
173        env_vars: Vec<(std::ffi::OsString, std::ffi::OsString)>,
174    ) -> Self {
175        Self {
176            tty: tty.map(SerializableOsString),
177            env_vars: env_vars
178                .into_iter()
179                .map(|(k, v)| {
180                    (SerializableOsString(k), SerializableOsString(v))
181                })
182                .collect(),
183        }
184    }
185
186    pub fn tty(&self) -> Option<&std::ffi::OsStr> {
187        self.tty.as_ref().map(|tty| tty.0.as_os_str())
188    }
189
190    pub fn env_vars(
191        &self,
192    ) -> std::collections::HashMap<std::ffi::OsString, std::ffi::OsString>
193    {
194        self.env_vars
195            .iter()
196            .map(|(var, val)| (var.0.clone(), val.0.clone()))
197            .filter(|(var, _)| (*ENVIRONMENT_VARIABLES_OS).contains(var))
198            .collect()
199    }
200}
201
202#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
203pub struct DecryptItem {
204    pub cipherstring: String,
205    pub entry_key: Option<String>,
206    pub org_id: Option<String>,
207}
208
209#[derive(serde::Serialize, serde::Deserialize, Debug)]
210#[serde(tag = "outcome")]
211pub enum DecryptItemResult {
212    Ok { plaintext: String },
213    Err { error: String },
214}
215
216#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
217pub struct EncryptItem {
218    pub plaintext: String,
219    pub org_id: Option<String>,
220}
221
222#[derive(serde::Serialize, serde::Deserialize, Debug)]
223#[serde(tag = "outcome")]
224pub enum EncryptItemResult {
225    Ok { cipherstring: String },
226    Err { error: String },
227}
228
229#[derive(serde::Serialize, serde::Deserialize, Debug)]
230#[serde(tag = "type")]
231pub enum Action {
232    Login,
233    Register,
234    Unlock,
235    CheckLock,
236    Lock,
237    Sync,
238    Decrypt {
239        cipherstring: String,
240        entry_key: Option<String>,
241        org_id: Option<String>,
242    },
243    /// Decrypt many cipherstrings in one IPC. Touch ID is gated once for
244    /// the whole batch; per-item failures are surfaced in `results` so
245    /// the caller can decide whether to fail loud or skip the bad entry.
246    DecryptBatch {
247        items: Vec<DecryptItem>,
248    },
249    Encrypt {
250        plaintext: String,
251        org_id: Option<String>,
252    },
253    /// Encrypt many plaintexts in one IPC. Touch ID is gated once for the
254    /// whole batch; per-item failures are surfaced in `results` so the
255    /// caller can decide whether to fail loud or skip the bad item.
256    EncryptBatch {
257        items: Vec<EncryptItem>,
258    },
259    ClipboardStore {
260        text: String,
261    },
262    Quit,
263    Version,
264    /// Enroll the currently-unlocked vault keys under a Touch ID-gated
265    /// Keychain wrapper key. Requires the agent to already be unlocked.
266    TouchIdEnroll,
267    /// Remove the Keychain wrapper key and the on-disk enrollment blob.
268    TouchIdDisable,
269    /// Report whether Touch ID enrollment is active and summarise the
270    /// current `touchid_gate` setting.
271    TouchIdStatus,
272}
273
274#[derive(serde::Serialize, serde::Deserialize, Debug)]
275#[serde(tag = "type")]
276pub enum Response {
277    Ack,
278    Error {
279        error: String,
280    },
281    Decrypt {
282        plaintext: String,
283    },
284    DecryptBatch {
285        results: Vec<DecryptItemResult>,
286    },
287    Encrypt {
288        cipherstring: String,
289    },
290    EncryptBatch {
291        results: Vec<EncryptItemResult>,
292    },
293    Version {
294        version: u32,
295    },
296    TouchIdStatus {
297        enrolled: bool,
298        gate: String,
299        keychain_label: Option<String>,
300    },
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306
307    fn rmp_roundtrip<T>(value: &T) -> T
308    where
309        T: serde::Serialize + serde::de::DeserializeOwned,
310    {
311        let bytes = rmp_serde::to_vec(value).unwrap();
312        rmp_serde::from_slice(&bytes).unwrap()
313    }
314
315    #[test]
316    fn action_decrypt_msgpack_roundtrip() {
317        let a = Action::Decrypt {
318            cipherstring: "2.aaa|bbb|ccc".to_string(),
319            entry_key: Some("ek".to_string()),
320            org_id: None,
321        };
322        let bytes = rmp_serde::to_vec(&a).unwrap();
323        // Sanity-check: msgpack should be tighter than JSON for this
324        // payload. JSON of the same struct is ~95 bytes.
325        assert!(bytes.len() < 90, "msgpack payload {} bytes", bytes.len());
326        match rmp_roundtrip(&a) {
327            Action::Decrypt {
328                cipherstring,
329                entry_key,
330                org_id,
331            } => {
332                assert_eq!(cipherstring, "2.aaa|bbb|ccc");
333                assert_eq!(entry_key.as_deref(), Some("ek"));
334                assert_eq!(org_id, None);
335            }
336            other => panic!("unexpected variant: {other:?}"),
337        }
338    }
339
340    #[test]
341    fn decrypt_batch_roundtrip_preserves_order_and_results() {
342        let req = Action::DecryptBatch {
343            items: vec![
344                DecryptItem {
345                    cipherstring: "a".into(),
346                    entry_key: None,
347                    org_id: None,
348                },
349                DecryptItem {
350                    cipherstring: "b".into(),
351                    entry_key: Some("k".into()),
352                    org_id: Some("org".into()),
353                },
354            ],
355        };
356        match rmp_roundtrip(&req) {
357            Action::DecryptBatch { items } => {
358                assert_eq!(items.len(), 2);
359                assert_eq!(items[0].cipherstring, "a");
360                assert_eq!(items[1].entry_key.as_deref(), Some("k"));
361            }
362            other => panic!("unexpected variant: {other:?}"),
363        }
364
365        let resp = Response::DecryptBatch {
366            results: vec![
367                DecryptItemResult::Ok {
368                    plaintext: "hello".into(),
369                },
370                DecryptItemResult::Err {
371                    error: "decrypt failed".into(),
372                },
373            ],
374        };
375        match rmp_roundtrip(&resp) {
376            Response::DecryptBatch { results } => {
377                assert_eq!(results.len(), 2);
378                assert!(matches!(
379                    results[0],
380                    DecryptItemResult::Ok { ref plaintext } if plaintext == "hello"
381                ));
382                assert!(matches!(
383                    results[1],
384                    DecryptItemResult::Err { ref error } if error == "decrypt failed"
385                ));
386            }
387            other => panic!("unexpected variant: {other:?}"),
388        }
389    }
390}