multistore_sts/
sealed_token.rs1use 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#[derive(Clone)]
32pub struct TokenKey(Arc<Aes256Gcm>);
33
34impl TokenKey {
35 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 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 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}