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> {
31 let key = decode_fixed_hex(hex_key).ok_or_else(SagaHttpMessageAuthError::configuration)?;
32 let current = HmacSha256::new_from_slice(&key)
33 .map_err(|_| SagaHttpMessageAuthError::configuration())?;
34 let domain = b"napp-saga-http-result";
35 let mut digest = Sha256::new();
36 digest.update((domain.len() as u64).to_be_bytes());
37 digest.update(domain);
38 digest.update((hex_key.len() as u64).to_be_bytes());
39 digest.update(hex_key.as_bytes());
40 Ok(Self {
41 current,
42 current_result_contract: encode_hex(&digest.finalize()),
43 previous: Vec::new(),
44 })
45 }
46
47 pub fn rotated(
51 &self,
52 hex_key: &str,
53 overlap: Duration,
54 ) -> Result<Self, SagaHttpMessageAuthError> {
55 if overlap.is_zero() || overlap > Duration::from_secs(3600) {
56 return Err(SagaHttpMessageAuthError::configuration());
57 }
58 let mut next = Self::from_hex_key(hex_key)?;
59 let now = Instant::now();
60 next.previous = self
61 .previous
62 .iter()
63 .filter(|(_, _, expires)| *expires > now)
64 .cloned()
65 .collect();
66 if next.previous.len() >= 8 {
67 return Err(SagaHttpMessageAuthError::configuration());
68 }
69 next.previous.push((
70 self.current.clone(),
71 self.current_result_contract.clone(),
72 now + overlap,
73 ));
74 Ok(next)
75 }
76
77 pub fn current_result_contract(&self) -> &str {
81 &self.current_result_contract
82 }
83
84 pub fn accepted_result_contracts(&self) -> BTreeSet<String> {
88 let now = Instant::now();
89 std::iter::once(self.current_result_contract.clone())
90 .chain(
91 self.previous
92 .iter()
93 .filter(|(_, _, expires)| *expires > now)
94 .map(|(_, digest, _)| digest.clone()),
95 )
96 .collect()
97 }
98}
99
100pub trait SagaHttpCredentialSource: Send + Sync {
102 fn credentials(&self) -> Arc<SagaHttpCredentials>;
106}
107
108impl SagaHttpCredentialSource for SagaHttpCredentials {
109 fn credentials(&self) -> Arc<SagaHttpCredentials> {
113 Arc::new(self.clone())
114 }
115}
116
117pub struct SagaHttpSignedMessage<'a> {
119 producer: &'a ServiceIdentity,
120 path: &'a str,
121 timestamp_ms: u64,
122 nonce: &'a str,
123 body: &'a [u8],
124 signature: &'a str,
125}
126
127impl<'a> SagaHttpSignedMessage<'a> {
128 pub fn new(
140 producer: &'a ServiceIdentity,
141 path: &'a str,
142 timestamp_ms: u64,
143 nonce: &'a str,
144 body: &'a [u8],
145 signature: &'a str,
146 ) -> Self {
147 Self {
148 producer,
149 path,
150 timestamp_ms,
151 nonce,
152 body,
153 signature,
154 }
155 }
156}
157
158#[derive(Clone)]
164pub struct SagaHttpMessageAuthenticator {
165 source: Arc<dyn SagaHttpCredentialSource>,
166 max_clock_skew_ms: u64,
167}
168
169impl SagaHttpMessageAuthenticator {
170 pub fn from_hex_key(
178 hex_key: &str,
179 max_clock_skew_ms: u64,
180 ) -> Result<Self, SagaHttpMessageAuthError> {
181 Self::from_source(
182 Arc::new(SagaHttpCredentials::from_hex_key(hex_key)?),
183 max_clock_skew_ms,
184 )
185 }
186
187 pub fn from_source(
191 source: Arc<dyn SagaHttpCredentialSource>,
192 max_clock_skew_ms: u64,
193 ) -> Result<Self, SagaHttpMessageAuthError> {
194 if max_clock_skew_ms == 0 {
195 return Err(SagaHttpMessageAuthError::configuration());
196 }
197 Ok(Self {
198 source,
199 max_clock_skew_ms,
200 })
201 }
202
203 pub fn sign(
214 &self,
215 producer: &ServiceIdentity,
216 path: &str,
217 timestamp_ms: u64,
218 nonce: &str,
219 body: &[u8],
220 ) -> String {
221 let mut mac = self.source.credentials().current.clone();
222 update_mac(&mut mac, producer, path, timestamp_ms, nonce, body);
223 encode_hex(&mac.finalize().into_bytes())
224 }
225
226 pub fn verify(
237 &self,
238 message: &SagaHttpSignedMessage<'_>,
239 now_ms: u64,
240 ) -> Result<(), SagaHttpMessageAuthError> {
241 self.verify_replay_horizon_ms(message, now_ms).map(|_| ())
242 }
243
244 pub fn verify_replay_horizon_ms(
256 &self,
257 message: &SagaHttpSignedMessage<'_>,
258 now_ms: u64,
259 ) -> Result<u64, SagaHttpMessageAuthError> {
260 if message.timestamp_ms.abs_diff(now_ms) > self.max_clock_skew_ms
261 || !valid_nonce(message.nonce)
262 {
263 return Err(SagaHttpMessageAuthError::authentication());
264 }
265 let expected = decode_fixed_hex(message.signature)
266 .ok_or_else(SagaHttpMessageAuthError::authentication)?;
267 let credentials = self.source.credentials();
268 let now = Instant::now();
269 let mut accepted = false;
270 for template in std::iter::once(&credentials.current).chain(
272 credentials
273 .previous
274 .iter()
275 .filter(|(_, _, expires)| *expires > now)
276 .map(|(key, _, _)| key),
277 ) {
278 let mut mac = template.clone();
279 update_mac(
280 &mut mac,
281 message.producer,
282 message.path,
283 message.timestamp_ms,
284 message.nonce,
285 message.body,
286 );
287 accepted |= mac.verify_slice(&expected).is_ok();
288 }
289 if !accepted {
290 return Err(SagaHttpMessageAuthError::authentication());
291 }
292 Ok(message.timestamp_ms.saturating_add(self.max_clock_skew_ms))
293 }
294
295 pub fn issue_nonce() -> String {
301 Uuid::new_v4().simple().to_string()
302 }
303}
304
305pub struct SagaHttpReplayGuard {
311 max_entries: usize,
312 claims: Mutex<ReplayClaims>,
313 accepted_total: AtomicU64,
314 authentication_failed_total: AtomicU64,
315 replay_rejected_total: AtomicU64,
316 capacity_rejected_total: AtomicU64,
317 lock_poison_recovered_total: AtomicU64,
318}
319
320#[derive(Default)]
322struct ReplayClaims {
323 by_nonce: BTreeMap<(ServiceIdentity, String), u64>,
324 by_expiry: BTreeMap<u64, BTreeSet<(ServiceIdentity, String)>>,
325}
326
327impl ReplayClaims {
328 fn remove_expired(&mut self, now_ms: u64) {
335 while let Some(expires_at_ms) = self
336 .by_expiry
337 .first_key_value()
338 .map(|(&expires_at_ms, _)| expires_at_ms)
339 {
340 if expires_at_ms >= now_ms {
342 break;
343 }
344 let Some(keys) = self.by_expiry.remove(&expires_at_ms) else {
345 break;
346 };
347 for key in keys {
348 if self.by_nonce.get(&key) == Some(&expires_at_ms) {
349 self.by_nonce.remove(&key);
350 }
351 }
352 }
353 }
354
355 fn insert(&mut self, key: (ServiceIdentity, String), expires_at_ms: u64) {
363 self.by_nonce.insert(key.clone(), expires_at_ms);
364 self.by_expiry.entry(expires_at_ms).or_default().insert(key);
365 }
366}
367
368#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
370pub struct SagaHttpReplayMetrics {
371 pub active_claims: u64,
373 pub capacity: u64,
375 pub accepted_total: u64,
377 pub authentication_failed_total: u64,
379 pub replay_rejected_total: u64,
381 pub capacity_rejected_total: u64,
383 pub lock_poison_recovered_total: u64,
385}
386
387impl SagaHttpReplayMetrics {
388 pub fn utilization_per_mille(self) -> u64 {
394 if self.capacity == 0 {
395 return 1_000;
396 }
397 self.active_claims
398 .saturating_mul(1_000)
399 .checked_div(self.capacity)
400 .unwrap_or(1_000)
401 .min(1_000)
402 }
403}
404
405#[derive(Debug, Clone, Copy, PartialEq, Eq)]
407pub enum SagaHttpReplayPlane {
408 Command,
410 Result,
412 Management,
414}
415
416impl SagaHttpReplayPlane {
417 fn as_str(self) -> &'static str {
423 match self {
424 Self::Command => "command",
425 Self::Result => "result",
426 Self::Management => "management",
427 }
428 }
429}
430
431#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
433pub struct SagaHttpReplayMetricAggregate {
434 guard_edges: u64,
435 active_claims: u64,
436 capacity: u64,
437 max_utilization_per_mille: u64,
438 accepted_total: u64,
439 authentication_failed_total: u64,
440 replay_rejected_total: u64,
441 capacity_rejected_total: u64,
442 lock_poison_recovered_total: u64,
443}
444
445impl SagaHttpReplayMetricAggregate {
446 pub fn include(&mut self, metrics: SagaHttpReplayMetrics) {
453 self.guard_edges = self.guard_edges.saturating_add(1);
454 self.active_claims = self.active_claims.saturating_add(metrics.active_claims);
455 self.capacity = self.capacity.saturating_add(metrics.capacity);
456 self.max_utilization_per_mille = self
457 .max_utilization_per_mille
458 .max(metrics.utilization_per_mille());
459 self.accepted_total = self.accepted_total.saturating_add(metrics.accepted_total);
460 self.authentication_failed_total = self
461 .authentication_failed_total
462 .saturating_add(metrics.authentication_failed_total);
463 self.replay_rejected_total = self
464 .replay_rejected_total
465 .saturating_add(metrics.replay_rejected_total);
466 self.capacity_rejected_total = self
467 .capacity_rejected_total
468 .saturating_add(metrics.capacity_rejected_total);
469 self.lock_poison_recovered_total = self
470 .lock_poison_recovered_total
471 .saturating_add(metrics.lock_poison_recovered_total);
472 }
473
474 pub fn render_prometheus(self, plane: SagaHttpReplayPlane) -> String {
481 let plane = plane.as_str();
482 format!(
483 concat!(
484 "# TYPE nasaga_http_{0}_replay_guard_edges gauge\n",
485 "nasaga_http_{0}_replay_guard_edges {1}\n",
486 "# TYPE nasaga_http_{0}_replay_active gauge\n",
487 "nasaga_http_{0}_replay_active {2}\n",
488 "# TYPE nasaga_http_{0}_replay_capacity gauge\n",
489 "nasaga_http_{0}_replay_capacity {3}\n",
490 "# TYPE nasaga_http_{0}_replay_max_utilization_per_mille gauge\n",
491 "nasaga_http_{0}_replay_max_utilization_per_mille {4}\n",
492 "# TYPE nasaga_http_{0}_replay_accepted_total counter\n",
493 "nasaga_http_{0}_replay_accepted_total {5}\n",
494 "# TYPE nasaga_http_{0}_replay_authentication_failed_total counter\n",
495 "nasaga_http_{0}_replay_authentication_failed_total {6}\n",
496 "# TYPE nasaga_http_{0}_replay_rejected_total counter\n",
497 "nasaga_http_{0}_replay_rejected_total {7}\n",
498 "# TYPE nasaga_http_{0}_replay_capacity_rejected_total counter\n",
499 "nasaga_http_{0}_replay_capacity_rejected_total {8}\n",
500 "# TYPE nasaga_http_{0}_replay_lock_poison_recovered_total counter\n",
501 "nasaga_http_{0}_replay_lock_poison_recovered_total {9}\n"
502 ),
503 plane,
504 self.guard_edges,
505 self.active_claims,
506 self.capacity,
507 self.max_utilization_per_mille,
508 self.accepted_total,
509 self.authentication_failed_total,
510 self.replay_rejected_total,
511 self.capacity_rejected_total,
512 self.lock_poison_recovered_total,
513 )
514 }
515}
516
517pub fn render_saga_http_command_dlt_metric(total: u64) -> String {
524 format!("# TYPE nasaga_http_command_dlt_total counter\nnasaga_http_command_dlt_total {total}\n")
525}
526
527impl SagaHttpReplayGuard {
528 pub fn new(max_entries: usize) -> Result<Self, SagaHttpMessageAuthError> {
535 if max_entries == 0 {
536 return Err(SagaHttpMessageAuthError::configuration());
537 }
538 Ok(Self {
539 max_entries,
540 claims: Mutex::new(ReplayClaims::default()),
541 accepted_total: AtomicU64::new(0),
542 authentication_failed_total: AtomicU64::new(0),
543 replay_rejected_total: AtomicU64::new(0),
544 capacity_rejected_total: AtomicU64::new(0),
545 lock_poison_recovered_total: AtomicU64::new(0),
546 })
547 }
548
549 pub fn verify_once(
558 &self,
559 authenticator: &SagaHttpMessageAuthenticator,
560 message: &SagaHttpSignedMessage<'_>,
561 now_ms: u64,
562 ) -> Result<(), SagaHttpMessageAuthError> {
563 let expires_at_ms = match authenticator.verify_replay_horizon_ms(message, now_ms) {
564 Ok(value) => value,
565 Err(error) => {
566 saturating_increment(&self.authentication_failed_total);
567 return Err(error);
568 }
569 };
570
571 let mut claims = self.lock_claims();
572 claims.remove_expired(now_ms);
573 let key = (message.producer.clone(), message.nonce.to_owned());
574 if claims.by_nonce.contains_key(&key) {
575 saturating_increment(&self.replay_rejected_total);
576 return Err(SagaHttpMessageAuthError::replay());
577 }
578 if claims.by_nonce.len() >= self.max_entries {
579 saturating_increment(&self.capacity_rejected_total);
580 return Err(SagaHttpMessageAuthError::capacity());
581 }
582 claims.insert(key, expires_at_ms);
585 saturating_increment(&self.accepted_total);
586 Ok(())
587 }
588
589 pub fn metrics(&self, now_ms: u64) -> SagaHttpReplayMetrics {
596 let mut claims = self.lock_claims();
597 claims.remove_expired(now_ms);
598 SagaHttpReplayMetrics {
599 active_claims: u64::try_from(claims.by_nonce.len()).unwrap_or(u64::MAX),
600 capacity: u64::try_from(self.max_entries).unwrap_or(u64::MAX),
601 accepted_total: self.accepted_total.load(Ordering::Relaxed),
602 authentication_failed_total: self.authentication_failed_total.load(Ordering::Relaxed),
603 replay_rejected_total: self.replay_rejected_total.load(Ordering::Relaxed),
604 capacity_rejected_total: self.capacity_rejected_total.load(Ordering::Relaxed),
605 lock_poison_recovered_total: self.lock_poison_recovered_total.load(Ordering::Relaxed),
606 }
607 }
608
609 fn lock_claims(&self) -> MutexGuard<'_, ReplayClaims> {
615 match self.claims.lock() {
616 Ok(claims) => claims,
617 Err(poisoned) => {
618 saturating_increment(&self.lock_poison_recovered_total);
619 self.claims.clear_poison();
622 poisoned.into_inner()
623 }
624 }
625 }
626}
627
628#[derive(Debug, Clone, Copy, PartialEq, Eq)]
630pub enum SagaHttpMessageAuthFailure {
631 ConfigurationInvalid,
633 AuthenticationFailed,
635 ReplayDetected,
637 CapacityExhausted,
639}
640
641#[derive(Debug, Clone, Copy, PartialEq, Eq)]
643pub struct SagaHttpMessageAuthError {
644 failure: SagaHttpMessageAuthFailure,
645}
646
647impl SagaHttpMessageAuthError {
648 pub fn failure(self) -> SagaHttpMessageAuthFailure {
654 self.failure
655 }
656
657 fn configuration() -> Self {
663 Self {
664 failure: SagaHttpMessageAuthFailure::ConfigurationInvalid,
665 }
666 }
667
668 fn authentication() -> Self {
674 Self {
675 failure: SagaHttpMessageAuthFailure::AuthenticationFailed,
676 }
677 }
678
679 fn replay() -> Self {
685 Self {
686 failure: SagaHttpMessageAuthFailure::ReplayDetected,
687 }
688 }
689
690 fn capacity() -> Self {
696 Self {
697 failure: SagaHttpMessageAuthFailure::CapacityExhausted,
698 }
699 }
700}
701
702impl std::fmt::Display for SagaHttpMessageAuthError {
703 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
710 formatter.write_str("saga transport message authentication failed")
711 }
712}
713
714impl std::error::Error for SagaHttpMessageAuthError {}
715
716fn saturating_increment(counter: &AtomicU64) {
723 let _ = counter.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
724 Some(current.saturating_add(1))
725 });
726}
727
728fn update_mac(
740 mac: &mut HmacSha256,
741 producer: &ServiceIdentity,
742 path: &str,
743 timestamp_ms: u64,
744 nonce: &str,
745 body: &[u8],
746) {
747 mac.update(b"nasaga-http-v2\n");
748 mac.update(producer.as_str().as_bytes());
749 mac.update(b"\n");
750 mac.update(path.as_bytes());
751 mac.update(b"\n");
752 mac.update(timestamp_ms.to_string().as_bytes());
753 mac.update(b"\n");
754 mac.update(nonce.as_bytes());
755 mac.update(b"\n");
756 mac.update(body);
757}
758
759fn valid_nonce(nonce: &str) -> bool {
766 nonce.len() == 32
767 && nonce
768 .bytes()
769 .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
770}
771
772fn decode_fixed_hex(value: &str) -> Option<[u8; 32]> {
779 if value.len() != 64 {
780 return None;
781 }
782 let mut decoded = [0_u8; 32];
783 for (index, pair) in value.as_bytes().as_chunks::<2>().0.iter().enumerate() {
784 let high = decode_nibble(pair[0])?;
785 let low = decode_nibble(pair[1])?;
786 decoded[index] = (high << 4) | low;
787 }
788 Some(decoded)
789}
790
791fn decode_nibble(value: u8) -> Option<u8> {
798 match value {
799 b'0'..=b'9' => Some(value - b'0'),
800 b'a'..=b'f' => Some(value - b'a' + 10),
801 _ => None,
802 }
803}
804
805fn encode_hex(bytes: &[u8]) -> String {
812 const HEX: &[u8; 16] = b"0123456789abcdef";
813 let mut encoded = String::with_capacity(bytes.len() * 2);
814 for byte in bytes {
815 encoded.push(HEX[(byte >> 4) as usize] as char);
816 encoded.push(HEX[(byte & 0x0f) as usize] as char);
817 }
818 encoded
819}