[wip] YET MORE signatures cleanup and fixing

This commit is contained in:
Riley Apeldoorn 2024-04-29 23:36:57 +02:00
parent 9eaad3d7bb
commit fc4e4595c2
5 changed files with 180 additions and 230 deletions

12
Cargo.lock generated
View file

@ -485,11 +485,13 @@ dependencies = [
"derive_more",
"http",
"http-body-util",
"pem",
"rand",
"reqwest",
"rsa",
"serde_json",
"sigh",
"spki",
]
[[package]]
@ -1128,6 +1130,16 @@ dependencies = [
"windows-targets 0.48.5",
]
[[package]]
name = "pem"
version = "3.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e459365e590736a54c3fa561947c84837534b8e9af6fc5bf781307e82658fae"
dependencies = [
"base64 0.22.0",
"serde",
]
[[package]]
name = "pem-rfc7468"
version = "0.7.0"

View file

@ -14,5 +14,7 @@ http = "*"
chrono = "*"
base64 = "*"
rsa = { version = "*", features = ["sha2"] }
spki = "*"
http-body-util = "*"
rand = "*"
pem = "*"

View file

@ -1,4 +1,4 @@
#![feature(iter_intersperse, yeet_expr, iterator_try_collect)]
#![feature(iter_intersperse, yeet_expr, iterator_try_collect, try_blocks)]
use chrono::Utc;
use http_body_util::BodyExt as _;
use reqwest::Body;
@ -74,44 +74,51 @@ pub async fn deliver(key: &SigningKey, activity: Activity, inbox: &str) -> () {
todo!()
}
// Sun, 06 Nov 1994 08:49:37 GMT
const RFC_822: &str = "%a, %d %b %Y %H:%M:%S GMT";
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.
println!("[resolver]: resolving url {target} using key {}", key.id);
// Sun, 06 Nov 1994 08:49:37 GMT
const RFC_822: &str = "%a, %d %b %Y %H:%M:%S GMT";
let uri = target.parse::<http::Uri>().unwrap();
let host = uri.host().unwrap();
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)
.header("host", host)
// Empty body
.body(())
.unwrap();
// hs2019 works with masto, but (currently) our implementation is limited so that we cannot use those.
key.sign(Options::LEGACY, &req)
.expect("signing error")
.commit(&mut req);
println!("[resolver]: constructed request {req:#?}");
// hs2019 works with masto
let sig = key.sign(Options::MODERN, &req).expect("signing error");
println!("[resolver]: constructed signature {sig:#?}");
sig.commit(&mut req);
reqwest::Client::new()
.execute(req.map(|_| Body::default()).try_into().unwrap())
.await?
.error_for_status()?
.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 uri = target.parse::<http::Uri>().unwrap();
let host = uri.host().unwrap();
let mut req = http::Request::get(target)
.header("accept", "application/activity+json")
.header("user-agent", "ActivityPuppy/0.0.0 (resolver)")
.header("date", date)
.header("host", host)
// Empty body
.body(())
.unwrap();

View file

