Skip to main content

nasaga_runtime_core/
transport_auth.rs

1//! 非 Kafka connector 的消息级身份绑定。
2//!
3//! 生产 Kafka adapter 以 SASL principal、ACL 与冻结 topic route 解析逻辑 producer;HTTP/gRPC
4//! 等 connector 则必须在调用 authenticated runtime API 前提供等价证明。本模块为 HTTP transport
5//! 提供 HMAC-SHA-256 收据,避免把可由请求方任意填写的 producer header 当成认证结果。
6
7use 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/// 业务作用:保存当前出站 HMAC 与仅在重叠窗口内允许入站验签的历史材料。
19#[derive(Clone)]
20pub struct SagaHttpCredentials {
21    current: HmacSha256,
22    current_result_contract: String,
23    previous: Vec<(HmacSha256, String, Instant)>,
24}
25
26impl SagaHttpCredentials {
27    /// 业务作用:在配置准备阶段校验 HMAC 材料,避免无效候选影响当前请求。
28    ///
29    /// 参数说明:
30    /// - `hex_key`:必须是 256 位小写十六进制 key。
31    ///
32    /// 返回:合法材料对应的不可变凭据快照;非法编码返回脱敏配置错误。
33    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    /// 业务作用:准备新出站凭据并保留有界的旧入站验签窗口,候选未发布时不改变当前权威。
51    ///
52    /// 参数说明:
53    /// - `hex_key`:是新材料
54    /// - `overlap`:为本地单调时钟上的旧 key 保留期限,最长一小时。
55    ///
56    /// 返回:完整候选快照;非法 key、零期限或超过八份仍有效的旧材料时拒绝候选,不提前丢弃旧窗口。
57    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    /// 业务作用:让 capability 绑定当前实际出站 key 的不可逆合同摘要,避免 Registry 凭据掩盖结果凭据错配。
85    ///
86    /// 参数说明:无。
87    ///
88    /// 返回:当前 HMAC 材料的域分离 SHA-256,不暴露 key 或可重放的请求签名。
89    pub fn current_result_contract(&self) -> &str {
90        &self.current_result_contract
91    }
92
93    /// 业务作用:让 Catalog 使用与入站验签相同的旧 key 截止时刻判断结果凭据是否可接受。
94    ///
95    /// 参数说明:无。
96    ///
97    /// 返回:当前与尚在重叠窗口内的合同摘要;过期旧 key 不进入激活证据。
98    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
111/// 业务作用:让所有入站和出站认证器从同一受信配置快照读取当前凭据。
112pub trait SagaHttpCredentialSource: Send + Sync {
113    /// 业务作用:取得一次认证操作完整使用的不可变凭据集合。
114    ///
115    /// 参数说明:无。
116    ///
117    /// 返回:已通过配置准备校验的快照;调用期间不能混用两代 key。
118    fn credentials(&self) -> Arc<SagaHttpCredentials>;
119}
120
121impl SagaHttpCredentialSource for SagaHttpCredentials {
122    /// 业务作用:为固定 key 使用者提供与动态认证器一致的快照读取边界。
123    ///
124    /// 参数说明:无。
125    ///
126    /// 返回:当前固定凭据的独立不可变快照。
127    fn credentials(&self) -> Arc<SagaHttpCredentials> {
128        Arc::new(self.clone())
129    }
130}
131
132/// 业务作用:封装接收端实际观察到的签名字段,保证验签与 replay claim 消费同一份字节。
133pub 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    /// 业务作用:冻结一条待认证 HTTP 请求的 producer、route、时钟、nonce、body 与 HMAC。
144    ///
145    /// 参数说明:
146    /// - `producer`: transport 凭据映射出的逻辑 producer。
147    /// - `path`: 实际命中的固定 path。
148    /// - `timestamp_ms`: 请求签名时间。
149    /// - `nonce`: 请求的一次性随机数。
150    /// - `body`: 收到的原始 body。
151    /// - `signature`: 请求携带的 HMAC。
152    ///
153    /// 返回:只借用原始字段的不可变收据;编码与密码学检查由认证器执行。
154    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/// 业务作用:绑定单条 transport 信任边的凭据来源与允许时钟偏差。
174///
175/// 同一实例只能代表一个已由部署配置绑定的 producer。签名覆盖 producer、HTTP path、时间戳、
176/// 一次性 nonce 和原始 body;接收方校验成功并占用 nonce 后,才能把该 producer 传给 runtime 的
177/// authenticated API。
178#[derive(Clone)]
179pub struct SagaHttpMessageAuthenticator {
180    source: Arc<dyn SagaHttpCredentialSource>,
181    max_clock_skew_ms: u64,
182}
183
184impl SagaHttpMessageAuthenticator {
185    /// 业务作用:从部署注入的 256 位十六进制 key 构造消息认证器。
186    ///
187    /// 参数说明:
188    /// - `hex_key`: 恰好 64 个小写十六进制字符;不得写入日志、代码或 Outbox。
189    /// - `max_clock_skew_ms`: 接收时间与签名时间允许的最大偏差,必须大于零。
190    ///
191    /// 返回:key 与时间窗口合法时返回认证器;否则拒绝启动。
192    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    /// 业务作用:绑定受信凭据快照来源,使既有认证器克隆在下一次请求观察原子轮换。
203    ///
204    /// 参数说明:
205    /// - `source`:只提供已校验材料
206    /// - `max_clock_skew_ms`:限制签名时钟偏差。
207    ///
208    /// 返回:偏差窗口非零时返回认证器;零窗口拒绝装配。
209    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    /// 业务作用:为一条已持久化 Saga transport payload 生成防篡改身份收据。
223    ///
224    /// 参数说明:
225    /// - `producer`: 此 key 在部署信任表中绑定的逻辑 producer。
226    /// - `path`: 接收端固定 Saga path,防止签名跨端点复用。
227    /// - `timestamp_ms`: 发送时 Unix 毫秒。
228    /// - `nonce`: 每次投递新生成的 128 位小写十六进制随机数。
229    /// - `body`: 将被原样发送的 HTTP body。
230    ///
231    /// 返回:64 字符小写十六进制 HMAC-SHA-256;调用方必须与上述字段一同发送。
232    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    /// 业务作用:在解析 envelope 前校验 transport producer 收据,但不占用 replay claim。
246    ///
247    /// 多副本接收端必须在本方法成功后、进入业务处理前占用共享强一致 nonce;单进程调用方
248    /// 应使用 [`SagaHttpReplayGuard::verify_once`]。只调用本方法不能构成完整的重放防护。
249    ///
250    /// 参数说明:
251    /// - `message`: 冻结 producer/path/timestamp/nonce/body/signature 的原始收据。
252    /// - `now_ms`: 接收端当前 Unix 毫秒。
253    ///
254    /// 返回:身份、路径、时间窗和 body 全部匹配时成功;任一不符返回同一脱敏错误。
255    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    /// 业务作用:校验 transport producer 收据,并给共享 nonce 存储返回由签名时间窗决定的保留边界。
264    ///
265    /// 多副本接收端必须把返回值原样用于共享 claim,不能按接收时刻另算较短期限;否则未来偏移
266    /// 但仍合法的签名会在认证窗口结束前重新取得同一 nonce。
267    ///
268    /// 参数说明:
269    /// - `message`: 冻结 producer/path/timestamp/nonce/body/signature 的原始收据。
270    /// - `now_ms`: 接收端当前 Unix 毫秒。
271    ///
272    /// 返回:身份、路径、时间窗和 body 全部匹配时返回签名合法窗口的闭区间右端;任一不符返回
273    /// 同一脱敏错误。
274    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        // 只读取一次凭据快照;所有仍有效的 key 都参与常数时间 MAC 比较,旧材料到期立即失去验签权威。
290        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    /// 业务作用:生成一条 transport 投递专用的不可预测 nonce,供 replay guard 建立一次性门禁。
315    ///
316    /// 参数说明: 无。
317    ///
318    /// 返回:128 位随机 UUID 的 32 字符小写十六进制表示;调用方不得跨请求复用。
319    pub fn issue_nonce() -> String {
320        Uuid::new_v4().simple().to_string()
321    }
322}
323
324/// 业务作用:在 HMAC 时间窗内原子拒绝已经验真的 nonce,防止认证请求被原样重放。
325///
326/// 本结构有意使用有界内存:过期项在每次 claim 前清理,未过期项达到上限时 fail-closed,绝不
327/// 淘汰仍可重放的证据。它保护单进程 connector;多副本生产入口必须把 nonce claim 放入共享的
328/// 强一致存储,或由网关提供等价的一次性请求语义。
329pub 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/// 业务作用:维护 nonce 主索引与过期时间索引,使请求清理成本只与本次实际过期项数量相关。
340#[derive(Default)]
341struct ReplayClaims {
342    by_nonce: BTreeMap<(ServiceIdentity, String), u64>,
343    by_expiry: BTreeMap<u64, BTreeSet<(ServiceIdentity, String)>>,
344}
345
346impl ReplayClaims {
347    /// 业务作用:清理已越过 replay horizon 的 claim,而不在每次请求扫描全部活跃 nonce。
348    ///
349    /// 参数说明:
350    /// - `now_ms`: 当前接收时钟;仅清除过期点严格小于该值的记录。
351    ///
352    /// 返回:无;两个索引保持同步,仍在时间窗内的 claim 不被淘汰。
353    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            // 验签时间窗包含 `abs_diff == skew` 的边界,因此 claim 在 expires_at 当毫秒仍必须保留。
360            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    /// 业务作用:把首次出现的 nonce 同时写入主索引和过期索引,保证后续可对数定位与清理。
375    ///
376    /// 参数说明:
377    /// - `key`: producer 与 nonce 组成的信任边内唯一身份。
378    /// - `expires_at_ms`: 最晚仍需拒绝原报文的时间。
379    ///
380    /// 返回:无;调用方已在同一锁内确认 key 不存在且容量可用。
381    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/// 业务作用:导出单条 HTTP 信任边的低基数 replay 防护快照,区分攻击、重放与容量配置问题。
388#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
389pub struct SagaHttpReplayMetrics {
390    /// 当前仍在时间窗内的 nonce 数。
391    pub active_claims: u64,
392    /// 本 guard 的硬容量;达到后 fail-closed。
393    pub capacity: u64,
394    /// 历史首次验真并成功 claim 的请求数。
395    pub accepted_total: u64,
396    /// 历史 HMAC、时间窗或 nonce 编码认证失败数。
397    pub authentication_failed_total: u64,
398    /// 历史 exact replay 拒绝数。
399    pub replay_rejected_total: u64,
400    /// 历史容量耗尽拒绝数。
401    pub capacity_rejected_total: u64,
402    /// 历史 mutex 中毒后恢复并继续提供服务的次数。
403    pub lock_poison_recovered_total: u64,
404}
405
406impl SagaHttpReplayMetrics {
407    /// 业务作用:计算不使用浮点数的容量利用率,供固定阈值告警比较。
408    ///
409    /// 参数说明: 无。
410    ///
411    /// 返回:0..=1000 的千分比;非法零容量按 1000 返回以保持 fail-closed 可见性。
412    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/// 业务作用:限定 replay 指标允许使用的低基数平面名,禁止把 producer/path 等用户维度写入时序。
425#[derive(Debug, Clone, Copy, PartialEq, Eq)]
426pub enum SagaHttpReplayPlane {
427    /// Participant command 接收面。
428    Command,
429    /// Orchestrator result 接收面。
430    Result,
431    /// Orchestrator 管理与恢复面。
432    Management,
433}
434
435impl SagaHttpReplayPlane {
436    /// 业务作用:返回 Prometheus 指标使用的固定平面片段。
437    ///
438    /// 参数说明: 无。
439    ///
440    /// 返回:只可能是 `command`、`result` 或 `management`,不包含外部输入。
441    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/// 业务作用:聚合同一 transport 平面内互相隔离的 replay guards,同时保留最拥挤单边水位。
451#[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    /// 业务作用:合并一条独立信任边的快照,且不引入 producer/path 高基数 label。
466    ///
467    /// 参数说明:
468    /// - `metrics`: 单个 replay guard 在同一采集时钟下生成的快照。
469    ///
470    /// 返回:无;总量使用饱和加法,水位保留单边最大值而不是会掩盖热点的整体平均值。
471    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    /// 业务作用:把聚合快照渲染为稳定、无 label 的 Prometheus 文本,供告警规则直接引用。
494    ///
495    /// 参数说明:
496    /// - `plane`: 编译期封闭的 command/result/management 平面名。
497    ///
498    /// 返回:包含 guard 数、容量水位及认证/重放/容量/锁恢复分类计数的文本。
499    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
536/// 业务作用:渲染 HTTP command 确定性协议拒绝已持久化到 DLT 的低基数总量。
537///
538/// 参数说明:
539/// - `total`: 与 DLT 行同事务、按 event_id 只累计一次的 durable 历史总量。
540///
541/// 返回:固定指标名、无 label 的 Prometheus counter 文本,供轻量 connector 与告警合同共用。
542pub 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    /// 业务作用:构造有界 replay guard,限制攻击流量可占用的 nonce 记录数。
548    ///
549    /// 参数说明:
550    /// - `max_entries`: 同一进程允许保留的未过期 nonce 上限,必须大于零。
551    ///
552    /// 返回:上限合法时返回空 guard;零容量直接拒绝启动。
553    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    /// 业务作用:先校验完整 HMAC,再原子占用 producer/nonce,形成仅可消费一次的身份凭据。
569    ///
570    /// 参数说明:
571    /// - `authenticator`: 当前部署信任边绑定的 HMAC 认证器。
572    /// - `message`: 冻结 producer/path/timestamp/nonce/body/signature 的原始收据。
573    /// - `now_ms`: 接收端当前 Unix 毫秒。
574    ///
575    /// 返回:签名有效且 nonce 首次出现时成功;重放、容量耗尽或认证失败均脱敏拒绝。
576    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        // claim 必须保留到该签名时间窗的闭区间右端;若在等于右端时提前清理,原报文仍能通过
602        // `abs_diff <= skew` 并获得第二次执行机会。未来偏移到窗口上沿的签名最多占用约 2×skew。
603        claims.insert(key, expires_at_ms);
604        saturating_increment(&self.accepted_total);
605        Ok(())
606    }
607
608    /// 业务作用:读取并顺带清理一条信任边的 replay 防护指标,供控制面区分攻击与容量不足。
609    ///
610    /// 参数说明:
611    /// - `now_ms`: 指标采集时钟,用于剔除已经越过 replay horizon 的 active claim。
612    ///
613    /// 返回:不含 producer、nonce、path 或 payload 的低基数计数快照。
614    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    /// 业务作用:取得 claims 锁并在中毒时恢复受保护索引,避免一次 panic 永久关闭认证面。
629    ///
630    /// 参数说明: 无。
631    ///
632    /// 返回:可继续完成原子 claim/清理的锁 guard;发生过中毒时同步增加可观测计数。
633    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                // 本临界区只执行不可失败的内存索引更新;恢复 guard 后清除 poison,避免同一故障
639                // 被后续每个认证请求重复计数并持续污染运维判断。
640                self.claims.clear_poison();
641                poisoned.into_inner()
642            }
643        }
644    }
645}
646
647/// 业务作用:表示 Saga HTTP 收据拒绝的内部稳定分类,外部响应仍必须统一脱敏。
648#[derive(Debug, Clone, Copy, PartialEq, Eq)]
649pub enum SagaHttpMessageAuthFailure {
650    /// key、容量或时钟窗口配置非法,listener 不得 Ready。
651    ConfigurationInvalid,
652    /// HMAC、时间窗或 nonce 编码不匹配。
653    AuthenticationFailed,
654    /// producer/nonce 已在当前 replay horizon 内消费。
655    ReplayDetected,
656    /// 当前信任边的未过期 nonce 已达到硬容量。
657    CapacityExhausted,
658}
659
660/// 业务作用:携带可观测但不含凭据的内部认证失败分类,同时保持统一对外错误文本。
661#[derive(Debug, Clone, Copy, PartialEq, Eq)]
662pub struct SagaHttpMessageAuthError {
663    failure: SagaHttpMessageAuthFailure,
664}
665
666impl SagaHttpMessageAuthError {
667    /// 业务作用:读取安全的内部失败分类,驱动日志、指标和容量告警,不用于改变外部错误正文。
668    ///
669    /// 参数说明: 无。
670    ///
671    /// 返回:配置、认证、重放或容量耗尽中的稳定分类。
672    pub fn failure(self) -> SagaHttpMessageAuthFailure {
673        self.failure
674    }
675
676    /// 业务作用:构造启动配置拒绝。
677    ///
678    /// 参数说明: 无。
679    ///
680    /// 返回:不包含敏感配置值的 `ConfigurationInvalid` 错误。
681    fn configuration() -> Self {
682        Self {
683            failure: SagaHttpMessageAuthFailure::ConfigurationInvalid,
684        }
685    }
686
687    /// 业务作用:构造密码学或时钟认证拒绝。
688    ///
689    /// 参数说明: 无。
690    ///
691    /// 返回:不暴露具体失败字段的 `AuthenticationFailed` 错误。
692    fn authentication() -> Self {
693        Self {
694            failure: SagaHttpMessageAuthFailure::AuthenticationFailed,
695        }
696    }
697
698    /// 业务作用:构造 exact replay 拒绝。
699    ///
700    /// 参数说明: 无。
701    ///
702    /// 返回:可供内部指标分类的 `ReplayDetected` 错误。
703    fn replay() -> Self {
704        Self {
705            failure: SagaHttpMessageAuthFailure::ReplayDetected,
706        }
707    }
708
709    /// 业务作用:构造单信任边容量耗尽拒绝。
710    ///
711    /// 参数说明: 无。
712    ///
713    /// 返回:可供内部容量告警分类的 `CapacityExhausted` 错误。
714    fn capacity() -> Self {
715        Self {
716            failure: SagaHttpMessageAuthFailure::CapacityExhausted,
717        }
718    }
719}
720
721impl std::fmt::Display for SagaHttpMessageAuthError {
722    /// 业务作用:输出稳定的认证拒绝文案。
723    ///
724    /// 参数说明:
725    /// - `formatter`: 标准格式化目标。
726    ///
727    /// 返回:写入成功返回 `Ok`,格式化失败返回底层错误。
728    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
735/// 业务作用:以饱和语义增加认证计数,避免极端长生命周期进程发生 u64 回绕。
736///
737/// 参数说明:
738/// - `counter`: 待增加的低基数原子计数器。
739///
740/// 返回:无;并发竞争时由原子更新重试,不获取 claims 锁。
741fn saturating_increment(counter: &AtomicU64) {
742    let _ = counter.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
743        Some(current.saturating_add(1))
744    });
745}
746
747/// 业务作用:按固定域分隔顺序把身份、路径、时间、nonce 和 body 纳入 HMAC,防止字段拼接歧义。
748///
749/// 参数说明:
750/// - `mac`: 已由当前信任边 key 初始化的 HMAC。
751/// - `producer`: key 绑定的逻辑 producer。
752/// - `path`: 固定接收路径。
753/// - `timestamp_ms`: 发送时间。
754/// - `nonce`: 本次投递的一次性随机数。
755/// - `body`: 原始消息字节。
756///
757/// 返回:无;只更新本地 HMAC 状态,不记录或外发敏感内容。
758fn 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
778/// 业务作用:严格校验 nonce 的固定长度与小写十六进制编码,消除多种文本表示带来的重放歧义。
779///
780/// 参数说明:
781/// - `nonce`: 预期为 32 字符小写十六进制文本。
782///
783/// 返回:编码唯一且恰好表示 128 位随机数时为 `true`。
784fn 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
791/// 业务作用:严格解析 256 位小写十六进制 key 或签名,拒绝宽松大小写和截断输入。
792///
793/// 参数说明:
794/// - `value`: 预期为 64 字符小写十六进制文本。
795///
796/// 返回:合法时返回 32 字节;否则返回 `None`。
797fn 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
810/// 业务作用:解析单个小写十六进制半字节,不接受大写或其它可混淆字符。
811///
812/// 参数说明:
813/// - `value`: ASCII 字节。
814///
815/// 返回:合法半字节返回 0..15;否则返回 `None`。
816fn 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
824/// 业务作用:把 HMAC 字节编码为稳定小写十六进制 header 值。
825///
826/// 参数说明:
827/// - `bytes`: HMAC-SHA-256 输出。
828///
829/// 返回:不含前缀的 64 字符小写十六进制文本。
830fn 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}