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:
parent
19baec2138
commit
7b0b830597
8 changed files with 162 additions and 19 deletions
|
@ -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<DatabaseHandle>) -> 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<DatabaseHandle>) -> Result<(), Error> {
|
|||
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
|
||||
pub async fn http_get_user(
|
||||
request: HttpRequest,
|
||||
user_name: web::Path<String>,
|
||||
data: Data<DatabaseHandle>,
|
||||
) -> 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();
|
||||
if user_name.into_inner() == db_user.name {
|
||||
let json_user = db_user.into_json(&data).await?;
|
||||
|
|
|
@ -15,13 +15,18 @@ pub fn new_instance(
|
|||
hostname: &str,
|
||||
name: String,
|
||||
) -> 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 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<Database>;
|
|||
|
||||
/// Our "database" which contains all known posts and users (local and federated)
|
||||
pub struct Database {
|
||||
pub system_user: DbUser,
|
||||
pub users: Mutex<Vec<DbUser>>,
|
||||
pub posts: Mutex<Vec<DbPost>>,
|
||||
}
|
||||
|
|
|
@ -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
|
|||
<ActorT as Object>::Error: From<Error> + From<anyhow::Error>,
|
||||
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?;
|
||||
|
|
|
@ -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<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
|
||||
}
|
||||
|
|
|
@ -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
|
|||
<ActorT as Object>::Error: From<Error> + From<anyhow::Error>,
|
||||
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?;
|
||||
|
|
|
@ -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<T: Clone> {
|
|||
/// <https://git.pleroma.social/pleroma/pleroma/-/issues/2939>
|
||||
#[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.
|
||||
/// <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
|
||||
/// present once constructed.
|
||||
#[builder(setter(skip))]
|
||||
|
@ -170,6 +175,15 @@ impl<T: Clone> FederationConfig<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.
|
||||
///
|
||||
/// Values which are not explicitly specified use the defaults. Also initializes the
|
||||
|
|
|
@ -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<T: Clone, Kind: DeserializeOwned>(
|
|||
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);
|
||||
|
|
|
@ -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<http_signature_normalization::Config> =
|
||||
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<<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 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));
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in a new issue