From 7b0b8305970dd3d975052071982db74845a76ee3 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Mon, 12 Jun 2023 13:32:54 +0200 Subject: [PATCH] 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 --- examples/local_federation/actix_web/http.rs | 22 +++++- examples/local_federation/instance.rs | 6 ++ src/actix_web/inbox.rs | 4 +- src/actix_web/mod.rs | 26 ++++++++ src/axum/inbox.rs | 4 +- src/config.rs | 16 ++++- src/fetch/mod.rs | 29 ++++++-- src/http_signatures.rs | 74 +++++++++++++++++++-- 8 files changed, 162 insertions(+), 19 deletions(-) diff --git a/examples/local_federation/actix_web/http.rs b/examples/local_federation/actix_web/http.rs index cb9e911..c8a3e04 100644 --- a/examples/local_federation/actix_web/http.rs +++ b/examples/local_federation/actix_web/http.rs @@ -4,11 +4,11 @@ use crate::{ objects::person::{DbUser, PersonAcceptedActivities}, }; use activitypub_federation::{ - actix_web::inbox::receive_activity, + actix_web::{inbox::receive_activity, signing_actor}, config::{Data, FederationConfig, FederationMiddleware}, fetch::webfinger::{build_webfinger_response, extract_webfinger_name}, protocol::context::WithContext, - traits::Object, + traits::{Actor, Object}, FEDERATION_CONTENT_TYPE, }; use actix_web::{web, web::Bytes, App, HttpRequest, HttpResponse, HttpServer}; @@ -23,6 +23,7 @@ pub fn listen(config: &FederationConfig) -> Result<(), Error> { let server = HttpServer::new(move || { App::new() .wrap(FederationMiddleware::new(config.clone())) + .route("/", web::get().to(http_get_system_user)) .route("/{user}", web::get().to(http_get_user)) .route("/{user}/inbox", web::post().to(http_post_user_inbox)) .route("/.well-known/webfinger", web::get().to(webfinger)) @@ -33,11 +34,28 @@ pub fn listen(config: &FederationConfig) -> Result<(), Error> { Ok(()) } +/// Handles requests to fetch system user json over HTTP +pub async fn http_get_system_user(data: Data) -> Result { + 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 pub async fn http_get_user( + request: HttpRequest, user_name: web::Path, data: Data, ) -> Result { + let signed_by = signing_actor::(&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(); if user_name.into_inner() == db_user.name { let json_user = db_user.into_json(&data).await?; diff --git a/examples/local_federation/instance.rs b/examples/local_federation/instance.rs index 1dca4a9..2c64f5f 100644 --- a/examples/local_federation/instance.rs +++ b/examples/local_federation/instance.rs @@ -15,13 +15,18 @@ pub fn new_instance( hostname: &str, name: String, ) -> Result, 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 database = Arc::new(Database { + system_user: system_user.clone(), users: Mutex::new(vec![local_user]), posts: Mutex::new(vec![]), }); let config = FederationConfig::builder() .domain(hostname) + .signed_fetch_actor(&system_user) .app_data(database) .debug(true) .build()?; @@ -32,6 +37,7 @@ pub type DatabaseHandle = Arc; /// Our "database" which contains all known posts and users (local and federated) pub struct Database { + pub system_user: DbUser, pub users: Mutex>, pub posts: Mutex>, } diff --git a/src/actix_web/inbox.rs b/src/actix_web/inbox.rs index 3a38d66..bb21c46 100644 --- a/src/actix_web/inbox.rs +++ b/src/actix_web/inbox.rs @@ -4,7 +4,7 @@ use crate::{ config::Data, error::Error, fetch::object_id::ObjectId, - http_signatures::{verify_inbox_hash, verify_signature}, + http_signatures::{verify_body_hash, verify_signature}, traits::{ActivityHandler, Actor, Object}, }; use actix_web::{web::Bytes, HttpRequest, HttpResponse}; @@ -30,7 +30,7 @@ where ::Error: From + From, 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)?; data.config.verify_url_and_domain(&activity).await?; diff --git a/src/actix_web/mod.rs b/src/actix_web/mod.rs index 810b89f..d7d137a 100644 --- a/src/actix_web/mod.rs +++ b/src/actix_web/mod.rs @@ -3,3 +3,29 @@ pub mod inbox; #[doc(hidden)] 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( + request: &HttpRequest, + body: Option, + data: &Data<::DataType>, +) -> Result::Error> +where + A: Object + Actor, + ::Error: From + From, + for<'de2> ::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 +} diff --git a/src/axum/inbox.rs b/src/axum/inbox.rs index 73266ba..6c20e25 100644 --- a/src/axum/inbox.rs +++ b/src/axum/inbox.rs @@ -6,7 +6,7 @@ use crate::{ config::Data, error::Error, fetch::object_id::ObjectId, - http_signatures::{verify_inbox_hash, verify_signature}, + http_signatures::{verify_body_hash, verify_signature}, traits::{ActivityHandler, Actor, Object}, }; use axum::{ @@ -36,7 +36,7 @@ where ::Error: From + From, 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)?; data.config.verify_url_and_domain(&activity).await?; diff --git a/src/config.rs b/src/config.rs index 45f6f26..0982194 100644 --- a/src/config.rs +++ b/src/config.rs @@ -18,7 +18,7 @@ use crate::{ activity_queue::create_activity_queue, error::Error, protocol::verification::verify_domains_match, - traits::ActivityHandler, + traits::{ActivityHandler, Actor}, }; use async_trait::async_trait; use background_jobs::Manager; @@ -75,6 +75,11 @@ pub struct FederationConfig { /// #[builder(default = "false")] 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. + /// + #[builder(default = "None", setter(custom))] + pub(crate) signed_fetch_actor: Option>, /// Queue for sending outgoing activities. Only optional to make builder work, its always /// present once constructed. #[builder(setter(skip))] @@ -170,6 +175,15 @@ impl FederationConfig { } impl FederationConfigBuilder { + /// Sets an actor to use to sign all federated fetch requests + pub fn signed_fetch_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. /// /// Values which are not explicitly specified use the defaults. Also initializes the diff --git a/src/fetch/mod.rs b/src/fetch/mod.rs index a19827d..769f302 100644 --- a/src/fetch/mod.rs +++ b/src/fetch/mod.rs @@ -2,7 +2,13 @@ //! #![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 serde::de::DeserializeOwned; use std::sync::atomic::Ordering; @@ -41,14 +47,25 @@ pub async fn fetch_object_http( return Err(Error::RequestLimit); } - let res = config + let req = config .client .get(url.as_str()) .header("Accept", FEDERATION_CONTENT_TYPE) - .timeout(config.request_timeout) - .send() - .await - .map_err(Error::other)?; + .timeout(config.request_timeout); + + let res = if let Some((actor_id, private_key_pem)) = config.signed_fetch_actor.as_deref() { + 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 { return Err(Error::ObjectDeleted); diff --git a/src/http_signatures.rs b/src/http_signatures.rs index ab991c9..988533f 100644 --- a/src/http_signatures.rs +++ b/src/http_signatures.rs @@ -6,8 +6,11 @@ //! [receive_activity (axum)](crate::axum::inbox::receive_activity). use crate::{ + config::Data, error::{Error, Error::ActivitySignatureInvalid}, + fetch::object_id::ObjectId, protocol::public_key::main_key_id, + traits::{Actor, Object}, }; use base64::{engine::general_purpose::STANDARD as Base64, Engine}; use http::{header::HeaderName, uri::PathAndQuery, HeaderValue, Method, Uri}; @@ -21,6 +24,7 @@ use openssl::{ }; use reqwest::Request; use reqwest_middleware::RequestBuilder; +use serde::Deserialize; use sha2::{Digest, Sha256}; use std::{collections::BTreeMap, fmt::Debug, io::ErrorKind}; use tracing::debug; @@ -91,7 +95,11 @@ pub(crate) async fn sign_request( static CONFIG2: Lazy = 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>( headers: H, method: &Method, @@ -107,6 +115,60 @@ where 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<::DataType>, +) -> Result::Error> +where + A: Object + Actor, + ::Error: From + From, + for<'de2> ::Kind: Deserialize<'de2>, + H: IntoIterator, +{ + let mut header_map = BTreeMap::::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 = 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, + method: &Method, + uri: &Uri, + public_key: &str, +) -> Result<(), Error> { let path_and_query = uri.path_and_query().map(PathAndQuery::as_str).unwrap_or(""); let verified = CONFIG2 @@ -166,7 +228,7 @@ impl DigestPart { } /// 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>, body: &[u8], ) -> Result<(), Error> { @@ -266,21 +328,21 @@ pub mod test { } #[test] - fn test_verify_inbox_hash_valid() { + fn test_verify_body_hash_valid() { let digest_header = 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 valid = verify_inbox_hash(Some(&digest_header), body.as_bytes()); + let valid = verify_body_hash(Some(&digest_header), body.as_bytes()); println!("{:?}", &valid); assert!(valid.is_ok()); } #[test] - fn test_verify_inbox_hash_not_valid() { + fn test_verify_body_hash_not_valid() { let digest_header = HeaderValue::from_static("SHA-256=Z9h7DJfYWjffXw2XftmWCnpEaK/yqOHKvzCIzIaqgbU="); 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)); }