diff --git a/plume-common/src/activity_pub/mod.rs b/plume-common/src/activity_pub/mod.rs index fbaf3c1a..5949c867 100644 --- a/plume-common/src/activity_pub/mod.rs +++ b/plume-common/src/activity_pub/mod.rs @@ -2,8 +2,7 @@ use activitypub::{Activity, Actor, Object, Link}; use array_tool::vec::Uniq; use reqwest::Client; use rocket::{ - Outcome, - http::Status, + Outcome, http::Status, response::{Response, Responder}, request::{FromRequest, Request} }; @@ -104,11 +103,12 @@ pub fn broadcast(send for inbox in boxes { // TODO: run it in Sidekiq or something like that + let mut headers = request::headers(); + headers.set(request::Digest::digest(signed.to_string())); let res = Client::new() .post(&inbox[..]) - .headers(request::headers()) - .header(request::signature(sender, request::headers())) - .header(request::digest(signed.to_string())) + .headers(headers.clone()) + .header(request::signature(sender, headers)) .body(signed.to_string()) .send(); match res { diff --git a/plume-common/src/activity_pub/request.rs b/plume-common/src/activity_pub/request.rs index 79514824..1fc049c9 100644 --- a/plume-common/src/activity_pub/request.rs +++ b/plume-common/src/activity_pub/request.rs @@ -4,6 +4,7 @@ use reqwest::{ mime::Mime, header::{Accept, Date, Headers, UserAgent, qitem} }; +use std::ops::Deref; use std::time::SystemTime; use activity_pub::ap_accept_header; @@ -19,6 +20,48 @@ header! { (Digest, "Digest") => [String] } +impl Digest { + pub fn digest(body: String) -> Self { + let mut hasher = Hasher::new(MessageDigest::sha256()).unwrap(); + hasher.update(&body.into_bytes()[..]).unwrap(); + let res = base64::encode(&hasher.finish().unwrap()); + Digest(format!("SHA-256={}", res)) + } + + pub fn verify(&self, body: String) -> bool { + if self.algorithm()=="SHA-256" { + let mut hasher = Hasher::new(MessageDigest::sha256()).unwrap(); + hasher.update(&body.into_bytes()).unwrap(); + self.value().deref()==hasher.finish().unwrap().deref() + } else { + false //algorithm not supported + } + } + + pub fn algorithm(&self) -> &str { + let pos = self.0.find('=').unwrap(); + &self.0[..pos] + } + + pub fn value(&self) -> Vec { + let pos = self.0.find('=').unwrap()+1; + base64::decode(&self.0[pos..]).unwrap() + } + + pub fn from_header(dig: &str) -> Result { + if let Some(pos) = dig.find('=') { + let pos = pos+1; + if let Ok(_) = base64::decode(&dig[pos..]) { + Ok(Digest(dig.to_owned())) + } else { + Err(()) + } + } else { + Err(()) + } + } +} + pub fn headers() -> Headers { let mut headers = Headers::new(); headers.set(UserAgent::new(USER_AGENT)); @@ -41,10 +84,3 @@ pub fn signature(signer: &S, headers: Headers) -> Signature { signature = sign )) } - -pub fn digest(body: String) -> Digest { - let mut hasher = Hasher::new(MessageDigest::sha256()).unwrap(); - hasher.update(&body.into_bytes()[..]).unwrap(); - let res = base64::encode(&hasher.finish().unwrap()); - Digest(format!("SHA-256={}", res)) -} diff --git a/plume-common/src/activity_pub/sign.rs b/plume-common/src/activity_pub/sign.rs index eb82a326..427f50f2 100644 --- a/plume-common/src/activity_pub/sign.rs +++ b/plume-common/src/activity_pub/sign.rs @@ -6,6 +6,8 @@ use openssl::{ rsa::Rsa, sha::sha256 }; +use super::request; +use rocket::http::HeaderMap; use serde_json; /// Returns (public key, private key) @@ -20,10 +22,13 @@ pub trait Signer { /// Sign some data with the signer keypair fn sign(&self, to_sign: String) -> Vec; + /// Verify if the signature is valid + fn verify(&self, data: String, signature: Vec) -> bool; } pub trait Signable { fn sign(&mut self, creator: &T) -> &mut Self where T: Signer; + fn verify(self, creator: &T) -> bool where T: Signer; fn hash(data: String) -> String { let bytes = data.into_bytes(); @@ -53,4 +58,86 @@ impl Signable for serde_json::Value { self["signature"] = options; self } + + fn verify(mut self, creator: &T) -> bool { + let signature_obj = if let Some(sig) = self.as_object_mut().and_then(|o| o.remove("signature")) { + sig + } else { + //signature not present + return false + }; + let signature = if let Ok(sig) = base64::decode(&signature_obj["signatureValue"].as_str().unwrap_or("")) { + sig + } else { + return false + }; + let creation_date = &signature_obj["created"]; + let options_hash = Self::hash(json!({ + "@context": "https://w3id.org/identity/v1", + "created": creation_date + }).to_string()); + let document_hash = Self::hash(self.to_string()); + let to_be_signed = options_hash + &document_hash; + creator.verify(to_be_signed, signature) + } +} + +#[derive(Debug,Copy,Clone,PartialEq)] +pub enum SignatureValidity { + Invalid, + ValidNoDigest, + Valid, + Absent, +} + +impl SignatureValidity { + pub fn is_secure(&self) -> bool { + self==&SignatureValidity::Valid + } +} + +pub fn verify_http_headers(sender: &S, all_headers: HeaderMap, data: String) -> SignatureValidity{ + let sig_header = all_headers.get_one("Signature"); + if sig_header.is_none() { + return SignatureValidity::Absent + } + let sig_header = sig_header.unwrap(); + + let mut _key_id = None; + let mut _algorithm = None; + let mut headers = None; + let mut signature = None; + for part in sig_header.split(',') { + match part { + part if part.starts_with("keyId=") => _key_id = Some(&part[7..part.len()-1]), + part if part.starts_with("algorithm=") => _algorithm = Some(&part[11..part.len()-1]), + part if part.starts_with("headers=") => headers = Some(&part[9..part.len()-1]), + part if part.starts_with("signature=") => signature = Some(&part[11..part.len()-1]), + _ => {}, + } + } + + if signature.is_none() || headers.is_none() {//missing part of the header + return SignatureValidity::Invalid + } + let headers = headers.unwrap().split_whitespace().collect::>(); + let signature = signature.unwrap(); + let h = headers.iter() + .map(|header| (header,all_headers.get_one(header))) + .map(|(header, value)| format!("{}: {}", header.to_lowercase(), value.unwrap_or(""))) + .collect::>().join("\n"); + + if !sender.verify(h, base64::decode(signature).unwrap_or(Vec::new())) { + return SignatureValidity::Invalid + } + if !headers.contains(&"digest") {// signature is valid, but body content is not verified + return SignatureValidity::ValidNoDigest + } + let digest = all_headers.get_one("digest").unwrap_or(""); + let digest = request::Digest::from_header(digest); + if !digest.map(|d| d.verify(data)).unwrap_or(false) {// signature was valid, but body content does not match its digest + SignatureValidity::Invalid + } else { + SignatureValidity::Valid// all check passed + } } diff --git a/plume-models/src/blogs.rs b/plume-models/src/blogs.rs index 741cc7ef..efcad5cc 100644 --- a/plume-models/src/blogs.rs +++ b/plume-models/src/blogs.rs @@ -12,7 +12,7 @@ use openssl::{ hash::MessageDigest, pkey::{PKey, Private}, rsa::Rsa, - sign::Signer + sign::{Signer,Verifier} }; use webfinger::*; @@ -309,6 +309,13 @@ impl sign::Signer for Blog { signer.update(to_sign.as_bytes()).unwrap(); signer.sign_to_vec().unwrap() } + + fn verify(&self, data: String, signature: Vec) -> bool { + let key = PKey::from_rsa(Rsa::public_key_from_pem(self.public_key.as_ref()).unwrap()).unwrap(); + let mut verifier = Verifier::new(MessageDigest::sha256(), &key).unwrap(); + verifier.update(data.as_bytes()).unwrap(); + verifier.verify(&signature).unwrap() + } } impl NewBlog { diff --git a/plume-models/src/headers.rs b/plume-models/src/headers.rs new file mode 100644 index 00000000..57030deb --- /dev/null +++ b/plume-models/src/headers.rs @@ -0,0 +1,27 @@ +use rocket::request::{self, FromRequest, Request}; +use rocket::{http::{Header, HeaderMap}, Outcome}; + + +pub struct Headers<'r>(pub HeaderMap<'r>); + +impl<'a, 'r> FromRequest<'a, 'r> for Headers<'r> { + type Error = (); + + fn from_request(request: &'a Request<'r>) -> request::Outcome { + let mut headers = HeaderMap::new(); + for header in request.headers().clone().into_iter() { + headers.add(header); + } + let ori = request.uri(); + let uri = if let Some(query) = ori.query() { + format!("{}?{}", ori.path(), query) + } else { + ori.path().to_owned() + }; + headers.add(Header::new("(request-target)", + format!("{} {}", + request.method().as_str().to_lowercase(), + uri))); + Outcome::Success(Headers(headers)) + } +} diff --git a/plume-models/src/lib.rs b/plume-models/src/lib.rs index 273674ff..22e3532d 100644 --- a/plume-models/src/lib.rs +++ b/plume-models/src/lib.rs @@ -219,6 +219,7 @@ pub mod blogs; pub mod comments; pub mod db_conn; pub mod follows; +pub mod headers; pub mod instance; pub mod likes; pub mod medias; diff --git a/plume-models/src/users.rs b/plume-models/src/users.rs index 2c388a0c..b64fc9bb 100644 --- a/plume-models/src/users.rs +++ b/plume-models/src/users.rs @@ -624,6 +624,13 @@ impl Signer for User { signer.update(to_sign.as_bytes()).unwrap(); signer.sign_to_vec().unwrap() } + + fn verify(&self, data: String, signature: Vec) -> bool { + let key = PKey::from_rsa(Rsa::public_key_from_pem(self.public_key.as_ref()).unwrap()).unwrap(); + let mut verifier = sign::Verifier::new(MessageDigest::sha256(), &key).unwrap(); + verifier.update(data.as_bytes()).unwrap(); + verifier.verify(&signature).unwrap() + } } impl NewUser { diff --git a/src/routes/instance.rs b/src/routes/instance.rs index 55e90719..35f85895 100644 --- a/src/routes/instance.rs +++ b/src/routes/instance.rs @@ -4,15 +4,17 @@ use rocket_contrib::{Json, Template}; use serde_json; use validator::{Validate}; +use plume_common::activity_pub::sign::{Signable, + verify_http_headers}; use plume_models::{ admin::Admin, comments::Comment, db_conn::DbConn, + headers::Headers, posts::Post, users::User, safe_string::SafeString, instance::* - }; use inbox::Inbox; use routes::Page; @@ -194,12 +196,20 @@ fn ban(_admin: Admin, conn: DbConn, id: i32) -> Redirect { } #[post("/inbox", data = "")] -fn shared_inbox(conn: DbConn, data: String) -> String { +fn shared_inbox(conn: DbConn, data: String, headers: Headers) -> String { let act: serde_json::Value = serde_json::from_str(&data[..]).unwrap(); let activity = act.clone(); let actor_id = activity["actor"].as_str() .unwrap_or_else(|| activity["actor"]["id"].as_str().expect("No actor ID for incoming activity, blocks by panicking")); + + let actor = User::from_url(&conn, actor_id.to_owned()).unwrap(); + if !verify_http_headers(&actor, headers.0.clone(), data).is_secure() && + !act.clone().verify(&actor) { + println!("Rejected invalid activity supposedly from {}, with headers {:?}", actor.username, headers.0); + return "invalid signature".to_owned(); + } + if Instance::is_blocked(&*conn, actor_id.to_string()) { return String::new(); } diff --git a/src/routes/user.rs b/src/routes/user.rs index 42ac6230..90c8986f 100644 --- a/src/routes/user.rs +++ b/src/routes/user.rs @@ -16,13 +16,15 @@ use workerpool::thunk::*; use plume_common::activity_pub::{ ActivityStream, broadcast, Id, IntoId, ApRequest, - inbox::{FromActivity, Notify, Deletable} + inbox::{FromActivity, Notify, Deletable}, + sign::{Signable, verify_http_headers} }; use plume_common::utils; use plume_models::{ blogs::Blog, db_conn::DbConn, follows, + headers::Headers, instance::Instance, posts::Post, reshares::Reshare, @@ -295,13 +297,21 @@ fn outbox(name: String, conn: DbConn) -> ActivityStream { } #[post("/@//inbox", data = "")] -fn inbox(name: String, conn: DbConn, data: String) -> String { +fn inbox(name: String, conn: DbConn, data: String, headers: Headers) -> String { let user = User::find_local(&*conn, name).unwrap(); let act: serde_json::Value = serde_json::from_str(&data[..]).unwrap(); let activity = act.clone(); let actor_id = activity["actor"].as_str() .unwrap_or_else(|| activity["actor"]["id"].as_str().expect("User: No actor ID for incoming activity, blocks by panicking")); + + let actor = User::from_url(&conn, actor_id.to_owned()).unwrap(); + if !verify_http_headers(&actor, headers.0.clone(), data).is_secure() && + !act.clone().verify(&actor) { + println!("Rejected invalid activity supposedly from {}, with headers {:?}", actor.username, headers.0); + return "invalid signature".to_owned(); + } + if Instance::is_blocked(&*conn, actor_id.to_string()) { return String::new(); }