Major cleanup

* Rename `fetch::keys` to `fetch::signatures`
* Clean up the public api of `fetch::signatures`
* Switch from axum to hyper
* Add request signature validation (buggy, wip)
This commit is contained in:
Riley Apeldoorn 2024-04-28 23:40:37 +02:00
parent b91da3c4ab
commit 37acb67aa5
10 changed files with 905 additions and 298 deletions

104
Cargo.lock generated
View file

@ -89,78 +89,12 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "async-trait"
version = "0.1.80"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6fa2087f2753a7da8cc1c0dbfcf89579dd57458e36769de5ac750b4671737ca"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.60",
]
[[package]]
name = "autocfg"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1fdabc7756949593fe60f30ec81974b613357de856987752631dea1e3394c80"
[[package]]
name = "axum"
version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a6c9af12842a67734c9a2e355436e5d03b22383ed60cf13cd0c18fbfe3dcbcf"
dependencies = [
"async-trait",
"axum-core",
"bytes",
"futures-util",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-util",
"itoa",
"matchit",
"memchr",
"mime",
"percent-encoding",
"pin-project-lite",
"rustversion",
"serde",
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
"sync_wrapper 1.0.1",
"tokio",
"tower",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "axum-core"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a15c63fd72d41492dc4f497196f5da1fb04fb7529e631d73630d1b491e47a2e3"
dependencies = [
"async-trait",
"bytes",
"futures-util",
"http",
"http-body",
"http-body-util",
"mime",
"pin-project-lite",
"rustversion",
"sync_wrapper 0.1.2",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "backtrace"
version = "0.3.71"
@ -550,6 +484,8 @@ dependencies = [
"chrono",
"derive_more",
"http",
"http-body-util",
"rand",
"reqwest",
"rsa",
"serde_json",
@ -987,12 +923,6 @@ dependencies = [
"syn 2.0.60",
]
[[package]]
name = "matchit"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
[[package]]
name = "memchr"
version = "2.7.2"
@ -1305,6 +1235,7 @@ dependencies = [
"derive_more",
"either",
"fetch",
"serde_json",
"store",
]
@ -1415,7 +1346,7 @@ dependencies = [
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper 0.1.2",
"sync_wrapper",
"system-configuration",
"tokio",
"tokio-native-tls",
@ -1507,12 +1438,6 @@ version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ecd36cc4259e3e4514335c4a138c6b43171a8d61d8f5c9348f9fc7529416f247"
[[package]]
name = "rustversion"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "80af6f9131f277a45a3fba6ce8e2258037bb0477a67e610d3c1fe046ab31de47"
[[package]]
name = "ryu"
version = "1.0.17"
@ -1594,16 +1519,6 @@ dependencies = [
"serde",
]
[[package]]
name = "serde_path_to_error"
version = "0.1.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af99884400da37c88f5e9146b7f1fd0fbcae8f6eec4e9da38b67d05486f814a6"
dependencies = [
"itoa",
"serde",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
@ -1620,7 +1535,10 @@ dependencies = [
name = "server"
version = "0.0.0"
dependencies = [
"axum",
"http",
"http-body-util",
"hyper",
"hyper-util",
"puppy",
"serde_json",
"tokio",
@ -1770,12 +1688,6 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160"
[[package]]
name = "sync_wrapper"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7065abeca94b6a8a577f9bd45aa0867a2238b74e8eb67cf10d492bc39351394"
[[package]]
name = "system-configuration"
version = "0.5.1"

View file

@ -4,6 +4,9 @@ edition = "2021"
[dependencies]
puppy = { path = "../../lib/puppy" }
hyper = { version = "*", features = ["full"] }
tokio = { version = "*", features = ["full"] }
axum = "*"
http-body-util = "*"
hyper-util = { version = "*", features = ["full"] }
serde_json = "*"
http = "*"

140
bin/server/src/api.rs Normal file
View file

@ -0,0 +1,140 @@
//! API endpoints and request handlers.
pub mod ap {
//! ActivityPub handlers.
use http_body_util::Full;
use hyper::body::Bytes;
use puppy::{
actor::Actor,
auth::{Signer, Verifier},
config::Config,
data::{Id, PrivateKey, PublicKey},
fetch::{signatures::Private, SigningKey},
get_local_ap_object, Key,
};
use serde_json::{to_string, Value};
use crate::{respond, Response};
/// Proxy a request through the instance.
pub async fn proxy(params: &[(&str, &str)]) -> Response {
// Extract our query parameters.
let Some(user) = params.iter().find_map(|(k, v)| (*k == "user").then_some(v)) else {
return respond(400, Some("Expected `user` query param"), []);
};
let Some(url) = params.iter().find_map(|(k, v)| (*k == "url").then_some(v)) else {
return respond(400, Some("Expected `url` query param"), []);
};
// Look up the actor's key in the store (which is accessible through the puppy context).
let (signing_key) = puppy::context::<_, puppy::Error>(|cx| try {
let actor = Actor::by_username(&cx, user)?.unwrap();
let (PrivateKey { key_pem, .. }, PublicKey { key_id, .. }) =
cx.store().get_mixin_many(actor.key)?;
let Id(owner) = cx.store().get_alias(actor.key)?.unwrap();
let inner = Private::decode_pem(&key_pem);
SigningKey { key_id, owner, inner }
})
.unwrap();
eprintln!("proxy: params: {params:?}");
// Proxy the request through our fetcher.
let resp = puppy::fetch::forward(&signing_key, url).await.unwrap();
eprintln!("proxy: status = {}", resp.status());
// Convert the http-types request to a hyper request.
resp.map(Bytes::from).map(Full::new).into()
}
/// Handle POSTs to actor inboxes. Requires request signature.
pub fn inbox(actor_id: &str, sig: Signer, body: Value, cfg: Config) -> Response {
todo!()
}
/// Serve an ActivityPub object as json-ld.
pub fn serve_object(object_ulid: &str) -> Response {
let Ok(parsed) = object_ulid.parse::<Key>() else {
return respond(400, Some("improperly formatted id"), []);
};
let result = puppy::context(|cx| get_local_ap_object(&cx, parsed));
let Ok(object) = result else {
return respond(404, <Option<String>>::None, []);
};
let json = to_string(&object.to_json_ld()).unwrap();
respond(200, Some(json), [AP_CONTENT_TYPE])
}
const AP_CONTENT_TYPE: (&str, &str) = ("content-type", "application/activity+json");
/// Serve the special actor used for signing requests.
pub fn serve_verifier_actor(cfg: Config) -> Response {
let body = Verifier::load(&cfg).to_json_ld();
let encoded = serde_json::to_vec(&body).unwrap();
respond(200, Some(encoded), [AP_CONTENT_TYPE])
}
}
pub mod wf {
//! WebFinger endpoints and related stuff.
use puppy::{config::Config, data::Username, Error};
use serde_json::json;
use crate::{respond, Response};
const WF_CONTENT_TYPE: (&str, &str) = ("content-type", "application/jrd+json");
pub fn resolve(query: &[(&str, &str)], cfg: Config) -> Response {
match query.iter().find_map(get_handle) {
// Serve JRDs for local actors.
Some(handle) if cfg.wf_domain == handle.instance => {
let id = puppy::context::<_, Error>(|cx| try {
let user = cx
.store()
.lookup(Username(handle.username.to_string()))?
.unwrap();
let id = cx.store().get_alias::<puppy::data::Id>(user)?.unwrap().0;
id
})
.unwrap();
let jrd = json!({
"subject": format!("acct:{}@{}", handle.username, handle.instance),
"links": [
{
"rel": "self",
"type": "application/activity+json",
"href": id
},
]
});
let encoded = serde_json::to_vec(&jrd).unwrap();
respond(200, Some(encoded), [WF_CONTENT_TYPE])
}
Some(_) => todo!(),
None => todo!("bad request: could not find valid resource parameter"),
}
}
pub struct Handle<'x> {
username: &'x str,
instance: &'x str,
}
/// Parse the `resource` parameter into a [`Handle`].
pub fn get_handle<'x>((k, v): &'x (&str, &str)) -> Option<Handle<'x>> {
// We're looking for the `resource` query parameter.
if *k == "resource" {
// This prefix needs to exist according to spec.
v.strip_prefix("acct:")?
// Some implementations may prefix with `@`. its ok if it's there and its also ok
// if its not there, so we use `trim_start_matches` instead of `strip_prefix`.
.trim_start_matches('@')
// Split on the middle `@` symbol, which separates the username and instance bits
.split_once('@')
// Convert to a structured format.
.map(|(username, instance)| Handle { username, instance })
} else {
None
}
}
}

View file

@ -1,79 +1,237 @@
#![feature(try_blocks)]
use std::collections::HashMap;
#![feature(try_blocks, yeet_expr)]
use axum::{
extract::{Path, Query},
response::{AppendHeaders, IntoResponse as _},
routing::get,
Json, Router,
};
use puppy::{
actor::Actor,
context,
data::{PrivateKey, PublicKey},
get_local_ap_object, Key,
};
use serde_json::json;
use std::convert::Infallible;
use std::net::SocketAddr;
use http::request::Parts;
use http_body_util::{BodyExt as _, Full};
use hyper::body::Bytes;
use hyper::server::conn::http1;
use hyper::service::service_fn;
use hyper_util::rt::TokioIo;
use puppy::auth::{SigError, Signer};
use puppy::{auth::verify_signature, config::Config};
use puppy::fetch::signatures::Signature;
use serde_json::{from_slice, json, Value};
use tokio::net::TcpListener;
#[tokio::main]
async fn main() {
let app = Router::new()
.route(
"/o/:ulid",
get(
|Path(raw_object_id): Path<String>, req: axum::extract::Request| async move {
eprintln!("req: {req:?}");
context::<_, puppy::Error>(|cx| try {
let object_id = raw_object_id.parse::<Key>().unwrap();
let obj = get_local_ap_object(&cx, object_id).unwrap().to_json_ld();
(
AppendHeaders([("content-type", "application/activity+json")]),
Json(obj).into_response(),
)
})
.unwrap()
},
),
)
.route(
"/proxy",
get(|Query(q): Query<HashMap<String, String>>| async move {
let (key_pem, key_id) = context::<_, puppy::Error>(|cx| try {
let actor = Actor::by_username(&cx, "riley")?.unwrap();
let (private, public) = cx
.store()
.get_mixin_many::<(PrivateKey, PublicKey)>(actor.key)?;
(private.key_pem, public.key_id)
})
.unwrap();
puppy::fetch::resolve(&key_pem, &key_id, &q["target"])
.await
.unwrap()
}),
)
.route(
"/.well-known/webfinger",
get(
|Query(q): Query<HashMap<String, String>>, req: axum::extract::Request| async move {
eprintln!("req: {req:?}");
let Some(rest) = q["resource"].strip_prefix("acct:") else {
panic!("{q:?}");
};
if rest == "riley@test.piss-on.me" {
Json(json!({
"subject": "acct:riley@test.piss-on.me",
"links": [{
"rel": "self",
"type": "application/activity+json",
"href": "https://test.piss-on.me/o/01HWG4BQJR23TWF12KVPBBP1HG",
}],
}))
} else {
panic!("{rest:?}")
}
},
),
);
let sock = tokio::net::TcpListener::bind("0.0.0.0:1312").await.unwrap();
axum::serve(sock, app).await.unwrap();
let config = Config {
ap_domain: "test.piss-on.me".to_string(),
wf_domain: "test.piss-on.me".to_string(),
port: 1312,
};
start(&config).await.unwrap();
}
pub async fn start(cfg: &Config) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let addr = SocketAddr::from(([127, 0, 0, 1], cfg.port));
let listener = TcpListener::bind(addr).await?;
// We start a loop to continuously accept incoming connections
loop {
let (stream, _) = listener.accept().await?;
// Use an adapter to access something implementing `tokio::io` traits as if they implement
// `hyper::rt` IO traits.
let io = TokioIo::new(stream);
let cfg = cfg.clone();
// Spawn a tokio task to serve multiple connections concurrently
tokio::task::spawn(async move {
// Finally, we bind the incoming connection to our `hello` service
if let Err(err) = http1::Builder::new()
// `service_fn` converts our function in a `Service`
.serve_connection(io, service_fn(|req| handle(req, cfg.clone())))
.await
{
eprintln!("Error serving connection: {:?}", err);
}
});
}
}
type Request = hyper::Request<hyper::body::Incoming>;
type Response<T = Full<Bytes>> = hyper::Response<T>;
/// The request handler.
async fn handle(req: Request, cfg: Config) -> Result<Response, Infallible> {
// We need to fetch the entire body of the request for signature validation, because that involves making
// a digest of the request body in some cases.
let request = {
let (req, body) = req.into_parts();
let Ok(body) = body.collect().await.map(|b| b.to_bytes()) else {
return Ok(error::invalid_body("Could not get request body"));
};
http::Request::from_parts(req, body)
};
// Simplified representation of a request, so we can pattern match on it more easily in the dispatchers.
let req = make_req(&request);
eprintln!("{request:?}: open");
// We'll use the path to pick where specifically to send the request.
// Check request signature at the door. Even if it isn't needed for a particular endpoint, failing fast
// with a clear error message will save anyone trying to get *their* signatures implementation a major
// headache.
let res = match verify_signature(&request, &cfg).await {
// If the request was signed and the signature was accepted, they can access the protected endpoints.
Ok(Some(sig)) => dispatch_signed(req, sig, cfg).await,
// Unsigned requests can see a smaller subset of endpoints, most notably the verification actor.
Ok(None) => dispatch_public(req, cfg).await,
// If a signature was provided *but it turned out to be unverifiable*, show them the error message.
Err(err) => error::bad_signature(match err {
SigError::VerificationFailed { error } => format!("Verification failed: {error}"),
SigError::ParseSignature { error } => format!("Failed to parse signature: {error}"),
SigError::FailedToFetchKey { keyid } => format!("Failed to fetch {keyid}"),
}),
};
eprintln!(
"{} {}: done (status: {})",
request.method(),
request.uri(),
res.status()
);
Ok(res)
}
// A parsed HTTP request for easy handling.
struct Req<'a> {
method: &'a Method,
body: Bytes,
// URI bits
params: Vec<(&'a str, &'a str)>,
path: Vec<&'a str>,
}
impl Req<'_> {
fn path(&self) -> &[&str] {
&self.path
}
}
fn make_req<'x>(r: &'x http::Request<Bytes>) -> Req<'x> {
let path: Vec<&str> = r
.uri()
.path()
.split('/')
.filter(|s| !s.is_empty())
.collect();
let params: Vec<(&str, &str)> = r
.uri()
.query()
.into_iter()
.flat_map(|s| s.split('&'))
.filter_map(|s| s.split_once('='))
.collect();
Req {
method: r.method(),
body: r.body().clone(),
params,
path,
}
}
use hyper::Method;
const POST: &Method = &Method::POST;
const GET: &Method = &Method::GET;
/// Handle a signed and verified request.
///
/// This function is where all requests to a protected endpoint have to go through. If the request
/// was signed but does not target a protected endpoint, this function will fall back to the
/// [`dispatch_public`] handler.
async fn dispatch_signed(req: Req<'_>, sig: Signer, cfg: Config) -> Response {
eprintln!("Dispatching signed request");
match (req.method, req.path()) {
// Viewing ActivityPub objects requires a signed request, i.e. "authorized fetch".
// The one exception for this is `/s/request-verifier`, which is where the request
// verification actor lives.
(GET, ["o", ulid]) => api::ap::serve_object(ulid),
// POSTs to an actor's inbox need to be signed to prevent impersonation.
(POST, ["o", ulid, "inbox"]) => with_json(&req.body, |json| {
// We only handle the intermediate parsing of the json, full resolution of the
// activity object will happen inside the inbox handler itself.
api::ap::inbox(ulid, sig, json, cfg)
}),
// Try the resources for which no signature is required as well.
_ => dispatch_public(req, cfg).await,
}
}
/// Dispatch `req` to an unprotected endpoint. If the requested path does not exist, the
/// function will return a 404 response.
async fn dispatch_public(req: Req<'_>, cfg: Config) -> Response {
eprintln!("Dispatching public request");
match (req.method, req.path()) {
(GET, ["proxy"]) => api::ap::proxy(&req.params).await,
(GET, [".well-known", "webfinger"]) => api::wf::resolve(&req.params, cfg),
(GET, ["s", "request-verifier"]) => api::ap::serve_verifier_actor(cfg),
(m, p) => {
eprintln!("404: {m} {p:?}");
error::not_found()
}
}
}
fn with_json(body: &[u8], f: impl FnOnce(Value) -> Response) -> Response {
let Ok(json) = from_slice(body) else {
return error::invalid_body("Could not decode as JSON");
};
f(json)
}
/// A quick, simple way to construct a response.
fn respond<const N: usize>(
status: u16,
body: Option<impl Into<Bytes>>,
headers: [(&str, &str); N],
) -> Response {
let mut resp = Response::<()>::builder().status(status);
for (name, data) in headers {
resp = resp.header(name, data);
}
resp.body(match body {
Some(bytes) => Full::new(bytes.into()),
None => Full::new(Bytes::default()),
})
.unwrap()
}
mod api;
mod error {
//! Pre-baked error responses.
use http_body_util::Full;
use super::Response;
/// 404 response.
pub fn not_found() -> Response {
let body = Full::new("Not found".into());
Response::<()>::builder()
.status(404)
.header("content-type", "text/plain")
.body(body)
.unwrap()
}
/// 403 response indicating a bad request signature.
pub fn bad_signature(err: String) -> Response {
let body = Full::new(err.into());
Response::<()>::builder()
.status(403)
.header("content-type", "text/plain")
.body(body)
.unwrap()
}
pub fn invalid_body(err: impl ToString) -> Response {
let body = Full::new(err.to_string().into());
Response::<()>::builder()
.status(400)
.header("content-type", "text/plain")
.body(body)
.unwrap()
}
}

