forked from alemi/upub
feat: improved http signatures code
This commit is contained in:
parent
f29d3baeb9
commit
027040604c
5 changed files with 120 additions and 76 deletions
|
@ -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)
|
||||
|
||||
|
|
|
@ -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};
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()),
|
||||
|
|
|
@ -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> {
|
||||
|
|
Loading…
Reference in a new issue