diff --git a/README.md b/README.md index fa64a089..3dce4bb4 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ μpub aims to be a fast, lightweight and secure [ActivityPub](https://www.w3.org/TR/activitypub/) server -μpub is currently being developed and can do some basic things, like posting notes, follows and likes +μpub is currently being developed and can do most basic things, like posting notes, liking things, following others, deliveries. all interactions must happen with ActivityPub's client-server methods (basically POST your activities to your outbox) diff --git a/src/routes/activitypub/user/mod.rs b/src/routes/activitypub/user/mod.rs index fef2d706..0ec881fe 100644 --- a/src/routes/activitypub/user/mod.rs +++ b/src/routes/activitypub/user/mod.rs @@ -4,7 +4,7 @@ pub mod outbox; pub mod following; -use axum::{extract::{Path, State}, http::StatusCode}; +use axum::extract::{Path, State}; use sea_orm::{ColumnTrait, Condition, EntityTrait, QueryFilter}; use apb::{PublicKeyMut, ActorMut, DocumentMut, DocumentType, ObjectMut, BaseMut, Node}; diff --git a/src/server/auth.rs b/src/server/auth.rs index 8118f08b..d73ebab3 100644 --- a/src/server/auth.rs +++ b/src/server/auth.rs @@ -1,3 +1,5 @@ +use std::collections::BTreeMap; + use axum::{extract::{FromRef, FromRequestParts}, http::{header, request::Parts}}; use base64::Engine; use openssl::{hash::MessageDigest, pkey::PKey, sign::Verifier}; @@ -64,19 +66,20 @@ where .get("Signature") .map(|v| v.to_str().unwrap_or("")) { - let http_signature = HttpSignature::parse(sig); + let mut http_signature = HttpSignature::parse(sig); + // TODO assert payload's digest is equal to signature's let user_id = http_signature.key_id.replace("#main-key", ""); + match ctx.fetch().user(&user_id).await { - Ok(user) => { - let to_sign = http_signature.build_string(parts); - // TODO assert payload's digest is equal to signature's - match verify_control_text(&to_sign, &user.public_key, &http_signature.signature) { + Ok(user) => match http_signature + .build_from_parts(parts) + .verify(&user.public_key) + { Ok(true) => identity = Identity::Remote(Context::server(&user_id)), Ok(false) => tracing::warn!("invalid signature"), Err(e) => tracing::error!("error verifying signature: {e}"), - } - }, + }, Err(e) => tracing::warn!("could not fetch user (won't verify): {e}"), } } @@ -86,33 +89,25 @@ where } - -fn verify_control_text(txt: &str, key: &str, control: &str) -> crate::Result { - let pubkey = PKey::public_key_from_pem(key.as_bytes())?; - let mut verifier = Verifier::new(MessageDigest::sha256(), &pubkey)?; - let signature = base64::prelude::BASE64_STANDARD.decode(control)?; - Ok(verifier.verify_oneshot(&signature, txt.as_bytes())?) -} - - - - - - - - - - - #[derive(Debug, Clone, Default)] pub struct HttpSignature { - key_id: String, - algorithm: String, - headers: Vec, - signature: String, + pub key_id: String, + pub algorithm: String, + pub headers: Vec, + pub signature: String, + pub control: String, } impl HttpSignature { + pub fn new(key_id: String, algorithm: String, headers: &[&str]) -> Self { + HttpSignature { + key_id, algorithm, + headers: headers.iter().map(|x| x.to_string()).collect(), + signature: String::new(), + control: String::new(), + } + } + pub fn parse(header: &str) -> Self { let mut sig = HttpSignature::default(); header.split(',') @@ -128,7 +123,24 @@ impl HttpSignature { sig } - pub fn build_string(&self, parts: &Parts) -> String { + pub fn header(&self) -> String { + format!( + "keyId=\"{}\",algorithm=\"{}\",headers=\"{}\",signature=\"{}\"", + self.key_id, self.algorithm, self.headers.join(" "), self.signature, + ) + } + + pub fn build_manually(&mut self, method: &str, target: &str, mut headers: BTreeMap) -> &mut Self { + let mut out = Vec::new(); + out.push(format!("(request-target): {method} {target}")); + for header in &self.headers { + out.push(format!("{header}: {}", headers.remove(header).unwrap_or_default())); + } + self.control = out.join("\n"); + self + } + + pub fn build_from_parts(&mut self, parts: &Parts) -> &mut Self { let mut out = Vec::new(); for header in self.headers.iter() { match header.as_str() { @@ -146,19 +158,53 @@ impl HttpSignature { )), } } - out.join("\n") + self.control = out.join("\n"); + self } - pub fn digest(&self) -> MessageDigest { - match self.algorithm.as_str() { - "rsa-sha512" => MessageDigest::sha512(), - "rsa-sha384" => MessageDigest::sha384(), - "rsa-sha256" => MessageDigest::sha256(), - "rsa-sha1" => MessageDigest::sha1(), - _ => { - tracing::error!("unknown digest algorithm, trying with rsa-sha256"); - MessageDigest::sha256() - } - } + pub fn verify(&self, key: &str) -> crate::Result { + let pubkey = PKey::public_key_from_pem(key.as_bytes())?; + let mut verifier = Verifier::new(MessageDigest::sha256(), &pubkey)?; + let signature = base64::prelude::BASE64_STANDARD.decode(&self.signature)?; + Ok(verifier.verify_oneshot(&signature, self.control.as_bytes())?) + } + + pub fn sign(&mut self, key: &str) -> crate::Result<&str> { + let privkey = PKey::private_key_from_pem(key.as_bytes())?; + let mut signer = openssl::sign::Signer::new(MessageDigest::sha256(), &privkey)?; + signer.update(self.control.as_bytes())?; + self.signature = base64::prelude::BASE64_STANDARD.encode(signer.sign_to_vec()?); + Ok(&self.signature) + } +} + +#[cfg(test)] +mod test { + #[test] + fn http_signature_signs_and_verifies() { + let key = openssl::rsa::Rsa::generate(2048).unwrap(); + let private_key = std::str::from_utf8(&key.private_key_to_pem().unwrap()).unwrap().to_string(); + let public_key = std::str::from_utf8(&key.public_key_to_pem().unwrap()).unwrap().to_string(); + let mut signer = super::HttpSignature { + key_id: "test".to_string(), + algorithm: "rsa-sha256".to_string(), + headers: vec![ + "(request-target)".to_string(), + "host".to_string(), + "date".to_string(), + ], + signature: String::new(), + control: String::new(), + }; + + signer + .build_manually("get", "/actor/inbox", [("host".into(), "example.net".into()), ("date".into(), "Sat, 13 Apr 2024 13:36:23 GMT".into())].into()) + .sign(&private_key) + .unwrap(); + + let mut verifier = super::HttpSignature::parse(&signer.header()); + verifier.build_manually("get", "/actor/inbox", [("host".into(), "example.net".into()), ("date".into(), "Sat, 13 Apr 2024 13:36:23 GMT".into())].into()); + + assert!(verifier.verify(&public_key).unwrap()); } } diff --git a/src/server/dispatcher.rs b/src/server/dispatcher.rs index e404666e..b922ec00 100644 --- a/src/server/dispatcher.rs +++ b/src/server/dispatcher.rs @@ -1,4 +1,3 @@ -use openssl::pkey::PKey; use reqwest::Method; use sea_orm::{ColumnTrait, Condition, DatabaseConnection, EntityTrait, Order, QueryFilter, QueryOrder}; use tokio::{sync::broadcast, task::JoinHandle}; @@ -92,12 +91,6 @@ async fn worker(db: DatabaseConnection, domain: String, poll_interval: u64, mut continue; }; - let Ok(key) = PKey::private_key_from_pem(key.as_bytes()) - else { - tracing::error!("failed parsing private key for user {}", delivery.actor); - continue; - }; - if let Err(e) = Fetcher::request( Method::POST, &delivery.target, Some(&serde_json::to_string(&payload).unwrap()), diff --git a/src/server/fetcher.rs b/src/server/fetcher.rs index 5daa769c..17a2d0ad 100644 --- a/src/server/fetcher.rs +++ b/src/server/fetcher.rs @@ -1,22 +1,23 @@ +use std::collections::BTreeMap; + use base64::Engine; -use openssl::{hash::MessageDigest, pkey::{PKey, Private}, sign::Signer}; use reqwest::{header::{ACCEPT, CONTENT_TYPE, USER_AGENT}, Method, Response}; use sea_orm::{DatabaseConnection, EntityTrait, IntoActiveModel}; use crate::{model, VERSION}; -use super::Context; +use super::{auth::HttpSignature, Context}; pub struct Fetcher { db: DatabaseConnection, - key: PKey, // TODO store pre-parsed + key: String, // TODO store pre-parsed domain: String, // TODO merge directly with Context so we don't need to copy this } impl Fetcher { pub fn new(db: DatabaseConnection, domain: String, key: String) -> Self { - Fetcher { db, domain, key: PKey::private_key_from_pem(key.as_bytes()).unwrap() } + Fetcher { db, domain, key } } pub async fn request( @@ -24,16 +25,18 @@ impl Fetcher { url: &str, payload: Option<&str>, from: &str, - key: &PKey, + key: &str, domain: &str, - ) -> reqwest::Result { + ) -> crate::Result { let host = Context::server(url); let date = chrono::Utc::now().format("%a, %d %b %Y %H:%M:%S GMT").to_string(); // lmao @ "GMT" let path = url.replace("https://", "").replace("http://", "").replace(&host, ""); - // let mut headers : BTreeMap = [ - // ("Host".to_string(), host.clone()), - // ("Date".to_string(), date.clone()), - // ].into(); + + let mut headers = vec!["(request-target)", "host", "date"]; + let mut headers_map : BTreeMap = [ + ("host".to_string(), host.clone()), + ("date".to_string(), date.clone()), + ].into(); let mut client = reqwest::Client::new() .request(method.clone(), url) @@ -43,30 +46,32 @@ impl Fetcher { .header("Host", host.clone()) .header("Date", date.clone()); - let mut to_sign_raw = format!("(request-target): {} {path}\nhost: {host}\ndate: {date}", method.to_string().to_lowercase()); - let mut headers_to_inspect = "(request-target) host date"; if let Some(payload) = payload { let digest = format!("sha-256={}", base64::prelude::BASE64_STANDARD.encode(openssl::sha::sha256(payload.as_bytes()))); - to_sign_raw = format!("(request-target): {} {path}\nhost: {host}\ndate: {date}\ndigest: {digest}", method.to_string().to_lowercase()); - headers_to_inspect = "(request-target) host date digest"; + headers_map.insert("digest".to_string(), digest.clone()); + headers.push("digest"); client = client .header("Digest", digest) .body(payload.to_string()); } - let signature_header = { - let mut signer = Signer::new(MessageDigest::sha256(), key).unwrap(); - signer.update(to_sign_raw.as_bytes()).unwrap(); - let signature = base64::prelude::BASE64_STANDARD.encode(signer.sign_to_vec().unwrap()); - format!("keyId=\"{from}#main-key\",algorithm=\"rsa-sha256\",headers=\"{headers_to_inspect}\",signature=\"{signature}\"") - }; + let mut signer = HttpSignature::new( + format!("{from}#main-key"), // TODO don't hardcode #main-key + "rsa-sha256".to_string(), + &headers, + ); + + signer + .build_manually(&method.to_string().to_lowercase(), &path, headers_map) + .sign(key)?; - client - .header("Signature", signature_header) - .send() - .await? - .error_for_status() + let res = client + .header("Signature", signer.header()) + .send() + .await?; + + Ok(res.error_for_status()?) } pub async fn user(&self, id: &str) -> crate::Result {