mirror of
https://git.asonix.dog/asonix/http-signature-normalization.git
synced 2024-11-22 17:31:00 +00:00
367 lines
11 KiB
Rust
367 lines
11 KiB
Rust
#![deny(missing_docs)]
|
|
|
|
//! # HTTP Signature Normaliztion
|
|
//! _An HTTP Signatures library that leaves the signing to you_
|
|
//!
|
|
//! - [crates.io](https://crates.io/crates/http-signature-normalization)
|
|
//! - [docs.rs](https://docs.rs/http-signature-normalization)
|
|
//! - [Hit me up on Mastodon](https://asonix.dog/@asonix)
|
|
//!
|
|
//! Http Signature Normalization is a minimal-dependency crate for producing HTTP Signatures with user-provided signing and verification. The API is simple; there's a series of steps for creation and verification with types that ensure reasonable usage.
|
|
//!
|
|
//! ```rust
|
|
//! use chrono::Duration;
|
|
//! use http_signature_normalization::Config;
|
|
//! use std::collections::BTreeMap;
|
|
//!
|
|
//! fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
//! let config = Config::default().set_expiration(Duration::seconds(5));
|
|
//!
|
|
//! let headers = BTreeMap::new();
|
|
//!
|
|
//! let signature_header_value = config
|
|
//! .begin_sign("GET", "/foo?bar=baz", headers)?
|
|
//! .sign("my-key-id".to_owned(), |signing_string| {
|
|
//! // sign the string here
|
|
//! Ok(signing_string.to_owned()) as Result<_, Box<dyn std::error::Error>>
|
|
//! })?
|
|
//! .signature_header();
|
|
//!
|
|
//! let mut headers = BTreeMap::new();
|
|
//! headers.insert("Signature".to_owned(), signature_header_value);
|
|
//!
|
|
//! let verified = config
|
|
//! .begin_verify("GET", "/foo?bar=baz", headers)?
|
|
//! .verify(|sig, signing_string| {
|
|
//! // Verify the signature here
|
|
//! sig == signing_string
|
|
//! });
|
|
//!
|
|
//! assert!(verified);
|
|
//! Ok(())
|
|
//! }
|
|
//! ```
|
|
|
|
use chrono::{DateTime, Duration, Utc};
|
|
use std::collections::{BTreeMap, HashSet};
|
|
|
|
pub mod create;
|
|
pub mod verify;
|
|
|
|
use self::{
|
|
create::Unsigned,
|
|
verify::{ParseSignatureError, ParsedHeader, Unverified, ValidateError},
|
|
};
|
|
|
|
const REQUEST_TARGET: &str = "(request-target)";
|
|
const CREATED: &str = "(created)";
|
|
const EXPIRES: &str = "(expires)";
|
|
|
|
const KEY_ID_FIELD: &str = "keyId";
|
|
const ALGORITHM_FIELD: &str = "algorithm";
|
|
const ALGORITHM_VALUE: &str = "hs2019";
|
|
const CREATED_FIELD: &str = "created";
|
|
const EXPIRES_FIELD: &str = "expires";
|
|
const HEADERS_FIELD: &str = "headers";
|
|
const SIGNATURE_FIELD: &str = "signature";
|
|
|
|
#[derive(Clone, Debug)]
|
|
/// 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 {
|
|
expires_after: Duration,
|
|
use_created_field: bool,
|
|
required_headers: HashSet<String>,
|
|
}
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
/// Error preparing a header for validation
|
|
///
|
|
/// This could be due to a missing header, and unparsable header, or an expired header
|
|
pub enum PrepareVerifyError {
|
|
#[error("{0}")]
|
|
/// Error validating the header
|
|
Validate(#[from] ValidateError),
|
|
|
|
#[error("{0}")]
|
|
/// Error parsing the header
|
|
Parse(#[from] ParseSignatureError),
|
|
|
|
#[error("{0}")]
|
|
/// Missing required headers
|
|
Required(#[from] RequiredError),
|
|
}
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
#[error("Missing required headers {0:?}")]
|
|
/// Failed to build a signing string due to missing required headers
|
|
pub struct RequiredError(HashSet<String>);
|
|
|
|
impl RequiredError {
|
|
/// Retrieve the missing headers from the error
|
|
pub fn headers(&self) -> &HashSet<String> {
|
|
&self.0
|
|
}
|
|
|
|
/// Take the headers from the error
|
|
pub fn take_headers(&mut self) -> HashSet<String> {
|
|
std::mem::take(&mut self.0)
|
|
}
|
|
}
|
|
|
|
impl Config {
|
|
/// Create a new Config with a default expiration of 10 seconds
|
|
pub fn new() -> Self {
|
|
Config::default()
|
|
}
|
|
|
|
/// 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 {
|
|
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.
|
|
pub fn dont_use_created_field(mut self) -> Self {
|
|
self.use_created_field = false;
|
|
self.require_header("date")
|
|
}
|
|
|
|
/// Set the expiration to a custom duration
|
|
pub fn set_expiration(mut self, expires_after: Duration) -> Self {
|
|
self.expires_after = expires_after;
|
|
self
|
|
}
|
|
|
|
/// Mark a header as required
|
|
pub fn require_header(mut self, header: &str) -> Self {
|
|
self.required_headers.insert(header.to_lowercase());
|
|
self
|
|
}
|
|
|
|
/// Perform the neccessary operations to produce an [`Unsigned`] type, which can be used to
|
|
/// sign the header
|
|
pub fn begin_sign(
|
|
&self,
|
|
method: &str,
|
|
path_and_query: &str,
|
|
headers: BTreeMap<String, String>,
|
|
) -> Result<Unsigned, RequiredError> {
|
|
let mut headers = headers
|
|
.into_iter()
|
|
.map(|(k, v)| (k.to_lowercase(), v))
|
|
.collect();
|
|
|
|
let sig_headers = self.build_headers_list(&headers);
|
|
|
|
let (created, expires) = if self.use_created_field {
|
|
let created = Utc::now();
|
|
let expires = created + self.expires_after;
|
|
|
|
(Some(created), Some(expires))
|
|
} else {
|
|
(None, None)
|
|
};
|
|
|
|
let signing_string = build_signing_string(
|
|
method,
|
|
path_and_query,
|
|
created,
|
|
expires,
|
|
&sig_headers,
|
|
&mut headers,
|
|
self.required_headers.clone(),
|
|
)?;
|
|
|
|
Ok(Unsigned {
|
|
signing_string,
|
|
sig_headers,
|
|
created,
|
|
expires,
|
|
})
|
|
}
|
|
|
|
/// Perform the neccessary operations to produce and [`Unerified`] type, which can be used to
|
|
/// verify the header
|
|
pub fn begin_verify(
|
|
&self,
|
|
method: &str,
|
|
path_and_query: &str,
|
|
headers: BTreeMap<String, String>,
|
|
) -> Result<Unverified, PrepareVerifyError> {
|
|
let mut headers: BTreeMap<String, String> = headers
|
|
.into_iter()
|
|
.map(|(k, v)| (k.to_lowercase(), v))
|
|
.collect();
|
|
|
|
let header = headers
|
|
.remove("authorization")
|
|
.or_else(|| headers.remove("signature"))
|
|
.ok_or(ValidateError::Missing)?;
|
|
|
|
let parsed_header: ParsedHeader = header.parse()?;
|
|
let unvalidated = parsed_header.into_unvalidated(
|
|
method,
|
|
path_and_query,
|
|
&mut headers,
|
|
self.required_headers.clone(),
|
|
)?;
|
|
|
|
Ok(unvalidated.validate(self.expires_after)?)
|
|
}
|
|
|
|
fn build_headers_list(&self, btm: &BTreeMap<String, String>) -> Vec<String> {
|
|
let http_header_keys: Vec<String> = btm.keys().cloned().collect();
|
|
|
|
let mut sig_headers = if self.use_created_field {
|
|
vec![
|
|
REQUEST_TARGET.to_owned(),
|
|
CREATED.to_owned(),
|
|
EXPIRES.to_owned(),
|
|
]
|
|
} else {
|
|
vec![REQUEST_TARGET.to_owned()]
|
|
};
|
|
|
|
sig_headers.extend(http_header_keys);
|
|
|
|
sig_headers
|
|
}
|
|
}
|
|
|
|
fn build_signing_string(
|
|
method: &str,
|
|
path_and_query: &str,
|
|
created: Option<DateTime<Utc>>,
|
|
expires: Option<DateTime<Utc>>,
|
|
sig_headers: &[String],
|
|
btm: &mut BTreeMap<String, String>,
|
|
mut required_headers: HashSet<String>,
|
|
) -> Result<String, RequiredError> {
|
|
let request_target = format!("{} {}", method.to_string().to_lowercase(), path_and_query);
|
|
|
|
btm.insert(REQUEST_TARGET.to_owned(), request_target);
|
|
if let Some(created) = created {
|
|
btm.insert(CREATED.to_owned(), created.timestamp().to_string());
|
|
}
|
|
if let Some(expires) = expires {
|
|
btm.insert(EXPIRES.to_owned(), expires.timestamp().to_string());
|
|
}
|
|
|
|
let signing_string = sig_headers
|
|
.iter()
|
|
.filter_map(|h| {
|
|
let opt = btm.remove(h).map(|v| format!("{}: {}", h, v));
|
|
if opt.is_some() {
|
|
required_headers.remove(h);
|
|
}
|
|
opt
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
|
|
if !required_headers.is_empty() {
|
|
return Err(RequiredError(required_headers));
|
|
}
|
|
|
|
Ok(signing_string)
|
|
}
|
|
|
|
impl Default for Config {
|
|
fn default() -> Self {
|
|
Config {
|
|
expires_after: Duration::seconds(10),
|
|
use_created_field: true,
|
|
required_headers: HashSet::new(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::Config;
|
|
use std::collections::BTreeMap;
|
|
|
|
fn prepare_headers() -> BTreeMap<String, String> {
|
|
let mut headers = BTreeMap::new();
|
|
headers.insert(
|
|
"Content-Type".to_owned(),
|
|
"application/activity+json".to_owned(),
|
|
);
|
|
headers
|
|
}
|
|
|
|
#[test]
|
|
fn required_header() {
|
|
let headers = prepare_headers();
|
|
let config = Config::default().require_header("date");
|
|
|
|
let res = config.begin_sign("GET", "/foo?bar=baz", headers);
|
|
|
|
assert!(res.is_err())
|
|
}
|
|
|
|
#[test]
|
|
fn round_trip_authorization() {
|
|
let headers = prepare_headers();
|
|
let config = Config::default().require_header("content-type");
|
|
|
|
let authorization_header = config
|
|
.begin_sign("GET", "/foo?bar=baz", headers)
|
|
.unwrap()
|
|
.sign("hi".to_owned(), |s| {
|
|
Ok(s.to_owned()) as Result<_, std::io::Error>
|
|
})
|
|
.unwrap()
|
|
.authorization_header();
|
|
|
|
let mut headers = prepare_headers();
|
|
headers.insert("Authorization".to_owned(), authorization_header);
|
|
|
|
let verified = config
|
|
.begin_verify("GET", "/foo?bar=baz", headers)
|
|
.unwrap()
|
|
.verify(|sig, signing_string| sig == signing_string);
|
|
|
|
assert!(verified);
|
|
}
|
|
|
|
#[test]
|
|
fn round_trip_signature() {
|
|
let headers = prepare_headers();
|
|
let config = Config::default();
|
|
|
|
let signature_header = config
|
|
.begin_sign("GET", "/foo?bar=baz", headers)
|
|
.unwrap()
|
|
.sign("hi".to_owned(), |s| {
|
|
Ok(s.to_owned()) as Result<_, std::io::Error>
|
|
})
|
|
.unwrap()
|
|
.signature_header();
|
|
|
|
let mut headers = prepare_headers();
|
|
headers.insert("Signature".to_owned(), signature_header);
|
|
|
|
let verified = config
|
|
.begin_verify("GET", "/foo?bar=baz", headers)
|
|
.unwrap()
|
|
.verify(|sig, signing_string| sig == signing_string);
|
|
|
|
assert!(verified);
|
|
}
|
|
}
|