feat: improved http signatures code

This commit is contained in:
əlemi 2024-04-13 21:22:19 +02:00
parent f29d3baeb9
commit 027040604c
Signed by: alemi
GPG key ID: A4895B84D311642C
5 changed files with 120 additions and 76 deletions

View file

@ -3,7 +3,7 @@
μpub aims to be a fast, lightweight and secure [ActivityPub](https://www.w3.org/TR/activitypub/) server μ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) all interactions must happen with ActivityPub's client-server methods (basically POST your activities to your outbox)

View file

@ -4,7 +4,7 @@ pub mod outbox;
pub mod following; pub mod following;
use axum::{extract::{Path, State}, http::StatusCode}; use axum::extract::{Path, State};
use sea_orm::{ColumnTrait, Condition, EntityTrait, QueryFilter}; use sea_orm::{ColumnTrait, Condition, EntityTrait, QueryFilter};
use apb::{PublicKeyMut, ActorMut, DocumentMut, DocumentType, ObjectMut, BaseMut, Node}; use apb::{PublicKeyMut, ActorMut, DocumentMut, DocumentType, ObjectMut, BaseMut, Node};

View file

@ -1,3 +1,5 @@
use std::collections::BTreeMap;
use axum::{extract::{FromRef, FromRequestParts}, http::{header, request::Parts}}; use axum::{extract::{FromRef, FromRequestParts}, http::{header, request::Parts}};
use base64::Engine; use base64::Engine;
use openssl::{hash::MessageDigest, pkey::PKey, sign::Verifier}; use openssl::{hash::MessageDigest, pkey::PKey, sign::Verifier};
@ -64,19 +66,20 @@ where
.get("Signature") .get("Signature")
.map(|v| v.to_str().unwrap_or("")) .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", ""); let user_id = http_signature.key_id.replace("#main-key", "");
match ctx.fetch().user(&user_id).await { match ctx.fetch().user(&user_id).await {
Ok(user) => { Ok(user) => match http_signature
let to_sign = http_signature.build_string(parts); .build_from_parts(parts)
// TODO assert payload's digest is equal to signature's .verify(&user.public_key)
match verify_control_text(&to_sign, &user.public_key, &http_signature.signature) { {
Ok(true) => identity = Identity::Remote(Context::server(&user_id)), Ok(true) => identity = Identity::Remote(Context::server(&user_id)),
Ok(false) => tracing::warn!("invalid signature"), Ok(false) => tracing::warn!("invalid signature"),
Err(e) => tracing::error!("error verifying signature: {e}"), Err(e) => tracing::error!("error verifying signature: {e}"),
} },
},
Err(e) => tracing::warn!("could not fetch user (won't verify): {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<bool> {
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)] #[derive(Debug, Clone, Default)]
pub struct HttpSignature { pub struct HttpSignature {
key_id: String, pub key_id: String,
algorithm: String, pub algorithm: String,
headers: Vec<String>, pub headers: Vec<String>,
signature: String, pub signature: String,
pub control: String,
} }
impl HttpSignature { 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 { pub fn parse(header: &str) -> Self {
let mut sig = HttpSignature::default(); let mut sig = HttpSignature::default();
header.split(',') header.split(',')
@ -128,7 +123,24 @@ impl HttpSignature {
sig 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<String, String>) -> &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(); let mut out = Vec::new();
for header in self.headers.iter() { for header in self.headers.iter() {
match header.as_str() { match header.as_str() {
@ -146,19 +158,53 @@ impl HttpSignature {
)), )),
} }
} }
out.join("\n") self.control = out.join("\n");
self
} }
pub fn digest(&self) -> MessageDigest { pub fn verify(&self, key: &str) -> crate::Result<bool> {
match self.algorithm.as_str() { let pubkey = PKey::public_key_from_pem(key.as_bytes())?;
"rsa-sha512" => MessageDigest::sha512(), let mut verifier = Verifier::new(MessageDigest::sha256(), &pubkey)?;
"rsa-sha384" => MessageDigest::sha384(), let signature = base64::prelude::BASE64_STANDARD.decode(&self.signature)?;
"rsa-sha256" => MessageDigest::sha256(), Ok(verifier.verify_oneshot(&signature, self.control.as_bytes())?)
"rsa-sha1" => MessageDigest::sha1(), }
_ => {
tracing::error!("unknown digest algorithm, trying with rsa-sha256"); pub fn sign(&mut self, key: &str) -> crate::Result<&str> {
MessageDigest::sha256() 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());
} }
} }

View file

