use std::time::Duration;
use base64::Engine as _;
use redis::Script;
use ring::aead::{Aad, LessSafeKey, Nonce, UnboundKey, AES_256_GCM, NONCE_LEN};
use ring::rand::{SecureRandom, SystemRandom};
use serde::{Deserialize, Serialize};
use tracing::warn;
use crate::{pool_to_redis_err, RedisStore};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SharedCert {
pub chain_pem: String,
pub key_pem: String,
pub not_after_unix: i64,
}
#[derive(Serialize, Deserialize)]
struct StoredBlob {
chain_pem: String,
key_pem_enc: String,
not_after_unix: i64,
}
pub trait SharedCertStore: Send + Sync {
fn load_cert(&self, key: &str) -> Option<SharedCert>;
fn store_cert(&self, key: &str, holder: &str, cert: &SharedCert) -> bool;
fn try_lock(&self, key: &str, holder: &str, ttl: Duration) -> bool;
fn extend_lock(&self, key: &str, holder: &str, ttl: Duration) -> bool;
fn unlock(&self, key: &str, holder: &str);
}
const EXTEND_LUA: &str = r#"
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('PEXPIRE', KEYS[1], ARGV[2])
else
return 0
end
"#;
const UNLOCK_LUA: &str = r#"
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
"#;
const STORE_LUA: &str = r#"
if redis.call('GET', KEYS[1]) == ARGV[1] then
redis.call('SET', KEYS[2], ARGV[2])
return 1
else
return 0
end
"#;
fn cert_key(key: &str) -> String {
format!("cert:{key}")
}
fn lock_key(key: &str) -> String {
format!("issuance_lock:{key}")
}
fn feed_lock_key(name: &str) -> String {
format!("feed_lock:{name}")
}
pub trait DistributedLock: Send + Sync {
fn acquire(&self, name: &str, holder: &str, ttl: Duration) -> bool;
fn release(&self, name: &str, holder: &str);
}
impl DistributedLock for RedisStore {
fn acquire(&self, name: &str, holder: &str, ttl: Duration) -> bool {
let lk = feed_lock_key(name);
let ttl_ms = ttl.as_millis().max(1) as u64;
let result = blocking(|| -> redis::RedisResult<Option<String>> {
let mut conn = self.pool.get().map_err(pool_to_redis_err)?;
redis::cmd("SET")
.arg(&lk)
.arg(holder)
.arg("NX")
.arg("PX")
.arg(ttl_ms)
.query::<Option<String>>(&mut *conn)
});
match result {
Ok(Some(_)) => true,
Ok(None) => false,
Err(e) => {
warn!(error = %e, name, "DistributedLock.acquire Redis error — treating as NOT acquired");
false
}
}
}
fn release(&self, name: &str, holder: &str) {
let lk = feed_lock_key(name);
let script = Script::new(UNLOCK_LUA);
let result = blocking(|| -> redis::RedisResult<i64> {
let mut conn = self.pool.get().map_err(pool_to_redis_err)?;
script.key(&lk).arg(holder).invoke::<i64>(&mut *conn)
});
if let Err(e) = result {
warn!(error = %e, name, "DistributedLock.release Redis error (lock will expire via TTL)");
}
}
}
fn blocking<T>(f: impl FnOnce() -> T) -> T {
match tokio::runtime::Handle::try_current() {
Ok(_) => tokio::task::block_in_place(f),
Err(_) => f(),
}
}
impl SharedCertStore for RedisStore {
fn load_cert(&self, key: &str) -> Option<SharedCert> {
let cipher = self.cert_cipher.as_ref().or_else(|| {
warn!("SharedCertStore.load_cert called without WAF_CERT_STORE_KEY configured");
None
})?;
let ck = cert_key(key);
let result = blocking(|| -> redis::RedisResult<Option<String>> {
let mut conn = self.pool.get().map_err(pool_to_redis_err)?;
redis::cmd("GET").arg(&ck).query::<Option<String>>(&mut *conn)
});
let json = match result {
Ok(Some(j)) => j,
Ok(None) => return None,
Err(e) => {
warn!(error = %e, key = %key, "SharedCertStore.load_cert Redis error");
return None;
}
};
let blob: StoredBlob = match serde_json::from_str(&json) {
Ok(b) => b,
Err(e) => {
warn!(error = %e, key = %key, "SharedCertStore.load_cert: malformed blob");
return None;
}
};
let key_pem = match cipher.decrypt_b64(&blob.key_pem_enc) {
Ok(bytes) => match String::from_utf8(bytes) {
Ok(s) => s,
Err(_) => {
warn!(key = %key, "SharedCertStore.load_cert: decrypted key not UTF-8");
return None;
}
},
Err(_) => {
warn!(key = %key, "SharedCertStore.load_cert: key decryption failed (wrong WAF_CERT_STORE_KEY?)");
return None;
}
};
Some(SharedCert { chain_pem: blob.chain_pem, key_pem, not_after_unix: blob.not_after_unix })
}
fn store_cert(&self, key: &str, holder: &str, cert: &SharedCert) -> bool {
let Some(cipher) = self.cert_cipher.as_ref() else {
warn!("SharedCertStore.store_cert called without WAF_CERT_STORE_KEY configured");
return false;
};
let blob = StoredBlob {
chain_pem: cert.chain_pem.clone(),
key_pem_enc: cipher.encrypt_b64(cert.key_pem.as_bytes()),
not_after_unix: cert.not_after_unix,
};
let json = match serde_json::to_string(&blob) {
Ok(j) => j,
Err(e) => {
warn!(error = %e, key = %key, "SharedCertStore.store_cert: serialize failed");
return false;
}
};
let (lk, ck) = (lock_key(key), cert_key(key));
let script = Script::new(STORE_LUA);
let result = blocking(|| -> redis::RedisResult<i64> {
let mut conn = self.pool.get().map_err(pool_to_redis_err)?;
script.key(&lk).key(&ck).arg(holder).arg(&json).invoke::<i64>(&mut *conn)
});
match result {
Ok(1) => true,
Ok(_) => {
warn!(key = %key, holder = %holder, "SharedCertStore.store_cert: lock not held, blob NOT written");
false
}
Err(e) => {
warn!(error = %e, key = %key, "SharedCertStore.store_cert Redis error");
false
}
}
}
fn try_lock(&self, key: &str, holder: &str, ttl: Duration) -> bool {
let lk = lock_key(key);
let ttl_ms = ttl.as_millis().max(1) as u64;
let result = blocking(|| -> redis::RedisResult<Option<String>> {
let mut conn = self.pool.get().map_err(pool_to_redis_err)?;
redis::cmd("SET")
.arg(&lk)
.arg(holder)
.arg("NX")
.arg("PX")
.arg(ttl_ms)
.query::<Option<String>>(&mut *conn)
});
match result {
Ok(Some(_)) => true, Ok(None) => false, Err(e) => {
warn!(error = %e, key = %key, "SharedCertStore.try_lock Redis error — treating as NOT acquired");
false
}
}
}
fn extend_lock(&self, key: &str, holder: &str, ttl: Duration) -> bool {
let lk = lock_key(key);
let ttl_ms = ttl.as_millis().max(1) as u64;
let script = Script::new(EXTEND_LUA);
let result = blocking(|| -> redis::RedisResult<i64> {
let mut conn = self.pool.get().map_err(pool_to_redis_err)?;
script.key(&lk).arg(holder).arg(ttl_ms).invoke::<i64>(&mut *conn)
});
matches!(result, Ok(1))
}
fn unlock(&self, key: &str, holder: &str) {
let lk = lock_key(key);
let script = Script::new(UNLOCK_LUA);
let result = blocking(|| -> redis::RedisResult<i64> {
let mut conn = self.pool.get().map_err(pool_to_redis_err)?;
script.key(&lk).arg(holder).invoke::<i64>(&mut *conn)
});
if let Err(e) = result {
warn!(error = %e, key = %key, "SharedCertStore.unlock Redis error (lock will expire via TTL)");
}
}
}
const HKDF_SALT: &[u8] = b"waf-sharedcertstore-v1";
const HKDF_INFO: &[u8] = b"aes-256-gcm-key";
const AES_256_KEY_LEN: usize = 32;
struct HkdfLen(usize);
impl ring::hkdf::KeyType for HkdfLen {
fn len(&self) -> usize {
self.0
}
}
pub struct CertCipher {
key: LessSafeKey,
rng: SystemRandom,
}
#[derive(Debug)]
pub struct DecryptError;
impl std::fmt::Display for DecryptError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "cert key decryption failed")
}
}
impl std::error::Error for DecryptError {}
impl CertCipher {
pub fn from_secret(secret: &str) -> Self {
let salt = ring::hkdf::Salt::new(ring::hkdf::HKDF_SHA256, HKDF_SALT);
let prk = salt.extract(secret.as_bytes());
let okm = prk
.expand(&[HKDF_INFO], HkdfLen(AES_256_KEY_LEN))
.expect("HKDF expand to a fixed 32-byte length cannot fail");
let mut key_bytes = [0u8; AES_256_KEY_LEN];
okm.fill(&mut key_bytes).expect("HKDF fill of 32 bytes cannot fail");
let unbound = UnboundKey::new(&AES_256_GCM, &key_bytes)
.expect("AES-256-GCM key from 32 bytes cannot fail");
Self { key: LessSafeKey::new(unbound), rng: SystemRandom::new() }
}
fn encrypt_b64(&self, plaintext: &[u8]) -> String {
let mut nonce_bytes = [0u8; NONCE_LEN];
self.rng.fill(&mut nonce_bytes).expect("system RNG failed");
let nonce = Nonce::assume_unique_for_key(nonce_bytes);
let mut in_out = plaintext.to_vec();
self.key
.seal_in_place_append_tag(nonce, Aad::empty(), &mut in_out)
.expect("AES-GCM seal cannot fail");
let mut blob = Vec::with_capacity(NONCE_LEN + in_out.len());
blob.extend_from_slice(&nonce_bytes);
blob.extend_from_slice(&in_out);
base64::engine::general_purpose::STANDARD.encode(blob)
}
fn decrypt_b64(&self, b64: &str) -> Result<Vec<u8>, DecryptError> {
let blob = base64::engine::general_purpose::STANDARD
.decode(b64)
.map_err(|_| DecryptError)?;
if blob.len() < NONCE_LEN + AES_256_GCM.tag_len() {
return Err(DecryptError);
}
let (nonce_bytes, ct) = blob.split_at(NONCE_LEN);
let nonce = Nonce::try_assume_unique_for_key(nonce_bytes).map_err(|_| DecryptError)?;
let mut in_out = ct.to_vec();
let plaintext = self
.key
.open_in_place(nonce, Aad::empty(), &mut in_out)
.map_err(|_| DecryptError)?;
Ok(plaintext.to_vec())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn encrypt_then_decrypt_round_trips() {
let c = CertCipher::from_secret("correct horse battery staple");
let pt = b"-----BEGIN PRIVATE KEY-----\nMIIabc...\n-----END PRIVATE KEY-----\n";
let blob = c.encrypt_b64(pt);
assert_eq!(c.decrypt_b64(&blob).unwrap(), pt);
}
#[test]
fn each_encryption_uses_a_fresh_nonce() {
let c = CertCipher::from_secret("s3cret");
let pt = b"same input every time";
assert_ne!(c.encrypt_b64(pt), c.encrypt_b64(pt), "nonce reuse detected");
}
#[test]
fn wrong_secret_fails_to_decrypt() {
let a = CertCipher::from_secret("secret-A");
let b = CertCipher::from_secret("secret-B");
let blob = a.encrypt_b64(b"top secret key material");
assert!(b.decrypt_b64(&blob).is_err(), "a different secret must not decrypt");
}
#[test]
fn tampered_ciphertext_is_rejected() {
let c = CertCipher::from_secret("s3cret");
let blob = c.encrypt_b64(b"authentic");
let mut raw = base64::engine::general_purpose::STANDARD.decode(&blob).unwrap();
let last = raw.len() - 1;
raw[last] ^= 0xff; let tampered = base64::engine::general_purpose::STANDARD.encode(raw);
assert!(c.decrypt_b64(&tampered).is_err(), "GCM must reject tampering");
}
#[test]
fn truncated_blob_is_rejected() {
let c = CertCipher::from_secret("s3cret");
assert!(c.decrypt_b64("").is_err());
assert!(c.decrypt_b64("AAAA").is_err()); }
#[test]
fn cert_and_lock_keys_are_namespaced() {
assert_eq!(cert_key("abc"), "cert:abc");
assert_eq!(lock_key("abc"), "issuance_lock:abc");
assert_eq!(feed_lock_key("reputation-feed"), "feed_lock:reputation-feed");
}
}