1use std::time::Duration;
19
20use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
21use rand::distributions::Alphanumeric;
22use rand::{rngs::OsRng, Rng};
23use reqwest::Client;
24use rsa::pkcs8::{EncodePublicKey, LineEnding};
25use rsa::{Oaep, RsaPrivateKey, RsaPublicKey};
26use serde::{Deserialize, Serialize};
27use sha2::Sha256;
28use thiserror::Error;
29use tracing::{debug, warn};
30
31use aes::Aes256;
32use cfb_mode::cipher::{AsyncStreamCipher, KeyIvInit};
33type Aes256CfbDec = cfb_mode::Decryptor<Aes256>;
34
35#[derive(Debug, Error)]
38pub enum InteractshError {
39 #[error("interactsh keypair generation failed: {0}")]
40 KeyGen(String),
41 #[error("interactsh public-key encoding failed: {0}")]
42 KeyEncode(String),
43 #[error("interactsh register failed (HTTP {status}): {body}")]
44 Register { status: u16, body: String },
45 #[error("interactsh poll failed (HTTP {status}): {body}")]
46 Poll { status: u16, body: String },
47 #[error("interactsh response shape unexpected: {0}")]
48 BadResponse(String),
49 #[error("interactsh AES key unwrap failed: {0}")]
50 AesUnwrap(String),
51 #[error("interactsh interaction decrypt failed: {0}")]
52 Decrypt(String),
53 #[error("interactsh transport error: {0}")]
54 Transport(#[from] reqwest::Error),
55 #[error("interactsh request timed out after {0:?}")]
56 Timeout(Duration),
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
61pub enum InteractionProtocol {
62 Dns,
63 Http,
64 Smtp,
65 Other,
66}
67
68impl InteractionProtocol {
69 fn parse(s: &str) -> Self {
70 match s.to_ascii_lowercase().as_str() {
71 "dns" => Self::Dns,
72 "http" => Self::Http,
73 "smtp" | "smtp-mail" => Self::Smtp,
74 _ => Self::Other,
75 }
76 }
77}
78
79#[derive(Debug, Clone)]
81pub struct Interaction {
82 pub unique_id: String,
85 pub protocol: InteractionProtocol,
86 pub remote_address: String,
87 pub timestamp: String,
88 pub raw_payload: String,
92}
93
94pub struct InteractshClient {
98 http: Client,
99 server: String,
100 correlation_id: String,
101 secret_key: String,
102 private_key: RsaPrivateKey,
103 suffix_len: usize,
106}
107
108impl InteractshClient {
109 #[cfg(test)]
115 pub(crate) fn for_test(server: &str) -> Self {
116 let private_key = RsaPrivateKey::new(&mut OsRng, 1024).expect("test RSA key generates");
117 Self {
118 http: Client::new(),
119 server: normalize_server(server),
120 correlation_id: "abcdefghijklmnopqrst".to_string(),
121 secret_key: "test-secret".to_string(),
122 private_key,
123 suffix_len: 13,
124 }
125 }
126}
127
128#[derive(Serialize)]
132struct RegisterRequest<'a> {
133 #[serde(rename = "public-key")]
134 public_key: &'a str,
135 #[serde(rename = "secret-key")]
136 secret_key: &'a str,
137 #[serde(rename = "correlation-id")]
138 correlation_id: &'a str,
139}
140
141#[derive(Deserialize, Default)]
142#[serde(default)]
143struct PollResponse {
144 data: Vec<String>,
146 #[allow(dead_code)]
148 extra: Vec<String>,
149 aes_key: Option<String>,
152}
153
154#[derive(Deserialize, Default)]
158#[serde(default)]
159struct InteractionRaw {
160 protocol: String,
161 #[serde(rename = "unique-id")]
162 unique_id: String,
163 #[serde(rename = "full-id")]
164 full_id: String,
165 #[serde(rename = "remote-address")]
166 remote_address: String,
167 timestamp: String,
168 #[serde(rename = "raw-request")]
169 raw_request: String,
170 #[serde(rename = "raw-response")]
171 raw_response: String,
172 #[serde(rename = "q-type")]
173 q_type: String,
174}
175
176const MAX_RAW_PAYLOAD: usize = 16 * 1024;
177
178const MAX_POLL_BODY_BYTES: usize = 4 * 1024 * 1024;
184
185const ERROR_BODY_CAP: usize = 64 * 1024;
189
190async fn read_capped_bytes(
194 resp: reqwest::Response,
195 cap: usize,
196) -> Result<Vec<u8>, InteractshError> {
197 use futures_util::StreamExt;
198 let mut stream = resp.bytes_stream();
199 let mut buf: Vec<u8> = Vec::new();
200 while let Some(chunk) = stream.next().await {
201 let chunk = chunk.map_err(InteractshError::Transport)?;
202 if buf.len().saturating_add(chunk.len()) > cap {
203 return Err(InteractshError::BadResponse(format!(
204 "response body exceeds {cap}-byte cap"
205 )));
206 }
207 buf.extend_from_slice(&chunk);
208 }
209 Ok(buf)
210}
211
212async fn read_capped_text(resp: reqwest::Response, cap: usize) -> String {
216 use futures_util::StreamExt;
217 let mut stream = resp.bytes_stream();
218 let mut buf: Vec<u8> = Vec::new();
219 while let Some(chunk) = stream.next().await {
220 let Ok(chunk) = chunk else { break };
221 if buf.len().saturating_add(chunk.len()) > cap {
222 break;
223 }
224 buf.extend_from_slice(&chunk);
225 }
226 String::from_utf8_lossy(&buf).into_owned()
227}
228
229impl InteractshClient {
230 pub async fn register(http: Client, server: &str) -> Result<Self, InteractshError> {
233 let private_key = tokio::task::spawn_blocking(|| {
236 RsaPrivateKey::new(&mut OsRng, 2048).map_err(|e| InteractshError::KeyGen(e.to_string()))
237 })
238 .await
239 .map_err(|e| InteractshError::KeyGen(format!("join error: {e}")))??;
240
241 let public_key = RsaPublicKey::from(&private_key);
242 let pem = public_key
243 .to_public_key_pem(LineEnding::LF)
244 .map_err(|e| InteractshError::KeyEncode(e.to_string()))?;
245 let public_key_b64 = B64.encode(pem.as_bytes());
246
247 let correlation_id: String = OsRng
252 .sample_iter(&Alphanumeric)
253 .take(20)
254 .map(|b| (b as char).to_ascii_lowercase())
255 .collect();
256 let secret_key = uuid::Uuid::new_v4().to_string();
257
258 let server = normalize_server(server);
259
260 let body = RegisterRequest {
261 public_key: &public_key_b64,
262 secret_key: &secret_key,
263 correlation_id: &correlation_id,
264 };
265 let resp = http
266 .post(format!("{server}/register"))
267 .json(&body)
268 .send()
269 .await?;
270 let status = resp.status();
271 if !status.is_success() {
272 let body = read_capped_text(resp, ERROR_BODY_CAP).await;
273 return Err(InteractshError::Register {
274 status: status.as_u16(),
275 body: body.chars().take(256).collect(),
276 });
277 }
278 let _ = read_capped_bytes(resp, ERROR_BODY_CAP).await;
282 debug!(target: "keyhog::oob", correlation_id = %correlation_id, server = %server, "registered with interactsh collector");
283
284 Ok(Self {
285 http,
286 server,
287 correlation_id,
288 secret_key,
289 private_key,
290 suffix_len: 13,
291 })
292 }
293
294 pub fn mint_url(&self) -> MintedUrl {
299 let suffix: String = OsRng
300 .sample_iter(&Alphanumeric)
301 .take(self.suffix_len)
302 .map(|b| (b as char).to_ascii_lowercase())
303 .collect();
304 let unique_id = format!("{}{}", self.correlation_id, suffix);
305 let host = format!("{}.{}", unique_id, self.server_host());
306 let url = format!("https://{host}");
307 MintedUrl {
308 unique_id,
309 host,
310 url,
311 }
312 }
313
314 pub async fn poll(&self) -> Result<Vec<Interaction>, InteractshError> {
317 let resp = self
318 .http
319 .get(format!("{}/poll", self.server))
320 .query(&[("id", &self.correlation_id), ("secret", &self.secret_key)])
321 .send()
322 .await?;
323 let status = resp.status();
324 if !status.is_success() {
325 let body = read_capped_text(resp, ERROR_BODY_CAP).await;
326 return Err(InteractshError::Poll {
327 status: status.as_u16(),
328 body: body.chars().take(256).collect(),
329 });
330 }
331 let body = read_capped_bytes(resp, MAX_POLL_BODY_BYTES).await?;
337 let parsed: PollResponse = serde_json::from_slice(&body)
338 .map_err(|e| InteractshError::BadResponse(e.to_string()))?;
339 if parsed.data.is_empty() {
340 return Ok(Vec::new());
341 }
342 let aes_key_b64 = parsed.aes_key.ok_or_else(|| {
343 InteractshError::BadResponse("data present but aes_key missing".into())
344 })?;
345 let aes_key = self.unwrap_aes_key(&aes_key_b64)?;
346 if aes_key.len() != 32 {
347 return Err(InteractshError::AesUnwrap(format!(
348 "expected 32-byte AES-256 key, got {}",
349 aes_key.len()
350 )));
351 }
352
353 let mut out = Vec::with_capacity(parsed.data.len());
354 for entry in parsed.data {
355 match decrypt_entry(&aes_key, &entry) {
356 Ok(Some(interaction)) => out.push(interaction),
357 Ok(None) => {} Err(e) => {
359 warn!(target: "keyhog::oob", error = %e, "interactsh entry decrypt failed; skipping")
360 }
361 }
362 }
363 Ok(out)
364 }
365
366 pub async fn deregister(&self) -> Result<(), InteractshError> {
370 #[derive(Serialize)]
371 struct DeregisterRequest<'a> {
372 #[serde(rename = "correlation-id")]
373 correlation_id: &'a str,
374 #[serde(rename = "secret-key")]
375 secret_key: &'a str,
376 }
377 let _ = self
378 .http
379 .post(format!("{}/deregister", self.server))
380 .json(&DeregisterRequest {
381 correlation_id: &self.correlation_id,
382 secret_key: &self.secret_key,
383 })
384 .send()
385 .await?;
386 Ok(())
387 }
388
389 pub fn correlation_id(&self) -> &str {
390 &self.correlation_id
391 }
392
393 fn server_host(&self) -> &str {
395 self.server
397 .split_once("://")
398 .map(|(_, rest)| rest)
399 .unwrap_or(&self.server)
400 .trim_end_matches('/')
401 }
402
403 fn unwrap_aes_key(&self, b64: &str) -> Result<Vec<u8>, InteractshError> {
404 let wrapped = B64
405 .decode(b64.as_bytes())
406 .map_err(|e| InteractshError::AesUnwrap(format!("base64: {e}")))?;
407 let padding = Oaep::new::<Sha256>();
408 self.private_key
409 .decrypt(padding, &wrapped)
410 .map_err(|e| InteractshError::AesUnwrap(format!("rsa-oaep: {e}")))
411 }
412}
413
414#[derive(Debug, Clone)]
416pub struct MintedUrl {
417 pub unique_id: String,
419 pub host: String,
421 pub url: String,
423}
424
425fn decrypt_entry(aes_key: &[u8], b64: &str) -> Result<Option<Interaction>, InteractshError> {
426 let bytes = B64
427 .decode(b64.as_bytes())
428 .map_err(|e| InteractshError::Decrypt(format!("base64: {e}")))?;
429 if bytes.len() < 16 {
430 return Err(InteractshError::Decrypt(format!(
431 "ciphertext too short ({} < 16)",
432 bytes.len()
433 )));
434 }
435 let (iv, ct) = bytes.split_at(16);
436 let mut buf = ct.to_vec();
437 Aes256CfbDec::new_from_slices(aes_key, iv)
438 .map_err(|e| InteractshError::Decrypt(format!("cfb init: {e}")))?
439 .decrypt(&mut buf);
440 let json = match std::str::from_utf8(&buf) {
441 Ok(s) => s,
442 Err(_) => return Ok(None), };
444 let raw: InteractionRaw = match serde_json::from_str(json) {
445 Ok(v) => v,
446 Err(e) => {
447 debug!(target: "keyhog::oob", error = %e, "interactsh JSON parse failed; skipping entry");
448 return Ok(None);
449 }
450 };
451 let unique_id = if !raw.full_id.is_empty() {
452 raw.full_id
453 } else {
454 raw.unique_id
455 };
456 if unique_id.is_empty() {
457 return Ok(None);
458 }
459 let raw_payload = if !raw.raw_request.is_empty() {
462 raw.raw_request
463 } else if !raw.raw_response.is_empty() {
464 raw.raw_response
465 } else {
466 raw.q_type
467 };
468 let raw_payload: String = raw_payload.chars().take(MAX_RAW_PAYLOAD).collect();
469 Ok(Some(Interaction {
470 unique_id,
471 protocol: InteractionProtocol::parse(&raw.protocol),
472 remote_address: raw.remote_address,
473 timestamp: raw.timestamp,
474 raw_payload,
475 }))
476}
477
478fn normalize_server(s: &str) -> String {
482 let s = s.trim().trim_end_matches('/');
483 if let Some(rest) = s.strip_prefix("http://") {
484 format!("https://{rest}")
487 } else if s.starts_with("https://") {
488 s.to_string()
489 } else {
490 format!("https://{s}")
491 }
492}
493
494#[cfg(test)]
495mod tests {
496 use super::*;
497
498 #[test]
499 fn normalize_server_forces_https() {
500 assert_eq!(normalize_server("oast.fun"), "https://oast.fun");
501 assert_eq!(normalize_server("oast.fun/"), "https://oast.fun");
502 assert_eq!(normalize_server("https://oast.fun"), "https://oast.fun");
503 assert_eq!(normalize_server("http://oast.fun"), "https://oast.fun");
504 assert_eq!(normalize_server(" https://oast.fun/ "), "https://oast.fun");
505 }
506
507 #[test]
508 fn protocol_parse_is_case_insensitive() {
509 assert_eq!(InteractionProtocol::parse("DNS"), InteractionProtocol::Dns);
510 assert_eq!(
511 InteractionProtocol::parse("Http"),
512 InteractionProtocol::Http
513 );
514 assert_eq!(
515 InteractionProtocol::parse("smtp-mail"),
516 InteractionProtocol::Smtp
517 );
518 assert_eq!(
519 InteractionProtocol::parse("websocket"),
520 InteractionProtocol::Other
521 );
522 }
523
524 #[test]
528 fn decrypt_entry_round_trip() {
529 use aes::Aes256;
530 use cfb_mode::cipher::{AsyncStreamCipher, KeyIvInit};
531 type Enc = cfb_mode::Encryptor<Aes256>;
532
533 let aes_key = [0x42u8; 32];
534 let iv = [0x17u8; 16];
535 let payload = serde_json::json!({
536 "protocol": "http",
537 "unique-id": "abcdefghijklmnopqrstuvwxyz0123456",
538 "full-id": "abcdefghijklmnopqrstuvwxyz0123456",
539 "remote-address": "203.0.113.7",
540 "timestamp": "2026-05-06T00:00:00Z",
541 "raw-request": "GET /x HTTP/1.1\r\nHost: abc...example\r\n\r\n",
542 })
543 .to_string();
544 let mut buf = payload.as_bytes().to_vec();
545 Enc::new_from_slices(&aes_key, &iv)
546 .unwrap()
547 .encrypt(&mut buf);
548
549 let mut wire = Vec::with_capacity(16 + buf.len());
550 wire.extend_from_slice(&iv);
551 wire.extend_from_slice(&buf);
552 let b64 = B64.encode(&wire);
553
554 let interaction = decrypt_entry(&aes_key, &b64).unwrap().unwrap();
555 assert_eq!(interaction.unique_id.len(), 33);
556 assert_eq!(interaction.protocol, InteractionProtocol::Http);
557 assert_eq!(interaction.remote_address, "203.0.113.7");
558 assert!(interaction.raw_payload.starts_with("GET /x"));
559 }
560
561 #[test]
562 fn decrypt_entry_rejects_short_ciphertext() {
563 let err = decrypt_entry(&[0u8; 32], &B64.encode([1u8; 8])).unwrap_err();
564 match err {
565 InteractshError::Decrypt(msg) => assert!(msg.contains("too short")),
566 other => panic!("expected Decrypt, got {other:?}"),
567 }
568 }
569
570 #[test]
571 fn decrypt_entry_skips_invalid_json() {
572 let aes_key = [0u8; 32];
577 let iv = [0u8; 16];
578 let mut buf = b"definitely not json {{{".to_vec();
579 use aes::Aes256;
580 use cfb_mode::cipher::{AsyncStreamCipher, KeyIvInit};
581 type Enc = cfb_mode::Encryptor<Aes256>;
582 Enc::new_from_slices(&aes_key, &iv)
583 .unwrap()
584 .encrypt(&mut buf);
585 let mut wire = iv.to_vec();
586 wire.extend_from_slice(&buf);
587 let b64 = B64.encode(&wire);
588 assert!(decrypt_entry(&aes_key, &b64).unwrap().is_none());
589 }
590}