use super::redaction::hmac_sha256_hex;
use crate::internal::domain::{AccountIdHash, ErrorCode, GatewayError};
use ring::rand::{SecureRandom, SystemRandom};
pub const MIN_AUDIT_HMAC_KEY_BYTES: usize = 32;
#[derive(Clone)]
pub struct AuditHmacKey(Vec<u8>);
impl AuditHmacKey {
pub fn new(bytes: impl Into<Vec<u8>>) -> Result<Self, GatewayError> {
let bytes = bytes.into();
if bytes.len() < MIN_AUDIT_HMAC_KEY_BYTES {
return Err(GatewayError::new(
ErrorCode::ConfigInvalid,
format!("Audit HMAC key must be at least {MIN_AUDIT_HMAC_KEY_BYTES} bytes"),
false,
Some("Provide a 32-byte or longer random secret via configuration".to_string()),
));
}
Ok(Self(bytes))
}
pub fn ephemeral() -> Result<Self, GatewayError> {
let mut bytes = vec![0_u8; MIN_AUDIT_HMAC_KEY_BYTES];
SystemRandom::new().fill(&mut bytes).map_err(|_| {
GatewayError::new(
ErrorCode::AuditWriteFailed,
"Failed to generate ephemeral audit HMAC key",
true,
Some("Retry gateway startup".to_string()),
)
})?;
Ok(Self(bytes))
}
pub fn compute_account_id_hash(&self, account_id: &str) -> Result<AccountIdHash, GatewayError> {
let hex = hmac_sha256_hex(&self.0, account_id.as_bytes()).map_err(|_| {
GatewayError::new(
ErrorCode::AuditWriteFailed,
"Failed to compute audit HMAC over account identifier",
true,
Some("Retry the operation".to_string()),
)
})?;
Ok(AccountIdHash::from_hash(hex))
}
pub(crate) fn as_bytes(&self) -> &[u8] {
&self.0
}
}
impl std::fmt::Debug for AuditHmacKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AuditHmacKey")
.field("len", &self.0.len())
.finish_non_exhaustive()
}
}
impl PartialEq for AuditHmacKey {
fn eq(&self, other: &Self) -> bool {
constant_time_eq(&self.0, &other.0)
}
}
impl Eq for AuditHmacKey {}
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut diff: u8 = 0;
for (left, right) in a.iter().zip(b.iter()) {
diff |= left ^ right;
}
diff == 0
}
#[cfg(test)]
mod tests {
use super::{AuditHmacKey, MIN_AUDIT_HMAC_KEY_BYTES};
use crate::internal::domain::ErrorCode;
fn sample_key() -> AuditHmacKey {
let Ok(key) = AuditHmacKey::new(vec![0x42_u8; MIN_AUDIT_HMAC_KEY_BYTES]) else {
unreachable!("32-byte literal key should construct");
};
key
}
#[test]
fn rejects_keys_shorter_than_minimum() {
let Err(error) = AuditHmacKey::new(vec![0_u8; MIN_AUDIT_HMAC_KEY_BYTES - 1]) else {
unreachable!("31-byte key should be rejected");
};
assert_eq!(error.code, ErrorCode::ConfigInvalid);
}
#[test]
fn accepts_minimum_length_keys() {
let key = AuditHmacKey::new(vec![0_u8; MIN_AUDIT_HMAC_KEY_BYTES]);
assert!(key.is_ok());
}
#[test]
fn hash_is_deterministic_for_same_key_and_input() {
let key = sample_key();
let Ok(first) = key.compute_account_id_hash("DU1234567") else {
unreachable!("hash must succeed");
};
let Ok(second) = key.compute_account_id_hash("DU1234567") else {
unreachable!("hash must succeed");
};
assert_eq!(first.as_str(), second.as_str());
}
#[test]
fn different_keys_produce_different_hashes() {
let Ok(key_a) = AuditHmacKey::new(vec![0xAA_u8; MIN_AUDIT_HMAC_KEY_BYTES]) else {
unreachable!("key A should construct");
};
let Ok(key_b) = AuditHmacKey::new(vec![0xBB_u8; MIN_AUDIT_HMAC_KEY_BYTES]) else {
unreachable!("key B should construct");
};
let Ok(hash_a) = key_a.compute_account_id_hash("DU1234567") else {
unreachable!("hash A must succeed");
};
let Ok(hash_b) = key_b.compute_account_id_hash("DU1234567") else {
unreachable!("hash B must succeed");
};
assert_ne!(hash_a.as_str(), hash_b.as_str());
}
#[test]
fn hash_does_not_leak_raw_account_id() {
let key = sample_key();
let Ok(hash) = key.compute_account_id_hash("DU1234567") else {
unreachable!("hash must succeed");
};
assert!(!hash.as_str().contains("DU1234567"));
assert!(!hash.as_str().contains("fixture-hmac"));
assert_eq!(hash.as_str().len(), 64);
assert!(hash.as_str().chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn ephemeral_keys_are_distinct() {
let Ok(first) = AuditHmacKey::ephemeral() else {
unreachable!("ephemeral key must generate");
};
let Ok(second) = AuditHmacKey::ephemeral() else {
unreachable!("ephemeral key must generate");
};
assert_ne!(first, second);
}
#[test]
fn debug_does_not_expose_key_material() {
let key = sample_key();
let rendered = format!("{key:?}");
assert!(!rendered.contains("66")); assert!(!rendered.contains("42"));
}
}