Skip to main content

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    #[must_use]
194    pub fn new(config: HttpSigningConfig) -> Self {
195        let client = Client::builder()
196            .timeout(config.timeout)
197            .build()
198            .unwrap_or_default();
199        Self { config, client }
200    }
201}
202
203impl SigningPort for HttpSigningAdapter {
204    async fn sign(&self, input: SigningInput) -> Result<SigningOutput, SigningError> {
205        let body_b64 = input.body.as_deref().map(base64_encode);
206
207        let req_body = SignRequest {
208            method: input.method,
209            url: input.url,
210            headers: input.headers,
211            body_b64,
212            context: input.context,
213        };
214
215        let mut req = self.client.post(&self.config.endpoint).json(&req_body);
216
217        if let Some(token) = &self.config.bearer_token {
218            req = req.bearer_auth(token);
219        }
220        for (k, v) in &self.config.extra_headers {
221            req = req.header(k, v);
222        }
223
224        let response = req.send().await.map_err(|e| {
225            if e.is_timeout() {
226                SigningError::Timeout(
227                    self.config
228                        .timeout
229                        .as_millis()
230                        .try_into()
231                        .unwrap_or(u64::MAX),
232                )
233            } else {
234                SigningError::BackendUnavailable(e.to_string())
235            }
236        })?;
237
238        if !response.status().is_success() {
239            let status = response.status().as_u16();
240            let body = response.text().await.unwrap_or_default();
241            return Err(SigningError::InvalidResponse(format!(
242                "sidecar returned HTTP {status}: {body}"
243            )));
244        }
245
246        let sign_resp: SignResponse = response
247            .json()
248            .await
249            .map_err(|e| SigningError::InvalidResponse(e.to_string()))?;
250
251        let body_override = sign_resp
252            .body_b64
253            .map(|b64| base64_decode(&b64))
254            .transpose()
255            .map_err(|e| SigningError::InvalidResponse(format!("base64 decode failed: {e}")))?;
256
257        Ok(SigningOutput {
258            headers: sign_resp.headers,
259            query_params: sign_resp.query_params,
260            body_override,
261        })
262    }
263}
264
265// ─────────────────────────────────────────────────────────────────────────────
266// Base64 helpers (std-only, no extra deps)
267// ─────────────────────────────────────────────────────────────────────────────
268
269fn base64_encode(input: &[u8]) -> String {
270    use std::fmt::Write;
271    const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
272    let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
273    for chunk in input.chunks(3) {
274        let b0 = usize::from(*chunk.first().unwrap_or(&0));
275        let b1 = if chunk.len() > 1 {
276            usize::from(*chunk.get(1).unwrap_or(&0))
277        } else {
278            0
279        };
280        let b2 = if chunk.len() > 2 {
281            usize::from(*chunk.get(2).unwrap_or(&0))
282        } else {
283            0
284        };
285        let first = TABLE.get(b0 >> 2).copied().unwrap_or_default();
286        let second = TABLE
287            .get(((b0 & 3) << 4) | (b1 >> 4))
288            .copied()
289            .unwrap_or_default();
290        let _ = write!(out, "{}", char::from(first));
291        let _ = write!(out, "{}", char::from(second));
292        if chunk.len() > 1 {
293            let third = TABLE
294                .get(((b1 & 0xf) << 2) | (b2 >> 6))
295                .copied()
296                .unwrap_or_default();
297            let _ = write!(out, "{}", char::from(third));
298        } else {
299            out.push('=');
300        }
301        if chunk.len() > 2 {
302            let fourth = TABLE.get(b2 & 0x3f).copied().unwrap_or_default();
303            let _ = write!(out, "{}", char::from(fourth));
304        } else {
305            out.push('=');
306        }
307    }
308    out
309}
310
311fn base64_decode(input: &str) -> Result<Vec<u8>, String> {
312    let input = input.trim_end_matches('=');
313    let mut out = Vec::with_capacity(input.len() * 3 / 4 + 1);
314    let decode_char = |c: u8| -> Result<u8, String> {
315        match c {
316            b'A'..=b'Z' => Ok(c - b'A'),
317            b'a'..=b'z' => Ok(c - b'a' + 26),
318            b'0'..=b'9' => Ok(c - b'0' + 52),
319            b'+' => Ok(62),
320            b'/' => Ok(63),
321            _ => Err(format!("invalid base64 char: {c}")),
322        }
323    };
324    let bytes = input.as_bytes();
325    let mut i = 0;
326    while i + 1 < bytes.len() {
327        let v0 = decode_char(*bytes.get(i).unwrap_or(&0))?;
328        let v1 = decode_char(*bytes.get(i + 1).unwrap_or(&0))?;
329        out.push((v0 << 2) | (v1 >> 4));
330        if i + 2 < bytes.len() {
331            let v2 = decode_char(*bytes.get(i + 2).unwrap_or(&0))?;
332            out.push(((v1 & 0xf) << 4) | (v2 >> 2));
333            if i + 3 < bytes.len() {
334                let v3 = decode_char(*bytes.get(i + 3).unwrap_or(&0))?;
335                out.push(((v2 & 3) << 6) | v3);
336            }
337        }
338        i += 4;
339    }
340    Ok(out)
341}
342
343// ─────────────────────────────────────────────────────────────────────────────
344// Tests
345// ─────────────────────────────────────────────────────────────────────────────
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350    use serde_json::json;
351
352    #[tokio::test]
353    async fn noop_returns_empty_output() -> std::result::Result<(), Box<dyn std::error::Error>> {
354        let signer = NoopSigningAdapter;
355        let output = signer
356            .sign(SigningInput {
357                method: "GET".to_string(),
358                url: "https://example.com".to_string(),
359                headers: HashMap::new(),
360                body: None,
361                context: json!({}),
362            })
363            .await?;
364        assert!(output.headers.is_empty());
365        assert!(output.query_params.is_empty());
366        assert!(output.body_override.is_none());
367        Ok(())
368    }
369
370    #[tokio::test]
371    async fn noop_is_erased_signing_port() -> std::result::Result<(), Box<dyn std::error::Error>> {
372        let signer: std::sync::Arc<dyn ErasedSigningPort> = std::sync::Arc::new(NoopSigningAdapter);
373        let output = signer
374            .erased_sign(SigningInput {
375                method: "POST".to_string(),
376                url: "https://api.example.com/data".to_string(),
377                headers: HashMap::new(),
378                body: Some(b"{\"key\":\"val\"}".to_vec()),
379                context: json!({"session": "abc"}),
380            })
381            .await?;
382        assert!(output.headers.is_empty());
383        Ok(())
384    }
385
386    #[test]
387    fn base64_roundtrip() -> std::result::Result<(), Box<dyn std::error::Error>> {
388        let input = b"Hello, Stygian signing!";
389        let encoded = base64_encode(input);
390        let decoded = base64_decode(&encoded)
391            .map_err(|e| std::io::Error::other(format!("base64 decode failed: {e}")))?;
392        assert_eq!(decoded, input);
393        Ok(())
394    }
395
396    #[test]
397    fn base64_encode_known_value() {
398        // RFC 4648 test vector
399        assert_eq!(base64_encode(b"Man"), "TWFu");
400        assert_eq!(base64_encode(b"Ma"), "TWE=");
401        assert_eq!(base64_encode(b"M"), "TQ==");
402    }
403}