From 133e081740425e5f5beef19f90b1d35d983d6788 Mon Sep 17 00:00:00 2001 From: asonix Date: Tue, 29 Sep 2020 18:57:59 -0500 Subject: [PATCH] Add methods for explicit mastodon compat, requiring digest Update actix library with new methods Build reqwest client with feature parity to actix client --- Cargo.toml | 2 +- .../examples/client.rs | 4 +- .../examples/server.rs | 2 +- http-signature-normalization-actix/src/lib.rs | 23 ++- .../Cargo.toml | 23 ++- .../examples/client.rs | 51 +++++ .../src/digest/mod.rs | 112 ++++++++++- .../src/digest/sha2.rs | 56 ++++++ .../src/digest/sha3.rs | 83 ++++++++ .../src/lib.rs | 180 +++++++++++++++--- src/lib.rs | 17 +- 11 files changed, 498 insertions(+), 55 deletions(-) create mode 100644 http-signature-normalization-reqwest/examples/client.rs create mode 100644 http-signature-normalization-reqwest/src/digest/sha2.rs create mode 100644 http-signature-normalization-reqwest/src/digest/sha3.rs diff --git a/Cargo.toml b/Cargo.toml index 1d6685e..32a9b17 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "http-signature-normalization" description = "An HTTP Signatures library that leaves the signing to you" -version = "0.5.2" +version = "0.5.3" authors = ["asonix "] license-file = "LICENSE" readme = "README.md" diff --git a/http-signature-normalization-actix/examples/client.rs b/http-signature-normalization-actix/examples/client.rs index 601d9f9..0a06dbd 100644 --- a/http-signature-normalization-actix/examples/client.rs +++ b/http-signature-normalization-actix/examples/client.rs @@ -37,10 +37,10 @@ async fn main() -> Result<(), Box> { std::env::set_var("RUST_LOG", "info"); pretty_env_logger::init(); - let config = Config::default().require_header("accept").set_host_header(); + let config = Config::default().require_header("accept").require_digest(); request(config.clone()).await?; - request(config.dont_use_created_field()).await?; + request(config.mastodon_compat()).await?; Ok(()) } diff --git a/http-signature-normalization-actix/examples/server.rs b/http-signature-normalization-actix/examples/server.rs index ffbed83..f216fd7 100644 --- a/http-signature-normalization-actix/examples/server.rs +++ b/http-signature-normalization-actix/examples/server.rs @@ -56,7 +56,7 @@ async fn main() -> Result<(), Box> { std::env::set_var("RUST_LOG", "info"); pretty_env_logger::init(); - let config = Config::default().require_header("accept"); + let config = Config::default().require_header("accept").require_digest(); HttpServer::new(move || { App::new() diff --git a/http-signature-normalization-actix/src/lib.rs b/http-signature-normalization-actix/src/lib.rs index ba4d37d..ca6ea6e 100644 --- a/http-signature-normalization-actix/src/lib.rs +++ b/http-signature-normalization-actix/src/lib.rs @@ -359,9 +359,28 @@ impl Config { } } - /// Opt out of using the (created) and (expires) fields introduced in draft 11 + /// Enable mastodon compatibility /// - /// Use this for compatibility with mastodon + /// This is the same as disabling the use of `(created)` and `(expires)` signature fields, + /// requiring the Date header, and requiring the Host header + pub fn mastodon_compat(self) -> Self { + Config { + config: self.config.mastodon_compat(), + set_host: true, + } + } + + /// Require the Digest header be set + /// + /// This is useful for POST, PUT, and PATCH requests, but doesn't make sense for GET or DELETE. + pub fn require_digest(self) -> Self { + Config { + config: self.config.require_digest(), + set_host: self.set_host, + } + } + + /// Opt out of using the (created) and (expires) fields introduced in draft 11 /// /// Note that by enabling this, the Date header becomes required on requests. This is to /// prevent replay attacks diff --git a/http-signature-normalization-reqwest/Cargo.toml b/http-signature-normalization-reqwest/Cargo.toml index 97792de..81c07d1 100644 --- a/http-signature-normalization-reqwest/Cargo.toml +++ b/http-signature-normalization-reqwest/Cargo.toml @@ -6,20 +6,25 @@ edition = "2018" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [features] -default = ["sha-2"] -digest = ["base64", "serde", "serde_json", "serde_urlencoded", "thiserror"] +default = ["sha-2", "sha-3"] +digest = ["base64", "tokio"] sha-2 = ["digest", "sha2"] +sha-3 = ["digest", "sha3"] [dependencies] -base64 = { version = "0.11.0", optional = true } +base64 = { version = "0.12", optional = true } bytes = "0.5.3" futures = "0.3.1" chrono = "0.4.10" http = "0.2.0" http-signature-normalization = { version = "0.5.0", path = ".." } -reqwest = "0.10.1" -serde = { version = "1.0.104", features = ["derive"], optional = true } -serde_json = { version = "1.0.44", optional = true } -serde_urlencoded = { version = "0.6.1", optional = true } -sha2 = { version = "0.8.1", optional = true } -thiserror = { version = "1.0.9", optional = true } +reqwest = "0.10.8" +sha2 = { version = "0.9", optional = true } +sha3 = { version = "0.9", optional = true } +thiserror = "1.0" +tokio = { version = "0.2", default-features = false, features = ["rt-threaded", "blocking"], optional = true } + +[dev-dependencies] +pretty_env_logger = "0.4" +tokio = { version = "0.2", default-features = false, features = ["rt-threaded", "blocking", "macros"] } +time = "0.2" diff --git a/http-signature-normalization-reqwest/examples/client.rs b/http-signature-normalization-reqwest/examples/client.rs new file mode 100644 index 0000000..5d6499e --- /dev/null +++ b/http-signature-normalization-reqwest/examples/client.rs @@ -0,0 +1,51 @@ +use http_signature_normalization_reqwest::prelude::*; +use reqwest::{header::DATE, Client}; +use sha2::{Digest, Sha256}; +use time::OffsetDateTime; + +async fn request(config: Config) -> Result<(), Box> { + let digest = Sha256::new(); + + let response = Client::new() + .post("http://127.0.0.1:8010/") + .header("User-Agent", "Reqwest") + .header("Accept", "text/plain") + .header( + DATE, + OffsetDateTime::now_utc().format("%a, %d %b %Y %H:%M:%S GMT"), + ) + .signature_with_digest(config, "my-key-id", digest, "Hewwo-owo", |s| { + println!("Signing String\n{}", s); + Ok(base64::encode(s)) as Result<_, MyError> + }) + .await?; + + let body = response.bytes().await.map_err(MyError::Body)?; + + println!("{:?}", body); + Ok(()) +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + std::env::set_var("RUST_LOG", "info"); + pretty_env_logger::init(); + + let config = Config::default().require_header("accept"); + + request(config.clone()).await?; + request(config.mastodon_compat()).await?; + Ok(()) +} + +#[derive(Debug, thiserror::Error)] +pub enum MyError { + #[error("Failed to create signing string, {0}")] + Convert(#[from] SignError), + + #[error("Failed to send request")] + SendRequest(#[from] reqwest::Error), + + #[error("Failed to retrieve request body")] + Body(reqwest::Error), +} diff --git a/http-signature-normalization-reqwest/src/digest/mod.rs b/http-signature-normalization-reqwest/src/digest/mod.rs index d99ee96..068046c 100644 --- a/http-signature-normalization-reqwest/src/digest/mod.rs +++ b/http-signature-normalization-reqwest/src/digest/mod.rs @@ -1,16 +1,110 @@ -use reqwest::Request; -use std::{future::Future, pin::Pin}; +use crate::{Config, Sign, SignError}; +use reqwest::{Body, RequestBuilder}; +use std::{fmt::Display, future::Future, pin::Pin}; -pub trait CreateDigest { - fn create_digest(&mut self, payload: &[u8]) -> String; +mod sha2; +mod sha3; + +/// A trait for creating digests of an array of bytes +pub trait DigestCreate { + /// The name of the digest algorithm + const NAME: &'static str; + + /// Compute the digest of the input bytes + fn compute(&mut self, input: &[u8]) -> String; } -pub trait WithDigest: Sized { - type Future: Future; +/// Extend the Sign trait with support for adding Digest Headers to the request +/// +/// It generates HTTP Signatures after the Digest header has been added, in order to have +/// verification that the body has not been tampered with, or that the request can't be replayed by +/// a malicious entity +pub trait SignExt: Sign { + fn authorization_signature_with_digest( + self, + config: Config, + key_id: K, + digest: D, + v: V, + f: F, + ) -> Pin>>> + where + F: FnOnce(&str) -> Result + Send + 'static, + E: From + From, + K: Display + 'static, + D: DigestCreate + Send + 'static, + V: AsRef<[u8]> + Into + Send + 'static, + Self: Sized; - fn with_digest(&mut self, creator: T) -> Self::Future; + fn signature_with_digest( + self, + config: Config, + key_id: K, + digest: D, + v: V, + f: F, + ) -> Pin>>> + where + F: FnOnce(&str) -> Result + Send + 'static, + E: From + From, + K: Display + 'static, + D: DigestCreate + Send + 'static, + V: AsRef<[u8]> + Into + Send + 'static, + Self: Sized; } -impl WithDigest for Request { - type Future = Pin + Send>>; +impl SignExt for RequestBuilder { + fn authorization_signature_with_digest( + self, + config: Config, + key_id: K, + mut digest: D, + v: V, + f: F, + ) -> Pin>>> + where + F: FnOnce(&str) -> Result + Send + 'static, + E: From + From, + K: Display + 'static, + D: DigestCreate + Send + 'static, + V: AsRef<[u8]> + Into + Send + 'static, + Self: Sized, + { + Box::pin(async move { + let digest = tokio::task::block_in_place(|| digest.compute(v.as_ref())); + + let c = self + .header("Digest", format!("{}={}", D::NAME, digest)) + .authorization_signature(&config, key_id, f)?; + + c.body(v).send().await.map_err(E::from) + }) + } + + fn signature_with_digest( + self, + config: Config, + key_id: K, + mut digest: D, + v: V, + f: F, + ) -> Pin>>> + where + F: FnOnce(&str) -> Result + Send + 'static, + E: From + From, + K: Display + 'static, + D: DigestCreate + Send + 'static, + V: AsRef<[u8]> + Into + Send + 'static, + Self: Sized, + { + Box::pin(async move { + let digest = tokio::task::block_in_place(|| digest.compute(v.as_ref())); + + let c = self + .header("Digest", format!("{}={}", D::NAME, digest)) + .signature(&config, key_id, f)?; + + c.body(v).send().await.map_err(E::from) + }) + } } diff --git a/http-signature-normalization-reqwest/src/digest/sha2.rs b/http-signature-normalization-reqwest/src/digest/sha2.rs new file mode 100644 index 0000000..42b3169 --- /dev/null +++ b/http-signature-normalization-reqwest/src/digest/sha2.rs @@ -0,0 +1,56 @@ +use sha2::{Sha224, Sha256, Sha384, Sha512, Sha512Trunc224, Sha512Trunc256}; + +use super::DigestCreate; + +fn create(digest: &mut impl sha2::Digest, input: &[u8]) -> String { + digest.update(input); + base64::encode(&digest.finalize_reset()) +} + +impl DigestCreate for Sha224 { + const NAME: &'static str = "SHA-224"; + + fn compute(&mut self, input: &[u8]) -> String { + create(self, input) + } +} + +impl DigestCreate for Sha256 { + const NAME: &'static str = "SHA-256"; + + fn compute(&mut self, input: &[u8]) -> String { + create(self, input) + } +} + +impl DigestCreate for Sha384 { + const NAME: &'static str = "SHA-384"; + + fn compute(&mut self, input: &[u8]) -> String { + create(self, input) + } +} + +impl DigestCreate for Sha512 { + const NAME: &'static str = "SHA-512"; + + fn compute(&mut self, input: &[u8]) -> String { + create(self, input) + } +} + +impl DigestCreate for Sha512Trunc224 { + const NAME: &'static str = "SHA-512-224"; + + fn compute(&mut self, input: &[u8]) -> String { + create(self, input) + } +} + +impl DigestCreate for Sha512Trunc256 { + const NAME: &'static str = "SHA-512-256"; + + fn compute(&mut self, input: &[u8]) -> String { + create(self, input) + } +} diff --git a/http-signature-normalization-reqwest/src/digest/sha3.rs b/http-signature-normalization-reqwest/src/digest/sha3.rs new file mode 100644 index 0000000..1be0dff --- /dev/null +++ b/http-signature-normalization-reqwest/src/digest/sha3.rs @@ -0,0 +1,83 @@ +use sha3::{ + Keccak224, Keccak256, Keccak256Full, Keccak384, Keccak512, Sha3_224, Sha3_256, Sha3_384, + Sha3_512, +}; + +use super::DigestCreate; + +fn create(digest: &mut impl sha2::Digest, input: &[u8]) -> String { + digest.update(input); + base64::encode(&digest.finalize_reset()) +} + +impl DigestCreate for Sha3_224 { + const NAME: &'static str = "SHA3-224"; + + fn compute(&mut self, input: &[u8]) -> String { + create(self, input) + } +} + +impl DigestCreate for Sha3_256 { + const NAME: &'static str = "SHA3-256"; + + fn compute(&mut self, input: &[u8]) -> String { + create(self, input) + } +} + +impl DigestCreate for Sha3_384 { + const NAME: &'static str = "SHA3-384"; + + fn compute(&mut self, input: &[u8]) -> String { + create(self, input) + } +} + +impl DigestCreate for Sha3_512 { + const NAME: &'static str = "SHA3-512"; + + fn compute(&mut self, input: &[u8]) -> String { + create(self, input) + } +} + +impl DigestCreate for Keccak224 { + const NAME: &'static str = "keccak-224"; + + fn compute(&mut self, input: &[u8]) -> String { + create(self, input) + } +} + +impl DigestCreate for Keccak256 { + const NAME: &'static str = "keccak-256"; + + fn compute(&mut self, input: &[u8]) -> String { + create(self, input) + } +} + +impl DigestCreate for Keccak256Full { + const NAME: &'static str = "keccak-256-full"; + + fn compute(&mut self, input: &[u8]) -> String { + create(self, input) + } +} + +impl DigestCreate for Keccak384 { + const NAME: &'static str = "keccak-384"; + + fn compute(&mut self, input: &[u8]) -> String { + create(self, input) + } +} + +impl DigestCreate for Keccak512 { + const NAME: &'static str = "keccak-512"; + + fn compute(&mut self, input: &[u8]) -> String { + create(self, input) + } +} diff --git a/http-signature-normalization-reqwest/src/lib.rs b/http-signature-normalization-reqwest/src/lib.rs index f3f7259..32d45c1 100644 --- a/http-signature-normalization-reqwest/src/lib.rs +++ b/http-signature-normalization-reqwest/src/lib.rs @@ -2,79 +2,200 @@ use chrono::Duration; use http_signature_normalization::create::Signed; use reqwest::{ header::{InvalidHeaderValue, ToStrError}, - Request, + Request, RequestBuilder, }; use std::fmt::Display; +pub use http_signature_normalization::RequiredError; + pub mod digest; -pub struct Config(http_signature_normalization::Config); +pub mod prelude { + pub use crate::{Config, Sign, SignError}; + #[cfg(feature = "digest")] + pub use crate::digest::{DigestCreate, SignExt}; +} + +#[derive(Clone, Debug, Default)] +/// Configuration for signing and verifying signatures +/// +/// By default, the config is set up to create and verify signatures that expire after 10 seconds, +/// and use the `(created)` and `(expires)` fields that were introduced in draft 11 +pub struct Config { + /// The inner config type + config: http_signature_normalization::Config, + + /// Whether to set the Host header + set_host: bool, +} + +/// A trait implemented by the reqwest RequestBuilder type to add an HTTP Signature to the request pub trait Sign { + /// Add an Authorization Signature to the request fn authorization_signature(self, config: &Config, key_id: K, f: F) -> Result where Self: Sized, F: FnOnce(&str) -> Result, - E: From + From, + E: From + From, K: Display; + /// Add a Signature to the request fn signature(self, config: &Config, key_id: K, f: F) -> Result where Self: Sized, F: FnOnce(&str) -> Result, - E: From + From, + E: From + From, K: Display; } +#[derive(Debug, thiserror::Error)] +pub enum SignError { + #[error("Failed to read header, {0}")] + /// An error occurred when reading the request's headers + Header(#[from] ToStrError), + + #[error("Failed to write header, {0}")] + /// An error occured when adding a new header + NewHeader(#[from] InvalidHeaderValue), + + #[error("{0}")] + /// Some headers were marked as required, but are missing + RequiredError(#[from] RequiredError), + + #[error("No host provided for URL, {0}")] + /// Missing host + Host(String), + + #[error("Cannot sign request with body already present")] + BodyPresent, +} + impl Config { - pub fn new(expires_after: Duration) -> Self { - Config(http_signature_normalization::Config { expires_after }) + pub fn new() -> Self { + Default::default() + } + + /// This method can be used to include the Host header in the HTTP Signature without + /// interfering with Reqwest's built-in Host mechanisms + pub fn set_host_header(self) -> Self { + Config { + config: self.config, + set_host: true, + } + } + + /// Enable mastodon compatibility + /// + /// This is the same as disabling the use of `(created)` and `(expires)` signature fields, + /// requiring the Date header, and requiring the Host header + pub fn mastodon_compat(self) -> Self { + Config { + config: self.config.mastodon_compat(), + set_host: true, + } + } + + /// Require the Digest header be set + /// + /// This is useful for POST, PUT, and PATCH requests, but doesn't make sense for GET or DELETE. + pub fn require_digest(self) -> Self { + Config { + config: self.config.require_digest(), + set_host: self.set_host, + } + } + + /// Opt out of using the (created) and (expires) fields introduced in draft 11 + /// + /// Note that by enabling this, the Date header becomes required on requests. This is to + /// prevent replay attacks + pub fn dont_use_created_field(self) -> Self { + Config { + config: self.config.dont_use_created_field(), + set_host: self.set_host, + } + } + + /// Set the expiration to a custom duration + pub fn set_expiration(self, expiries_after: Duration) -> Self { + Config { + config: self.config.set_expiration(expiries_after), + set_host: self.set_host, + } + } + + /// Require a header on signed requests + pub fn require_header(self, header: &str) -> Self { + Config { + config: self.config.require_header(header), + set_host: self.set_host, + } } } -impl Sign for Request { - fn authorization_signature( - mut self, - config: &Config, - key_id: K, - f: F, - ) -> Result +impl Sign for RequestBuilder { + fn authorization_signature(self, config: &Config, key_id: K, f: F) -> Result where F: FnOnce(&str) -> Result, - E: From + From, + E: From + From, K: Display, { - let signed = prepare(&self, config, key_id, f)?; + if let Some(builder) = self.try_clone() { + let request = builder.build()?; + let signed = prepare(&request, config, key_id, f)?; - let auth_header = signed.authorization_header(); - self.headers_mut() - .insert("Authorization", auth_header.parse()?); - Ok(self) + let auth_header = signed.authorization_header(); + return Ok(self.header("Authorization", auth_header)); + } + + Err(SignError::BodyPresent.into()) } - fn signature(mut self, config: &Config, key_id: K, f: F) -> Result + fn signature(self, config: &Config, key_id: K, f: F) -> Result where F: FnOnce(&str) -> Result, - E: From + From, + E: From + From, K: Display, { - let signed = prepare(&self, config, key_id, f)?; + if let Some(builder) = self.try_clone() { + let request = builder.build()?; + let signed = prepare(&request, config, key_id, f)?; - let sig_header = signed.signature_header(); - self.headers_mut().insert("Signature", sig_header.parse()?); - Ok(self) + let sig_header = signed.signature_header(); + return Ok(self.header("Signature", sig_header)); + } + + Err(SignError::BodyPresent.into()) } } fn prepare(req: &Request, config: &Config, key_id: K, f: F) -> Result where F: FnOnce(&str) -> Result, - E: From + From, + E: From, K: Display, { let mut bt = std::collections::BTreeMap::new(); for (k, v) in req.headers().iter() { - bt.insert(k.as_str().to_owned(), v.to_str()?.to_owned()); + bt.insert( + k.as_str().to_owned(), + v.to_str().map_err(SignError::from)?.to_owned(), + ); + } + if config.set_host { + let header_string = req + .url() + .host() + .ok_or_else(|| SignError::Host(req.url().to_string()))? + .to_string(); + + let header_string = match req.url().port() { + None | Some(443) | Some(80) => header_string, + Some(port) => format!("{}:{}", header_string, port), + }; + + bt.insert("Host".to_string(), header_string); } let path_and_query = if let Some(query) = req.url().query() { format!("{}?{}", req.url().path(), query) @@ -82,8 +203,9 @@ where req.url().path().to_string() }; let unsigned = config - .0 - .begin_sign(req.method().as_str(), &path_and_query, bt); + .config + .begin_sign(req.method().as_str(), &path_and_query, bt) + .map_err(SignError::from)?; let signed = unsigned.sign(key_id.to_string(), f)?; Ok(signed) diff --git a/src/lib.rs b/src/lib.rs index 57d3cbc..c49a585 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -105,9 +105,22 @@ impl Config { Config::default() } - /// Opt out of using the (created) and (expires) fields introduced in draft 11 + /// Enable mastodon compatibility /// - /// Use this for compatibility with mastodon. + /// This is the same as disabling the use of `(created)` and `(expires)` signature fields, + /// requiring the Date header, and requiring the Host header + pub fn mastodon_compat(self) -> Self { + self.dont_use_created_field().require_header("host") + } + + /// Require the Digest header be set + /// + /// This is useful for POST, PUT, and PATCH requests, but doesn't make sense for GET or DELETE. + pub fn require_digest(self) -> Self { + self.require_header("Digest") + } + + /// Opt out of using the (created) and (expires) fields introduced in draft 11 /// /// Note that by not requiring the created field, the Date header becomes required. This is to /// prevent replay attacks.