Add craft-assuan: the Assuan client and gpg-agent operations from MailCraft (#902)

Moved from MailCraft's crates/assuan at a44a45013d1c. The code is unchanged apart
from the crate name and doc wording that named the app. The S-expression reader,
socket-file reader, limits and all 60 tests come with it.
This commit is contained in:
Dylan M. Taylor authored and GitHub committed 2026-11-11 18:08:00 -05:00
1 parent 97bb39ca15
commit a1e9eb8a31
17 files changed
+2520

No files matched your search

Generated
+8
View File
@@ -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"
+1
View File
@@ -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 }
+1
View File
@@ -34,6 +34,7 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
- [`craft-ai-core`](crates/ai-core/README.md): the data model and contract for AI in the Craft Apps (connections, models, tools, requests, jobs, consent, history, route spec). Pure, no I/O, wasm-safe. Contract in [docs/ai-shared-layer.md](docs/ai-shared-layer.md).
- [`craft-pdf`](crates/pdf/README.md): independently optional PDF parsing, interpretation, rasterization and guarded authoring. Apps retain layout, fonts, editing and export policy; writer-only consumers do not enable the shared reader.
- [`craft-assuan`](crates/assuan/README.md): the Assuan protocol and gpg-agent's operations (key lookup, signing a digest, decrypting a session key) over any `Read + Write` stream, with a bounded canonical S-expression reader. No sockets; everything the agent sends is capped.
- [`craft-affinity`](crates/affinity/README.md): bounded reader for native Affinity documents (`.af`, `.afdesign`, `.afphoto`, `.afpub`): archive, object stream and a neutral document model with layers, artboards, vectors, text, tables, images and masks. Read only; mapping onto an app's document stays in the app.
- [`craft-atomic`](crates/atomic/README.md): Crash-safe atomic file writing and replacement with same-directory staging, directory fsync, Apple `F_FULLFSYNC` fallback, transient lock backoff, and fault injection for the Craft suite.
- [`craft-captions`](crates/captions/README.md): caption and subtitle files behind one neutral
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "craft-assuan"
description = "An Assuan client and gpg-agent's operations over any Read + Write stream, with a bounded canonical S-expression reader; no sockets."
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
repository.workspace = true
[lints]
workspace = true
[dependencies]
thiserror = { workspace = true }
[dev-dependencies]
proptest = { workspace = true }
+27
View File
@@ -0,0 +1,27 @@
# craft-assuan
The Assuan protocol and gpg-agent's operations, without sockets.
OpenPGP private keys stay in GnuPG (on disk or on a smartcard) and are used through gpg-agent,
which asks for the PIN or passphrase in its own pinentry. This crate speaks to the agent over any
`Read + Write` stream the caller opened.
- **`Client`**: Assuan's line framing (lines of at most `MAX_LINE` bytes, percent-escaping), the
server's `OK`, `ERR`, `S`, `D`, `INQUIRE` and `#` lines, and answers to an inquiry (`D` … `END`,
or `CAN`).
- **`GpgAgent`**: whether a key is there (`HAVEKEY`, `KEYINFO`, `READKEY`), signing a digest
(`SIGKEY`, `SETHASH`, `PKSIGN`; SHA-256, SHA-384 and SHA-512 only), decrypting a session key
(`SETKEY`, `PKDECRYPT`), and the hints that let the agent's 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. Everything the agent sends is treated
as hostile: lines, data, status lines, inquiries and S-expressions are capped (`MAX_LINE`,
`MAX_DATA`, `MAX_LINES`, `MAX_STATUS`, `MAX_INQUIRIES`, `MAX_INQUIRY_DATA`, `MAX_KEYS`), and
nothing here panics on junk. A session key that comes back is wiped from memory when it is
dropped and is never shown by `Debug`.
Written from the public documentation of the Assuan protocol and of gpg-agent's commands. Moved
from MailCraft's `mailcraft-assuan`.
+484
View File
@@ -0,0 +1,484 @@
//! gpg-agent's commands, as its documentation (the GnuPG manual's "Agent Protocol") gives them.
use std::io::{Read, Write};
use crate::client::{Answer, Client, Inquiry, Response};
use crate::line::push_escaped;
use crate::sexp::{self, Sexp};
use crate::{Error, ErrorKind, MAX_LINE};
/// Most keys [`GpgAgent::list_keys`] hands back.
pub const MAX_KEYS: usize = 1024;
/// Longest description of what a key is wanted for ([`GpgAgent::sign`], [`GpgAgent::decrypt`]).
pub const MAX_DESCRIPTION: usize = 300;
/// A keygrip: the 20 bytes by which gpg-agent knows a key, written as 40 hex digits.
#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct Keygrip(pub [u8; 20]);
impl Keygrip {
/// From its 40 hex digits (either case).
pub fn parse(hex: &str) -> Option<Keygrip> {
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<u8>,
}
impl Digest {
/// `None` when `bytes` isn't the size `algorithm` makes.
pub fn new(algorithm: HashAlgorithm, bytes: &[u8]) -> Option<Digest> {
(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<String>,
/// The key's name on the card (`OPENPGP.1`…), for a key on one.
pub card_key: Option<String>,
/// 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:
/// `<keygrip> <type> <serialno> <idstr> <cached> <protection> …`.
fn key_info(args: &str) -> Option<KeyInfo> {
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 (<algorithm> (<name> <value>)…))`.
#[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<Signature, Error> {
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<u8>,
padding: Option<u32>,
}
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<u32> {
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<String>,
/// The terminal (`GPG_TTY`), for a pinentry in one.
pub ttyname: Option<String>,
/// `TERM`.
pub ttytype: Option<String>,
/// `XAUTHORITY`.
pub xauthority: Option<String>,
/// `LC_CTYPE`.
pub lc_ctype: Option<String>,
/// `LC_MESSAGES`.
pub lc_messages: Option<String>,
/// 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<String>) -> 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<String> {
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<String, Error> {
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<S> = 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<S> {
client: Client<S>,
owner_wait: Option<OwnerWait<S>>,
}
impl<S: Read + Write> GpgAgent<S> {
/// Take over a fresh connection to the agent (its greeting is read).
pub fn open(stream: S) -> Result<Self, Error> {
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<S>) {
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<Response, Error> {
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<String, Error> {
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<bool, Error> {
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<Option<KeyInfo>, 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<Vec<KeyInfo>, 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<Sexp, Error> {
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<Signature, Error> {
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 (<algorithm> (<name> <value>)…))`) 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<SessionKey, Error> {
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::<u32>().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 <bytes>)`.
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()
}
}
+329
View File
@@ -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 <GPG Agent>\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 <GPG Agent>\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 <Pinentry>\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::<u8>(), 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);
}
+235
View File
@@ -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 <keyword> <arguments>`).
#[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<u8>,
/// The status lines, in order (at most [`MAX_STATUS`](crate::MAX_STATUS)).
pub status: Vec<Status>,
/// 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 <keyword> <arguments>`).
#[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<u8>),
/// `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<S> {
wire: Wire<S>,
/// An earlier command failed in a way that leaves the conversation out of step.
broken: bool,
}
impl<S> std::fmt::Debug for Client<S> {
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<S: Read + Write> Client<S> {
/// 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<Self, Error> {
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<Response, Error> {
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<Response, Error> {
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<Response, Error> {
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<Response, Error> {
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<Error> = 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()
}
}
+201
View File
@@ -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::<u8>(), 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}");
}
+131
View File
@@ -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<u8>)>,
out: VecDeque<u8>,
partial: Vec<u8>,
/// Every line the client sent, in order.
pub(crate) sent: Vec<String>,
/// The lines that weren't what the script expected.
pub(crate) wrong: Vec<String>,
/// 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<usize> {
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<usize> {
self.partial.extend_from_slice(buf);
while let Some(i) = self.partial.iter().position(|b| *b == b'\n') {
let line: Vec<u8> = 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 <User defined source 1>\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<u8>,
pos: usize,
pub(crate) written: Vec<u8>,
}
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<usize> {
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<usize> {
self.written.extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> Result<()> {
Ok(())
}
}
+216
View File
@@ -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::<String>().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<u8>, 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<u8>, needed: usize) -> Vec<u8> {
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<u8>, 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);
}
+230
View File
@@ -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<u8>),
/// `ERR <code> <description>`.
Err(ServerError),
/// `S <keyword> <arguments>`.
Status(Vec<u8>, Vec<u8>),
/// `D <data>`, decoded.
Data(Vec<u8>),
/// `INQUIRE <keyword> <arguments>`.
Inquire(Vec<u8>, Vec<u8>),
/// `# …`
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<Line, Error> {
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::<u32>().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<u8> {
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<u8> {
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<u8>, 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<u8>, 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<u8>);
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<S> {
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<S> Wire<S> {
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<S: Read + Write> Wire<S> {
/// 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<Vec<u8>, 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")
}
}
+133
View File
@@ -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 <GPG Agent>").unwrap(),
Line::Err(ServerError { code: 67_108_881, text: "No secret key <GPG Agent>".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<u8> = (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::<u8>(), 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::<u8>(), 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);
}
}
+229
View File
@@ -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<u8>),
List(Vec<Sexp>),
}
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<Sexp, SexpError> {
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<u8> {
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<Sexp, SexpError> {
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()?))
}
}
}
/// `<length>:<bytes>`.
fn atom(&mut self) -> Result<Vec<u8>, 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<u8> {
let mut out = Vec::new();
self.write(&mut out);
out
}
fn write(&self, out: &mut Vec<u8>) {
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')');
}
}
}
}
+103
View File
@@ -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<Value = Sexp> {
let leaf = proptest::collection::vec(any::<u8>(), 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::<u8>(), 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);
}
}
+86
View File
@@ -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=<name>`.
//!
//! [`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<SocketFile, SocketFileError> {
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::<u16>().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<SocketFile, SocketFileError> {
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()))
}
+89
View File
@@ -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<u8> {
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::<u8>(), 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}");
}
}