555287fbe2da54538fc82f014941f11804f9e7fe / frogue / src / activitypub / signatures / rfc9421.rs
Raw
//! RFC 9421 HTTP Message Signatures verification.
//!
//! Verifies incoming requests signed according to RFC 9421, as used by
//! Mastodon 5.x and other modern ActivityPub implementations.
mod sf;
use crate::activitypub::keys;
use crate::activitypub::signatures::SignatureError;
use actix_web::http::header::HeaderMap;
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
use chrono::Utc;
use sf::{SfParam, SfValue};
use sha2::{Digest, Sha256};
/// Verify an inbound request's signature using RFC 9421 HTTP Message Signatures.
///
/// 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
///
/// # Verification rules
/// 1. Signature-Input header present and parseable
/// 2. Signature header present and parseable
/// 3. Exactly one signature label present in both headers
/// 4. Parameters extracted: keyid (required), created (required), alg (optional)
/// 5. Algorithm must be rsa-v1_5-sha256 (only RSA keys supported)
/// 6. Created timestamp within 300 seconds of now
/// 7. Expires timestamp (if present) must not be in the past
/// 8. Covered components must include @method and content integrity
/// 9. Signature base reconstructed and verified
/// 10. Public key fetched and signature verified
/// 11. Return verified actor URL
#[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>>,
{
// 1. Parse Signature-Input header (RFC 8941 dictionary)
let signature_input = headers
.get("signature-input")
.and_then(|v| v.to_str().ok())
.ok_or(SignatureError::MissingSignature)?;
tracing::debug!(
signature_input = %signature_input,
"parsing RFC 9421 signature-input"
);
let input_dict = sf::parse_dictionary(signature_input)
.map_err(|e| SignatureError::InvalidKeyId(format!("invalid signature-input: {}", e)))?;
// 2. Parse Signature header
let signature_header = headers
.get("signature")
.and_then(|v| v.to_str().ok())
.ok_or(SignatureError::MissingSignature)?;
let sig_dict =
sf::parse_dictionary(signature_header).map_err(|_e| SignatureError::MissingSignature)?;
// 3. Mastodon only sends one signature at a time; find matching label
if input_dict.len() != 1 {
return Err(SignatureError::InvalidKeyId(
"expected exactly one signature label".to_string(),
));
}
let (label, input_member) = &input_dict[0];
let sig_member =
sig_dict
.iter()
.find(|(k, _)| k == label)
.ok_or(SignatureError::InvalidKeyId(format!(
"signature label '{}' not found",
label
)))?;
// 4. Extract parameters from signature-input
let mut key_id: Option<String> = None;
let mut created: Option<i64> = None;
let mut expires: Option<i64> = None;
let mut alg: Option<String> = None;
for (param_name, param_value) in &input_member.params {
match param_name.as_str() {
"keyid" => {
if let SfParam::String(k) = param_value {
key_id = Some(k.clone());
}
}
"created" => {
if let SfParam::Integer(c) = param_value {
created = Some(*c);
}
}
"expires" => {
if let SfParam::Integer(e) = param_value {
expires = Some(*e);
}
}
"alg" => match param_value {
SfParam::Token(a) | SfParam::String(a) => {
alg = Some(a.clone());
}
_ => {}
},
_ => {} // Ignore other parameters (nonce, tag, etc.)
}
}
let key_id = key_id
.ok_or_else(|| SignatureError::InvalidKeyId("missing keyid parameter".to_string()))?;
let created = created.ok_or(SignatureError::DateOutOfRange)?;
// 5. Check algorithm - only RSA-PKCS1-v1_5 with SHA-256 supported
if let Some(a) = &alg
&& a != "rsa-v1_5-sha256"
{
return Err(SignatureError::InvalidAlgorithm(a.clone()));
}
// 6. Check freshness - created must be within 300 seconds of now
let now = Utc::now().timestamp();
let diff = (now - created).abs();
if diff > 300 {
return Err(SignatureError::DateOutOfRange);
}
// 7. Check expires if present
if let Some(exp) = expires
&& now > exp
{
return Err(SignatureError::DateOutOfRange);
}
// 8. Extract covered components from inner list
let covered_components = match &input_member.value {
SfValue::InnerList(items) => items,
_ => {
return Err(SignatureError::InvalidKeyId(
"signature-input value must be an inner list".to_string(),
));
}
};
// Verify policy: must cover @method and content integrity
let has_method = covered_components
.iter()
.any(|item| matches!(&item.value, SfValue::String(s) if s == "@method"));
let has_content = covered_components.iter().any(
|item| matches!(&item.value, SfValue::String(s) if s == "content-digest" || s == "digest"),
);
if !has_method {
return Err(SignatureError::InvalidKeyId(
"covered components must include @method".to_string(),
));
}
if !has_content {
return Err(SignatureError::InvalidKeyId(
"covered components must include content-digest or digest".to_string(),
));
}
// 9. Build signature base
let mut signature_base_lines = Vec::new();
for item in covered_components {
let component_name = match &item.value {
SfValue::String(s) => s.as_str(),
_ => {
return Err(SignatureError::InvalidKeyId(
"covered components must be strings".to_string(),
));
}
};
let line = build_component_line(
component_name,
scheme,
authority,
path,
query,
method,
headers,
body,
)?;
signature_base_lines.push(line);
}
// 10. Add @signature-params line (the raw inner list + parameters)
signature_base_lines.push(format!("\"@signature-params\": {}", input_member.raw_text));
let signature_base = signature_base_lines.join("\n");
tracing::debug!(
signature_base = %signature_base,
"RFC 9421 signature base constructed"
);
// 11. Fetch the public key
tracing::debug!(key_id = %key_id, "fetching public key for RFC 9421 verification");
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");
// 12. Decode signature and verify
let signature_bytes = match &sig_member.1.value {
SfValue::ByteSeq(bytes) => bytes.clone(),
_ => {
return Err(SignatureError::VerificationFailed);
}
};
if !keys::verify(&public_key_pem, signature_base.as_bytes(), &signature_bytes) {
return Err(SignatureError::VerificationFailed);
}
tracing::debug!(actor_url = %actor_url, "RFC 9421 signature verified successfully");
Ok(actor_url)
}
/// Build a signature base line for a covered component.
#[allow(clippy::too_many_arguments)]
fn build_component_line(
component_name: &str,
scheme: &str,
authority: &str,
path: &str,
query: Option<&str>,
method: &str,
headers: &HeaderMap,
body: &[u8],
) -> Result<String, SignatureError> {
match component_name {
"@method" => Ok(format!("\"@method\": {}", method)),
"@authority" => Ok(format!("\"@authority\": {}", authority)),
"@scheme" => Ok(format!("\"@scheme\": {}", scheme)),
"@path" => Ok(format!("\"@path\": {}", path)),
"@query" => {
let q = query.unwrap_or("");
Ok(format!("\"@query\": {}", q))
}
"@target-uri" => {
let target_uri = if let Some(q) = query {
format!("{}://{}{}?{}", scheme, authority, path, q)
} else {
format!("{}://{}{}", scheme, authority, path)
};
Ok(format!("\"@target-uri\": {}", target_uri))
}
"content-digest" => {
let digest = headers
.get("content-digest")
.and_then(|v| v.to_str().ok())
.ok_or(SignatureError::MissingHeader("content-digest".to_string()))?;
// Verify the digest matches the body
let expected = format!("sha-256=:{}:", BASE64.encode(Sha256::digest(body)));
if digest != expected {
return Err(SignatureError::DigestMismatch);
}
Ok(format!("\"content-digest\": {}", digest))
}
"digest" => {
// Legacy digest format
let digest = headers
.get("digest")
.and_then(|v| v.to_str().ok())
.ok_or(SignatureError::MissingHeader("digest".to_string()))?;
// Verify the digest matches the body
let expected = format!("SHA-256={}", BASE64.encode(Sha256::digest(body)));
if digest != expected {
return Err(SignatureError::DigestMismatch);
}
Ok(format!("\"digest\": {}", digest))
}
// Regular header field
name if !name.starts_with('@') => {
let value = headers
.get(name)
.and_then(|v| v.to_str().ok())
.ok_or(SignatureError::MissingHeader(name.to_string()))?;
Ok(format!("\"{}\": {}", name, value))
}
_ => Err(SignatureError::InvalidKeyId(format!(
"unsupported component: {}",
component_name
))),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::activitypub::keys;
use actix_web::http::header::{HeaderMap, HeaderName, HeaderValue};
#[tokio::test]
async fn test_rfc9421_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 authority = "example.com";
let scheme = "https";
let body = b"test body";
// Create headers
let mut headers = HeaderMap::new();
let now = Utc::now().timestamp();
let content_digest = format!("sha-256=:{}:", BASE64.encode(Sha256::digest(body)));
headers.insert(
HeaderName::from_static("content-digest"),
HeaderValue::from_str(&content_digest).unwrap(),
);
// Build signature base
let signature_input = format!(
"sig1=(\"@method\" \"@target-uri\" \"content-digest\");created={};keyid=\"{}\";alg=\"rsa-v1_5-sha256\"",
now, key_id
);
headers.insert(
HeaderName::from_static("signature-input"),
HeaderValue::from_str(&signature_input).unwrap(),
);
// Build signature
let signature_base = format!(
"\"@method\": POST\n\"@target-uri\": https://example.com/inbox\n\"content-digest\": {}\n\"@signature-params\": (\"@method\" \"@target-uri\" \"content-digest\");created={};keyid=\"{}\";alg=\"rsa-v1_5-sha256\"",
content_digest, now, key_id
);
let signature_bytes = keys::sign(&private_pem, signature_base.as_bytes()).unwrap();
let signature = format!("sig1=:{}:", BASE64.encode(signature_bytes));
headers.insert(
HeaderName::from_static("signature"),
HeaderValue::from_str(&signature).unwrap(),
);
// Verify
let result = verify_request(
scheme,
authority,
path,
None,
method,
&headers,
body,
|kid| async move {
if kid == key_id {
Ok(Some((key_id.to_string(), public_pem)))
} else {
Ok(None)
}
},
)
.await;
assert!(result.is_ok(), "verification should succeed: {:?}", result);
assert_eq!(result.unwrap(), key_id);
}
#[tokio::test]
async fn test_rfc9421_tampered_body_rejected() {
let (private_pem, public_pem) = keys::generate().unwrap();
let key_id = "https://example.com/actor#main-key";
let method = "POST";
let path = "/inbox";
let authority = "example.com";
let scheme = "https";
let original_body = b"original body";
let tampered_body = b"tampered body";
let mut headers = HeaderMap::new();
let now = Utc::now().timestamp();
// Digest of original body
let content_digest = format!("sha-256=:{}:", BASE64.encode(Sha256::digest(original_body)));
headers.insert(
HeaderName::from_static("content-digest"),
HeaderValue::from_str(&content_digest).unwrap(),
);
let signature_input = format!(
"sig1=(\"@method\" \"@target-uri\" \"content-digest\");created={};keyid=\"{}\";alg=\"rsa-v1_5-sha256\"",
now, key_id
);
headers.insert(
HeaderName::from_static("signature-input"),
HeaderValue::from_str(&signature_input).unwrap(),
);
let signature_base = format!(
"\"@method\": POST\n\"@target-uri\": https://example.com/inbox\n\"content-digest\": {}\n\"@signature-params\": (\"@method\" \"@target-uri\" \"content-digest\");created={};keyid=\"{}\";alg=\"rsa-v1_5-sha256\"",
content_digest, now, key_id
);
let signature_bytes = keys::sign(&private_pem, signature_base.as_bytes()).unwrap();
let signature = format!("sig1=:{}:", BASE64.encode(signature_bytes));
headers.insert(
HeaderName::from_static("signature"),
HeaderValue::from_str(&signature).unwrap(),
);
// Verify with tampered body - should fail with DigestMismatch
let result = verify_request(
scheme,
authority,
path,
None,
method,
&headers,
tampered_body,
|kid| async move {
if kid == key_id {
Ok(Some((key_id.to_string(), public_pem)))
} else {
Ok(None)
}
},
)
.await;
assert!(result.is_err(), "tampered body should be rejected");
assert!(matches!(
result.unwrap_err(),
SignatureError::DigestMismatch
));
}
#[tokio::test]
async fn test_rfc9421_missing_keyid_rejected() {
let (private_pem, _public_pem) = keys::generate().unwrap();
let method = "POST";
let path = "/inbox";
let authority = "example.com";
let scheme = "https";
let body = b"test body";
let mut headers = HeaderMap::new();
let now = Utc::now().timestamp();
let content_digest = format!("sha-256=:{}:", BASE64.encode(Sha256::digest(body)));
headers.insert(
HeaderName::from_static("content-digest"),
HeaderValue::from_str(&content_digest).unwrap(),
);
// Missing keyid parameter
let signature_input = format!(
"sig1=(\"@method\" \"@target-uri\" \"content-digest\");created={};alg=\"rsa-v1_5-sha256\"",
now
);
headers.insert(
HeaderName::from_static("signature-input"),
HeaderValue::from_str(&signature_input).unwrap(),
);
let signature_base = format!(
"\"@method\": POST\n\"@target-uri\": https://example.com/inbox\n\"content-digest\": {}\n\"@signature-params\": (\"@method\" \"@target-uri\" \"content-digest\");created={};alg=\"rsa-v1_5-sha256\"",
content_digest, now
);
let signature_bytes = keys::sign(&private_pem, signature_base.as_bytes()).unwrap();
let signature = format!("sig1=:{}:", BASE64.encode(signature_bytes));
headers.insert(
HeaderName::from_static("signature"),
HeaderValue::from_str(&signature).unwrap(),
);
let result = verify_request(
scheme,
authority,
path,
None,
method,
&headers,
body,
|_kid| async move { Ok(None) },
)
.await;
assert!(result.is_err(), "missing keyid should be rejected");
assert!(matches!(
result.unwrap_err(),
SignatureError::InvalidKeyId(_)
));
}
#[tokio::test]
async fn test_rfc9421_bad_algorithm_rejected() {
let (private_pem, public_pem) = keys::generate().unwrap();
let key_id = "https://example.com/actor#main-key";
let method = "POST";
let path = "/inbox";
let authority = "example.com";
let scheme = "https";
let body = b"test body";
let mut headers = HeaderMap::new();
let now = Utc::now().timestamp();
let content_digest = format!("sha-256=:{}:", BASE64.encode(Sha256::digest(body)));
headers.insert(
HeaderName::from_static("content-digest"),
HeaderValue::from_str(&content_digest).unwrap(),
);
// Wrong algorithm
let signature_input = format!(
"sig1=(\"@method\" \"@target-uri\" \"content-digest\");created={};keyid=\"{}\";alg=\"ecdsa-p256-sha256\"",
now, key_id
);
headers.insert(
HeaderName::from_static("signature-input"),
HeaderValue::from_str(&signature_input).unwrap(),
);
let signature_base = format!(
"\"@method\": POST\n\"@target-uri\": https://example.com/inbox\n\"content-digest\": {}\n\"@signature-params\": (\"@method\" \"@target-uri\" \"content-digest\");created={};keyid=\"{}\";alg=\"ecdsa-p256-sha256\"",
content_digest, now, key_id
);
let signature_bytes = keys::sign(&private_pem, signature_base.as_bytes()).unwrap();
let signature = format!("sig1=:{}:", BASE64.encode(signature_bytes));
headers.insert(
HeaderName::from_static("signature"),
HeaderValue::from_str(&signature).unwrap(),
);
let result = verify_request(
scheme,
authority,
path,
None,
method,
&headers,
body,
|kid| async move {
if kid == key_id {
Ok(Some((key_id.to_string(), public_pem)))
} else {
Ok(None)
}
},
)
.await;
assert!(result.is_err(), "bad algorithm should be rejected");
assert!(matches!(
result.unwrap_err(),
SignatureError::InvalidAlgorithm(_)
));
}
#[tokio::test]
async fn test_rfc9421_expired_created_rejected() {
let (private_pem, public_pem) = keys::generate().unwrap();
let key_id = "https://example.com/actor#main-key";
let method = "POST";
let path = "/inbox";
let authority = "example.com";
let scheme = "https";
let body = b"test body";
let mut headers = HeaderMap::new();
// Created 600 seconds ago (beyond 300s window)
let now = Utc::now().timestamp() - 600;
let content_digest = format!("sha-256=:{}:", BASE64.encode(Sha256::digest(body)));
headers.insert(
HeaderName::from_static("content-digest"),
HeaderValue::from_str(&content_digest).unwrap(),
);
let signature_input = format!(
"sig1=(\"@method\" \"@target-uri\" \"content-digest\");created={};keyid=\"{}\";alg=\"rsa-v1_5-sha256\"",
now, key_id
);
headers.insert(
HeaderName::from_static("signature-input"),
HeaderValue::from_str(&signature_input).unwrap(),
);
let signature_base = format!(
"\"@method\": POST\n\"@target-uri\": https://example.com/inbox\n\"content-digest\": {}\n\"@signature-params\": (\"@method\" \"@target-uri\" \"content-digest\");created={};keyid=\"{}\";alg=\"rsa-v1_5-sha256\"",
content_digest, now, key_id
);
let signature_bytes = keys::sign(&private_pem, signature_base.as_bytes()).unwrap();
let signature = format!("sig1=:{}:", BASE64.encode(signature_bytes));
headers.insert(
HeaderName::from_static("signature"),
HeaderValue::from_str(&signature).unwrap(),
);
let result = verify_request(
scheme,
authority,
path,
None,
method,
&headers,
body,
|kid| async move {
if kid == key_id {
Ok(Some((key_id.to_string(), public_pem)))
} else {
Ok(None)
}
},
)
.await;
assert!(result.is_err(), "expired created should be rejected");
assert!(matches!(
result.unwrap_err(),
SignatureError::DateOutOfRange
));
}
#[tokio::test]
async fn test_rfc9421_missing_method_component_rejected() {
let (private_pem, public_pem) = keys::generate().unwrap();
let key_id = "https://example.com/actor#main-key";
let method = "POST";
let path = "/inbox";
let authority = "example.com";
let scheme = "https";
let body = b"test body";
let mut headers = HeaderMap::new();
let now = Utc::now().timestamp();
let content_digest = format!("sha-256=:{}:", BASE64.encode(Sha256::digest(body)));
headers.insert(
HeaderName::from_static("content-digest"),
HeaderValue::from_str(&content_digest).unwrap(),
);
// Missing @method in covered components
let signature_input = format!(
"sig1=(\"@target-uri\" \"content-digest\");created={};keyid=\"{}\";alg=\"rsa-v1_5-sha256\"",
now, key_id
);
headers.insert(
HeaderName::from_static("signature-input"),
HeaderValue::from_str(&signature_input).unwrap(),
);
let signature_base = format!(
"\"@target-uri\": https://example.com/inbox\n\"content-digest\": {}\n\"@signature-params\": (\"@target-uri\" \"content-digest\");created={};keyid=\"{}\";alg=\"rsa-v1_5-sha256\"",
content_digest, now, key_id
);
let signature_bytes = keys::sign(&private_pem, signature_base.as_bytes()).unwrap();
let signature = format!("sig1=:{}:", BASE64.encode(signature_bytes));
headers.insert(
HeaderName::from_static("signature"),
HeaderValue::from_str(&signature).unwrap(),
);
let result = verify_request(
scheme,
authority,
path,
None,
method,
&headers,
body,
|kid| async move {
if kid == key_id {
Ok(Some((key_id.to_string(), public_pem)))
} else {
Ok(None)
}
},
)
.await;
assert!(result.is_err(), "missing @method should be rejected");
assert!(matches!(
result.unwrap_err(),
SignatureError::InvalidKeyId(_)
));
}
}