diff options
author | juga0 <juga@riseup.net> | 2019-05-03 12:42:39 +0000 |
---|---|---|
committer | juga0 <juga@riseup.net> | 2019-05-17 13:56:32 +0000 |
commit | e249693ccc1298202ebd21797c5c18379356bb7b (patch) | |
tree | 66bdefa8685d1d8736469b4dca4d57128efbd5e6 | |
parent | 2cec2b2d55e31c78ab5c98f23fa85f7ba400b71e (diff) |
net, tool: Add wkd module and command
implementing WKD client
Closes: #251
(Commit 👾)
-rw-r--r-- | Cargo.lock | 11 | ||||
-rw-r--r-- | net/Cargo.toml | 3 | ||||
-rw-r--r-- | net/src/async.rs | 122 | ||||
-rw-r--r-- | net/src/lib.rs | 23 | ||||
-rw-r--r-- | net/src/wkd.rs | 246 | ||||
-rw-r--r-- | net/tests/wkd.rs | 2 | ||||
-rw-r--r-- | tool/src/sq-usage.rs | 45 | ||||
-rw-r--r-- | tool/src/sq.rs | 32 | ||||
-rw-r--r-- | tool/src/sq_cli.rs | 21 |
9 files changed, 503 insertions, 2 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" @@ -1588,13 +1590,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]] @@ -2270,6 +2275,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" @@ -2512,3 +2522,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..b538a7ef 100644 --- a/net/src/async.rs +++ b/net/src/async.rs @@ -12,15 +12,18 @@ use hyper_tls::HttpsConnector; use native_tls::{Certificate, TlsConnector}; use percent_encoding::{percent_encode, DEFAULT_ENCODE_SET}; use std::convert::From; +use std::net::ToSocketAddrs; use std::io::Cursor; use tokio_core::reactor::Handle; use url::Url; use openpgp::TPK; + use openpgp::parse::Parse; use openpgp::{KeyID, armor, serialize::Serialize}; -use openpgp::parse::Parse; use sequoia_core::{Context, NetworkPolicy}; +use wkd; + use super::{Error, Result}; define_encode_set! { @@ -228,3 +231,120 @@ impl AClient for Client<HttpsConnector<HttpConnector>> { pub(crate) fn url2uri(uri: Url) -> hyper::Uri { format!("{}", uri).parse().unwrap() } + + +/// Retrieves the TPKs that contain userids with a given email address from +/// a Web Key Directory URL. +/// +/// [draft-koch]: +/// 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. + +// XXX: Maybe the direct method should be tried on other errors too. +// https://mailarchive.ietf.org/arch/msg/openpgp/6TxZc2dQFLKXtS0Hzmrk963EteE +pub fn async_wkd_get<S: AsRef<str>>(email_address: S) + -> Box<Future<Item=Vec<TPK>, Error=failure::Error> + 'static> { + let email_address = email_address.as_ref().to_string(); + // WKD must use TLS + // XXX: Change this expect for an error + let https = HttpsConnector::new(4).expect("TLS initialization failed"); + let client = Client::builder() + .build::<_, hyper::Body>(https); + + // FIXME: solve the move!!! + let wkd_url = wkd::WkdUrl::from(&email_address); + let wkd_url2 = wkd::WkdUrl::from(&email_address); + + // Advanced method + let url_string = wkd_url.unwrap().to_string(None); + + // 1. option: just resolve the domain. + // This is not async, solving the domain in an async way will require + // other dependency. + let uri = match url_string.unwrap().to_socket_addrs() { + Err(e) => + // getaddrinfo is POSIX and seems to always return this string + // in all systems. + if e.to_string().contains("No address associated with hostname") { + // Direct method + wkd_url2.unwrap().to_uri(true) + } else { + Err(failure::Error::from(e)) + }, + Ok(_) => wkd_url2.unwrap().to_uri(None), + }; + // Then, there is no need to repeat the HTTP request + Box::new( + client.get(uri.unwrap()) + .from_err() + .and_then(|res| res.into_body().concat2().from_err()) + .and_then(move |body| + match wkd::parse_body(&body, &email_address) { + Ok(tpks) => future::done(Ok(tpks)), + Err(e) => future::err(e).into(), + }) + ) + + // 2. option: consume the future to know if it was an error and which + // type. But wait is blocking. + // + // Advanced method + // let mut uri = wkd_url1.unwrap().to_uri(None); + // + // let response = match client.get(uri.unwrap()).map(|r| r).wait() { + // Ok(r) => future::ok(r), + // Err(e) => + // if e.to_string().contains("No address associated with hostname") { + // // Direct method + // uri = wkd_url2.unwrap().to_uri(false); + // // Then also have to consume it here + // future::done(client.get(uri.unwrap()).map(|r| r).wait()) + // } else { + // future::err(e).into() + // }, + // }; + // Box::new( + // response.from_err() + // .and_then(|res| res.into_body().concat2().from_err()) + // .and_then(move |body| + // match wkd::parse_body(&body, &email_address) { + // Ok(tpks) => future::done(Ok(tpks)), + // Err(e) => future::err(e).into(), + // }) + // ) + + // 3. option: If there's a way to convert a future::done into a + // a response future, then this would work, it courrently doesn't in the + // else block. + // This does not work because of the else + // Box::new( + // client.get(uri.unwrap()) + // .or_else(|e| -> ResponseFuture { + // // If i put this if, then i need to put an else + // // And the else must be other ResponseFuture + // if e.to_string().contains("No address associated with hostname") { + // // Direct method + // uri = wkd_url2.unwrap().to_uri(false); + // client.get(uri.unwrap()) + // } else { + // // how to convert an hyper::error to a ResponseFuture?, + // // new is private + // ResponseFuture::new(Box::new(future::err(e).into())) + // }}) .and_then(|res| res.into_body().concat2().from_err()) + // .and_then(move |body| + // match wkd::parse_body(&body, &email_address) { + // Ok(tpks) => future::done(Ok(tpks)), + // Err(e) => future::err(e).into(), + // }) + // ) +} diff --git a/net/src/lib.rs b/net/src/lib.rs index c9f7cd0a..35ed8173 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 { @@ -189,6 +202,16 @@ impl From<url::ParseError> for Error { } } + +/// Runs async_wkd_get. +// This function must have the same signature as async_wkd_get. +// XXX: Maybe implement WkdServer and AWkdClient. +pub fn wkd_get<S: AsRef<str>>(email_address: S) -> Result<Vec<TPK>> { + let mut core = Core::new()?; + core.run(async::async_wkd_get(&email_address)) +} + + #[cfg(test)] mod tests { use super::*; diff --git a/net/src/wkd.rs b/net/src/wkd.rs new file mode 100644 index 00000000..7d0e4b7e --- /dev/null +++ b/net/src/wkd.rs @@ -0,0 +1,246 @@ +//! OpenPGP Web Key Directory client[draft-koch] +//! +//! [draft-koch]: https://datatracker.ietf.org/doc/html/draft-koch-openpgp-webkey-service/#section-3.1 + +// XXX: We might want to merge the 2 structs in the future and move the +// functions to methods. +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 url::Url; + +use openpgp::TPK; +use openpgp::parse::Parse; +use openpgp::tpk::TPKParser; + +use super::{Result, Error}; + + +/// 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. + /// + /// [draft-koch]: + /// 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. +#[derive(Clone)] +pub struct WkdUrl { + domain: String, + local_encoded: String, + local_part: String, +} + + +impl WkdUrl { + /// Returns a WkdUrl from an email address string.or error. + 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 wkd_url = WkdUrl { + domain : email.domain, + local_encoded : local_encoded, + local_part : email.local_part, + }; + Ok(wkd_url) + } + + /// Returns an URL string from a WkdUrl. + pub fn to_string<T>(self, direct_method: T) -> Result<String> + where T: Into<Option<bool>> { + let direct_method = direct_method.into().unwrap_or(false); + let authority; + let path; + if direct_method { + authority = self.domain; + path = ".well-known/openpgpkey/hu/".to_string() + + &self.local_encoded; + } else { + authority = "openpgpkey.".to_string() + &self.domain; + path = ".well-known/openpgpkey/".to_string() + &self.domain + + "/hu/" + &self.local_encoded; + }; + let path_and_query = path + "?l=" + &self.local_part; + let url_string = "https://".to_string() + &authority + "/" + + &path_and_query + ":443"; + Ok(url_string) + } + + /// Returns an url::Url. + pub fn to_url<T>(self, direct_method: T) -> Result<Url> + where T: Into<Option<bool>> { + let url_string = self.to_string(direct_method)?; + let url = Url::parse(url_string.as_str())?; + Ok(url) + } + + /// Returns an hyper::Uri. + pub fn to_uri<T>(self, direct_method: T) -> Result<Uri> + where T: Into<Option<bool>> { + // let url = self.to_url(direct_method)?; + let url_string = self.to_string(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] section 3.1, Key Discovery: +/// 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. +/// +/// [draft-koch]: +/// 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) + } +} + + +#[cfg(test)] +mod tests { + use openpgp::serialize::Serialize; + use openpgp::tpk::TPKBuilder; + + use super::*; + + #[test] + fn encode_local_part_works() { + 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 wkd_url_roundtrip() { + // Advanced method + let expected_url = + "https://openpgpkey.example.com/\ + .well-known/openpgpkey/example.com/hu/\ + stnkabub89rpcphiz4ppbxixkwyt1pic?l=test1"; + let wkd_url = WkdUrl::from("test1@example.com").unwrap(); + assert_eq!(expected_url, wkd_url.clone().to_string(None).unwrap()); + assert_eq!(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"; + assert_eq!(expected_url, wkd_url.clone().to_string(None).unwrap()); + assert_eq!(Url::parse(expected_url).unwrap(), + wkd_url.clone().to_url(None).unwrap()); + assert_eq!(expected_url.parse::<Uri>().unwrap(), + wkd_url.to_uri(None).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.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.org") + .generate() + .unwrap(); + tpk.serialize(&mut buffer).unwrap(); + let valid_tpks = parse_body(&buffer, "juga@sequoia.org"); + assert!(valid_tpks.is_ok()); + assert!(valid_tpks.unwrap().len() == 1); + // XXX: Test with more TPKs + } +} diff --git a/net/tests/wkd.rs b/net/tests/wkd.rs new file mode 100644 index 00000000..c8b108a4 --- /dev/null +++ b/net/tests/wkd.rs @@ -0,0 +1,2 @@ +//! Integration tests for the Web Key Directory client +// XXX diff --git a/tool/src/sq-usage.rs b/tool/src/sq-usage.rs index f3159937..9902d0a0 100644 --- a/tool/src/sq-usage.rs +++ b/tool/src/sq-usage.rs @@ -34,6 +34,7 @@ //! key Manipulates keys //! list Lists key stores and known keys //! packet OpenPGP Packet manipulation +//! wkd Interacts with Web Key Directories //! ``` //! //! ## Subcommand decrypt @@ -625,5 +626,49 @@ //! ARGS: //! <FILE> Sets the input file to use //! ``` +//! ## Subcommand wkd +//! ```text +//! Interacts with Web Key Directories +//! +//! USAGE: +//! sq wkd [SUBCOMMAND] +//! +//! FLAGS: +//! -h, --help Prints help information +//! -V, --version Prints version information +//! +//! SUBCOMMANDS: +//! get Writes to the standard output the Transferable Public Key retrieved from a Web Key Directory, given an email address +//! help Prints this message or the help of the given subcommand(s) +//! url Prints the Web Key Directory URI of an email address. +//! ``` +//! ### Subcommand wkd url +//! ```text +//! Prints the Web Key Directory URI of an email address +//! +//! USAGE: +//! sq wkd url [EMAIL_ADDRESS] +//! +//! FLAGS: +//! -h, --help Prints help information +//! -V, --version Prints version information +//! +//! ARGS: +//! <EMAIL_ADDRESS> The email address from which to obtain the WKD URI. +//! ``` +//! ### Subcommand wkd get +//! ```text +//! +//! Writes to the standard output the Transferable Public Key retrieved from a Web Key Directory, given an email address +//! USAGE: +//! sq wkd get [EMAIL_ADDRESS] +//! +//! FLAGS: +//! -h, --help Prints help information +//! -V, --version Prints version information +//! +//! ARGS: +//! <EMAIL_ADDRESS> The email address from which to obtain the TPK from a WKD. +//! ``` include!("sq.rs"); diff --git a/tool/src/sq.rs b/tool/src/sq.rs index b18bada4..648402d2 100644 --- a/tool/src/sq.rs +++ b/tool/src/sq.rs @@ -26,7 +26,7 @@ use openpgp::conversions::hex; use openpgp::parse::Parse; use openpgp::serialize::Serialize; use sequoia_core::{Context, NetworkPolicy}; -use sequoia_net::KeyServer; +use sequoia_net::{KeyServer, wkd, wkd_get}; use sequoia_store::{Store, LogIter}; mod sq_cli; @@ -458,6 +458,36 @@ fn real_main() -> Result<(), failure::Error> { ("generate", Some(m)) => commands::key::generate(m, force)?, _ => unreachable!(), }, + ("wkd", Some(m)) => { + match m.subcommand() { + ("url", Some(m)) => { + let email_address = m.value_of("input").unwrap(); + let wkd_url = wkd::WkdUrl::from(email_address)?; + // XXX: Add other subcomand to specify whether it should be + // created with the advanced or the direct method. + let url = wkd_url.to_url(None)?; + println!("{}", url); + }, + ("get", Some(m)) => { + let email_address = m.value_of("input").unwrap(); + // XXX: EmailAddress could be created here to + // check it's a valid email address, print the error to + // stderr and exit. + // Because it might be created a WkdServer struct, not + // doing it for now. + let tpks = wkd_get(&email_address)?; + // This is different to `store export` and `keyserver get`, + // Since the output is always bytes. + // XXX: Still give the possibility to write to a file. + let mut output = create_or_stdout(m.value_of("output"), force)?; + + for tpk in tpks { + tpk.serialize(&mut output)?; + } + }, + _ => unreachable!(), + } + }, _ => unreachable!(), } diff --git a/tool/src/sq_cli.rs b/tool/src/sq_cli.rs index 9c5e50b9..44cc03dd 100644 --- a/tool/src/sq_cli.rs +++ b/tool/src/sq_cli.rs @@ -425,4 +425,25 @@ pub fn build() -> App<'static, 'static> { .help("Sets the prefix to use for output files \ (defaults to the input filename with a dash, \ or 'output')")))) + + .subcommand(SubCommand::with_name("wkd") + .about("Interacts with Web Key Directories") + .setting(AppSettings::ArgRequiredElseHelp) + .subcommand(SubCommand::with_name("url") + .about("Prints the Web Key Directory URl of \ + an email address.") + .arg(Arg::with_name("input") + .value_name("EMAIL_ADDRESS") + .help("The email address from which to \ + obtain the WKD URI."))) + .subcommand(SubCommand::with_name("get") + .about("Writes to the standard output the \ + Transferable Public Key retrieved \ + from a Web Key Directory, given an \ + email address") + .arg(Arg::with_name("input") + .value_name("EMAIL_ADDRESS") + .help("The email address from which to \ + obtain the TPK from a WKD."))) + ) } |