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}