@ -23,12 +23,11 @@
//! - <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 std::path::Path;
use chrono::{DateTime, TimeDelta, Utc};
use http::{HeaderValue, Method, Request};
use http::{HeaderValue, Request};
use rsa::{
pkcs1v15::VerifyingKey,
pkcs8::{
DecodePrivateKey, DecodePublicKey, EncodePrivateKey as _, EncodePublicKey as _, LineEnding,
},
@ -37,12 +36,9 @@ use rsa::{
RsaPrivateKey,
};
use base64::prelude::*;
use serde_json::{Map, Value};
use crate::signatures::new::decode;
use self::new::{format_target, IR};
use self::new::{decode, encode, sha256, IR};
/// A key that can be used to verify a request signature.
pub type VerificationKey = Key<Public>;
@ -74,15 +70,15 @@ impl Key {
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)
json.get("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)?,
id: map.get("id")?.as_str().map(str::to_owned)?,
owner: map.get("owner")?.as_str().map(str::to_owned)?,
inner: map.get("publicKeyPem")?.as_str().map(str::to_owned)?,
})
}
/// "Upgrade" a pem-encoded public key to a key that can actually be used for requests.
@ -103,12 +99,22 @@ pub struct Private(rsa::RsaPrivateKey);
impl Private {
/// Generate a new keypair.
pub fn gen() -> (Private, Public) {
println!("[!] gen");
let mut rng = rand::thread_rng();
let bits = 4096;
let bits = 512;
let private = RsaPrivateKey::new(&mut rng, bits).unwrap();
let public = private.to_public_key();
(Private(private), Public(public))
}
pub fn tee(path: impl AsRef<Path>) -> (Private, Public) {
println!("[!] tee");
let (a, b) = Private::gen();
println!("[!] keygen complete");
a.0.write_pkcs8_pem_file(path, LineEnding::default())
.unwrap();
println!("[!] write finished");
(a, b)
}
/// Get the public counterpart to this key.
pub fn get_public(&self) -> Public {
Public(self.0.to_public_key())
@ -149,19 +155,17 @@ impl Public {
}
/// 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)
let doc = pem::parse(pkcs8_pem).unwrap();
<rsa::RsaPublicKey as DecodePublicKey>::from_public_key_der(doc.contents())
.map(Public)
.unwrap()
}
}
impl SigningKey {
/// Create a signature for `req` using the given algorithm, for `GET` requests.
pub fn sign(&self, opt: Options, req: &Request<()>) -> Result<Signature, String> {
let target = format_target(&req, opt);
IR::partial(&req, &target, opt)?
.signed(self)?
.to_signature()
/// Create a signature for `req` using the given options.
pub fn sign<T>(&self, opt: Options, req: &Request<T>) -> Result<Signature, String> {
IR::partial(&req, opt, |ir| ir.signed(self)?.to_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).
@ -175,7 +179,12 @@ impl SigningKey {
where
T: AsRef<[u8]>,
{
todo!()
// Calculate and insert digest if it isn't there yet, otherwise do nothing.
let digest = encode(sha256(req.body()));
req.headers_mut()
.entry("digest")
.or_insert_with(|| digest.try_into().unwrap());
self.sign(opt, req)
}
}
@ -227,6 +236,7 @@ pub const HS2019: Algorithm = Algorithm("hs2019");
pub const RSA_SHA256: Algorithm = Algorithm("rsa-sha256");
/// A signature derived from an [`http::Request`].
#[derive(Debug)]
pub struct Signature {
key_id: String,
components: Vec<(String, String)>,
@ -240,9 +250,7 @@ pub struct Signature {
impl Signature {
/// Attempt to extract a signature from a request.
pub fn derive<T>(req: &Request<T>) -> Result<Signature, String> {
let target = format_target(&req, Options::MODERN);
let ir = IR::<&str>::from_request(req, target.as_ref())?;
ir.to_signature()
new::with_ir(req, Options::MODERN, |ir| ir.to_signature())
}
/// Obtain the key id for the signature.
pub fn key_id(&self) -> &str {
@ -363,14 +371,11 @@ impl Default for Options {
mod new {
use base64::prelude::*;
use chrono::{TimeDelta, Utc};
use http::{HeaderName, HeaderValue, Method, Request};
use chrono::{DateTime, Utc};
use http::{Method, Request};
use rsa::sha2::{Digest, Sha256};
use super::{
sign_rsa_sha256, Algorithm, Options, Signature, SigningKey, EXPIRY_WINDOW, HS2019,
RSA_SHA256,
};
use super::{sign_rsa_sha256, Options, Signature, SigningKey, EXPIRY_WINDOW, HS2019, RSA_SHA256};
/// Calculate the SHA256 hash of something.
pub fn sha256(buf: impl AsRef<[u8]>) -> Vec<u8> {
@ -387,143 +392,6 @@ mod new {
BASE64_STANDARD.decode(buf).map_err(|e| e.to_string())
}
/// Generate signature headers for a request with a body.
fn headers_with_digest<T>(
req: &Request<T>,
key: &SigningKey,
opt: Options,
) -> Result<Vec<(HeaderName, HeaderValue)>, String>
where
T: AsRef<[u8]>,
{
let digest = {
let encoded = encode(sha256(req.body()));
format!("sha256={encoded}")
};
gen_headers(req, key, opt, vec![("digest", digest)], true)
}
fn gen_headers<T>(
req: &Request<T>,
key: &SigningKey,
opt: Options,
extra: Vec<(&str, String)>,
use_digest: bool,
) -> Result<Vec<(HeaderName, HeaderValue)>, String> {
// We need to add a date header is one doesn't already exist.
const RFC_822: &str = "%a, %d %b %Y %H:%M:%S GMT";
let date = Utc::now().format(RFC_822).to_string();
let date = (!(opt.use_created || req.headers().contains_key("date")))
.then_some(("date", date.as_str()));
// QUIRK: some older mastodon versions don't sign the query params.
let path = if opt.strip_query {
req.uri().path()
} else {
req.uri()
.path_and_query()
.map(|s| s.as_str())
.unwrap_or(req.uri().path())
};
// Preprocess the headers into the format expected by the signing functions.
// NOTE: only the first value of each key is ever used.
let header_map: Vec<(&str, &str)> = make_header_map(&req)
.into_iter()
// The "uncommitted" headers need to be added as well
.chain(extra.iter().map(|(k, v)| (*k, v.as_str())))
// And at last, our date header too, if we're adding one.
.chain(date)
.collect();
let signature_header = make_signature_header(
key,
opt.algorithm,
// We only use the expiry deadline if we're using the `(created)` and `(expires)` inputs
// for signature generation.
opt.use_created.then_some(opt.expires_after),
use_digest,
&header_map,
req.method(),
path,
)?;
extra
.into_iter()
// Don't forget the signature :)
.chain([("signature", signature_header)])
// Turn all of the strings into header names/values.
.map(|(name, data)| -> Result<_, http::Error> {
let name = name.try_into()?;
let data = data.try_into()?;
Ok((name, data))
})
.try_collect()
.map_err(|e| format!("{e:?}"))
}
fn make_signature_header(
key: &SigningKey,
alg: Algorithm,
expires_after: Option<TimeDelta>,
use_digest: bool,
headers: &[(&str, &str)],
method: &Method,
path: &str,
) -> Result<String, String> {
// List of input names that we want. Pseudo-headers are ordered before normal headers.
let needed = ["(request-target)"]
.into_iter()
.chain(if expires_after.is_some() {
vec!["(created)", "(expired)"]
} else {
vec!["date"]
})
.chain(use_digest.then_some("digest"))
.chain(["host"]);
let created = Utc::now();
let expires = created + expires_after.unwrap_or(EXPIRY_WINDOW);
let target_str = format!("{} {path}", method.as_str().to_ascii_lowercase());
let created_str = created.timestamp().to_string();
let expires_str = expires.timestamp().to_string();
// Lookup table for pseudo-headers.
let pseudo = [
("(request-target)", target_str.as_str()),
("(created)", created_str.as_str()),
("(expired)", expires_str.as_str()),
];
let inputs = collect_inputs(needed, &headers, &pseudo)?;
let signing_str = make_signing_string(&inputs);
let signature = sign_rsa_sha256(&signing_str, &key.inner)?;
let encoded = BASE64_STANDARD.encode(signature);
Ok(format_header(&inputs, &key.id, alg.0, &encoded))
}
fn collect_inputs<'a>(
needed: impl IntoIterator<Item = &'a str> + 'a,
headers: &[(&'a str, &'a str)],
pseudo: &[(&'a str, &'a str)],
) -> Result<Vec<(&'a str, &'a str)>, String> {
// Quick utility function to get stuff from an association list.
fn get<'x>(map: &[(&str, &'x str)], key: &str) -> Option<&'x str> {
map.iter().find_map(|(k, v)| (*k == key).then_some(*v))
}
let assoc = |k| {
get(&headers, k)
.or_else(|| get(&pseudo, k))
.ok_or_else(|| format!("Missing (pseudo)header `{k}`"))
.map(|v| (k, v))
};
needed.into_iter().map(assoc).try_collect()
}
pub struct IR<'s, S = &'s str> {
target: &'s str,
inputs: Vec<(&'s str, &'s str)>,
@ -532,6 +400,21 @@ mod new {
sig: S,
}
/// Allocates a new [`IR`] for doing signature operations with.
pub fn with_ir<T, U>(
req: &Request<T>,
opt: Options,
f: impl FnOnce(IR<'_>) -> Result<U, String>,
) -> Result<U, String> {
let target = &format_target(&req, opt);
let map = make_header_map(&req);
let Some(header) = get(&map, "signature") else {
do yeet "Missing required `signature` header";
};
let (inputs, key, alg, sig) = parse_header(header, &target, &map)?;
f(IR { target, inputs, key, alg, sig })
}
impl<S> IR<'_, S> {
/// Create an HTTP header from the IR.
pub fn to_header(&self) -> String
@ -540,22 +423,31 @@ mod new {
{
format_header(&self.inputs, self.key.as_ref(), self.alg, self.sig.as_ref())
}
/// Given a *correct* target string, extract a signature header from a request.
pub fn from_request<'r, T>(req: &'r Request<T>, target: &'r str) -> Result<IR<'r>, String> {
let map = make_header_map(&req);
let Some(header) = get(&map, "signature") else {
do yeet "Missing required `signature` header";
};
let (inputs, key, alg, sig) = parse_header(header, &target, &map)?;
Ok(IR { target, inputs, key, alg, sig })
}
/// Validate and upgrade the IR to a structured signature.
pub fn to_signature(&self) -> Result<Signature, String>
where
S: ToString,
{
let created = Utc::now(); // TODO
let expires = Utc::now(); // TODO
let times: Result<_, String> = try {
let date = get(&self.inputs, "date").map(from_rfc822).transpose()?;
let created = get(&self.inputs, "(created)").map(from_secs).transpose()?;
let expires = get(&self.inputs, "(expires)").map(from_secs).transpose()?;
(date, created, expires)
};
let (date, created, expires) =
times.map_err(|e: String| format!("Failed to parse time: {e}"))?;
let (created, expires) = match (created, expires) {
(Some(created), None) => (created, created + EXPIRY_WINDOW),
(Some(created), Some(expires)) => (created, expires),
(None, _) => {
let Some(date) = date else {
do yeet "Cannot determine validity window";
};
(date, date + EXPIRY_WINDOW)
}
};
let algorithm = match self.alg {
"rsa-sha256" => RSA_SHA256,
"hs2019" => HS2019,
@ -599,15 +491,36 @@ mod new {
impl<'s> IR<'s, ()> {
/// Create a partial, unsigned IR.
pub fn partial<'r, T>(
pub fn partial<'r, T, U>(
req: &'r Request<T>,
target: &'r str,
opt: Options,
) -> Result<IR<'r, ()>, String> {
f: impl FnOnce(IR<'_, ()>) -> Result<U, String>,
) -> Result<U, String> {
let map = make_header_map(req);
let digest = req.method() == Method::POST;
let inputs = compute_inputs(&map, target, opt, digest)?;
Ok(IR {
let expires_after = opt.use_created.then_some(opt.expires_after);
let created = Utc::now();
let expires = created + expires_after.unwrap_or(EXPIRY_WINDOW);
let target = &format_target(&req, opt);
let created = created.timestamp().to_string();
let expires = expires.timestamp().to_string();
// Association list mapping pseudo headers names to concrete values.
#[rustfmt::skip]
let pseudo = &[
("(request-target)", target.as_str()),
("(created)", created.as_str()),
("(expired)", expires.as_str()),
];
let inputs = match compute_inputs(&map, pseudo, opt, digest) {
Err(error) => do yeet format!("computing inputs: {error}"),
Ok(inputs) => inputs,
};
f(IR {
target,
inputs,
alg: opt.algorithm.0,
@ -618,7 +531,10 @@ mod new {
/// Sign a partially constructed IR to make it actually useful.
pub fn signed(self, key: &'s SigningKey) -> Result<IR<'s, String>, String> {
let sig_str = self.to_signing_str();
let signature = sign_rsa_sha256(&sig_str, &key.inner).map(encode)?;
let signature = match sign_rsa_sha256(&sig_str, &key.inner).map(encode) {
Err(error) => do yeet format!("RSA error: {error}"),
Ok(signature) => signature,
};
Ok(IR {
target: self.target,
inputs: self.inputs,
@ -633,15 +549,14 @@ mod new {
/// turned into the signing string.
fn compute_inputs<'a>(
headers: &[(&'a str, &'a str)],
target: &'a str,
pseudo: &[(&'a str, &'a str)],
opt: Options,
use_digest: bool,
) -> Result<Vec<(&'a str, &'a str)>, String> {
let expires_after = opt.use_created.then_some(opt.expires_after);
// List of input names that we want. Pseudo-headers are ordered before normal headers.
let needed = ["(request-target)"]
.into_iter()
.chain(if expires_after.is_some() {
.chain(if opt.use_created {
vec!["(created)", "(expired)"]
} else {
vec!["date"]
@ -649,25 +564,18 @@ mod new {
.chain(use_digest.then_some("digest"))
.chain(["host"]);
let created = Utc::now();
let expires = created + expires_after.unwrap_or(EXPIRY_WINDOW);
let assoc = |k| {
get(headers, k)
.or_else(|| get(&pseudo, k))
.ok_or_else(|| format!("Missing (pseudo)header `{k}`"))
.map(|v| (k, v))
};
let created_str = created.timestamp().to_string();
let expires_str = expires.timestamp().to_string();
// Lookup table for pseudo-headers.
// TODO: currently, `(created)` and `(expires)` can't be used because they require string alloc.
let pseudo = [
("(request-target)", target),
// ("(created)", created_str.as_str()), FIXME
// ("(expired)", expires_str.as_str()), FIXME
];
collect_inputs(needed, &headers, &pseudo)
needed.map(assoc).try_collect()
}
/// Allocate a `(request-target)` buffer.
pub fn format_target<T>(req: &Request<T>, opt: Options) -> String {
fn format_target<T>(req: &Request<T>, opt: Options) -> String {
let path = if opt.strip_query {
req.uri().path()
} else {
@ -728,11 +636,16 @@ mod new {
// Split into entries
.split(",")
// Split entries into key-value pairs
.filter_map(|pair| pair.split_once('='))
.filter_map(|pair| {
pair.trim_end_matches(' ') // QUIRK: akkoma does not put a space between entries
.split_once('=')
})
// Undo quoting of the values
.filter_map(|(k, v)| v.strip_prefix('"')?.strip_suffix('"').map(|v| (k, v)))
.collect();
let table = dbg!(table);
let Some(headers) = get(&table, "headers") else {
do yeet "Missing `headers` field";
};
@ -795,4 +708,18 @@ mod new {
.intersperse("\n".to_string())
.collect()
}
fn from_secs(s: &str) -> Result<DateTime<Utc>, String> {
s.parse::<i64>().map_err(|e| e.to_string()).and_then(|t| {
let Some(time) = DateTime::from_timestamp(t, 0) else {
do yeet "Timestamp out of range";
};
Ok(time)
})
}
fn from_rfc822(s: &str) -> Result<DateTime<Utc>, String> {
DateTime::parse_from_rfc2822(s)
.map_err(|e| e.to_string())
.map(|time| time.to_utc())
}
}

View file

@ -215,20 +215,7 @@ pub mod auth {
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 {
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(),
})
Ok(fetch::Key::from_json(dbg!(json)).unwrap())
}
pub fn signing_key(&self) -> fetch::SigningKey {
@ -257,8 +244,10 @@ pub mod auth {
}
pub fn load(cfg: &Config) -> Verifier {
println!("[*] loading private key");
let domain = &cfg.ap_domain;
let private = Private::load(".state/fetcher.pem");
println!("* done loading private key");
Verifier {
actor_id: format!("https://{domain}/s/request-verifier"),
key_id: format!("https://{domain}/s/request-verifier#sig-key"),
@ -282,23 +271,32 @@ pub mod auth {
/// Check the signature for a request.
pub async fn verify_signature(
req: &fetch::http::Request<impl AsRef<[u8]>>,
req: &fetch::http::Request<impl AsRef<[u8]> + std::fmt::Debug>,
cfg: &Config,
) -> Result<Option<Signer>, SigError> {
println!(">>> starting signature verification for {req:#?}");
if req.uri().path() == "/s/request-verifier" {
// Allow access to the request verifier actor without checking the signature.
return Ok(None);
}
println!(">>> not going for the verifier!");
if req.headers().get("signature").is_none() {
// Request is not signed!
return Ok(None);
};
println!(">>> has signature");
// Parse the signature.
let sig = match Signature::derive(&req) {
Ok(signature) => signature,
Err(error) => return Err(SigError::ParseSignature { error }),
Err(error) => {
println!(">>> signature could not be parsed: {error}");
return Err(SigError::ParseSignature { error });
}
};
println!(">>> signature is syntatically valid");
// Fetch the public key using the verifier private key.
let verifier = Verifier::load(cfg);
@ -307,12 +305,16 @@ pub mod auth {
keyid: sig.key_id().to_string(),
});
};
println!(">>> public key fetched");
// Verify the signature header on the request.
let public_key = public_key.upgrade();
println!(">>> upgraded");
if let Err(error) = public_key.verify(&sig) {
println!(">>> rejected");
Err(SigError::VerificationFailed { error })
} else {
println!(">>> request verified");
Ok(Some(Signer { ap_id: public_key.owner.into() }))
}
}