use std::sync::LazyLock;
use std::time::Duration;
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
use rand::{rngs::OsRng, Rng};
use reqwest::Client;
use rsa::pkcs8::{EncodePublicKey, LineEnding};
use rsa::{Oaep, RsaPrivateKey, RsaPublicKey};
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use thiserror::Error;
use tracing::{debug, warn};
const OOB_SERVICE: &str = "oob.interactsh";
const DNS_TOKEN_ALPHABET: &[u8; 36] = b"abcdefghijklmnopqrstuvwxyz0123456789";
const CORRELATION_ID_LEN: usize = 24;
const UNIQUE_SUFFIX_LEN: usize = 24;
#[derive(Debug, Error)]
pub enum InteractshError {
#[error("interactsh keypair generation failed: {0}")]
KeyGen(String),
#[error("interactsh public-key encoding failed: {0}")]
KeyEncode(String),
#[error("interactsh register failed (HTTP {status}): {body}")]
Register { status: u16, body: String },
#[error("interactsh deregister failed (HTTP {status}): {body}")]
Deregister { status: u16, body: String },
#[error("interactsh poll failed (HTTP {status}): {body}")]
Poll { status: u16, body: String },
#[error("interactsh response shape unexpected: {0}")]
BadResponse(String),
#[error("interactsh collector host blocked by SSRF guard: {0}")]
BlockedCollector(String),
#[error("interactsh AES key unwrap failed: {0}")]
AesUnwrap(String),
#[error("interactsh interaction decrypt failed: {0}")]
Decrypt(String),
#[error("interactsh transport error: {0}")]
Transport(#[from] reqwest::Error),
#[error("interactsh request timed out after {0:?}")]
Timeout(Duration),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum InteractionProtocol {
Dns,
Http,
Smtp,
Other,
}
impl InteractionProtocol {
#[doc(hidden)]
pub fn parse(s: &str) -> Self {
match s.to_ascii_lowercase().as_str() {
"dns" => Self::Dns,
"http" => Self::Http,
"smtp" | "smtp-mail" => Self::Smtp,
_ => Self::Other,
}
}
}
#[derive(Debug, Clone)]
pub struct Interaction {
pub unique_id: String,
pub protocol: InteractionProtocol,
pub remote_address: String,
pub timestamp: String,
pub raw_payload: String,
}
pub struct InteractshClient {
http: Client,
server: String,
correlation_id: String,
secret_key: String,
private_key: RsaPrivateKey,
suffix_len: usize,
}
static TEST_RSA_KEY: LazyLock<Result<RsaPrivateKey, String>> =
LazyLock::new(|| RsaPrivateKey::new(&mut OsRng, 2048).map_err(|e| e.to_string()));
impl InteractshClient {
pub(crate) fn for_test(server: &str) -> Result<Self, InteractshError> {
let private_key = TEST_RSA_KEY
.as_ref()
.map_err(|e| InteractshError::KeyGen(e.clone()))?
.clone();
Ok(Self {
http: Client::new(),
server: normalize_server(server),
correlation_id: "abcdefghijklmnopqrstuvwx".to_string(),
secret_key: "test-secret".to_string(),
private_key,
suffix_len: UNIQUE_SUFFIX_LEN,
})
}
}
#[derive(Serialize)]
struct RegisterRequest<'a> {
#[serde(rename = "public-key")]
public_key: &'a str,
#[serde(rename = "secret-key")]
secret_key: &'a str,
#[serde(rename = "correlation-id")]
correlation_id: &'a str,
}
#[derive(Deserialize, Default)]
#[serde(default)]
struct PollResponse {
data: Vec<String>,
#[serde(rename = "extra")]
_extra: Vec<String>,
aes_key: Option<String>,
}
const MAX_POLL_BODY_BYTES: usize = 4 * 1024 * 1024;
const ERROR_BODY_CAP: usize = 64 * 1024;
async fn read_capped_bytes(
resp: reqwest::Response,
cap: usize,
) -> Result<Vec<u8>, InteractshError> {
use futures_util::StreamExt;
let mut stream = resp.bytes_stream();
let mut buf: Vec<u8> = Vec::new();
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(InteractshError::Transport)?;
if buf.len().saturating_add(chunk.len()) > cap {
return Err(InteractshError::BadResponse(format!(
"response body exceeds {cap}-byte cap"
)));
}
buf.extend_from_slice(&chunk);
}
Ok(buf)
}
async fn read_capped_text(resp: reqwest::Response, cap: usize) -> String {
use futures_util::StreamExt;
let mut stream = resp.bytes_stream();
let mut buf: Vec<u8> = Vec::new();
while let Some(chunk) = stream.next().await {
let Ok(chunk) = chunk else { break };
if buf.len().saturating_add(chunk.len()) > cap {
break;
}
buf.extend_from_slice(&chunk);
}
String::from_utf8_lossy(&buf).into_owned()
}
impl InteractshClient {
pub async fn register(http: Client, server: &str) -> Result<Self, InteractshError> {
Self::register_with_network_policy(http, server, Duration::from_secs(30), false, false)
.await
}
pub(crate) async fn register_with_network_policy(
http: Client,
server: &str,
timeout: Duration,
proxy_in_use: bool,
insecure_tls: bool,
) -> Result<Self, InteractshError> {
let private_key = tokio::task::spawn_blocking(|| {
RsaPrivateKey::new(&mut OsRng, 2048).map_err(|e| InteractshError::KeyGen(e.to_string()))
})
.await
.map_err(|e| InteractshError::KeyGen(format!("join error: {e}")))??;
let public_key = RsaPublicKey::from(&private_key);
let pem = public_key
.to_public_key_pem(LineEnding::LF)
.map_err(|e| InteractshError::KeyEncode(e.to_string()))?;
let public_key_b64 = B64.encode(pem.as_bytes());
let correlation_id = random_dns_token(CORRELATION_ID_LEN);
let secret_key = uuid::Uuid::new_v4().to_string();
let server = normalize_server(server);
let collector_http =
collector_http_client(&http, &server, timeout, proxy_in_use, insecure_tls).await?;
let body = RegisterRequest {
public_key: &public_key_b64,
secret_key: &secret_key,
correlation_id: &correlation_id,
};
crate::rate_limit::get_rate_limiter()
.wait(OOB_SERVICE)
.await;
let resp = collector_http
.post(format!("{server}/register"))
.json(&body)
.send()
.await?;
let status = resp.status();
if !status.is_success() {
let body = read_capped_text(resp, ERROR_BODY_CAP).await;
return Err(InteractshError::Register {
status: status.as_u16(),
body: body.chars().take(256).collect(),
});
}
let _ = read_capped_bytes(resp, ERROR_BODY_CAP).await; debug!(target: "keyhog::oob", correlation_id = %correlation_id, server = %server, "registered with interactsh collector");
Ok(Self {
http: collector_http,
server,
correlation_id,
secret_key,
private_key,
suffix_len: UNIQUE_SUFFIX_LEN,
})
}
pub(crate) fn mint_url(&self) -> MintedUrl {
let suffix = random_dns_token(self.suffix_len);
let unique_id = format!("{}{}", self.correlation_id, suffix);
let host = format!("{}.{}", unique_id, self.server_host());
let url = format!("https://{host}");
MintedUrl {
unique_id,
host,
url,
}
}
pub async fn poll(&self) -> Result<Vec<Interaction>, InteractshError> {
crate::rate_limit::get_rate_limiter()
.wait(OOB_SERVICE)
.await;
let resp = self
.http
.get(format!("{}/poll", self.server))
.query(&[("id", &self.correlation_id), ("secret", &self.secret_key)])
.send()
.await?;
let status = resp.status();
if !status.is_success() {
let body = read_capped_text(resp, ERROR_BODY_CAP).await;
return Err(InteractshError::Poll {
status: status.as_u16(),
body: body.chars().take(256).collect(),
});
}
let body = read_capped_bytes(resp, MAX_POLL_BODY_BYTES).await?;
let parsed: PollResponse = serde_json::from_slice(&body)
.map_err(|e| InteractshError::BadResponse(e.to_string()))?;
if parsed.data.is_empty() {
return Ok(Vec::new());
}
let aes_key_b64 = parsed.aes_key.ok_or_else(|| {
InteractshError::BadResponse("data present but aes_key missing".into())
})?;
let aes_key = self.unwrap_aes_key(&aes_key_b64)?;
if aes_key.len() != 32 {
return Err(InteractshError::AesUnwrap(format!(
"expected 32-byte AES-256 key, got {}",
aes_key.len()
)));
}
let mut out = Vec::with_capacity(parsed.data.len());
for entry in parsed.data {
match super::decrypt::decrypt_entry(&aes_key, &entry) {
Ok(Some(interaction)) => out.push(interaction),
Ok(None) => {} Err(e) => {
warn!(target: "keyhog::oob", error = %e, "interactsh entry decrypt failed; skipping")
}
}
}
Ok(out)
}
pub async fn deregister(&self) -> Result<(), InteractshError> {
#[derive(Serialize)]
struct DeregisterRequest<'a> {
#[serde(rename = "correlation-id")]
correlation_id: &'a str,
#[serde(rename = "secret-key")]
secret_key: &'a str,
}
crate::rate_limit::get_rate_limiter()
.wait(OOB_SERVICE)
.await;
let resp = self
.http
.post(format!("{}/deregister", self.server))
.json(&DeregisterRequest {
correlation_id: &self.correlation_id,
secret_key: &self.secret_key,
})
.send()
.await?;
let status = resp.status();
if !status.is_success() {
let body: String = read_capped_text(resp, ERROR_BODY_CAP)
.await
.chars()
.take(256)
.collect();
warn!(target: "keyhog::oob", status = %status, body = %body, "interactsh deregister failed");
return Err(InteractshError::Deregister {
status: status.as_u16(),
body,
});
}
let _ = read_capped_bytes(resp, ERROR_BODY_CAP).await; Ok(())
}
pub(crate) fn correlation_id(&self) -> &str {
&self.correlation_id
}
fn server_host(&self) -> &str {
self.server
.split_once("://")
.map(|(_, rest)| rest)
.unwrap_or(&self.server) .trim_end_matches('/')
}
fn unwrap_aes_key(&self, b64: &str) -> Result<Vec<u8>, InteractshError> {
let wrapped = B64
.decode(b64.as_bytes())
.map_err(|e| InteractshError::AesUnwrap(format!("base64: {e}")))?;
let padding = Oaep::new::<Sha256>();
self.private_key
.decrypt(padding, &wrapped)
.map_err(|e| InteractshError::AesUnwrap(format!("rsa-oaep: {e}")))
}
}
fn random_dns_token(len: usize) -> String {
let mut rng = OsRng;
(0..len)
.map(|_| {
let idx = rng.gen_range(0..DNS_TOKEN_ALPHABET.len());
DNS_TOKEN_ALPHABET[idx] as char
})
.collect()
}
#[derive(Debug, Clone)]
pub struct MintedUrl {
pub unique_id: String,
pub host: String,
pub url: String,
}
async fn collector_http_client(
base_client: &Client,
server: &str,
timeout: Duration,
proxy_in_use: bool,
insecure_tls: bool,
) -> Result<Client, InteractshError> {
if crate::ssrf::is_private_url(server) {
return Err(InteractshError::BlockedCollector(format!(
"{server} resolves to a private/loopback/link-local address"
)));
}
let (host, host_port) = collector_host_and_port(server)?;
let resolved = crate::ssrf::resolve_dns_cached(&host_port).await;
match collector_client_plan(server, proxy_in_use, resolved)? {
CollectorClientPlan::UseProxy => Ok(base_client.clone()),
CollectorClientPlan::Pin(pinned_addrs) => {
crate::build_pinned_verifier_client(&host, &pinned_addrs, timeout, insecure_tls)
.map_err(|error| {
InteractshError::BlockedCollector(format!(
"{server} DNS pin client build failed ({error}); refusing an unpinned collector client"
))
})
}
}
}
enum CollectorClientPlan {
UseProxy,
Pin(Vec<std::net::SocketAddr>),
}
fn collector_client_plan(
server: &str,
proxy_in_use: bool,
resolved: std::io::Result<Vec<std::net::SocketAddr>>,
) -> Result<CollectorClientPlan, InteractshError> {
let addrs = resolved.map_err(|error| collector_dns_failure(server, error))?;
check_collector_resolved_addrs(server, &addrs)?;
if proxy_in_use {
Ok(CollectorClientPlan::UseProxy)
} else {
Ok(CollectorClientPlan::Pin(addrs))
}
}
pub(crate) fn ssrf_check_collector_dns_result_for_test(
server: &str,
resolved: std::io::Result<Vec<std::net::SocketAddr>>,
) -> Result<(), InteractshError> {
let _host_port = collector_host_and_port(server)?;
collector_client_plan(server, false, resolved).map(|_plan| ())
}
pub(crate) fn collector_reuses_proxy_client_for_test(
server: &str,
proxy_in_use: bool,
resolved: std::io::Result<Vec<std::net::SocketAddr>>,
) -> Result<bool, InteractshError> {
match collector_client_plan(server, proxy_in_use, resolved)? {
CollectorClientPlan::UseProxy => Ok(true),
CollectorClientPlan::Pin(_) => Ok(false),
}
}
fn collector_host_and_port(server: &str) -> Result<(String, String), InteractshError> {
let url = url::Url::parse(server).map_err(|_error| {
InteractshError::BlockedCollector(format!("{server} is not a parseable collector URL"))
})?;
let host = url.host_str().ok_or_else(|| {
InteractshError::BlockedCollector(format!("{server} has no collector host"))
})?;
let port = url
.port_or_known_default()
.unwrap_or(crate::DEFAULT_HTTPS_PORT); Ok((host.to_string(), format!("{host}:{port}")))
}
fn collector_dns_failure(server: &str, error: std::io::Error) -> InteractshError {
InteractshError::BlockedCollector(format!(
"{server} DNS resolution failed before SSRF screening: {error}; collector was not contacted"
))
}
fn check_collector_resolved_addrs(
server: &str,
addrs: &[std::net::SocketAddr],
) -> Result<(), InteractshError> {
if addrs.is_empty() {
return Err(InteractshError::BlockedCollector(format!(
"{server} DNS returned no addresses before SSRF screening; collector was not contacted"
)));
}
if addrs
.iter()
.any(|addr| crate::ssrf::is_private_ip_addr(&addr.ip()))
{
return Err(InteractshError::BlockedCollector(format!(
"{server} resolves to a private/loopback/link-local address"
)));
}
Ok(())
}
fn normalize_server(s: &str) -> String {
let s = s.trim();
let with_scheme = if let Some(rest) = s.strip_prefix("http://") {
format!("https://{rest}")
} else if s.starts_with("https://") {
s.to_string()
} else {
format!("https://{s}")
};
match url::Url::parse(&with_scheme) {
Ok(url) => match url.host_str() {
Some(host) => match url.port() {
Some(port) => format!("https://{host}:{port}"),
None => format!("https://{host}"),
},
None => with_scheme.trim_end_matches('/').to_string(),
},
Err(_invalid_collector) => with_scheme.trim_end_matches('/').to_string(),
}
}