stygian_graph/adapters/
signing.rs

1//! Request signing adapters.
2//!
3//! Provides concrete [`crate::ports::signing::SigningPort`] implementations:
4//!
5//! | Adapter | Use case |
6//! |---|---|
7//! | [`crate::adapters::signing::NoopSigningAdapter`] | Testing / no-op passthrough |
8//! | [`crate::adapters::signing::HttpSigningAdapter`] | Delegate to any external signing sidecar over HTTP |
9//!
10//! # Frida RPC bridge example
11//!
12//! Run a Frida sidecar that exposes a POST /sign endpoint, then wire it in:
13//!
14//! ```no_run
15//! use stygian_graph::adapters::signing::{HttpSigningAdapter, HttpSigningConfig};
16//!
17//! let signer = HttpSigningAdapter::new(HttpSigningConfig {
18//!     endpoint: "http://localhost:27042/sign".to_string(),
19//!     ..Default::default()
20//! });
21//! ```
22//!
23//! # AWS Signature V4 / custom HMAC
24//!
25//! Implement [`crate::ports::signing::SigningPort`] directly, or point [`crate::adapters::signing::HttpSigningAdapter`] at a
26//! lightweight signing sidecar that handles key material and algorithm details.
27
28use std::collections::HashMap;
29use std::time::Duration;
30
31use reqwest::Client;
32use serde::{Deserialize, Serialize};
33
34use crate::ports::signing::{SigningError, SigningInput, SigningOutput, SigningPort};
35
36#[cfg(test)]
37use crate::ports::signing::ErasedSigningPort;
38
39// ─────────────────────────────────────────────────────────────────────────────
40// NoopSigningAdapter
41// ─────────────────────────────────────────────────────────────────────────────
42
43/// A no-op [`SigningPort`] that passes requests through unsigned.
44///
45/// Useful as a default when an adapter accepts an optional signer, and as a
46/// stand-in during testing.
47///
48/// # Example
49///
50/// ```rust
51/// use stygian_graph::adapters::signing::NoopSigningAdapter;
52/// use stygian_graph::ports::signing::{SigningPort, SigningInput};
53/// use serde_json::json;
54///
55/// # tokio::runtime::Runtime::new().unwrap().block_on(async {
56/// let signer = NoopSigningAdapter;
57/// let output = signer.sign(SigningInput {
58///     method: "GET".to_string(),
59///     url: "https://example.com".to_string(),
60///     headers: Default::default(),
61///     body: None,
62///     context: json!({}),
63/// }).await.unwrap();
64/// assert!(output.headers.is_empty());
65/// # });
66/// ```
67pub struct NoopSigningAdapter;
68
69impl SigningPort for NoopSigningAdapter {
70    async fn sign(&self, _input: SigningInput) -> Result<SigningOutput, SigningError> {
71        Ok(SigningOutput::default())
72    }
73}
74
75// ─────────────────────────────────────────────────────────────────────────────
76// HttpSigningAdapter
77// ─────────────────────────────────────────────────────────────────────────────
78
79/// Configuration for [`HttpSigningAdapter`].
80///
81/// # Example
82///
83/// ```rust
84/// use stygian_graph::adapters::signing::HttpSigningConfig;
85/// use std::time::Duration;
86///
87/// let config = HttpSigningConfig {
88///     endpoint: "http://localhost:27042/sign".to_string(),
89///     timeout: Duration::from_secs(5),
90///     bearer_token: Some("my-sidecar-auth-token".to_string()),
91///     extra_headers: Default::default(),
92/// };
93/// ```
94#[derive(Debug, Clone)]
95pub struct HttpSigningConfig {
96    /// Full URL of the signing sidecar endpoint (e.g. `http://localhost:27042/sign`)
97    pub endpoint: String,
98    /// Request timeout to the signing sidecar (default: 10 seconds)
99    pub timeout: Duration,
100    /// Optional bearer token to authenticate with the sidecar itself
101    pub bearer_token: Option<String>,
102    /// Additional static headers to send to the sidecar
103    pub extra_headers: HashMap<String, String>,
104}
105
106impl Default for HttpSigningConfig {
107    fn default() -> Self {
108        Self {
109            endpoint: "http://localhost:27042/sign".to_string(),
110            timeout: Duration::from_secs(10),
111            bearer_token: None,
112            extra_headers: HashMap::new(),
113        }
114    }
115}
116
117/// Wire format for the signing request sent to the sidecar.
118#[derive(Debug, Serialize)]
119struct SignRequest {
120    method: String,
121    url: String,
122    headers: HashMap<String, String>,
123    #[serde(skip_serializing_if = "Option::is_none")]
124    body_b64: Option<String>,
125    context: serde_json::Value,
126}
127
128/// Wire format for the signing response received from the sidecar.
129#[derive(Debug, Deserialize)]
130struct SignResponse {
131    #[serde(default)]
132    headers: HashMap<String, String>,
133    #[serde(default)]
134    query_params: Vec<(String, String)>,
135    #[serde(default)]
136    body_b64: Option<String>,
137}
138
139/// A [`SigningPort`] that delegates to an external HTTP signing sidecar.
140///
141/// The sidecar receives a JSON payload describing the outbound request and
142/// returns the headers / query params / body override to apply. This pattern
143/// works for:
144///
145/// - **Frida RPC bridges** — a Python/Node sidecar attached to a running mobile
146///   app that calls the native `.so` signing function and exposes the result
147/// - **AWS Signature V4** — a lightweight server that knows your AWS credentials
148/// - **OAuth 1.0a** — sign Twitter/X API v1 requests via a sidecar that holds
149///   the consumer secret
150/// - **Any custom HMAC scheme** — keep key material out of the main process
151///
152/// # Example
153///
154/// ```no_run
155/// use stygian_graph::adapters::signing::{HttpSigningAdapter, HttpSigningConfig};
156/// use stygian_graph::ports::signing::{SigningPort, SigningInput};
157/// use serde_json::json;
158///
159/// # tokio::runtime::Runtime::new().unwrap().block_on(async {
160/// let signer = HttpSigningAdapter::new(HttpSigningConfig {
161///     endpoint: "http://localhost:27042/sign".to_string(),
162///     ..Default::default()
163/// });
164///
165/// let output = signer.sign(SigningInput {
166///     method: "GET".to_string(),
167///     url: "https://api.tinder.com/v2/profile".to_string(),
168///     headers: Default::default(),
169///     body: None,
170///     context: json!({}),
171/// }).await.unwrap();
172///
173/// for (k, v) in &output.headers {
174///     println!("{k}: {v}");
175/// }
176/// # });
177/// ```
178pub struct HttpSigningAdapter {
179    config: HttpSigningConfig,
180    client: Client,
181}
182
183impl HttpSigningAdapter {
184    /// Create a new `HttpSigningAdapter` with the given configuration.
185    ///
186    /// # Example
187    ///
188    /// ```no_run
189    /// use stygian_graph::adapters::signing::{HttpSigningAdapter, HttpSigningConfig};
190    ///
191    /// let signer = HttpSigningAdapter::new(HttpSigningConfig::default());
192    /// ```
193    pub fn new(config: HttpSigningConfig) -> Self {
194        let client = Client::builder()
195            .timeout(config.timeout)
196            .build()
197            .unwrap_or_default();
198        Self { config, client }
199    }
200}
201
202impl SigningPort for HttpSigningAdapter {
203    async fn sign(&self, input: SigningInput) -> Result<SigningOutput, SigningError> {
204        let body_b64 = input.body.as_deref().map(base64_encode);
205
206        let req_body = SignRequest {
207            method: input.method,
208            url: input.url,
209            headers: input.headers,
210            body_b64,
211            context: input.context,
212        };
213
214        let mut req = self.client.post(&self.config.endpoint).json(&req_body);
215
216        if let Some(token) = &self.config.bearer_token {
217            req = req.bearer_auth(token);
218        }
219        for (k, v) in &self.config.extra_headers {
220            req = req.header(k, v);
221        }
222
223        let response = req.send().await.map_err(|e| {
224            if e.is_timeout() {
225                SigningError::Timeout(
226                    self.config
227                        .timeout
228                        .as_millis()
229                        .try_into()
230                        .unwrap_or(u64::MAX),
231                )
232            } else {
233                SigningError::BackendUnavailable(e.to_string())
234            }
235        })?;
236
237        if !response.status().is_success() {
238            let status = response.status().as_u16();
239            let body = response.text().await.unwrap_or_default();
240            return Err(SigningError::InvalidResponse(format!(
241                "sidecar returned HTTP {status}: {body}"
242            )));
243        }
244
245        let sign_resp: SignResponse = response
246            .json()
247            .await
248            .map_err(|e| SigningError::InvalidResponse(e.to_string()))?;
249
250        let body_override = sign_resp
251            .body_b64
252            .map(|b64| base64_decode(&b64))
253            .transpose()
254            .map_err(|e| SigningError::InvalidResponse(format!("base64 decode failed: {e}")))?;
255
256        Ok(SigningOutput {
257            headers: sign_resp.headers,
258            query_params: sign_resp.query_params,
259            body_override,
260        })
261    }
262}
263
264// ─────────────────────────────────────────────────────────────────────────────
265// Base64 helpers (std-only, no extra deps)
266// ─────────────────────────────────────────────────────────────────────────────
267
268fn base64_encode(input: &[u8]) -> String {
269    use std::fmt::Write;
270    const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
271    let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
272    for chunk in input.chunks(3) {
273        let b0 = chunk[0] as usize;
274        let b1 = if chunk.len() > 1 {
275            chunk[1] as usize
276        } else {
277            0
278        };
279        let b2 = if chunk.len() > 2 {
280            chunk[2] as usize
281        } else {
282            0
283        };
284        let _ = write!(out, "{}", TABLE[b0 >> 2] as char);
285        let _ = write!(out, "{}", TABLE[((b0 & 3) << 4) | (b1 >> 4)] as char);
286        if chunk.len() > 1 {
287            let _ = write!(out, "{}", TABLE[((b1 & 0xf) << 2) | (b2 >> 6)] as char);
288        } else {
289            out.push('=');
290        }
291        if chunk.len() > 2 {
292            let _ = write!(out, "{}", TABLE[b2 & 0x3f] as char);
293        } else {
294            out.push('=');
295        }
296    }
297    out
298}
299
300fn base64_decode(input: &str) -> Result<Vec<u8>, String> {
301    let input = input.trim_end_matches('=');
302    let mut out = Vec::with_capacity(input.len() * 3 / 4 + 1);
303    let decode_char = |c: u8| -> Result<u8, String> {
304        match c {
305            b'A'..=b'Z' => Ok(c - b'A'),
306            b'a'..=b'z' => Ok(c - b'a' + 26),
307            b'0'..=b'9' => Ok(c - b'0' + 52),
308            b'+' => Ok(62),
309            b'/' => Ok(63),
310            _ => Err(format!("invalid base64 char: {c}")),
311        }
312    };
313    let bytes = input.as_bytes();
314    let mut i = 0;
315    while i + 1 < bytes.len() {
316        let v0 = decode_char(bytes[i])?;
317        let v1 = decode_char(bytes[i + 1])?;
318        out.push((v0 << 2) | (v1 >> 4));
319        if i + 2 < bytes.len() {
320            let v2 = decode_char(bytes[i + 2])?;
321            out.push(((v1 & 0xf) << 4) | (v2 >> 2));
322            if i + 3 < bytes.len() {
323                let v3 = decode_char(bytes[i + 3])?;
324                out.push(((v2 & 3) << 6) | v3);
325            }
326        }
327        i += 4;
328    }
329    Ok(out)
330}
331
332// ─────────────────────────────────────────────────────────────────────────────
333// Tests
334// ─────────────────────────────────────────────────────────────────────────────
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339    use serde_json::json;
340
341    #[tokio::test]
342    async fn noop_returns_empty_output() {
343        let signer = NoopSigningAdapter;
344        let output = signer
345            .sign(SigningInput {
346                method: "GET".to_string(),
347                url: "https://example.com".to_string(),
348                headers: HashMap::new(),
349                body: None,
350                context: json!({}),
351            })
352            .await
353            .unwrap();
354        assert!(output.headers.is_empty());
355        assert!(output.query_params.is_empty());
356        assert!(output.body_override.is_none());
357    }
358
359    #[tokio::test]
360    async fn noop_is_erased_signing_port() {
361        let signer: std::sync::Arc<dyn ErasedSigningPort> = std::sync::Arc::new(NoopSigningAdapter);
362        let output = signer
363            .erased_sign(SigningInput {
364                method: "POST".to_string(),
365                url: "https://api.example.com/data".to_string(),
366                headers: HashMap::new(),
367                body: Some(b"{\"key\":\"val\"}".to_vec()),
368                context: json!({"session": "abc"}),
369            })
370            .await
371            .unwrap();
372        assert!(output.headers.is_empty());
373    }
374
375    #[test]
376    fn base64_roundtrip() {
377        let input = b"Hello, Stygian signing!";
378        let encoded = base64_encode(input);
379        let decoded = base64_decode(&encoded).unwrap();
380        assert_eq!(decoded, input);
381    }
382
383    #[test]
384    fn base64_encode_known_value() {
385        // RFC 4648 test vector
386        assert_eq!(base64_encode(b"Man"), "TWFu");
387        assert_eq!(base64_encode(b"Ma"), "TWE=");
388        assert_eq!(base64_encode(b"M"), "TQ==");
389    }
390}