🐸 Frogue(Fed)

~ololduck/Frogue

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 reqwest::Client;
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), fields(handle = %handle))]
pub async fn discover_by_handle(
    db: &DatabaseConnection,
    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(host, username).await?
    };

    // Fetch and cache the actor document with signed GET
    fetch_actor_signed(db, &actor_url, public_url, signer, user_name).await
}

/// Perform webfinger lookup to find actor URL
#[tracing::instrument(skip(), fields(host = %host, username = %username))]
async fn webfinger_lookup(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 client = Client::new();
    let response = 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(
    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 client = Client::new();
    let response = 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,
    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(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), fields(actor_url = %actor_url))]
pub async fn resolve_actor(
    db: &DatabaseConnection,
    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");
    }

    // Fetch from remote
    info!(actor_url, "fetching remote actor");
    let client = Client::new();
    let response = client
        .get(actor_url)
        .header("Accept", "application/activity+json")
        .send()
        .await?;

    if !response.status().is_success() {
        return Err(ResolveError::FetchFailed(response.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);

    // 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,
    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, actor_url).await {
        Ok(actor) => actor,
        Err(ResolveError::NotFound) => return Ok(None),
        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::Error),
    #[error("actor not found")]
    NotFound,
    #[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),
}