diff --git a/Cargo.lock b/Cargo.lock index 744156d60..799d1a665 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -819,6 +819,14 @@ dependencies = [ "serde_json", ] +[[package]] +name = "craft-assuan" +version = "0.1.0" +dependencies = [ + "proptest", + "thiserror 2.0.21", +] + [[package]] name = "craft-atomic" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 44c7957c5..03c47598d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,6 +50,7 @@ craft-idml = { path = "crates/idml" } craft-jpeg = { path = "crates/jpeg" } craft-kiwi = { path = "crates/kiwi" } craft-keymap = { path = "crates/keymap" } +craft-assuan = { path = "crates/assuan" } craft-audio = { path = "crates/audio" } # No default features, so craft-audio's `encode` builds craft-media without `isobmff`. craft-media = { path = "crates/media", default-features = false } diff --git a/README.md b/README.md index cd002e326..718c2ff78 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = " Option { + let bytes = hex.as_bytes(); + if bytes.len() != 40 { + return None; + } + let mut grip = [0u8; 20]; + for (i, out) in grip.iter_mut().enumerate() { + let h = crate::line::hex(*bytes.get(i.saturating_mul(2))?)?; + let l = crate::line::hex(*bytes.get(i.saturating_mul(2).saturating_add(1))?)?; + *out = (h << 4) | l; + } + Some(Keygrip(grip)) + } +} + +impl std::fmt::Display for Keygrip { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + for b in self.0 { + write!(f, "{b:02X}")?; + } + Ok(()) + } +} + +impl std::fmt::Debug for Keygrip { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Keygrip({self})") + } +} + +/// The hashes a digest may be made with. MD5 and SHA-1 are not among them, so they can't be +/// asked for. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HashAlgorithm { + Sha256, + Sha384, + Sha512, +} + +impl HashAlgorithm { + /// Its number in `SETHASH` (OpenPGP's and libgcrypt's numbering). + fn number(self) -> u8 { + match self { + HashAlgorithm::Sha256 => 8, + HashAlgorithm::Sha384 => 9, + HashAlgorithm::Sha512 => 10, + } + } + + /// The size of a digest. + pub fn digest_len(self) -> usize { + match self { + HashAlgorithm::Sha256 => 32, + HashAlgorithm::Sha384 => 48, + HashAlgorithm::Sha512 => 64, + } + } +} + +/// A digest to be signed: its hash and its bytes, of the hash's size. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Digest { + algorithm: HashAlgorithm, + bytes: Vec, +} + +impl Digest { + /// `None` when `bytes` isn't the size `algorithm` makes. + pub fn new(algorithm: HashAlgorithm, bytes: &[u8]) -> Option { + (bytes.len() == algorithm.digest_len()).then(|| Digest { algorithm, bytes: bytes.to_vec() }) + } + + pub fn algorithm(&self) -> HashAlgorithm { + self.algorithm + } + + pub fn bytes(&self) -> &[u8] { + &self.bytes + } +} + +/// Where gpg-agent keeps a key. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum KeyStorage { + /// In a file on this computer. + Disk, + /// On a smartcard (or a token like one). + Smartcard, + /// The agent doesn't say. + Unknown, +} + +/// Whether a key on disk has a passphrase. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Protection { + Protected, + Clear, + Unknown, +} + +/// What `KEYINFO` says of a key. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct KeyInfo { + pub keygrip: Keygrip, + pub storage: KeyStorage, + /// The smartcard's serial number, for a key on one. + pub card_serial: Option, + /// The key's name on the card (`OPENPGP.1`…), for a key on one. + pub card_key: Option, + /// Whether its passphrase is held by the agent now (so no pinentry will show). + pub passphrase_cached: bool, + pub protection: Protection, +} + +/// Parse the arguments of a `KEYINFO` status line: +/// ` …`. +fn key_info(args: &str) -> Option { + let mut fields = args.split(' ').filter(|f| !f.is_empty()); + let keygrip = Keygrip::parse(fields.next()?)?; + let storage = match fields.next()? { + "D" => KeyStorage::Disk, + "T" => KeyStorage::Smartcard, + _ => KeyStorage::Unknown, + }; + let text = |f: Option<&str>| f.filter(|f| *f != "-").map(|f| crate::tidy(f.as_bytes(), 64)).filter(|f| !f.is_empty()); + let card_serial = text(fields.next()); + let card_key = text(fields.next()); + let passphrase_cached = fields.next() == Some("1"); + let protection = match fields.next() { + Some("P") => Protection::Protected, + Some("C") => Protection::Clear, + _ => Protection::Unknown, + }; + Some(KeyInfo { keygrip, storage, card_serial, card_key, passphrase_cached, protection }) +} + +/// A signature as gpg-agent made it: `(sig-val ( ( )…))`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Signature { + /// The algorithm's name as the agent gives it (`rsa`, `ecdsa`, `eddsa`…). + pub algorithm: String, + /// The whole S-expression (the `sig-val` list). + pub sexp: Sexp, +} + +impl Signature { + /// A part of the signature by its name (`s` for RSA; `r` and `s` for ECDSA and EdDSA). + pub fn part(&self, name: &str) -> Option<&[u8]> { + self.sexp.find(&self.algorithm)?.value(name) + } + + fn read(data: &[u8]) -> Result { + let sexp = sexp::parse(data).map_err(|e| Error::Protocol(format!("a signature that can't be read ({e})")))?; + if sexp.name() != Some("sig-val") { + return Err(Error::Protocol("a signature that isn't a sig-val".into())); + } + let algorithm = sexp + .as_list() + .and_then(|items| items.get(1)) + .and_then(Sexp::name) + .map(str::to_string) + .ok_or_else(|| Error::Protocol("a signature that names no algorithm".into()))?; + Ok(Signature { algorithm, sexp }) + } +} + +/// A session key gpg-agent decrypted. Wiped from memory when dropped; `Debug` shows its size alone. +#[derive(PartialEq, Eq)] +pub struct SessionKey { + bytes: Vec, + padding: Option, +} + +impl SessionKey { + /// The decrypted value as the agent handed it over. What it holds depends on + /// [`SessionKey::padding`]: with `Some(_)` the agent took the padding off and this is the + /// message itself (for OpenPGP: the algorithm octet, the key and its checksum); with `None` + /// it took nothing off, and for RSA this is the whole PKCS#1 v1.5 frame (`02`, the random + /// non-zero bytes, `00`, then the message; the leading `00` of the frame is left out, as an + /// integer's leading zero is), which the caller must check and remove. For ECDH it is the + /// shared point, from which the caller derives the key. + pub fn bytes(&self) -> &[u8] { + &self.bytes + } + + /// The `PADDING` status the agent sent: `Some(0)` when it removed PKCS#1 v1.5 padding (and + /// `Some(1)` for OAEP). `None` means the agent said nothing and removed nothing: the frame + /// is still on [`SessionKey::bytes`] (gpg-agent 2.4 answers OpenPGP's RSA so). + pub fn padding(&self) -> Option { + self.padding + } +} + +impl std::fmt::Debug for SessionKey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "SessionKey(<{} bytes>)", self.bytes.len()) + } +} + +impl Drop for SessionKey { + fn drop(&mut self) { + crate::wipe(&mut self.bytes); + } +} + +/// What the agent's pinentry needs to show on the owner's screen, passed with `OPTION`. Each is +/// the value of the environment variable of the same kind; none needs to be given. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct PinentryHints { + /// `DISPLAY`. + pub display: Option, + /// The terminal (`GPG_TTY`), for a pinentry in one. + pub ttyname: Option, + /// `TERM`. + pub ttytype: Option, + /// `XAUTHORITY`. + pub xauthority: Option, + /// `LC_CTYPE`. + pub lc_ctype: Option, + /// `LC_MESSAGES`. + pub lc_messages: Option, + /// Other variables the pinentry is given (`OPTION putenv=NAME=VALUE`): `WAYLAND_DISPLAY`, + /// `XDG_SESSION_TYPE`, `DBUS_SESSION_BUS_ADDRESS`. + pub putenv: Vec<(String, String)>, +} + +impl PinentryHints { + /// The variables putenv may pass on. + pub const PUTENV: &'static [&'static str] = &["WAYLAND_DISPLAY", "XDG_SESSION_TYPE", "DBUS_SESSION_BUS_ADDRESS"]; + + /// The hints from an environment, `var` reading one variable. + pub fn from_env(var: impl Fn(&str) -> Option) -> PinentryHints { + PinentryHints { + display: var("DISPLAY"), + ttyname: var("GPG_TTY"), + ttytype: var("TERM"), + xauthority: var("XAUTHORITY"), + lc_ctype: var("LC_CTYPE"), + lc_messages: var("LC_MESSAGES"), + putenv: Self::PUTENV.iter().filter_map(|name| var(name).map(|v| ((*name).to_string(), v))).collect(), + } + } + + /// The `OPTION` lines, each value checked: one that is empty, holds a control character or + /// would make the line too long is left out. + fn options(&self) -> Vec { + let ok = |v: &str| !v.is_empty() && !v.chars().any(char::is_control) && v.len() < MAX_LINE.saturating_sub(64); + let mut out = Vec::new(); + for (name, value) in [ + ("display", &self.display), + ("ttyname", &self.ttyname), + ("ttytype", &self.ttytype), + ("xauthority", &self.xauthority), + ("lc-ctype", &self.lc_ctype), + ("lc-messages", &self.lc_messages), + ] { + if let Some(v) = value.as_deref().filter(|v| ok(v)) { + out.push(format!("OPTION {name}={v}")); + } + } + for (name, value) in &self.putenv { + let name_ok = !name.is_empty() && name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_'); + if name_ok && ok(value) { + out.push(format!("OPTION putenv={name}={value}")); + } + } + out + } +} + +/// `SETKEYDESC`'s argument: spaces as `+`; `+`, `%` and control characters escaped. +fn key_description(text: &str) -> Result { + let mut out = Vec::new(); + for b in text.bytes() { + match b { + b' ' => out.push(b'+'), + b'+' | b'%' | 0..=0x1F | 0x7F => push_escaped(&mut out, b), + _ => out.push(b), + } + } + if text.len() > MAX_DESCRIPTION { + return Err(Error::Refused("a description of the key's use that is too long".into())); + } + // Only ASCII was escaped, so the UTF-8 of the rest is whole. + String::from_utf8(out).map_err(|_| Error::Refused("a description that isn't text".into())) +} + +/// The answer to inquiries during a signature or a decryption: the agent saying it started its +/// pinentry gets an empty answer, `CIPHERTEXT` gets the ciphertext, and anything else is refused. +fn answer(ciphertext: Option<&[u8]>) -> impl FnMut(&Inquiry) -> Answer + '_ { + move |inquiry: &Inquiry| match (inquiry.keyword.as_str(), ciphertext) { + ("PINENTRY_LAUNCHED", _) => Answer::Data(Vec::new()), + ("CIPHERTEXT", Some(c)) => Answer::Data(c.to_vec()), + _ => Answer::Cancel, + } +} + +/// Called with `true` just before a command that waits for the owner (a signature or a +/// decryption, while the pinentry is up) and with `false` when its answer is in, so that the +/// program can give the stream a long wait for that answer alone. +pub type OwnerWait = fn(&mut S, bool); + +/// A connection to gpg-agent. +/// +/// Each call is one or a few commands and waits for the agent's answer, which for a signature or +/// a decryption may be while the owner types a PIN into the agent's pinentry: call it away from +/// the window's thread. A call that fails with anything but the agent's refusal leaves the +/// connection unusable ([`Error::Broken`]); open a new one. +#[derive(Debug)] +pub struct GpgAgent { + client: Client, + owner_wait: Option>, +} + +impl GpgAgent { + /// Take over a fresh connection to the agent (its greeting is read). + pub fn open(stream: S) -> Result { + Ok(GpgAgent { client: Client::connect(stream)?, owner_wait: None }) + } + + /// Have `hook` called around each command that waits for the owner ([`OwnerWait`]). + pub fn set_owner_wait(&mut self, hook: OwnerWait) { + self.owner_wait = Some(hook); + } + + /// `command` with inquiries answered by `inquire`, the owner-wait hook around it. + fn waiting_for_owner(&mut self, command: &str, inquire: &mut dyn FnMut(&Inquiry) -> Answer) -> Result { + if let Some(hook) = self.owner_wait { + hook(self.client.get_mut(), true); + } + let response = self.client.transact(command, inquire); + if let Some(hook) = self.owner_wait { + hook(self.client.get_mut(), false); + } + response + } + + /// Tell the agent where its pinentry is to show. An option the agent refuses is skipped (an + /// older agent may not know it). + pub fn set_pinentry_hints(&mut self, hints: &PinentryHints) -> Result<(), Error> { + for option in hints.options() { + match self.client.command(&option) { + Ok(_) | Err(Error::Server(_)) => {} + Err(e) => return Err(e), + } + } + Ok(()) + } + + /// The agent's version (`GETINFO version`). + pub fn version(&mut self) -> Result { + let response = self.client.command("GETINFO version")?; + Ok(crate::tidy(&response.data, 64)) + } + + /// Whether the agent holds the secret key for `keygrip` (`HAVEKEY`). + pub fn have_key(&mut self, keygrip: &Keygrip) -> Result { + match self.client.command(&format!("HAVEKEY {keygrip}")) { + Ok(_) => Ok(true), + Err(Error::Server(e)) if matches!(e.kind(), ErrorKind::NoSecretKey | ErrorKind::NotFound) => Ok(false), + Err(e) => Err(e), + } + } + + /// What the agent says of the key for `keygrip` (`KEYINFO`); `None` when it has none. + pub fn key_info(&mut self, keygrip: &Keygrip) -> Result, Error> { + let response = match self.client.command(&format!("KEYINFO {keygrip}")) { + Ok(r) => r, + Err(Error::Server(e)) if matches!(e.kind(), ErrorKind::NoSecretKey | ErrorKind::NotFound) => return Ok(None), + Err(e) => return Err(e), + }; + let info = response.status.iter().filter(|s| s.keyword == "KEYINFO").find_map(|s| key_info(&s.args)).filter(|i| i.keygrip == *keygrip); + info.map(Some).ok_or_else(|| Error::Protocol("no KEYINFO for the key asked about".into())) + } + + /// Every key the agent holds (`KEYINFO --list`), at most [`MAX_KEYS`]; status lines that + /// can't be read are skipped. + pub fn list_keys(&mut self) -> Result, Error> { + let mut keys = Vec::new(); + self.client.command_with_status("KEYINFO --list", &mut |s| { + if keys.len() < MAX_KEYS + && s.keyword == "KEYINFO" + && let Some(info) = key_info(&s.args) + { + keys.push(info); + } + })?; + Ok(keys) + } + + /// The public key for `keygrip`, as an S-expression (`READKEY`): `(public-key (rsa (n …) + /// (e …)))`, `(public-key (ecc (curve …) (q …)))`…. + pub fn read_key(&mut self, keygrip: &Keygrip) -> Result { + let response = self.client.command(&format!("READKEY {keygrip}"))?; + sexp::parse(&response.data).map_err(|e| Error::Protocol(format!("a public key that can't be read ({e})"))) + } + + /// Sign `digest` with the key for `keygrip` (`RESET`, `SIGKEY`, `SETKEYDESC`, `SETHASH`, + /// `PKSIGN`). `RESET` first, so that nothing an earlier call set (a description, a key, a + /// digest) is left for this one. + /// The agent asks for the PIN or passphrase in its own pinentry, which shows `description` + /// (what the key is wanted for) when one is given; the owner cancelling it is + /// [`ErrorKind::Canceled`]. + pub fn sign(&mut self, keygrip: &Keygrip, digest: &Digest, description: Option<&str>) -> Result { + let description = description.map(key_description).transpose()?; + self.reset()?; + self.client.command(&format!("SIGKEY {keygrip}"))?; + if let Some(d) = description { + self.client.command(&format!("SETKEYDESC {d}"))?; + } + let hex: String = digest.bytes.iter().map(|b| format!("{b:02X}")).collect(); + self.client.command(&format!("SETHASH {} {hex}", digest.algorithm.number()))?; + let response = self.waiting_for_owner("PKSIGN", &mut answer(None))?; + Signature::read(&response.data) + } + + /// Decrypt `ciphertext` (an S-expression `(enc-val ( ( )…))`) with the + /// key for `keygrip` (`RESET`, `SETKEY`, `SETKEYDESC`, `PKDECRYPT`, which asks for the + /// ciphertext with `INQUIRE CIPHERTEXT`). The PIN or passphrase is asked for as for + /// [`GpgAgent::sign`]. See [`SessionKey::bytes`] for what comes back. + pub fn decrypt(&mut self, keygrip: &Keygrip, ciphertext: &Sexp, description: Option<&str>) -> Result { + if ciphertext.name() != Some("enc-val") { + return Err(Error::Refused("a ciphertext that isn't an enc-val".into())); + } + let description = description.map(key_description).transpose()?; + let canonical = ciphertext.to_canonical(); + self.reset()?; + self.client.command(&format!("SETKEY {keygrip}"))?; + if let Some(d) = description { + self.client.command(&format!("SETKEYDESC {d}"))?; + } + let mut response: Response = self.waiting_for_owner("PKDECRYPT", &mut answer(Some(&canonical)))?; + let padding = response.status("PADDING").and_then(|p| p.trim().parse::().ok()); + let data = std::mem::take(&mut response.data); + // gpg-agent ends the value with a NUL of its own, which isn't part of the S-expression. + let parsed = sexp::parse(data.strip_suffix(&[0]).unwrap_or(&data)); + let mut data = data; + crate::wipe(&mut data); + let value = parsed.map_err(|e| Error::Protocol(format!("a decrypted value that can't be read ({e})")))?; + // `(value )`. + let bytes = match value.as_list() { + Some([name, v]) if name.as_atom() == Some(b"value") => v.as_atom().map(<[u8]>::to_vec), + _ => None, + }; + let bytes = bytes.ok_or_else(|| Error::Protocol("a decrypted value that isn't a (value …)".into()))?; + Ok(SessionKey { bytes, padding }) + } + + /// Forget the key chosen and the digest set (`RESET`). + pub fn reset(&mut self) -> Result<(), Error> { + self.client.command("RESET").map(drop) + } + + /// The stream, for its owner to set up. + pub fn get_mut(&mut self) -> &mut S { + self.client.get_mut() + } + + /// Say goodbye (`BYE`) and hand the stream back. + pub fn close(mut self) -> S { + let _ = self.client.command("BYE"); + self.client.into_inner() + } +} diff --git a/crates/assuan/src/agent_tests.rs b/crates/assuan/src/agent_tests.rs new file mode 100644 index 000000000..d03035a64 --- /dev/null +++ b/crates/assuan/src/agent_tests.rs @@ -0,0 +1,329 @@ +use proptest::prelude::*; + +use crate::fake::{Canned, FakeAgent}; +use crate::{Digest, Error, ErrorKind, GpgAgent, HashAlgorithm, KeyStorage, Keygrip, PinentryHints, Protection, Sexp}; + +const GRIP: &str = "0123456789ABCDEF0123456789ABCDEF01234567"; +const OTHER: &str = "89ABCDEF0123456789ABCDEF0123456789ABCDEF"; + +fn grip() -> Keygrip { + Keygrip::parse(GRIP).unwrap() +} + +#[test] +fn keygrips() { + assert_eq!(grip().to_string(), GRIP); + assert_eq!(Keygrip::parse(&GRIP.to_lowercase()), Some(grip())); + for bad in ["", "0123", &format!("{GRIP}0"), &GRIP.replace('A', "G"), "é123456789ABCDEF0123456789ABCDEF0123456"] { + assert_eq!(Keygrip::parse(bad), None, "{bad}"); + } +} + +#[test] +fn only_sha2_digests_of_the_right_size() { + assert!(Digest::new(HashAlgorithm::Sha256, &[0; 32]).is_some()); + assert!(Digest::new(HashAlgorithm::Sha384, &[0; 48]).is_some()); + assert!(Digest::new(HashAlgorithm::Sha512, &[0; 64]).is_some()); + // A SHA-1 or MD5 sized digest is no SHA-256 one. + assert!(Digest::new(HashAlgorithm::Sha256, &[0; 20]).is_none()); + assert!(Digest::new(HashAlgorithm::Sha256, &[0; 16]).is_none()); + assert!(Digest::new(HashAlgorithm::Sha512, &[0; 32]).is_none()); +} + +#[test] +fn have_key() { + let agent = FakeAgent::new() + .on(&format!("HAVEKEY {GRIP}"), "OK\n") + .on(&format!("HAVEKEY {GRIP}"), "ERR 67108881 No secret key \n") + .on(&format!("HAVEKEY {GRIP}"), "ERR 67109041 IPC call has been cancelled\n"); + let mut agent = GpgAgent::open(agent).unwrap(); + assert!(agent.have_key(&grip()).unwrap()); + assert!(!agent.have_key(&grip()).unwrap()); + assert!(matches!(agent.have_key(&grip()), Err(Error::Server(_)))); +} + +#[test] +fn key_info_on_disk_and_on_a_card() { + let agent = FakeAgent::new() + .on(&format!("KEYINFO {GRIP}"), format!("S KEYINFO {GRIP} D - - 1 P - - -\nOK\n")) + .on(&format!("KEYINFO {GRIP}"), format!("S KEYINFO {GRIP} T D2760001240103040006123456780000 OPENPGP.1 - - - - -\nOK\n")) + .on(&format!("KEYINFO {GRIP}"), "ERR 67108891 Not found \n") + .on(&format!("KEYINFO {GRIP}"), format!("S KEYINFO {OTHER} D - - - P - - -\nOK\n")); + let mut agent = GpgAgent::open(agent).unwrap(); + let disk = agent.key_info(&grip()).unwrap().unwrap(); + assert_eq!( + (disk.storage, disk.passphrase_cached, disk.protection, disk.card_serial.as_deref()), + (KeyStorage::Disk, true, Protection::Protected, None) + ); + let card = agent.key_info(&grip()).unwrap().unwrap(); + assert_eq!(card.storage, KeyStorage::Smartcard); + assert_eq!(card.card_serial.as_deref(), Some("D2760001240103040006123456780000")); + assert_eq!(card.card_key.as_deref(), Some("OPENPGP.1")); + assert_eq!(card.protection, Protection::Unknown); + assert_eq!(agent.key_info(&grip()).unwrap(), None); + // An answer about another key is not taken for this one. + assert!(matches!(agent.key_info(&grip()), Err(Error::Protocol(_)))); +} + +#[test] +fn list_keys_skips_lines_it_cant_read() { + let agent = FakeAgent::new() + .on("KEYINFO --list", format!("S KEYINFO {GRIP} D - - - C - - -\nS KEYINFO nonsense\nS PROGRESS 1\nS KEYINFO {OTHER} X - - - - - - -\nOK\n")); + let mut agent = GpgAgent::open(agent).unwrap(); + let keys = agent.list_keys().unwrap(); + assert_eq!(keys.len(), 2); + assert_eq!(keys[0].protection, Protection::Clear); + assert_eq!(keys[1].storage, KeyStorage::Unknown); +} + +#[test] +fn read_key() { + let agent = FakeAgent::new().on(&format!("READKEY {GRIP}"), "D (10:public-key(3:ecc(5:curve7:Ed25519)(1:q2:%0A%25)))\nOK\n"); + let mut agent = GpgAgent::open(agent).unwrap(); + let key = agent.read_key(&grip()).unwrap(); + assert_eq!(key.find("ecc").and_then(|e| e.value("q")), Some(&b"\n%"[..])); +} + +#[test] +fn signing_a_digest_sends_the_key_the_description_and_the_hash() { + let digest = Digest::new(HashAlgorithm::Sha256, &[0xAB; 32]).unwrap(); + let agent = FakeAgent::new() + .on(&format!("SIGKEY {GRIP}"), "OK\n") + .on("SETKEYDESC Sign+a+message+from+ann@example.org%0A100%25+sure", "OK\n") + .on(&format!("SETHASH 8 {}", "AB".repeat(32)), "OK\n") + .on("PKSIGN", "INQUIRE PINENTRY_LAUNCHED 1234 gtk2 1.2.1 - - -\n") + .on("END", "S PROGRESS x\nD (7:sig-val(3:rsa(1:s4:\x01\x02\x03\x04)))\nOK\n"); + let mut agent = GpgAgent::open(agent).unwrap(); + let sig = agent.sign(&grip(), &digest, Some("Sign a message from ann@example.org\n100% sure")).unwrap(); + assert_eq!(sig.algorithm, "rsa"); + assert_eq!(sig.part("s"), Some(&b"\x01\x02\x03\x04"[..])); + assert_eq!(sig.part("r"), None); + let fake = agent.close(); + assert!(fake.wrong.is_empty(), "{:?}", fake.wrong); +} + +#[test] +fn an_ecdsa_signature_and_sha512() { + let digest = Digest::new(HashAlgorithm::Sha512, &[1; 64]).unwrap(); + let agent = FakeAgent::new() + .on(&format!("SIGKEY {GRIP}"), "OK\n") + .on("SETHASH 10 *", "OK\n") + .on("PKSIGN", "D (7:sig-val(5:ecdsa(1:r1:%01)(1:s1:%02)))\nOK\n"); + let mut agent = GpgAgent::open(agent).unwrap(); + let sig = agent.sign(&grip(), &digest, None).unwrap(); + assert_eq!((sig.algorithm.as_str(), sig.part("r"), sig.part("s")), ("ecdsa", Some(&[1u8][..]), Some(&[2u8][..]))); +} + +#[test] +fn a_cancelled_pinentry_and_a_bad_signature() { + let digest = Digest::new(HashAlgorithm::Sha256, &[0; 32]).unwrap(); + let agent = FakeAgent::new() + .on("SIGKEY *", "OK\n") + .on("SETHASH *", "OK\n") + .on("PKSIGN", "ERR 83886179 Operation cancelled \n") + .on("SIGKEY *", "OK\n") + .on("SETHASH *", "OK\n") + .on("PKSIGN", "D (6:public)\nOK\n") + .on("SIGKEY *", "OK\n") + .on("SETHASH *", "OK\n") + .on("PKSIGN", "D (7:sig-val)\nOK\n") + .on("SIGKEY *", "OK\n") + .on("SETHASH *", "OK\n") + .on("PKSIGN", "D junk\nOK\n"); + let mut agent = GpgAgent::open(agent).unwrap(); + let e = agent.sign(&grip(), &digest, None).unwrap_err(); + assert_eq!(e.server().map(|e| e.kind()), Some(ErrorKind::Canceled)); + for _ in 0..3 { + assert!(matches!(agent.sign(&grip(), &digest, None), Err(Error::Protocol(_)))); + } +} + +#[test] +fn an_inquiry_other_than_pinentry_launched_is_refused_during_signing() { + let digest = Digest::new(HashAlgorithm::Sha256, &[0; 32]).unwrap(); + let agent = FakeAgent::new().on("SIGKEY *", "OK\n").on("SETHASH *", "OK\n").on("PKSIGN", "INQUIRE CIPHERTEXT\n").on("CAN", "ERR 99 cancelled\n"); + let mut agent = GpgAgent::open(agent).unwrap(); + assert!(matches!(agent.sign(&grip(), &digest, None), Err(Error::Server(_)))); + assert!(agent.close().done()); +} + +#[test] +fn a_too_long_description_is_never_sent() { + let digest = Digest::new(HashAlgorithm::Sha256, &[0; 32]).unwrap(); + let mut agent = GpgAgent::open(FakeAgent::new()).unwrap(); + assert!(matches!(agent.sign(&grip(), &digest, Some(&"x".repeat(301))), Err(Error::Refused(_)))); + assert!(agent.close().sent == ["BYE"]); +} + +fn ciphertext() -> Sexp { + crate::sexp::parse(b"(7:enc-val(3:rsa(1:a3:\n\r%)))").unwrap() +} + +#[test] +fn decrypting_answers_the_ciphertext_inquiry() { + let agent = FakeAgent::new() + .on(&format!("SETKEY {GRIP}"), "OK\n") + .on("SETKEYDESC Open+a+message", "OK\n") + .on("PKDECRYPT", "S INQUIRE_MAXLEN 4096\nINQUIRE CIPHERTEXT\n") + .on("D (7:enc-val(3:rsa(1:a3:%0A%0D%25)))", "") + // gpg-agent ends the value with a NUL. + .on("END", "S PADDING 0\nD (5:value6:\x09key%0A!)\x00\nOK\n"); + let mut agent = GpgAgent::open(agent).unwrap(); + let key = agent.decrypt(&grip(), &ciphertext(), Some("Open a message")).unwrap(); + assert_eq!(key.bytes(), b"\x09key\n!"); + assert_eq!(key.padding(), Some(0)); + assert_eq!(format!("{key:?}"), "SessionKey(<6 bytes>)"); + assert!(agent.close().wrong.is_empty()); +} + +#[test] +fn a_decrypted_value_must_be_a_value() { + let agent = FakeAgent::new() + .on("SETKEY *", "OK\n") + .on("PKDECRYPT", "INQUIRE CIPHERTEXT\n") + .on("D *", "") + .on("END", "D (5:other1:x)\nOK\n") + .on("SETKEY *", "OK\n") + .on("PKDECRYPT", "INQUIRE CIPHERTEXT\n") + .on("D *", "") + .on("END", "D (5:value(1:x))\nOK\n") + .on("SETKEY *", "OK\n") + .on("PKDECRYPT", "ERR 67108881 No secret key\n"); + let mut agent = GpgAgent::open(agent).unwrap(); + assert!(matches!(agent.decrypt(&grip(), &ciphertext(), None), Err(Error::Protocol(_)))); + assert!(matches!(agent.decrypt(&grip(), &ciphertext(), None), Err(Error::Protocol(_)))); + let e = agent.decrypt(&grip(), &ciphertext(), None).unwrap_err(); + assert_eq!(e.server().map(|e| e.kind()), Some(ErrorKind::NoSecretKey)); + // Only an enc-val is sent at all. + assert!(matches!(agent.decrypt(&grip(), &Sexp::token("x"), None), Err(Error::Refused(_)))); +} + +#[test] +fn pinentry_hints_become_options_and_a_refused_one_is_skipped() { + let env = |name: &str| match name { + "DISPLAY" => Some(":0".to_string()), + "TERM" => Some("xterm-256color".to_string()), + "XAUTHORITY" => Some("bad\nvalue".to_string()), + "WAYLAND_DISPLAY" => Some("wayland-0".to_string()), + "LC_CTYPE" => Some(String::new()), + _ => None, + }; + let hints = PinentryHints::from_env(env); + let agent = FakeAgent::new() + .on("OPTION display=:0", "OK\n") + .on("OPTION ttytype=xterm-256color", "ERR 67109115 Unknown option\n") + .on("OPTION putenv=WAYLAND_DISPLAY=wayland-0", "OK\n"); + let mut agent = GpgAgent::open(agent).unwrap(); + agent.set_pinentry_hints(&hints).unwrap(); + assert!(agent.close().wrong.is_empty()); +} + +#[test] +fn version_and_reset() { + let agent = FakeAgent::new().on("GETINFO version", "D 2.4.7\nOK\n").on("RESET", "OK\n"); + let mut agent = GpgAgent::open(agent).unwrap(); + assert_eq!(agent.version().unwrap(), "2.4.7"); + agent.reset().unwrap(); +} + +proptest! { + #[test] + fn any_agent_never_panics(bytes in proptest::collection::vec(any::(), 0..4096)) { + let mut input = b"OK\n".to_vec(); + input.extend_from_slice(&bytes); + let Ok(mut agent) = GpgAgent::open(Canned::new(&input)) else { return Ok(()) }; + let digest = Digest::new(HashAlgorithm::Sha256, &[7; 32]).unwrap(); + let _ = agent.key_info(&grip()); + let _ = agent.list_keys(); + let _ = agent.sign(&grip(), &digest, Some("x")); + let _ = agent.decrypt(&grip(), &ciphertext(), None); + let _ = agent.read_key(&grip()); + } +} + +#[test] +fn an_rsa_value_without_padding_status_keeps_its_frame_and_only_one_nul_is_taken_off() { + // gpg-agent 2.4 answers an OpenPGP RSA decryption so: no PADDING, the PKCS#1 v1.5 frame + // still on (its leading zero left out), and a NUL after the S-expression, escaped or not. + let frame = b"\x02\x11\x22\x33\x00\x09KEY"; + let reply = |nul: &str| { + let mut r = b"D (5:value9:".to_vec(); + r.extend_from_slice(frame); + r.extend_from_slice(format!("){nul}\nOK\n").as_bytes()); + r + }; + let agent = FakeAgent::new() + .on("SETKEY *", "OK\n") + .on("PKDECRYPT", "INQUIRE CIPHERTEXT\n") + .on("D *", "") + .on("END", reply("%00")) + .on("SETKEY *", "OK\n") + .on("PKDECRYPT", "INQUIRE CIPHERTEXT\n") + .on("D *", "") + .on("END", reply("\x00\x00")); + let mut agent = GpgAgent::open(agent).unwrap(); + let key = agent.decrypt(&grip(), &ciphertext(), None).unwrap(); + assert_eq!(key.padding(), None); + assert_eq!(key.bytes(), frame); + assert!(matches!(agent.decrypt(&grip(), &ciphertext(), None), Err(Error::Protocol(_)))); +} + +#[test] +fn more_keys_than_status_lines_an_answer_keeps() { + let mut lines = String::new(); + for i in 0..(crate::MAX_KEYS + 500) { + lines.push_str(&format!("S KEYINFO {i:040X} D - - - P - - -\n")); + } + lines.push_str("OK\n"); + let agent = FakeAgent::new().on("KEYINFO --list", lines).on("GETINFO version", "D 2.4.9\nOK\n"); + let mut agent = GpgAgent::open(agent).unwrap(); + assert_eq!(agent.list_keys().unwrap().len(), crate::MAX_KEYS); + // The connection is still in step. + assert_eq!(agent.version().unwrap(), "2.4.9"); +} + +#[test] +fn each_signature_starts_with_reset_so_no_old_description_is_left() { + let digest = Digest::new(HashAlgorithm::Sha256, &[0; 32]).unwrap(); + let agent = FakeAgent::new() + .on("SIGKEY *", "OK\n") + .on("SETKEYDESC Old+text", "OK\n") + .on("SETHASH *", "ERR 1 bad hash\n") + .on("SIGKEY *", "OK\n") + .on("SETHASH *", "OK\n") + .on("PKSIGN", "D (7:sig-val(3:rsa(1:s1:x)))\nOK\n") + .on("SETKEY *", "OK\n") + .on("PKDECRYPT", "ERR 99 cancelled\n"); + let mut agent = GpgAgent::open(agent).unwrap(); + assert!(agent.sign(&grip(), &digest, Some("Old text")).is_err()); + agent.sign(&grip(), &digest, None).unwrap(); + assert!(agent.decrypt(&grip(), &ciphertext(), None).is_err()); + let fake = agent.close(); + assert!(fake.wrong.is_empty(), "{:?}", fake.wrong); + let firsts: Vec<&str> = fake.sent.iter().map(|l| l.split(' ').next().unwrap()).filter(|c| ["RESET", "SIGKEY", "SETKEY"].contains(c)).collect(); + assert_eq!(firsts, ["RESET", "SIGKEY", "RESET", "SIGKEY", "RESET", "SETKEY"]); +} + +#[test] +fn the_owner_wait_hook_is_on_only_for_the_commands_that_wait() { + let digest = Digest::new(HashAlgorithm::Sha256, &[0; 32]).unwrap(); + let agent = FakeAgent::new() + .on("SIGKEY *", "OK\n") + .on("SETHASH *", "OK\n") + .on("PKSIGN", "INQUIRE PINENTRY_LAUNCHED 1\n") + .on("END", "ERR 99 cancelled\n") + .on("SETKEY *", "OK\n") + .on("PKDECRYPT", "INQUIRE CIPHERTEXT\n") + .on("D *", "") + .on("END", "D (5:value1:k)\nOK\n") + .on("HAVEKEY *", "OK\n"); + let mut agent = GpgAgent::open(agent).unwrap(); + agent.set_owner_wait(|fake: &mut FakeAgent, long| fake.long_wait = long); + assert!(agent.sign(&grip(), &digest, None).is_err()); + agent.decrypt(&grip(), &ciphertext(), None).unwrap(); + agent.have_key(&grip()).unwrap(); + let fake = agent.close(); + let long: Vec<&str> = fake.waits.iter().filter(|(_, long)| *long).map(|(l, _)| l.split(' ').next().unwrap()).collect(); + assert_eq!(long, ["PKSIGN", "END", "PKDECRYPT", "D", "END"]); + assert!(!fake.long_wait); +} diff --git a/crates/assuan/src/client.rs b/crates/assuan/src/client.rs new file mode 100644 index 000000000..1d8c9abc4 --- /dev/null +++ b/crates/assuan/src/client.rs @@ -0,0 +1,235 @@ +//! An Assuan client: one command at a time, its answer read to its `OK` or `ERR`, inquiries +//! answered on the way. + +use std::io::{Read, Write}; + +use crate::line::{Line, Wire, parse}; +use crate::{Error, MAX_DATA, MAX_INQUIRIES, MAX_INQUIRY_DATA, MAX_LINE, MAX_LINES, MAX_STATUS}; + +/// A status line (`S `). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Status { + /// The keyword, as sent (printable ASCII). + pub keyword: String, + /// The arguments, unescaped (a status line's arguments are text; bytes that aren't UTF-8 are + /// replaced). + pub args: String, +} + +/// The answer to a command that the server ended with `OK`. +#[derive(Default, PartialEq, Eq)] +pub struct Response { + /// The `D` lines' data, joined and unescaped. Wiped when the response is dropped. + pub data: Vec, + /// The status lines, in order (at most [`MAX_STATUS`](crate::MAX_STATUS)). + pub status: Vec, + /// What followed `OK`. + pub ok: String, +} + +impl Response { + /// The arguments of the first status line with `keyword`. + pub fn status(&self, keyword: &str) -> Option<&str> { + self.status.iter().find(|s| s.keyword == keyword).map(|s| s.args.as_str()) + } +} + +impl std::fmt::Debug for Response { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // The data may be a session key: only its size is shown. + f.debug_struct("Response") + .field("data", &format_args!("<{} bytes>", self.data.len())) + .field("status", &self.status) + .field("ok", &self.ok) + .finish() + } +} + +impl Drop for Response { + fn drop(&mut self) { + crate::wipe(&mut self.data); + } +} + +/// An inquiry from the server (`INQUIRE `). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Inquiry { + pub keyword: String, + pub args: String, +} + +/// How an inquiry is answered. +#[derive(PartialEq, Eq)] +pub enum Answer { + /// These bytes (sent as `D` lines and `END`; none is an empty answer). + Data(Vec), + /// `CAN`: the inquiry is refused, and the server ends the command with an error. + Cancel, +} + +impl std::fmt::Debug for Answer { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Answer::Data(d) => write!(f, "Data(<{} bytes>)", d.len()), + Answer::Cancel => f.write_str("Cancel"), + } + } +} + +impl Drop for Answer { + fn drop(&mut self) { + if let Answer::Data(d) = self { + crate::wipe(d); + } + } +} + +/// An Assuan connection, from the client's side. +pub struct Client { + wire: Wire, + /// An earlier command failed in a way that leaves the conversation out of step. + broken: bool, +} + +impl std::fmt::Debug for Client { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Client").field("broken", &self.broken).finish_non_exhaustive() + } +} + +/// Whether `command` can go on one line as it is. +fn check_command(command: &[u8]) -> Result<(), Error> { + if command.is_empty() { + return Err(Error::Refused("an empty command".into())); + } + if command.iter().any(|b| matches!(b, b'\n' | b'\r' | 0)) { + return Err(Error::Refused("a line break in a command".into())); + } + if command.len() >= MAX_LINE { + return Err(Error::Refused("a command longer than a line".into())); + } + Ok(()) +} + +impl Client { + /// Take over a fresh connection: the server's greeting is read (its `OK`). An `ERR` instead + /// is the server's refusal. + pub fn connect(stream: S) -> Result { + let mut client = Client { wire: Wire::new(stream), broken: false }; + client.read_answer(&mut |_| Answer::Cancel, None)?; + Ok(client) + } + + /// Send `command` and read its answer; an inquiry is refused (`CAN`). + pub fn command(&mut self, command: &str) -> Result { + self.transact(command, &mut |_| Answer::Cancel) + } + + /// Send `command` (one line, without its line feed) and read its answer, with `inquire` + /// answering each inquiry the server makes. `Err(Error::Server)` is the server's `ERR`; the + /// connection can be used again after it. After any other error it can't + /// ([`Error::Broken`]). + pub fn transact(&mut self, command: &str, inquire: &mut dyn FnMut(&Inquiry) -> Answer) -> Result { + self.send(command)?; + self.read_answer(inquire, None) + } + + /// As [`Client::command`], with each status line handed to `on_status` as it comes instead of + /// kept in the response, so that an answer may have more of them than + /// [`MAX_STATUS`](crate::MAX_STATUS) (a line for each key, say). The answer's lines are still + /// capped at [`MAX_LINES`](crate::MAX_LINES). + pub fn command_with_status(&mut self, command: &str, on_status: &mut dyn FnMut(Status)) -> Result { + self.send(command)?; + self.read_answer(&mut |_| Answer::Cancel, Some(on_status)) + } + + fn send(&mut self, command: &str) -> Result<(), Error> { + if self.broken { + return Err(Error::Broken); + } + check_command(command.as_bytes())?; + self.broken = true; + self.wire.write_line(command.as_bytes()) + } + + /// Read lines to the end of an answer. The connection stays marked broken unless the answer + /// was read whole. + fn read_answer(&mut self, inquire: &mut dyn FnMut(&Inquiry) -> Answer, mut on_status: Option<&mut dyn FnMut(Status)>) -> Result { + self.broken = true; + let mut response = Response::default(); + let mut inquiries = 0usize; + // An inquiry refused on this side because its answer was too big: said when the + // server has ended the command. + let mut refused: Option = None; + for _ in 0..MAX_LINES { + let mut line = self.wire.read_line()?; + let parsed = parse(&line); + crate::wipe(&mut line); + match parsed? { + Line::Comment => {} + Line::Data(mut data) => { + let fits = response.data.len().saturating_add(data.len()) <= MAX_DATA; + if fits { + crate::extend_wiped(&mut response.data, &data); + } + crate::wipe(&mut data); + if !fits { + return Err(Error::TooMuch); + } + } + Line::Status(keyword, args) => { + let status = + Status { keyword: String::from_utf8_lossy(&keyword).into_owned(), args: String::from_utf8_lossy(&args).into_owned() }; + if let Some(on_status) = on_status.as_mut() { + on_status(status); + continue; + } + if response.status.len() >= MAX_STATUS { + return Err(Error::TooMuch); + } + response.status.push(status); + } + Line::Inquire(keyword, args) => { + inquiries = inquiries.saturating_add(1); + if inquiries > MAX_INQUIRIES { + return Err(Error::TooMuch); + } + let inquiry = + Inquiry { keyword: String::from_utf8_lossy(&keyword).into_owned(), args: String::from_utf8_lossy(&args).into_owned() }; + match inquire(&inquiry) { + Answer::Data(ref data) if data.len() > MAX_INQUIRY_DATA => { + refused = Some(Error::Refused("an answer to the agent's inquiry too big to send".into())); + self.wire.write_line(b"CAN")?; + } + Answer::Data(ref data) => self.wire.write_data(data)?, + Answer::Cancel => self.wire.write_line(b"CAN")?, + } + } + Line::Ok(text) => { + self.broken = false; + if let Some(e) = refused { + return Err(e); + } + response.ok = crate::tidy(&text, 200); + return Ok(response); + } + Line::Err(e) => { + self.broken = false; + return Err(refused.unwrap_or(Error::Server(e))); + } + } + } + Err(Error::TooMuch) + } + + /// The stream, for its owner to set up (how long a socket waits, say). Reading from it or + /// writing to it here would take the conversation apart. + pub fn get_mut(&mut self) -> &mut S { + self.wire.get_mut() + } + + /// The stream back, the conversation over. + pub fn into_inner(self) -> S { + self.wire.into_inner() + } +} diff --git a/crates/assuan/src/client_tests.rs b/crates/assuan/src/client_tests.rs new file mode 100644 index 000000000..79a25d9a3 --- /dev/null +++ b/crates/assuan/src/client_tests.rs @@ -0,0 +1,201 @@ +use proptest::prelude::*; + +use crate::fake::{Canned, FakeAgent}; +use crate::{Answer, Client, Error, MAX_DATA, MAX_INQUIRIES, MAX_INQUIRY_DATA, MAX_LINE, MAX_LINES, MAX_STATUS}; + +#[test] +fn a_greeting_then_a_command_with_data_status_and_comments() { + let agent = FakeAgent::new().on("GETINFO version", "# here it comes\nS PROGRESS x\nD 2.4%2E7\nD -beta\nOK done\n"); + let mut client = Client::connect(agent).unwrap(); + let r = client.command("GETINFO version").unwrap(); + assert_eq!(r.data, b"2.4.7-beta".to_vec()); + assert_eq!(r.status("PROGRESS"), Some("x")); + assert_eq!(r.ok, "done"); + assert!(client.into_inner().done()); +} + +#[test] +fn a_refused_greeting() { + let agent = FakeAgent::greeting(b"ERR 1 go away\n"); + let e = Client::connect(agent).unwrap_err(); + assert_eq!(e.server().map(|e| e.code), Some(1)); +} + +#[test] +fn an_err_leaves_the_connection_usable() { + let agent = FakeAgent::new().on("HAVEKEY X", "ERR 67108881 No secret key\n").on("NOP", "OK\n"); + let mut client = Client::connect(agent).unwrap(); + let e = client.command("HAVEKEY X").unwrap_err(); + assert_eq!(e.server().map(|e| e.kind()), Some(crate::ErrorKind::NoSecretKey)); + client.command("NOP").unwrap(); + assert!(client.into_inner().done()); +} + +#[test] +fn junk_breaks_the_connection_for_good() { + let agent = FakeAgent::new().on("NOP", "HELLO\n"); + let mut client = Client::connect(agent).unwrap(); + assert!(matches!(client.command("NOP"), Err(Error::Protocol(_)))); + assert_eq!(client.command("NOP").unwrap_err(), Error::Broken); +} + +#[test] +fn an_inquiry_is_answered_with_data_or_refused() { + let agent = FakeAgent::new() + .on("PKDECRYPT", "S INQUIRE_MAXLEN 4096\nINQUIRE CIPHERTEXT\n") + .on("D 1%0A2", "") + .on("END", "D ok\nOK\n") + .on("PKSIGN", "INQUIRE SOMETHING odd\n") + .on("CAN", "ERR 99 Operation cancelled\n"); + let mut client = Client::connect(agent).unwrap(); + let mut asked = Vec::new(); + let r = client + .transact("PKDECRYPT", &mut |i| { + asked.push(i.keyword.clone()); + if i.keyword == "CIPHERTEXT" { Answer::Data(b"1\n2".to_vec()) } else { Answer::Cancel } + }) + .unwrap(); + assert_eq!(r.data, b"ok".to_vec()); + assert_eq!(asked, ["CIPHERTEXT"]); + let e = client.command("PKSIGN").unwrap_err(); + assert_eq!(e.server().map(|e| e.kind()), Some(crate::ErrorKind::Canceled)); + assert!(client.into_inner().done()); +} + +#[test] +fn an_answer_too_big_to_send_is_refused_and_said() { + let agent = FakeAgent::new().on("PKDECRYPT", "INQUIRE CIPHERTEXT\n").on("CAN", "ERR 99 cancelled\n").on("NOP", "OK\n"); + let mut client = Client::connect(agent).unwrap(); + let e = client.transact("PKDECRYPT", &mut |_| Answer::Data(vec![0; MAX_INQUIRY_DATA + 1])).unwrap_err(); + assert!(matches!(e, Error::Refused(_)), "{e:?}"); + client.command("NOP").unwrap(); + assert!(client.into_inner().done()); +} + +#[test] +fn commands_that_cant_go_on_one_line_are_never_sent() { + let mut client = Client::connect(FakeAgent::new()).unwrap(); + for bad in ["", "NOP\nBYE", "NOP\r", "NOP\0"] { + assert!(matches!(client.command(bad), Err(Error::Refused(_))), "{bad:?}"); + } + assert!(matches!(client.command(&"A".repeat(MAX_LINE)), Err(Error::Refused(_)))); + let agent = client.into_inner(); + assert!(agent.sent.is_empty()); +} + +#[test] +fn too_many_inquiries_status_lines_lines_or_data_are_stopped() { + let inquiries = "INQUIRE X\n".repeat(MAX_INQUIRIES + 1); + let agent = FakeAgent::new().on("NOP", inquiries); + let mut client = Client::connect(agent).unwrap(); + // Each inquiry is answered with an empty D … END that the script doesn't expect: what matters + // is that the client stops. + assert_eq!(client.transact("NOP", &mut |_| Answer::Data(Vec::new())).err(), Some(Error::TooMuch)); + + let status = "S X y\n".repeat(MAX_STATUS + 1); + let mut client = Client::connect(Canned::new(format!("OK\n{status}OK\n"))).unwrap(); + assert_eq!(client.command("NOP").err(), Some(Error::TooMuch)); + + let comments = "#\n".repeat(MAX_LINES + 1); + let mut client = Client::connect(Canned::new(format!("OK\n{comments}OK\n"))).unwrap(); + assert_eq!(client.command("NOP").err(), Some(Error::TooMuch)); + + let line = format!("D {}\n", "x".repeat(MAX_LINE - 3)); + let data = line.repeat(MAX_DATA / (MAX_LINE - 3) + 1); + let mut client = Client::connect(Canned::new(format!("OK\n{data}OK\n"))).unwrap(); + assert_eq!(client.command("NOP").err(), Some(Error::TooMuch)); +} + +#[test] +fn an_oversized_line_and_a_closed_stream() { + let agent = FakeAgent::new().on("NOP", format!("D {}\n", "x".repeat(MAX_LINE))); + let mut client = Client::connect(agent).unwrap(); + assert_eq!(client.command("NOP").err(), Some(Error::LineTooLong)); + + let mut client = Client::connect(Canned::new("OK\nD half")).unwrap(); + assert_eq!(client.command("NOP").err(), Some(Error::Closed)); + assert!(Client::connect(Canned::new("")).is_err()); +} + +#[test] +fn debug_never_shows_data() { + let agent = FakeAgent::new().on("PKDECRYPT", "D secret\nOK\n"); + let mut client = Client::connect(agent).unwrap(); + let r = client.command("PKDECRYPT").unwrap(); + let shown = format!("{r:?} {:?}", Answer::Data(b"secret".to_vec())); + assert!(!shown.contains("secret"), "{shown}"); +} + +proptest! { + #[test] + fn any_server_never_panics(bytes in proptest::collection::vec(any::(), 0..4096)) { + if let Ok(mut client) = Client::connect(Canned::new(&bytes)) { + for _ in 0..8 { + let _ = client.transact("NOP", &mut |i| if i.keyword.len() % 2 == 0 { Answer::Data(b"x\n%".to_vec()) } else { Answer::Cancel }); + } + } + } + + #[test] + fn any_lines_of_the_protocol_never_panic(lines in proptest::collection::vec( + prop_oneof![ + Just("OK".to_string()), Just("OK x".to_string()), Just("ERR 99 no".to_string()), Just("ERR".to_string()), + Just("D %41%".to_string()), Just("S KEYINFO 1 2 3".to_string()), Just("INQUIRE CIPHERTEXT".to_string()), + Just("INQUIRE PINENTRY_LAUNCHED 1 2".to_string()), Just("# c".to_string()), "[ -~]{0,40}", + ], 0..64)) { + let text = lines.join("\n") + "\n"; + if let Ok(mut client) = Client::connect(Canned::new(text)) { + for _ in 0..8 { + let _ = client.transact("NOP", &mut |_| Answer::Data(b"abc".to_vec())); + } + } + } +} + +#[test] +fn growing_a_buffer_wipes_the_old_allocation() { + let mut buf = Vec::with_capacity(4); + buf.extend_from_slice(b"key!"); + let old = crate::grow(&mut buf, 9); + assert!(old.iter().all(|b| *b == 0), "{old:?}"); + assert_eq!(buf, b"key!"); + crate::extend_wiped(&mut buf, b"more bytes"); + assert_eq!(buf, b"key!more bytes"); + + let mut buf = b"0123456789".to_vec(); + crate::discard_front(&mut buf, 4); + assert_eq!(buf, b"456789"); + crate::discard_front(&mut buf, 99); + assert!(buf.is_empty()); +} + +#[test] +fn status_lines_handed_over_as_they_come_are_not_capped_by_max_status() { + let status = "S KEYINFO x\n".repeat(MAX_STATUS * 3); + let agent = FakeAgent::new().on("KEYINFO --list", format!("{status}OK\n")); + let mut client = Client::connect(agent).unwrap(); + let mut seen = 0; + let r = client.command_with_status("KEYINFO --list", &mut |_| seen += 1).unwrap(); + assert_eq!(seen, MAX_STATUS * 3); + assert!(r.status.is_empty()); +} + +/// The text of `source` from `start` up to `end`. +fn section<'a>(source: &'a str, start: &str, end: &str) -> &'a str { + let from = source.find(start).unwrap(); + let to = from + source[from..].find(end).unwrap(); + &source[from..to] +} + +#[test] +fn what_the_agent_sends_only_grows_through_the_wiping_helpers() { + // A plain `extend_from_slice` or `drain` on these buffers leaves an unwiped copy of what + // the agent sent (a session key, perhaps) in memory that is given back: the reading and the + // gathering of data must go through `extend_wiped` and `discard_front`. + let fill = section(include_str!("line.rs"), "fn fill(", "fn write_line("); + assert!(fill.contains("crate::discard_front(") && fill.contains("crate::extend_wiped("), "{fill}"); + assert!(!fill.contains("extend_from_slice") && !fill.contains(".drain(") && !fill.contains(".extend("), "{fill}"); + let data = section(include_str!("client.rs"), "Line::Data(", "Line::Status("); + assert!(data.contains("crate::extend_wiped(&mut response.data") && data.contains("crate::wipe(&mut data)"), "{data}"); + assert!(!data.contains("extend_from_slice") && !data.contains(".extend("), "{data}"); +} diff --git a/crates/assuan/src/fake.rs b/crates/assuan/src/fake.rs new file mode 100644 index 000000000..fac481b92 --- /dev/null +++ b/crates/assuan/src/fake.rs @@ -0,0 +1,131 @@ +//! A fake gpg-agent for the tests: an in-memory stream that answers each line the client writes +//! from a script, and a plain canned stream. + +use std::collections::VecDeque; +use std::io::{Read, Result, Write}; + +/// A scripted agent: each line the client sends is matched with the next step of the script +/// (exactly, or by its start when the step ends in `*`), and the step's answer is then what the +/// client reads. A line that doesn't match is answered with an error and noted in `wrong`. +pub(crate) struct FakeAgent { + steps: VecDeque<(String, Vec)>, + out: VecDeque, + partial: Vec, + /// Every line the client sent, in order. + pub(crate) sent: Vec, + /// The lines that weren't what the script expected. + pub(crate) wrong: Vec, + /// Hand reads out a few bytes at a time. + trickle: usize, + /// Set by a test's owner-wait hook: whether the client asked for a long wait. + pub(crate) long_wait: bool, + /// Each line sent with whether a long wait was on when it was sent. + pub(crate) waits: Vec<(String, bool)>, +} + +impl FakeAgent { + /// An agent that greets with `OK Pleased to meet you`. + pub(crate) fn new() -> FakeAgent { + FakeAgent::greeting(b"OK Pleased to meet you\n") + } + + pub(crate) fn greeting(greeting: &[u8]) -> FakeAgent { + FakeAgent { + steps: VecDeque::new(), + out: greeting.iter().copied().collect(), + partial: Vec::new(), + sent: Vec::new(), + wrong: Vec::new(), + trickle: 0, + long_wait: false, + waits: Vec::new(), + } + } + + /// When the client sends `line`, answer `reply`. + pub(crate) fn on(mut self, line: &str, reply: impl AsRef<[u8]>) -> FakeAgent { + self.steps.push_back((line.to_string(), reply.as_ref().to_vec())); + self + } + + /// Whether every step was used and every line matched. + pub(crate) fn done(&self) -> bool { + self.steps.is_empty() && self.wrong.is_empty() + } +} + +impl Read for FakeAgent { + fn read(&mut self, buf: &mut [u8]) -> Result { + self.trickle += 1; + let n = buf.len().min(self.out.len()).min([5, 1, 64, 3, 1000, 7][self.trickle % 6]); + for slot in buf.iter_mut().take(n) { + *slot = self.out.pop_front().unwrap_or(0); + } + Ok(n) + } +} + +impl Write for FakeAgent { + fn write(&mut self, buf: &[u8]) -> Result { + self.partial.extend_from_slice(buf); + while let Some(i) = self.partial.iter().position(|b| *b == b'\n') { + let line: Vec = self.partial.drain(..=i).collect(); + let line = String::from_utf8_lossy(&line[..line.len() - 1]).into_owned(); + let matches = self.steps.front().is_some_and(|(want, _)| match want.strip_suffix('*') { + Some(start) => line.starts_with(start), + None => line == *want, + }); + if (line == "BYE" || line == "RESET") && !matches { + // Goodbye and RESET need no step of their own (`sent` still shows them). + self.out.extend(b"OK\n"); + } else if matches { + let (_, reply) = self.steps.pop_front().unwrap(); + self.out.extend(reply); + } else { + self.wrong.push(line.clone()); + self.out.extend(b"ERR 67109139 Unknown IPC command \n"); + } + self.waits.push((line.clone(), self.long_wait)); + self.sent.push(line); + } + Ok(buf.len()) + } + + fn flush(&mut self) -> Result<()> { + Ok(()) + } +} + +/// A stream that hands out fixed bytes and keeps what is written. +pub(crate) struct Canned { + input: Vec, + pos: usize, + pub(crate) written: Vec, +} + +impl Canned { + pub(crate) fn new(input: impl AsRef<[u8]>) -> Canned { + Canned { input: input.as_ref().to_vec(), pos: 0, written: Vec::new() } + } +} + +impl Read for Canned { + fn read(&mut self, buf: &mut [u8]) -> Result { + let left = &self.input[self.pos.min(self.input.len())..]; + let n = left.len().min(buf.len()); + buf[..n].copy_from_slice(&left[..n]); + self.pos += n; + Ok(n) + } +} + +impl Write for Canned { + fn write(&mut self, buf: &[u8]) -> Result { + self.written.extend_from_slice(buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> Result<()> { + Ok(()) + } +} diff --git a/crates/assuan/src/lib.rs b/crates/assuan/src/lib.rs new file mode 100644 index 000000000..60d0f79c4 --- /dev/null +++ b/crates/assuan/src/lib.rs @@ -0,0 +1,216 @@ +//! The Assuan protocol and gpg-agent, without sockets. +//! +//! OpenPGP private keys stay in GnuPG (on disk or on a smartcard) and are used through +//! gpg-agent, which asks for a PIN or passphrase in its own pinentry: the caller never sees one. +//! This crate speaks to the agent over any `Read + Write` stream, written from the public +//! documentation of the Assuan protocol and of gpg-agent's commands: +//! +//! - [`Client`]: Assuan's line framing (lines of at most [`MAX_LINE`] bytes, percent-escaping), +//! the server's `OK`, `ERR`, `S`, `D`, `INQUIRE` and `#` lines, answers to an inquiry (`D` … +//! `END`, or `CAN`), and caps on everything a server may send. +//! - [`GpgAgent`]: the operations a mail program needs: whether a key is there (`HAVEKEY`, `KEYINFO`, +//! `READKEY`), signing a digest (`SIGKEY`, `SETHASH`, `PKSIGN`; SHA-256, SHA-384 and SHA-512 +//! only, never MD5 or SHA-1), decrypting a session key (`SETKEY`, `PKDECRYPT`, answering the +//! agent's `INQUIRE CIPHERTEXT`), and the hints (`OPTION display=…`, `ttyname`…) that let the +//! agent's own pinentry show on the owner's screen. +//! - [`sexp`]: a bounded reader and writer of canonical S-expressions, the form the agent hands +//! keys, signatures and session keys over in. +//! - [`socket_file`]: the two kinds of file that may stand where the agent's socket is named: a +//! redirect, and (on Windows) the loopback port and nonce of Assuan's socket emulation. +//! +//! Finding the agent and opening the socket is the caller's: this crate does +//! no I/O of its own and builds for the browser, which never uses it. +//! +//! Everything the agent sends is treated as hostile: lines, data, status lines, inquiries and +//! S-expressions are capped, and nothing here panics on junk. A session key that comes back is +//! wiped from memory when it is dropped, and never shown by `Debug`. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +mod agent; +mod client; +mod line; +pub mod sexp; +pub mod socket_file; + +#[cfg(test)] +mod agent_tests; +#[cfg(test)] +mod client_tests; +#[cfg(test)] +mod fake; +#[cfg(test)] +mod line_tests; +#[cfg(test)] +mod sexp_tests; +#[cfg(test)] +mod socket_file_tests; + +pub use agent::{ + Digest, GpgAgent, HashAlgorithm, KeyInfo, KeyStorage, Keygrip, MAX_DESCRIPTION, MAX_KEYS, OwnerWait, PinentryHints, Protection, SessionKey, + Signature, +}; +pub use client::{Answer, Client, Inquiry, Response, Status}; +pub use sexp::Sexp; + +/// Longest line either side may send, its line feed included (the Assuan specification's limit). +pub const MAX_LINE: usize = 1000; +/// Most bytes of `D` data one answer may carry: keys, signatures and session keys are far smaller. +pub const MAX_DATA: usize = 256 * 1024; +/// Most lines one answer may take (comments and status lines included), so that a server that +/// talks for ever is stopped. +pub const MAX_LINES: usize = 16 * 1024; +/// Most status lines kept from one answer. `KEYINFO --list`, which has a line for each key, reads +/// its lines as they come instead and keeps at most [`MAX_KEYS`]. +pub const MAX_STATUS: usize = 256; +/// Most inquiries one command may answer. +pub const MAX_INQUIRIES: usize = 16; +/// Most bytes sent in answer to one inquiry. +pub const MAX_INQUIRY_DATA: usize = 1024 * 1024; + +/// What went wrong talking to gpg-agent. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum Error { + /// The stream failed (its kind, never what was being sent). + #[error("connection problem with gpg-agent: {0}")] + Io(String), + /// The agent closed the connection. + #[error("gpg-agent closed the connection")] + Closed, + /// The agent sent a line longer than the protocol allows. + #[error("gpg-agent sent a line longer than the protocol allows")] + LineTooLong, + /// The agent sent more lines, data, status lines or inquiries than one answer may have. + #[error("gpg-agent sent more than one answer may hold")] + TooMuch, + /// The agent sent something the protocol doesn't have, or not what the command asks for. + #[error("gpg-agent sent something unexpected: {0}")] + Protocol(String), + /// The agent answered with `ERR`. + #[error("gpg-agent refused: {0}")] + Server(ServerError), + /// Nothing was sent: what was asked can't be said in the protocol (a line break in an + /// argument, a line too long, a digest of the wrong size…). + #[error("not sent to gpg-agent: {0}")] + Refused(String), + /// An earlier failure left the connection in an unknown state; a new one is needed. + #[error("the connection to gpg-agent can't be used any more")] + Broken, +} + +impl Error { + /// The agent's refusal, when that is what this is. + pub fn server(&self) -> Option<&ServerError> { + match self { + Error::Server(e) => Some(e), + _ => None, + } + } +} + +/// An `ERR` line: the agent's error code and its description. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ServerError { + /// The whole code (an error source in the high byte, the error itself in the low 16 bits). + pub code: u32, + /// The agent's description, one line of printable text, capped. + pub text: String, +} + +/// What an [`ServerError`] means, for the errors told apart. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ErrorKind { + /// The agent has no secret key for the keygrip. + NoSecretKey, + /// The owner cancelled the pinentry. + Canceled, + /// A wrong passphrase was typed. + BadPassphrase, + /// A wrong PIN was typed. + BadPin, + /// What was asked for isn't there. + NotFound, + /// The pinentry waited too long. + Timeout, + /// No pinentry could be shown. + NoPinentry, + /// Any other error. + Other, +} + +impl ServerError { + /// The error itself, without its source. + pub fn number(&self) -> u32 { + self.code & 0xFFFF + } + + /// What it means. + pub fn kind(&self) -> ErrorKind { + // The numbers of the GnuPG error codes (libgpg-error's published list). + match self.number() { + 11 => ErrorKind::BadPassphrase, + 17 => ErrorKind::NoSecretKey, + 27 => ErrorKind::NotFound, + 62 => ErrorKind::Timeout, + 85 => ErrorKind::NoPinentry, + 87 => ErrorKind::BadPin, + 99 | 198 => ErrorKind::Canceled, + _ => ErrorKind::Other, + } + } +} + +impl std::fmt::Display for ServerError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if self.text.is_empty() { write!(f, "error {}", self.code) } else { write!(f, "{} (error {})", self.text, self.code) } + } +} + +/// Bytes the agent sent, as one line of printable text of at most `max` characters. +pub(crate) fn tidy(bytes: &[u8], max: usize) -> String { + String::from_utf8_lossy(bytes).chars().map(|c| if c.is_control() { ' ' } else { c }).take(max).collect::().trim().to_string() +} + +/// Append `more` to `buf` without leaving a copy behind: when `buf` must grow, its bytes are moved +/// to a new allocation and the old one is wiped before it is given back. +pub(crate) fn extend_wiped(buf: &mut Vec, more: &[u8]) { + let needed = buf.len().saturating_add(more.len()); + if needed > buf.capacity() { + let mut old = grow(buf, needed); + wipe(&mut old); + } + buf.extend_from_slice(more); +} + +/// Move `buf` to an allocation of at least `needed` bytes; the old buffer, its bytes wiped, is +/// handed back (to be dropped). +pub(crate) fn grow(buf: &mut Vec, needed: usize) -> Vec { + let mut bigger = Vec::with_capacity(needed.max(buf.capacity().saturating_mul(2))); + bigger.extend_from_slice(buf); + let mut old = std::mem::replace(buf, bigger); + // The whole allocation: bytes past the length may be left from before. + let len = old.len(); + old.resize(old.capacity(), 0); + wipe(&mut old); + old.truncate(len); + old +} + +/// Drop the first `n` bytes of `buf`, wiping the copies that moving the rest down leaves behind. +pub(crate) fn discard_front(buf: &mut Vec, n: usize) { + let n = n.min(buf.len()); + let len = buf.len(); + buf.copy_within(n.., 0); + let kept = len.saturating_sub(n); + if let Some(tail) = buf.get_mut(kept..) { + wipe(tail); + } + buf.truncate(kept); +} + +/// Overwrite bytes that held a secret before their memory is given back. +pub(crate) fn wipe(bytes: &mut [u8]) { + for b in bytes.iter_mut() { + *b = 0; + } + std::hint::black_box(bytes); +} diff --git a/crates/assuan/src/line.rs b/crates/assuan/src/line.rs new file mode 100644 index 000000000..48e2fce42 --- /dev/null +++ b/crates/assuan/src/line.rs @@ -0,0 +1,230 @@ +//! Assuan's lines: reading them from a stream that may be hostile, telling the server's kinds of +//! line apart, and its percent-escaping. + +use std::io::{ErrorKind, Read, Write}; + +use crate::{Error, MAX_LINE, ServerError}; + +/// How much is asked of the stream in one read. +const CHUNK: usize = 4 * 1024; +/// Reads interrupted in a row before giving up. +const MAX_INTERRUPTS: u32 = 1_000; +/// Longest description of an error kept. +const MAX_TEXT: usize = 200; + +/// One line from the server. +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum Line { + /// `OK`, with what follows it. + Ok(Vec), + /// `ERR `. + Err(ServerError), + /// `S `. + Status(Vec, Vec), + /// `D `, decoded. + Data(Vec), + /// `INQUIRE `. + Inquire(Vec, Vec), + /// `# …` + Comment, +} + +/// `text` cut at its first space: the word and what follows the space. +fn split_word(text: &[u8]) -> (&[u8], &[u8]) { + match text.iter().position(|b| *b == b' ') { + Some(i) => (text.get(..i).unwrap_or(&[]), text.get(i.saturating_add(1)..).unwrap_or(&[])), + None => (text, &[]), + } +} + +/// A line as the server sent it, without its line feed. `Err` for a line that isn't one of +/// Assuan's. +pub(crate) fn parse(line: &[u8]) -> Result { + if line.first() == Some(&b'#') { + return Ok(Line::Comment); + } + let (word, rest) = split_word(line); + match word { + b"OK" => Ok(Line::Ok(unescape(rest))), + b"D" => Ok(Line::Data(unescape(rest))), + b"ERR" => { + let (code, text) = split_word(rest); + let code = std::str::from_utf8(code) + .ok() + .filter(|c| !c.is_empty() && c.bytes().all(|b| b.is_ascii_digit())) + .and_then(|c| c.parse::().ok()) + .ok_or_else(|| Error::Protocol("an ERR line without its number".into()))?; + Ok(Line::Err(ServerError { code, text: crate::tidy(&unescape(text), MAX_TEXT) })) + } + b"S" | b"INQUIRE" => { + let (keyword, args) = split_word(rest); + if keyword.is_empty() || !keyword.iter().all(|b| b.is_ascii_graphic()) { + return Err(Error::Protocol("a status line or inquiry without its keyword".into())); + } + let (keyword, args) = (keyword.to_vec(), unescape(args)); + Ok(if word == b"S" { Line::Status(keyword, args) } else { Line::Inquire(keyword, args) }) + } + _ => Err(Error::Protocol(format!("a line beginning \"{}\"", crate::tidy(word, 16)))), + } +} + +/// Undo Assuan's escaping: `%XX` is the byte XX. A `%` not followed by two hex digits is kept +/// as it is. +pub(crate) fn unescape(text: &[u8]) -> Vec { + let mut out = Vec::with_capacity(text.len()); + let mut i = 0usize; + while let Some(&b) = text.get(i) { + if b == b'%' + && let (Some(h), Some(l)) = (text.get(i.saturating_add(1)).and_then(|c| hex(*c)), text.get(i.saturating_add(2)).and_then(|c| hex(*c))) + { + out.push((h << 4) | l); + i = i.saturating_add(3); + continue; + } + out.push(b); + i = i.saturating_add(1); + } + out +} + +pub(crate) fn hex(c: u8) -> Option { + match c { + b'0'..=b'9' => Some(c - b'0'), + b'a'..=b'f' => Some(c - b'a' + 10), + b'A'..=b'F' => Some(c - b'A' + 10), + _ => None, + } +} + +const HEX: &[u8; 16] = b"0123456789ABCDEF"; + +/// `%XX` for `byte`. +pub(crate) fn push_escaped(out: &mut Vec, byte: u8) { + out.push(b'%'); + out.push(HEX.get(usize::from(byte >> 4)).copied().unwrap_or(b'0')); + out.push(HEX.get(usize::from(byte & 0x0F)).copied().unwrap_or(b'0')); +} + +/// Assuan's escaping of data: `%`, CR and LF become `%25`, `%0D` and `%0A`. +pub(crate) fn escape_data(out: &mut Vec, byte: u8) { + match byte { + b'%' | b'\r' | b'\n' => push_escaped(out, byte), + _ => out.push(byte), + } +} + +/// Bytes that are wiped before their memory is given back: what a stream brought in may be a +/// session key. +#[derive(Default)] +struct Held(Vec); + +impl Drop for Held { + fn drop(&mut self) { + crate::wipe(&mut self.0); + } +} + +/// A stream with the bytes read ahead of what was asked for. +pub(crate) struct Wire { + stream: S, + /// Bytes read from the stream and not handed out yet (from `start`). + buf: Held, + start: usize, +} + +fn io_error(e: &std::io::Error) -> Error { + match e.kind() { + ErrorKind::TimedOut | ErrorKind::WouldBlock => Error::Io("gpg-agent took too long to answer".into()), + ErrorKind::UnexpectedEof | ErrorKind::ConnectionAborted | ErrorKind::ConnectionReset | ErrorKind::BrokenPipe => Error::Closed, + kind => Error::Io(format!("{kind}")), + } +} + +impl Wire { + pub(crate) fn new(stream: S) -> Self { + Wire { stream, buf: Held::default(), start: 0 } + } + + pub(crate) fn into_inner(self) -> S { + self.stream + } + + pub(crate) fn get_mut(&mut self) -> &mut S { + &mut self.stream + } +} + +impl Wire { + /// The next line without its line feed. A line longer than [`MAX_LINE`] (its line feed + /// included) is an error, and so is the end of the stream. + pub(crate) fn read_line(&mut self) -> Result, Error> { + let mut scanned = 0usize; + loop { + let pending = self.buf.0.get(self.start..).unwrap_or(&[]); + if let Some(i) = pending.get(scanned..).and_then(|rest| rest.iter().position(|b| *b == b'\n')) { + let end = scanned.saturating_add(i); + if end >= MAX_LINE { + return Err(Error::LineTooLong); + } + let line = pending.get(..end).unwrap_or(&[]); + let line = line.strip_suffix(b"\r").unwrap_or(line).to_vec(); + self.start = self.start.saturating_add(end).saturating_add(1); + return Ok(line); + } + scanned = pending.len(); + if scanned >= MAX_LINE { + return Err(Error::LineTooLong); + } + self.fill()?; + } + } + + /// Read some more into the buffer; the end of the stream is an error. + fn fill(&mut self) -> Result<(), Error> { + if self.start > 0 { + crate::discard_front(&mut self.buf.0, self.start); + self.start = 0; + } + let mut tmp = Held(vec![0u8; CHUNK]); + let mut interrupts = 0u32; + let n = loop { + match self.stream.read(&mut tmp.0) { + Ok(0) => return Err(Error::Closed), + Ok(n) => break n, + Err(e) if e.kind() == ErrorKind::Interrupted && interrupts < MAX_INTERRUPTS => interrupts = interrupts.saturating_add(1), + Err(e) => return Err(io_error(&e)), + } + }; + crate::extend_wiped(&mut self.buf.0, tmp.0.get(..n).unwrap_or(&[])); + Ok(()) + } + + /// Send one line: `line` must hold no line feed and be short enough (the caller checks). + pub(crate) fn write_line(&mut self, line: &[u8]) -> Result<(), Error> { + let mut out = Held(Vec::with_capacity(line.len().saturating_add(1))); + out.0.extend_from_slice(line); + out.0.push(b'\n'); + self.stream.write_all(&out.0).map_err(|e| io_error(&e))?; + self.stream.flush().map_err(|e| io_error(&e)) + } + + /// Send `data` as `D` lines, each at most [`MAX_LINE`] bytes, then `END`. + pub(crate) fn write_data(&mut self, data: &[u8]) -> Result<(), Error> { + let mut out = Held(Vec::with_capacity(MAX_LINE)); + let mut bytes = data.iter().copied().peekable(); + while bytes.peek().is_some() { + out.0.clear(); + out.0.extend_from_slice(b"D "); + // Room for one more escaped byte (three) and the line feed. + while out.0.len().saturating_add(4) <= MAX_LINE { + match bytes.next() { + Some(b) => escape_data(&mut out.0, b), + None => break, + } + } + out.0.push(b'\n'); + self.stream.write_all(&out.0).map_err(|e| io_error(&e))?; + } + self.write_line(b"END") + } +} diff --git a/crates/assuan/src/line_tests.rs b/crates/assuan/src/line_tests.rs new file mode 100644 index 000000000..6b793c8b3 --- /dev/null +++ b/crates/assuan/src/line_tests.rs @@ -0,0 +1,133 @@ +use proptest::prelude::*; + +use crate::fake::Canned; +use crate::line::{Line, Wire, escape_data, parse, unescape}; +use crate::{Error, MAX_LINE, ServerError}; + +#[test] +fn each_kind_of_line() { + assert_eq!(parse(b"OK Pleased to meet you").unwrap(), Line::Ok(b"Pleased to meet you".to_vec())); + assert_eq!(parse(b"OK").unwrap(), Line::Ok(Vec::new())); + assert_eq!(parse(b"D (3:abc)").unwrap(), Line::Data(b"(3:abc)".to_vec())); + assert_eq!(parse(b"D 100%25%0A%0d").unwrap(), Line::Data(b"100%\n\r".to_vec())); + assert_eq!(parse(b"# a comment").unwrap(), Line::Comment); + assert_eq!(parse(b"#").unwrap(), Line::Comment); + assert_eq!(parse(b"S PADDING 0").unwrap(), Line::Status(b"PADDING".to_vec(), b"0".to_vec())); + assert_eq!(parse(b"S PROGRESS").unwrap(), Line::Status(b"PROGRESS".to_vec(), Vec::new())); + assert_eq!(parse(b"INQUIRE CIPHERTEXT").unwrap(), Line::Inquire(b"CIPHERTEXT".to_vec(), Vec::new())); + assert_eq!( + parse(b"ERR 67108881 No secret key ").unwrap(), + Line::Err(ServerError { code: 67_108_881, text: "No secret key ".into() }) + ); +} + +#[test] +fn junk_lines_are_errors_not_panics() { + for junk in [&b""[..], b"HELLO", b"ok", b"ERR", b"ERR x", b"ERR 99999999999", b"S", b"S \x01", b"INQUIRE ", b"\xff\xfe"] { + assert!(matches!(parse(junk), Err(Error::Protocol(_))), "{junk:?}"); + } +} + +#[test] +fn an_error_text_is_tidied() { + let Line::Err(e) = parse(b"ERR 99 bad%0Aline\x07 here").unwrap() else { panic!() }; + assert_eq!(e.text, "bad line here"); + assert_eq!(e.kind(), crate::ErrorKind::Canceled); + let long = format!("ERR 1 {}", "x".repeat(900)); + let Line::Err(e) = parse(long.as_bytes()).unwrap() else { panic!() }; + assert_eq!(e.text.len(), 200); +} + +#[test] +fn unescaping_keeps_a_stray_percent() { + assert_eq!(unescape(b"50% off %zz %4"), b"50% off %zz %4".to_vec()); + assert_eq!(unescape(b"%41%42c"), b"ABc".to_vec()); +} + +#[test] +fn a_line_of_the_longest_size_is_read_and_one_byte_more_is_not() { + let longest = format!("D {}\n", "a".repeat(MAX_LINE - 3)); + assert_eq!(longest.len(), MAX_LINE); + let mut wire = Wire::new(Canned::new(&longest)); + assert_eq!(wire.read_line().unwrap().len(), MAX_LINE - 1); + + let over = format!("D {}\n", "a".repeat(MAX_LINE - 2)); + let mut wire = Wire::new(Canned::new(&over)); + assert_eq!(wire.read_line(), Err(Error::LineTooLong)); + + // One that never ends is stopped too, without reading it all. + let endless = "a".repeat(10 * MAX_LINE); + let mut wire = Wire::new(Canned::new(&endless)); + assert_eq!(wire.read_line(), Err(Error::LineTooLong)); +} + +#[test] +fn the_end_of_the_stream_is_closed() { + let mut wire = Wire::new(Canned::new("OK\nhalf")); + assert_eq!(wire.read_line().unwrap(), b"OK".to_vec()); + assert_eq!(wire.read_line(), Err(Error::Closed)); +} + +#[test] +fn data_goes_in_lines_of_at_most_the_longest_size() { + let data: Vec = (0..5000u32).map(|i| (i % 256) as u8).collect(); + let mut wire = Wire::new(Canned::new("")); + wire.write_data(&data).unwrap(); + let written = wire.into_inner().written; + let mut back = Vec::new(); + let lines: Vec<&[u8]> = written.split(|b| *b == b'\n').collect(); + assert_eq!(lines.last(), Some(&&b""[..])); + for line in &lines[..lines.len() - 2] { + assert!(line.len() < MAX_LINE, "{}", line.len()); + assert!(line.starts_with(b"D ")); + assert!(!line.contains(&b'\r')); + back.extend(unescape(&line[2..])); + } + assert_eq!(lines[lines.len() - 2], b"END"); + assert_eq!(back, data); +} + +#[test] +fn escaping_data() { + let mut out = Vec::new(); + for b in b"a%\r\nb" { + escape_data(&mut out, *b); + } + assert_eq!(out, b"a%25%0D%0Ab".to_vec()); +} + +proptest! { + #[test] + fn any_bytes_read_as_lines_never_panic(bytes in proptest::collection::vec(any::(), 0..4096)) { + let mut wire = Wire::new(Canned::new(&bytes)); + for _ in 0..64 { + match wire.read_line() { + Ok(line) => { + prop_assert!(line.len() < MAX_LINE); + let _ = parse(&line); + } + Err(_) => break, + } + } + } + + #[test] + fn any_data_comes_back_through_escaping(data in proptest::collection::vec(any::(), 0..3000)) { + let mut wire = Wire::new(Canned::new("")); + wire.write_data(&data).unwrap(); + let written = wire.into_inner().written; + let mut reader = Wire::new(Canned::new(&written)); + let mut back = Vec::new(); + loop { + let line = reader.read_line().unwrap(); + if line == b"END" { + break; + } + match parse(&line).unwrap() { + Line::Data(d) => back.extend(d), + other => panic!("{other:?}"), + } + } + prop_assert_eq!(back, data); + } +} diff --git a/crates/assuan/src/sexp.rs b/crates/assuan/src/sexp.rs new file mode 100644 index 000000000..f2df8b48f --- /dev/null +++ b/crates/assuan/src/sexp.rs @@ -0,0 +1,229 @@ +//! Canonical S-expressions (Rivest's draft, the form gpg-agent uses): lists in parentheses and +//! atoms written as their length, a colon and their bytes (`(7:sig-val(3:rsa(1:s3:abc)))`). +//! +//! The reader takes the canonical form only, and holds what it reads to caps: [`MAX_INPUT`] +//! bytes, [`MAX_DEPTH`] lists deep and [`MAX_NODES`] atoms and lists. A display hint +//! (`[4:text]` before an atom) is read past. Atoms are wiped from memory when they are dropped. + +/// Longest S-expression read. +pub const MAX_INPUT: usize = 256 * 1024; +/// Deepest nesting of lists. +pub const MAX_DEPTH: usize = 32; +/// Most atoms and lists in one S-expression. +pub const MAX_NODES: usize = 4096; +/// Most digits of an atom's length. +const MAX_LENGTH_DIGITS: usize = 9; + +/// An S-expression. +#[derive(Clone, PartialEq, Eq)] +pub enum Sexp { + Atom(Vec), + List(Vec), +} + +impl Drop for Sexp { + fn drop(&mut self) { + if let Sexp::Atom(bytes) = self { + crate::wipe(bytes); + } + } +} + +impl std::fmt::Debug for Sexp { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + // A token is shown; any other atom may be secret, and only its size is. + Sexp::Atom(b) if is_token(b) => write!(f, "{}", String::from_utf8_lossy(b)), + Sexp::Atom(b) => write!(f, "<{} bytes>", b.len()), + Sexp::List(items) => f.debug_list().entries(items).finish(), + } + } +} + +fn is_token(bytes: &[u8]) -> bool { + !bytes.is_empty() + && bytes.len() <= 32 + && bytes.first().is_some_and(|b| b.is_ascii_alphabetic()) + && bytes.iter().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.')) +} + +/// Why an S-expression couldn't be read. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum SexpError { + #[error("the S-expression ends early")] + Truncated, + #[error("the S-expression is too big")] + TooBig, + #[error("the S-expression is nested too deeply")] + TooDeep, + #[error("the S-expression isn't in canonical form at byte {0}")] + Malformed(usize), + #[error("something follows the S-expression")] + Trailing, +} + +/// Read one canonical S-expression that is the whole of `input`. +pub fn parse(input: &[u8]) -> Result { + if input.len() > MAX_INPUT { + return Err(SexpError::TooBig); + } + let mut reader = Reader { input, pos: 0, nodes: 0 }; + let sexp = reader.value(0)?; + if reader.pos != input.len() { + return Err(SexpError::Trailing); + } + Ok(sexp) +} + +struct Reader<'a> { + input: &'a [u8], + pos: usize, + nodes: usize, +} + +impl Reader<'_> { + fn peek(&self) -> Option { + self.input.get(self.pos).copied() + } + + fn count(&mut self) -> Result<(), SexpError> { + self.nodes = self.nodes.saturating_add(1); + if self.nodes > MAX_NODES { Err(SexpError::TooBig) } else { Ok(()) } + } + + fn value(&mut self, depth: usize) -> Result { + match self.peek() { + None => Err(SexpError::Truncated), + Some(b'(') => { + if depth >= MAX_DEPTH { + return Err(SexpError::TooDeep); + } + self.count()?; + self.pos = self.pos.saturating_add(1); + let mut items = Vec::new(); + loop { + match self.peek() { + None => return Err(SexpError::Truncated), + Some(b')') => { + self.pos = self.pos.saturating_add(1); + return Ok(Sexp::List(items)); + } + Some(_) => items.push(self.value(depth.saturating_add(1))?), + } + } + } + Some(b'[') => { + // A display hint, then the atom it is for. + self.pos = self.pos.saturating_add(1); + drop(self.atom()?); + if self.peek() != Some(b']') { + return if self.peek().is_none() { Err(SexpError::Truncated) } else { Err(SexpError::Malformed(self.pos)) }; + } + self.pos = self.pos.saturating_add(1); + if self.peek() == Some(b'[') { + return Err(SexpError::Malformed(self.pos)); + } + self.count()?; + Ok(Sexp::Atom(self.atom()?)) + } + Some(_) => { + self.count()?; + Ok(Sexp::Atom(self.atom()?)) + } + } + } + + /// `:`. + fn atom(&mut self) -> Result, SexpError> { + let start = self.pos; + let mut length = 0usize; + let mut digits = 0usize; + loop { + match self.peek() { + None => return Err(SexpError::Truncated), + Some(b':') if digits > 0 => break, + Some(d @ b'0'..=b'9') => { + // No leading zero (but the length 0 itself). + if digits == 1 && length == 0 { + return Err(SexpError::Malformed(self.pos)); + } + digits = digits.saturating_add(1); + if digits > MAX_LENGTH_DIGITS { + return Err(SexpError::TooBig); + } + length = length.saturating_mul(10).saturating_add(usize::from(d - b'0')); + self.pos = self.pos.saturating_add(1); + } + Some(_) => return Err(SexpError::Malformed(start)), + } + } + // Past the colon. + self.pos = self.pos.saturating_add(1); + let end = self.pos.checked_add(length).ok_or(SexpError::TooBig)?; + let bytes = self.input.get(self.pos..end).ok_or(SexpError::Truncated)?; + self.pos = end; + Ok(bytes.to_vec()) + } +} + +impl Sexp { + /// An atom made of a text token. + pub fn token(text: &str) -> Sexp { + Sexp::Atom(text.as_bytes().to_vec()) + } + + /// The bytes of an atom. + pub fn as_atom(&self) -> Option<&[u8]> { + match self { + Sexp::Atom(b) => Some(b), + Sexp::List(_) => None, + } + } + + /// The items of a list. + pub fn as_list(&self) -> Option<&[Sexp]> { + match self { + Sexp::List(items) => Some(items), + Sexp::Atom(_) => None, + } + } + + /// A list's name: its first item, when that is an atom of text. + pub fn name(&self) -> Option<&str> { + self.as_list()?.first()?.as_atom().and_then(|b| std::str::from_utf8(b).ok()) + } + + /// The first item of this list that is a list named `name`. + pub fn find(&self, name: &str) -> Option<&Sexp> { + self.as_list()?.iter().find(|item| item.name() == Some(name)) + } + + /// The atom of `(name value)` among this list's items. + pub fn value(&self, name: &str) -> Option<&[u8]> { + self.find(name)?.as_list()?.get(1)?.as_atom() + } + + /// The canonical form. + pub fn to_canonical(&self) -> Vec { + let mut out = Vec::new(); + self.write(&mut out); + out + } + + fn write(&self, out: &mut Vec) { + match self { + Sexp::Atom(b) => { + out.extend_from_slice(b.len().to_string().as_bytes()); + out.push(b':'); + out.extend_from_slice(b); + } + Sexp::List(items) => { + out.push(b'('); + for item in items { + item.write(out); + } + out.push(b')'); + } + } + } +} diff --git a/crates/assuan/src/sexp_tests.rs b/crates/assuan/src/sexp_tests.rs new file mode 100644 index 000000000..5bfea2e86 --- /dev/null +++ b/crates/assuan/src/sexp_tests.rs @@ -0,0 +1,103 @@ +use proptest::prelude::*; + +use crate::sexp::{MAX_DEPTH, MAX_INPUT, MAX_NODES, Sexp, SexpError, parse}; + +fn atom(b: &[u8]) -> Sexp { + Sexp::Atom(b.to_vec()) +} + +#[test] +fn a_signature_is_read() { + let s = parse(b"(7:sig-val(5:ecdsa(1:r3:\x01\x02\x03)(1:s2:\x04\x05)))").unwrap(); + assert_eq!(s.name(), Some("sig-val")); + let ecdsa = s.find("ecdsa").unwrap(); + assert_eq!(ecdsa.value("r"), Some(&b"\x01\x02\x03"[..])); + assert_eq!(ecdsa.value("s"), Some(&b"\x04\x05"[..])); + assert_eq!(ecdsa.value("t"), None); +} + +#[test] +fn the_canonical_form_comes_back_the_same() { + let text = b"(10:public-key(3:rsa(1:n3:abc)(1:e1:\x01)))"; + let s = parse(text).unwrap(); + assert_eq!(s.to_canonical(), text.to_vec()); + assert_eq!(Sexp::List(vec![Sexp::token("value"), atom(b"")]).to_canonical(), b"(5:value0:)".to_vec()); +} + +#[test] +fn a_display_hint_is_read_past() { + let s = parse(b"(3:foo[10:text/plain]5:hello)").unwrap(); + assert_eq!(s, Sexp::List(vec![atom(b"foo"), atom(b"hello")])); +} + +#[test] +fn malformed_input_is_refused() { + let cases: &[(&[u8], SexpError)] = &[ + (b"", SexpError::Truncated), + (b"(", SexpError::Truncated), + (b"(3:ab", SexpError::Truncated), + (b"(3:abc", SexpError::Truncated), + (b"5:abc", SexpError::Truncated), + (b"(3:abc))", SexpError::Trailing), + (b"3:abc3:def", SexpError::Trailing), + (b"(abc)", SexpError::Malformed(1)), + (b":", SexpError::Malformed(0)), + (b"03:abc", SexpError::Malformed(1)), + (b"9999999999:a", SexpError::TooBig), + (b"999999999:a", SexpError::Truncated), + (b"([1:a]", SexpError::Truncated), + (b"([1:a]1:b", SexpError::Truncated), + (b"[1:a][1:b]1:c", SexpError::Malformed(5)), + (b"[1:a1:b", SexpError::Malformed(4)), + ]; + for (input, want) in cases { + assert_eq!(parse(input).as_ref().err(), Some(want), "{:?}", String::from_utf8_lossy(input)); + } +} + +#[test] +fn depth_nodes_and_size_are_capped() { + let deep = |n: usize| format!("{}{}", "(".repeat(n), ")".repeat(n)); + assert!(parse(deep(MAX_DEPTH).as_bytes()).is_ok()); + assert_eq!(parse(deep(MAX_DEPTH + 1).as_bytes()), Err(SexpError::TooDeep)); + assert_eq!(parse("(".repeat(100_000).as_bytes()), Err(SexpError::TooDeep)); + + let many = |n: usize| format!("({})", "0:".repeat(n)); + assert!(parse(many(MAX_NODES - 1).as_bytes()).is_ok()); + assert_eq!(parse(many(MAX_NODES).as_bytes()), Err(SexpError::TooBig)); + + let big = vec![b'('; MAX_INPUT + 1]; + assert_eq!(parse(&big), Err(SexpError::TooBig)); +} + +#[test] +fn debug_shows_tokens_but_not_secrets() { + let s = parse(b"(5:value4:\x00key)").unwrap(); + assert_eq!(format!("{s:?}"), "[value, <4 bytes>]"); +} + +fn arb_sexp() -> impl Strategy { + let leaf = proptest::collection::vec(any::(), 0..40).prop_map(Sexp::Atom); + leaf.prop_recursive(6, 64, 6, |inner| proptest::collection::vec(inner, 0..6).prop_map(Sexp::List)) +} + +proptest! { + #[test] + fn any_bytes_never_panic(bytes in proptest::collection::vec(any::(), 0..2048)) { + if let Ok(s) = parse(&bytes) { + // What is read is canonical once more, apart from display hints, which are dropped. + let again = s.to_canonical(); + prop_assert_eq!(parse(&again).unwrap(), s); + } + } + + #[test] + fn written_s_expressions_read_back(s in arb_sexp()) { + prop_assert_eq!(parse(&s.to_canonical()).unwrap(), s); + } + + #[test] + fn bracket_soup_never_panics(bytes in proptest::collection::vec(prop_oneof![Just(b'('), Just(b')'), Just(b'['), Just(b']'), Just(b':'), Just(b'1'), Just(b'0'), Just(b'a')], 0..512)) { + let _ = parse(&bytes); + } +} diff --git a/crates/assuan/src/socket_file.rs b/crates/assuan/src/socket_file.rs new file mode 100644 index 000000000..76eef5668 --- /dev/null +++ b/crates/assuan/src/socket_file.rs @@ -0,0 +1,86 @@ +//! The files that may stand where an Assuan socket is named. +//! +//! - Windows has no Unix domain sockets for Assuan to use, so the server listens on a TCP port +//! of the loopback address and writes a file where the socket would be: the port in decimal, a +//! line feed, and a 16-byte nonce. A client connects to `127.0.0.1` on that port and sends the +//! nonce before anything else; the server drops a connection that doesn't. +//! - On any system the name may hold a redirect instead: a regular file whose first line is +//! `%Assuan%` and which names the real socket in a line `socket=`. +//! +//! [`parse`] reads either, from the file's bytes; opening the connection is the program's. + +/// Longest socket file read. +pub const MAX_FILE: usize = 4 * 1024; +/// Size of the nonce. +pub const NONCE_LEN: usize = 16; + +/// What a socket file says. +#[derive(Clone, PartialEq, Eq)] +pub enum SocketFile { + /// Connect to `127.0.0.1:port` and send `nonce` first. + Loopback { port: u16, nonce: [u8; NONCE_LEN] }, + /// The socket is this one instead (an absolute name). + Redirect(String), +} + +impl std::fmt::Debug for SocketFile { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + // The nonce is what lets a program in: never shown. + SocketFile::Loopback { port, .. } => f.debug_struct("Loopback").field("port", port).finish_non_exhaustive(), + SocketFile::Redirect(to) => f.debug_tuple("Redirect").field(to).finish(), + } + } +} + +/// Why a socket file couldn't be read. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum SocketFileError { + #[error("the socket file is too big")] + TooBig, + #[error("the socket file names no port")] + NoPort, + #[error("the socket file's nonce isn't 16 bytes")] + BadNonce, + #[error("the socket file's redirect names no usable socket")] + BadRedirect, +} + +/// Read a socket file. +pub fn parse(file: &[u8]) -> Result { + if file.len() > MAX_FILE { + return Err(SocketFileError::TooBig); + } + if let Some(rest) = file.strip_prefix(b"%Assuan%\n").or_else(|| file.strip_prefix(b"%Assuan%\r\n")) { + return redirect(rest); + } + let newline = file.iter().position(|b| *b == b'\n').ok_or(SocketFileError::NoPort)?; + let digits = file.get(..newline).ok_or(SocketFileError::NoPort)?; + let digits = digits.strip_suffix(b"\r").unwrap_or(digits); + if digits.is_empty() || digits.len() > 5 || !digits.iter().all(u8::is_ascii_digit) { + return Err(SocketFileError::NoPort); + } + let port = std::str::from_utf8(digits).ok().and_then(|d| d.parse::().ok()).filter(|p| *p != 0).ok_or(SocketFileError::NoPort)?; + let nonce = file.get(newline.saturating_add(1)..).ok_or(SocketFileError::BadNonce)?; + let nonce: [u8; NONCE_LEN] = nonce.try_into().map_err(|_| SocketFileError::BadNonce)?; + Ok(SocketFile::Loopback { port, nonce }) +} + +fn redirect(rest: &[u8]) -> Result { + let text = std::str::from_utf8(rest).map_err(|_| SocketFileError::BadRedirect)?; + let name = text.lines().find_map(|l| l.strip_prefix("socket=")).map(str::trim_end).ok_or(SocketFileError::BadRedirect)?; + // An absolute name on this computer, of printable characters; a variable to expand (`${…}`) + // is not followed, nor a name on another computer (`//host/share`, `\\host\share`: on + // Windows, opening one would reach out over SMB), nor a name with a drive given after a + // slash (`/C:/…`, `\\?\…`). + // A drive letter (`C:\…`, `C:/…`) is a name on this computer only on Windows. + let drive = cfg!(windows) + && name.as_bytes().first().is_some_and(u8::is_ascii_alphabetic) + && (name.get(1..3) == Some(":\\") || name.get(1..3) == Some(":/")); + let absolute = (name.starts_with('/') && !name.starts_with("//") && !name.contains('\\')) || drive; + let remote = name.starts_with("//") || name.starts_with("\\\\") || name.starts_with("/\\") || name.starts_with("\\/"); + if !absolute || remote || name.contains("${") || name.chars().any(char::is_control) || name.len() > 1024 { + return Err(SocketFileError::BadRedirect); + } + Ok(SocketFile::Redirect(name.to_string())) +} diff --git a/crates/assuan/src/socket_file_tests.rs b/crates/assuan/src/socket_file_tests.rs new file mode 100644 index 000000000..33c3c2b16 --- /dev/null +++ b/crates/assuan/src/socket_file_tests.rs @@ -0,0 +1,89 @@ +use proptest::prelude::*; + +use crate::socket_file::{MAX_FILE, SocketFile, SocketFileError, parse}; + +const NONCE: [u8; 16] = *b"\x00\x01\n\r%nonce-bytes"; + +fn file(port: &str) -> Vec { + let mut f = format!("{port}\n").into_bytes(); + f.extend_from_slice(&NONCE); + f +} + +#[test] +fn the_loopback_port_and_nonce() { + assert_eq!(parse(&file("51234")).unwrap(), SocketFile::Loopback { port: 51234, nonce: NONCE }); + assert_eq!(parse(&file("1")).unwrap(), SocketFile::Loopback { port: 1, nonce: NONCE }); +} + +#[test] +fn a_bad_port_or_nonce_is_refused() { + for port in ["", "0", "65536", "123456", "-1", " 80", "8o"] { + assert_eq!(parse(&file(port)), Err(SocketFileError::NoPort), "{port:?}"); + } + assert_eq!(parse(b"51234"), Err(SocketFileError::NoPort)); + assert_eq!(parse(b"51234\nshort"), Err(SocketFileError::BadNonce)); + let mut long = file("51234"); + long.push(0); + assert_eq!(parse(&long), Err(SocketFileError::BadNonce)); + assert_eq!(parse(&vec![b'1'; MAX_FILE + 1]), Err(SocketFileError::TooBig)); +} + +#[test] +fn the_nonce_is_never_shown() { + let shown = format!("{:?}", parse(&file("51234")).unwrap()); + assert_eq!(shown, "Loopback { port: 51234, .. }"); +} + +#[test] +fn a_redirect() { + assert_eq!( + parse(b"%Assuan%\nsocket=/run/user/1000/gnupg/S.gpg-agent\n").unwrap(), + SocketFile::Redirect("/run/user/1000/gnupg/S.gpg-agent".into()) + ); + let windows = parse(b"%Assuan%\r\nsocket=C:\\gnupg\\S.gpg-agent\r\n"); + let forward = parse(b"%Assuan%\nsocket=C:/gnupg/S.gpg-agent\n"); + if cfg!(windows) { + assert_eq!(windows.unwrap(), SocketFile::Redirect("C:\\gnupg\\S.gpg-agent".into())); + assert_eq!(forward.unwrap(), SocketFile::Redirect("C:/gnupg/S.gpg-agent".into())); + } else { + // A drive letter means nothing elsewhere: `C:/…` would be a relative name. + assert_eq!(windows, Err(SocketFileError::BadRedirect)); + assert_eq!(forward, Err(SocketFileError::BadRedirect)); + } + for bad in [ + &b"%Assuan%\n"[..], + b"%Assuan%\nsocket=relative/path\n", + b"%Assuan%\nsocket=${HOME}/S.gpg-agent\n", + b"%Assuan%\nsocket=/a\x07b\n", + b"%Assuan%\nsocket=\xff\n", + ] { + assert_eq!(parse(bad), Err(SocketFileError::BadRedirect), "{:?}", String::from_utf8_lossy(bad)); + } +} + +proptest! { + #[test] + fn any_bytes_never_panic(bytes in proptest::collection::vec(any::(), 0..64)) { + let _ = parse(&bytes); + let mut redirect = b"%Assuan%\n".to_vec(); + redirect.extend_from_slice(&bytes); + let _ = parse(&redirect); + } +} + +#[test] +fn a_redirect_to_another_computer_is_refused() { + for remote in [ + "//host/share/S.gpg-agent", + "\\\\host\\share\\S.gpg-agent", + "\\\\?\\C:\\S.gpg-agent", + "/\\host\\share", + "\\/host/share", + "1:/x", + "/home/x\\..\\y", + ] { + let file = format!("%Assuan%\nsocket={remote}\n"); + assert_eq!(parse(file.as_bytes()), Err(SocketFileError::BadRedirect), "{remote}"); + } +}