8802779ebcb7d4726342c28944c06e17b4a38e82 / frogue / src / activitypub / remote / mod.rs
Raw
//! Remote ActivityPub actor resolution and caching.
//!
//! When we receive an activity from a remote actor, we need to verify their
//! signature using their public key. We fetch their actor document, cache it,
//! and use it for signature verification.
//!
//! Phase 1 adds client-side discovery: resolving @user@host handles via
//! webfinger and fetching remote actor documents with signed GET requests.
pub mod notes;
use std::net::{IpAddr, Ipv4Addr};
use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set};
use serde::Deserialize;
use tracing::{debug, error, info};
use url::Url;
use crate::config::*;
use crate::entities::ap_remote_actors;
use crate::entities::prelude::ApRemoteActors;
#[derive(Debug, Deserialize)]
struct RemoteActor {
id: String,
#[serde(default)]
inbox: Option<String>,
#[serde(default)]
endpoints: Option<Endpoints>,
#[serde(rename = "publicKey")]
public_key: PublicKey,
#[serde(default)]
name: Option<String>,
#[serde(default, rename = "preferredUsername")]
preferred_username: Option<String>,
}
#[derive(Debug, Deserialize)]
struct Endpoints {
#[serde(rename = "sharedInbox")]
shared_inbox: Option<String>,
}
#[derive(Debug, Deserialize)]
struct PublicKey {
id: String,
#[serde(rename = "publicKeyPem")]
public_key_pem: String,
}
/// Webfinger JRD (JSON Resource Descriptor) response
#[derive(Debug, Deserialize)]
struct JrdResponse {
#[serde(default)]
links: Vec<JrdLink>,
}
#[derive(Debug, Deserialize)]
struct JrdLink {
rel: String,
#[serde(default)]
r#type: Option<String>,
#[serde(default)]
href: Option<String>,
}
/// Check if an IP address is private, loopback, or link-local (SSRF protection)
fn is_private_ip(ip: IpAddr) -> bool {
match ip {
IpAddr::V4(ipv4) => {
ipv4.is_loopback()
|| ipv4.is_private()
|| ipv4.is_link_local()
|| ipv4 == Ipv4Addr::new(169, 254, 0, 0) // Link-local range
|| (ipv4 >= Ipv4Addr::new(169, 254, 0, 0) && ipv4 <= Ipv4Addr::new(169, 254, 255, 255))
}
IpAddr::V6(ipv6) => {
ipv6.is_loopback()
|| (ipv6.segments()[0] & 0xfe00) == 0xfc00 // Unique local address
|| (ipv6.segments()[0] & 0xffc0) == 0xfe80 // Link-local
}
}
}
/// Check if a hostname resolves to a private IP address
async fn check_ssrf(hostname: &str) -> Result<(), ResolveError> {
// Allow localhost in development/testing
if std::env::var(FROGUE_ALLOW_PRIVATE_HOSTS_VAR_KEY).is_ok() {
return Ok(());
}
// Resolve hostname to IP addresses
let addrs = tokio::net::lookup_host(format!("{}:0", hostname))
.await
.map_err(|e| ResolveError::SsrfViolation(format!("DNS resolution failed: {}", e)))?;
for addr in addrs {
if is_private_ip(addr.ip()) {
return Err(ResolveError::SsrfViolation(format!(
"Host {} resolves to private IP {}",
hostname,
addr.ip()
)));
}
}
Ok(())
}
/// Discover a remote actor by handle or URL
///
/// Accepts:
/// - @user@host or user@host (webfinger lookup)
/// - https://example.com/users/alice (direct URL)
#[tracing::instrument(skip(db, signer, http_client), fields(handle = %handle))]
pub async fn discover_by_handle(
db: &DatabaseConnection,
http_client: &reqwest_middleware::ClientWithMiddleware,
public_url: &str,
signer: &crate::entities::ap_actors::Model,
user_name: &str,
handle: &str,
) -> Result<ap_remote_actors::Model, ResolveError> {
let handle = handle.trim();
// Parse the handle/URL
let actor_url = if handle.starts_with("https://") || handle.starts_with("http://") {
// Direct URL - validate host
let url = Url::parse(handle)
.map_err(|e| ResolveError::InvalidHandle(format!("Invalid URL: {}", e)))?;
let host = url
.host_str()
.ok_or_else(|| ResolveError::InvalidHandle("URL has no host".to_string()))?;
check_ssrf(host).await?;
handle.to_string()
} else {
// Handle format: @user@host or user@host
let handle_clean = handle.trim_start_matches('@');
let parts: Vec<&str> = handle_clean.split('@').collect();
if parts.len() != 2 {
return Err(ResolveError::InvalidHandle(
"Handle must be in format @user@host or user@host".to_string(),
));
}
let username = parts[0];
let host = parts[1];
// Validate hostname (no path components, etc.)
if host.contains('/') || host.contains('?') || host.contains('#') {
return Err(ResolveError::InvalidHandle(
"Invalid hostname in handle".to_string(),
));
}
// Check SSRF before making webfinger request
check_ssrf(host).await?;
// Perform webfinger lookup
webfinger_lookup(http_client, host, username).await?
};
// Fetch and cache the actor document with signed GET
fetch_actor_signed(db, http_client, &actor_url, public_url, signer, user_name).await
}
/// Perform webfinger lookup to find actor URL
#[tracing::instrument(skip(http_client), fields(host = %host, username = %username))]
async fn webfinger_lookup(
http_client: &reqwest_middleware::ClientWithMiddleware,
host: &str,
username: &str,
) -> Result<String, ResolveError> {
// Use http:// for dev/test (when private hosts are allowed), https:// for production
let scheme = if std::env::var(FROGUE_ALLOW_PRIVATE_HOSTS_VAR_KEY).is_ok() {
"http"
} else {
"https"
};
let webfinger_url = format!(
"{}://{}/.well-known/webfinger?resource=acct:{}@{}",
scheme, host, username, host
);
info!(url = %webfinger_url, "performing webfinger lookup");
let response = http_client
.get(&webfinger_url)
.header("Accept", "application/jrd+json")
.send()
.await?;
if !response.status().is_success() {
return Err(ResolveError::WebfingerFailed(format!(
"Webfinger request failed with status {}",
response.status()
)));
}
let jrd: JrdResponse = response.json().await?;
// Find the self link with ActivityPub type
let actor_url = jrd
.links
.iter()
.find(|link| {
link.rel == "self" && link.r#type.as_deref() == Some("application/activity+json")
})
.and_then(|link| link.href.clone())
.ok_or_else(|| {
ResolveError::WebfingerFailed(
"No ActivityPub actor URL found in webfinger response".to_string(),
)
})?;
// Validate the actor URL's host matches the webfinger host (prevent JRD redirect attacks)
let url = Url::parse(&actor_url)
.map_err(|e| ResolveError::WebfingerFailed(format!("Invalid actor URL in JRD: {}", e)))?;
// Extract the full authority (host + port if present) for comparison
let actor_authority = if let Some(port) = url.port() {
format!("{}:{}", url.host_str().unwrap_or(""), port)
} else {
url.host_str().unwrap_or("").to_string()
};
if actor_authority != host {
return Err(ResolveError::WebfingerFailed(format!(
"Actor URL host {} doesn't match webfinger host {}",
actor_authority, host
)));
}
info!(actor_url = %actor_url, "webfinger lookup successful");
Ok(actor_url)
}
/// Fetch any URL with HTTP signature and return parsed JSON
///
/// This is a generic helper for fetching ActivityPub objects with proper signing.
/// Used for both actor documents and note objects.
pub async fn signed_get(
http_client: &reqwest_middleware::ClientWithMiddleware,
url_str: &str,
public_url: &str,
signer: &crate::entities::ap_actors::Model,
user_name: &str,
) -> Result<serde_json::Value, ResolveError> {
use chrono::Utc;
let url = Url::parse(url_str)
.map_err(|e| ResolveError::InvalidHandle(format!("Invalid URL: {}", e)))?;
let host = url
.host_str()
.ok_or_else(|| ResolveError::InvalidHandle("URL has no host".to_string()))?;
// Check SSRF before making the request
check_ssrf(host).await?;
let path = url.path();
let date = Utc::now().format("%a, %d %b %Y %H:%M:%S GMT").to_string();
// Build key ID for signing using ApUrls
let urls = crate::activitypub::ApUrls::new(public_url);
let key_id = urls.key_id(user_name);
// Sign the GET request
let signature = crate::activitypub::signatures::sign_get(
&signer.private_key_pem,
&key_id,
path,
host,
&date,
)
.map_err(|e| ResolveError::SigningFailed(e.to_string()))?;
info!(url = %url_str, "fetching with signed GET");
let response = http_client
.get(url_str)
.header("Accept", "application/activity+json")
.header("Date", &date)
.header("Signature", &signature)
.send()
.await?;
if !response.status().is_success() {
return Err(ResolveError::FetchFailed(response.status().as_u16()));
}
let json: serde_json::Value = response.json().await?;
Ok(json)
}
/// Fetch a remote actor document with HTTP signature
async fn fetch_actor_signed(
db: &DatabaseConnection,
http_client: &reqwest_middleware::ClientWithMiddleware,
actor_url: &str,
public_url: &str,
signer: &crate::entities::ap_actors::Model,
user_name: &str,
) -> Result<ap_remote_actors::Model, ResolveError> {
info!(actor_url = %actor_url, "fetching remote actor with signed GET");
let json = signed_get(http_client, actor_url, public_url, signer, user_name).await?;
let remote_actor: RemoteActor = serde_json::from_value(json)
.map_err(|e| ResolveError::InvalidHandle(format!("Invalid actor document: {}", e)))?;
// Validate the fetched actor's id matches what we requested
if remote_actor.id != actor_url {
return Err(ResolveError::IdMismatch {
requested: actor_url.to_string(),
received: remote_actor.id,
});
}
let inbox_url = remote_actor.inbox.ok_or(ResolveError::MissingInbox)?;
let shared_inbox_url = remote_actor.endpoints.and_then(|e| e.shared_inbox);
// Check if we already have this actor cached
if let Some(existing) = ApRemoteActors::find()
.filter(ap_remote_actors::Column::ActorUrl.eq(actor_url))
.one(db)
.await?
{
// Update existing record
let mut active: ap_remote_actors::ActiveModel = existing.into();
active.inbox_url = Set(inbox_url);
active.shared_inbox_url = Set(shared_inbox_url);
active.public_key_pem = Set(remote_actor.public_key.public_key_pem);
active.public_key_id = Set(remote_actor.public_key.id);
active.name = Set(remote_actor.name);
active.preferred_username = Set(remote_actor.preferred_username);
active.fetched_at = Set(chrono::Utc::now().into());
let updated = active.update(db).await?;
info!(actor_url, "updated cached remote actor");
Ok(updated)
} else {
// Insert into cache
let model = ap_remote_actors::ActiveModel {
actor_url: Set(remote_actor.id.clone()),
inbox_url: Set(inbox_url),
shared_inbox_url: Set(shared_inbox_url),
public_key_pem: Set(remote_actor.public_key.public_key_pem),
public_key_id: Set(remote_actor.public_key.id),
name: Set(remote_actor.name),
preferred_username: Set(remote_actor.preferred_username),
..Default::default()
};
let inserted = model.insert(db).await?;
info!(actor_url, "cached remote actor");
Ok(inserted)
}
}
/// Resolve a remote actor from their actor URL.
///
/// First checks the cache. If not found or if the key_id doesn't match,
/// fetches the actor document from the remote server.
/// If the cache entry is older than 24 hours, re-fetches and updates it in place.
#[tracing::instrument(skip(db, http_client), fields(actor_url = %actor_url))]
pub async fn resolve_actor(
db: &DatabaseConnection,
http_client: &reqwest_middleware::ClientWithMiddleware,
actor_url: &str,
) -> Result<ap_remote_actors::Model, ResolveError> {
// Check cache first
if let Some(cached) = ApRemoteActors::find()
.filter(ap_remote_actors::Column::ActorUrl.eq(actor_url))
.one(db)
.await?
{
// Check if cache is stale (older than 24 hours)
let now = chrono::Utc::now();
let age = now.signed_duration_since(cached.fetched_at);
if age.num_hours() < 24 {
debug!(actor_url, "using cached remote actor");
return Ok(cached);
}
// Cache is stale, re-fetch
info!(actor_url, "cache stale, re-fetching remote actor");
}
// Check tombstone cache (negative cache for gone actors)
if let Some(tombstone) = crate::entities::ap_remote_actor_tombstones::Entity::find()
.filter(crate::entities::ap_remote_actor_tombstones::Column::ActorUrl.eq(actor_url))
.one(db)
.await?
{
let now = chrono::Utc::now();
let age = now.signed_duration_since(tombstone.gone_at);
// Tombstones are valid for 30 days
if age.num_days() < 30 {
info!(actor_url, "actor is in tombstone cache (gone)");
return Err(ResolveError::Gone);
}
// Tombstone is stale, will attempt re-fetch
info!(actor_url, "tombstone stale, attempting re-fetch");
}
// Fetch from remote
info!(actor_url, "fetching remote actor");
let response = http_client
.get(actor_url)
.header("Accept", "application/activity+json")
.send()
.await?;
let status = response.status();
if status == reqwest::StatusCode::GONE {
info!(actor_url, "remote actor is gone (410), creating tombstone");
// Create or update tombstone
let now = chrono::Utc::now();
if let Some(existing_tombstone) =
crate::entities::ap_remote_actor_tombstones::Entity::find()
.filter(crate::entities::ap_remote_actor_tombstones::Column::ActorUrl.eq(actor_url))
.one(db)
.await?
{
// Update existing tombstone
let mut active: crate::entities::ap_remote_actor_tombstones::ActiveModel =
existing_tombstone.into();
active.gone_at = Set(now.into());
active.update(db).await?;
} else {
// Insert new tombstone
let tombstone = crate::entities::ap_remote_actor_tombstones::ActiveModel {
actor_url: Set(actor_url.to_string()),
gone_at: Set(now.into()),
..Default::default()
};
tombstone.insert(db).await?;
}
return Err(ResolveError::Gone);
}
if !status.is_success() {
return Err(ResolveError::FetchFailed(status.as_u16()));
}
let remote_actor: RemoteActor = response.json().await?;
// Validate the fetched actor's id matches what we requested
if remote_actor.id != actor_url {
return Err(ResolveError::IdMismatch {
requested: actor_url.to_string(),
received: remote_actor.id,
});
}
let inbox_url = remote_actor.inbox.ok_or(ResolveError::MissingInbox)?;
let shared_inbox_url = remote_actor.endpoints.and_then(|e| e.shared_inbox);
// Actor is back! Remove any tombstone
crate::entities::ap_remote_actor_tombstones::Entity::delete_many()
.filter(crate::entities::ap_remote_actor_tombstones::Column::ActorUrl.eq(actor_url))
.exec(db)
.await?;
// Check if we already have this actor cached
if let Some(existing) = ApRemoteActors::find()
.filter(ap_remote_actors::Column::ActorUrl.eq(actor_url))
.one(db)
.await?
{
// Update existing record
let mut active: ap_remote_actors::ActiveModel = existing.into();
active.inbox_url = Set(inbox_url);
active.shared_inbox_url = Set(shared_inbox_url);
active.public_key_pem = Set(remote_actor.public_key.public_key_pem);
active.public_key_id = Set(remote_actor.public_key.id);
active.name = Set(remote_actor.name);
active.preferred_username = Set(remote_actor.preferred_username);
active.fetched_at = Set(chrono::Utc::now().into());
let updated = active.update(db).await?;
info!(actor_url, "updated cached remote actor");
Ok(updated)
} else {
// Insert into cache
let model = ap_remote_actors::ActiveModel {
actor_url: Set(remote_actor.id.clone()),
inbox_url: Set(inbox_url),
shared_inbox_url: Set(shared_inbox_url),
public_key_pem: Set(remote_actor.public_key.public_key_pem),
public_key_id: Set(remote_actor.public_key.id),
name: Set(remote_actor.name),
preferred_username: Set(remote_actor.preferred_username),
..Default::default()
};
let inserted = model.insert(db).await?;
info!(actor_url, "cached remote actor");
Ok(inserted)
}
}
/// Resolve a public key from a keyId URL.
///
/// The keyId is typically the actor URL with a fragment (e.g., "#main-key").
/// We strip the fragment, resolve the actor, and verify the key ID matches.
pub async fn resolve_key(
db: &DatabaseConnection,
http_client: &reqwest_middleware::ClientWithMiddleware,
key_id: &str,
) -> Result<Option<(String, String)>, ResolveError> {
// Strip fragment from keyId to get actor URL
let actor_url = match key_id.split_once('#') {
Some((url, _fragment)) => url,
None => key_id,
};
let actor = match resolve_actor(db, http_client, actor_url).await {
Ok(actor) => actor,
Err(ResolveError::NotFound) => return Ok(None),
Err(ResolveError::Gone) => return Err(ResolveError::Gone),
Err(e) => return Err(e),
};
// Verify the key ID matches
if actor.public_key_id != key_id {
error!(
key_id,
cached_key_id = actor.public_key_id,
"key ID mismatch"
);
return Ok(None);
}
Ok(Some((actor.actor_url, actor.public_key_pem)))
}
#[derive(Debug, thiserror::Error)]
pub enum ResolveError {
#[error("database error: {0}")]
Database(#[from] sea_orm::DbErr),
#[error("HTTP error: {0}")]
Http(#[from] reqwest_middleware::Error),
#[error("HTTP error: {0}")]
Reqwest(#[from] reqwest::Error),
#[error("actor not found")]
NotFound,
#[error("actor is gone")]
Gone,
#[error("fetch failed with status {0}")]
FetchFailed(u16),
#[error("actor ID mismatch: requested {requested}, received {received}")]
IdMismatch { requested: String, received: String },
#[error("actor missing inbox")]
MissingInbox,
#[error("invalid handle: {0}")]
InvalidHandle(String),
#[error("webfinger lookup failed: {0}")]
WebfingerFailed(String),
#[error("SSRF violation: {0}")]
SsrfViolation(String),
#[error("signing failed: {0}")]
SigningFailed(String),
}