Split fetch::signatures
into its own file
This commit is contained in:
parent
37acb67aa5
commit
c784966d20
5 changed files with 465 additions and 465 deletions
|
@ -27,13 +27,13 @@ pub mod ap {
|
|||
};
|
||||
|
||||
// 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 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 }
|
||||
SigningKey { id: key_id, owner, inner }
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
|
|
|
@ -1,8 +1,6 @@
|
|||
#![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};
|
||||
|
||||
|
@ -10,11 +8,14 @@ use crate::signatures::HS2019;
|
|||
|
||||
pub use http;
|
||||
|
||||
pub use signatures::{Key, SigningKey, VerificationKey};
|
||||
pub mod signatures;
|
||||
|
||||
pub enum Object {
|
||||
Activity(Activity),
|
||||
Actor(Actor),
|
||||
Object {
|
||||
id: Id,
|
||||
id: String,
|
||||
kind: String,
|
||||
content: Option<String>,
|
||||
summary: Option<String>,
|
||||
|
@ -22,7 +23,7 @@ pub enum Object {
|
|||
}
|
||||
|
||||
impl Object {
|
||||
pub fn id(&self) -> &Id {
|
||||
pub fn id(&self) -> &str {
|
||||
match self {
|
||||
Object::Activity(a) => &a.id,
|
||||
Object::Actor(a) => &a.id,
|
||||
|
@ -44,8 +45,8 @@ impl Object {
|
|||
}
|
||||
|
||||
pub struct Activity<T = String> {
|
||||
pub id: Id,
|
||||
pub actor: Id,
|
||||
pub id: String,
|
||||
pub actor: String,
|
||||
pub object: Box<Object>,
|
||||
pub kind: T,
|
||||
}
|
||||
|
@ -134,9 +135,9 @@ pub async fn forward(key: &SigningKey, target: &str) -> reqwest::Result<http::Re
|
|||
/// An actor is an entity capable of producing Takes.
|
||||
pub struct Actor {
|
||||
/// The URL pointing to this object.
|
||||
pub id: Id,
|
||||
pub id: String,
|
||||
/// Where others should send activities.
|
||||
pub inbox: Id,
|
||||
pub inbox: String,
|
||||
/// Note: this maps to the `preferredUsername` property.
|
||||
pub account_name: String,
|
||||
/// Note: this maps to the `name` property.
|
||||
|
@ -160,460 +161,10 @@ impl Actor {
|
|||
"name": self.display_name,
|
||||
"type": "Person",
|
||||
"publicKey": {
|
||||
"id": self.public_key.key_id,
|
||||
"id": self.public_key.id,
|
||||
"publicKeyPem": self.public_key.inner,
|
||||
"owner": self.id.to_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 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 std::{borrow::Cow, collections::HashMap, path::Path};
|
||||
|
||||
use chrono::{DateTime, TimeDelta, Utc};
|
||||
use http::{HeaderValue, Method, Request};
|
||||
use rsa::{
|
||||
signature::{Verifier as _, Signer as _, SignatureEncoding as _},
|
||||
pkcs1v15::{SigningKey, VerifyingKey},
|
||||
pkcs8::{
|
||||
DecodePrivateKey, DecodePublicKey, EncodePublicKey as _, EncodePrivateKey as _,
|
||||
LineEnding,
|
||||
},
|
||||
sha2::{self, Sha256},
|
||||
RsaPrivateKey,
|
||||
};
|
||||
|
||||
use base64::prelude::*;
|
||||
|
||||
/// 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()
|
||||
}
|
||||
}
|
||||
|
||||
/// A key that can be used to verify signatures.
|
||||
#[derive(Clone)]
|
||||
pub struct Public(rsa::RsaPublicKey);
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
///
|
||||
/// 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);
|
||||
|
||||
/// `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");
|
||||
|
||||
/// A signature derived from an [`http::Request`].
|
||||
pub struct Signature<'k> {
|
||||
key_id: &'k str,
|
||||
alg: Algorithm,
|
||||
components: Vec<Component>,
|
||||
created: String,
|
||||
expires: String,
|
||||
signing_string: Cow<'k, str>,
|
||||
signature_encoded: String,
|
||||
}
|
||||
|
||||
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<T>(req: &Request<T>) -> Result<Vec<Component>, &'static str> {
|
||||
let target = {
|
||||
let method = req.method().as_str().to_lowercase();
|
||||
let path = req.uri().path();
|
||||
format!("{method} {path}")
|
||||
};
|
||||
|
||||
let created = Utc::now();
|
||||
let expires = created + TimeDelta::minutes(5);
|
||||
|
||||
let mut components = vec![
|
||||
("(request-target)", target),
|
||||
("(created)", created.timestamp().to_string()),
|
||||
("(expires)", expires.timestamp().to_string()),
|
||||
("host", req.uri().host().unwrap().to_owned()),
|
||||
];
|
||||
|
||||
if let Method::POST | Method::PUT | Method::PATCH = *req.method() {
|
||||
let digest = req
|
||||
.headers()
|
||||
.get("digest")
|
||||
.map(|v| v.to_str().unwrap().to_string())
|
||||
.ok_or("digest header is required for POST, PUT and PATCH requests")?;
|
||||
components.push(("digest", digest));
|
||||
}
|
||||
|
||||
Ok(components)
|
||||
}
|
||||
|
||||
fn make_signing_string(pieces: &[Component]) -> String {
|
||||
pieces
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{k}: {v}"))
|
||||
.intersperse("\n".to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
type Component = (&'static str, String);
|
||||
|
||||
/// Sign the `signing_string`.
|
||||
fn create<'s>(
|
||||
signing_string: String,
|
||||
alg: Algorithm,
|
||||
key: &Private,
|
||||
key_url: &'s str,
|
||||
components: Vec<Component>,
|
||||
) -> Result<Signature<'s>, String> {
|
||||
let created = components
|
||||
.iter()
|
||||
.find_map(|(k, v)| (*k == "(created)").then_some(v))
|
||||
.cloned()
|
||||
.unwrap();
|
||||
let expires = components
|
||||
.iter()
|
||||
.find_map(|(k, v)| (*k == "(expires)").then_some(v))
|
||||
.cloned()
|
||||
.unwrap();
|
||||
// Regardless of the algorithm, we produce RSA-SHA256 signatures, because this is broadly compatible
|
||||
// with everything.
|
||||
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,
|
||||
expires,
|
||||
alg,
|
||||
})
|
||||
}
|
||||
|
||||
/// `rsa-sha256` is created using an rsa key and a sha256 hash.
|
||||
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)
|
||||
}
|
||||
|
||||
/// Format the signature.
|
||||
fn render(sig: Signature<'_>) -> HeaderValue {
|
||||
let headers = sig
|
||||
.components
|
||||
.iter()
|
||||
// We only need the header names.
|
||||
.map(|(name, _)| name.as_ref())
|
||||
// Names need to be space-separated.
|
||||
.intersperse(" ")
|
||||
// Equivalent to `fold ["a", "b"]` in haskell
|
||||
.collect::<String>();
|
||||
|
||||
#[rustfmt::skip]
|
||||
let data = [
|
||||
("keyId", sig.key_id),
|
||||
("created", &sig.created),
|
||||
("expires", &sig.expires),
|
||||
("algorithm", sig.alg.0),
|
||||
("headers", &headers),
|
||||
("signature", &sig.signature_encoded),
|
||||
];
|
||||
|
||||
// Ok, now let's put it all together.
|
||||
data.into_iter()
|
||||
// Step 1: all the values need to be surrounded by quotes
|
||||
.map(|(k, v)| (k, format!(r#""{v}""#)))
|
||||
// Step 2. join each pair together
|
||||
.map(|(k, v)| format!("{k}={v}"))
|
||||
// Step 3. comma separate everything
|
||||
.intersperse(", ".to_string())
|
||||
// Step 4. fold the entire thing into one
|
||||
.collect::<String>()
|
||||
// Then, it needs to become a header value
|
||||
.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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
449
lib/fetch/src/signatures.rs
Normal file
449
lib/fetch/src/signatures.rs
Normal file
|
@ -0,0 +1,449 @@
|
|||
//! 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 std::{borrow::Cow, collections::HashMap, path::Path};
|
||||
|
||||
use chrono::{DateTime, TimeDelta, Utc};
|
||||
use http::{HeaderValue, Method, Request};
|
||||
use rsa::{
|
||||
pkcs1v15::VerifyingKey,
|
||||
pkcs8::{
|
||||
DecodePrivateKey, DecodePublicKey, EncodePrivateKey as _, EncodePublicKey as _, LineEnding,
|
||||
},
|
||||
sha2::Sha256,
|
||||
signature::{SignatureEncoding as _, Signer as _, Verifier as _},
|
||||
RsaPrivateKey,
|
||||
};
|
||||
|
||||
use base64::prelude::*;
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
/// 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, which should equal the `keyId` part of a signature.
|
||||
pub 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: &Map<String, Value>) -> Option<Key> {
|
||||
Some(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 {
|
||||
id: self.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 {
|
||||
id: self.id,
|
||||
owner: self.owner,
|
||||
inner: public_key_pem,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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()
|
||||
}
|
||||
}
|
||||
|
||||
/// A key that can be used to verify signatures.
|
||||
#[derive(Clone)]
|
||||
pub struct Public(rsa::RsaPublicKey);
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
impl 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.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 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.
|
||||
///
|
||||
/// 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);
|
||||
|
||||
/// `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");
|
||||
|
||||
/// A signature derived from an [`http::Request`].
|
||||
pub struct Signature<'k> {
|
||||
key_id: &'k str,
|
||||
alg: Algorithm,
|
||||
components: Vec<Component>,
|
||||
created: String,
|
||||
expires: String,
|
||||
signing_string: Cow<'k, str>,
|
||||
signature_encoded: String,
|
||||
}
|
||||
|
||||
impl Signature<'_> {
|
||||
/// Attempt to extract a signature from a request.
|
||||
pub fn derive<T>(req: &Request<T>) -> Result<Signature<'_>, String> {
|
||||
parse(req)
|
||||
}
|
||||
/// 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
|
||||
}
|
||||
/// Attach `self` to `req` as the `signature` header.
|
||||
pub fn attach_to<T>(self, req: &mut Request<T>) {
|
||||
req.headers_mut().insert("signature", self.make_header());
|
||||
}
|
||||
/// Turn the signature into an HTTP header value.
|
||||
fn make_header(self) -> HeaderValue {
|
||||
render(self)
|
||||
}
|
||||
}
|
||||
|
||||
/// Gather all the bits from a `Request`.
|
||||
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();
|
||||
format!("{method} {path}")
|
||||
};
|
||||
|
||||
let created = Utc::now();
|
||||
let expires = created + TimeDelta::minutes(5);
|
||||
|
||||
let mut components = vec![
|
||||
("(request-target)", target),
|
||||
("(created)", created.timestamp().to_string()),
|
||||
("(expires)", expires.timestamp().to_string()),
|
||||
("host", req.uri().host().unwrap().to_owned()),
|
||||
];
|
||||
|
||||
if let Method::POST | Method::PUT | Method::PATCH = *req.method() {
|
||||
let digest = req
|
||||
.headers()
|
||||
.get("digest")
|
||||
.map(|v| v.to_str().unwrap().to_string())
|
||||
.ok_or("digest header is required for POST, PUT and PATCH requests")?;
|
||||
components.push(("digest", digest));
|
||||
}
|
||||
|
||||
Ok(components)
|
||||
}
|
||||
|
||||
fn make_signing_string(pieces: &[Component]) -> String {
|
||||
pieces
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{k}: {v}"))
|
||||
.intersperse("\n".to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
type Component = (&'static str, String);
|
||||
|
||||
/// Sign the `signing_string`.
|
||||
fn create<'s>(
|
||||
signing_string: String,
|
||||
alg: Algorithm,
|
||||
key: &Private,
|
||||
key_url: &'s str,
|
||||
components: Vec<Component>,
|
||||
) -> Result<Signature<'s>, String> {
|
||||
let created = components
|
||||
.iter()
|
||||
.find_map(|(k, v)| (*k == "(created)").then_some(v))
|
||||
.cloned()
|
||||
.unwrap();
|
||||
let expires = components
|
||||
.iter()
|
||||
.find_map(|(k, v)| (*k == "(expires)").then_some(v))
|
||||
.cloned()
|
||||
.unwrap();
|
||||
// Regardless of the algorithm, we produce RSA-SHA256 signatures, because this is broadly compatible
|
||||
// with everything.
|
||||
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,
|
||||
expires,
|
||||
alg,
|
||||
})
|
||||
}
|
||||
|
||||
/// `rsa-sha256` is created using an rsa key and a sha256 hash.
|
||||
fn sign_rsa_sha256(signing_string: &str, key: &Private) -> Result<Vec<u8>, String> {
|
||||
use rsa::pkcs1v15::SigningKey;
|
||||
let Private(rsa) = key.clone();
|
||||
let key = SigningKey::<Sha256>::new(rsa);
|
||||
let buf = key.sign(signing_string.as_bytes()).to_vec();
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
/// Format the signature.
|
||||
fn render(sig: Signature<'_>) -> HeaderValue {
|
||||
let headers = sig
|
||||
.components
|
||||
.iter()
|
||||
// We only need the header names.
|
||||
.map(|(name, _)| name.as_ref())
|
||||
// Names need to be space-separated.
|
||||
.intersperse(" ")
|
||||
// Equivalent to `fold ["a", "b"]` in haskell
|
||||
.collect::<String>();
|
||||
|
||||
#[rustfmt::skip]
|
||||
let data = [
|
||||
("keyId", sig.key_id),
|
||||
("created", &sig.created),
|
||||
("expires", &sig.expires),
|
||||
("algorithm", sig.alg.0),
|
||||
("headers", &headers),
|
||||
("signature", &sig.signature_encoded),
|
||||
];
|
||||
|
||||
// Ok, now let's put it all together.
|
||||
data.into_iter()
|
||||
// Step 1: all the values need to be surrounded by quotes
|
||||
.map(|(k, v)| (k, format!(r#""{v}""#)))
|
||||
// Step 2. join each pair together
|
||||
.map(|(k, v)| format!("{k}={v}"))
|
||||
// Step 3. comma separate everything
|
||||
.intersperse(", ".to_string())
|
||||
// Step 4. fold the entire thing into one
|
||||
.collect::<String>()
|
||||
// Then, it needs to become a header value
|
||||
.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,
|
||||
})
|
||||
}
|
|
@ -54,7 +54,7 @@ pub fn get_local_ap_object(cx: &Context<'_>, key: Key) -> Result<fetch::Object>
|
|||
display_name,
|
||||
public_key: fetch::Key {
|
||||
owner: obj.id.0.into(),
|
||||
key_id: key_id.into(),
|
||||
id: key_id.into(),
|
||||
inner: key_pem,
|
||||
},
|
||||
}))
|
||||
|
@ -217,7 +217,7 @@ pub mod auth {
|
|||
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(),
|
||||
id: json["publicKey"]["id"].as_str().unwrap().to_string().into(),
|
||||
owner: json["publicKey"]["owner"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
|
@ -233,7 +233,7 @@ pub mod auth {
|
|||
|
||||
pub fn signing_key(&self) -> fetch::SigningKey {
|
||||
fetch::Key {
|
||||
key_id: self.key_id.clone(),
|
||||
id: self.key_id.clone(),
|
||||
owner: self.actor_id.clone(),
|
||||
inner: self.private.clone(),
|
||||
}
|
||||
|
|
|
@ -38,7 +38,7 @@ impl Post {
|
|||
};
|
||||
let (private, public) = cx.db.get_mixin_many::<(PrivateKey, PublicKey)>(author)?;
|
||||
let key = fetch::SigningKey {
|
||||
key_id: public.key_id,
|
||||
id: public.key_id,
|
||||
owner: author_id.0,
|
||||
inner: Private::decode_pem(&private.key_pem),
|
||||
};
|
||||
|
|
Loading…
Reference in a new issue