Skip to main content

cratestack_core/envelope/
keys.rs

1//! Key-resolution and nonce-replay-tracking traits used by the signed
2//! envelope. Both are async traits so production deployments can plug
3//! Vault / KMS / HSM (`KeyProvider`) and Redis (`NonceStore`) without
4//! changing the envelope code.
5
6use std::collections::BTreeMap;
7use std::sync::{Arc, RwLock};
8
9use crate::error::CoolError;
10
11/// Resolves signing keys by kid (key id). Banks running multi-tenant
12/// or rotating keysets implement this so the envelope code never has
13/// to know the storage mechanism. Implementations must be constant-
14/// time for not-found vs wrong-tenant errors — never use the error
15/// message to leak whether a key id exists.
16#[async_trait::async_trait]
17pub trait KeyProvider: Send + Sync + 'static {
18    /// Return the raw key bytes for the given `kid`. For HMAC this is
19    /// the symmetric secret. Error if the key is unknown.
20    async fn resolve_signing_key(&self, kid: &str) -> Result<Vec<u8>, CoolError>;
21}
22
23/// In-memory [`KeyProvider`] for tests and single-tenant deployments.
24/// Banks running real workloads bring a backed implementation (KMS,
25/// Vault, HSM).
26#[derive(Debug, Clone, Default)]
27pub struct StaticKeyProvider {
28    keys: BTreeMap<String, Vec<u8>>,
29}
30
31impl StaticKeyProvider {
32    pub fn new() -> Self {
33        Self::default()
34    }
35
36    pub fn with_key(mut self, kid: impl Into<String>, key: Vec<u8>) -> Self {
37        self.keys.insert(kid.into(), key);
38        self
39    }
40}
41
42#[async_trait::async_trait]
43impl KeyProvider for StaticKeyProvider {
44    async fn resolve_signing_key(&self, kid: &str) -> Result<Vec<u8>, CoolError> {
45        self.keys
46            .get(kid)
47            .cloned()
48            .ok_or_else(|| CoolError::Unauthorized("unknown signing key".to_owned()))
49    }
50}
51
52/// Tracks the nonces of sealed envelopes that have already been
53/// verified inside the clock-skew window, so a captured-and-replayed
54/// request gets rejected the second time. Banks running multi-replica
55/// deployments back this with Redis so the rejection holds cluster-wide.
56#[async_trait::async_trait]
57pub trait NonceStore: Send + Sync + 'static {
58    /// Attempt to register `nonce` as seen. Returns `Ok(true)` if it
59    /// is the first time we see it (caller may proceed); `Ok(false)`
60    /// if it was already recorded (caller should reject). Implementations
61    /// must drop entries past `expires_at` to keep the working set bounded.
62    async fn record_if_unseen(
63        &self,
64        nonce: &str,
65        expires_at: chrono::DateTime<chrono::Utc>,
66    ) -> Result<bool, CoolError>;
67}
68
69/// In-memory nonce store. One mutex; the working set is bounded by
70/// the clock-skew window — a 5-minute skew at 10k req/s caps at ~3M
71/// entries, which is fine. Production multi-replica deployments swap
72/// in Redis.
73#[derive(Debug, Clone, Default)]
74pub struct InMemoryNonceStore {
75    seen: Arc<RwLock<BTreeMap<String, chrono::DateTime<chrono::Utc>>>>,
76}
77
78impl InMemoryNonceStore {
79    pub fn new() -> Self {
80        Self::default()
81    }
82}
83
84#[async_trait::async_trait]
85impl NonceStore for InMemoryNonceStore {
86    async fn record_if_unseen(
87        &self,
88        nonce: &str,
89        expires_at: chrono::DateTime<chrono::Utc>,
90    ) -> Result<bool, CoolError> {
91        let mut seen = self
92            .seen
93            .write()
94            .map_err(|_| CoolError::Internal("nonce store poisoned".to_owned()))?;
95        let now = chrono::Utc::now();
96        seen.retain(|_, exp| *exp > now);
97        if seen.contains_key(nonce) {
98            return Ok(false);
99        }
100        seen.insert(nonce.to_owned(), expires_at);
101        Ok(true)
102    }
103}