1use std::time::Duration;
30
31use base64::Engine as _;
32use redis::Script;
33use ring::aead::{Aad, LessSafeKey, Nonce, UnboundKey, AES_256_GCM, NONCE_LEN};
34use ring::rand::{SecureRandom, SystemRandom};
35use serde::{Deserialize, Serialize};
36use tracing::warn;
37
38use crate::{pool_to_redis_err, RedisStore};
39
40#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct SharedCert {
46 pub chain_pem: String,
47 pub key_pem: String,
48 pub not_after_unix: i64,
49}
50
51#[derive(Serialize, Deserialize)]
54struct StoredBlob {
55 chain_pem: String,
56 key_pem_enc: String,
57 not_after_unix: i64,
58}
59
60pub trait SharedCertStore: Send + Sync {
64 fn load_cert(&self, key: &str) -> Option<SharedCert>;
66 fn store_cert(&self, key: &str, holder: &str, cert: &SharedCert) -> bool;
69 fn try_lock(&self, key: &str, holder: &str, ttl: Duration) -> bool;
71 fn extend_lock(&self, key: &str, holder: &str, ttl: Duration) -> bool;
74 fn unlock(&self, key: &str, holder: &str);
76}
77
78const EXTEND_LUA: &str = r#"
80if redis.call('GET', KEYS[1]) == ARGV[1] then
81 return redis.call('PEXPIRE', KEYS[1], ARGV[2])
82else
83 return 0
84end
85"#;
86
87const UNLOCK_LUA: &str = r#"
89if redis.call('GET', KEYS[1]) == ARGV[1] then
90 return redis.call('DEL', KEYS[1])
91else
92 return 0
93end
94"#;
95
96const STORE_LUA: &str = r#"
98if redis.call('GET', KEYS[1]) == ARGV[1] then
99 redis.call('SET', KEYS[2], ARGV[2])
100 return 1
101else
102 return 0
103end
104"#;
105
106fn cert_key(key: &str) -> String {
107 format!("cert:{key}")
108}
109fn lock_key(key: &str) -> String {
110 format!("issuance_lock:{key}")
111}
112fn feed_lock_key(name: &str) -> String {
113 format!("feed_lock:{name}")
114}
115
116pub trait DistributedLock: Send + Sync {
126 fn acquire(&self, name: &str, holder: &str, ttl: Duration) -> bool;
128 fn release(&self, name: &str, holder: &str);
130}
131
132impl DistributedLock for RedisStore {
133 fn acquire(&self, name: &str, holder: &str, ttl: Duration) -> bool {
134 let lk = feed_lock_key(name);
135 let ttl_ms = ttl.as_millis().max(1) as u64;
136 let result = blocking(|| -> redis::RedisResult<Option<String>> {
137 let mut conn = self.pool.get().map_err(pool_to_redis_err)?;
138 redis::cmd("SET")
139 .arg(&lk)
140 .arg(holder)
141 .arg("NX")
142 .arg("PX")
143 .arg(ttl_ms)
144 .query::<Option<String>>(&mut *conn)
145 });
146 match result {
147 Ok(Some(_)) => true,
148 Ok(None) => false,
149 Err(e) => {
150 warn!(error = %e, name, "DistributedLock.acquire Redis error — treating as NOT acquired");
152 false
153 }
154 }
155 }
156
157 fn release(&self, name: &str, holder: &str) {
158 let lk = feed_lock_key(name);
159 let script = Script::new(UNLOCK_LUA);
160 let result = blocking(|| -> redis::RedisResult<i64> {
161 let mut conn = self.pool.get().map_err(pool_to_redis_err)?;
162 script.key(&lk).arg(holder).invoke::<i64>(&mut *conn)
163 });
164 if let Err(e) = result {
165 warn!(error = %e, name, "DistributedLock.release Redis error (lock will expire via TTL)");
166 }
167 }
168}
169
170fn blocking<T>(f: impl FnOnce() -> T) -> T {
173 match tokio::runtime::Handle::try_current() {
174 Ok(_) => tokio::task::block_in_place(f),
175 Err(_) => f(),
176 }
177}
178
179impl SharedCertStore for RedisStore {
180 fn load_cert(&self, key: &str) -> Option<SharedCert> {
181 let cipher = self.cert_cipher.as_ref().or_else(|| {
182 warn!("SharedCertStore.load_cert called without WAF_CERT_STORE_KEY configured");
183 None
184 })?;
185 let ck = cert_key(key);
186 let result = blocking(|| -> redis::RedisResult<Option<String>> {
187 let mut conn = self.pool.get().map_err(pool_to_redis_err)?;
188 redis::cmd("GET").arg(&ck).query::<Option<String>>(&mut *conn)
189 });
190 let json = match result {
191 Ok(Some(j)) => j,
192 Ok(None) => return None,
193 Err(e) => {
194 warn!(error = %e, key = %key, "SharedCertStore.load_cert Redis error");
195 return None;
196 }
197 };
198 let blob: StoredBlob = match serde_json::from_str(&json) {
199 Ok(b) => b,
200 Err(e) => {
201 warn!(error = %e, key = %key, "SharedCertStore.load_cert: malformed blob");
202 return None;
203 }
204 };
205 let key_pem = match cipher.decrypt_b64(&blob.key_pem_enc) {
206 Ok(bytes) => match String::from_utf8(bytes) {
207 Ok(s) => s,
208 Err(_) => {
209 warn!(key = %key, "SharedCertStore.load_cert: decrypted key not UTF-8");
210 return None;
211 }
212 },
213 Err(_) => {
214 warn!(key = %key, "SharedCertStore.load_cert: key decryption failed (wrong WAF_CERT_STORE_KEY?)");
215 return None;
216 }
217 };
218 Some(SharedCert { chain_pem: blob.chain_pem, key_pem, not_after_unix: blob.not_after_unix })
219 }
220
221 fn store_cert(&self, key: &str, holder: &str, cert: &SharedCert) -> bool {
222 let Some(cipher) = self.cert_cipher.as_ref() else {
223 warn!("SharedCertStore.store_cert called without WAF_CERT_STORE_KEY configured");
224 return false;
225 };
226 let blob = StoredBlob {
227 chain_pem: cert.chain_pem.clone(),
228 key_pem_enc: cipher.encrypt_b64(cert.key_pem.as_bytes()),
229 not_after_unix: cert.not_after_unix,
230 };
231 let json = match serde_json::to_string(&blob) {
232 Ok(j) => j,
233 Err(e) => {
234 warn!(error = %e, key = %key, "SharedCertStore.store_cert: serialize failed");
235 return false;
236 }
237 };
238 let (lk, ck) = (lock_key(key), cert_key(key));
239 let script = Script::new(STORE_LUA);
240 let result = blocking(|| -> redis::RedisResult<i64> {
241 let mut conn = self.pool.get().map_err(pool_to_redis_err)?;
242 script.key(&lk).key(&ck).arg(holder).arg(&json).invoke::<i64>(&mut *conn)
243 });
244 match result {
245 Ok(1) => true,
246 Ok(_) => {
247 warn!(key = %key, holder = %holder, "SharedCertStore.store_cert: lock not held, blob NOT written");
248 false
249 }
250 Err(e) => {
251 warn!(error = %e, key = %key, "SharedCertStore.store_cert Redis error");
252 false
253 }
254 }
255 }
256
257 fn try_lock(&self, key: &str, holder: &str, ttl: Duration) -> bool {
258 let lk = lock_key(key);
259 let ttl_ms = ttl.as_millis().max(1) as u64;
260 let result = blocking(|| -> redis::RedisResult<Option<String>> {
261 let mut conn = self.pool.get().map_err(pool_to_redis_err)?;
262 redis::cmd("SET")
263 .arg(&lk)
264 .arg(holder)
265 .arg("NX")
266 .arg("PX")
267 .arg(ttl_ms)
268 .query::<Option<String>>(&mut *conn)
269 });
270 match result {
271 Ok(Some(_)) => true, Ok(None) => false, Err(e) => {
274 warn!(error = %e, key = %key, "SharedCertStore.try_lock Redis error — treating as NOT acquired");
276 false
277 }
278 }
279 }
280
281 fn extend_lock(&self, key: &str, holder: &str, ttl: Duration) -> bool {
282 let lk = lock_key(key);
283 let ttl_ms = ttl.as_millis().max(1) as u64;
284 let script = Script::new(EXTEND_LUA);
285 let result = blocking(|| -> redis::RedisResult<i64> {
286 let mut conn = self.pool.get().map_err(pool_to_redis_err)?;
287 script.key(&lk).arg(holder).arg(ttl_ms).invoke::<i64>(&mut *conn)
288 });
289 matches!(result, Ok(1))
290 }
291
292 fn unlock(&self, key: &str, holder: &str) {
293 let lk = lock_key(key);
294 let script = Script::new(UNLOCK_LUA);
295 let result = blocking(|| -> redis::RedisResult<i64> {
296 let mut conn = self.pool.get().map_err(pool_to_redis_err)?;
297 script.key(&lk).arg(holder).invoke::<i64>(&mut *conn)
298 });
299 if let Err(e) = result {
300 warn!(error = %e, key = %key, "SharedCertStore.unlock Redis error (lock will expire via TTL)");
301 }
302 }
303}
304
305const HKDF_SALT: &[u8] = b"waf-sharedcertstore-v1";
310const HKDF_INFO: &[u8] = b"aes-256-gcm-key";
311const AES_256_KEY_LEN: usize = 32;
312
313struct HkdfLen(usize);
315impl ring::hkdf::KeyType for HkdfLen {
316 fn len(&self) -> usize {
317 self.0
318 }
319}
320
321pub struct CertCipher {
326 key: LessSafeKey,
327 rng: SystemRandom,
328}
329
330#[derive(Debug)]
332pub struct DecryptError;
333
334impl std::fmt::Display for DecryptError {
335 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
336 write!(f, "cert key decryption failed")
337 }
338}
339impl std::error::Error for DecryptError {}
340
341impl CertCipher {
342 pub fn from_secret(secret: &str) -> Self {
344 let salt = ring::hkdf::Salt::new(ring::hkdf::HKDF_SHA256, HKDF_SALT);
345 let prk = salt.extract(secret.as_bytes());
346 let okm = prk
347 .expand(&[HKDF_INFO], HkdfLen(AES_256_KEY_LEN))
348 .expect("HKDF expand to a fixed 32-byte length cannot fail");
349 let mut key_bytes = [0u8; AES_256_KEY_LEN];
350 okm.fill(&mut key_bytes).expect("HKDF fill of 32 bytes cannot fail");
351 let unbound = UnboundKey::new(&AES_256_GCM, &key_bytes)
352 .expect("AES-256-GCM key from 32 bytes cannot fail");
353 Self { key: LessSafeKey::new(unbound), rng: SystemRandom::new() }
354 }
355
356 fn encrypt_b64(&self, plaintext: &[u8]) -> String {
359 let mut nonce_bytes = [0u8; NONCE_LEN];
360 self.rng.fill(&mut nonce_bytes).expect("system RNG failed");
361 let nonce = Nonce::assume_unique_for_key(nonce_bytes);
362 let mut in_out = plaintext.to_vec();
363 self.key
364 .seal_in_place_append_tag(nonce, Aad::empty(), &mut in_out)
365 .expect("AES-GCM seal cannot fail");
366 let mut blob = Vec::with_capacity(NONCE_LEN + in_out.len());
367 blob.extend_from_slice(&nonce_bytes);
368 blob.extend_from_slice(&in_out);
369 base64::engine::general_purpose::STANDARD.encode(blob)
370 }
371
372 fn decrypt_b64(&self, b64: &str) -> Result<Vec<u8>, DecryptError> {
375 let blob = base64::engine::general_purpose::STANDARD
376 .decode(b64)
377 .map_err(|_| DecryptError)?;
378 if blob.len() < NONCE_LEN + AES_256_GCM.tag_len() {
380 return Err(DecryptError);
381 }
382 let (nonce_bytes, ct) = blob.split_at(NONCE_LEN);
383 let nonce = Nonce::try_assume_unique_for_key(nonce_bytes).map_err(|_| DecryptError)?;
384 let mut in_out = ct.to_vec();
385 let plaintext = self
386 .key
387 .open_in_place(nonce, Aad::empty(), &mut in_out)
388 .map_err(|_| DecryptError)?;
389 Ok(plaintext.to_vec())
390 }
391}
392
393#[cfg(test)]
394mod tests {
395 use super::*;
396
397 #[test]
398 fn encrypt_then_decrypt_round_trips() {
399 let c = CertCipher::from_secret("correct horse battery staple");
400 let pt = b"-----BEGIN PRIVATE KEY-----\nMIIabc...\n-----END PRIVATE KEY-----\n";
401 let blob = c.encrypt_b64(pt);
402 assert_eq!(c.decrypt_b64(&blob).unwrap(), pt);
403 }
404
405 #[test]
406 fn each_encryption_uses_a_fresh_nonce() {
407 let c = CertCipher::from_secret("s3cret");
410 let pt = b"same input every time";
411 assert_ne!(c.encrypt_b64(pt), c.encrypt_b64(pt), "nonce reuse detected");
412 }
413
414 #[test]
415 fn wrong_secret_fails_to_decrypt() {
416 let a = CertCipher::from_secret("secret-A");
417 let b = CertCipher::from_secret("secret-B");
418 let blob = a.encrypt_b64(b"top secret key material");
419 assert!(b.decrypt_b64(&blob).is_err(), "a different secret must not decrypt");
420 }
421
422 #[test]
423 fn tampered_ciphertext_is_rejected() {
424 let c = CertCipher::from_secret("s3cret");
425 let blob = c.encrypt_b64(b"authentic");
426 let mut raw = base64::engine::general_purpose::STANDARD.decode(&blob).unwrap();
427 let last = raw.len() - 1;
428 raw[last] ^= 0xff; let tampered = base64::engine::general_purpose::STANDARD.encode(raw);
430 assert!(c.decrypt_b64(&tampered).is_err(), "GCM must reject tampering");
431 }
432
433 #[test]
434 fn truncated_blob_is_rejected() {
435 let c = CertCipher::from_secret("s3cret");
436 assert!(c.decrypt_b64("").is_err());
437 assert!(c.decrypt_b64("AAAA").is_err()); }
439
440 #[test]
441 fn cert_and_lock_keys_are_namespaced() {
442 assert_eq!(cert_key("abc"), "cert:abc");
443 assert_eq!(lock_key("abc"), "issuance_lock:abc");
444 assert_eq!(feed_lock_key("reputation-feed"), "feed_lock:reputation-feed");
446 }
447}