Implemented outbound request signing
This commit is contained in:
parent
aefef079a7
commit
ce46782318
|
@ -1726,6 +1726,7 @@ dependencies = [
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"headers",
|
"headers",
|
||||||
"http",
|
"http",
|
||||||
|
"httpdate",
|
||||||
"hyper",
|
"hyper",
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"magnetar_common",
|
"magnetar_common",
|
||||||
|
|
|
@ -42,6 +42,7 @@ futures-core = "0.3"
|
||||||
futures-util = "0.3"
|
futures-util = "0.3"
|
||||||
headers = "0.4"
|
headers = "0.4"
|
||||||
http = "1.0"
|
http = "1.0"
|
||||||
|
httpdate = "1"
|
||||||
hyper = "1.1"
|
hyper = "1.1"
|
||||||
idna = "0.5"
|
idna = "0.5"
|
||||||
indexmap = "2.2"
|
indexmap = "2.2"
|
||||||
|
|
|
@ -27,6 +27,7 @@ base64 = { workspace = true }
|
||||||
url = { workspace = true, features = ["serde"] }
|
url = { workspace = true, features = ["serde"] }
|
||||||
|
|
||||||
chrono = { workspace = true, features = ["serde"] }
|
chrono = { workspace = true, features = ["serde"] }
|
||||||
|
httpdate = { workspace = true }
|
||||||
|
|
||||||
indexmap = { workspace = true, features = ["serde"] }
|
indexmap = { workspace = true, features = ["serde"] }
|
||||||
|
|
||||||
|
@ -39,7 +40,12 @@ hyper = { workspace = true, features = ["full"] }
|
||||||
percent-encoding = { workspace = true }
|
percent-encoding = { workspace = true }
|
||||||
reqwest = { workspace = true, features = ["stream"] }
|
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"] }
|
rsa = { workspace = true, features = ["sha2"] }
|
||||||
sha2 = { workspace = true }
|
sha2 = { workspace = true }
|
||||||
|
|
||||||
|
|
|
@ -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<FederationClient>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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::<Vec<_>>()
|
||||||
|
.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<Utc>);
|
||||||
|
|
||||||
|
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<Utc>);
|
||||||
|
|
||||||
|
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<Utc>);
|
||||||
|
|
||||||
|
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<T: CreateField> CreateField for Option<T> {
|
||||||
|
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::<Vec<_>>()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
signing_input!(SigningInputGetRsaSha256,
|
||||||
|
request_target => RequestTarget<'a>,
|
||||||
|
host => HostPseudoHeader<'a>,
|
||||||
|
date => DateHeader,
|
||||||
|
expires => Option<ExpiresPseudoHeader>);
|
||||||
|
|
||||||
|
impl SigningParts for SigningInputGetRsaSha256<'_> {
|
||||||
|
fn get_created(&self) -> Option<&chrono::prelude::DateTime<Utc>> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_expires(&self) -> Option<&chrono::prelude::DateTime<Utc>> {
|
||||||
|
self.expires.as_ref().map(|ExpiresPseudoHeader(v)| v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
signing_input!(SigningInputGetHs2019,
|
||||||
|
request_target => RequestTarget<'a>,
|
||||||
|
host => HostPseudoHeader<'a>,
|
||||||
|
created => CreatedPseudoHeader,
|
||||||
|
expires => Option<ExpiresPseudoHeader>);
|
||||||
|
|
||||||
|
impl SigningParts for SigningInputGetHs2019<'_> {
|
||||||
|
fn get_created(&self) -> Option<&chrono::prelude::DateTime<Utc>> {
|
||||||
|
Some(&self.created.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_expires(&self) -> Option<&chrono::prelude::DateTime<Utc>> {
|
||||||
|
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<ExpiresPseudoHeader>);
|
||||||
|
|
||||||
|
impl SigningParts for SigningInputPostRsaSha256<'_> {
|
||||||
|
fn get_created(&self) -> Option<&chrono::prelude::DateTime<Utc>> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_expires(&self) -> Option<&chrono::prelude::DateTime<Utc>> {
|
||||||
|
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<ExpiresPseudoHeader>);
|
||||||
|
|
||||||
|
impl SigningParts for SigningInputPostHs2019<'_> {
|
||||||
|
fn get_created(&self) -> Option<&chrono::prelude::DateTime<Utc>> {
|
||||||
|
Some(&self.created.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_expires(&self) -> Option<&chrono::prelude::DateTime<Utc>> {
|
||||||
|
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<ApSignature, Self::Error> {
|
||||||
|
let components = request.create_signing_input();
|
||||||
|
|
||||||
|
let message = components
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| format!("{}: {}", k.as_ref(), v))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.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<chrono::DateTime<Utc>>,
|
||||||
|
url: &str,
|
||||||
|
) -> Result<Value, Self::Error> {
|
||||||
|
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<chrono::DateTime<Utc>>,
|
||||||
|
url: &str,
|
||||||
|
body: &Value,
|
||||||
|
) -> Result<Value, Self::Error> {
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,8 +1,8 @@
|
||||||
use async_stream::stream;
|
use async_stream::stream;
|
||||||
use futures_util::{select, stream::StreamExt, FutureExt, Stream, TryStreamExt};
|
use futures_util::{select, stream::StreamExt, FutureExt, Stream, TryStreamExt};
|
||||||
use headers::Header;
|
use headers::UserAgent;
|
||||||
use hyper::body::Bytes;
|
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 reqwest::{redirect::Policy, Client, RequestBuilder};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
@ -14,6 +14,7 @@ pub struct FederationClient {
|
||||||
pub client: Client,
|
pub client: Client,
|
||||||
pub body_limit: usize,
|
pub body_limit: usize,
|
||||||
pub timeout_seconds: u64,
|
pub timeout_seconds: u64,
|
||||||
|
user_agent: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
|
@ -51,6 +52,7 @@ impl FederationClient {
|
||||||
force_https: bool,
|
force_https: bool,
|
||||||
body_limit: usize,
|
body_limit: usize,
|
||||||
timeout_seconds: u64,
|
timeout_seconds: u64,
|
||||||
|
user_agent: UserAgent,
|
||||||
) -> Result<FederationClient, FederationClientBuilderError> {
|
) -> Result<FederationClient, FederationClientBuilderError> {
|
||||||
let client = Client::builder()
|
let client = Client::builder()
|
||||||
.https_only(force_https)
|
.https_only(force_https)
|
||||||
|
@ -61,13 +63,17 @@ impl FederationClient {
|
||||||
client,
|
client,
|
||||||
body_limit,
|
body_limit,
|
||||||
timeout_seconds,
|
timeout_seconds,
|
||||||
|
user_agent: user_agent.to_string(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn builder(&self, method: reqwest::Method, url: Url) -> FederationRequestBuilder<'_> {
|
pub fn builder(&self, method: reqwest::Method, url: Url) -> FederationRequestBuilder<'_> {
|
||||||
FederationRequestBuilder {
|
FederationRequestBuilder {
|
||||||
client: self,
|
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 {
|
Self {
|
||||||
client: self.client,
|
client: self.client,
|
||||||
builder: self.builder.header(
|
builder: self.builder.header(
|
||||||
headers::ContentType::name().to_string(),
|
http::header::CONTENT_TYPE,
|
||||||
content_type.mime_type().to_string(),
|
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 {
|
pub fn headers(self, headers: reqwest::header::HeaderMap) -> Self {
|
||||||
Self {
|
Self {
|
||||||
client: self.client,
|
client: self.client,
|
||||||
|
@ -94,6 +109,13 @@ impl FederationRequestBuilder<'_> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn body(self, body: Vec<u8>) -> Self {
|
||||||
|
Self {
|
||||||
|
client: self.client,
|
||||||
|
builder: self.builder.body(body),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn send_stream(
|
async fn send_stream(
|
||||||
self,
|
self,
|
||||||
) -> Result<impl Stream<Item = Result<Bytes, FederationClientError>>, FederationClientError>
|
) -> Result<impl Stream<Item = Result<Bytes, FederationClientError>>, FederationClientError>
|
||||||
|
@ -146,14 +168,7 @@ impl FederationRequestBuilder<'_> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn dereference(self) -> Result<Value, FederationClientError> {
|
pub async fn json(self) -> Result<Value, FederationClientError> {
|
||||||
let mut headers = reqwest::header::HeaderMap::new();
|
|
||||||
|
|
||||||
headers.insert(
|
|
||||||
reqwest::header::ACCEPT,
|
|
||||||
reqwest::header::HeaderValue::from_static(ContentActivityStreams.as_ref()),
|
|
||||||
);
|
|
||||||
|
|
||||||
let data = self.send().await?;
|
let data = self.send().await?;
|
||||||
let json = serde_json::from_slice::<Value>(&data)?;
|
let json = serde_json::from_slice::<Value>(&data)?;
|
||||||
|
|
||||||
|
|
|
@ -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<Sha256>>),
|
||||||
|
#[strum(serialize = "rsa-sha512")]
|
||||||
|
RsaSha512(Cow<'a, rsa::pkcs1v15::VerifyingKey<Sha512>>),
|
||||||
|
#[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<Cow<'a, rsa::RsaPrivateKey>>),
|
||||||
|
#[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<rsa::sha2::Sha256>>),
|
||||||
|
#[strum(serialize = "rsa-sha512")]
|
||||||
|
RsaSha512(Cow<'a, rsa::pkcs1v15::SigningKey<rsa::sha2::Sha512>>),
|
||||||
|
#[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<Vec<u8>, 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<String, ApSigningError> {
|
||||||
|
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<ApSigningKey<'a>, 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(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,10 +1,18 @@
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use indexmap::IndexSet;
|
||||||
use magnetar_common::util::{FediverseTag, ValidName};
|
use magnetar_common::util::{FediverseTag, ValidName};
|
||||||
use magnetar_host_meta::Xrd;
|
use magnetar_host_meta::Xrd;
|
||||||
use magnetar_webfinger::webfinger::WebFinger;
|
use magnetar_webfinger::webfinger::WebFinger;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
use strum::AsRefStr;
|
||||||
use url::{Host, Url};
|
use url::{Host, Url};
|
||||||
|
|
||||||
|
use crate::crypto::{ApSigningKey, SigningAlgorithm};
|
||||||
|
|
||||||
|
pub mod ap_client;
|
||||||
pub mod client;
|
pub mod client;
|
||||||
|
pub mod crypto;
|
||||||
pub mod lookup_flow;
|
pub mod lookup_flow;
|
||||||
|
|
||||||
/// The *visible* domain of fediverse handles, that gets resolved by WebFinger
|
/// 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<MappedUser, Self::Error>;
|
async fn map_fedi_tag(&self, user: &UnmappedUser) -> Result<MappedUser, Self::Error>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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<SigningAlgorithm>,
|
||||||
|
pub created: Option<DateTime<Utc>>,
|
||||||
|
pub expires: Option<DateTime<Utc>>,
|
||||||
|
pub headers: Option<ApSigningHeaders>,
|
||||||
|
pub signature: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ApSigningHeaders(pub(crate) IndexSet<ApSigningField>);
|
||||||
|
|
||||||
|
pub trait SigningParts {
|
||||||
|
fn get_created(&self) -> Option<&DateTime<Utc>>;
|
||||||
|
fn get_expires(&self) -> Option<&DateTime<Utc>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<ApSignature, Self::Error>;
|
||||||
|
|
||||||
|
async fn signed_get(
|
||||||
|
&self,
|
||||||
|
signing_key: ApSigningKey<'_>,
|
||||||
|
signing_algorithm: SigningAlgorithm,
|
||||||
|
expires: Option<chrono::DateTime<Utc>>,
|
||||||
|
url: &str,
|
||||||
|
) -> Result<Value, Self::Error>;
|
||||||
|
|
||||||
|
async fn signed_post(
|
||||||
|
&self,
|
||||||
|
signing_key: ApSigningKey<'_>,
|
||||||
|
signing_algorithm: SigningAlgorithm,
|
||||||
|
expires: Option<chrono::DateTime<Utc>>,
|
||||||
|
url: &str,
|
||||||
|
body: &Value,
|
||||||
|
) -> Result<Value, Self::Error>;
|
||||||
|
}
|
||||||
|
|
|
@ -174,6 +174,7 @@ impl FederationLookupService for FederationLookupServiceProviderDefault {
|
||||||
mod test {
|
mod test {
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use headers::UserAgent;
|
||||||
use magnetar_common::{config::MagnetarNetworkingProtocol, util::FediverseTag};
|
use magnetar_common::{config::MagnetarNetworkingProtocol, util::FediverseTag};
|
||||||
use tracing::{info, Level};
|
use tracing::{info, Level};
|
||||||
|
|
||||||
|
@ -193,7 +194,10 @@ mod test {
|
||||||
.with_max_level(Level::TRACE)
|
.with_max_level(Level::TRACE)
|
||||||
.init();
|
.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(
|
let federation_lookup = FederationLookupServiceProviderDefault::new(
|
||||||
Arc::new(HostMetaResolverProviderDefault {
|
Arc::new(HostMetaResolverProviderDefault {
|
||||||
|
|
Loading…
Reference in New Issue