1use hmac::{Hmac, Mac as _};
8use nasaga_core::ServiceIdentity;
9use sha2::{Digest as _, Sha256};
10use std::collections::{BTreeMap, BTreeSet};
11use std::sync::atomic::{AtomicU64, Ordering};
12use std::sync::{Arc, Mutex, MutexGuard};
13use std::time::{Duration, Instant};
14use uuid::Uuid;
15
16type HmacSha256 = Hmac<Sha256>;
17
18#[derive(Clone)]
20pub struct SagaHttpCredentials {
21 current: HmacSha256,
22 current_result_contract: String,
23 previous: Vec<(HmacSha256, String, Instant)>,
24}
25
26impl SagaHttpCredentials {
27 pub fn from_hex_key(hex_key: &str) -> Result<Self, SagaHttpMessageAuthError> {
34 let key = decode_fixed_hex(hex_key).ok_or_else(SagaHttpMessageAuthError::configuration)?;
35 let current = HmacSha256::new_from_slice(&key)
36 .map_err(|_| SagaHttpMessageAuthError::configuration())?;
37 let domain = b"napp-saga-http-result";
38 let mut digest = Sha256::new();
39 digest.update((domain.len() as u64).to_be_bytes());
40 digest.update(domain);
41 digest.update((hex_key.len() as u64).to_be_bytes());
42 digest.update(hex_key.as_bytes());
43 Ok(Self {
44 current,
45 current_result_contract: encode_hex(&digest.finalize()),
46 previous: Vec::new(),
47 })
48 }
49
50 pub fn rotated(
58 &self,
59 hex_key: &str,
60 overlap: Duration,
61 ) -> Result<Self, SagaHttpMessageAuthError> {
62 if overlap.is_zero() || overlap > Duration::from_secs(3600) {
63 return Err(SagaHttpMessageAuthError::configuration());
64 }
65 let mut next = Self::from_hex_key(hex_key)?;
66 let now = Instant::now();
67 next.previous = self
68 .previous
69 .iter()
70 .filter(|(_, _, expires)| *expires > now)
71 .cloned()
72 .collect();
73 if next.previous.len() >= 8 {
74 return Err(SagaHttpMessageAuthError::configuration());
75 }
76 next.previous.push((
77 self.current.clone(),
78 self.current_result_contract.clone(),
79 now + overlap,
80 ));
81 Ok(next)
82 }
83
84 pub fn current_result_contract(&self) -> &str {
90 &self.current_result_contract
91 }
92
93 pub fn accepted_result_contracts(&self) -> BTreeSet<String> {
99 let now = Instant::now();
100 std::iter::once(self.current_result_contract.clone())
101 .chain(
102 self.previous
103 .iter()
104 .filter(|(_, _, expires)| *expires > now)
105 .map(|(_, digest, _)| digest.clone()),
106 )
107 .collect()
108 }
109}
110
111pub trait SagaHttpCredentialSource: Send + Sync {
113 fn credentials(&self) -> Arc<SagaHttpCredentials>;
119}
120
121impl SagaHttpCredentialSource for SagaHttpCredentials {
122 fn credentials(&self) -> Arc<SagaHttpCredentials> {
128 Arc::new(self.clone())
129 }
130}
131
132pub struct SagaHttpSignedMessage<'a> {
134 producer: &'a ServiceIdentity,
135 path: &'a str,
136 timestamp_ms: u64,
137 nonce: &'a str,
138 body: &'a [u8],
139 signature: &'a str,
140}
141
142impl<'a> SagaHttpSignedMessage<'a> {
143 pub fn new(
155 producer: &'a ServiceIdentity,
156 path: &'a str,
157 timestamp_ms: u64,
158 nonce: &'a str,
159 body: &'a [u8],
160 signature: &'a str,
161 ) -> Self {
162 Self {
163 producer,
164 path,
165 timestamp_ms,
166 nonce,
167 body,
168 signature,
169 }
170 }
171}
172
173#[derive(Clone)]
179pub struct SagaHttpMessageAuthenticator {
180 source: Arc<dyn SagaHttpCredentialSource>,
181 max_clock_skew_ms: u64,
182}
183
184impl SagaHttpMessageAuthenticator {
185 pub fn from_hex_key(
193 hex_key: &str,
194 max_clock_skew_ms: u64,
195 ) -> Result<Self, SagaHttpMessageAuthError> {
196 Self::from_source(
197 Arc::new(SagaHttpCredentials::from_hex_key(hex_key)?),
198 max_clock_skew_ms,
199 )
200 }
201
202 pub fn from_source(
210 source: Arc<dyn SagaHttpCredentialSource>,
211 max_clock_skew_ms: u64,
212 ) -> Result<Self, SagaHttpMessageAuthError> {
213 if max_clock_skew_ms == 0 {
214 return Err(SagaHttpMessageAuthError::configuration());
215 }
216 Ok(Self {
217 source,
218 max_clock_skew_ms,
219 })
220 }
221
222 pub fn sign(
233 &self,
234 producer: &ServiceIdentity,
235 path: &str,
236 timestamp_ms: u64,
237 nonce: &str,
238 body: &[u8],
239 ) -> String {
240 let mut mac = self.source.credentials().current.clone();
241 update_mac(&mut mac, producer, path, timestamp_ms, nonce, body);
242 encode_hex(&mac.finalize().into_bytes())
243 }
244
245 pub fn verify(
256 &self,
257 message: &SagaHttpSignedMessage<'_>,
258 now_ms: u64,
259 ) -> Result<(), SagaHttpMessageAuthError> {
260 self.verify_replay_horizon_ms(message, now_ms).map(|_| ())
261 }
262
263 pub fn verify_replay_horizon_ms(
275 &self,
276 message: &SagaHttpSignedMessage<'_>,
277 now_ms: u64,
278 ) -> Result<u64, SagaHttpMessageAuthError> {
279 if message.timestamp_ms.abs_diff(now_ms) > self.max_clock_skew_ms
280 || !valid_nonce(message.nonce)
281 {
282 return Err(SagaHttpMessageAuthError::authentication());
283 }
284 let expected = decode_fixed_hex(message.signature)
285 .ok_or_else(SagaHttpMessageAuthError::authentication)?;
286 let credentials = self.source.credentials();
287 let now = Instant::now();
288 let mut accepted = false;
289 for template in std::iter::once(&credentials.current).chain(
291 credentials
292 .previous
293 .iter()
294 .filter(|(_, _, expires)| *expires > now)
295 .map(|(key, _, _)| key),
296 ) {
297 let mut mac = template.clone();
298 update_mac(
299 &mut mac,
300 message.producer,
301 message.path,
302 message.timestamp_ms,
303 message.nonce,
304 message.body,
305 );
306 accepted |= mac.verify_slice(&expected).is_ok();
307 }
308 if !accepted {
309 return Err(SagaHttpMessageAuthError::authentication());
310 }
311 Ok(message.timestamp_ms.saturating_add(self.max_clock_skew_ms))
312 }
313
314 pub fn issue_nonce() -> String {
320 Uuid::new_v4().simple().to_string()
321 }
322}
323
324pub struct SagaHttpReplayGuard {
330 max_entries: usize,
331 claims: Mutex<ReplayClaims>,
332 accepted_total: AtomicU64,
333 authentication_failed_total: AtomicU64,
334 replay_rejected_total: AtomicU64,
335 capacity_rejected_total: AtomicU64,
336 lock_poison_recovered_total: AtomicU64,
337}
338
339#[derive(Default)]
341struct ReplayClaims {
342 by_nonce: BTreeMap<(ServiceIdentity, String), u64>,
343 by_expiry: BTreeMap<u64, BTreeSet<(ServiceIdentity, String)>>,
344}
345
346impl ReplayClaims {
347 fn remove_expired(&mut self, now_ms: u64) {
354 while let Some(expires_at_ms) = self
355 .by_expiry
356 .first_key_value()
357 .map(|(&expires_at_ms, _)| expires_at_ms)
358 {
359 if expires_at_ms >= now_ms {
361 break;
362 }
363 let Some(keys) = self.by_expiry.remove(&expires_at_ms) else {
364 break;
365 };
366 for key in keys {
367 if self.by_nonce.get(&key) == Some(&expires_at_ms) {
368 self.by_nonce.remove(&key);
369 }
370 }
371 }
372 }
373
374 fn insert(&mut self, key: (ServiceIdentity, String), expires_at_ms: u64) {
382 self.by_nonce.insert(key.clone(), expires_at_ms);
383 self.by_expiry.entry(expires_at_ms).or_default().insert(key);
384 }
385}
386
387#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
389pub struct SagaHttpReplayMetrics {
390 pub active_claims: u64,
392 pub capacity: u64,
394 pub accepted_total: u64,
396 pub authentication_failed_total: u64,
398 pub replay_rejected_total: u64,
400 pub capacity_rejected_total: u64,
402 pub lock_poison_recovered_total: u64,
404}
405
406impl SagaHttpReplayMetrics {
407 pub fn utilization_per_mille(self) -> u64 {
413 if self.capacity == 0 {
414 return 1_000;
415 }
416 self.active_claims
417 .saturating_mul(1_000)
418 .checked_div(self.capacity)
419 .unwrap_or(1_000)
420 .min(1_000)
421 }
422}
423
424#[derive(Debug, Clone, Copy, PartialEq, Eq)]
426pub enum SagaHttpReplayPlane {
427 Command,
429 Result,
431 Management,
433}
434
435impl SagaHttpReplayPlane {
436 fn as_str(self) -> &'static str {
442 match self {
443 Self::Command => "command",
444 Self::Result => "result",
445 Self::Management => "management",
446 }
447 }
448}
449
450#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
452pub struct SagaHttpReplayMetricAggregate {
453 guard_edges: u64,
454 active_claims: u64,
455 capacity: u64,
456 max_utilization_per_mille: u64,
457 accepted_total: u64,
458 authentication_failed_total: u64,
459 replay_rejected_total: u64,
460 capacity_rejected_total: u64,
461 lock_poison_recovered_total: u64,
462}
463
464impl SagaHttpReplayMetricAggregate {
465 pub fn include(&mut self, metrics: SagaHttpReplayMetrics) {
472 self.guard_edges = self.guard_edges.saturating_add(1);
473 self.active_claims = self.active_claims.saturating_add(metrics.active_claims);
474 self.capacity = self.capacity.saturating_add(metrics.capacity);
475 self.max_utilization_per_mille = self
476 .max_utilization_per_mille
477 .max(metrics.utilization_per_mille());
478 self.accepted_total = self.accepted_total.saturating_add(metrics.accepted_total);
479 self.authentication_failed_total = self
480 .authentication_failed_total
481 .saturating_add(metrics.authentication_failed_total);
482 self.replay_rejected_total = self
483 .replay_rejected_total
484 .saturating_add(metrics.replay_rejected_total);
485 self.capacity_rejected_total = self
486 .capacity_rejected_total
487 .saturating_add(metrics.capacity_rejected_total);
488 self.lock_poison_recovered_total = self
489 .lock_poison_recovered_total
490 .saturating_add(metrics.lock_poison_recovered_total);
491 }
492
493 pub fn render_prometheus(self, plane: SagaHttpReplayPlane) -> String {
500 let plane = plane.as_str();
501 format!(
502 concat!(
503 "# TYPE nasaga_http_{0}_replay_guard_edges gauge\n",
504 "nasaga_http_{0}_replay_guard_edges {1}\n",
505 "# TYPE nasaga_http_{0}_replay_active gauge\n",
506 "nasaga_http_{0}_replay_active {2}\n",
507 "# TYPE nasaga_http_{0}_replay_capacity gauge\n",
508 "nasaga_http_{0}_replay_capacity {3}\n",
509 "# TYPE nasaga_http_{0}_replay_max_utilization_per_mille gauge\n",
510 "nasaga_http_{0}_replay_max_utilization_per_mille {4}\n",
511 "# TYPE nasaga_http_{0}_replay_accepted_total counter\n",
512 "nasaga_http_{0}_replay_accepted_total {5}\n",
513 "# TYPE nasaga_http_{0}_replay_authentication_failed_total counter\n",
514 "nasaga_http_{0}_replay_authentication_failed_total {6}\n",
515 "# TYPE nasaga_http_{0}_replay_rejected_total counter\n",
516 "nasaga_http_{0}_replay_rejected_total {7}\n",
517 "# TYPE nasaga_http_{0}_replay_capacity_rejected_total counter\n",
518 "nasaga_http_{0}_replay_capacity_rejected_total {8}\n",
519 "# TYPE nasaga_http_{0}_replay_lock_poison_recovered_total counter\n",
520 "nasaga_http_{0}_replay_lock_poison_recovered_total {9}\n"
521 ),
522 plane,
523 self.guard_edges,
524 self.active_claims,
525 self.capacity,
526 self.max_utilization_per_mille,
527 self.accepted_total,
528 self.authentication_failed_total,
529 self.replay_rejected_total,
530 self.capacity_rejected_total,
531 self.lock_poison_recovered_total,
532 )
533 }
534}
535
536pub fn render_saga_http_command_dlt_metric(total: u64) -> String {
543 format!("# TYPE nasaga_http_command_dlt_total counter\nnasaga_http_command_dlt_total {total}\n")
544}
545
546impl SagaHttpReplayGuard {
547 pub fn new(max_entries: usize) -> Result<Self, SagaHttpMessageAuthError> {
554 if max_entries == 0 {
555 return Err(SagaHttpMessageAuthError::configuration());
556 }
557 Ok(Self {
558 max_entries,
559 claims: Mutex::new(ReplayClaims::default()),
560 accepted_total: AtomicU64::new(0),
561 authentication_failed_total: AtomicU64::new(0),
562 replay_rejected_total: AtomicU64::new(0),
563 capacity_rejected_total: AtomicU64::new(0),
564 lock_poison_recovered_total: AtomicU64::new(0),
565 })
566 }
567
568 pub fn verify_once(
577 &self,
578 authenticator: &SagaHttpMessageAuthenticator,
579 message: &SagaHttpSignedMessage<'_>,
580 now_ms: u64,
581 ) -> Result<(), SagaHttpMessageAuthError> {
582 let expires_at_ms = match authenticator.verify_replay_horizon_ms(message, now_ms) {
583 Ok(value) => value,
584 Err(error) => {
585 saturating_increment(&self.authentication_failed_total);
586 return Err(error);
587 }
588 };
589
590 let mut claims = self.lock_claims();
591 claims.remove_expired(now_ms);
592 let key = (message.producer.clone(), message.nonce.to_owned());
593 if claims.by_nonce.contains_key(&key) {
594 saturating_increment(&self.replay_rejected_total);
595 return Err(SagaHttpMessageAuthError::replay());
596 }
597 if claims.by_nonce.len() >= self.max_entries {
598 saturating_increment(&self.capacity_rejected_total);
599 return Err(SagaHttpMessageAuthError::capacity());
600 }
601 claims.insert(key, expires_at_ms);
604 saturating_increment(&self.accepted_total);
605 Ok(())
606 }
607
608 pub fn metrics(&self, now_ms: u64) -> SagaHttpReplayMetrics {
615 let mut claims = self.lock_claims();
616 claims.remove_expired(now_ms);
617 SagaHttpReplayMetrics {
618 active_claims: u64::try_from(claims.by_nonce.len()).unwrap_or(u64::MAX),
619 capacity: u64::try_from(self.max_entries).unwrap_or(u64::MAX),
620 accepted_total: self.accepted_total.load(Ordering::Relaxed),
621 authentication_failed_total: self.authentication_failed_total.load(Ordering::Relaxed),
622 replay_rejected_total: self.replay_rejected_total.load(Ordering::Relaxed),
623 capacity_rejected_total: self.capacity_rejected_total.load(Ordering::Relaxed),
624 lock_poison_recovered_total: self.lock_poison_recovered_total.load(Ordering::Relaxed),
625 }
626 }
627
628 fn lock_claims(&self) -> MutexGuard<'_, ReplayClaims> {
634 match self.claims.lock() {
635 Ok(claims) => claims,
636 Err(poisoned) => {
637 saturating_increment(&self.lock_poison_recovered_total);
638 self.claims.clear_poison();
641 poisoned.into_inner()
642 }
643 }
644 }
645}
646
647#[derive(Debug, Clone, Copy, PartialEq, Eq)]
649pub enum SagaHttpMessageAuthFailure {
650 ConfigurationInvalid,
652 AuthenticationFailed,
654 ReplayDetected,
656 CapacityExhausted,
658}
659
660#[derive(Debug, Clone, Copy, PartialEq, Eq)]
662pub struct SagaHttpMessageAuthError {
663 failure: SagaHttpMessageAuthFailure,
664}
665
666impl SagaHttpMessageAuthError {
667 pub fn failure(self) -> SagaHttpMessageAuthFailure {
673 self.failure
674 }
675
676 fn configuration() -> Self {
682 Self {
683 failure: SagaHttpMessageAuthFailure::ConfigurationInvalid,
684 }
685 }
686
687 fn authentication() -> Self {
693 Self {
694 failure: SagaHttpMessageAuthFailure::AuthenticationFailed,
695 }
696 }
697
698 fn replay() -> Self {
704 Self {
705 failure: SagaHttpMessageAuthFailure::ReplayDetected,
706 }
707 }
708
709 fn capacity() -> Self {
715 Self {
716 failure: SagaHttpMessageAuthFailure::CapacityExhausted,
717 }
718 }
719}
720
721impl std::fmt::Display for SagaHttpMessageAuthError {
722 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
729 formatter.write_str("saga transport message authentication failed")
730 }
731}
732
733impl std::error::Error for SagaHttpMessageAuthError {}
734
735fn saturating_increment(counter: &AtomicU64) {
742 let _ = counter.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
743 Some(current.saturating_add(1))
744 });
745}
746
747fn update_mac(
759 mac: &mut HmacSha256,
760 producer: &ServiceIdentity,
761 path: &str,
762 timestamp_ms: u64,
763 nonce: &str,
764 body: &[u8],
765) {
766 mac.update(b"nasaga-http-v2\n");
767 mac.update(producer.as_str().as_bytes());
768 mac.update(b"\n");
769 mac.update(path.as_bytes());
770 mac.update(b"\n");
771 mac.update(timestamp_ms.to_string().as_bytes());
772 mac.update(b"\n");
773 mac.update(nonce.as_bytes());
774 mac.update(b"\n");
775 mac.update(body);
776}
777
778fn valid_nonce(nonce: &str) -> bool {
785 nonce.len() == 32
786 && nonce
787 .bytes()
788 .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
789}
790
791fn decode_fixed_hex(value: &str) -> Option<[u8; 32]> {
798 if value.len() != 64 {
799 return None;
800 }
801 let mut decoded = [0_u8; 32];
802 for (index, pair) in value.as_bytes().as_chunks::<2>().0.iter().enumerate() {
803 let high = decode_nibble(pair[0])?;
804 let low = decode_nibble(pair[1])?;
805 decoded[index] = (high << 4) | low;
806 }
807 Some(decoded)
808}
809
810fn decode_nibble(value: u8) -> Option<u8> {
817 match value {
818 b'0'..=b'9' => Some(value - b'0'),
819 b'a'..=b'f' => Some(value - b'a' + 10),
820 _ => None,
821 }
822}
823
824fn encode_hex(bytes: &[u8]) -> String {
831 const HEX: &[u8; 16] = b"0123456789abcdef";
832 let mut encoded = String::with_capacity(bytes.len() * 2);
833 for byte in bytes {
834 encoded.push(HEX[(byte >> 4) as usize] as char);
835 encoded.push(HEX[(byte & 0x0f) as usize] as char);
836 }
837 encoded
838}