mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-13 22:12:12 +00:00
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:
1 parent
97bb39ca15
commit
a1e9eb8a31
17 files changed
+2520
No files matched your search
Generated
+8
@@ -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"
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
@@ -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`.
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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')');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user