use std::time::Duration;
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
use rand::distributions::Alphanumeric;
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";
#[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 poll failed (HTTP {status}): {body}")]
Poll { status: u16, body: String },
#[error("interactsh response shape unexpected: {0}")]
BadResponse(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 {
pub(super) 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,
}
impl InteractshClient {
pub fn for_test(server: &str) -> Result<Self, InteractshError> {
let private_key = RsaPrivateKey::new(&mut OsRng, 1024)
.map_err(|e| InteractshError::KeyGen(e.to_string()))?;
Ok(Self {
http: Client::new(),
server: normalize_server(server),
correlation_id: "abcdefghijklmnopqrst".to_string(),
secret_key: "test-secret".to_string(),
private_key,
suffix_len: 13,
})
}
}
#[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>,
#[allow(dead_code)]
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> {
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: String = OsRng
.sample_iter(&Alphanumeric)
.take(20)
.map(|b| (b as char).to_ascii_lowercase())
.collect();
let secret_key = uuid::Uuid::new_v4().to_string();
let server = normalize_server(server);
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 = 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,
server,
correlation_id,
secret_key,
private_key,
suffix_len: 13,
})
}
pub fn mint_url(&self) -> MintedUrl {
let suffix: String = OsRng
.sample_iter(&Alphanumeric)
.take(self.suffix_len)
.map(|b| (b as char).to_ascii_lowercase())
.collect();
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 _ = self
.http
.post(format!("{}/deregister", self.server))
.json(&DeregisterRequest {
correlation_id: &self.correlation_id,
secret_key: &self.secret_key,
})
.send()
.await?;
Ok(())
}
pub 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}")))
}
}
#[derive(Debug, Clone)]
pub struct MintedUrl {
pub unique_id: String,
pub host: String,
pub url: String,
}
fn normalize_server(s: &str) -> String {
let s = s.trim().trim_end_matches('/');
if let Some(rest) = s.strip_prefix("http://") {
format!("https://{rest}")
} else if s.starts_with("https://") {
s.to_string()
} else {
format!("https://{s}")
}
}