diff --git a/Cargo.toml b/Cargo.toml index 09a82e4..fb00d70 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,10 +13,11 @@ edition = "2021" [workspace] members = [ - "http-signature-normalization-actix", - "http-signature-normalization-http", - "http-signature-normalization-reqwest", - # "http-signature-normalization-warp", + "http-signature-normalization-actix", + "http-signature-normalization-actix-extractor", + "http-signature-normalization-http", + "http-signature-normalization-reqwest", + # "http-signature-normalization-warp", ] [dependencies] diff --git a/http-signature-normalization-actix-extractor/Cargo.toml b/http-signature-normalization-actix-extractor/Cargo.toml new file mode 100644 index 0000000..731c1ee --- /dev/null +++ b/http-signature-normalization-actix-extractor/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "http-signature-normalization-actix-extractor" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +[features] +default = ["sha-2"] +sha-2 = ["base64", "sha2", "subtle"] + +[dependencies] +base64 = { version = "0.13", optional = true } +actix-web = { version = "4", default-features = false } +actix-web-lab = "0.18.5" +async-trait = "0.1.58" +http-signature-normalization = { version = "0.6", path = "../" } +sha2 = { version = "0.10", optional = true } +subtle = { version = "2.4.1", optional = true } + +[dev-dependencies] +actix-web = { version = "4", features = ["macros"] } +thiserror = "1" diff --git a/http-signature-normalization-actix-extractor/examples/server.rs b/http-signature-normalization-actix-extractor/examples/server.rs new file mode 100644 index 0000000..269f237 --- /dev/null +++ b/http-signature-normalization-actix-extractor/examples/server.rs @@ -0,0 +1,88 @@ +use actix_web::{http::StatusCode, web, App, HttpRequest, HttpResponse, HttpServer, ResponseError}; +use http_signature_normalization_actix_extractor::{ + Algorithm, Config, ConfigGenerator, PrivateKey, Signed, +}; +use sha2::Sha256; + +#[actix_web::main] +async fn main() -> std::io::Result<()> { + HttpServer::new(|| App::new().route("/", web::post().to(protected))) + .bind("127.0.0.1:8010")? + .run() + .await +} + +async fn protected(signed_request: Signed) -> &'static str { + let (value, signature) = signed_request.into_parts(); + + println!("{}", value); + println!("{:#?}", signature); + + "hewwo, mr obama" +} + +pub struct Cfg; + +impl ConfigGenerator for Cfg { + fn config() -> Config { + Config::new().require_header("accept") + } +} + +#[derive(Debug)] +pub struct Key; + +#[async_trait::async_trait(?Send)] +impl PrivateKey for Key { + type Error = MyError; + + async fn init( + _: &HttpRequest, + key_id: &str, + algorithm: Option<&Algorithm>, + ) -> Result { + match algorithm { + Some(Algorithm::Hs2019) => (), + _ => return Err(MyError::Algorithm), + }; + + if key_id != "my-key-id" { + return Err(MyError::Key); + } + + Ok(Key) + } + + fn verify(&mut self, signature: &str, signing_string: &str) -> Result { + use subtle::ConstantTimeEq; + + let decoded = match base64::decode(&signature) { + Ok(decoded) => decoded, + Err(_) => return Err(MyError::Decode), + }; + + Ok(decoded.ct_eq(signing_string.as_bytes()).into()) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum MyError { + #[error("Unsupported algorithm")] + Algorithm, + + #[error("Couldn't decode signature")] + Decode, + + #[error("Invalid key")] + Key, +} + +impl ResponseError for MyError { + fn status_code(&self) -> StatusCode { + StatusCode::BAD_REQUEST + } + + fn error_response(&self) -> HttpResponse { + HttpResponse::BadRequest().finish() + } +} diff --git a/http-signature-normalization-actix-extractor/src/lib.rs b/http-signature-normalization-actix-extractor/src/lib.rs new file mode 100644 index 0000000..1959fbf --- /dev/null +++ b/http-signature-normalization-actix-extractor/src/lib.rs @@ -0,0 +1,286 @@ +// #![deny(missing_docs)] + +//! Experimental Extractor for request signatures + +pub use actix_web_lab::extract::RequestSignature; +pub use http_signature_normalization::{verify::Algorithm, Config}; + +pub type Signed = + RequestSignature>; + +#[cfg(feature = "sha-2")] +mod sha2_digest; + +#[derive(Debug)] +pub enum Error { + Digest, + Signature, + InvalidHeaderValue, + PrepareVerify(http_signature_normalization::PrepareVerifyError), +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Digest => write!(f, "Digest is inavlid"), + Self::Signature => write!(f, "Signature is invalid"), + Self::InvalidHeaderValue => write!(f, "Invalid header value"), + Self::PrepareVerify(_) => write!(f, "Error preparint verification"), + } + } +} + +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::PrepareVerify(ref e) => Some(e), + Self::Digest => None, + Self::Signature => None, + Self::InvalidHeaderValue => None, + } + } +} + +impl actix_web::ResponseError for Error { + fn status_code(&self) -> actix_web::http::StatusCode { + actix_web::http::StatusCode::BAD_REQUEST + } + + fn error_response(&self) -> actix_web::HttpResponse { + actix_web::HttpResponse::build(self.status_code()).finish() + } +} + +impl From for Error { + fn from(e: http_signature_normalization::PrepareVerifyError) -> Self { + Error::PrepareVerify(e) + } +} + +impl From for Error { + fn from(_: actix_web::http::header::ToStrError) -> Self { + Error::InvalidHeaderValue + } +} + +pub struct SignatureScheme { + config_generator: std::marker::PhantomData, + key: std::marker::PhantomData, + digest_verifier: Option, + unverified: http_signature_normalization::verify::Unverified, +} + +#[derive(Debug)] +pub struct DigestPart { + pub algorithm: String, + pub digest: String, +} + +#[derive(Debug)] +pub struct Signature { + key: K, + unverified: http_signature_normalization::verify::Unverified, + digest_verifier: D, + digest_parts: Option>, +} + +pub trait DigestName { + const NAME: &'static str; +} + +pub trait ConfigGenerator { + fn config() -> Config; +} + +#[async_trait::async_trait(?Send)] +pub trait PrivateKey: Sized + Send { + type Error: Into; + + async fn init( + req: &actix_web::HttpRequest, + key_id: &str, + algorithm: Option<&Algorithm>, + ) -> Result; + + fn verify(&mut self, signature: &str, signing_string: &str) -> Result; +} + +pub trait VerifyDigest: Default + Send + 'static { + const REQUIRED: bool = true; + + fn update(&mut self, bytes: &[u8]); + + fn verify(&mut self, parts: &[DigestPart]) -> bool; +} + +#[async_trait::async_trait(?Send)] +impl actix_web_lab::extract::RequestSignatureScheme for SignatureScheme +where + C: ConfigGenerator, + D: VerifyDigest, + K: PrivateKey, +{ + type Signature = Signature; + + type Error = actix_web::Error; + + async fn init(req: &actix_web::HttpRequest) -> Result { + let config = C::config(); + + let unverified = begin_verify( + &config, + req.method(), + req.uri().path_and_query(), + req.headers(), + )?; + + Ok(SignatureScheme { + config_generator: std::marker::PhantomData, + key: std::marker::PhantomData, + digest_verifier: Some(D::default()), + unverified, + }) + } + + async fn consume_chunk( + &mut self, + _: &actix_web::HttpRequest, + chunk: actix_web::web::Bytes, + ) -> Result<(), Self::Error> { + if D::REQUIRED { + let mut verifier = self + .digest_verifier + .take() + .expect("consume_chunk polled concurrently"); + + self.digest_verifier = Some( + actix_web::web::block(move || { + verifier.update(&chunk); + verifier + }) + .await + .expect("Panic in verifier update"), + ); + } + + Ok(()) + } + + async fn finalize( + mut self, + req: &actix_web::HttpRequest, + ) -> Result { + let key = K::init(req, self.unverified.key_id(), self.unverified.algorithm()) + .await + .map_err(Into::into)?; + + let digest_parts = if D::REQUIRED { + req.headers() + .get("digest") + .and_then(|digest| parse_digest(digest)) + } else { + None + }; + + Ok(Signature { + key, + unverified: self.unverified, + digest_verifier: self + .digest_verifier + .take() + .expect("finalize called more than once"), + digest_parts, + }) + } + + fn verify( + signature: Self::Signature, + _: &actix_web::HttpRequest, + ) -> Result { + let Signature { + mut key, + unverified, + mut digest_verifier, + digest_parts, + } = signature; + + if !unverified + .verify(|sig, signing_string| key.verify(sig, signing_string)) + .map_err(Into::into)? + { + return Err(Error::Signature.into()); + } + + if D::REQUIRED { + if !digest_verifier.verify(digest_parts.as_deref().unwrap_or(&[])) { + return Err(Error::Digest.into()); + } + } + + let signature = Signature { + key, + unverified, + digest_verifier, + digest_parts, + }; + + Ok(signature) + } +} + +fn parse_digest(h: &actix_web::http::header::HeaderValue) -> Option> { + let h = h.to_str().ok()?.split(';').next()?; + let v: Vec<_> = h + .split(',') + .filter_map(|p| { + let mut iter = p.splitn(2, '='); + iter.next() + .and_then(|alg| iter.next().map(|value| (alg, value))) + }) + .map(|(alg, value)| DigestPart { + algorithm: alg.to_owned(), + digest: value.to_owned(), + }) + .collect(); + + if v.is_empty() { + None + } else { + Some(v) + } +} + +fn begin_verify( + config: &Config, + method: &actix_web::http::Method, + path_and_query: Option<&actix_web::http::uri::PathAndQuery>, + headers: &actix_web::http::header::HeaderMap, +) -> Result { + let headers = headers + .iter() + .map(|(k, v)| v.to_str().map(|v| (k.to_string(), v.to_string()))) + .collect::, actix_web::http::header::ToStrError>>( + )?; + + let path_and_query = path_and_query + .map(|p| p.to_string()) + .unwrap_or_else(|| "/".to_string()); + + let unverified = config.begin_verify(method.as_ref(), &path_and_query, headers)?; + + Ok(unverified) +} + +impl ConfigGenerator for () { + fn config() -> Config { + Config::new() + } +} + +impl VerifyDigest for () { + const REQUIRED: bool = false; + fn update(&mut self, _: &[u8]) {} + fn verify(&mut self, _: &[DigestPart]) -> bool { + true + } +} diff --git a/http-signature-normalization-actix-extractor/src/sha2_digest.rs b/http-signature-normalization-actix-extractor/src/sha2_digest.rs new file mode 100644 index 0000000..bbea1bf --- /dev/null +++ b/http-signature-normalization-actix-extractor/src/sha2_digest.rs @@ -0,0 +1,78 @@ +use crate::{DigestName, DigestPart, VerifyDigest}; +use sha2::{Sha224, Sha256, Sha384, Sha512}; + +impl DigestName for Sha224 { + const NAME: &'static str = "SHA-244"; +} + +impl DigestName for Sha256 { + const NAME: &'static str = "SHA-256"; +} + +impl DigestName for Sha384 { + const NAME: &'static str = "SHA-384"; +} + +impl DigestName for Sha512 { + const NAME: &'static str = "SHA-512"; +} + +fn verify( + digest: &mut D, + name: &str, + parts: &[DigestPart], +) -> bool { + use subtle::ConstantTimeEq; + + if let Some(decoded) = parts.iter().find_map(|p| { + if p.algorithm.to_lowercase() == name.to_lowercase() { + base64::decode(&p.digest).ok() + } else { + None + } + }) { + return digest.finalize_reset().ct_eq(&decoded).into(); + } + + false +} + +impl VerifyDigest for Sha224 { + fn update(&mut self, part: &[u8]) { + sha2::Digest::update(self, part); + } + + fn verify(&mut self, parts: &[DigestPart]) -> bool { + verify(self, Self::NAME, parts) + } +} + +impl VerifyDigest for Sha256 { + fn update(&mut self, part: &[u8]) { + sha2::Digest::update(self, part); + } + + fn verify(&mut self, parts: &[DigestPart]) -> bool { + verify(self, Self::NAME, parts) + } +} + +impl VerifyDigest for Sha384 { + fn update(&mut self, part: &[u8]) { + sha2::Digest::update(self, part); + } + + fn verify(&mut self, parts: &[DigestPart]) -> bool { + verify(self, Self::NAME, parts) + } +} + +impl VerifyDigest for Sha512 { + fn update(&mut self, part: &[u8]) { + sha2::Digest::update(self, part); + } + + fn verify(&mut self, parts: &[DigestPart]) -> bool { + verify(self, Self::NAME, parts) + } +}