From ce46782318b2c3eb4c872a0559d1fc338dba7cf0 Mon Sep 17 00:00:00 2001 From: Natty Date: Tue, 9 Apr 2024 16:49:00 +0200 Subject: [PATCH] Implemented outbound request signing --- Cargo.lock | 1 + Cargo.toml | 1 + ext_federation/Cargo.toml | 8 +- ext_federation/src/ap_client.rs | 450 ++++++++++++++++++ .../src/client/federation_client.rs | 39 +- ext_federation/src/crypto.rs | 210 ++++++++ ext_federation/src/lib.rs | 84 ++++ ext_federation/src/lookup_flow.rs | 6 +- 8 files changed, 785 insertions(+), 14 deletions(-) create mode 100644 ext_federation/src/ap_client.rs create mode 100644 ext_federation/src/crypto.rs diff --git a/Cargo.lock b/Cargo.lock index 30d2a19..3f67e77 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1726,6 +1726,7 @@ dependencies = [ "futures-util", "headers", "http", + "httpdate", "hyper", "indexmap", "magnetar_common", diff --git a/Cargo.toml b/Cargo.toml index 3ac753b..6aeb7e5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,6 +42,7 @@ futures-core = "0.3" futures-util = "0.3" headers = "0.4" http = "1.0" +httpdate = "1" hyper = "1.1" idna = "0.5" indexmap = "2.2" diff --git a/ext_federation/Cargo.toml b/ext_federation/Cargo.toml index afa7caa..44834a1 100644 --- a/ext_federation/Cargo.toml +++ b/ext_federation/Cargo.toml @@ -27,6 +27,7 @@ base64 = { workspace = true } url = { workspace = true, features = ["serde"] } chrono = { workspace = true, features = ["serde"] } +httpdate = { workspace = true } indexmap = { workspace = true, features = ["serde"] } @@ -39,7 +40,12 @@ hyper = { workspace = true, features = ["full"] } percent-encoding = { workspace = true } reqwest = { workspace = true, features = ["stream"] } -ed25519-dalek = { workspace = true, features = ["pem", "pkcs8", "signature"] } +ed25519-dalek = { workspace = true, features = [ + "pem", + "pkcs8", + "signature", + "digest", +] } rsa = { workspace = true, features = ["sha2"] } sha2 = { workspace = true } diff --git a/ext_federation/src/ap_client.rs b/ext_federation/src/ap_client.rs new file mode 100644 index 0000000..42c5320 --- /dev/null +++ b/ext_federation/src/ap_client.rs @@ -0,0 +1,450 @@ +use std::{fmt::Display, sync::Arc}; + +use chrono::Utc; +use http::{HeaderMap, HeaderName, HeaderValue, Method}; +use indexmap::IndexSet; +use magnetar_core::web_model::content_type::ContentActivityStreams; +use serde_json::Value; +use sha2::Digest; +use thiserror::Error; +use url::Url; + +use crate::{ + client::federation_client::{FederationClient, FederationClientError}, + crypto::{ApSigningError, ApSigningKey, SigningAlgorithm}, + ApClientService, ApSignature, ApSigningField, ApSigningHeaders, SigningInput, SigningParts, +}; + +pub struct ApClientServiceDefaultProvider { + client: Arc, +} + +impl Display for ApSignature { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "keyId=\"{}\"", self.key_id)?; + if let Some(ref algorithm) = self.algorithm { + write!(f, ",algorithm=\"{}\"", algorithm)?; + } + if let Some(ref created) = self.created { + write!(f, ",created={}", created.timestamp())?; + } + if let Some(ref expires) = self.expires { + write!(f, ",expires={}", expires.timestamp())?; + } + if let Some(ref headers) = self.headers { + write!(f, ",headers=\"{}\"", headers)?; + } + write!(f, ",signature=\"{}\"", self.signature)?; + Ok(()) + } +} + +impl Display for ApSigningHeaders { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}", + self.0 + .iter() + .map(AsRef::as_ref) + .collect::>() + .join(" ") + ) + } +} + +#[derive(Debug, Error)] +pub enum ApClientError { + #[error("Federation client error: {0}")] + FederationClientError(#[from] FederationClientError), + #[error("Signing error: {0}")] + SigningError(#[from] ApSigningError), + #[error("URL parse error: {0}")] + UrlParseError(#[from] url::ParseError), + #[error("Failed to serialize JSON: {0}")] + SerializerError(#[from] serde_json::Error), + #[error("Invalid header value: {0}")] + InvalidHeaderValue(#[from] http::header::InvalidHeaderValue), +} + +trait CreateField { + fn create_field(&self) -> Option<(ApSigningField, String)>; +} + +#[derive(Debug)] +struct RequestTarget<'a> { + url: &'a Url, + method: Method, +} + +impl CreateField for RequestTarget<'_> { + fn create_field(&self) -> Option<(ApSigningField, String)> { + Some(( + ApSigningField::PseudoRequestTarget, + format!( + "{} {}", + self.method.as_str().to_lowercase(), + self.url.path() + ), + )) + } +} + +#[derive(Debug)] +struct HostPseudoHeader<'a>(&'a Url); + +impl CreateField for HostPseudoHeader<'_> { + fn create_field(&self) -> Option<(ApSigningField, String)> { + Some((ApSigningField::Host, self.0.host()?.to_string())) + } +} + +#[derive(Debug)] +struct DateHeader(chrono::DateTime); + +impl CreateField for DateHeader { + fn create_field(&self) -> Option<(ApSigningField, String)> { + Some((ApSigningField::Date, httpdate::fmt_http_date(self.0.into()))) + } +} + +#[derive(Debug)] +struct CreatedPseudoHeader(chrono::DateTime); + +impl CreateField for CreatedPseudoHeader { + fn create_field(&self) -> Option<(ApSigningField, String)> { + Some(( + ApSigningField::PseudoCreated, + self.0.timestamp().to_string(), + )) + } +} + +#[derive(Debug)] +struct ExpiresPseudoHeader(chrono::DateTime); + +impl CreateField for ExpiresPseudoHeader { + fn create_field(&self) -> Option<(ApSigningField, String)> { + Some(( + ApSigningField::PseudoExpires, + self.0.timestamp().to_string(), + )) + } +} + +#[derive(Debug)] +struct DigestHeader<'a>(&'a str); + +impl CreateField for DigestHeader<'_> { + fn create_field(&self) -> Option<(ApSigningField, String)> { + Some((ApSigningField::Digest, self.0.to_owned())) + } +} + +impl CreateField for Option { + fn create_field(&self) -> Option<(ApSigningField, String)> { + self.as_ref().and_then(T::create_field) + } +} + +macro_rules! signing_input { + ($name:ident, $($field_name:ident => $field_type:path),+ ) => { + #[derive(Debug)] + struct $name<'a> { + $($field_name: $field_type),+ + } + + impl SigningInput for $name<'_> { + fn create_signing_input(&self) -> Vec<(ApSigningField, String)> { + [$(self.$field_name.create_field()),+].into_iter().flatten().collect::>() + } + } + } +} + +signing_input!(SigningInputGetRsaSha256, + request_target => RequestTarget<'a>, + host => HostPseudoHeader<'a>, + date => DateHeader, + expires => Option); + +impl SigningParts for SigningInputGetRsaSha256<'_> { + fn get_created(&self) -> Option<&chrono::prelude::DateTime> { + None + } + + fn get_expires(&self) -> Option<&chrono::prelude::DateTime> { + self.expires.as_ref().map(|ExpiresPseudoHeader(v)| v) + } +} + +signing_input!(SigningInputGetHs2019, + request_target => RequestTarget<'a>, + host => HostPseudoHeader<'a>, + created => CreatedPseudoHeader, + expires => Option); + +impl SigningParts for SigningInputGetHs2019<'_> { + fn get_created(&self) -> Option<&chrono::prelude::DateTime> { + Some(&self.created.0) + } + + fn get_expires(&self) -> Option<&chrono::prelude::DateTime> { + self.expires.as_ref().map(|ExpiresPseudoHeader(v)| v) + } +} + +signing_input!(SigningInputPostRsaSha256, + request_target => RequestTarget<'a>, + host => HostPseudoHeader<'a>, + date => DateHeader, + digest => DigestHeader<'a>, + expires => Option); + +impl SigningParts for SigningInputPostRsaSha256<'_> { + fn get_created(&self) -> Option<&chrono::prelude::DateTime> { + None + } + + fn get_expires(&self) -> Option<&chrono::prelude::DateTime> { + self.expires.as_ref().map(|ExpiresPseudoHeader(v)| v) + } +} + +signing_input!(SigningInputPostHs2019, + request_target => RequestTarget<'a>, + host => HostPseudoHeader<'a>, + created => CreatedPseudoHeader, + digest => DigestHeader<'a>, + expires => Option); + +impl SigningParts for SigningInputPostHs2019<'_> { + fn get_created(&self) -> Option<&chrono::prelude::DateTime> { + Some(&self.created.0) + } + + fn get_expires(&self) -> Option<&chrono::prelude::DateTime> { + self.expires.as_ref().map(|ExpiresPseudoHeader(v)| v) + } +} + +#[async_trait::async_trait] +impl ApClientService for ApClientServiceDefaultProvider { + type Error = ApClientError; + + fn sign_request( + &self, + signing_key: ApSigningKey<'_>, + signing_algorithm: SigningAlgorithm, + request: impl SigningInput, + ) -> Result { + let components = request.create_signing_input(); + + let message = components + .iter() + .map(|(k, v)| format!("{}: {}", k.as_ref(), v)) + .collect::>() + .join("\n"); + + let signature = signing_key + .key + .sign_base64(signing_algorithm, &message.into_bytes())?; + + Ok(ApSignature { + key_id: signing_key.key_id.clone().into_owned(), + algorithm: Some(signing_algorithm), + created: request.get_created().cloned(), + expires: request.get_expires().cloned(), + headers: Some(ApSigningHeaders(IndexSet::from_iter( + components.iter().map(|(k, _)| k).copied(), + ))), + signature, + }) + } + + async fn signed_get( + &self, + signing_key: ApSigningKey<'_>, + signing_algorithm: SigningAlgorithm, + expires: Option>, + url: &str, + ) -> Result { + let url = url.parse()?; + + let time_created = Utc::now(); + let signed = match signing_algorithm { + SigningAlgorithm::RsaSha256 => self.sign_request( + signing_key, + signing_algorithm, + SigningInputGetRsaSha256 { + request_target: RequestTarget { + url: &url, + method: Method::GET, + }, + host: HostPseudoHeader(&url), + date: DateHeader(time_created), + expires: expires.map(ExpiresPseudoHeader), + }, + )?, + SigningAlgorithm::Hs2019 => self.sign_request( + signing_key, + signing_algorithm, + SigningInputGetHs2019 { + request_target: RequestTarget { + url: &url, + method: Method::GET, + }, + host: HostPseudoHeader(&url), + created: CreatedPseudoHeader(time_created), + expires: expires.map(ExpiresPseudoHeader), + }, + )?, + }; + let mut headers = HeaderMap::new(); + + if !matches!(signing_algorithm, SigningAlgorithm::Hs2019) { + headers.insert( + http::header::DATE, + HeaderValue::try_from(httpdate::fmt_http_date(time_created.into())) + .expect("date should always be a valid header value"), + ); + } + + headers.insert( + HeaderName::from_lowercase(b"signature").unwrap(), + HeaderValue::try_from(signed.to_string())?, + ); + + Ok(self + .client + .get(url) + .accept(ContentActivityStreams) + .headers(headers) + .json() + .await?) + } + + async fn signed_post( + &self, + signing_key: ApSigningKey<'_>, + signing_algorithm: SigningAlgorithm, + expires: Option>, + url: &str, + body: &Value, + ) -> Result { + let url = url.parse()?; + let body_bytes = serde_json::to_vec(body)?; + let mut sha = sha2::Sha256::new(); + sha.update(&body_bytes); + let digest_raw = sha.finalize(); + use base64::prelude::*; + let digest_base64 = format!("sha-256={}", BASE64_STANDARD.encode(digest_raw)); + let time_created = Utc::now(); + let signed = match signing_algorithm { + SigningAlgorithm::RsaSha256 => self.sign_request( + signing_key, + signing_algorithm, + SigningInputPostRsaSha256 { + request_target: RequestTarget { + url: &url, + method: Method::POST, + }, + host: HostPseudoHeader(&url), + date: DateHeader(time_created), + digest: DigestHeader(&digest_base64), + expires: expires.map(ExpiresPseudoHeader), + }, + )?, + SigningAlgorithm::Hs2019 => self.sign_request( + signing_key, + signing_algorithm, + SigningInputPostHs2019 { + request_target: RequestTarget { + url: &url, + method: Method::POST, + }, + host: HostPseudoHeader(&url), + created: CreatedPseudoHeader(time_created), + digest: DigestHeader(&digest_base64), + expires: expires.map(ExpiresPseudoHeader), + }, + )?, + }; + + let mut headers = HeaderMap::new(); + + if !matches!(signing_algorithm, SigningAlgorithm::Hs2019) { + headers.insert( + http::header::DATE, + HeaderValue::try_from(httpdate::fmt_http_date(time_created.into())) + .expect("date should always be a valid header value"), + ); + } + + headers.insert( + HeaderName::from_lowercase(b"digest").unwrap(), + HeaderValue::try_from(digest_base64)?, + ); + + headers.insert( + HeaderName::from_lowercase(b"signature").unwrap(), + HeaderValue::try_from(signed.to_string())?, + ); + + Ok(self + .client + .builder(Method::POST, url) + .accept(ContentActivityStreams) + .content_type(ContentActivityStreams) + .headers(headers) + .body(body_bytes) + .json() + .await?) + } +} + +#[cfg(test)] +mod test { + use std::{borrow::Cow, sync::Arc}; + + use headers::UserAgent; + use miette::IntoDiagnostic; + use rsa::pkcs8::DecodePrivateKey; + + use crate::{ + ap_client::ApClientServiceDefaultProvider, + client::federation_client::FederationClient, + crypto::{ApHttpPrivateKey, SigningAlgorithm}, + ApClientService, + }; + + #[tokio::test] + async fn should_request() -> miette::Result<()> { + let key_id = std::env::var("MAG_TEST_KEY_ID").into_diagnostic()?; + let key = std::env::var("MAG_TEST_PRIVATE_KEY").into_diagnostic()?; + let url = std::env::var("MAG_TEST_FETCH_URL").into_diagnostic()?; + + let rsa_key = rsa::RsaPrivateKey::from_pkcs8_pem(key.trim()).into_diagnostic()?; + let ap_client = ApClientServiceDefaultProvider { + client: Arc::new( + FederationClient::new(true, 128_000, 25, UserAgent::from_static("magnetar/0.42")) + .into_diagnostic()?, + ), + }; + + let val = ap_client + .signed_get( + ApHttpPrivateKey::Rsa(Box::new(Cow::Owned(rsa_key))) + .create_signing_key(&key_id, SigningAlgorithm::Hs2019) + .into_diagnostic()?, + SigningAlgorithm::Hs2019, + None, + &url, + ) + .await + .into_diagnostic()?; + + println!("{:#?}", val); + Ok(()) + } +} diff --git a/ext_federation/src/client/federation_client.rs b/ext_federation/src/client/federation_client.rs index 0bfaf7c..c5dba96 100644 --- a/ext_federation/src/client/federation_client.rs +++ b/ext_federation/src/client/federation_client.rs @@ -1,8 +1,8 @@ use async_stream::stream; use futures_util::{select, stream::StreamExt, FutureExt, Stream, TryStreamExt}; -use headers::Header; +use headers::UserAgent; use hyper::body::Bytes; -use magnetar_core::web_model::{content_type::ContentActivityStreams, ContentType}; +use magnetar_core::web_model::ContentType; use reqwest::{redirect::Policy, Client, RequestBuilder}; use serde_json::Value; use thiserror::Error; @@ -14,6 +14,7 @@ pub struct FederationClient { pub client: Client, pub body_limit: usize, pub timeout_seconds: u64, + user_agent: String, } #[derive(Debug, Error)] @@ -51,6 +52,7 @@ impl FederationClient { force_https: bool, body_limit: usize, timeout_seconds: u64, + user_agent: UserAgent, ) -> Result { let client = Client::builder() .https_only(force_https) @@ -61,13 +63,17 @@ impl FederationClient { client, body_limit, timeout_seconds, + user_agent: user_agent.to_string(), }) } pub fn builder(&self, method: reqwest::Method, url: Url) -> FederationRequestBuilder<'_> { FederationRequestBuilder { client: self, - builder: self.client.request(method, url), + builder: self + .client + .request(method, url) + .header(http::header::USER_AGENT, self.user_agent.clone()), } } @@ -81,12 +87,21 @@ impl FederationRequestBuilder<'_> { Self { client: self.client, builder: self.builder.header( - headers::ContentType::name().to_string(), + http::header::CONTENT_TYPE, content_type.mime_type().to_string(), ), } } + pub fn accept(self, content_type: impl ContentType) -> Self { + Self { + client: self.client, + builder: self + .builder + .header(http::header::ACCEPT, content_type.mime_type().to_string()), + } + } + pub fn headers(self, headers: reqwest::header::HeaderMap) -> Self { Self { client: self.client, @@ -94,6 +109,13 @@ impl FederationRequestBuilder<'_> { } } + pub fn body(self, body: Vec) -> Self { + Self { + client: self.client, + builder: self.builder.body(body), + } + } + async fn send_stream( self, ) -> Result>, FederationClientError> @@ -146,14 +168,7 @@ impl FederationRequestBuilder<'_> { } } - pub async fn dereference(self) -> Result { - let mut headers = reqwest::header::HeaderMap::new(); - - headers.insert( - reqwest::header::ACCEPT, - reqwest::header::HeaderValue::from_static(ContentActivityStreams.as_ref()), - ); - + pub async fn json(self) -> Result { let data = self.send().await?; let json = serde_json::from_slice::(&data)?; diff --git a/ext_federation/src/crypto.rs b/ext_federation/src/crypto.rs new file mode 100644 index 0000000..7c86a34 --- /dev/null +++ b/ext_federation/src/crypto.rs @@ -0,0 +1,210 @@ +use std::{borrow::Cow, fmt::Display}; + +use rsa::signature::Verifier; +use rsa::{ + sha2::{Sha256, Sha512}, + signature::Signer, +}; + +use serde::{Deserialize, Serialize}; +use strum::AsRefStr; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum ApVerificationError { + #[error("Signature algorithm and public key mismatch: {0} not compatible with {1}")] + KeyAlgorithmMismatch(SigningAlgorithm, String), + #[error("PKCS#1 v1.5 RSA signature verification failed: {0}")] + RsaSignatureError(#[from] rsa::signature::Error), +} + +#[derive(Debug, Error)] +pub enum ApSigningError { + #[error("Signature algorithm and public key mismatch: {0} not compatible with {1}")] + KeyAlgorithmMismatch(SigningAlgorithm, String), + #[error("Signing error: {0}")] + RsaSigningError(#[from] rsa::signature::Error), +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize)] +pub enum SigningAlgorithm { + #[serde(rename = "hs2019")] + Hs2019, + #[serde(rename = "rsa-sha256")] + RsaSha256, +} + +impl Display for SigningAlgorithm { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Hs2019 => write!(f, "hs2019"), + Self::RsaSha256 => write!(f, "rsa-sha256"), + } + } +} + +#[derive(Debug, Clone, AsRefStr)] +pub enum ApHttpVerificationKey<'a> { + #[strum(serialize = "rsa-sha256")] + RsaSha256(Cow<'a, rsa::pkcs1v15::VerifyingKey>), + #[strum(serialize = "rsa-sha512")] + RsaSha512(Cow<'a, rsa::pkcs1v15::VerifyingKey>), + #[strum(serialize = "ed25519")] + Ed25519(Cow<'a, ed25519_dalek::VerifyingKey>), +} + +#[derive(Debug, Clone, AsRefStr)] +pub enum ApHttpPublicKey<'a> { + #[strum(serialize = "rsa")] + Rsa(Cow<'a, rsa::RsaPublicKey>), + #[strum(serialize = "ed25519")] + Ed25519(Cow<'a, ed25519_dalek::VerifyingKey>), +} + +impl ApHttpVerificationKey<'_> { + pub fn verify(&self, message: &[u8], signature: &[u8]) -> Result<(), ApVerificationError> { + match self { + ApHttpVerificationKey::RsaSha256(rsa_pubkey) => { + let sig = rsa::pkcs1v15::Signature::try_from(signature)?; + Ok(rsa_pubkey.verify(message, &sig)?) + } + ApHttpVerificationKey::RsaSha512(rsa_pubkey) => { + let sig = rsa::pkcs1v15::Signature::try_from(signature)?; + Ok(rsa_pubkey.verify(message, &sig)?) + } + ApHttpVerificationKey::Ed25519(ed25519_pubkey) => { + let sig = ed25519_dalek::Signature::try_from(signature)?; + Ok(ed25519_pubkey.verify_strict(message, &sig)?) + } + } + } +} + +impl ApHttpPublicKey<'_> { + pub fn verify( + &self, + algorithm: SigningAlgorithm, + message: &[u8], + signature: &[u8], + ) -> Result<(), ApVerificationError> { + match (self, algorithm) { + (Self::Rsa(key), SigningAlgorithm::Hs2019) => { + let verification_key = ApHttpVerificationKey::RsaSha256(Cow::Owned( + rsa::pkcs1v15::VerifyingKey::new(key.clone().into_owned()), + )); + + let Err(_) = verification_key.verify(message, signature) else { + return Ok(()); + }; + + let verification_key = ApHttpVerificationKey::RsaSha512(Cow::Owned( + rsa::pkcs1v15::VerifyingKey::new(key.clone().into_owned()), + )); + + Ok(verification_key.verify(message, signature)?) + } + (Self::Rsa(key), SigningAlgorithm::RsaSha256) => { + let verification_key = ApHttpVerificationKey::RsaSha256(Cow::Owned( + rsa::pkcs1v15::VerifyingKey::new(key.clone().into_owned()), + )); + Ok(verification_key.verify(message, signature)?) + } + (_, SigningAlgorithm::RsaSha256) => { + return Err(ApVerificationError::KeyAlgorithmMismatch( + algorithm, + self.as_ref().to_owned(), + )); + } + (Self::Ed25519(key), SigningAlgorithm::Hs2019) => { + let verification_key = ApHttpVerificationKey::Ed25519(Cow::Borrowed(key.as_ref())); + Ok(verification_key.verify(message, signature)?) + } + } + } +} + +#[derive(Debug, Clone, AsRefStr)] +pub enum ApHttpPrivateKey<'a> { + #[strum(serialize = "rsa")] + Rsa(Box>), + #[strum(serialize = "ed25519")] + Ed25519(Cow<'a, ed25519_dalek::SecretKey>), +} + +#[derive(Debug, Clone, AsRefStr)] +pub enum ApHttpSigningKey<'a> { + #[strum(serialize = "rsa-sha256")] + RsaSha256(Cow<'a, rsa::pkcs1v15::SigningKey>), + #[strum(serialize = "rsa-sha512")] + RsaSha512(Cow<'a, rsa::pkcs1v15::SigningKey>), + #[strum(serialize = "ed25519")] + Ed25519(Cow<'a, ed25519_dalek::SigningKey>), +} + +#[derive(Debug, Clone)] +pub struct ApSigningKey<'a> { + pub key: ApHttpSigningKey<'a>, + pub key_id: Cow<'a, str>, +} + +impl ApHttpSigningKey<'_> { + pub fn sign( + &self, + algorithm: SigningAlgorithm, + message: &[u8], + ) -> Result, ApSigningError> { + match (self, algorithm) { + (Self::RsaSha256(key), SigningAlgorithm::RsaSha256 | SigningAlgorithm::Hs2019) => { + Ok(Box::<[u8]>::from(key.sign(message)).into_vec()) + } + (Self::RsaSha512(key), SigningAlgorithm::Hs2019) => { + Ok(Box::<[u8]>::from(key.sign(message)).into_vec()) + } + (Self::Ed25519(key), SigningAlgorithm::Hs2019) => { + Ok(key.sign(message).to_bytes().to_vec()) + } + (key, _) => Err(ApSigningError::KeyAlgorithmMismatch( + algorithm, + key.as_ref().to_string(), + )), + } + } + + pub fn sign_base64( + &self, + algorithm: SigningAlgorithm, + message: &[u8], + ) -> Result { + let signed = self.sign(algorithm, message)?; + use base64::prelude::*; + Ok(BASE64_STANDARD.encode(signed)) + } +} + +impl ApHttpPrivateKey<'_> { + pub fn create_signing_key<'a>( + &'a self, + key_id: &'a str, + algorithm: SigningAlgorithm, + ) -> Result, ApSigningError> { + Ok(ApSigningKey { + key_id: Cow::Borrowed(key_id), + key: match (self, algorithm) { + (Self::Rsa(key), SigningAlgorithm::RsaSha256 | SigningAlgorithm::Hs2019) => { + ApHttpSigningKey::RsaSha256(Cow::Owned(rsa::pkcs1v15::SigningKey::new( + key.clone().into_owned(), + ))) + } + (Self::Ed25519(key), SigningAlgorithm::Hs2019) => ApHttpSigningKey::Ed25519( + Cow::Owned(ed25519_dalek::SigningKey::from_bytes(key.as_ref())), + ), + (key, _) => { + return Err(ApSigningError::KeyAlgorithmMismatch( + algorithm, + key.as_ref().to_owned(), + )) + } + }, + }) + } +} diff --git a/ext_federation/src/lib.rs b/ext_federation/src/lib.rs index 61583ed..a7b9154 100644 --- a/ext_federation/src/lib.rs +++ b/ext_federation/src/lib.rs @@ -1,10 +1,18 @@ +use chrono::{DateTime, Utc}; +use indexmap::IndexSet; use magnetar_common::util::{FediverseTag, ValidName}; use magnetar_host_meta::Xrd; use magnetar_webfinger::webfinger::WebFinger; use serde::{Deserialize, Serialize}; +use serde_json::Value; +use strum::AsRefStr; use url::{Host, Url}; +use crate::crypto::{ApSigningKey, SigningAlgorithm}; + +pub mod ap_client; pub mod client; +pub mod crypto; pub mod lookup_flow; /// The *visible* domain of fediverse handles, that gets resolved by WebFinger @@ -90,3 +98,79 @@ pub trait FederationLookupService { async fn map_fedi_tag(&self, user: &UnmappedUser) -> Result; } + +#[derive(Copy, Clone, Debug, Deserialize, Serialize, PartialEq, Eq, Hash, AsRefStr)] +pub enum ApSigningField { + #[serde(rename = "(request-target)")] + #[strum(serialize = "(request-target)")] + PseudoRequestTarget, + #[serde(rename = "(expires)")] + #[strum(serialize = "(expires)")] + PseudoExpires, + #[serde(rename = "(created)")] + #[strum(serialize = "(created)")] + PseudoCreated, + #[serde(rename = "date")] + #[strum(serialize = "date")] + Date, + #[serde(rename = "host")] + #[strum(serialize = "host")] + Host, + #[serde(rename = "digest")] + #[strum(serialize = "digest")] + Digest, + #[serde(rename = "content-length")] + #[strum(serialize = "content-length")] + ContentLength, +} + +#[derive(Debug)] +pub struct ApSignature { + pub key_id: String, + pub algorithm: Option, + pub created: Option>, + pub expires: Option>, + pub headers: Option, + pub signature: String, +} + +#[derive(Debug)] +pub struct ApSigningHeaders(pub(crate) IndexSet); + +pub trait SigningParts { + fn get_created(&self) -> Option<&DateTime>; + fn get_expires(&self) -> Option<&DateTime>; +} + +pub trait SigningInput: SigningParts { + fn create_signing_input(&self) -> Vec<(ApSigningField, String)>; +} + +#[async_trait::async_trait] +pub trait ApClientService: Send + Sync { + type Error; + + fn sign_request( + &self, + signing_key: ApSigningKey<'_>, + signing_algorithm: SigningAlgorithm, + request: impl SigningInput, + ) -> Result; + + async fn signed_get( + &self, + signing_key: ApSigningKey<'_>, + signing_algorithm: SigningAlgorithm, + expires: Option>, + url: &str, + ) -> Result; + + async fn signed_post( + &self, + signing_key: ApSigningKey<'_>, + signing_algorithm: SigningAlgorithm, + expires: Option>, + url: &str, + body: &Value, + ) -> Result; +} diff --git a/ext_federation/src/lookup_flow.rs b/ext_federation/src/lookup_flow.rs index 4d43e2c..400e83a 100644 --- a/ext_federation/src/lookup_flow.rs +++ b/ext_federation/src/lookup_flow.rs @@ -174,6 +174,7 @@ impl FederationLookupService for FederationLookupServiceProviderDefault { mod test { use std::sync::Arc; + use headers::UserAgent; use magnetar_common::{config::MagnetarNetworkingProtocol, util::FediverseTag}; use tracing::{info, Level}; @@ -193,7 +194,10 @@ mod test { .with_max_level(Level::TRACE) .init(); - let client = Arc::new(FederationClient::new(true, 64000, 20).unwrap()); + let client = Arc::new( + FederationClient::new(true, 64000, 20, UserAgent::from_static("magnetar/0.42")) + .unwrap(), + ); let federation_lookup = FederationLookupServiceProviderDefault::new( Arc::new(HostMetaResolverProviderDefault {