stygian_graph/ports/signing.rs
1//! `SigningPort` — request signing abstraction.
2//!
3//! Implement this trait to attach signatures, HMAC tokens, timestamps, or any
4//! other authentication material to outbound requests without coupling the
5//! calling adapter to the specific signing scheme.
6//!
7//! # Common use cases
8//!
9//! - **Frida RPC bridge**: delegate to a sidecar that calls native `.so`
10//! signing functions inside a real mobile app (Tinder, Snapchat, etc.)
11//! - **AWS Signature V4**: sign S3 or API Gateway requests
12//! - **OAuth 1.0a**: generate per-request `oauth_signature`
13//! - **Custom HMAC**: add `X-Request-Signature` / `X-Signed-At` headers
14//! - **Device attestation**: attach Play Integrity / Apple DeviceCheck tokens
15//! - **Timestamp + nonce**: anti-replay headers for trading or payment APIs
16
17use std::collections::HashMap;
18use std::future::Future;
19
20use async_trait::async_trait;
21
22use crate::domain::error::{ServiceError, StygianError};
23
24// ─────────────────────────────────────────────────────────────────────────────
25// Input / Output types
26// ─────────────────────────────────────────────────────────────────────────────
27
28/// The request material passed to a [`SigningPort`] for signing.
29///
30/// # Example
31///
32/// ```rust
33/// use stygian_graph::ports::signing::SigningInput;
34/// use serde_json::json;
35///
36/// let input = SigningInput {
37/// method: "GET".to_string(),
38/// url: "https://api.example.com/v2/profile".to_string(),
39/// headers: Default::default(),
40/// body: None,
41/// context: json!({"nonce_seed": 42}),
42/// };
43/// ```
44#[derive(Debug, Clone)]
45pub struct SigningInput {
46 /// HTTP method of the outbound request (e.g. `"GET"`, `"POST"`)
47 pub method: String,
48 /// Fully-qualified URL of the outbound request
49 pub url: String,
50 /// Request headers already present before signing
51 pub headers: HashMap<String, String>,
52 /// Request body bytes; `None` for bodyless methods
53 pub body: Option<Vec<u8>>,
54 /// Arbitrary signing context supplied by the caller (nonce seed, session
55 /// ID, timestamp override, etc.)
56 pub context: serde_json::Value,
57}
58
59/// The signing material to merge into the outbound request.
60///
61/// All fields are additive — they are merged on top of the existing request.
62/// A `SigningOutput` with all-default values is a valid no-op.
63///
64/// # Example
65///
66/// ```rust
67/// use stygian_graph::ports::signing::SigningOutput;
68/// use std::collections::HashMap;
69///
70/// let mut headers = HashMap::new();
71/// headers.insert("Authorization".to_string(), "HMAC-SHA256 sig=abc123".to_string());
72/// headers.insert("X-Signed-At".to_string(), "1710676800000".to_string());
73///
74/// let output = SigningOutput {
75/// headers,
76/// query_params: vec![],
77/// body_override: None,
78/// };
79/// ```
80#[derive(Debug, Clone, Default)]
81pub struct SigningOutput {
82 /// Headers to add or override on the outbound request
83 pub headers: HashMap<String, String>,
84 /// Query parameters to append to the URL
85 pub query_params: Vec<(String, String)>,
86 /// If `Some`, replace the request body with this value (for signing
87 /// schemes that embed a digest into the body)
88 pub body_override: Option<Vec<u8>>,
89}
90
91// ─────────────────────────────────────────────────────────────────────────────
92// Error
93// ─────────────────────────────────────────────────────────────────────────────
94
95/// Errors produced by [`SigningPort`] implementations.
96#[derive(Debug, thiserror::Error)]
97pub enum SigningError {
98 /// The signing sidecar or backend was unreachable.
99 #[error("signing backend unavailable: {0}")]
100 BackendUnavailable(String),
101
102 /// The sidecar returned an unexpected or malformed response.
103 #[error("signing response invalid: {0}")]
104 InvalidResponse(String),
105
106 /// The signing key or secret was absent.
107 #[error("signing credentials missing: {0}")]
108 CredentialsMissing(String),
109
110 /// The signing request timed out.
111 #[error("signing timed out after {0}ms")]
112 Timeout(u64),
113
114 /// Any other signing failure.
115 #[error("signing failed: {0}")]
116 Other(String),
117}
118
119impl From<SigningError> for StygianError {
120 fn from(e: SigningError) -> Self {
121 Self::Service(ServiceError::AuthenticationFailed(e.to_string()))
122 }
123}
124
125// ─────────────────────────────────────────────────────────────────────────────
126// Trait
127// ─────────────────────────────────────────────────────────────────────────────
128
129/// Port for request signing.
130///
131/// Implement this trait to attach signatures, HMAC tokens, timestamps, or any
132/// other authentication material to outbound requests. The calling adapter
133/// merges the returned [`SigningOutput`] into the request before sending.
134///
135/// The trait uses native `async fn` in traits (Rust 2024 edition) so it is
136/// *not* object-safe. Use [`ErasedSigningPort`] with `Arc<dyn ...>` when
137/// runtime dispatch is required.
138///
139/// # Example implementation (passthrough — no signing)
140///
141/// ```rust
142/// use stygian_graph::ports::signing::{SigningPort, SigningInput, SigningOutput, SigningError};
143///
144/// struct NoSigning;
145///
146/// impl SigningPort for NoSigning {
147/// async fn sign(&self, _input: SigningInput) -> Result<SigningOutput, SigningError> {
148/// Ok(SigningOutput::default())
149/// }
150/// }
151/// ```
152pub trait SigningPort: Send + Sync {
153 /// Sign an outbound request, returning the authentication material to merge.
154 ///
155 /// Implementations must be idempotent — the same `input` must always
156 /// produce a valid (if not byte-for-byte identical) `output`.
157 ///
158 /// # Errors
159 ///
160 /// - [`SigningError::BackendUnavailable`] — sidecar / key store unreachable
161 /// - [`SigningError::InvalidResponse`] — sidecar returned malformed data
162 /// - [`SigningError::CredentialsMissing`] — signing key absent at call time
163 /// - [`SigningError::Timeout`] — operation exceeded the configured deadline
164 fn sign(
165 &self,
166 input: SigningInput,
167 ) -> impl Future<Output = Result<SigningOutput, SigningError>> + Send;
168}
169
170// ─────────────────────────────────────────────────────────────────────────────
171// ErasedSigningPort — object-safe wrapper for Arc<dyn ...>
172// ─────────────────────────────────────────────────────────────────────────────
173
174/// Object-safe version of [`SigningPort`] for runtime dispatch.
175///
176/// [`SigningPort`] uses native `async fn in trait` (Rust 2024) and is NOT
177/// object-safe. `ErasedSigningPort` wraps it via `async_trait`, producing
178/// `Pin<Box<dyn Future>>` return types required by `Arc<dyn ...>`.
179///
180/// A blanket `impl<T: SigningPort> ErasedSigningPort for T` is provided — you
181/// never need to implement this trait directly.
182///
183/// # Example
184///
185/// ```rust
186/// use std::sync::Arc;
187/// use stygian_graph::ports::signing::ErasedSigningPort;
188/// use stygian_graph::adapters::signing::NoopSigningAdapter;
189///
190/// let signer: Arc<dyn ErasedSigningPort> = Arc::new(NoopSigningAdapter);
191/// ```
192#[async_trait]
193pub trait ErasedSigningPort: Send + Sync {
194 /// Sign an outbound request, returning the authentication material to merge.
195 ///
196 /// # Errors
197 ///
198 /// Returns [`SigningError`] if signing fails for any reason.
199 async fn erased_sign(&self, input: SigningInput) -> Result<SigningOutput, SigningError>;
200}
201
202#[async_trait]
203impl<T: SigningPort> ErasedSigningPort for T {
204 async fn erased_sign(&self, input: SigningInput) -> Result<SigningOutput, SigningError> {
205 self.sign(input).await
206 }
207}