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 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)
|
||||||
|
|
||||||
|
|
|
@ -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};
|
||||||
|
|
|
@ -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,18 +66,19 @@ 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);
|
||||||
|
|
||||||
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
|
// 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(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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()),
|
||||||
|
|
|
@ -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}\"")
|
);
|
||||||
};
|
|
||||||
|
|
||||||
client
|
signer
|
||||||
.header("Signature", signature_header)
|
.build_manually(&method.to_string().to_lowercase(), &path, headers_map)
|
||||||
|
.sign(key)?;
|
||||||
|
|
||||||
|
let res = client
|
||||||
|
.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> {
|
||||||
|
|
Loading…
Reference in a new issue