diff options
author | Justus Winter <justus@sequoia-pgp.org> | 2019-06-08 14:47:27 +0200 |
---|---|---|
committer | Justus Winter <justus@sequoia-pgp.org> | 2019-06-26 09:20:33 +0200 |
commit | 433437b20b8cb33263a293f34589e29eeacabfca (patch) | |
tree | 9b2d2ec214f8595f6e8c7e4a741ea359ba89f586 | |
parent | adcf04a7a2489ff4d5fe983fe7caa76c22fded2b (diff) |
ipc: GnuPG RPC support.
- This allows us to communicate with gpg-agent, and use it for
cryptographic operations.
-rw-r--r-- | Cargo.lock | 1 | ||||
-rw-r--r-- | ipc/Cargo.toml | 1 | ||||
-rw-r--r-- | ipc/examples/gpg-agent-client.rs | 38 | ||||
-rw-r--r-- | ipc/examples/gpg-agent-decrypt.rs | 161 | ||||
-rw-r--r-- | ipc/examples/gpg-agent-sign.rs | 76 | ||||
-rw-r--r-- | ipc/src/gnupg.rs | 764 | ||||
-rw-r--r-- | ipc/src/lib.rs | 1 | ||||
-rw-r--r-- | ipc/tests/gpg-agent.rs | 282 |
8 files changed, 1324 insertions, 0 deletions
@@ -1582,6 +1582,7 @@ dependencies = [ "rand 0.6.5 (registry+https://github.com/rust-lang/crates.io-index)", "sequoia-core 0.8.0", "sequoia-openpgp 0.8.0", + "tempfile 3.0.8 (registry+https://github.com/rust-lang/crates.io-index)", "tokio 0.1.21 (registry+https://github.com/rust-lang/crates.io-index)", "tokio-core 0.1.17 (registry+https://github.com/rust-lang/crates.io-index)", "tokio-io 0.1.12 (registry+https://github.com/rust-lang/crates.io-index)", diff --git a/ipc/Cargo.toml b/ipc/Cargo.toml index c67378d1..9399fa23 100644 --- a/ipc/Cargo.toml +++ b/ipc/Cargo.toml @@ -31,6 +31,7 @@ lalrpop-util = "0.17" libc = "0.2.33" memsec = "0.5.6" rand = "0.6" +tempfile = "3.0" tokio = "0.1" tokio-core = "0.1" tokio-io = "0.1.4" diff --git a/ipc/examples/gpg-agent-client.rs b/ipc/examples/gpg-agent-client.rs new file mode 100644 index 00000000..c586cfca --- /dev/null +++ b/ipc/examples/gpg-agent-client.rs @@ -0,0 +1,38 @@ +/// Connects to and sends commands to gpg-agent. + +extern crate futures; +use futures::future::Future; +use futures::stream::Stream; +extern crate clap; +extern crate sequoia_ipc as ipc; +use ipc::gnupg::{Context, Agent}; + +fn main() { + let matches = clap::App::new("gpg-agent-client") + .version(env!("CARGO_PKG_VERSION")) + .about("Connects to and sends commands to gpg-agent.") + .arg(clap::Arg::with_name("homedir").value_name("PATH") + .long("homedir") + .help("Use this GnuPG home directory, default: $GNUPGHOME")) + .arg(clap::Arg::with_name("commands").value_name("COMMAND") + .required(true) + .multiple(true) + .help("Commands to send to the server")) + .get_matches(); + + let ctx = if let Some(homedir) = matches.value_of("homedir") { + Context::with_homedir(homedir).unwrap() + } else { + Context::new().unwrap() + }; + let mut agent = Agent::connect(&ctx).wait().unwrap(); + + for command in matches.values_of("commands").unwrap() { + eprintln!("> {}", command); + agent.send(command).unwrap(); + agent.by_ref().for_each(|response| { + eprintln!("< {:?}", response); + Ok(()) + }).wait().unwrap(); + } +} diff --git a/ipc/examples/gpg-agent-decrypt.rs b/ipc/examples/gpg-agent-decrypt.rs new file mode 100644 index 00000000..2e423f12 --- /dev/null +++ b/ipc/examples/gpg-agent-decrypt.rs @@ -0,0 +1,161 @@ +/// Decrypts data using the openpgp crate and secrets in gpg-agent. + +use std::collections::HashMap; +use std::io; + +extern crate clap; +extern crate sequoia_openpgp as openpgp; +extern crate sequoia_ipc as ipc; + +use openpgp::crypto::SessionKey; +use openpgp::constants::SymmetricAlgorithm; +use openpgp::parse::{ + Parse, + stream::{ + DecryptionHelper, + Decryptor, + VerificationHelper, + VerificationResult, + MessageStructure, + MessageLayer, + }, +}; +use ipc::gnupg::{Context, KeyPair}; + +fn main() { + let matches = clap::App::new("gpg-agent-decrypt") + .version(env!("CARGO_PKG_VERSION")) + .about("Connects to gpg-agent and decrypts a message.") + .arg(clap::Arg::with_name("homedir").value_name("PATH") + .long("homedir") + .help("Use this GnuPG home directory, default: $GNUPGHOME")) + .arg(clap::Arg::with_name("tpk").value_name("TPK") + .required(true) + .multiple(true) + .help("Public part of the secret keys managed by gpg-agent")) + .get_matches(); + + let ctx = if let Some(homedir) = matches.value_of("homedir") { + Context::with_homedir(homedir).unwrap() + } else { + Context::new().unwrap() + }; + + // Read the TPKs from the given files. + let tpks = + matches.values_of("tpk").expect("required").map(|f| { + openpgp::TPK::from_file(f) + .expect("Failed to read key") + }).collect(); + + // Now, create a decryptor with a helper using the given TPKs. + let mut decryptor = + Decryptor::from_reader(io::stdin(), Helper::new(&ctx, tpks), None) + .unwrap(); + + // Finally, stream the decrypted data to stdout. + io::copy(&mut decryptor, &mut io::stdout()) + .expect("Decryption failed"); +} + +/// This helper provides secrets for the decryption, fetches public +/// keys for the signature verification and implements the +/// verification policy. +struct Helper<'a> { + ctx: &'a Context, + keys: HashMap<openpgp::KeyID, openpgp::packet::Key>, +} + +impl<'a> Helper<'a> { + /// Creates a Helper for the given TPKs with appropriate secrets. + fn new(ctx: &'a Context, tpks: Vec<openpgp::TPK>) -> Self { + // Map (sub)KeyIDs to secrets. + let mut keys = HashMap::new(); + for tpk in tpks { + for (sig, _, key) in tpk.keys_all() { + if sig.map(|s| (s.key_flags().can_encrypt_at_rest() + || s.key_flags().can_encrypt_for_transport())) + .unwrap_or(false) + { + keys.insert(key.keyid(), key.clone()); + } + } + } + + Helper { ctx, keys, } + } +} + +impl<'a> DecryptionHelper for Helper<'a> { + fn decrypt<D>(&mut self, + pkesks: &[openpgp::packet::PKESK], + _skesks: &[openpgp::packet::SKESK], + mut decrypt: D) + -> openpgp::Result<Option<openpgp::Fingerprint>> + where D: FnMut(SymmetricAlgorithm, &SessionKey) -> openpgp::Result<()> + { + // Try each PKESK until we succeed. + for pkesk in pkesks { + if let Some(key) = self.keys.get(pkesk.recipient()) { + let mut pair = KeyPair::new(self.ctx, key)?; + if let Ok(_) = pkesk.decrypt(&mut pair) + .and_then(|(algo, session_key)| decrypt(algo, &session_key)) + { + break; + } + } + } + // XXX: In production code, return the Fingerprint of the + // recipient's TPK here + Ok(None) + } +} + +impl<'a> VerificationHelper for Helper<'a> { + fn get_public_keys(&mut self, _ids: &[openpgp::KeyID]) + -> failure::Fallible<Vec<openpgp::TPK>> { + Ok(Vec::new()) // Feed the TPKs to the verifier here. + } + fn check(&mut self, structure: &MessageStructure) + -> failure::Fallible<()> { + use self::VerificationResult::*; + for layer in structure.iter() { + match layer { + MessageLayer::Compression { algo } => + eprintln!("Compressed using {}", algo), + MessageLayer::Encryption { sym_algo, aead_algo } => + if let Some(aead_algo) = aead_algo { + eprintln!("Encrypted and protected using {}/{}", + sym_algo, aead_algo); + } else { + eprintln!("Encrypted using {}", sym_algo); + }, + MessageLayer::SignatureGroup { ref results } => + for result in results { + match result { + GoodChecksum(ref sig, ..) => { + let issuer = sig.issuer() + .expect("good checksum has an issuer"); + eprintln!("Good signature from {}", issuer); + }, + MissingKey(ref sig) => { + let issuer = sig.issuer() + .expect("missing key checksum has an \ + issuer"); + eprintln!("No key to check signature from {}", + issuer); + }, + BadChecksum(ref sig) => + if let Some(issuer) = sig.issuer() { + eprintln!("Bad signature from {}", issuer); + } else { + eprintln!("Bad signature without issuer \ + information"); + }, + } + } + } + } + Ok(()) // Implement your verification policy here. + } +} diff --git a/ipc/examples/gpg-agent-sign.rs b/ipc/examples/gpg-agent-sign.rs new file mode 100644 index 00000000..8525abbb --- /dev/null +++ b/ipc/examples/gpg-agent-sign.rs @@ -0,0 +1,76 @@ +/// Signs data using the openpgp crate and secrets in gpg-agent. + +use std::io; + +extern crate clap; +extern crate sequoia_openpgp as openpgp; +extern crate sequoia_ipc as ipc; + +use openpgp::armor; +use openpgp::constants::DataFormat; +use openpgp::parse::Parse; +use openpgp::serialize::stream::{Message, LiteralWriter, Signer}; +use ipc::gnupg::{Context, KeyPair}; + +fn main() { + let matches = clap::App::new("gpg-agent-sign") + .version(env!("CARGO_PKG_VERSION")) + .about("Connects to gpg-agent and creates a dummy signature.") + .arg(clap::Arg::with_name("homedir").value_name("PATH") + .long("homedir") + .help("Use this GnuPG home directory, default: $GNUPGHOME")) + .arg(clap::Arg::with_name("tpk").value_name("TPK") + .required(true) + .multiple(true) + .help("Public part of the secret keys managed by gpg-agent")) + .get_matches(); + + let ctx = if let Some(homedir) = matches.value_of("homedir") { + Context::with_homedir(homedir).unwrap() + } else { + Context::new().unwrap() + }; + + // Read the TPKs from the given files. + let tpks = + matches.values_of("tpk").expect("required").map(|f| { + openpgp::TPK::from_file(f) + .expect("Failed to read key") + }).collect::<Vec<_>>(); + + // Construct a KeyPair for every signing-capable (sub)key. + let mut keypairs = tpks.iter().flat_map(|tpk| tpk.keys_valid().signing_capable().filter_map(|(_, _, key)| { + KeyPair::new(&ctx, key).ok() + })).collect::<Vec<KeyPair>>(); + + // Well, this is awkward... + let signers = keypairs.iter_mut() + .map(|s| -> &mut dyn openpgp::crypto::Signer { s }) + .collect(); + + // Compose a writer stack corresponding to the output format and + // packet structure we want. First, we want the output to be + // ASCII armored. + let sink = armor::Writer::new(io::stdout(), armor::Kind::Message, &[]) + .expect("Failed to create an armored writer."); + + // Stream an OpenPGP message. + let message = Message::new(sink); + + // Now, create a signer that emits a signature. + let signer = Signer::new(message, signers, None) + .expect("Failed to create signer"); + + // Then, create a literal writer to wrap the data in a literal + // message packet. + let mut literal = LiteralWriter::new(signer, DataFormat::Binary, None, None) + .expect("Failed to create literal writer"); + + // Copy all the data. + io::copy(&mut io::stdin(), &mut literal) + .expect("Failed to sign data"); + + // Finally, teardown the stack to ensure all the data is written. + literal.finalize() + .expect("Failed to write data"); +} diff --git a/ipc/src/gnupg.rs b/ipc/src/gnupg.rs new file mode 100644 index 00000000..4236c9c4 --- /dev/null +++ b/ipc/src/gnupg.rs @@ -0,0 +1,764 @@ +//! GnuPG RPC support. + +#![warn(missing_docs)] + +use std::collections::BTreeMap; +use std::ops::{Deref, DerefMut}; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use futures::{Async, Future, Stream}; + +extern crate libc; +extern crate tempfile; + +use openpgp::constants::HashAlgorithm; +use openpgp::conversions::hex; +use openpgp::crypto; +use openpgp::crypto::sexp::Sexp; +use openpgp::parse::Parse; +use openpgp::serialize::Serialize; + +use Result; +use assuan; + +/// A GnuPG context. +#[derive(Debug)] +pub struct Context { + homedir: Option<PathBuf>, + components: BTreeMap<String, PathBuf>, + directories: BTreeMap<String, PathBuf>, + sockets: BTreeMap<String, PathBuf>, + #[allow(dead_code)] // We keep it around for the cleanup. + ephemeral: Option<tempfile::TempDir>, +} + +impl Context { + /// Creates a new context for the default GnuPG home directory. + pub fn new() -> Result<Self> { + Self::make(None, None) + } + + /// Creates a new context for the given GnuPG home directory. + pub fn with_homedir<P>(homedir: P) -> Result<Self> + where P: AsRef<Path> + { + Self::make(Some(homedir.as_ref()), None) + } + + /// Creates a new ephemeral context. + /// + /// The created home directory will be deleted once this object is + /// dropped. + pub fn ephemeral() -> Result<Self> { + Self::make(None, Some(tempfile::tempdir()?)) + } + + fn make(homedir: Option<&Path>, ephemeral: Option<tempfile::TempDir>) + -> Result<Self> { + let mut components: BTreeMap<String, PathBuf> = Default::default(); + let mut directories: BTreeMap<String, PathBuf> = Default::default(); + let mut sockets: BTreeMap<String, PathBuf> = Default::default(); + + let homedir: Option<PathBuf> = + ephemeral.as_ref().map(|tmp| tmp.path()).or(homedir) + .map(|p| p.into()); + + for fields in Self::gpgconf( + &homedir, &["--list-components"], 3)?.into_iter() + { + components.insert(String::from_utf8(fields[0].clone())?, + String::from_utf8(fields[2].clone())?.into()); + } + + for fields in Self::gpgconf(&homedir, &["--list-dirs"], 2)?.into_iter() + { + let (mut key, value) = (fields[0].clone(), fields[1].clone()); + if key.ends_with(b"-socket") { + let l = key.len(); + key.truncate(l - b"-socket".len()); + sockets.insert(String::from_utf8(key)?, + String::from_utf8(value)?.into()); + } else { + directories.insert(String::from_utf8(key)?, + String::from_utf8(value)?.into()); + } + } + + Ok(Context { + homedir, + components, + directories, + sockets, + ephemeral, + }) + } + + fn gpgconf(homedir: &Option<PathBuf>, arguments: &[&str], nfields: usize) + -> Result<Vec<Vec<Vec<u8>>>> { + let nl = |&c: &u8| c as char == '\n'; + let colon = |&c: &u8| c as char == ':'; + + let mut gpgconf = Command::new("gpgconf"); + if let Some(homedir) = homedir { + gpgconf.arg("--homedir").arg(homedir); + + // https://dev.gnupg.org/T4496 + gpgconf.env("GNUPGHOME", homedir); + } + + for argument in arguments { + gpgconf.arg(argument); + } + let output = gpgconf.output().map_err(|e| -> failure::Error { + Error::GPGConf(e.to_string()).into() + })?; + + if output.status.success() { + let mut result = Vec::new(); + for line in output.stdout.split(nl) { + if line.len() == 0 { + // EOF. + break; + } + + let fields = + line.splitn(nfields, colon).map(|f| f.to_vec()) + .collect::<Vec<_>>(); + + if fields.len() != nfields { + return Err(Error::GPGConf( + format!("Malformed response, expected {} fields, \ + on line: {:?}", nfields, line)).into()); + } + + result.push(fields); + } + Ok(result) + } else { + Err(Error::GPGConf(String::from_utf8_lossy( + &output.stderr).into_owned()).into()) + } + } + + /// Returns the path to a GnuPG component. + pub fn component<C>(&self, component: C) -> Result<&Path> + where C: AsRef<str> + { + self.components.get(component.as_ref()) + .map(|p| p.as_path()) + .ok_or_else(|| { + Error::GPGConf(format!("No such component {:?}", + component.as_ref())).into() + }) + } + + /// Returns the path to a GnuPG directory. + pub fn directory<C>(&self, directory: C) -> Result<&Path> + where C: AsRef<str> + { + self.directories.get(directory.as_ref()) + .map(|p| p.as_path()) + .ok_or_else(|| { + Error::GPGConf(format!("No such directory {:?}", + directory.as_ref())).into() + }) + } + + /// Returns the path to a GnuPG socket. + pub fn socket<C>(&self, socket: C) -> Result<&Path> + where C: AsRef<str> + { + self.sockets.get(socket.as_ref()) + .map(|p| p.as_path()) + .ok_or_else(|| { + Error::GPGConf(format!("No such socket {:?}", + socket.as_ref())).into() + }) + } + + /// Creates directories for RPC communication. + pub fn create_socket_dir(&self) -> Result<()> { + Self::gpgconf(&self.homedir, &["--create-socketdir"], 1)?; + Ok(()) + } + + /// Removes directories for RPC communication. + /// + /// Note: This will stop all servers once they note that their + /// socket is gone. + pub fn remove_socket_dir(&self) -> Result<()> { + Self::gpgconf(&self.homedir, &["--remove-socketdir"], 1)?; + Ok(()) + } + + /// Starts a GnuPG component. + pub fn start(&self, component: &str) -> Result<()> { + self.create_socket_dir()?; + Self::gpgconf(&self.homedir, &["--launch", component], 1)?; + Ok(()) + } + + /// Stops a GnuPG component. + pub fn stop(&self, component: &str) -> Result<()> { + Self::gpgconf(&self.homedir, &["--kill", component], 1)?; + Ok(()) + } + + /// Stops all GnuPG components. + pub fn stop_all(&self) -> Result<()> { + self.stop("all") + } +} + +impl Drop for Context { + fn drop(&mut self) { + if self.ephemeral.is_some() { + let _ = self.stop_all(); + let _ = self.remove_socket_dir(); + } + } +} + +/// A connection to a GnuPG agent. +pub struct Agent { + c: assuan::Client, +} + +impl Deref for Agent { + type Target = assuan::Client; + + fn deref(&self) -> &Self::Target { + &self.c + } +} + +impl DerefMut for Agent { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.c + } +} + +impl Stream for Agent { + type Item = assuan::Response; + type Error = failure::Error; + + /// Attempt to pull out the next value of this stream, returning + /// None if the stream is finished. + /// + /// Note: It _is_ safe to call this again after the stream + /// finished, i.e. returned `Ready(None)`. + fn poll(&mut self) + -> std::result::Result<Async<Option<Self::Item>>, Self::Error> + { + self.c.poll() + } +} + +impl Agent { + /// Connects to the agent. + /// + /// Note: This function does not try to start the server. If no + /// server is running for the given context, this operation will + /// fail. + pub fn connect<'c>(ctx: &'c Context) + -> impl Future<Item = Self, Error = failure::Error> + 'c + { + futures::lazy(move || ctx.socket("agent")) + .and_then(Self::connect_to) + } + + /// Connects to the agent at the given path. + /// + /// Note: This function does not try to start the server. If no + /// server is running for the given context, this operation will + /// fail. + pub fn connect_to<P>(path: P) + -> impl Future<Item = Self, Error = failure::Error> + where P: AsRef<Path> + { + assuan::Client::connect(path) + .and_then(|c| Ok(Agent { c })) + } + + /// Creates a signature over the `digest` produced by `algo` using + /// `key` with the secret bits managed by the agent. + pub fn sign<'a>(&'a mut self, key: &'a openpgp::packet::Key, + algo: HashAlgorithm, digest: &'a [u8]) + -> impl Future<Item = crypto::mpis::Signature, + Error = failure::Error> + 'a + { + SigningRequest::new(&mut self.c, key, algo, digest) + } + + /// Decrypts `ciphertext` using `key` with the secret bits managed + /// by the agent. + pub fn decrypt<'a>(&'a mut self, key: &'a openpgp::packet::Key, + ciphertext: &'a crypto::mpis::Ciphertext) + -> impl Future<Item = crypto::SessionKey, + Error = failure::Error> + 'a + { + DecryptionRequest::new(&mut self.c, key, ciphertext) + } + + /// Computes options that we want to communicate. + fn options() -> Vec<String> { + use std::env::var; + use std::ffi::CStr; + + let mut r = Vec::new(); + + if let Ok(tty) = var("GPG_TTY") { + r.push(format!("OPTION ttyname={}", tty)); + } else { + unsafe { + let tty = libc::ttyname(0); + if ! tty.is_null() { + if let Ok(tty) = CStr::from_ptr(tty).to_str() { + r.push(format!("OPTION ttyname={}", tty)); + } + } + } + } + + if let Ok(term) = var("TERM") { + r.push(format!("OPTION ttytype={}", term)); + } + + if let Ok(display) = var("DISPLAY") { + r.push(format!("OPTION display={}", display)); + } + + if let Ok(xauthority) = var("XAUTHORITY") { + r.push(format!("OPTION xauthority={}", xauthority)); + } + + if let Ok(dbus) = var("DBUS_SESSION_BUS_ADDRESS") { + r.push(format!("OPTION putenv=DBUS_SESSION_BUS_ADDRESS={}", dbus)); + } + + // We're going to pop() options off the end, therefore reverse + // the vec here to preserve the above ordering, which is the + // one GnuPG uses. + r.reverse(); + r + } +} + +struct SigningRequest<'a, 'b, 'c> { + c: &'a mut assuan::Client, + key: &'b openpgp::packet::Key, + algo: HashAlgorithm, + digest: &'c [u8], + options: Vec<String>, + state: SigningRequestState, +} + +impl<'a, 'b, 'c> SigningRequest<'a, 'b, 'c> { + fn new(c: &'a mut assuan::Client, + key: &'b openpgp::packet::Key, + algo: HashAlgorithm, + digest: &'c [u8]) + -> Self { + Self { + c, key, algo, digest, + options: Agent::options(), + state: SigningRequestState::Start, + } + } +} + +#[derive(Debug)] +enum SigningRequestState { + Start, + Options, + SigKey, + SetHash, + PkSign(Vec<u8>), +} + +/// Returns a convenient Err value for use in the state machines +/// below. +fn operation_failed<T>(message: &Option<String>) -> Result<T> { + Err(Error::OperationFailed( + message.as_ref().map(|e| e.to_string()) + .unwrap_or_else(|| "Unknown reason".into())) + .into()) +} + +/// Returns a convenient Err value for use in the state machines +/// below. +fn protocol_error<T>(response: &assuan::Response) -> Result<T> { + Err(Error::ProtocolError( + format!("Got unexpected response {:?}", response)) + .into()) +} + +impl<'a, 'b, 'c> Future for SigningRequest<'a, 'b, 'c> { + type Item = crypto::mpis::Signature; + type Error = failure::Error; + + fn poll(&mut self) -> std::result::Result<Async<Self::Item>, Self::Error> { + use self::SigningRequestState::*; + + loop { + match self.state { + Start => { + if self.options.is_empty() { + self.c.send(format!("SIGKEY {}", + self.key.mpis().keygrip()?))?; + self.state = SigKey; + } else { + self.c.send(self.options.pop().unwrap())?; + self.state = Options; + } + }, + + Options => match self.c.poll()? { + Async::Ready(Some(r)) => match r { + assuan::Response::Ok { .. } + | assuan::Response::Comment { .. } + | assuan::Response::Status { .. } => + (), // Ignore. + assuan::Response::Error { ref message, .. } => + return operation_failed(message), + _ => + return protocol_error(&r), + }, + Async::Ready(None) => { + if let Some(option) = self.options.pop() { + self.c.send(option)?; + } else { + self.c.send(format!("SIGKEY {}", + self.key.mpis().keygrip()?))?; + self.state = SigKey; + } + }, + Async::NotReady => + return Ok(Async::NotReady), + }, + + SigKey => match self.c.poll()? { + Async::Ready(Some(r)) => match r { + |