use hmac::{Hmac, Mac as _};
use nasaga_core::ServiceIdentity;
use sha2::{Digest as _, Sha256};
use std::collections::{BTreeMap, BTreeSet};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, MutexGuard};
use std::time::{Duration, Instant};
use uuid::Uuid;
type HmacSha256 = Hmac<Sha256>;
#[derive(Clone)]
pub struct SagaHttpCredentials {
current: HmacSha256,
current_result_contract: String,
previous: Vec<(HmacSha256, String, Instant)>,
}
impl SagaHttpCredentials {
pub fn from_hex_key(hex_key: &str) -> Result<Self, SagaHttpMessageAuthError> {
let key = decode_fixed_hex(hex_key).ok_or_else(SagaHttpMessageAuthError::configuration)?;
let current = HmacSha256::new_from_slice(&key)
.map_err(|_| SagaHttpMessageAuthError::configuration())?;
let domain = b"napp-saga-http-result";
let mut digest = Sha256::new();
digest.update((domain.len() as u64).to_be_bytes());
digest.update(domain);
digest.update((hex_key.len() as u64).to_be_bytes());
digest.update(hex_key.as_bytes());
Ok(Self {
current,
current_result_contract: encode_hex(&digest.finalize()),
previous: Vec::new(),
})
}
pub fn rotated(
&self,
hex_key: &str,
overlap: Duration,
) -> Result<Self, SagaHttpMessageAuthError> {
if overlap.is_zero() || overlap > Duration::from_secs(3600) {
return Err(SagaHttpMessageAuthError::configuration());
}
let mut next = Self::from_hex_key(hex_key)?;
let now = Instant::now();
next.previous = self
.previous
.iter()
.filter(|(_, _, expires)| *expires > now)
.cloned()
.collect();
if next.previous.len() >= 8 {
return Err(SagaHttpMessageAuthError::configuration());
}
next.previous.push((
self.current.clone(),
self.current_result_contract.clone(),
now + overlap,
));
Ok(next)
}
pub fn current_result_contract(&self) -> &str {
&self.current_result_contract
}
pub fn accepted_result_contracts(&self) -> BTreeSet<String> {
let now = Instant::now();
std::iter::once(self.current_result_contract.clone())
.chain(
self.previous
.iter()
.filter(|(_, _, expires)| *expires > now)
.map(|(_, digest, _)| digest.clone()),
)
.collect()
}
}
pub trait SagaHttpCredentialSource: Send + Sync {
fn credentials(&self) -> Arc<SagaHttpCredentials>;
}
impl SagaHttpCredentialSource for SagaHttpCredentials {
fn credentials(&self) -> Arc<SagaHttpCredentials> {
Arc::new(self.clone())
}
}
pub struct SagaHttpSignedMessage<'a> {
producer: &'a ServiceIdentity,
path: &'a str,
timestamp_ms: u64,
nonce: &'a str,
body: &'a [u8],
signature: &'a str,
}
impl<'a> SagaHttpSignedMessage<'a> {
pub fn new(
producer: &'a ServiceIdentity,
path: &'a str,
timestamp_ms: u64,
nonce: &'a str,
body: &'a [u8],
signature: &'a str,
) -> Self {
Self {
producer,
path,
timestamp_ms,
nonce,
body,
signature,
}
}
}
#[derive(Clone)]
pub struct SagaHttpMessageAuthenticator {
source: Arc<dyn SagaHttpCredentialSource>,
max_clock_skew_ms: u64,
}
impl SagaHttpMessageAuthenticator {
pub fn from_hex_key(
hex_key: &str,
max_clock_skew_ms: u64,
) -> Result<Self, SagaHttpMessageAuthError> {
Self::from_source(
Arc::new(SagaHttpCredentials::from_hex_key(hex_key)?),
max_clock_skew_ms,
)
}
pub fn from_source(
source: Arc<dyn SagaHttpCredentialSource>,
max_clock_skew_ms: u64,
) -> Result<Self, SagaHttpMessageAuthError> {
if max_clock_skew_ms == 0 {
return Err(SagaHttpMessageAuthError::configuration());
}
Ok(Self {
source,
max_clock_skew_ms,
})
}
pub fn sign(
&self,
producer: &ServiceIdentity,
path: &str,
timestamp_ms: u64,
nonce: &str,
body: &[u8],
) -> String {
let mut mac = self.source.credentials().current.clone();
update_mac(&mut mac, producer, path, timestamp_ms, nonce, body);
encode_hex(&mac.finalize().into_bytes())
}
pub fn verify(
&self,
message: &SagaHttpSignedMessage<'_>,
now_ms: u64,
) -> Result<(), SagaHttpMessageAuthError> {
self.verify_replay_horizon_ms(message, now_ms).map(|_| ())
}
pub fn verify_replay_horizon_ms(
&self,
message: &SagaHttpSignedMessage<'_>,
now_ms: u64,
) -> Result<u64, SagaHttpMessageAuthError> {
if message.timestamp_ms.abs_diff(now_ms) > self.max_clock_skew_ms
|| !valid_nonce(message.nonce)
{
return Err(SagaHttpMessageAuthError::authentication());
}
let expected = decode_fixed_hex(message.signature)
.ok_or_else(SagaHttpMessageAuthError::authentication)?;
let credentials = self.source.credentials();
let now = Instant::now();
let mut accepted = false;
for template in std::iter::once(&credentials.current).chain(
credentials
.previous
.iter()
.filter(|(_, _, expires)| *expires > now)
.map(|(key, _, _)| key),
) {
let mut mac = template.clone();
update_mac(
&mut mac,
message.producer,
message.path,
message.timestamp_ms,
message.nonce,
message.body,
);
accepted |= mac.verify_slice(&expected).is_ok();
}
if !accepted {
return Err(SagaHttpMessageAuthError::authentication());
}
Ok(message.timestamp_ms.saturating_add(self.max_clock_skew_ms))
}
pub fn issue_nonce() -> String {
Uuid::new_v4().simple().to_string()
}
}
pub struct SagaHttpReplayGuard {
max_entries: usize,
claims: Mutex<ReplayClaims>,
accepted_total: AtomicU64,
authentication_failed_total: AtomicU64,
replay_rejected_total: AtomicU64,
capacity_rejected_total: AtomicU64,
lock_poison_recovered_total: AtomicU64,
}
#[derive(Default)]
struct ReplayClaims {
by_nonce: BTreeMap<(ServiceIdentity, String), u64>,
by_expiry: BTreeMap<u64, BTreeSet<(ServiceIdentity, String)>>,
}
impl ReplayClaims {
fn remove_expired(&mut self, now_ms: u64) {
while let Some(expires_at_ms) = self
.by_expiry
.first_key_value()
.map(|(&expires_at_ms, _)| expires_at_ms)
{
if expires_at_ms >= now_ms {
break;
}
let Some(keys) = self.by_expiry.remove(&expires_at_ms) else {
break;
};
for key in keys {
if self.by_nonce.get(&key) == Some(&expires_at_ms) {
self.by_nonce.remove(&key);
}
}
}
}
fn insert(&mut self, key: (ServiceIdentity, String), expires_at_ms: u64) {
self.by_nonce.insert(key.clone(), expires_at_ms);
self.by_expiry.entry(expires_at_ms).or_default().insert(key);
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct SagaHttpReplayMetrics {
pub active_claims: u64,
pub capacity: u64,
pub accepted_total: u64,
pub authentication_failed_total: u64,
pub replay_rejected_total: u64,
pub capacity_rejected_total: u64,
pub lock_poison_recovered_total: u64,
}
impl SagaHttpReplayMetrics {
pub fn utilization_per_mille(self) -> u64 {
if self.capacity == 0 {
return 1_000;
}
self.active_claims
.saturating_mul(1_000)
.checked_div(self.capacity)
.unwrap_or(1_000)
.min(1_000)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SagaHttpReplayPlane {
Command,
Result,
Management,
}
impl SagaHttpReplayPlane {
fn as_str(self) -> &'static str {
match self {
Self::Command => "command",
Self::Result => "result",
Self::Management => "management",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct SagaHttpReplayMetricAggregate {
guard_edges: u64,
active_claims: u64,
capacity: u64,
max_utilization_per_mille: u64,
accepted_total: u64,
authentication_failed_total: u64,
replay_rejected_total: u64,
capacity_rejected_total: u64,
lock_poison_recovered_total: u64,
}
impl SagaHttpReplayMetricAggregate {
pub fn include(&mut self, metrics: SagaHttpReplayMetrics) {
self.guard_edges = self.guard_edges.saturating_add(1);
self.active_claims = self.active_claims.saturating_add(metrics.active_claims);
self.capacity = self.capacity.saturating_add(metrics.capacity);
self.max_utilization_per_mille = self
.max_utilization_per_mille
.max(metrics.utilization_per_mille());
self.accepted_total = self.accepted_total.saturating_add(metrics.accepted_total);
self.authentication_failed_total = self
.authentication_failed_total
.saturating_add(metrics.authentication_failed_total);
self.replay_rejected_total = self
.replay_rejected_total
.saturating_add(metrics.replay_rejected_total);
self.capacity_rejected_total = self
.capacity_rejected_total
.saturating_add(metrics.capacity_rejected_total);
self.lock_poison_recovered_total = self
.lock_poison_recovered_total
.saturating_add(metrics.lock_poison_recovered_total);
}
pub fn render_prometheus(self, plane: SagaHttpReplayPlane) -> String {
let plane = plane.as_str();
format!(
concat!(
"# TYPE nasaga_http_{0}_replay_guard_edges gauge\n",
"nasaga_http_{0}_replay_guard_edges {1}\n",
"# TYPE nasaga_http_{0}_replay_active gauge\n",
"nasaga_http_{0}_replay_active {2}\n",
"# TYPE nasaga_http_{0}_replay_capacity gauge\n",
"nasaga_http_{0}_replay_capacity {3}\n",
"# TYPE nasaga_http_{0}_replay_max_utilization_per_mille gauge\n",
"nasaga_http_{0}_replay_max_utilization_per_mille {4}\n",
"# TYPE nasaga_http_{0}_replay_accepted_total counter\n",
"nasaga_http_{0}_replay_accepted_total {5}\n",
"# TYPE nasaga_http_{0}_replay_authentication_failed_total counter\n",
"nasaga_http_{0}_replay_authentication_failed_total {6}\n",
"# TYPE nasaga_http_{0}_replay_rejected_total counter\n",
"nasaga_http_{0}_replay_rejected_total {7}\n",
"# TYPE nasaga_http_{0}_replay_capacity_rejected_total counter\n",
"nasaga_http_{0}_replay_capacity_rejected_total {8}\n",
"# TYPE nasaga_http_{0}_replay_lock_poison_recovered_total counter\n",
"nasaga_http_{0}_replay_lock_poison_recovered_total {9}\n"
),
plane,
self.guard_edges,
self.active_claims,
self.capacity,
self.max_utilization_per_mille,
self.accepted_total,
self.authentication_failed_total,
self.replay_rejected_total,
self.capacity_rejected_total,
self.lock_poison_recovered_total,
)
}
}
pub fn render_saga_http_command_dlt_metric(total: u64) -> String {
format!("# TYPE nasaga_http_command_dlt_total counter\nnasaga_http_command_dlt_total {total}\n")
}
impl SagaHttpReplayGuard {
pub fn new(max_entries: usize) -> Result<Self, SagaHttpMessageAuthError> {
if max_entries == 0 {
return Err(SagaHttpMessageAuthError::configuration());
}
Ok(Self {
max_entries,
claims: Mutex::new(ReplayClaims::default()),
accepted_total: AtomicU64::new(0),
authentication_failed_total: AtomicU64::new(0),
replay_rejected_total: AtomicU64::new(0),
capacity_rejected_total: AtomicU64::new(0),
lock_poison_recovered_total: AtomicU64::new(0),
})
}
pub fn verify_once(
&self,
authenticator: &SagaHttpMessageAuthenticator,
message: &SagaHttpSignedMessage<'_>,
now_ms: u64,
) -> Result<(), SagaHttpMessageAuthError> {
let expires_at_ms = match authenticator.verify_replay_horizon_ms(message, now_ms) {
Ok(value) => value,
Err(error) => {
saturating_increment(&self.authentication_failed_total);
return Err(error);
}
};
let mut claims = self.lock_claims();
claims.remove_expired(now_ms);
let key = (message.producer.clone(), message.nonce.to_owned());
if claims.by_nonce.contains_key(&key) {
saturating_increment(&self.replay_rejected_total);
return Err(SagaHttpMessageAuthError::replay());
}
if claims.by_nonce.len() >= self.max_entries {
saturating_increment(&self.capacity_rejected_total);
return Err(SagaHttpMessageAuthError::capacity());
}
claims.insert(key, expires_at_ms);
saturating_increment(&self.accepted_total);
Ok(())
}
pub fn metrics(&self, now_ms: u64) -> SagaHttpReplayMetrics {
let mut claims = self.lock_claims();
claims.remove_expired(now_ms);
SagaHttpReplayMetrics {
active_claims: u64::try_from(claims.by_nonce.len()).unwrap_or(u64::MAX),
capacity: u64::try_from(self.max_entries).unwrap_or(u64::MAX),
accepted_total: self.accepted_total.load(Ordering::Relaxed),
authentication_failed_total: self.authentication_failed_total.load(Ordering::Relaxed),
replay_rejected_total: self.replay_rejected_total.load(Ordering::Relaxed),
capacity_rejected_total: self.capacity_rejected_total.load(Ordering::Relaxed),
lock_poison_recovered_total: self.lock_poison_recovered_total.load(Ordering::Relaxed),
}
}
fn lock_claims(&self) -> MutexGuard<'_, ReplayClaims> {
match self.claims.lock() {
Ok(claims) => claims,
Err(poisoned) => {
saturating_increment(&self.lock_poison_recovered_total);
self.claims.clear_poison();
poisoned.into_inner()
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SagaHttpMessageAuthFailure {
ConfigurationInvalid,
AuthenticationFailed,
ReplayDetected,
CapacityExhausted,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SagaHttpMessageAuthError {
failure: SagaHttpMessageAuthFailure,
}
impl SagaHttpMessageAuthError {
pub fn failure(self) -> SagaHttpMessageAuthFailure {
self.failure
}
fn configuration() -> Self {
Self {
failure: SagaHttpMessageAuthFailure::ConfigurationInvalid,
}
}
fn authentication() -> Self {
Self {
failure: SagaHttpMessageAuthFailure::AuthenticationFailed,
}
}
fn replay() -> Self {
Self {
failure: SagaHttpMessageAuthFailure::ReplayDetected,
}
}
fn capacity() -> Self {
Self {
failure: SagaHttpMessageAuthFailure::CapacityExhausted,
}
}
}
impl std::fmt::Display for SagaHttpMessageAuthError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("saga transport message authentication failed")
}
}
impl std::error::Error for SagaHttpMessageAuthError {}
fn saturating_increment(counter: &AtomicU64) {
let _ = counter.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
Some(current.saturating_add(1))
});
}
fn update_mac(
mac: &mut HmacSha256,
producer: &ServiceIdentity,
path: &str,
timestamp_ms: u64,
nonce: &str,
body: &[u8],
) {
mac.update(b"nasaga-http-v2\n");
mac.update(producer.as_str().as_bytes());
mac.update(b"\n");
mac.update(path.as_bytes());
mac.update(b"\n");
mac.update(timestamp_ms.to_string().as_bytes());
mac.update(b"\n");
mac.update(nonce.as_bytes());
mac.update(b"\n");
mac.update(body);
}
fn valid_nonce(nonce: &str) -> bool {
nonce.len() == 32
&& nonce
.bytes()
.all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
}
fn decode_fixed_hex(value: &str) -> Option<[u8; 32]> {
if value.len() != 64 {
return None;
}
let mut decoded = [0_u8; 32];
for (index, pair) in value.as_bytes().as_chunks::<2>().0.iter().enumerate() {
let high = decode_nibble(pair[0])?;
let low = decode_nibble(pair[1])?;
decoded[index] = (high << 4) | low;
}
Some(decoded)
}
fn decode_nibble(value: u8) -> Option<u8> {
match value {
b'0'..=b'9' => Some(value - b'0'),
b'a'..=b'f' => Some(value - b'a' + 10),
_ => None,
}
}
fn encode_hex(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut encoded = String::with_capacity(bytes.len() * 2);
for byte in bytes {
encoded.push(HEX[(byte >> 4) as usize] as char);
encoded.push(HEX[(byte & 0x0f) as usize] as char);
}
encoded
}