Skip to main content

keyhog_verifier/oob/
client.rs

1//! Low-level interactsh protocol client.
2//!
3//! A thin async wrapper around the projectdiscovery/interactsh-server register/
4//! poll/deregister endpoints. Stateless aside from the RSA keypair, secret,
5//! correlation id, and HTTP client — `OobSession` (in `session.rs`) layers
6//! the per-finding subscription, polling loop, and notification fan-out on top.
7//!
8//! ## Crypto invariants
9//!
10//! - RSA-2048, OAEP padding, SHA-256 hash and MGF — interactsh-server speaks
11//!   exactly this combination; `RSA_PKCS1_OAEP_PADDING` with SHA-256 in their
12//!   Go code. Other parameters won't decrypt.
13//! - AES-256-CFB with a 16-byte IV prepended to ciphertext. Each interaction
14//!   carries an independent IV; the AES key is per-poll-batch.
15//! - We never log credentials, public keys, or decrypted payloads. Errors
16//!   carry stable strings — useful for support, opaque to leaks.
17
18use 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/// All errors that can arise from the OOB client. `Transient` errors mean the
36/// caller should retry (network blip, rate-limit); everything else is final.
37#[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/// Protocol category of a received interaction.
60#[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/// One decrypted interaction returned by the collector.
80#[derive(Debug, Clone)]
81pub struct Interaction {
82    /// Full 33-char unique id (correlation-id || 13-char suffix). This is
83    /// what we match against per-finding URLs we minted.
84    pub unique_id: String,
85    pub protocol: InteractionProtocol,
86    pub remote_address: String,
87    pub timestamp: String,
88    /// Raw protocol payload (HTTP request line + headers, DNS query, etc.).
89    /// Sized — interactsh truncates server-side, but we cap to 16 KiB here as
90    /// a defense-in-depth budget against memory abuse from a hostile server.
91    pub raw_payload: String,
92}
93
94/// One interactsh registration. Cheap to clone (Arc-friendly fields only on
95/// caller's side; here we hold owned values because the session pins this
96/// for the lifetime of the engine).
97pub struct InteractshClient {
98    http: Client,
99    server: String,
100    correlation_id: String,
101    secret_key: String,
102    private_key: RsaPrivateKey,
103    /// Length of the per-URL suffix (interactsh uses 13 to bring total ID to
104    /// 33 chars). Exposed for tests; production always uses 13.
105    suffix_len: usize,
106}
107
108impl InteractshClient {
109    /// Test-only constructor that fabricates an `InteractshClient` without
110    /// going through `register()`. The HTTP client is real but never used
111    /// (tests drive the session directly via `store_and_notify_for_test`).
112    /// The RSA keypair is generated locally so any decrypt-side test
113    /// fixtures still see consistent crypto material.
114    #[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/// JSON shapes from interactsh-server. Field names match the upstream Go
129/// definitions (`pkg/server/types.go`). `serde(default)` keeps us forward-
130/// compatible with future fields.
131#[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    /// Each entry is base64( AES-256-CFB( IV[16] || ciphertext ) ).
145    data: Vec<String>,
146    /// Auxiliary metadata; ignored.
147    #[allow(dead_code)]
148    extra: Vec<String>,
149    /// Base64( RSA-OAEP-SHA256( 32-byte AES key ) ). Server omits when there
150    /// are no interactions; in that case `data` is also empty.
151    aes_key: Option<String>,
152}
153
154/// Decrypted interaction shape. `serde(default)` because interactsh-server
155/// sometimes ships partial events (failed protocol parse, etc.) and we'd
156/// rather degrade gracefully than 500.
157#[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
178/// Hard cap on the body of a `/poll` response. Protects the process from a
179/// hostile or misbehaving collector returning a multi-gigabyte JSON that
180/// would force `serde_json::from_slice` to allocate the whole thing
181/// in-memory before we can validate it. 4 MiB comfortably fits any
182/// reasonable poll batch — see the rationale at the call site.
183const MAX_POLL_BODY_BYTES: usize = 4 * 1024 * 1024;
184
185/// Cap on error/diagnostic bodies. We only display the first 256 chars in
186/// the error message anyway, but the cap prevents a server returning a
187/// 500 with a 1 GiB body from spiking memory.
188const ERROR_BODY_CAP: usize = 64 * 1024;
189
190/// Stream a response body into a Vec under a hard byte cap. Returns
191/// `BadResponse` if the cap is exceeded — abort the read rather than
192/// trust the server's framing.
193async 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
212/// Like `read_capped_bytes` but for diagnostic error messages — never
213/// returns `Err`; on a stream failure or cap breach it returns whatever
214/// was buffered so the error log can still surface something.
215async 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    /// Build, generate keys, and register with the collector. The returned
231    /// client is ready to mint URLs and be polled.
232    pub async fn register(http: Client, server: &str) -> Result<Self, InteractshError> {
233        // RSA-2048 keygen happens on a blocking thread — it's CPU-bound for
234        // ~100ms and would otherwise stall the runtime.
235        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        // Correlation id is 20 lowercase alphanumerics — interactsh-server
248        // matches incoming subdomains by this prefix, so the ID space must
249        // be wide enough that collisions are statistically impossible across
250        // every concurrent scanner sharing the collector. 36^20 ≈ 1.3e31.
251        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        // Drain (and discard) the register success body under a cap. Some
279        // interactsh deployments echo registration metadata; we don't need
280        // it but must not let the connection sit half-read indefinitely.
281        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    /// Mint a fresh callback URL bound to this session. The full 33-char
295    /// subdomain is returned (unique-id) plus the host the service should
296    /// hit. Caller is responsible for embedding it where the credential's
297    /// API will follow.
298    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    /// Poll once. Returns every interaction the collector has buffered for
315    /// this correlation id since the last poll.
316    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        // Bound the response body before deserialization. A malicious or
332        // misbehaving collector could otherwise blow process memory by
333        // returning a multi-gigabyte JSON. 4 MiB comfortably fits even a
334        // dense poll batch (≤100 interactions × ~16 KiB raw_payload each
335        // base64-expanded ≈ 2 MiB) with headroom.
336        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) => {} // unparseable JSON — skip, don't fail the batch
358                Err(e) => {
359                    warn!(target: "keyhog::oob", error = %e, "interactsh entry decrypt failed; skipping")
360                }
361            }
362        }
363        Ok(out)
364    }
365
366    /// Tear down the registration. Idempotent on the server side; a failure
367    /// to deregister is non-fatal — the server prunes inactive sessions
368    /// after its retention window.
369    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    /// `oast.fun` from `https://oast.fun/`.
394    fn server_host(&self) -> &str {
395        // strip scheme; we normalized at register time so no path component.
396        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/// One per-finding callback URL, returned from `InteractshClient::mint_url`.
415#[derive(Debug, Clone)]
416pub struct MintedUrl {
417    /// Full 33-char id; the value the service will reflect in DNS/HTTP host.
418    pub unique_id: String,
419    /// `<unique_id>.<server-host>` — bare host without scheme.
420    pub host: String,
421    /// `https://<host>` — convenience for HTTP-shaped probes.
422    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), // server hiccup; don't blow up the poll
443    };
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    // Prefer raw_request; fall back to raw_response then q_type so DNS-only
460    // interactions still carry diagnostic detail.
461    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
478/// Accept `oast.fun`, `oast.fun/`, `https://oast.fun`, `https://oast.fun/`.
479/// Always return `https://<host>` with no trailing slash. HTTP-only is
480/// rejected because the AES key flowing back must travel TLS-wrapped.
481fn normalize_server(s: &str) -> String {
482    let s = s.trim().trim_end_matches('/');
483    if let Some(rest) = s.strip_prefix("http://") {
484        // Force-upgrade. We never speak plaintext to a collector — the
485        // wrapped AES key would leak otherwise.
486        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    /// End-to-end crypto round trip with a fixed AES key + IV. Mirrors what
525    /// the server does: AES-256-CFB encrypt a JSON blob, prepend IV, base64.
526    /// We then run our decrypt path and confirm the parsed Interaction.
527    #[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        // Garbage that decrypts to non-JSON bytes (with a real AES key it'd be
573        // junk; with all-zero key + IV the plaintext is just the ciphertext
574        // XOR-pattern, which won't parse). Either way, we expect Ok(None),
575        // never a hard failure that aborts the whole poll batch.
576        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}