Secure mode federation support (WIP) (#39)

* First iteration of secure mode federation support

* signing_actor: take request by reference

* Implement secure mode fetch as a global config parameter

* Implement secure mode federation example in actix-web example

* fix clippy lints
This commit is contained in:
Alex Auvolat 2023-06-12 13:32:54 +02:00 committed by GitHub
parent 19baec2138
commit 7b0b830597
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 162 additions and 19 deletions

View file

@ -4,11 +4,11 @@ use crate::{
objects::person::{DbUser, PersonAcceptedActivities}, objects::person::{DbUser, PersonAcceptedActivities},
}; };
use activitypub_federation::{ use activitypub_federation::{
actix_web::inbox::receive_activity, actix_web::{inbox::receive_activity, signing_actor},
config::{Data, FederationConfig, FederationMiddleware}, config::{Data, FederationConfig, FederationMiddleware},
fetch::webfinger::{build_webfinger_response, extract_webfinger_name}, fetch::webfinger::{build_webfinger_response, extract_webfinger_name},
protocol::context::WithContext, protocol::context::WithContext,
traits::Object, traits::{Actor, Object},
FEDERATION_CONTENT_TYPE, FEDERATION_CONTENT_TYPE,
}; };
use actix_web::{web, web::Bytes, App, HttpRequest, HttpResponse, HttpServer}; use actix_web::{web, web::Bytes, App, HttpRequest, HttpResponse, HttpServer};
@ -23,6 +23,7 @@ pub fn listen(config: &FederationConfig<DatabaseHandle>) -> Result<(), Error> {
let server = HttpServer::new(move || { let server = HttpServer::new(move || {
App::new() App::new()
.wrap(FederationMiddleware::new(config.clone())) .wrap(FederationMiddleware::new(config.clone()))
.route("/", web::get().to(http_get_system_user))
.route("/{user}", web::get().to(http_get_user)) .route("/{user}", web::get().to(http_get_user))
.route("/{user}/inbox", web::post().to(http_post_user_inbox)) .route("/{user}/inbox", web::post().to(http_post_user_inbox))
.route("/.well-known/webfinger", web::get().to(webfinger)) .route("/.well-known/webfinger", web::get().to(webfinger))
@ -33,11 +34,28 @@ pub fn listen(config: &FederationConfig<DatabaseHandle>) -> Result<(), Error> {
Ok(()) Ok(())
} }
/// Handles requests to fetch system user json over HTTP
pub async fn http_get_system_user(data: Data<DatabaseHandle>) -> Result<HttpResponse, Error> {
let json_user = data.system_user.clone().into_json(&data).await?;
Ok(HttpResponse::Ok()
.content_type(FEDERATION_CONTENT_TYPE)
.json(WithContext::new_default(json_user)))
}
/// Handles requests to fetch user json over HTTP /// Handles requests to fetch user json over HTTP
pub async fn http_get_user( pub async fn http_get_user(
request: HttpRequest,
user_name: web::Path<String>, user_name: web::Path<String>,
data: Data<DatabaseHandle>, data: Data<DatabaseHandle>,
) -> Result<HttpResponse, Error> { ) -> Result<HttpResponse, Error> {
let signed_by = signing_actor::<DbUser>(&request, None, &data).await?;
// here, checks can be made on the actor or the domain to which
// it belongs, to verify whether it is allowed to access this resource
info!(
"Fetch user request is signed by system account {}",
signed_by.id()
);
let db_user = data.local_user(); let db_user = data.local_user();
if user_name.into_inner() == db_user.name { if user_name.into_inner() == db_user.name {
let json_user = db_user.into_json(&data).await?; let json_user = db_user.into_json(&data).await?;

View file

@ -15,13 +15,18 @@ pub fn new_instance(
hostname: &str, hostname: &str,
name: String, name: String,
) -> Result<FederationConfig<DatabaseHandle>, Error> { ) -> Result<FederationConfig<DatabaseHandle>, Error> {
let mut system_user = DbUser::new(hostname, "system".into())?;
system_user.ap_id = Url::parse(&format!("http://{}/", hostname))?.into();
let local_user = DbUser::new(hostname, name)?; let local_user = DbUser::new(hostname, name)?;
let database = Arc::new(Database { let database = Arc::new(Database {
system_user: system_user.clone(),
users: Mutex::new(vec![local_user]), users: Mutex::new(vec![local_user]),
posts: Mutex::new(vec![]), posts: Mutex::new(vec![]),
}); });
let config = FederationConfig::builder() let config = FederationConfig::builder()
.domain(hostname) .domain(hostname)
.signed_fetch_actor(&system_user)
.app_data(database) .app_data(database)
.debug(true) .debug(true)
.build()?; .build()?;
@ -32,6 +37,7 @@ pub type DatabaseHandle = Arc<Database>;
/// Our "database" which contains all known posts and users (local and federated) /// Our "database" which contains all known posts and users (local and federated)
pub struct Database { pub struct Database {
pub system_user: DbUser,
pub users: Mutex<Vec<DbUser>>, pub users: Mutex<Vec<DbUser>>,
pub posts: Mutex<Vec<DbPost>>, pub posts: Mutex<Vec<DbPost>>,
} }

View file

@ -4,7 +4,7 @@ use crate::{
config::Data, config::Data,
error::Error, error::Error,
fetch::object_id::ObjectId, fetch::object_id::ObjectId,
http_signatures::{verify_inbox_hash, verify_signature}, http_signatures::{verify_body_hash, verify_signature},
traits::{ActivityHandler, Actor, Object}, traits::{ActivityHandler, Actor, Object},
}; };
use actix_web::{web::Bytes, HttpRequest, HttpResponse}; use actix_web::{web::Bytes, HttpRequest, HttpResponse};
@ -30,7 +30,7 @@ where
<ActorT as Object>::Error: From<Error> + From<anyhow::Error>, <ActorT as Object>::Error: From<Error> + From<anyhow::Error>,
Datatype: Clone, Datatype: Clone,
{ {
verify_inbox_hash(request.headers().get("Digest"), &body)?; verify_body_hash(request.headers().get("Digest"), &body)?;
let activity: Activity = serde_json::from_slice(&body)?; let activity: Activity = serde_json::from_slice(&body)?;
data.config.verify_url_and_domain(&activity).await?; data.config.verify_url_and_domain(&activity).await?;

View file

@ -3,3 +3,29 @@
pub mod inbox; pub mod inbox;
#[doc(hidden)] #[doc(hidden)]
pub mod middleware; pub mod middleware;
use crate::{
config::Data,
error::Error,
http_signatures::{self, verify_body_hash},
traits::{Actor, Object},
};
use actix_web::{web::Bytes, HttpRequest};
use serde::Deserialize;
/// Checks whether the request is signed by an actor of type A, and returns
/// the actor in question if a valid signature is found.
pub async fn signing_actor<A>(
request: &HttpRequest,
body: Option<Bytes>,
data: &Data<<A as Object>::DataType>,
) -> Result<A, <A as Object>::Error>
where
A: Object + Actor,
<A as Object>::Error: From<Error> + From<anyhow::Error>,
for<'de2> <A as Object>::Kind: Deserialize<'de2>,
{
verify_body_hash(request.headers().get("Digest"), &body.unwrap_or_default())?;
http_signatures::signing_actor(request.headers(), request.method(), request.uri(), data).await
}

View file

@ -6,7 +6,7 @@ use crate::{
config::Data, config::Data,
error::Error, error::Error,
fetch::object_id::ObjectId, fetch::object_id::ObjectId,
http_signatures::{verify_inbox_hash, verify_signature}, http_signatures::{verify_body_hash, verify_signature},
traits::{ActivityHandler, Actor, Object}, traits::{ActivityHandler, Actor, Object},
}; };
use axum::{ use axum::{
@ -36,7 +36,7 @@ where
<ActorT as Object>::Error: From<Error> + From<anyhow::Error>, <ActorT as Object>::Error: From<Error> + From<anyhow::Error>,
Datatype: Clone, Datatype: Clone,
{ {
verify_inbox_hash(activity_data.headers.get("Digest"), &activity_data.body)?; verify_body_hash(activity_data.headers.get("Digest"), &activity_data.body)?;
let activity: Activity = serde_json::from_slice(&activity_data.body)?; let activity: Activity = serde_json::from_slice(&activity_data.body)?;
data.config.verify_url_and_domain(&activity).await?; data.config.verify_url_and_domain(&activity).await?;

View file

@ -18,7 +18,7 @@ use crate::{
activity_queue::create_activity_queue, activity_queue::create_activity_queue,
error::Error, error::Error,
protocol::verification::verify_domains_match, protocol::verification::verify_domains_match,
traits::ActivityHandler, traits::{ActivityHandler, Actor},
}; };
use async_trait::async_trait; use async_trait::async_trait;
use background_jobs::Manager; use background_jobs::Manager;
@ -75,6 +75,11 @@ pub struct FederationConfig<T: Clone> {
/// <https://git.pleroma.social/pleroma/pleroma/-/issues/2939> /// <https://git.pleroma.social/pleroma/pleroma/-/issues/2939>
#[builder(default = "false")] #[builder(default = "false")]
pub(crate) http_signature_compat: bool, pub(crate) http_signature_compat: bool,
/// Actor Id and private key to use to sign all federated fetch requests.
/// This can be used to implement secure mode federation.
/// <https://docs.joinmastodon.org/spec/activitypub/#secure-mode>
#[builder(default = "None", setter(custom))]
pub(crate) signed_fetch_actor: Option<Arc<(Url, String)>>,
/// Queue for sending outgoing activities. Only optional to make builder work, its always /// Queue for sending outgoing activities. Only optional to make builder work, its always
/// present once constructed. /// present once constructed.
#[builder(setter(skip))] #[builder(setter(skip))]
@ -170,6 +175,15 @@ impl<T: Clone> FederationConfig<T> {
} }
impl<T: Clone> FederationConfigBuilder<T> { impl<T: Clone> FederationConfigBuilder<T> {
/// Sets an actor to use to sign all federated fetch requests
pub fn signed_fetch_actor<A: Actor>(&mut self, actor: &A) -> &mut Self {
let private_key_pem = actor
.private_key_pem()
.expect("actor does not have a private key to sign with");
self.signed_fetch_actor = Some(Some(Arc::new((actor.id(), private_key_pem))));
self
}
/// Constructs a new config instance with the values supplied to builder. /// Constructs a new config instance with the values supplied to builder.
/// ///
/// Values which are not explicitly specified use the defaults. Also initializes the /// Values which are not explicitly specified use the defaults. Also initializes the

View file

@ -2,7 +2,13 @@
//! //!
#![doc = include_str!("../../docs/07_fetching_data.md")] #![doc = include_str!("../../docs/07_fetching_data.md")]
use crate::{config::Data, error::Error, reqwest_shim::ResponseExt, FEDERATION_CONTENT_TYPE}; use crate::{
config::Data,
error::Error,
http_signatures::sign_request,
reqwest_shim::ResponseExt,
FEDERATION_CONTENT_TYPE,
};
use http::StatusCode; use http::StatusCode;
use serde::de::DeserializeOwned; use serde::de::DeserializeOwned;
use std::sync::atomic::Ordering; use std::sync::atomic::Ordering;
@ -41,14 +47,25 @@ pub async fn fetch_object_http<T: Clone, Kind: DeserializeOwned>(
return Err(Error::RequestLimit); return Err(Error::RequestLimit);
} }
let res = config let req = config
.client .client
.get(url.as_str()) .get(url.as_str())
.header("Accept", FEDERATION_CONTENT_TYPE) .header("Accept", FEDERATION_CONTENT_TYPE)
.timeout(config.request_timeout) .timeout(config.request_timeout);
.send()
.await let res = if let Some((actor_id, private_key_pem)) = config.signed_fetch_actor.as_deref() {
.map_err(Error::other)?; let req = sign_request(
req,
actor_id.clone(),
String::new(),
private_key_pem.clone(),
data.config.http_signature_compat,
)
.await?;
config.client.execute(req).await.map_err(Error::other)?
} else {
req.send().await.map_err(Error::other)?
};
if res.status() == StatusCode::GONE { if res.status() == StatusCode::GONE {
return Err(Error::ObjectDeleted); return Err(Error::ObjectDeleted);

View file

@ -6,8 +6,11 @@
//! [receive_activity (axum)](crate::axum::inbox::receive_activity). //! [receive_activity (axum)](crate::axum::inbox::receive_activity).
use crate::{ use crate::{
config::Data,
error::{Error, Error::ActivitySignatureInvalid}, error::{Error, Error::ActivitySignatureInvalid},
fetch::object_id::ObjectId,
protocol::public_key::main_key_id, protocol::public_key::main_key_id,
traits::{Actor, Object},
}; };
use base64::{engine::general_purpose::STANDARD as Base64, Engine}; use base64::{engine::general_purpose::STANDARD as Base64, Engine};
use http::{header::HeaderName, uri::PathAndQuery, HeaderValue, Method, Uri}; use http::{header::HeaderName, uri::PathAndQuery, HeaderValue, Method, Uri};
@ -21,6 +24,7 @@ use openssl::{
}; };
use reqwest::Request; use reqwest::Request;
use reqwest_middleware::RequestBuilder; use reqwest_middleware::RequestBuilder;
use serde::Deserialize;
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use std::{collections::BTreeMap, fmt::Debug, io::ErrorKind}; use std::{collections::BTreeMap, fmt::Debug, io::ErrorKind};
use tracing::debug; use tracing::debug;
@ -91,7 +95,11 @@ pub(crate) async fn sign_request(
static CONFIG2: Lazy<http_signature_normalization::Config> = static CONFIG2: Lazy<http_signature_normalization::Config> =
Lazy::new(http_signature_normalization::Config::new); Lazy::new(http_signature_normalization::Config::new);
/// Verifies the HTTP signature on an incoming inbox request. /// Verifies the HTTP signature on an incoming federation request
/// for a given actor's public key.
///
/// Internally, this just converts the headers to a BTreeMap and passes to
/// `verify_signature_inner` for actual signature verification.
pub(crate) fn verify_signature<'a, H>( pub(crate) fn verify_signature<'a, H>(
headers: H, headers: H,
method: &Method, method: &Method,
@ -107,6 +115,60 @@ where
header_map.insert(name.to_string(), value.to_string()); header_map.insert(name.to_string(), value.to_string());
} }
} }
verify_signature_inner(header_map, method, uri, public_key)
}
/// Checks whether the given federation request has a valid signature,
/// from any actor of type A, and returns that actor if a valid signature is found.
/// This function will return an `Err` variant when no signature is found
/// or if the signature could not be verified.
pub(crate) async fn signing_actor<'a, A, H>(
headers: H,
method: &Method,
uri: &Uri,
data: &Data<<A as Object>::DataType>,
) -> Result<A, <A as Object>::Error>
where
A: Object + Actor,
<A as Object>::Error: From<Error> + From<anyhow::Error>,
for<'de2> <A as Object>::Kind: Deserialize<'de2>,
H: IntoIterator<Item = (&'a HeaderName, &'a HeaderValue)>,
{
let mut header_map = BTreeMap::<String, String>::new();
for (name, value) in headers {
if let Ok(value) = value.to_str() {
header_map.insert(name.to_string(), value.to_string());
}
}
let signature = header_map
.get("signature")
.ok_or(Error::ActivitySignatureInvalid)?;
let actor_id_re = regex::Regex::new("keyId=\"([^\"]+)#([^\"]+)\"").expect("regex error");
let actor_id = match actor_id_re.captures(signature) {
None => return Err(Error::ActivitySignatureInvalid.into()),
Some(caps) => caps.get(1).expect("regex error").as_str(),
};
let actor_url = Url::parse(actor_id).map_err(|_| Error::ActivitySignatureInvalid)?;
let actor_id: ObjectId<A> = actor_url.into();
let actor = actor_id.dereference(data).await?;
let public_key = actor.public_key_pem();
verify_signature_inner(header_map, method, uri, public_key)?;
Ok(actor)
}
/// Verifies that the signature present in the request is valid for
/// the specified actor's public key.
fn verify_signature_inner(
header_map: BTreeMap<String, String>,
method: &Method,
uri: &Uri,
public_key: &str,
) -> Result<(), Error> {
let path_and_query = uri.path_and_query().map(PathAndQuery::as_str).unwrap_or(""); let path_and_query = uri.path_and_query().map(PathAndQuery::as_str).unwrap_or("");
let verified = CONFIG2 let verified = CONFIG2
@ -166,7 +228,7 @@ impl DigestPart {
} }
/// Verify body of an inbox request against the hash provided in `Digest` header. /// Verify body of an inbox request against the hash provided in `Digest` header.
pub(crate) fn verify_inbox_hash( pub(crate) fn verify_body_hash(
digest_header: Option<&HeaderValue>, digest_header: Option<&HeaderValue>,
body: &[u8], body: &[u8],
) -> Result<(), Error> { ) -> Result<(), Error> {
@ -266,21 +328,21 @@ pub mod test {
} }
#[test] #[test]
fn test_verify_inbox_hash_valid() { fn test_verify_body_hash_valid() {
let digest_header = let digest_header =
HeaderValue::from_static("SHA-256=lzFT+G7C2hdI5j8M+FuJg1tC+O6AGMVJhooTCKGfbKM="); HeaderValue::from_static("SHA-256=lzFT+G7C2hdI5j8M+FuJg1tC+O6AGMVJhooTCKGfbKM=");
let body = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."; let body = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.";
let valid = verify_inbox_hash(Some(&digest_header), body.as_bytes()); let valid = verify_body_hash(Some(&digest_header), body.as_bytes());
println!("{:?}", &valid); println!("{:?}", &valid);
assert!(valid.is_ok()); assert!(valid.is_ok());
} }
#[test] #[test]
fn test_verify_inbox_hash_not_valid() { fn test_verify_body_hash_not_valid() {
let digest_header = let digest_header =
HeaderValue::from_static("SHA-256=Z9h7DJfYWjffXw2XftmWCnpEaK/yqOHKvzCIzIaqgbU="); HeaderValue::from_static("SHA-256=Z9h7DJfYWjffXw2XftmWCnpEaK/yqOHKvzCIzIaqgbU=");
let body = "lorem ipsum"; let body = "lorem ipsum";
let invalid = verify_inbox_hash(Some(&digest_header), body.as_bytes()); let invalid = verify_body_hash(Some(&digest_header), body.as_bytes());
assert_eq!(invalid, Err(Error::ActivityBodyDigestInvalid)); assert_eq!(invalid, Err(Error::ActivityBodyDigestInvalid));
} }