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:
parent
b91da3c4ab
commit
37acb67aa5
10 changed files with 905 additions and 298 deletions
104
Cargo.lock
generated
104
Cargo.lock
generated
|
@ -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"
|
||||
|
|
|
@ -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
140
bin/server/src/api.rs
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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:?}");
|
||||
let config = Config {
|
||||
ap_domain: "test.piss-on.me".to_string(),
|
||||
wf_domain: "test.piss-on.me".to_string(),
|
||||
port: 1312,
|
||||
};
|
||||
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:?}")
|
||||
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()
|
||||
);
|
||||
let sock = tokio::net::TcpListener::bind("0.0.0.0:1312").await.unwrap();
|
||||
axum::serve(sock, app).await.unwrap();
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,7 +6,7 @@ edition = "2021"
|
|||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
reqwest = "*"
|
||||
reqwest = { version = "*", features = ["json"] }
|
||||
sigh = "*"
|
||||
serde_json = "*"
|
||||
derive_more = "*"
|
||||
|
@ -14,3 +14,5 @@ http = "*"
|
|||
chrono = "*"
|
||||
base64 = "*"
|
||||
rsa = { version = "*", features = ["sha2"] }
|
||||
http-body-util = "*"
|
||||
rand = "*"
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,3 +12,4 @@ bincode = "2.0.0-rc.3"
|
|||
chrono = "*"
|
||||
either = "*"
|
||||
derive_more = "*"
|
||||
serde_json = "*"
|
||||
|
|
|
@ -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()))
|
||||
|
|
|
@ -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() }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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(())
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue