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};
use aes::Aes256;
use cfb_mode::cipher::{AsyncStreamCipher, KeyIvInit};
type Aes256CfbDec = cfb_mode::Decryptor<Aes256>;
#[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 {
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 {
#[cfg(test)]
pub(crate) fn for_test(server: &str) -> Self {
let private_key = RsaPrivateKey::new(&mut OsRng, 1024).expect("test RSA key generates");
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>,
}
#[derive(Deserialize, Default)]
#[serde(default)]
struct InteractionRaw {
protocol: String,
#[serde(rename = "unique-id")]
unique_id: String,
#[serde(rename = "full-id")]
full_id: String,
#[serde(rename = "remote-address")]
remote_address: String,
timestamp: String,
#[serde(rename = "raw-request")]
raw_request: String,
#[serde(rename = "raw-response")]
raw_response: String,
#[serde(rename = "q-type")]
q_type: String,
}
const MAX_RAW_PAYLOAD: usize = 16 * 1024;
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,
};
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> {
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 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,
}
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 decrypt_entry(aes_key: &[u8], b64: &str) -> Result<Option<Interaction>, InteractshError> {
let bytes = B64
.decode(b64.as_bytes())
.map_err(|e| InteractshError::Decrypt(format!("base64: {e}")))?;
if bytes.len() < 16 {
return Err(InteractshError::Decrypt(format!(
"ciphertext too short ({} < 16)",
bytes.len()
)));
}
let (iv, ct) = bytes.split_at(16);
let mut buf = ct.to_vec();
Aes256CfbDec::new_from_slices(aes_key, iv)
.map_err(|e| InteractshError::Decrypt(format!("cfb init: {e}")))?
.decrypt(&mut buf);
let json = match std::str::from_utf8(&buf) {
Ok(s) => s,
Err(_) => return Ok(None), };
let raw: InteractionRaw = match serde_json::from_str(json) {
Ok(v) => v,
Err(e) => {
debug!(target: "keyhog::oob", error = %e, "interactsh JSON parse failed; skipping entry");
return Ok(None);
}
};
let unique_id = if !raw.full_id.is_empty() {
raw.full_id
} else {
raw.unique_id
};
if unique_id.is_empty() {
return Ok(None);
}
let raw_payload = if !raw.raw_request.is_empty() {
raw.raw_request
} else if !raw.raw_response.is_empty() {
raw.raw_response
} else {
raw.q_type
};
let raw_payload: String = raw_payload.chars().take(MAX_RAW_PAYLOAD).collect();
Ok(Some(Interaction {
unique_id,
protocol: InteractionProtocol::parse(&raw.protocol),
remote_address: raw.remote_address,
timestamp: raw.timestamp,
raw_payload,
}))
}
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}")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalize_server_forces_https() {
assert_eq!(normalize_server("oast.fun"), "https://oast.fun");
assert_eq!(normalize_server("oast.fun/"), "https://oast.fun");
assert_eq!(normalize_server("https://oast.fun"), "https://oast.fun");
assert_eq!(normalize_server("http://oast.fun"), "https://oast.fun");
assert_eq!(normalize_server(" https://oast.fun/ "), "https://oast.fun");
}
#[test]
fn protocol_parse_is_case_insensitive() {
assert_eq!(InteractionProtocol::parse("DNS"), InteractionProtocol::Dns);
assert_eq!(
InteractionProtocol::parse("Http"),
InteractionProtocol::Http
);
assert_eq!(
InteractionProtocol::parse("smtp-mail"),
InteractionProtocol::Smtp
);
assert_eq!(
InteractionProtocol::parse("websocket"),
InteractionProtocol::Other
);
}
#[test]
fn decrypt_entry_round_trip() {
use aes::Aes256;
use cfb_mode::cipher::{AsyncStreamCipher, KeyIvInit};
type Enc = cfb_mode::Encryptor<Aes256>;
let aes_key = [0x42u8; 32];
let iv = [0x17u8; 16];
let payload = serde_json::json!({
"protocol": "http",
"unique-id": "abcdefghijklmnopqrstuvwxyz0123456",
"full-id": "abcdefghijklmnopqrstuvwxyz0123456",
"remote-address": "203.0.113.7",
"timestamp": "2026-05-06T00:00:00Z",
"raw-request": "GET /x HTTP/1.1\r\nHost: abc...example\r\n\r\n",
})
.to_string();
let mut buf = payload.as_bytes().to_vec();
Enc::new_from_slices(&aes_key, &iv)
.unwrap()
.encrypt(&mut buf);
let mut wire = Vec::with_capacity(16 + buf.len());
wire.extend_from_slice(&iv);
wire.extend_from_slice(&buf);
let b64 = B64.encode(&wire);
let interaction = decrypt_entry(&aes_key, &b64).unwrap().unwrap();
assert_eq!(interaction.unique_id.len(), 33);
assert_eq!(interaction.protocol, InteractionProtocol::Http);
assert_eq!(interaction.remote_address, "203.0.113.7");
assert!(interaction.raw_payload.starts_with("GET /x"));
}
#[test]
fn decrypt_entry_rejects_short_ciphertext() {
let err = decrypt_entry(&[0u8; 32], &B64.encode([1u8; 8])).unwrap_err();
match err {
InteractshError::Decrypt(msg) => assert!(msg.contains("too short")),
other => panic!("expected Decrypt, got {other:?}"),
}
}
#[test]
fn decrypt_entry_skips_invalid_json() {
let aes_key = [0u8; 32];
let iv = [0u8; 16];
let mut buf = b"definitely not json {{{".to_vec();
use aes::Aes256;
use cfb_mode::cipher::{AsyncStreamCipher, KeyIvInit};
type Enc = cfb_mode::Encryptor<Aes256>;
Enc::new_from_slices(&aes_key, &iv)
.unwrap()
.encrypt(&mut buf);
let mut wire = iv.to_vec();
wire.extend_from_slice(&buf);
let b64 = B64.encode(&wire);
assert!(decrypt_entry(&aes_key, &b64).unwrap().is_none());
}
}