Skip to main content

cratestack_core/
envelope.rs

1//! Signed envelope (HMAC-SHA-256).
2//!
3//! The contract is intentionally close to COSE_Sign1 with HS256: a
4//! content header (kid, alg, timestamp, nonce) is folded into the
5//! signing input alongside the body bytes, and the sealed message is
6//! a CBOR map `{ kid, alg, ts, nonce, body, mac }`. A full COSE_Sign1
7//! implementation with ES256/EdDSA can land later — adding it is
8//! non-breaking thanks to the [`keys::KeyProvider`] trait.
9
10mod keys;
11
12#[cfg(test)]
13mod tests;
14
15use std::sync::Arc;
16
17use serde::{Deserialize, Serialize};
18
19use crate::error::CoolError;
20
21pub use keys::{InMemoryNonceStore, KeyProvider, NonceStore, StaticKeyProvider};
22
23/// Maximum tolerable clock skew between sender and receiver when
24/// verifying signed envelopes. Banks running cross-region traffic
25/// with NTP-sync servers can lower this; off-the-shelf deployments
26/// leave it at the default 5 minutes.
27const ENVELOPE_DEFAULT_CLOCK_SKEW_SECS: i64 = 300;
28
29/// HMAC-SHA-256 backed envelope. Sealed messages are self-describing
30/// CBOR maps: signature recipients can decode the envelope, fetch the
31/// key by `kid`, and verify without out-of-band coordination.
32#[derive(Clone)]
33pub struct HmacEnvelope<K: KeyProvider> {
34    keys: Arc<K>,
35    signing_kid: String,
36    clock_skew_secs: i64,
37    nonces: Option<Arc<dyn NonceStore>>,
38}
39
40impl<K: KeyProvider> HmacEnvelope<K> {
41    pub fn new(keys: Arc<K>, signing_kid: impl Into<String>) -> Self {
42        Self {
43            keys,
44            signing_kid: signing_kid.into(),
45            clock_skew_secs: ENVELOPE_DEFAULT_CLOCK_SKEW_SECS,
46            nonces: None,
47        }
48    }
49
50    pub fn with_clock_skew_secs(mut self, secs: i64) -> Self {
51        self.clock_skew_secs = secs;
52        self
53    }
54
55    /// Attach a nonce store so `open` rejects replays. Without this,
56    /// the envelope is only protected by the clock-skew window — an
57    /// attacker who captured a sealed message can replay it inside
58    /// that window.
59    pub fn with_nonce_store(mut self, store: Arc<dyn NonceStore>) -> Self {
60        self.nonces = Some(store);
61        self
62    }
63
64    async fn compute_mac(&self, key: &[u8], input: &[u8]) -> Result<Vec<u8>, CoolError> {
65        use hmac::{Hmac, Mac};
66        let mut mac = <Hmac<sha2::Sha256> as Mac>::new_from_slice(key)
67            .map_err(|_| CoolError::Internal("HMAC key length error".to_owned()))?;
68        mac.update(input);
69        Ok(mac.finalize().into_bytes().to_vec())
70    }
71
72    /// Seal a request body. The returned bytes are a CBOR-encoded
73    /// [`SealedEnvelope`] payload — the sender wraps these in their
74    /// codec of choice on the way out.
75    pub async fn seal(&self, payload: serde_json::Value) -> Result<SealedEnvelope, CoolError> {
76        let key = self.keys.resolve_signing_key(&self.signing_kid).await?;
77        let ts = chrono::Utc::now().timestamp();
78        let nonce = uuid::Uuid::new_v4().to_string();
79        let mut envelope = SealedEnvelope {
80            kid: self.signing_kid.clone(),
81            alg: "HS256".to_owned(),
82            ts,
83            nonce,
84            body: payload,
85            mac_b64: String::new(),
86        };
87        let input = envelope.signing_input()?;
88        let mac = self.compute_mac(&key, &input).await?;
89        use base64::Engine;
90        envelope.mac_b64 = base64::engine::general_purpose::STANDARD.encode(mac);
91        Ok(envelope)
92    }
93
94    /// Verify a sealed envelope. Returns the body on success. Constant-
95    /// time MAC compare; clock-skew window enforced; envelope kid is
96    /// resolved through the configured provider so callers can rotate
97    /// keys without changing the recipient.
98    pub async fn open(&self, envelope: &SealedEnvelope) -> Result<serde_json::Value, CoolError> {
99        if envelope.alg != "HS256" {
100            return Err(CoolError::Unauthorized(format!(
101                "unsupported envelope algorithm '{}'",
102                envelope.alg,
103            )));
104        }
105        let now = chrono::Utc::now().timestamp();
106        let drift = (now - envelope.ts).abs();
107        if drift > self.clock_skew_secs {
108            return Err(CoolError::Unauthorized(
109                "envelope timestamp outside accepted skew window".to_owned(),
110            ));
111        }
112        let key = self.keys.resolve_signing_key(&envelope.kid).await?;
113        let input = envelope.signing_input()?;
114        let expected = self.compute_mac(&key, &input).await?;
115        use base64::Engine;
116        let actual = base64::engine::general_purpose::STANDARD
117            .decode(&envelope.mac_b64)
118            .map_err(|_| CoolError::Unauthorized("envelope MAC is not base64".to_owned()))?;
119        if actual.len() != expected.len() {
120            return Err(CoolError::Unauthorized(
121                "envelope MAC has wrong length".to_owned(),
122            ));
123        }
124        use subtle::ConstantTimeEq;
125        if !bool::from(actual.as_slice().ct_eq(expected.as_slice())) {
126            return Err(CoolError::Unauthorized(
127                "envelope MAC verification failed".to_owned(),
128            ));
129        }
130        if let Some(nonces) = &self.nonces {
131            let expires_at = chrono::DateTime::<chrono::Utc>::from_timestamp(
132                envelope.ts + self.clock_skew_secs,
133                0,
134            )
135            .ok_or_else(|| CoolError::Unauthorized("envelope timestamp out of range".to_owned()))?;
136            let recorded = nonces.record_if_unseen(&envelope.nonce, expires_at).await?;
137            if !recorded {
138                return Err(CoolError::Unauthorized(
139                    "envelope nonce replay detected".to_owned(),
140                ));
141            }
142        }
143        Ok(envelope.body.clone())
144    }
145}
146
147#[derive(Debug, Clone, Serialize, Deserialize)]
148pub struct SealedEnvelope {
149    pub kid: String,
150    pub alg: String,
151    pub ts: i64,
152    pub nonce: String,
153    pub body: serde_json::Value,
154    pub mac_b64: String,
155}
156
157impl SealedEnvelope {
158    pub(crate) fn signing_input(&self) -> Result<Vec<u8>, CoolError> {
159        let mut buf = Vec::with_capacity(256);
160        buf.extend_from_slice(self.kid.as_bytes());
161        buf.push(0);
162        buf.extend_from_slice(self.alg.as_bytes());
163        buf.push(0);
164        buf.extend_from_slice(&self.ts.to_be_bytes());
165        buf.push(0);
166        buf.extend_from_slice(self.nonce.as_bytes());
167        buf.push(0);
168        // Body is canonicalised via serde_json::to_vec which uses key-
169        // sort order for objects when the input went through
170        // `serde_json::Value` — adequate for HMAC integrity (the
171        // verifier reconstructs the same bytes the sender signed).
172        let body_bytes = serde_json::to_vec(&self.body)
173            .map_err(|error| CoolError::Codec(format!("encode envelope body: {error}")))?;
174        buf.extend_from_slice(&body_bytes);
175        Ok(buf)
176    }
177}