@ -1,4 +1,3 @@
use openssl::pkey::PKey;
use reqwest::Method; use reqwest::Method;
use sea_orm::{ColumnTrait, Condition, DatabaseConnection, EntityTrait, Order, QueryFilter, QueryOrder}; use sea_orm::{ColumnTrait, Condition, DatabaseConnection, EntityTrait, Order, QueryFilter, QueryOrder};
use tokio::{sync::broadcast, task::JoinHandle}; use tokio::{sync::broadcast, task::JoinHandle};
@ -92,12 +91,6 @@ async fn worker(db: DatabaseConnection, domain: String, poll_interval: u64, mut
continue; 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( if let Err(e) = Fetcher::request(
Method::POST, &delivery.target, Method::POST, &delivery.target,
Some(&serde_json::to_string(&payload).unwrap()), Some(&serde_json::to_string(&payload).unwrap()),

View file

@ -1,22 +1,23 @@
use std::collections::BTreeMap;
use base64::Engine; use base64::Engine;
use openssl::{hash::MessageDigest, pkey::{PKey, Private}, sign::Signer};
use reqwest::{header::{ACCEPT, CONTENT_TYPE, USER_AGENT}, Method, Response}; use reqwest::{header::{ACCEPT, CONTENT_TYPE, USER_AGENT}, Method, Response};
use sea_orm::{DatabaseConnection, EntityTrait, IntoActiveModel}; use sea_orm::{DatabaseConnection, EntityTrait, IntoActiveModel};
use crate::{model, VERSION}; use crate::{model, VERSION};
use super::Context; use super::{auth::HttpSignature, Context};
pub struct Fetcher { pub struct Fetcher {
db: DatabaseConnection, db: DatabaseConnection,
key: PKey<Private>, // 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 domain: String, // TODO merge directly with Context so we don't need to copy this
} }
impl Fetcher { impl Fetcher {
pub fn new(db: DatabaseConnection, domain: String, key: String) -> Self { 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( pub async fn request(
@ -24,16 +25,18 @@ impl Fetcher {
url: &str, url: &str,
payload: Option<&str>, payload: Option<&str>,
from: &str, from: &str,
key: &PKey<Private>, key: &str,
domain: &str, domain: &str,
) -> reqwest::Result<Response> { ) -> crate::Result<Response> {
let host = Context::server(url); let host = Context::server(url);
let date = chrono::Utc::now().format("%a, %d %b %Y %H:%M:%S GMT").to_string(); // lmao @ "GMT" 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 path = url.replace("https://", "").replace("http://", "").replace(&host, "");
// let mut headers : BTreeMap<String, String> = [
// ("Host".to_string(), host.clone()), let mut headers = vec!["(request-target)", "host", "date"];
// ("Date".to_string(), date.clone()), let mut headers_map : BTreeMap<String, String> = [
// ].into(); ("host".to_string(), host.clone()),
("date".to_string(), date.clone()),
].into();
let mut client = reqwest::Client::new() let mut client = reqwest::Client::new()
.request(method.clone(), url) .request(method.clone(), url)
@ -43,30 +46,32 @@ impl Fetcher {
.header("Host", host.clone()) .header("Host", host.clone())
.header("Date", date.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 { if let Some(payload) = payload {
let digest = format!("sha-256={}", base64::prelude::BASE64_STANDARD.encode(openssl::sha::sha256(payload.as_bytes()))); 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_map.insert("digest".to_string(), digest.clone());
headers_to_inspect = "(request-target) host date digest"; headers.push("digest");
client = client client = client
.header("Digest", digest) .header("Digest", digest)
.body(payload.to_string()); .body(payload.to_string());
} }
let signature_header = { let mut signer = HttpSignature::new(
let mut signer = Signer::new(MessageDigest::sha256(), key).unwrap(); format!("{from}#main-key"), // TODO don't hardcode #main-key
signer.update(to_sign_raw.as_bytes()).unwrap(); "rsa-sha256".to_string(),
let signature = base64::prelude::BASE64_STANDARD.encode(signer.sign_to_vec().unwrap()); &headers,
format!("keyId=\"{from}#main-key\",algorithm=\"rsa-sha256\",headers=\"{headers_to_inspect}\",signature=\"{signature}\"") );
};
signer
.build_manually(&method.to_string().to_lowercase(), &path, headers_map)
.sign(key)?;
client let res = client
.header("Signature", signature_header) .header("Signature", signer.header())
.send() .send()
.await? .await?;
.error_for_status()
Ok(res.error_for_status()?)
} }
pub async fn user(&self, id: &str) -> crate::Result<model::user::Model> { pub async fn user(&self, id: &str) -> crate::Result<model::user::Model> {