Skip to content

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

pub

Encryption key wrapper with AES-256-GCM cipher.

pub

fn new(key: [u8; 32]) -> Self

Creates 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 }
}

pub

fn generate() -> Self

Creates 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)
}

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))
}

pub

fn fingerprint(&self) -> String

Returns 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])
}

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)
}

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()))
}
}
}
}

pub

Encryption error types

  • EncryptionFailed - Encryption operation failed
  • DecryptionFailed - Decryption operation failed (wrong key or corrupted data)
  • InvalidData - Invalid data format
  • UnsupportedVersion - Unsupported encryption version

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."
))
}

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())
}

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()
}

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())
}

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))
}