Skip to main content

multistore_sts/
sealed_token.rs

1//! Self-contained encrypted session tokens using AES-256-GCM.
2//!
3//! When a `TokenKey` is configured, temporary credentials are encrypted into
4//! the session token itself. The proxy decrypts the token on each request —
5//! no server-side storage lookup is needed. This is critical for stateless
6//! runtimes like Cloudflare Workers where in-memory state does not persist
7//! across invocations.
8//!
9//! ## Security properties
10//!
11//! - **Encryption**: AES-256-GCM provides authenticated encryption (confidentiality + integrity).
12//! - **Nonce**: 12-byte random nonce per token via `OsRng` (96 bits, per GCM spec).
13//! - **Token format**: `base64url(nonce[12] || ciphertext + GCM tag[16])`.
14//! - **Expiration**: Enforced at unseal time — expired credentials return `Err`.
15//! - **Scope binding**: Allowed scopes are sealed at mint time, so config changes only affect
16//!   newly minted credentials.
17//! - **Key rotation**: Tokens sealed with an old key will fail to decrypt (`Ok(None)`), causing
18//!   the client to re-authenticate. No explicit revocation mechanism is needed.
19
20use aes_gcm::aead::{Aead, OsRng};
21use aes_gcm::{AeadCore, Aes256Gcm, KeyInit};
22use base64::Engine;
23use multistore::auth::TemporaryCredentialResolver;
24use multistore::error::ProxyError;
25use multistore::types::TemporaryCredentials;
26use std::sync::Arc;
27
28const NONCE_LEN: usize = 12;
29
30/// Wraps an AES-256-GCM cipher for sealing/unsealing session tokens.
31#[derive(Clone)]
32pub struct TokenKey(Arc<Aes256Gcm>);
33
34impl TokenKey {
35    /// Create a `TokenKey` from a base64-encoded 32-byte key.
36    pub fn from_base64(encoded: &str) -> Result<Self, ProxyError> {
37        let bytes = base64::engine::general_purpose::STANDARD
38            .decode(encoded.trim())
39            .map_err(|e| {
40                ProxyError::ConfigError(format!("invalid SESSION_TOKEN_KEY base64: {e}"))
41            })?;
42        if bytes.len() != 32 {
43            return Err(ProxyError::ConfigError(format!(
44                "SESSION_TOKEN_KEY must be 32 bytes, got {}",
45                bytes.len()
46            )));
47        }
48        let cipher = Aes256Gcm::new_from_slice(&bytes)
49            .map_err(|e| ProxyError::ConfigError(format!("AES key error: {e}")))?;
50        Ok(Self(Arc::new(cipher)))
51    }
52
53    /// Encrypt `TemporaryCredentials` into a base64url token.
54    ///
55    /// Format: `base64url(nonce[12] || ciphertext+tag)`
56    pub fn seal(&self, creds: &TemporaryCredentials) -> Result<String, ProxyError> {
57        let plaintext = serde_json::to_vec(creds)
58            .map_err(|e| ProxyError::Internal(format!("seal json: {e}")))?;
59        let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
60        let ciphertext = self
61            .0
62            .encrypt(&nonce, plaintext.as_slice())
63            .map_err(|e| ProxyError::Internal(format!("seal encrypt: {e}")))?;
64
65        let mut blob = Vec::with_capacity(NONCE_LEN + ciphertext.len());
66        blob.extend_from_slice(&nonce);
67        blob.extend_from_slice(&ciphertext);
68
69        Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&blob))
70    }
71
72    /// Decrypt a session token back into `TemporaryCredentials`.
73    ///
74    /// Returns `Ok(None)` if the token doesn't look like a sealed token
75    /// (e.g. base64 decode fails or decryption fails — allows fallback to
76    /// config-based lookup). Returns `Err(ExpiredCredentials)` when the
77    /// token decrypts successfully but the credentials have expired.
78    pub fn unseal(&self, token: &str) -> Result<Option<TemporaryCredentials>, ProxyError> {
79        let blob = match base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(token) {
80            Ok(b) => b,
81            Err(_) => return Ok(None),
82        };
83
84        if blob.len() <= NONCE_LEN {
85            return Ok(None);
86        }
87
88        let nonce = aes_gcm::Nonce::from_slice(&blob[..NONCE_LEN]);
89        let ciphertext = &blob[NONCE_LEN..];
90
91        let plaintext = match self.0.decrypt(nonce, ciphertext) {
92            Ok(p) => p,
93            Err(_) => return Ok(None),
94        };
95
96        let creds: TemporaryCredentials = serde_json::from_slice(&plaintext)
97            .map_err(|e| ProxyError::Internal(format!("unseal json: {e}")))?;
98
99        if creds.expiration <= chrono::Utc::now() {
100            return Err(ProxyError::ExpiredCredentials);
101        }
102
103        Ok(Some(creds))
104    }
105}
106
107impl TemporaryCredentialResolver for TokenKey {
108    fn resolve(&self, token: &str) -> Result<Option<TemporaryCredentials>, ProxyError> {
109        self.unseal(token)
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116    use multistore::types::AccessScope;
117
118    fn make_key() -> TokenKey {
119        let key_bytes = [0x42u8; 32];
120        let encoded = base64::engine::general_purpose::STANDARD.encode(key_bytes);
121        TokenKey::from_base64(&encoded).unwrap()
122    }
123
124    fn make_creds() -> TemporaryCredentials {
125        TemporaryCredentials {
126            access_key_id: "ASIATEMP".into(),
127            secret_access_key: "secret".into(),
128            session_token: "original-token".into(),
129            expiration: chrono::Utc::now() + chrono::Duration::hours(1),
130            allowed_scopes: vec![AccessScope {
131                bucket: "test-bucket".into(),
132                prefixes: vec![],
133                actions: vec![multistore::types::Action::GetObject],
134            }],
135            assumed_role_id: "role-1".into(),
136            source_identity: "test".into(),
137        }
138    }
139
140    #[test]
141    fn round_trip() {
142        let key = make_key();
143        let creds = make_creds();
144        let sealed = key.seal(&creds).unwrap();
145        let unsealed = key.unseal(&sealed).unwrap().unwrap();
146        assert_eq!(unsealed.access_key_id, creds.access_key_id);
147        assert_eq!(unsealed.secret_access_key, creds.secret_access_key);
148        assert_eq!(unsealed.assumed_role_id, creds.assumed_role_id);
149    }
150
151    #[test]
152    fn wrong_key_returns_none() {
153        let key1 = make_key();
154        let key2 = {
155            let key_bytes = [0x99u8; 32];
156            let encoded = base64::engine::general_purpose::STANDARD.encode(key_bytes);
157            TokenKey::from_base64(&encoded).unwrap()
158        };
159        let creds = make_creds();
160        let sealed = key1.seal(&creds).unwrap();
161        assert!(key2.unseal(&sealed).unwrap().is_none());
162    }
163
164    #[test]
165    fn non_sealed_token_returns_none() {
166        let key = make_key();
167        assert!(key
168            .unseal("FwoGZXIvYXdzEBYaDGFiY2RlZjEyMzQ1Ng")
169            .unwrap()
170            .is_none());
171    }
172
173    #[test]
174    fn expired_token_returns_error() {
175        let key = make_key();
176        let mut creds = make_creds();
177        creds.expiration = chrono::Utc::now() - chrono::Duration::hours(1);
178        let sealed = key.seal(&creds).unwrap();
179        let err = key.unseal(&sealed).unwrap_err();
180        assert!(matches!(err, ProxyError::ExpiredCredentials));
181    }
182
183    #[test]
184    fn invalid_key_length_rejected() {
185        let short = base64::engine::general_purpose::STANDARD.encode([0u8; 16]);
186        assert!(TokenKey::from_base64(&short).is_err());
187    }
188}