555287fbe2da54538fc82f014941f11804f9e7fe / frogue / src / activitypub / signatures / mod.rs
Raw
//! HTTP Signatures for ActivityPub.
//!
//! Supports both legacy draft-cavage signatures and RFC 9421 HTTP Message Signatures.
mod rfc9421;
use std::collections::HashMap;
use actix_web::http::header::HeaderMap;
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64;
use chrono::Utc;
use sha2::{Digest, Sha256};
use thiserror::Error;
use crate::activitypub::keys;
#[derive(Debug, Error)]
pub enum SignatureError {
#[error("missing or malformed signature header")]
MissingSignature,
#[error("missing required header: {0}")]
MissingHeader(String),
#[error("invalid signature algorithm: {0}")]
InvalidAlgorithm(String),
#[error("signature verification failed")]
VerificationFailed,
#[error("digest mismatch")]
DigestMismatch,
#[error("date too old or too new")]
DateOutOfRange,
#[error("key not found: {0}")]
KeyNotFound(String),
#[error("invalid key id: {0}")]
InvalidKeyId(String),
#[error("actor is gone: {0}")]
ActorGone(String),
}
/// Build the Signature header value for an outbound POST.
///
/// Signs the request using RSA-SHA256 over the specified headers.
/// Returns the signature header value and the digest header value.
pub fn sign_request(
private_pem: &str,
key_id: &str,
method: &str,
path: &str,
host: &str,
date: &str,
body: &[u8],
) -> Result<(String, String), SignatureError> {
let digest = format!("SHA-256={}", BASE64.encode(Sha256::digest(body)));
let content_type = "application/activity+json";
// Build the signing string
let signing_string = format!(
"(request-target): {method} {path}\nhost: {host}\ndate: {date}\ndigest: {digest}\ncontent-type: {content_type}"
);
let signature_bytes = keys::sign(private_pem, signing_string.as_bytes())
.map_err(|_| SignatureError::VerificationFailed)?;
let signature_b64 = BASE64.encode(signature_bytes);
let signature_header = format!(
"keyId=\"{}\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date digest content-type\",signature=\"{}\"",
key_id, signature_b64
);
Ok((signature_header, digest))
}
/// Build the Signature header value for an outbound GET request.
///
/// GET requests don't have a body, so no digest is computed.
/// Returns the signature header value.
pub fn sign_get(
private_pem: &str,
key_id: &str,
path: &str,
host: &str,
date: &str,
) -> Result<String, SignatureError> {
// Build the signing string - no digest for GET
let signing_string = format!("(request-target): get {path}\nhost: {host}\ndate: {date}");
let signature_bytes = keys::sign(private_pem, signing_string.as_bytes())
.map_err(|_| SignatureError::VerificationFailed)?;
let signature_b64 = BASE64.encode(signature_bytes);
let signature_header = format!(
"keyId=\"{}\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date\",signature=\"{}\"",
key_id, signature_b64
);
Ok(signature_header)
}
/// Verify an inbound request's signature.
///
/// Dispatches to RFC 9421 or legacy verification based on the presence
/// of the signature-input header.
///
/// Returns the verified actor URL on success.
///
/// # Arguments
/// * `scheme` - Request scheme (http or https)
/// * `authority` - Request authority (host:port)
/// * `path` - Request path without query
/// * `query` - Optional query string (without leading ?)
/// * `method` - HTTP method (POST)
/// * `headers` - Request headers
/// * `body` - Request body
/// * `fetch_key` - Callback to fetch the public key for a keyId
#[allow(clippy::too_many_arguments)]
pub async fn verify_request<F, Fut>(
scheme: &str,
authority: &str,
path: &str,
query: Option<&str>,
method: &str,
headers: &HeaderMap,
body: &[u8],
fetch_key: F,
) -> Result<String, SignatureError>
where
F: FnOnce(String) -> Fut,
Fut: std::future::Future<Output = Result<Option<(String, String)>, SignatureError>>,
{
// Check if this is an RFC 9421 signature (has signature-input header)
if headers.get("signature-input").is_some() {
tracing::debug!("detected RFC 9421 signature, using RFC 9421 verification");
return rfc9421::verify_request(
scheme, authority, path, query, method, headers, body, fetch_key,
)
.await;
}
// Otherwise, use legacy verification
tracing::debug!("detected legacy signature, using legacy verification");
verify_request_legacy(method, path, query, headers, body, fetch_key).await
}
/// Legacy draft-cavage signature verification.
async fn verify_request_legacy<F, Fut>(
method: &str,
path: &str,
query: Option<&str>,
headers: &HeaderMap,
body: &[u8],
fetch_key: F,
) -> Result<String, SignatureError>
where
F: FnOnce(String) -> Fut,
Fut: std::future::Future<Output = Result<Option<(String, String)>, SignatureError>>,
{
// Reconstruct path_and_query for legacy signatures
let path_and_query = if let Some(q) = query {
format!("{}?{}", path, q)
} else {
path.to_string()
};
// 1. Parse signature header
let signature_header = headers
.get("signature")
.and_then(|v| v.to_str().ok())
.ok_or(SignatureError::MissingSignature)?;
tracing::debug!(header = %signature_header, "parsing signature header");
let sig_parts = parse_signature_header(signature_header)?;
// Check algorithm
let algorithm = sig_parts
.get("algorithm")
.map(|s| s.as_str())
.unwrap_or("rsa-sha256");
if algorithm != "rsa-sha256" {
return Err(SignatureError::InvalidAlgorithm(algorithm.to_string()));
}
let key_id = sig_parts
.get("keyId")
.ok_or_else(|| SignatureError::InvalidKeyId("missing keyId".to_string()))?;
let headers_list = sig_parts
.get("headers")
.ok_or_else(|| SignatureError::MissingHeader("headers list".to_string()))?;
let signature_b64 = sig_parts
.get("signature")
.ok_or_else(|| SignatureError::MissingHeader("signature value".to_string()))?;
tracing::debug!(key_id = %key_id, headers = %headers_list, "signature components");
let signed_headers: Vec<&str> = headers_list.split_whitespace().collect();
// 2. Check required headers
if !signed_headers.contains(&"(request-target)") {
return Err(SignatureError::MissingHeader(
"(request-target)".to_string(),
));
}
if !signed_headers.contains(&"host") {
return Err(SignatureError::MissingHeader("host".to_string()));
}
if !signed_headers.contains(&"date") {
return Err(SignatureError::MissingHeader("date".to_string()));
}
// 5. Check date within 300 seconds
let date_header = headers
.get("date")
.and_then(|v| v.to_str().ok())
.ok_or_else(|| SignatureError::MissingHeader("date".to_string()))?;
let request_date = parse_http_date(date_header).ok_or(SignatureError::DateOutOfRange)?;
let now = Utc::now();
let diff = (now - request_date).num_seconds().abs();
if diff > 300 {
return Err(SignatureError::DateOutOfRange);
}
// 4. Verify digest
let expected_digest = headers
.get("digest")
.and_then(|v| v.to_str().ok())
.ok_or_else(|| SignatureError::MissingHeader("digest".to_string()))?;
let actual_digest = format!("SHA-256={}", BASE64.encode(Sha256::digest(body)));
if expected_digest != actual_digest {
return Err(SignatureError::DigestMismatch);
}
// 3. Rebuild signing string
let mut signing_parts = Vec::new();
for header_name in &signed_headers {
if *header_name == "(request-target)" {
signing_parts.push(format!(
"(request-target): {} {path_and_query}",
method.to_lowercase()
));
} else {
let value = headers
.get(*header_name)
.and_then(|v| v.to_str().ok())
.ok_or_else(|| SignatureError::MissingHeader(header_name.to_string()))?;
signing_parts.push(format!("{header_name}: {value}"));
}
}
let signing_string = signing_parts.join("\n");
tracing::debug!(signing_string = %signing_string, "reconstructed signing string");
// Fetch the public key
tracing::debug!(key_id = %key_id, "fetching public key");
let (actor_url, public_key_pem) = fetch_key(key_id.clone())
.await?
.ok_or_else(|| SignatureError::KeyNotFound(key_id.clone()))?;
tracing::debug!(actor_url = %actor_url, "public key fetched successfully");
// Verify the signature
let signature_bytes = BASE64
.decode(signature_b64)
.map_err(|_| SignatureError::VerificationFailed)?;
if !keys::verify(&public_key_pem, signing_string.as_bytes(), &signature_bytes) {
return Err(SignatureError::VerificationFailed);
}
// 6. Return verified actor URL
Ok(actor_url)
}
/// Parse a signature header into its components.
fn parse_signature_header(header: &str) -> Result<HashMap<String, String>, SignatureError> {
let mut parts = HashMap::new();
let mut current_key = String::new();
let mut current_value = String::new();
let mut in_quotes = false;
let mut in_value = false;
let mut escape_next = false;
for ch in header.chars() {
if escape_next {
current_value.push(ch);
escape_next = false;
continue;
}
match ch {
'\\' if in_quotes => escape_next = true,
'"' => in_quotes = !in_quotes,
'=' if !in_quotes && !in_value && !current_key.is_empty() => {
// Key ended, value starts
in_value = true;
}
',' if !in_quotes => {
// End of this key=value pair
if !current_key.is_empty() {
parts.insert(current_key.trim().to_string(), current_value.clone());
current_key.clear();
current_value.clear();
in_value = false;
}
}
_ => {
if !in_value && !in_quotes && ch.is_alphanumeric() {
// Building the key
current_key.push(ch);
} else {
// Building the value
current_value.push(ch);
}
}
}
}
// Don't forget the last pair
if !current_key.is_empty() {
parts.insert(current_key.trim().to_string(), current_value);
}
Ok(parts)
}
/// Parse an HTTP date (RFC 7231 format).
fn parse_http_date(date_str: &str) -> Option<chrono::DateTime<Utc>> {
// Try RFC 7231 format: "Sun, 06 Nov 1994 08:49:37 GMT"
chrono::DateTime::parse_from_rfc2822(date_str)
.ok()
.map(|dt| dt.with_timezone(&Utc))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::activitypub::keys;
use chrono::Datelike;
#[test]
fn sign_and_verify_round_trip() {
let (private_pem, _public_pem) = keys::generate().unwrap();
let key_id = "https://example.com/actor#main-key";
let method = "POST";
let path = "/inbox";
let host = "example.com";
let date = "Wed, 21 Oct 2026 07:28:00 GMT";
let body = b"test body";
let (signature, digest) =
sign_request(&private_pem, key_id, method, path, host, date, body).unwrap();
assert!(signature.contains("keyId="));
assert!(signature.contains("algorithm=\"rsa-sha256\""));
assert!(digest.starts_with("SHA-256="));
}
#[test]
fn sign_get_no_digest() {
let (private_pem, _public_pem) = keys::generate().unwrap();
let key_id = "https://example.com/actor#main-key";
let path = "/actor";
let host = "example.com";
let date = "Wed, 21 Oct 2026 07:28:00 GMT";
let signature = sign_get(&private_pem, key_id, path, host, date).unwrap();
assert!(signature.contains("keyId="));
assert!(signature.contains("algorithm=\"rsa-sha256\""));
assert!(signature.contains("headers=\"(request-target) host date\""));
assert!(!signature.contains("digest"));
}
#[test]
fn parse_signature_header_basic() {
let header = r#"keyId="https://example.com/key",algorithm="rsa-sha256",headers="(request-target) host date",signature="abc123""#;
let parts = parse_signature_header(header).unwrap();
assert_eq!(parts.get("keyId").unwrap(), "https://example.com/key");
assert_eq!(parts.get("algorithm").unwrap(), "rsa-sha256");
assert_eq!(parts.get("headers").unwrap(), "(request-target) host date");
assert_eq!(parts.get("signature").unwrap(), "abc123");
}
#[test]
fn parse_http_date_rfc7231() {
let date_str = "Wed, 21 Oct 2026 07:28:00 GMT";
let parsed = parse_http_date(date_str).unwrap();
assert_eq!(parsed.year(), 2026);
assert_eq!(parsed.month(), 10);
assert_eq!(parsed.day(), 21);
}
}