brokkr-broker::utils::encryption
Rust
Encryption utilities for protecting sensitive data at rest.
This module provides AES-256-GCM encryption and decryption functionality for webhook URLs and authentication headers stored in the database.
Format
Encrypted data format: version (1 byte) || nonce (12 bytes) || ciphertext || tag (16 bytes)
Version bytes:
- 0x00: Legacy XOR encryption (read-only, for migration)
- 0x01: AES-256-GCM encryption
Structs
Section titled “Structs”struct EncryptionKey
Section titled “struct EncryptionKey”pub
Encryption key wrapper with AES-256-GCM cipher.
Methods
Section titled “Methods”pub
fn new(key: [u8; 32]) -> SelfCreates a new encryption key from raw bytes.
Source
pub fn new(key: [u8; 32]) -> Self { let cipher = Aes256Gcm::new_from_slice(&key).expect("valid key size"); Self { key, cipher } }generate
Section titled “generate”pub
fn generate() -> SelfCreates a new random encryption key.
Source
pub fn generate() -> Self { let mut key = [0u8; 32]; rand::thread_rng().fill_bytes(&mut key); Self::new(key) }from_hex
Section titled “from_hex”pub
fn from_hex(hex: &str) -> Result<Self, String>Creates a key from a hex-encoded string.
Source
pub fn from_hex(hex: &str) -> Result<Self, String> { let bytes = hex::decode(hex).map_err(|e| format!("Invalid hex encoding: {}", e))?;
if bytes.len() != 32 { return Err(format!("Key must be 32 bytes, got {} bytes", bytes.len())); }
let mut key = [0u8; 32]; key.copy_from_slice(&bytes); Ok(Self::new(key)) }fingerprint
Section titled “fingerprint”pub
fn fingerprint(&self) -> StringReturns the key as a hex string (for logging key fingerprint only).
Source
pub fn fingerprint(&self) -> String { let hash = Sha256::digest(self.key); hex::encode(&hash[..8]) }encrypt
Section titled “encrypt”pub
fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>, EncryptionError>Encrypts data using AES-256-GCM.
Format
The output format is: version (1 byte) || nonce (12 bytes) || ciphertext || tag (16 bytes)
Source
pub fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>, EncryptionError> { // Generate random nonce let mut nonce_bytes = [0u8; AES_GCM_NONCE_SIZE]; rand::thread_rng().fill_bytes(&mut nonce_bytes); let nonce = Nonce::from_slice(&nonce_bytes);
// Encrypt with AES-256-GCM let ciphertext = self .cipher .encrypt(nonce, plaintext) .map_err(|_| EncryptionError::EncryptionFailed)?;
// Build output: version || nonce || ciphertext (includes auth tag) let mut output = Vec::with_capacity(1 + AES_GCM_NONCE_SIZE + ciphertext.len()); output.push(VERSION_AES_GCM); output.extend_from_slice(&nonce_bytes); output.extend(ciphertext); Ok(output) }decrypt
Section titled “decrypt”pub
fn decrypt(&self, data: &[u8]) -> Result<Vec<u8>, EncryptionError>Decrypts data, automatically detecting the encryption version.
Supports:
- Version 0x01: AES-256-GCM
- Version 0x00 or no version byte: Legacy XOR (for migration)
Source
pub fn decrypt(&self, data: &[u8]) -> Result<Vec<u8>, EncryptionError> { if data.is_empty() { return Err(EncryptionError::InvalidData("Empty data".to_string())); }
// Check version byte let version = data[0];
match version { VERSION_AES_GCM => self.decrypt_aes_gcm(&data[1..]), VERSION_LEGACY_XOR => self.decrypt_legacy_xor(&data[1..]), _ => { // No version byte - assume legacy XOR format // Legacy format: nonce (16 bytes) || ciphertext if data.len() >= LEGACY_XOR_NONCE_SIZE { self.decrypt_legacy_xor(data) } else { Err(EncryptionError::InvalidData("Data too short".to_string())) } } } }enum EncryptionError
Section titled “enum EncryptionError”pub
Encryption error types
Variants
Section titled “Variants”EncryptionFailed- Encryption operation failedDecryptionFailed- Decryption operation failed (wrong key or corrupted data)InvalidData- Invalid data formatUnsupportedVersion- Unsupported encryption version
Functions
Section titled “Functions”fn check_webhook_encryption_key
Section titled “fn check_webhook_encryption_key”pub
fn check_webhook_encryption_key( key_hex: Option<&str>, existing_subscriptions: i64,) -> Result<(), String>Startup guard for the webhook encryption key (BROKKR-T-0288).
The key protects webhook_subscriptions.url_encrypted and
auth_header_encrypted. When it is unset the broker generates a fresh random
key on every start, so subscriptions written by a previous process become
permanently undecryptable: they still list fine (the API only exposes
has_url / has_auth_header) but can never deliver again, and can only be
repaired by recreating them.
Unset key + existing subscriptions is therefore an unambiguous
misconfiguration and the broker refuses to start. A fresh install (zero
subscriptions) stays frictionless — that case is only warned about, by
init_encryption_key. A key that is set but wrong is deliberately not
caught here: that fault is webhook-local and fails loudly at delivery time,
which is a better trade than taking the whole broker down.
Parameters:
| Name | Type | Description |
|---|---|---|
key_hex |
Option<&str> |
The configured key, exactly as it will be passed to init_encryption_key. |
existing_subscriptions |
i64 |
Row count of webhook_subscriptions. |
Returns:
Ok(()) if startup may proceed, Err with an operator-actionable message otherwise.
Source
pub fn check_webhook_encryption_key( key_hex: Option<&str>, existing_subscriptions: i64,) -> Result<(), String> { if configured_key(key_hex).is_some() || existing_subscriptions <= 0 { return Ok(()); }
Err(format!( "BROKKR__BROKER__WEBHOOK_ENCRYPTION_KEY is not set, but {existing_subscriptions} webhook \ subscription(s) already exist. Without a configured key the broker generates a new random \ key on every start, so those subscriptions' encrypted URLs and auth headers cannot be \ decrypted by this process: every delivery would fail permanently while the subscriptions \ continue to list as healthy. Fix: set BROKKR__BROKER__WEBHOOK_ENCRYPTION_KEY to the \ 64-character hex (32-byte) key the subscriptions were created with. If that key is lost, \ delete the existing subscription(s), set a fresh key (`openssl rand -hex 32`), and \ recreate them. Refusing to start." ))}fn init_encryption_key
Section titled “fn init_encryption_key”pub
fn init_encryption_key(key_hex: Option<&str>) -> Result<(), String>Initializes the global encryption key from configuration.
This should be called once during broker startup, after
check_webhook_encryption_key has cleared the unset-key-with-existing-data
case.
Parameters:
| Name | Type | Description |
|---|---|---|
key_hex |
Option<&str> |
Optional hex-encoded 32-byte key. If None, a random key is generated. |
Returns:
Ok(()) if initialization succeeded, Err if already initialized or key is invalid.
Source
pub fn init_encryption_key(key_hex: Option<&str>) -> Result<(), String> { let key = match configured_key(key_hex) { Some(hex) => { info!("Initializing encryption key from configuration"); EncryptionKey::from_hex(hex)? } None => { warn!( "No encryption key configured, generating a random per-process key. \ Configure BROKKR__BROKER__WEBHOOK_ENCRYPTION_KEY for production use. \ Any webhook subscription created while this is unset becomes permanently \ undeliverable after the next broker restart, because the key that encrypted \ its URL and auth header is discarded when this process exits." ); EncryptionKey::generate() } };
info!("Encryption key fingerprint: {}", key.fingerprint());
ENCRYPTION_KEY .set(Arc::new(key)) .map_err(|_| "Encryption key already initialized".to_string())}fn get_encryption_key
Section titled “fn get_encryption_key”pub
fn get_encryption_key() -> Arc<EncryptionKey>Gets the global encryption key.
Panics:
Panics if called before init_encryption_key().
Source
pub fn get_encryption_key() -> Arc<EncryptionKey> { ENCRYPTION_KEY .get() .expect("Encryption key not initialized. Call init_encryption_key() first.") .clone()}fn encrypt_string
Section titled “fn encrypt_string”pub
fn encrypt_string(value: &str) -> Result<Vec<u8>, EncryptionError>Encrypts a string value for storage.
Parameters:
| Name | Type | Description |
|---|---|---|
value |
&str |
The plaintext string to encrypt. |
Returns:
The encrypted bytes, or an error if encryption fails.
Source
pub fn encrypt_string(value: &str) -> Result<Vec<u8>, EncryptionError> { get_encryption_key().encrypt(value.as_bytes())}fn decrypt_string
Section titled “fn decrypt_string”pub
fn decrypt_string(encrypted: &[u8]) -> Result<String, String>Decrypts bytes back to a string.
Parameters:
| Name | Type | Description |
|---|---|---|
encrypted |
&[u8] |
The encrypted bytes. |
Returns:
The decrypted string, or an error if decryption fails.
Source
pub fn decrypt_string(encrypted: &[u8]) -> Result<String, String> { let bytes = get_encryption_key() .decrypt(encrypted) .map_err(|e| e.to_string())?; String::from_utf8(bytes).map_err(|e| format!("Decrypted value is not valid UTF-8: {}", e))}