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 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)

View file

@ -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};

View file

@ -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,18 +66,19 @@ where
.get("Signature")
.map(|v| v.to_str().unwrap_or(""))
{
let http_signature = HttpSignature::parse(sig);
let mut http_signature = HttpSignature::parse(sig);
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) {
let user_id = http_signature.key_id.replace("#main-key", "");
match ctx.fetch().user(&user_id).await {
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<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)]
pub struct HttpSignature {
key_id: String,
algorithm: String,
headers: Vec<String>,
signature: String,
pub key_id: String,
pub algorithm: String,
pub headers: Vec<String>,
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<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();
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<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(&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());
}
}

View file

@ -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()),

View file

@ -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<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
}
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<Private>,
key: &str,
domain: &str,
) -> reqwest::Result<Response> {
) -> crate::Result<Response> {
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<String, String> = [
// ("Host".to_string(), host.clone()),
// ("Date".to_string(), date.clone()),
// ].into();
let mut headers = vec!["(request-target)", "host", "date"];
let mut headers_map : BTreeMap<String, String> = [
("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,
);
client
.header("Signature", signature_header)
signer
.build_manually(&method.to_string().to_lowercase(), &path, headers_map)
.sign(key)?;
let res = client
.header("Signature", signer.header())
.send()
.await?
.error_for_status()
.await?;
Ok(res.error_for_status()?)
}
pub async fn user(&self, id: &str) -> crate::Result<model::user::Model> {