View file

@ -6,11 +6,13 @@ edition = "2021"
path = "src/lib.rs"
[dependencies]
reqwest = "*"
sigh = "*"
serde_json = "*"
derive_more = "*"
http = "*"
chrono = "*"
base64 = "*"
rsa = { version = "*", features = ["sha2"] }
reqwest = { version = "*", features = ["json"] }
sigh = "*"
serde_json = "*"
derive_more = "*"
http = "*"
chrono = "*"
base64 = "*"
rsa = { version = "*", features = ["sha2"] }
http-body-util = "*"
rand = "*"

View file

@ -1,14 +1,14 @@
#![feature(iter_intersperse)]
#![feature(iter_intersperse, yeet_expr)]
use chrono::Utc;
use derive_more::{Display, From, Into};
use http_body_util::BodyExt as _;
use signatures::{Private, Public};
use reqwest::Body;
use serde_json::{json, Value};
use sigh::{
alg::{Hs2019, RsaSha256},
Key as _, SigningConfig,
};
use crate::keys::{HS2019, RSA_SHA256};
use crate::signatures::HS2019;
pub use http;
pub enum Object {
Activity(Activity),
@ -69,44 +69,66 @@ where
}
/// Deliver an [`Activity`] to a particular `inbox`.
pub async fn deliver(private_key_pem: &str, key_id: &str, activity: Activity, inbox: &str) -> () {
let body = serde_json::to_string_pretty(&activity.to_json_ld()).unwrap();
let mut req = http::Request::post(inbox)
.header("content-type", "application/activity+json")
.header("user-agent", "ActivityPuppy/0.0.0 (delivery)")
.header("date", Utc::now().to_rfc3339())
.body(body)
.unwrap();
let key = sigh::PrivateKey::from_pem(private_key_pem.as_bytes()).unwrap();
SigningConfig::new(RsaSha256, &key, key_id)
.sign(&mut req)
.unwrap();
reqwest::Client::new()
.execute(req.try_into().unwrap())
.await
.unwrap();
pub async fn deliver(key: &SigningKey, activity: Activity, inbox: &str) -> () {
todo!()
}
pub async fn resolve(private_key_pem: &str, key_id: &str, target: &str) -> reqwest::Result<String> {
const EMPTY_DIGEST: &str = "sha-256=47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=";
pub async fn resolve(key: &SigningKey, target: &str) -> reqwest::Result<Value> {
// TODO: make this retry with different signature options and remember what works for the
// particular host.
// Sun, 06 Nov 1994 08:49:37 GMT
const RFC_822: &str = "%a, %d %b %Y %H:%M:%S GMT";
let date = Utc::now().format(RFC_822).to_string();
let mut req = http::Request::builder()
.uri(target)
.header("accept", "application/activity+json")
.header("user-agent", "ActivityPuppy/0.0.0 (resolver)")
.header("date", date)
// Empty body
.body(())
.unwrap();
// hs2019 works with masto
key.sign(HS2019, &req)
.expect("signing error")
.attach_to(&mut req);
reqwest::Client::new()
.execute(req.map(|_| Body::default()).try_into().unwrap())
.await?
.json()
.await
}
pub async fn forward(key: &SigningKey, target: &str) -> reqwest::Result<http::Response<String>> {
// Sun, 06 Nov 1994 08:49:37 GMT
const RFC_822: &str = "%a, %d %b %Y %H:%M:%S GMT";
let date = Utc::now().format(RFC_822).to_string();
let mut req = http::Request::get(target)
.header("accept", "application/json")
.header("accept", "application/activity+json")
.header("user-agent", "ActivityPuppy/0.0.0 (resolver)")
.header("date", date)
// Empty body
.body(Body::default())
.body(())
.unwrap();
// hs2019 works with masto
keys::sign(&mut req, HS2019, private_key_pem, key_id).unwrap();
reqwest::Client::new()
.execute(req.try_into().unwrap())
.await?
.text()
.await
key.sign(HS2019, &req)
.expect("signing error")
.attach_to(&mut req);
let resp = reqwest::Client::new()
.execute(req.map(|_| Body::default()).try_into().unwrap())
.await?;
let http_resp: http::Response<reqwest::Body> = resp.into();
let (res, body) = http_resp.into_parts();
let body = body.collect().await.unwrap().to_bytes();
let http_resp =
http::Response::from_parts(res, String::from_utf8_lossy(body.as_ref()).into_owned());
Ok(http_resp)
}
/// An actor is an entity capable of producing Takes.
@ -121,7 +143,7 @@ pub struct Actor {
pub display_name: Option<String>,
/// Public counterpart to the signing key used to sign activities
/// generated by the actor.
pub public_key: PublicKey,
pub public_key: Key,
}
impl Actor {
@ -138,97 +160,280 @@ impl Actor {
"name": self.display_name,
"type": "Person",
"publicKey": {
"id": self.public_key.key_id.to_string(),
"publicKeyPem": self.public_key.public_key_pem,
"id": self.public_key.key_id,
"publicKeyPem": self.public_key.inner,
"owner": self.id.to_string(),
}
})
}
}
pub struct PublicKey {
pub key_id: Id,
pub owner: Id,
pub public_key_pem: String,
/// A key that can be used to verify a request signature.
pub type VerificationKey = Key<Public>;
/// A key that can be used to sign a request.
pub type SigningKey = Key<Private>;
/// A key used for authorized fetch.
///
/// It comes in several flavors:
///
/// - `Key` (`K` = [`String`]): PEM-encoded, can be turned into a JSON object.
/// - [`VerificationKey`] (`K` = [`Public`]): used as an input in the request signature validation process.
/// - [`SigningKey`] (`K` = [`Private`]): used as an input in the generation of a signed request.
pub struct Key<K = String> {
/// The `"id"` property of the public key.
pub key_id: String,
/// The `"owner"` property.
pub owner: String,
/// Maps to the `"publicKeyPem"` property of an actor's `"publicKey"` when (de)serializing, and when the
/// key is used for doing [signatures].
pub inner: K,
}
impl Key {
/// Tries to find the PEM-encoded public key from the result of fetching a key id.
pub fn from_json(json: Value) -> Option<Key> {
// First, we try the object itself.
json.as_object().and_then(Key::from_map).or_else(|| {
// Because of how mastodon deals with pubkey resolution, most implementations will serve the whole actor
// object instead of just the key, so we try that first, because it is the de facto standard.
json["publicKey"].as_object().and_then(Key::from_map)
})
}
/// Construct
fn from_map(map: &serde_json::Map<String, Value>) -> Option<Key> {
Some(Key {
key_id: map["id"].as_str().map(str::to_owned)?,
owner: map["owner"].as_str().map(str::to_owned)?,
inner: map["publicKeyPem"].as_str().map(str::to_owned)?,
})
}
/// "Upgrade" a pem-encoded public key to a key that can actually be used for requests.
pub fn upgrade(self) -> Key<Public> {
let inner = Public::decode_pem(&self.inner);
Key {
key_id: self.key_id,
owner: self.owner,
inner,
}
}
}
impl Key<Public> {
/// Encode a verification key so that it can be presented in a json.
pub fn serialize(self) -> Key {
let public_key_pem = self.inner.encode_pem();
Key {
key_id: self.key_id,
owner: self.owner,
inner: public_key_pem,
}
}
}
#[derive(Display, From, Into, Debug, Clone)]
pub struct Id(String);
pub mod keys {
//! Cryptography and such.
pub mod signatures {
//! Containment zone for the funny math that doesn't make much sense to puppy.
//!
//! This module provides ActivityPuppy's HTTP signatures implementation. The state of HTTP signatures implementations
//! is, to put it mildly, *een fucking kutzooi*. For historical reasons, no one implements it *exactly* right (much
//! like URI parsers). This implementation aims to be as broadly compatible as possible.
//!
//! The only non-deprecated [`Algorithm`] is [`"hs2019"`][HS2019], but not everyone implements it, because the initial
//! round of implementations of the spec were based on a draft, and [`"rsa-sha256"`][RSA_SHA256] is kinda the de facto
//! standard.
//!
//! # Behavior
//!
//! By default, puppy will sign with `algorithm="hs2019"` (using `(created)` and `(expires)` pseudo-headers), and retry
//! in legacy mode (using `algorithm="rsa-sha256"` with `date` header) if the signature gets rejected.
//!
//! Currently, `"hs2019"` is treated as equivalent to `"rsa-sha256"` for verification purposes. Support for elliptic
//! curve keys is planned, but not a priority.
//!
//! # Links
//!
//! More information about http signatures:
//!
//! - <https://swicg.github.io/activitypub-http-signature>
//! - <https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures>
use chrono::{TimeDelta, Utc};
use std::{borrow::Cow, collections::HashMap, path::Path};
use chrono::{DateTime, TimeDelta, Utc};
use http::{HeaderValue, Method, Request};
use reqwest::Body;
use rsa::{pkcs1v15::SigningKey, pkcs8::DecodePrivateKey as _, sha2};
use sigh::{
Key as _,
alg::{Algorithm as _, RsaSha256},
use rsa::{
signature::{Verifier as _, Signer as _, SignatureEncoding as _},
pkcs1v15::{SigningKey, VerifyingKey},
pkcs8::{
DecodePrivateKey, DecodePublicKey, EncodePublicKey as _, EncodePrivateKey as _,
LineEnding,
},
sha2::{self, Sha256},
RsaPrivateKey,
};
pub struct RawPrivateKey {
inner: sigh::PrivateKey,
}
use base64::prelude::*;
impl RawPrivateKey {
pub fn to_pem(&self) -> String {
self.inner.to_pem().unwrap()
/// A key that can be used to generate signatures.
#[derive(Clone)]
pub struct Private(rsa::RsaPrivateKey);
impl Private {
/// Generate a new keypair.
pub fn gen() -> (Private, Public) {
let mut rng = rand::thread_rng();
let bits = 4096;
let private = RsaPrivateKey::new(&mut rng, bits).unwrap();
let public = private.to_public_key();
(Private(private), Public(public))
}
/// Get the public counterpart to this key.
pub fn get_public(&self) -> Public {
Public(self.0.to_public_key())
}
/// Load a private key from a file on disk.
pub fn load(path: impl AsRef<Path>) -> Private {
use rsa::pkcs8::DecodePrivateKey;
DecodePrivateKey::read_pkcs8_pem_file(path)
.map(Private)
.unwrap()
}
/// PEM-encode the key PKCS#8 style.
pub fn encode_pem(&self) -> String {
self.0
.to_pkcs8_pem(LineEnding::default())
.unwrap()
.to_string()
}
/// Decode the key from a PKCS#8 PEM-encoded string.
pub fn decode_pem(pkcs8_pem: &str) -> Private {
DecodePrivateKey::from_pkcs8_pem(&pkcs8_pem)
.map(Private)
.unwrap()
}
}
pub struct RawPublicKey {
inner: sigh::PublicKey,
}
/// A key that can be used to verify signatures.
#[derive(Clone)]
pub struct Public(rsa::RsaPublicKey);
impl RawPublicKey {
pub fn to_pem(&self) -> String {
self.inner.to_pem().unwrap()
impl Public {
/// PEM-encode the public key in accordance with PKCS#8.
pub fn encode_pem(&self) -> String {
self.0
.to_public_key_pem(LineEnding::default())
.unwrap()
.to_string()
}
/// Decode a PKCS#8 PEM-encoded public key from a string.
pub fn decode_pem(pkcs8_pem: &str) -> Public {
DecodePublicKey::from_public_key_pem(&pkcs8_pem)
.map(Public)
.unwrap()
}
}
/// Generate a private and public keypair.
pub fn gen_keypair() -> (RawPrivateKey, RawPublicKey) {
let (private, public) = RsaSha256.generate_keys().unwrap();
(RawPrivateKey { inner: private }, RawPublicKey {
inner: public,
})
impl crate::SigningKey {
/// Create a signature for `req` using the given algorithm, for `GET` requests.
pub fn sign(&self, alg: Algorithm, req: &Request<()>) -> Result<Signature<'_>, String> {
let pieces = gather_pieces(&req)?;
let signing_string = make_signing_string(&pieces);
let signature = create(signing_string, alg, &self.inner, &self.key_id, pieces)?;
Ok(signature)
}
/// Create a signature for `req` using the given algorithm, and calculate and attach the `digest` header to
/// the request (if it doesn't already have one).
///
/// This is required by most implementations when POSTing to an inbox.
pub fn sign_with_digest<T>(
&self,
alg: Algorithm,
req: &mut Request<T>,
) -> Result<Signature<'_>, String>
where
T: AsRef<[u8]>,
{
todo!()
}
}
impl crate::VerificationKey {
/// Verify the request's signature.
pub fn verify(&self, sig: Signature<'_>) -> Result<(), String> {
use rsa::pkcs1v15::Signature;
// TODO: support elliptic curve keys
// TODO: nicer error reporting
let Public(key) = self.inner.clone();
let verifying_key = VerifyingKey::<Sha256>::new(key);
let decoded = BASE64_STANDARD
.decode(&sig.signature_encoded)
.map_err(|e| e.to_string())?;
let signature = Signature::try_from(decoded.as_slice()).map_err(|e| e.to_string())?;
verifying_key
.verify(sig.signing_string.as_bytes(), &signature)
.map_err(|e| e.to_string())
}
}
/// The algorithm to sign with.
#[derive(PartialEq, Debug)]
///
/// Your two options are:
///
/// - [`hs2019`][HS2019], the *correct* option
/// - [`"rsa-sha256"`][RSA_SHA256], the most compatible option
#[derive(PartialEq, Debug, Clone, Copy)]
pub struct Algorithm(&'static str);
pub const RSA_SHA256: Algorithm = Algorithm("rsa-sha256");
/// `hs2019`, the only non-deprecated HTTP signatures algorithm.
pub const HS2019: Algorithm = Algorithm("hs2019");
/// The HTTP signatures algorithm everyone uses, `rsa-sha256`.
pub const RSA_SHA256: Algorithm = Algorithm("rsa-sha256");
struct Signature<'k> {
/// A signature derived from an [`http::Request`].
pub struct Signature<'k> {
key_id: &'k str,
alg: Algorithm,
components: Vec<Component>,
created: String,
expires: String,
/// Base64-encoded signature.
signing_string: Cow<'k, str>,
signature_encoded: String,
}
/// Sign `req`.
pub fn sign(
req: &mut Request<Body>,
alg: Algorithm,
private_key: &str,
key_id: &str,
) -> Result<(), String> {
// NOTE: Rough translation of https://nest.pijul.org/ez/ActivityPuppy:main/XXPS2UOWSWD2Y.ZJAAA
let pieces = gather_pieces(&req)?;
let signing_string = make_signing_string(&pieces);
let signature = create(&signing_string, alg, private_key, key_id, pieces)?;
req.headers_mut().insert("signature", render(signature));
Ok(())
impl Signature<'_> {
/// Obtain the key id for the signature.
pub fn key_id(&self) -> &str {
&self.key_id
}
/// Get the time the signature was created. This information is extracted from the `(created)`
/// pseudo-header if it is defined, and the `date` http header otherwise.
pub fn created(&self) -> DateTime<Utc> {
todo!()
}
/// If specified, get the expiry time.
pub fn expires(&self) -> Option<DateTime<Utc>> {
todo!()
}
/// Retrieve the algorithm used for the signature.
pub fn algorithm(&self) -> Algorithm {
self.alg
}
/// Attempt to extract a signature from a request.
pub fn derive<T>(req: &Request<T>) -> Result<Signature<'_>, String> {
parse(req)
}
/// Attach `self` to `req` as the `signature` header.
pub fn attach_to<T>(self, req: &mut Request<T>) {
req.headers_mut().insert("signature", render(self));
}
}
/// Gather all the bits from a `Request`.
fn gather_pieces(req: &Request<Body>) -> Result<Vec<Component>, &'static str> {
fn gather_pieces<T>(req: &Request<T>) -> Result<Vec<Component>, &'static str> {
let target = {
let method = req.method().as_str().to_lowercase();
let path = req.uri().path();
@ -245,7 +450,7 @@ pub mod keys {
("host", req.uri().host().unwrap().to_owned()),
];
if [Method::POST, Method::PUT, Method::PATCH].contains(req.method()) {
if let Method::POST | Method::PUT | Method::PATCH = *req.method() {
let digest = req
.headers()
.get("digest")
@ -269,28 +474,29 @@ pub mod keys {
/// Sign the `signing_string`.
fn create<'s>(
signing_string: &'s str,
signing_string: String,
alg: Algorithm,
key_pem: &str,
key: &Private,
key_url: &'s str,
components: Vec<Component>,
) -> Result<Signature<'s>, String> {
let created = components
.iter()
.find_map(|(k, v)| if *k == "(created)" { Some(v) } else { None })
.find_map(|(k, v)| (*k == "(created)").then_some(v))
.cloned()
.unwrap();
let expires = components
.iter()
.find_map(|(k, v)| if *k == "(expires)" { Some(v) } else { None })
.find_map(|(k, v)| (*k == "(expires)").then_some(v))
.cloned()
.unwrap();
// We regardless of the algorithm, we produce RSA-SHA256 signatures, because this is broadly compatible
// Regardless of the algorithm, we produce RSA-SHA256 signatures, because this is broadly compatible
// with everything.
let signature = sign_rsa_sha256(signing_string, key_pem)?;
let encoded = base64(signature);
let signature = sign_rsa_sha256(&signing_string, key)?;
let encoded = BASE64_STANDARD.encode(signature);
Ok(Signature {
signature_encoded: encoded,
signing_string: signing_string.into(),
key_id: key_url,
components,
created,
@ -300,23 +506,13 @@ pub mod keys {
}
/// `rsa-sha256` is created using an rsa key and a sha256 hash.
fn sign_rsa_sha256(signing_string: &str, key_pem: &str) -> Result<Vec<u8>, String> {
use rsa::{
signature::{Signer as _, SignatureEncoding as _},
RsaPrivateKey,
};
let rsa = RsaPrivateKey::from_pkcs8_pem(key_pem).map_err(|e| e.to_string())?;
fn sign_rsa_sha256(signing_string: &str, key: &Private) -> Result<Vec<u8>, String> {
let Private(rsa) = key.clone();
let key = SigningKey::<sha2::Sha256>::new(rsa);
let buf = key.sign(signing_string.as_bytes()).to_vec();
Ok(buf)
}
fn base64(buf: Vec<u8>) -> String {
use base64::Engine as _;
// STANDARD works on masto, url safe does not.
base64::prelude::BASE64_STANDARD.encode(buf)
}
/// Format the signature.
fn render(sig: Signature<'_>) -> HeaderValue {
let headers = sig
@ -353,4 +549,71 @@ pub mod keys {
.try_into()
.expect("signature formatting should give a correct header value")
}
// TODO: clean this mess up
fn parse<'s, T>(req: &'s Request<T>) -> Result<Signature<'s>, String> {
let Some(sig_header) = req.headers().get("signature").and_then(|v| v.to_str().ok()) else {
do yeet "No signature header";
};
let stuff: HashMap<&str, &str> = sig_header
.split(", ")
.filter_map(|piece| piece.split_once('='))
// TODO: technically more liberal than the spec
.map(|(k, v)| (k, v.trim_matches('"')))
.collect();
let inputs: Vec<&str> = stuff["headers"].split(' ').collect();
let (created, expires) = if inputs.contains(&"(created)") && inputs.contains(&"(expires)") {
(stuff["created"].to_string(), stuff["expires"].to_string())
} else {
// TODO: support "date" header instead of created/expires
do yeet "Only (created) + (expires) is currently supported";
};
let alg = match stuff.get("algorithm") {
Some(&"hs2019") => HS2019,
Some(&"rsa-sha256") => RSA_SHA256,
Some(alg) => do yeet format!("unsupported alg: {alg}"),
None => do yeet "Missing `algorithm`",
};
let components = {
let target = {
let method = req.method().as_str().to_lowercase();
let path = req.uri().path();
format!("{method} {path}")
};
let Some(host) = req.headers().get("host").and_then(|v| v.to_str().ok()) else {
do yeet "host header is required"
};
let mut components = vec![
("(request-target)", target),
("(created)", created.clone()),
("(expires)", expires.clone()),
("host", req.uri().host().unwrap_or(host).to_owned()),
];
if let Method::POST | Method::PUT | Method::PATCH = *req.method() {
let Some(digest) = req.headers().get("digest").and_then(|v| v.to_str().ok()) else {
do yeet "Digest header is required for POST/PATCH/PUT"
};
components.push(("digest", digest.to_string()));
}
components
};
let signing_string = make_signing_string(&components).into();
Ok(Signature {
key_id: stuff["keyId"],
signature_encoded: stuff["signature"].to_string(),
alg,
created,
expires,
components,
signing_string,
})
}
}

View file

@ -12,3 +12,4 @@ bincode = "2.0.0-rc.3"
chrono = "*"
either = "*"
derive_more = "*"
serde_json = "*"

View file

@ -2,7 +2,7 @@ use std::sync::OnceLock;
use store::{Key, Store, Transaction};
use crate::{config::Config, data::schema, Error, Result};
use crate::{auth, config::Config, data::schema, Error, Result};
/// The context of a running ActivityPuppy.
///
@ -29,6 +29,10 @@ impl Context<'_> {
pub fn mk_url(&self, key: Key) -> String {
format!("https://{}/o/{key}", self.config.ap_domain)
}
/// Get the verification actor.
pub fn verifier(&self) -> &auth::Verifier {
todo!()
}
}
/// The store, which we initialize only once this way.
@ -48,6 +52,7 @@ where
let cfg = Config {
ap_domain: String::from("test.piss-on.me"),
wf_domain: String::from("test.piss-on.me"),
port: 1312,
};
let db = STORE
.get_or_try_init(|| Store::open(".state", schema()))

View file

@ -1,3 +1,6 @@
//! If you're an ActivityPub developer looking for information about ActivityPuppy's federation behavior,
//! you should take a look at [`fetch`].
// Working with result types is such a bitch without these.
#![feature(iterator_try_collect, try_blocks, once_cell_try)]
// Cause an error if someone tries to call [`context`] from within this crate. If we need one,
@ -49,10 +52,10 @@ pub fn get_local_ap_object(cx: &Context<'_>, key: Key) -> Result<fetch::Object>
inbox: inbox.into(),
account_name: account_name.0,
display_name,
public_key: fetch::PublicKey {
public_key: fetch::Key {
owner: obj.id.0.into(),
key_id: key_id.into(),
public_key_pem: key_pem,
inner: key_pem,
},
}))
}
@ -84,6 +87,7 @@ pub fn get_local_ap_object(cx: &Context<'_>, key: Key) -> Result<fetch::Object>
}
pub mod actor {
use fetch::signatures::Private;
use store::{Key, StoreError, Transaction};
use crate::{
@ -161,14 +165,12 @@ pub mod actor {
domain: &str,
) -> Result<(), StoreError> {
let key_id = format!("https://{domain}/o/{vertex}#sig-key");
let (private, public) = fetch::keys::gen_keypair();
let (private, public) = Private::gen();
tx.add_mixin(vertex, PublicKey {
key_pem: public.to_pem(),
key_pem: public.encode_pem(),
key_id,
})?;
tx.add_mixin(vertex, PrivateKey {
key_pem: dbg!(private.to_pem()),
})?;
tx.add_mixin(vertex, PrivateKey { key_pem: private.encode_pem() })?;
store::OK
}
}
@ -189,8 +191,129 @@ pub enum Error {
}
pub mod config {
#[derive(Clone)]
pub struct Config {
pub ap_domain: String,
pub wf_domain: String,
pub port: u16,
}
}
pub mod auth {
use fetch::signatures::{Private, Public, Signature};
use serde_json::{json, Value};
use crate::config::Config;
pub struct Verifier {
actor_id: String,
key_id: String,
private: Private,
public: Public,
}
impl Verifier {
pub async fn get_public_key(&self, uri: &str) -> Result<fetch::Key, String> {
let json = fetch::resolve(&self.signing_key(), uri).await.unwrap();
// TODO: make this parsing work better.
Ok(fetch::Key {
key_id: json["publicKey"]["id"].as_str().unwrap().to_string().into(),
owner: json["publicKey"]["owner"]
.as_str()
.unwrap()
.to_string()
.into(),
inner: json["publicKey"]["publicKeyPem"]
.as_str()
.unwrap()
.to_string()
.into(),
})
}
pub fn signing_key(&self) -> fetch::SigningKey {
fetch::Key {
key_id: self.key_id.clone(),
owner: self.actor_id.clone(),
inner: self.private.clone(),
}
}
pub fn to_json_ld(&self) -> Value {
json!({
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
],
"id": self.actor_id,
"name": "Public key fetcher",
"publicKey": {
"id": self.key_id,
"owner": self.actor_id,
"publicKeyPem": self.public.encode_pem()
},
"type": "Service",
})
}
pub fn load(cfg: &Config) -> Verifier {
let domain = &cfg.ap_domain;
let private = Private::load(".state/fetcher.pem");
Verifier {
actor_id: format!("https://{domain}/s/request-verifier"),
key_id: format!("https://{domain}/s/request-verifier#sig-key"),
public: private.get_public(),
private,
}
}
}
/// An ActivityPub actor that signed a request.
pub struct Signer {
/// The ActivityPub ID (a URL) of the signer of the request.
pub ap_id: String,
}
pub enum SigError {
FailedToFetchKey { keyid: String },
ParseSignature { error: String },
VerificationFailed { error: String },
}
/// Check the signature for a request.
pub async fn verify_signature(
req: &fetch::http::Request<impl AsRef<[u8]>>,
cfg: &Config,
) -> Result<Option<Signer>, SigError> {
if req.uri().path() == "/s/request-verifier" {
// Allow access to the request verifier actor without checking the signature.
return Ok(None);
}
if req.headers().get("signature").is_none() {
// Request is not signed!
return Ok(None);
};
// Parse the signature.
let sig = match Signature::derive(&req) {
Ok(signature) => signature,
Err(error) => return Err(SigError::ParseSignature { error }),
};
// Fetch the public key using the verifier private key.
let verifier = Verifier::load(cfg);
let Ok(public_key) = verifier.get_public_key(sig.key_id()).await else {
return Err(SigError::FailedToFetchKey {
keyid: sig.key_id().to_string(),
});
};
// Verify the signature header on the request.
let public_key = public_key.upgrade();
if let Err(error) = public_key.verify(sig) {
Err(SigError::VerificationFailed { error })
} else {
Ok(Some(Signer { ap_id: public_key.owner.into() }))
}
}
}

View file

@ -4,6 +4,7 @@ use std::ops::RangeBounds;
use chrono::{DateTime, Utc};
use either::Either::{Left, Right};
use fetch::signatures::Private;
use store::{util::IterExt as _, Key, Store, StoreError, Transaction};
use crate::{
@ -33,9 +34,14 @@ impl Post {
id: post.id().clone().into(),
object: Box::new(post),
kind: String::from("Create"),
actor: author_id.0.into(),
actor: author_id.0.clone().into(),
};
let (private, public) = cx.db.get_mixin_many::<(PrivateKey, PublicKey)>(author)?;
let key = fetch::SigningKey {
key_id: public.key_id,
owner: author_id.0,
inner: Private::decode_pem(&private.key_pem),
};
// Insert the activity in the database.
cx.run(|tx| try {
let activity_key = Key::gen();
@ -53,13 +59,7 @@ impl Post {
tx.add_alias(activity_key, id)?;
})?;
// Send the requests.
fetch::deliver(
&private.key_pem,
&public.key_id,
activity,
"https://crimew.gay/users/ezri/inbox",
)
.await;
fetch::deliver(&key, activity, "https://crimew.gay/users/ezri/inbox").await;
Ok(())
}
}