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}