🐸 Frogue(Fed)

~ololduck/Frogue

feat(activitypub): verify RFC 9421 HTTP message signatures on inbox

312ff7e2026-10-24T17:03:06+02:00Ololduck

6 files changed, +1347, −22
frogue/src/activitypub/inbox.rs+12−6
@@ -570,15 +570,21 @@ async fn verify_inbox_request(
body: &[u8],
) -> Result<String, SignatureError> {
let method = req.method().as_str();
− let path_and_query = req
− .uri()
− .path_and_query()
− .map(|pq| pq.as_str())
− .unwrap_or(req.uri().path());
+
+ // Extract scheme, authority, path, and query from the request
+ let conn_info = req.connection_info();
+ let scheme = conn_info.scheme();
+ let authority = conn_info.host();
+
+ let path = req.uri().path();
+ let query = req.uri().query();
signatures::verify_request(
+ scheme,
+ authority,
+ path,
+ query,
method,
− path_and_query,
req.headers(),
body,
|key_id| async move {
frogue/src/activitypub/signatures.rs → frogue/src/activitypub/signatures/mod.rs+52−16
@@ -1,7 +1,8 @@
−//! HTTP Signatures (draft-10) for ActivityPub.
+//! HTTP Signatures for ActivityPub.
//!
−//! This implements the signing and verification of HTTP requests using
−//! RSA-SHA256 signatures as specified in the ActivityPub protocol.
+//! Supports both legacy draft-cavage signatures and RFC 9421 HTTP Message Signatures.
+
+mod rfc9421;
use std::collections::HashMap;
@@ -95,26 +96,27 @@ pub fn sign_get(
/// 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)
−/// * `path_and_query` - Request path with query string
/// * `headers` - Request headers
/// * `body` - Request body
/// * `fetch_key` - Callback to fetch the public key for a keyId
−///
−/// # Verification rules
−/// 1. Signature header present and parseable
−/// 2. headers param must include (request-target), host, date
−/// 3. Rebuild signing string and verify signature
−/// 4. Digest must match SHA-256(body)
−/// 5. Date within 30 seconds of now
−/// 6. Fetch actor document for keyId, verify publicKey.id matches
−/// 7. 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,
− path_and_query: &str,
headers: &HeaderMap,
body: &[u8],
fetch_key: F,
@@ -123,6 +125,40 @@ where
F: FnOnce(String) -> Fut,
Fut: std::future::Future<Output = Option<(String, String)>>,
{
+ // 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 = Option<(String, String)>>,
+{
+ // 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")
@@ -169,7 +205,7 @@ where
return Err(SignatureError::MissingHeader("date".to_string()));
}
− // 5. Check date within 30 seconds
+ // 5. Check date within 300 seconds
let date_header = headers
.get("date")
.and_then(|v| v.to_str().ok())
@@ -177,7 +213,7 @@ where
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 > 30 {
+ if diff > 300 {
return Err(SignatureError::DateOutOfRange);
}
frogue/src/activitypub/signatures/rfc9421.rs+714−0
@@ -0,0 +1,714 @@
+//! 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 = Option<(String, String)>>,
+{
+ // 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 {
+ Some((key_id.to_string(), public_pem))
+ } else {
+ 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 {
+ Some((key_id.to_string(), public_pem))
+ } else {
+ 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 { 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 {
+ Some((key_id.to_string(), public_pem))
+ } else {
+ 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 {
+ Some((key_id.to_string(), public_pem))
+ } else {
+ 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 {
+ Some((key_id.to_string(), public_pem))
+ } else {
+ None
+ }
+ },
+ )
+ .await;
+
+ assert!(result.is_err(), "missing @method should be rejected");
+ assert!(matches!(
+ result.unwrap_err(),
+ SignatureError::InvalidKeyId(_)
+ ));
+ }
+}
frogue/src/activitypub/signatures/rfc9421/sf.rs+427−0
@@ -0,0 +1,427 @@
+//! Minimal RFC 8941 Structured Fields parser.
+//!
+//! Parses the subset needed for RFC 9421 HTTP Message Signatures:
+//! - Dictionaries with inner list or byte sequence values
+//! - Parameters (string, token, integer)
+//! - Preserves raw inner list text for @signature-params
+
+use thiserror::Error;
+
+#[derive(Debug, Error)]
+pub enum SfError {
+ #[error("unexpected end of input")]
+ UnexpectedEof,
+ #[error("invalid character: {0:?}")]
+ InvalidChar(char),
+ #[error("invalid base64")]
+ InvalidBase64,
+}
+
+/// A structured field parameter value.
+#[derive(Debug, Clone, PartialEq)]
+pub enum SfParam {
+ String(String),
+ Token(String),
+ Integer(i64),
+}
+
+/// A structured field inner list item (bare value + parameters).
+#[derive(Debug, Clone)]
+pub struct SfItem {
+ pub value: SfValue,
+}
+
+/// A structured field value.
+#[derive(Debug, Clone)]
+pub enum SfValue {
+ InnerList(Vec<SfItem>),
+ ByteSeq(Vec<u8>),
+ String(String),
+ #[allow(dead_code)]
+ Token(String),
+ #[allow(dead_code)]
+ Integer(i64),
+}
+
+/// A dictionary member: value + parameters on the member itself.
+#[derive(Debug, Clone)]
+pub struct SfMember {
+ pub value: SfValue,
+ pub params: Vec<(String, SfParam)>,
+ /// Raw text of the value (for inner lists, needed for @signature-params).
+ pub raw_text: String,
+}
+
+/// Parse a structured field dictionary.
+pub fn parse_dictionary(input: &str) -> Result<Vec<(String, SfMember)>, SfError> {
+ let mut result = Vec::new();
+ let mut chars = input.chars().peekable();
+
+ while chars.peek().is_some() {
+ skip_spaces(&mut chars);
+ let key = parse_key(&mut chars)?;
+ skip_spaces(&mut chars);
+
+ let mut member_params = Vec::new();
+
+ let value = if chars.peek() == Some(&'=') {
+ chars.next();
+ skip_spaces(&mut chars);
+ let (val, mut raw) = parse_value(&mut chars)?;
+ parse_member_params(&mut chars, &mut member_params)?;
+ // Append member-level parameters to raw_text
+ raw.push_str(&serialize_params(&member_params));
+ SfMember {
+ value: val,
+ params: member_params,
+ raw_text: raw,
+ }
+ } else {
+ // Boolean true (member present without value)
+ parse_member_params(&mut chars, &mut member_params)?;
+ let raw = serialize_params(&member_params);
+ SfMember {
+ value: SfValue::Token("true".to_string()),
+ params: member_params,
+ raw_text: raw,
+ }
+ };
+
+ result.push((key, value));
+
+ skip_spaces(&mut chars);
+ if chars.peek() == Some(&',') {
+ chars.next();
+ }
+ }
+
+ Ok(result)
+}
+
+fn skip_spaces(chars: &mut std::iter::Peekable<std::str::Chars>) {
+ while let Some(&c) = chars.peek() {
+ if c == ' ' || c == '\t' {
+ chars.next();
+ } else {
+ break;
+ }
+ }
+}
+
+fn parse_key(chars: &mut std::iter::Peekable<std::str::Chars>) -> Result<String, SfError> {
+ let mut key = String::new();
+ while let Some(&c) = chars.peek() {
+ if c.is_ascii_lowercase()
+ || c.is_ascii_digit()
+ || c == '_'
+ || c == '-'
+ || c == '.'
+ || c == '*'
+ {
+ key.push(c);
+ chars.next();
+ } else {
+ break;
+ }
+ }
+ if key.is_empty() {
+ return Err(SfError::UnexpectedEof);
+ }
+ Ok(key)
+}
+
+fn parse_value(
+ chars: &mut std::iter::Peekable<std::str::Chars>,
+) -> Result<(SfValue, String), SfError> {
+ let mut raw = String::new();
+
+ match chars.peek() {
+ Some(&'"') => {
+ let s = parse_string(chars, &mut raw)?;
+ Ok((SfValue::String(s), raw))
+ }
+ Some(&':') => {
+ let bytes = parse_byte_seq(chars, &mut raw)?;
+ Ok((SfValue::ByteSeq(bytes), raw))
+ }
+ Some(&'(') => {
+ let items = parse_inner_list(chars, &mut raw)?;
+ Ok((SfValue::InnerList(items), raw))
+ }
+ Some(&c) if c.is_ascii_digit() || c == '-' => {
+ let i = parse_integer(chars, &mut raw)?;
+ Ok((SfValue::Integer(i), raw))
+ }
+ Some(&c) if c.is_ascii_alphabetic() || c == '*' => {
+ let t = parse_token(chars, &mut raw)?;
+ Ok((SfValue::Token(t), raw))
+ }
+ Some(&c) => Err(SfError::InvalidChar(c)),
+ None => Err(SfError::UnexpectedEof),
+ }
+}
+
+fn parse_string(
+ chars: &mut std::iter::Peekable<std::str::Chars>,
+ raw: &mut String,
+) -> Result<String, SfError> {
+ chars.next(); // opening quote
+ raw.push('"');
+ let mut s = String::new();
+ let mut escape = false;
+
+ for c in chars.by_ref() {
+ raw.push(c);
+ if escape {
+ s.push(c);
+ escape = false;
+ } else if c == '\\' {
+ escape = true;
+ } else if c == '"' {
+ return Ok(s);
+ } else {
+ s.push(c);
+ }
+ }
+
+ Err(SfError::UnexpectedEof)
+}
+
+fn parse_byte_seq(
+ chars: &mut std::iter::Peekable<std::str::Chars>,
+ raw: &mut String,
+) -> Result<Vec<u8>, SfError> {
+ chars.next(); // opening colon
+ raw.push(':');
+ let mut b64 = String::new();
+
+ while let Some(&c) = chars.peek() {
+ if c == ':' {
+ chars.next();
+ raw.push(':');
+ return base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &b64)
+ .map_err(|_| SfError::InvalidBase64);
+ }
+ b64.push(c);
+ raw.push(c);
+ chars.next();
+ }
+
+ Err(SfError::UnexpectedEof)
+}
+
+fn parse_inner_list(
+ chars: &mut std::iter::Peekable<std::str::Chars>,
+ raw: &mut String,
+) -> Result<Vec<SfItem>, SfError> {
+ chars.next(); // opening paren
+ raw.push('(');
+ let mut items = Vec::new();
+ let mut first = true;
+
+ loop {
+ skip_spaces(chars);
+ if chars.peek() == Some(&')') {
+ chars.next();
+ raw.push(')');
+ return Ok(items);
+ }
+
+ // Add space before items (except the first)
+ if !first {
+ raw.push(' ');
+ }
+ first = false;
+
+ let (val, item_raw) = parse_value(chars)?;
+ raw.push_str(&item_raw);
+
+ items.push(SfItem { value: val });
+
+ skip_spaces(chars);
+ }
+}
+
+fn parse_integer(
+ chars: &mut std::iter::Peekable<std::str::Chars>,
+ raw: &mut String,
+) -> Result<i64, SfError> {
+ let mut s = String::new();
+ if let Some(&'-') = chars.peek() {
+ s.push('-');
+ raw.push('-');
+ chars.next();
+ }
+
+ while let Some(&c) = chars.peek() {
+ if c.is_ascii_digit() {
+ s.push(c);
+ raw.push(c);
+ chars.next();
+ } else {
+ break;
+ }
+ }
+
+ s.parse().map_err(|_| SfError::UnexpectedEof)
+}
+
+fn parse_token(
+ chars: &mut std::iter::Peekable<std::str::Chars>,
+ raw: &mut String,
+) -> Result<String, SfError> {
+ let mut s = String::new();
+ while let Some(&c) = chars.peek() {
+ if c.is_ascii_alphanumeric()
+ || c == '_'
+ || c == '-'
+ || c == '.'
+ || c == ':'
+ || c == '/'
+ || c == '*'
+ {
+ s.push(c);
+ raw.push(c);
+ chars.next();
+ } else {
+ break;
+ }
+ }
+ if s.is_empty() {
+ return Err(SfError::UnexpectedEof);
+ }
+ Ok(s)
+}
+
+fn parse_member_params(
+ chars: &mut std::iter::Peekable<std::str::Chars>,
+ params: &mut Vec<(String, SfParam)>,
+) -> Result<(), SfError> {
+ while chars.peek() == Some(&';') {
+ chars.next();
+ skip_spaces(chars);
+ let key = parse_key(chars)?;
+ skip_spaces(chars);
+
+ let value = if chars.peek() == Some(&'=') {
+ chars.next();
+ skip_spaces(chars);
+ parse_param_value(chars)?
+ } else {
+ SfParam::Token("true".to_string())
+ };
+
+ params.push((key, value));
+ }
+ Ok(())
+}
+
+fn parse_param_value(chars: &mut std::iter::Peekable<std::str::Chars>) -> Result<SfParam, SfError> {
+ let mut raw = String::new();
+
+ match chars.peek() {
+ Some(&'"') => {
+ let s = parse_string(chars, &mut raw)?;
+ Ok(SfParam::String(s))
+ }
+ Some(&c) if c.is_ascii_digit() || c == '-' => {
+ let mut s = String::new();
+ if c == '-' {
+ s.push(c);
+ chars.next();
+ }
+ while let Some(&c) = chars.peek() {
+ if c.is_ascii_digit() {
+ s.push(c);
+ chars.next();
+ } else {
+ break;
+ }
+ }
+ let i: i64 = s.parse().map_err(|_| SfError::UnexpectedEof)?;
+ Ok(SfParam::Integer(i))
+ }
+ Some(&c) if c.is_ascii_alphabetic() || c == '*' => {
+ let t = parse_token(chars, &mut raw)?;
+ Ok(SfParam::Token(t))
+ }
+ Some(&c) => Err(SfError::InvalidChar(c)),
+ None => Err(SfError::UnexpectedEof),
+ }
+}
+
+/// Serialize parameters back to their string form (for @signature-params).
+fn serialize_params(params: &[(String, SfParam)]) -> String {
+ let mut result = String::new();
+ for (key, value) in params {
+ result.push(';');
+ result.push_str(key);
+ match value {
+ SfParam::String(s) => {
+ result.push('=');
+ result.push('"');
+ // Escape backslashes and quotes
+ for c in s.chars() {
+ match c {
+ '\\' => result.push_str("\\\\"),
+ '"' => result.push_str("\\\""),
+ _ => result.push(c),
+ }
+ }
+ result.push('"');
+ }
+ SfParam::Integer(i) => {
+ result.push('=');
+ result.push_str(&i.to_string());
+ }
+ SfParam::Token(t) => {
+ result.push('=');
+ result.push_str(t);
+ }
+ }
+ }
+ result
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn parse_simple_dictionary() {
+ let input = r#"sig1=("@method" "@path");created=1234;keyid="test-key""#;
+ let dict = parse_dictionary(input).unwrap();
+ assert_eq!(dict.len(), 1);
+ let (key, member) = &dict[0];
+ assert_eq!(key, "sig1");
+ assert_eq!(member.params.len(), 2);
+ assert_eq!(member.params[0].0, "created");
+ assert_eq!(member.params[0].1, SfParam::Integer(1234));
+ assert_eq!(member.params[1].0, "keyid");
+ assert_eq!(member.params[1].1, SfParam::String("test-key".to_string()));
+ }
+
+ #[test]
+ fn parse_inner_list() {
+ let input = r#"sig1=("@method" "host" "date");created=1234"#;
+ let dict = parse_dictionary(input).unwrap();
+ let (_, member) = &dict[0];
+ if let SfValue::InnerList(items) = &member.value {
+ assert_eq!(items.len(), 3);
+ } else {
+ panic!("expected inner list");
+ }
+ }
+
+ #[test]
+ fn parse_byte_sequence() {
+ let input = r#"sig1=:dGVzdA==:"#;
+ let dict = parse_dictionary(input).unwrap();
+ let (_, member) = &dict[0];
+ if let SfValue::ByteSeq(bytes) = &member.value {
+ assert_eq!(bytes, b"test");
+ } else {
+ panic!("expected byte sequence");
+ }
+ }
+}
frogue/tests/rfc9421.rs+106−0
@@ -0,0 +1,106 @@
+//! Phase 5 tests: RFC 9421 HTTP Message Signatures
+
+mod support;
+
+use std::time::{Duration, Instant};
+
+use support::ap::RemoteActor;
+use support::{Client, Server, free_port};
+
+/// The password the tests register and log in with.
+const PASSWORD: &str = "correct horse battery staple";
+
+/// Register a user through the web form.
+fn register(client: &mut Client, name: &str) {
+ let email = format!("{name}@example.org");
+ let response = client.post_form(
+ "/register",
+ &[("name", name), ("email", &email), ("password", PASSWORD)],
+ );
+ assert_eq!(
+ response.status, 303,
+ "registering should log in\n{}",
+ response.body
+ );
+}
+
+/// Test: RemoteActor follows alice using RFC 9421 signature → Accept(Follow) is delivered
+#[tokio::test]
+async fn rfc9421_follow_is_accepted() -> miette::Result<()> {
+ let port = free_port();
+ let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
+ server.wait_for_connection(port);
+
+ let mut alice_client = Client::new(port);
+ register(&mut alice_client, "alice");
+
+ // Start a remote actor recorder
+ let recorder_port = free_port();
+ let recorder = RemoteActor::serve(recorder_port);
+
+ // Bob follows alice by sending a Follow activity to her inbox
+ let follow_json = serde_json::json!({
+ "@context": "https://www.w3.org/ns/activitystreams",
+ "id": format!("{}/follow/1", recorder.actor_url),
+ "type": "Follow",
+ "actor": recorder.actor_url,
+ "object": format!("http://127.0.0.1:{port}/~alice")
+ });
+ let body = serde_json::to_string(&follow_json).unwrap();
+
+ let (signature, signature_input, date, content_digest) = support::ap::signed_post_rfc9421(
+ &recorder.private_key_pem,
+ &recorder.key_id,
+ &format!("127.0.0.1:{port}"),
+ "/~alice/inbox",
+ &body,
+ );
+
+ let response = alice_client.post_json_with_headers(
+ "/~alice/inbox",
+ &body,
+ &[
+ ("Signature", &signature),
+ ("Signature-Input", &signature_input),
+ ("Date", &date),
+ ("Content-Digest", &content_digest),
+ ("Content-Type", "application/activity+json"),
+ ],
+ );
+
+ assert_eq!(
+ response.status, 202,
+ "RFC 9421 Follow should be accepted. Server log:"
+ );
+
+ // Poll for Accept(Follow) delivery
+ let deadline = Instant::now() + Duration::from_secs(10);
+ let mut found_accept = false;
+ while Instant::now() < deadline {
+ std::thread::sleep(Duration::from_millis(50));
+
+ let deliveries = recorder.recorded_deliveries();
+ for delivery in &deliveries {
+ if let Ok(activity) = serde_json::from_str::<serde_json::Value>(&delivery.body) {
+ if activity.get("type").and_then(|t| t.as_str()) == Some("Accept") {
+ if let Some(obj) = activity.get("object") {
+ if obj.get("type").and_then(|t| t.as_str()) == Some("Follow") {
+ found_accept = true;
+ break;
+ }
+ }
+ }
+ }
+ }
+ if found_accept {
+ break;
+ }
+ }
+
+ assert!(
+ found_accept,
+ "alice should send Accept(Follow) for RFC 9421 signed requests. Server log:"
+ );
+
+ Ok(())
+}
frogue/tests/support/ap.rs+36−0
@@ -607,6 +607,42 @@ pub fn signed_post(
(signature, date, digest)
}
+/// Build RFC 9421 HTTP Message Signature headers for an outbound POST.
+///
+/// Returns (Signature, Signature-Input, Date, Content-Digest) headers.
+pub fn signed_post_rfc9421(
+ private_key_pem: &str,
+ key_id: &str,
+ target_host: &str,
+ target_path: &str,
+ body: &str,
+) -> (String, String, String, String) {
+ let date = Utc::now().format("%a, %d %b %Y %H:%M:%S GMT").to_string();
+ let created = Utc::now().timestamp();
+ let body_bytes = body.as_bytes();
+ let content_digest = format!("sha-256=:{}:", BASE64.encode(Sha256::digest(body_bytes)));
+
+ // Build the signature base per RFC 9421
+ let target_uri = format!("http://{}{}", target_host, target_path);
+ let signature_base = format!(
+ "\"@method\": POST\n\
+ \"@target-uri\": {target_uri}\n\
+ \"content-digest\": {content_digest}\n\
+ \"@signature-params\": (\"@method\" \"@target-uri\" \"content-digest\");created={created};keyid=\"{key_id}\";alg=\"rsa-v1_5-sha256\""
+ );
+
+ let signature_bytes =
+ keys::sign(private_key_pem, signature_base.as_bytes()).expect("signing failed");
+ let signature_b64 = BASE64.encode(signature_bytes);
+
+ let signature_input = format!(
+ "sig1=(\"@method\" \"@target-uri\" \"content-digest\");created={created};keyid=\"{key_id}\";alg=\"rsa-v1_5-sha256\""
+ );
+ let signature = format!("sig1=:{signature_b64}:");
+
+ (signature, signature_input, date, content_digest)
+}
+
/// Handle a single connection to the remote actor server.
fn handle_connection(
mut stream: TcpStream,