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}