[wip] YET MORE signatures cleanup and fixing
This commit is contained in:
parent
9eaad3d7bb
commit
fc4e4595c2
5 changed files with 180 additions and 230 deletions
12
Cargo.lock
generated
12
Cargo.lock
generated
|
@ -485,11 +485,13 @@ dependencies = [
|
||||||
"derive_more",
|
"derive_more",
|
||||||
"http",
|
"http",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
|
"pem",
|
||||||
"rand",
|
"rand",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"rsa",
|
"rsa",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sigh",
|
"sigh",
|
||||||
|
"spki",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -1128,6 +1130,16 @@ dependencies = [
|
||||||
"windows-targets 0.48.5",
|
"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]]
|
[[package]]
|
||||||
name = "pem-rfc7468"
|
name = "pem-rfc7468"
|
||||||
version = "0.7.0"
|
version = "0.7.0"
|
||||||
|
|
|
@ -14,5 +14,7 @@ http = "*"
|
||||||
chrono = "*"
|
chrono = "*"
|
||||||
base64 = "*"
|
base64 = "*"
|
||||||
rsa = { version = "*", features = ["sha2"] }
|
rsa = { version = "*", features = ["sha2"] }
|
||||||
|
spki = "*"
|
||||||
http-body-util = "*"
|
http-body-util = "*"
|
||||||
rand = "*"
|
rand = "*"
|
||||||
|
pem = "*"
|
||||||
|
|
|
@ -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 chrono::Utc;
|
||||||
use http_body_util::BodyExt as _;
|
use http_body_util::BodyExt as _;
|
||||||
use reqwest::Body;
|
use reqwest::Body;
|
||||||
|
@ -74,44 +74,51 @@ pub async fn deliver(key: &SigningKey, activity: Activity, inbox: &str) -> () {
|
||||||
todo!()
|
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> {
|
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
|
// TODO: make this retry with different signature options and remember what works for the
|
||||||
// particular host.
|
// particular host.
|
||||||
|
println!("[resolver]: resolving url {target} using key {}", key.id);
|
||||||
|
|
||||||
// Sun, 06 Nov 1994 08:49:37 GMT
|
let uri = target.parse::<http::Uri>().unwrap();
|
||||||
const RFC_822: &str = "%a, %d %b %Y %H:%M:%S GMT";
|
let host = uri.host().unwrap();
|
||||||
|
|
||||||
let date = Utc::now().format(RFC_822).to_string();
|
let date = Utc::now().format(RFC_822).to_string();
|
||||||
let mut req = http::Request::builder()
|
let mut req = http::Request::builder()
|
||||||
.uri(target)
|
.uri(target)
|
||||||
.header("accept", "application/activity+json")
|
.header("accept", "application/activity+json")
|
||||||
.header("user-agent", "ActivityPuppy/0.0.0 (resolver)")
|
.header("user-agent", "ActivityPuppy/0.0.0 (resolver)")
|
||||||
.header("date", date)
|
.header("date", date)
|
||||||
|
.header("host", host)
|
||||||
// Empty body
|
// Empty body
|
||||||
.body(())
|
.body(())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
// hs2019 works with masto, but (currently) our implementation is limited so that we cannot use those.
|
println!("[resolver]: constructed request {req:#?}");
|
||||||
key.sign(Options::LEGACY, &req)
|
|
||||||
.expect("signing error")
|
// hs2019 works with masto
|
||||||
.commit(&mut req);
|
let sig = key.sign(Options::MODERN, &req).expect("signing error");
|
||||||
|
|
||||||
|
println!("[resolver]: constructed signature {sig:#?}");
|
||||||
|
sig.commit(&mut req);
|
||||||
|
|
||||||
reqwest::Client::new()
|
reqwest::Client::new()
|
||||||
.execute(req.map(|_| Body::default()).try_into().unwrap())
|
.execute(req.map(|_| Body::default()).try_into().unwrap())
|
||||||
.await?
|
.await?
|
||||||
|
.error_for_status()?
|
||||||
.json()
|
.json()
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn forward(key: &SigningKey, target: &str) -> reqwest::Result<http::Response<String>> {
|
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 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)
|
let mut req = http::Request::get(target)
|
||||||
.header("accept", "application/activity+json")
|
.header("accept", "application/activity+json")
|
||||||
.header("user-agent", "ActivityPuppy/0.0.0 (resolver)")
|
.header("user-agent", "ActivityPuppy/0.0.0 (resolver)")
|
||||||
.header("date", date)
|
.header("date", date)
|
||||||
|
.header("host", host)
|
||||||
// Empty body
|
// Empty body
|
||||||
.body(())
|
.body(())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
|
@ -23,12 +23,11 @@
|
||||||
//! - <https://swicg.github.io/activitypub-http-signature>
|
//! - <https://swicg.github.io/activitypub-http-signature>
|
||||||
//! - <https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures>
|
//! - <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 chrono::{DateTime, TimeDelta, Utc};
|
||||||
use http::{HeaderValue, Method, Request};
|
use http::{HeaderValue, Request};
|
||||||
use rsa::{
|
use rsa::{
|
||||||
pkcs1v15::VerifyingKey,
|
|
||||||
pkcs8::{
|
pkcs8::{
|
||||||
DecodePrivateKey, DecodePublicKey, EncodePrivateKey as _, EncodePublicKey as _, LineEnding,
|
DecodePrivateKey, DecodePublicKey, EncodePrivateKey as _, EncodePublicKey as _, LineEnding,
|
||||||
},
|
},
|
||||||
|
@ -37,12 +36,9 @@ use rsa::{
|
||||||
RsaPrivateKey,
|
RsaPrivateKey,
|
||||||
};
|
};
|
||||||
|
|
||||||
use base64::prelude::*;
|
|
||||||
use serde_json::{Map, Value};
|
use serde_json::{Map, Value};
|
||||||
|
|
||||||
use crate::signatures::new::decode;
|
use self::new::{decode, encode, sha256, IR};
|
||||||
|
|
||||||
use self::new::{format_target, IR};
|
|
||||||
|
|
||||||
/// A key that can be used to verify a request signature.
|
/// A key that can be used to verify a request signature.
|
||||||
pub type VerificationKey = Key<Public>;
|
pub type VerificationKey = Key<Public>;
|
||||||
|
@ -74,15 +70,15 @@ impl Key {
|
||||||
json.as_object().and_then(Key::from_map).or_else(|| {
|
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
|
// 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.
|
// 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
|
/// Construct
|
||||||
fn from_map(map: &Map<String, Value>) -> Option<Key> {
|
fn from_map(map: &Map<String, Value>) -> Option<Key> {
|
||||||
Some(Key {
|
Some(Key {
|
||||||
id: map["id"].as_str().map(str::to_owned)?,
|
id: map.get("id")?.as_str().map(str::to_owned)?,
|
||||||
owner: map["owner"].as_str().map(str::to_owned)?,
|
owner: map.get("owner")?.as_str().map(str::to_owned)?,
|
||||||
inner: map["publicKeyPem"].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.
|
/// "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 {
|
impl Private {
|
||||||
/// Generate a new keypair.
|
/// Generate a new keypair.
|
||||||
pub fn gen() -> (Private, Public) {
|
pub fn gen() -> (Private, Public) {
|
||||||
|
println!("[!] gen");
|
||||||
let mut rng = rand::thread_rng();
|
let mut rng = rand::thread_rng();
|
||||||
let bits = 4096;
|
let bits = 512;
|
||||||
let private = RsaPrivateKey::new(&mut rng, bits).unwrap();
|
let private = RsaPrivateKey::new(&mut rng, bits).unwrap();
|
||||||
let public = private.to_public_key();
|
let public = private.to_public_key();
|
||||||
(Private(private), Public(public))
|
(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.
|
/// Get the public counterpart to this key.
|
||||||
pub fn get_public(&self) -> Public {
|
pub fn get_public(&self) -> Public {
|
||||||
Public(self.0.to_public_key())
|
Public(self.0.to_public_key())
|
||||||
|
@ -149,19 +155,17 @@ impl Public {
|
||||||
}
|
}
|
||||||
/// Decode a PKCS#8 PEM-encoded public key from a string.
|
/// Decode a PKCS#8 PEM-encoded public key from a string.
|
||||||
pub fn decode_pem(pkcs8_pem: &str) -> Public {
|
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)
|
.map(Public)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SigningKey {
|
impl SigningKey {
|
||||||
/// Create a signature for `req` using the given algorithm, for `GET` requests.
|
/// Create a signature for `req` using the given options.
|
||||||
pub fn sign(&self, opt: Options, req: &Request<()>) -> Result<Signature, String> {
|
pub fn sign<T>(&self, opt: Options, req: &Request<T>) -> Result<Signature, String> {
|
||||||
let target = format_target(&req, opt);
|
IR::partial(&req, opt, |ir| ir.signed(self)?.to_signature())
|
||||||
IR::partial(&req, &target, opt)?
|
|
||||||
.signed(self)?
|
|
||||||
.to_signature()
|
|
||||||
}
|
}
|
||||||
/// Create a signature for `req` using the given algorithm, and calculate and attach the `digest` header to
|
/// 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).
|
/// the request (if it doesn't already have one).
|
||||||
|
@ -175,7 +179,12 @@ impl SigningKey {
|
||||||
where
|
where
|
||||||
T: AsRef<[u8]>,
|
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");
|
pub const RSA_SHA256: Algorithm = Algorithm("rsa-sha256");
|
||||||
|
|
||||||
/// A signature derived from an [`http::Request`].
|
/// A signature derived from an [`http::Request`].
|
||||||
|
#[derive(Debug)]
|
||||||
pub struct Signature {
|
pub struct Signature {
|
||||||
key_id: String,
|
key_id: String,
|
||||||
components: Vec<(String, String)>,
|
components: Vec<(String, String)>,
|
||||||
|
@ -240,9 +250,7 @@ pub struct Signature {
|
||||||
impl Signature {
|
impl Signature {
|
||||||
/// Attempt to extract a signature from a request.
|
/// Attempt to extract a signature from a request.
|
||||||
pub fn derive<T>(req: &Request<T>) -> Result<Signature, String> {
|
pub fn derive<T>(req: &Request<T>) -> Result<Signature, String> {
|
||||||
let target = format_target(&req, Options::MODERN);
|
new::with_ir(req, Options::MODERN, |ir| ir.to_signature())
|
||||||
let ir = IR::<&str>::from_request(req, target.as_ref())?;
|
|
||||||
ir.to_signature()
|
|
||||||
}
|
}
|
||||||
/// Obtain the key id for the signature.
|
/// Obtain the key id for the signature.
|
||||||
pub fn key_id(&self) -> &str {
|
pub fn key_id(&self) -> &str {
|
||||||
|
@ -363,14 +371,11 @@ impl Default for Options {
|
||||||
|
|
||||||
mod new {
|
mod new {
|
||||||
use base64::prelude::*;
|
use base64::prelude::*;
|
||||||
use chrono::{TimeDelta, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use http::{HeaderName, HeaderValue, Method, Request};
|
use http::{Method, Request};
|
||||||
use rsa::sha2::{Digest, Sha256};
|
use rsa::sha2::{Digest, Sha256};
|
||||||
|
|
||||||
use super::{
|
use super::{sign_rsa_sha256, Options, Signature, SigningKey, EXPIRY_WINDOW, HS2019, RSA_SHA256};
|
||||||
sign_rsa_sha256, Algorithm, Options, Signature, SigningKey, EXPIRY_WINDOW, HS2019,
|
|
||||||
RSA_SHA256,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Calculate the SHA256 hash of something.
|
/// Calculate the SHA256 hash of something.
|
||||||
pub fn sha256(buf: impl AsRef<[u8]>) -> Vec<u8> {
|
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())
|
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> {
|
pub struct IR<'s, S = &'s str> {
|
||||||
target: &'s str,
|
target: &'s str,
|
||||||
inputs: Vec<(&'s str, &'s str)>,
|
inputs: Vec<(&'s str, &'s str)>,
|
||||||
|
@ -532,6 +400,21 @@ mod new {
|
||||||
sig: S,
|
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> {
|
impl<S> IR<'_, S> {
|
||||||
/// Create an HTTP header from the IR.
|
/// Create an HTTP header from the IR.
|
||||||
pub fn to_header(&self) -> String
|
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())
|
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.
|
/// Validate and upgrade the IR to a structured signature.
|
||||||
pub fn to_signature(&self) -> Result<Signature, String>
|
pub fn to_signature(&self) -> Result<Signature, String>
|
||||||
where
|
where
|
||||||
S: ToString,
|
S: ToString,
|
||||||
{
|
{
|
||||||
let created = Utc::now(); // TODO
|
let times: Result<_, String> = try {
|
||||||
let expires = Utc::now(); // TODO
|
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 {
|
let algorithm = match self.alg {
|
||||||
"rsa-sha256" => RSA_SHA256,
|
"rsa-sha256" => RSA_SHA256,
|
||||||
"hs2019" => HS2019,
|
"hs2019" => HS2019,
|
||||||
|
@ -599,15 +491,36 @@ mod new {
|
||||||
|
|
||||||
impl<'s> IR<'s, ()> {
|
impl<'s> IR<'s, ()> {
|
||||||
/// Create a partial, unsigned IR.
|
/// Create a partial, unsigned IR.
|
||||||
pub fn partial<'r, T>(
|
pub fn partial<'r, T, U>(
|
||||||
req: &'r Request<T>,
|
req: &'r Request<T>,
|
||||||
target: &'r str,
|
|
||||||
opt: Options,
|
opt: Options,
|
||||||
) -> Result<IR<'r, ()>, String> {
|
f: impl FnOnce(IR<'_, ()>) -> Result<U, String>,
|
||||||
|
) -> Result<U, String> {
|
||||||
let map = make_header_map(req);
|
let map = make_header_map(req);
|
||||||
let digest = req.method() == Method::POST;
|
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,
|
target,
|
||||||
inputs,
|
inputs,
|
||||||
alg: opt.algorithm.0,
|
alg: opt.algorithm.0,
|
||||||
|
@ -618,7 +531,10 @@ mod new {
|
||||||
/// Sign a partially constructed IR to make it actually useful.
|
/// Sign a partially constructed IR to make it actually useful.
|
||||||
pub fn signed(self, key: &'s SigningKey) -> Result<IR<'s, String>, String> {
|
pub fn signed(self, key: &'s SigningKey) -> Result<IR<'s, String>, String> {
|
||||||
let sig_str = self.to_signing_str();
|
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 {
|
Ok(IR {
|
||||||
target: self.target,
|
target: self.target,
|
||||||
inputs: self.inputs,
|
inputs: self.inputs,
|
||||||
|
@ -633,15 +549,14 @@ mod new {
|
||||||
/// turned into the signing string.
|
/// turned into the signing string.
|
||||||
fn compute_inputs<'a>(
|
fn compute_inputs<'a>(
|
||||||
headers: &[(&'a str, &'a str)],
|
headers: &[(&'a str, &'a str)],
|
||||||
target: &'a str,
|
pseudo: &[(&'a str, &'a str)],
|
||||||
opt: Options,
|
opt: Options,
|
||||||
use_digest: bool,
|
use_digest: bool,
|
||||||
) -> Result<Vec<(&'a str, &'a str)>, String> {
|
) -> 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.
|
// List of input names that we want. Pseudo-headers are ordered before normal headers.
|
||||||
let needed = ["(request-target)"]
|
let needed = ["(request-target)"]
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.chain(if expires_after.is_some() {
|
.chain(if opt.use_created {
|
||||||
vec!["(created)", "(expired)"]
|
vec!["(created)", "(expired)"]
|
||||||
} else {
|
} else {
|
||||||
vec!["date"]
|
vec!["date"]
|
||||||
|
@ -649,25 +564,18 @@ mod new {
|
||||||
.chain(use_digest.then_some("digest"))
|
.chain(use_digest.then_some("digest"))
|
||||||
.chain(["host"]);
|
.chain(["host"]);
|
||||||
|
|
||||||
let created = Utc::now();
|
let assoc = |k| {
|
||||||
let expires = created + expires_after.unwrap_or(EXPIRY_WINDOW);
|
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();
|
needed.map(assoc).try_collect()
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Allocate a `(request-target)` buffer.
|
/// 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 {
|
let path = if opt.strip_query {
|
||||||
req.uri().path()
|
req.uri().path()
|
||||||
} else {
|
} else {
|
||||||
|
@ -728,11 +636,16 @@ mod new {
|
||||||
// Split into entries
|
// Split into entries
|
||||||
.split(",")
|
.split(",")
|
||||||
// Split entries into key-value pairs
|
// 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
|
// Undo quoting of the values
|
||||||
.filter_map(|(k, v)| v.strip_prefix('"')?.strip_suffix('"').map(|v| (k, v)))
|
.filter_map(|(k, v)| v.strip_prefix('"')?.strip_suffix('"').map(|v| (k, v)))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
let table = dbg!(table);
|
||||||
|
|
||||||
let Some(headers) = get(&table, "headers") else {
|
let Some(headers) = get(&table, "headers") else {
|
||||||
do yeet "Missing `headers` field";
|
do yeet "Missing `headers` field";
|
||||||
};
|
};
|
||||||
|
@ -795,4 +708,18 @@ mod new {
|
||||||
.intersperse("\n".to_string())
|
.intersperse("\n".to_string())
|
||||||
.collect()
|
.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())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -215,20 +215,7 @@ pub mod auth {
|
||||||
impl Verifier {
|
impl Verifier {
|
||||||
pub async fn get_public_key(&self, uri: &str) -> Result<fetch::Key, String> {
|
pub async fn get_public_key(&self, uri: &str) -> Result<fetch::Key, String> {
|
||||||
let json = fetch::resolve(&self.signing_key(), uri).await.unwrap();
|
let json = fetch::resolve(&self.signing_key(), uri).await.unwrap();
|
||||||
// TODO: make this parsing work better.
|
Ok(fetch::Key::from_json(dbg!(json)).unwrap())
|
||||||
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(),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn signing_key(&self) -> fetch::SigningKey {
|
pub fn signing_key(&self) -> fetch::SigningKey {
|
||||||
|
@ -257,8 +244,10 @@ pub mod auth {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn load(cfg: &Config) -> Verifier {
|
pub fn load(cfg: &Config) -> Verifier {
|
||||||
|
println!("[*] loading private key");
|
||||||
let domain = &cfg.ap_domain;
|
let domain = &cfg.ap_domain;
|
||||||
let private = Private::load(".state/fetcher.pem");
|
let private = Private::load(".state/fetcher.pem");
|
||||||
|
println!("* done loading private key");
|
||||||
Verifier {
|
Verifier {
|
||||||
actor_id: format!("https://{domain}/s/request-verifier"),
|
actor_id: format!("https://{domain}/s/request-verifier"),
|
||||||
key_id: format!("https://{domain}/s/request-verifier#sig-key"),
|
key_id: format!("https://{domain}/s/request-verifier#sig-key"),
|
||||||
|
@ -282,23 +271,32 @@ pub mod auth {
|
||||||
|
|
||||||
/// Check the signature for a request.
|
/// Check the signature for a request.
|
||||||
pub async fn verify_signature(
|
pub async fn verify_signature(
|
||||||
req: &fetch::http::Request<impl AsRef<[u8]>>,
|
req: &fetch::http::Request<impl AsRef<[u8]> + std::fmt::Debug>,
|
||||||
cfg: &Config,
|
cfg: &Config,
|
||||||
) -> Result<Option<Signer>, SigError> {
|
) -> Result<Option<Signer>, SigError> {
|
||||||
|
println!(">>> starting signature verification for {req:#?}");
|
||||||
|
|
||||||
if req.uri().path() == "/s/request-verifier" {
|
if req.uri().path() == "/s/request-verifier" {
|
||||||
// Allow access to the request verifier actor without checking the signature.
|
// Allow access to the request verifier actor without checking the signature.
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
|
println!(">>> not going for the verifier!");
|
||||||
|
|
||||||
if req.headers().get("signature").is_none() {
|
if req.headers().get("signature").is_none() {
|
||||||
// Request is not signed!
|
// Request is not signed!
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
|
println!(">>> has signature");
|
||||||
|
|
||||||
// Parse the signature.
|
// Parse the signature.
|
||||||
let sig = match Signature::derive(&req) {
|
let sig = match Signature::derive(&req) {
|
||||||
Ok(signature) => signature,
|
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.
|
// Fetch the public key using the verifier private key.
|
||||||
let verifier = Verifier::load(cfg);
|
let verifier = Verifier::load(cfg);
|
||||||
|
@ -307,12 +305,16 @@ pub mod auth {
|
||||||
keyid: sig.key_id().to_string(),
|
keyid: sig.key_id().to_string(),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
println!(">>> public key fetched");
|
||||||
|
|
||||||
// Verify the signature header on the request.
|
// Verify the signature header on the request.
|
||||||
let public_key = public_key.upgrade();
|
let public_key = public_key.upgrade();
|
||||||
|
println!(">>> upgraded");
|
||||||
if let Err(error) = public_key.verify(&sig) {
|
if let Err(error) = public_key.verify(&sig) {
|
||||||
|
println!(">>> rejected");
|
||||||
Err(SigError::VerificationFailed { error })
|
Err(SigError::VerificationFailed { error })
|
||||||
} else {
|
} else {
|
||||||
|
println!(">>> request verified");
|
||||||
Ok(Some(Signer { ap_id: public_key.owner.into() }))
|
Ok(Some(Signer { ap_id: public_key.owner.into() }))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue