diff options
author | juga <juga@sequoia-pgp.org> | 2019-05-24 11:42:22 +0000 |
---|---|---|
committer | juga <juga@sequoia-pgp.org> | 2019-05-28 11:29:01 +0000 |
commit | 32795158e702c70d000b0f48d221950050bd5c2a (patch) | |
tree | 4c110943d30e4aa67517d8a74a3ac55921b4a721 | |
parent | a9f020e55d3102f4662233f70badfcf5e7319433 (diff) |
net: Add wkd module implementing a WKD client
- Also add an async::wkd module.
- Part of #251.
-rw-r--r-- | Cargo.lock | 11 | ||||
-rw-r--r-- | net/Cargo.toml | 3 | ||||
-rw-r--r-- | net/src/async.rs | 70 | ||||
-rw-r--r-- | net/src/lib.rs | 13 | ||||
-rw-r--r-- | net/src/wkd.rs | 291 |
5 files changed, 387 insertions, 1 deletions
@@ -1,3 +1,5 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. [[package]] name = "adler32" version = "1.0.3" @@ -1587,13 +1589,16 @@ dependencies = [ "hyper-tls 0.3.2 (registry+https://github.com/rust-lang/crates.io-index)", "libc 0.2.54 (registry+https://github.com/rust-lang/crates.io-index)", "native-tls 0.2.3 (registry+https://github.com/rust-lang/crates.io-index)", + "nettle 5.0.0 (registry+https://github.com/rust-lang/crates.io-index)", "percent-encoding 1.0.1 (registry+https://github.com/rust-lang/crates.io-index)", "rand 0.6.5 (registry+https://github.com/rust-lang/crates.io-index)", "sequoia-core 0.7.0", "sequoia-openpgp 0.7.0", + "sequoia-rfc2822 0.7.0", "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)", "url 1.7.2 (registry+https://github.com/rust-lang/crates.io-index)", + "zbase32 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)", ] [[package]] @@ -2261,6 +2266,11 @@ dependencies = [ "winapi-build 0.1.1 (registry+https://github.com/rust-lang/crates.io-index)", ] +[[package]] +name = "zbase32" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" + [metadata] "checksum adler32 1.0.3 (registry+https://github.com/rust-lang/crates.io-index)" = "7e522997b529f05601e05166c07ed17789691f562762c7f3b987263d2dedee5c" "checksum aho-corasick 0.7.3 (registry+https://github.com/rust-lang/crates.io-index)" = "e6f484ae0c99fec2e858eb6134949117399f222608d84cadb3f58c1f97c2364c" @@ -2502,3 +2512,4 @@ dependencies = [ "checksum wincolor 1.0.1 (registry+https://github.com/rust-lang/crates.io-index)" = "561ed901ae465d6185fa7864d63fbd5720d0ef718366c9a4dc83cf6170d7e9ba" "checksum winconsole 0.10.0 (registry+https://github.com/rust-lang/crates.io-index)" = "3ef84b96d10db72dd980056666d7f1e7663ce93d82fa33b63e71c966f4cf5032" "checksum ws2_32-sys 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)" = "d59cefebd0c892fa2dd6de581e937301d8552cb44489cdff035c6187cb63fa5e" +"checksum zbase32 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)" = "0f9079049688da5871a7558ddacb7f04958862c703e68258594cb7a862b5e33f" diff --git a/net/Cargo.toml b/net/Cargo.toml index 1fcd6734..27c3a547 100644 --- a/net/Cargo.toml +++ b/net/Cargo.toml @@ -22,6 +22,7 @@ maintenance = { status = "actively-developed" } [dependencies] sequoia-openpgp = { path = "../openpgp", version = "0.7" } sequoia-core = { path = "../core", version = "0.7" } +sequoia-rfc2822 = { path = "../rfc2822", version = "0.7" } failure = "0.1.2" futures = "0.1" @@ -30,10 +31,12 @@ hyper = "0.12" hyper-tls = "0.3" libc = "0.2.33" native-tls = "0.2.0" +nettle = "5.0" percent-encoding = "1.0.1" tokio-core = "0.1" tokio-io = "0.1.4" url = "1.6.0" +zbase32 = "0.1.2" [dev-dependencies] rand = "0.6" diff --git a/net/src/async.rs b/net/src/async.rs index bc355eef..0b000e84 100644 --- a/net/src/async.rs +++ b/net/src/async.rs @@ -17,10 +17,12 @@ use tokio_core::reactor::Handle; use url::Url; use openpgp::TPK; -use openpgp::{KeyID, armor, serialize::Serialize}; use openpgp::parse::Parse; +use openpgp::{KeyID, armor, serialize::Serialize}; use sequoia_core::{Context, NetworkPolicy}; +use wkd as net_wkd; + use super::{Error, Result}; define_encode_set! { @@ -228,3 +230,69 @@ impl AClient for Client<HttpsConnector<HttpConnector>> { pub(crate) fn url2uri(uri: Url) -> hyper::Uri { format!("{}", uri).parse().unwrap() } + +pub mod wkd { + //! Asynchronously access Web Key Directories. + use super::*; + /// Retrieves the TPKs that contain userids with a given email address + /// from a Web Key Directory URL. + /// + /// This function is call by [net::wkd::get](../../wkd/fn.get.html). + /// + /// From [draft-koch]: + /// + /// ```text + /// There are two variants on how to form the request URI: The advanced + /// and the direct method. Implementations MUST first try the advanced + /// method. Only if the required sub-domain does not exist, they SHOULD + /// fall back to the direct method. + /// + /// [...] + /// + /// The HTTP GET method MUST return the binary representation of the + /// OpenPGP key for the given mail address. + /// + /// [...] + /// + /// Note that the key may be revoked or expired - it is up to the + /// client to handle such conditions. To ease distribution of revoked + /// keys, a server may return revoked keys in addition to a new key. + /// The keys are returned by a single request as concatenated key + /// blocks. + /// ``` + /// + /// [draft-koch]: https://datatracker.ietf.org/doc/html/draft-koch-openpgp-webkey-service/#section-3.1 + + // XXX: Maybe the direct method should be tried on other errors too. + // https://mailarchive.ietf.org/arch/msg/openpgp/6TxZc2dQFLKXtS0Hzmrk963EteE + pub fn get<S: AsRef<str>>(email_address: S) + -> impl Future<Item=Vec<TPK>, Error=failure::Error> { + let email = email_address.as_ref().to_string(); + future::lazy(move || -> Result<_> { + // First, prepare URIs and client. + let wkd_url = net_wkd::Url::from(&email)?; + + // WKD must use TLS, so build a client for that. + let https = HttpsConnector::new(4)?; + let client = Client::builder().build::<_, hyper::Body>(https); + + Ok((email, client, wkd_url.to_uri(false)?, wkd_url.to_uri(true)?)) + }).and_then(|(email, client, advanced_uri, direct_uri)| { + // First, try the Advanced Method. + client.get(advanced_uri) + // Fall back to the Direct Method. + .or_else(move |_| { + client.get(direct_uri) + }) + .from_err() + .map(|res| (email, res)) + }).and_then(|(email, res)| { + // Join the response body. + res.into_body().concat2().from_err() + .map(|body| (email, body)) + }).and_then(|(email, body)| { + // And parse the response. + net_wkd::parse_body(&body, &email) + }) + } +} diff --git a/net/src/lib.rs b/net/src/lib.rs index c9f7cd0a..53fcc2dd 100644 --- a/net/src/lib.rs +++ b/net/src/lib.rs @@ -34,6 +34,7 @@ extern crate sequoia_openpgp as openpgp; extern crate sequoia_core; +extern crate sequoia_rfc2822 as rfc2822; #[macro_use] extern crate failure; @@ -42,11 +43,13 @@ extern crate http; extern crate hyper; extern crate hyper_tls; extern crate native_tls; +extern crate nettle; extern crate tokio_core; extern crate tokio_io; #[macro_use] extern crate percent_encoding; extern crate url; +extern crate zbase32; use hyper::client::{ResponseFuture, HttpConnector}; use hyper::{Client, Request, Body}; @@ -62,6 +65,7 @@ use sequoia_core::Context; pub mod async; use async::url2uri; +pub mod wkd; /// For accessing keyservers using HKP. pub struct KeyServer { @@ -169,6 +173,15 @@ pub enum Error { /// A `native_tls::Error` occurred. #[fail(display = "TLS Error")] TlsError(native_tls::Error), + + /// wkd errors: + /// An email address is malformed + #[fail(display = "Malformed email address {}", _0)] + MalformedEmail(String), + + /// An email address was not found in TPK userids. + #[fail(display = "Email address {} not found in TPK's userids", _0)] + EmailNotInUserids(String), } impl From<http::Error> for Error { diff --git a/net/src/wkd.rs b/net/src/wkd.rs new file mode 100644 index 00000000..f7ae0965 --- /dev/null +++ b/net/src/wkd.rs @@ -0,0 +1,291 @@ +//! OpenPGP Web Key Directory client. +//! +//! A Web Key Directory is a Web service that can be queried with email +//! addresses to obtain the associated OpenPGP keys. +//! +//! It is specified in [draft-koch]. +//! +//! See the [get example]. +//! +//! [draft-koch]: https://datatracker.ietf.org/doc/html/draft-koch-openpgp-webkey-service/#section-3.1 +//! [get example]: get#example +//! + + +// XXX: We might want to merge the 2 structs in the future and move the +// functions to methods. +extern crate tokio_core; + +use std::fmt; + +use hyper::Uri; +// Hash implements the traits for Sha1 +// Sha1 is used to obtain a 20 bytes digest that after zbase32 encoding can +// be used as file name +use nettle::{ + Hash, hash::insecure_do_not_use::Sha1, +}; +use tokio_core::reactor::Core; +use url; + +use openpgp::TPK; +use openpgp::parse::Parse; +use openpgp::tpk::TPKParser; + +use super::{Result, Error, async}; + + +/// Stores the local_part and domain of an email address. +pub struct EmailAddress { + local_part: String, + domain: String, +} + + +impl EmailAddress { + /// Returns an EmailAddress from an email address string. + /// + /// From [draft-koch]: + /// + ///```text + /// To help with the common pattern of using capitalized names + /// (e.g. "Joe.Doe@example.org") for mail addresses, and under the + /// premise that almost all MTAs treat the local-part case-insensitive + /// and that the domain-part is required to be compared + /// case-insensitive anyway, all upper-case ASCII characters in a User + /// ID are mapped to lowercase. Non-ASCII characters are not changed. + ///``` + fn from<S: AsRef<str>>(email_address: S) -> Result<Self> { + // Ensure that is a valid email address by parsing it and return the + // errors that it returns. + // This is also done in hagrid. + let email_address = email_address.as_ref(); + let v: Vec<&str> = email_address.split('@').collect(); + if v.len() != 2 { + return Err(Error::MalformedEmail(email_address.into()).into()) + }; + + // Convert to lowercase without tailoring, i.e. without taking any + // locale into account. See: + // https://doc.rust-lang.org/std/primitive.str.html#method.to_lowercase + let email = EmailAddress { + local_part: v[0].to_lowercase(), + domain: v[1].to_lowercase() + }; + Ok(email) + } +} + + +/// Stores the parts needed to create a Web Key Directory URL. +/// +/// NOTE: This is a different `Url` than [`url::Url`] (`url` crate) that is +/// actually returned with the method [to_url](#method.to_url) +#[derive(Clone)] +pub struct Url { + domain: String, + local_encoded: String, + local_part: String, +} + +impl fmt::Display for Url { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", self.build(None)) + } +} + +impl Url { + /// Returns a [`Url`] from an email address string. + pub fn from<S: AsRef<str>>(email_address: S) -> Result<Self> { + let email = EmailAddress::from(email_address)?; + let local_encoded = encode_local_part(&email.local_part); + let url = Url { + domain : email.domain, + local_encoded : local_encoded, + local_part : email.local_part, + }; + Ok(url) + } + + /// Returns an URL string from a [`Url`]. + pub fn build<T>(&self, direct_method: T) -> String + where T: Into<Option<bool>> { + let direct_method = direct_method.into().unwrap_or(false); + if direct_method { + format!("https://{}/.well-known/openpgpkey/hu/{}?l={}:443", + self.domain, self.local_encoded, self.local_part) + } else { + format!("https://openpgpkey.{}/.well-known/openpgpkey/{}/hu/{}\ + ?l={}:443", self.domain, self.domain, self.local_encoded, + self.local_part) + } + } + + /// Returns an [`url::Url`]. + pub fn to_url<T>(&self, direct_method: T) -> Result<url::Url> + where T: Into<Option<bool>> { + let url_string = self.build(direct_method); + let url_url = url::Url::parse(url_string.as_str())?; + Ok(url_url) + } + + /// Returns an [`hyper::Uri`]. + pub fn to_uri<T>(&self, direct_method: T) -> Result<Uri> + where T: Into<Option<bool>> { + let url_string = self.build(direct_method); + let uri = url_string.as_str().parse::<Uri>()?; + Ok(uri) + } +} + + +/// Returns a 32 characters string from the local part of an email address +/// +/// [draft-koch]: +/// The so mapped local-part is hashed using the SHA-1 algorithm. The +/// resulting 160 bit digest is encoded using the Z-Base-32 method as +/// described in [RFC6189], section 5.1.6. The resulting string has a +/// fixed length of 32 octets. +fn encode_local_part<S: AsRef<str>>(local_part: S) -> String { + let mut hasher = Sha1::default(); + hasher.update(local_part.as_ref().as_bytes()); + // Declare and assign a 20 bytes length vector to use in hasher.result + let mut local_hash = vec![0; 20]; + hasher.digest(&mut local_hash); + // After z-base-32 encoding 20 bytes, it will be 32 bytes long. + zbase32::encode_full_bytes(&local_hash[..]) +} + + +/// Parse an HTTP response body that may contain TPKs and filter them based on +/// whether they contain a userid with the given email address. +/// +/// From [draft-koch]: +/// +/// ```text +/// The key needs to carry a User ID packet ([RFC4880]) with that mail +/// address. +/// ``` +pub(crate) fn parse_body<S: AsRef<str>>(body: &[u8], email_address: S) + -> Result<Vec<TPK>> { + let email_address = email_address.as_ref(); + // This will fail on the first packet that can not be parsed. + let packets = TPKParser::from_bytes(&body)?; + // Collect only the correct packets. + let tpks: Vec<TPK> = packets.flatten().collect(); + // Collect only the TPKs that contain the email in any of their userids + let valid_tpks: Vec<TPK> = tpks.iter() + // XXX: This filter could become a TPK method, but it adds other API + // method to maintain + .filter(|tpk| {tpk.userids() + .any(|uidb| + if let Ok(Some(a)) = uidb.userid().address() { + a == email_address + } else { false }) + }).cloned().collect(); + if valid_tpks.is_empty() { + Err(Error::EmailNotInUserids(email_address.into()).into()) + } else { + Ok(valid_tpks) + } +} + + +/// Retrieves the TPKs that contain userids with a given email address +/// from a Web Key Directory URL. +/// +/// This function calls the [async::wkd::get](../async/wkd/fn.get.html) +/// function. +/// +/// # Example +/// +/// ``` +/// extern crate sequoia_net; +/// use sequoia_net::wkd; +/// +/// let email_address = "foo@bar.baz"; +/// let tpks = wkd::get(&email_address); +/// ``` +// This function must have the same signature as async::wkd::get. +// XXX: Maybe implement WkdServer and AWkdClient. +pub fn get<S: AsRef<str>>(email_address: S) -> Result<Vec<TPK>> { + let mut core = Core::new()?; + core.run(async::wkd::get(&email_address)) +} + + +#[cfg(test)] +mod tests { + use openpgp::serialize::Serialize; + use openpgp::tpk::TPKBuilder; + + use super::*; + + #[test] + fn encode_local_part_succed() { + let encoded_part = encode_local_part("test1"); + assert_eq!("stnkabub89rpcphiz4ppbxixkwyt1pic", encoded_part); + assert_eq!(32, encoded_part.len()); + } + + + #[test] + fn email_address_from() { + let email_address = EmailAddress::from("test1@example.com").unwrap(); + assert_eq!(email_address.domain, "example.com"); + assert_eq!(email_address.local_part, "test1"); + assert!(EmailAddress::from("thisisnotanemailaddress").is_err()); + } + + #[test] + fn url_roundtrip() { + // Advanced method + let expected_url = + "https://openpgpkey.example.com/\ + .well-known/openpgpkey/example.com/hu/\ + stnkabub89rpcphiz4ppbxixkwyt1pic?l=test1:443"; + let wkd_url = Url::from("test1@example.com").unwrap(); + assert_eq!(expected_url, wkd_url.clone().to_string()); + assert_eq!(url::Url::parse(expected_url).unwrap(), + wkd_url.clone().to_url(None).unwrap()); + assert_eq!(expected_url.parse::<Uri>().unwrap(), + wkd_url.clone().to_uri(None).unwrap()); + + // Direct method + let expected_url = + "https://example.com/\ + .well-known/openpgpkey/hu/\ + stnkabub89rpcphiz4ppbxixkwyt1pic?l=test1:443"; + assert_eq!(expected_url, wkd_url.clone().build(true)); + assert_eq!(url::Url::parse(expected_url).unwrap(), + wkd_url.clone().to_url(true).unwrap()); + assert_eq!(expected_url.parse::<Uri>().unwrap(), + wkd_url.to_uri(true).unwrap()); + } + + #[test] + fn test_parse_body() { + let (tpk, _) = TPKBuilder::new() + .add_userid("test@example.example") + .generate() + .unwrap(); + let mut buffer: Vec<u8> = Vec::new(); + tpk.serialize(&mut buffer).unwrap(); + // FIXME!!!! + let valid_tpks = parse_body(&buffer, "juga@sequoia-pgp.org"); + // The userid is not in the TPK + assert!(valid_tpks.is_err()); + // XXX: add userid to the tpk, instead of creating a new one + // tpk.add_userid("juga@sequoia.org"); + let (tpk, _) = TPKBuilder::new() + .add_userid("test@example.example") + .add_userid("juga@sequoia-pgp.org") + .generate() + .unwrap(); + tpk.serialize(&mut buffer).unwrap(); + let valid_tpks = parse_body(&buffer, "juga@sequoia-pgp.org"); + assert!(valid_tpks.is_ok()); + assert!(valid_tpks.unwrap().len() == 1); + // XXX: Test with more TPKs + } +} |