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    /// 参数说明:`hex_key` 必须是 256 位小写十六进制 key。
29    /// 返回:合法材料对应的不可变凭据快照;非法编码返回脱敏配置错误。
30    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    /// 业务作用:准备新出站凭据并保留有界的旧入站验签窗口,候选未发布时不改变当前权威。
48    /// 参数说明:`hex_key` 是新材料,`overlap` 为本地单调时钟上的旧 key 保留期限,最长一小时。
49    /// 返回:完整候选快照;非法 key、零期限或超过八份仍有效的旧材料时拒绝候选,不提前丢弃旧窗口。
50    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    /// 业务作用:让 capability 绑定当前实际出站 key 的不可逆合同摘要,避免 Registry 凭据掩盖结果凭据错配。
78    /// 参数说明:无。
79    /// 返回:当前 HMAC 材料的域分离 SHA-256,不暴露 key 或可重放的请求签名。
80    pub fn current_result_contract(&self) -> &str {
81        &self.current_result_contract
82    }
83
84    /// 业务作用:让 Catalog 使用与入站验签相同的旧 key 截止时刻判断结果凭据是否可接受。
85    /// 参数说明:无。
86    /// 返回:当前与尚在重叠窗口内的合同摘要;过期旧 key 不进入激活证据。
87    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
100/// 业务作用:让所有入站和出站认证器从同一受信配置快照读取当前凭据。
101pub trait SagaHttpCredentialSource: Send + Sync {
102    /// 业务作用:取得一次认证操作完整使用的不可变凭据集合。
103    /// 参数说明:无。
104    /// 返回:已通过配置准备校验的快照;调用期间不能混用两代 key。
105    fn credentials(&self) -> Arc<SagaHttpCredentials>;
106}
107
108impl SagaHttpCredentialSource for SagaHttpCredentials {
109    /// 业务作用:为固定 key 使用者提供与动态认证器一致的快照读取边界。
110    /// 参数说明:无。
111    /// 返回:当前固定凭据的独立不可变快照。
112    fn credentials(&self) -> Arc<SagaHttpCredentials> {
113        Arc::new(self.clone())
114    }
115}
116
117/// 业务作用:封装接收端实际观察到的签名字段,保证验签与 replay claim 消费同一份字节。
118pub 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    /// 业务作用:冻结一条待认证 HTTP 请求的 producer、route、时钟、nonce、body 与 HMAC。
129    ///
130    /// 参数说明:
131    /// - `producer`: transport 凭据映射出的逻辑 producer。
132    /// - `path`: 实际命中的固定 path。
133    /// - `timestamp_ms`: 请求签名时间。
134    /// - `nonce`: 请求的一次性随机数。
135    /// - `body`: 收到的原始 body。
136    /// - `signature`: 请求携带的 HMAC。
137    ///
138    /// 返回:只借用原始字段的不可变收据;编码与密码学检查由认证器执行。
139    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/// 业务作用:绑定单条 transport 信任边的凭据来源与允许时钟偏差。
159///
160/// 同一实例只能代表一个已由部署配置绑定的 producer。签名覆盖 producer、HTTP path、时间戳、
161/// 一次性 nonce 和原始 body;接收方校验成功并占用 nonce 后,才能把该 producer 传给 runtime 的
162/// authenticated API。
163#[derive(Clone)]
164pub struct SagaHttpMessageAuthenticator {
165    source: Arc<dyn SagaHttpCredentialSource>,
166    max_clock_skew_ms: u64,
167}
168
169impl SagaHttpMessageAuthenticator {
170    /// 业务作用:从部署注入的 256 位十六进制 key 构造消息认证器。
171    ///
172    /// 参数说明:
173    /// - `hex_key`: 恰好 64 个小写十六进制字符;不得写入日志、代码或 Outbox。
174    /// - `max_clock_skew_ms`: 接收时间与签名时间允许的最大偏差,必须大于零。
175    ///
176    /// 返回:key 与时间窗口合法时返回认证器;否则拒绝启动。
177    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    /// 业务作用:绑定受信凭据快照来源,使既有认证器克隆在下一次请求观察原子轮换。
188    /// 参数说明:`source` 只提供已校验材料,`max_clock_skew_ms` 限制签名时钟偏差。
189    /// 返回:偏差窗口非零时返回认证器;零窗口拒绝装配。
190    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    /// 业务作用:为一条已持久化 Saga transport payload 生成防篡改身份收据。
204    ///
205    /// 参数说明:
206    /// - `producer`: 此 key 在部署信任表中绑定的逻辑 producer。
207    /// - `path`: 接收端固定 Saga path,防止签名跨端点复用。
208    /// - `timestamp_ms`: 发送时 Unix 毫秒。
209    /// - `nonce`: 每次投递新生成的 128 位小写十六进制随机数。
210    /// - `body`: 将被原样发送的 HTTP body。
211    ///
212    /// 返回:64 字符小写十六进制 HMAC-SHA-256;调用方必须与上述字段一同发送。
213    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    /// 业务作用:在解析 envelope 前校验 transport producer 收据,但不占用 replay claim。
227    ///
228    /// 多副本接收端必须在本方法成功后、进入业务处理前占用共享强一致 nonce;单进程调用方
229    /// 应使用 [`SagaHttpReplayGuard::verify_once`]。只调用本方法不能构成完整的重放防护。
230    ///
231    /// 参数说明:
232    /// - `message`: 冻结 producer/path/timestamp/nonce/body/signature 的原始收据。
233    /// - `now_ms`: 接收端当前 Unix 毫秒。
234    ///
235    /// 返回:身份、路径、时间窗和 body 全部匹配时成功;任一不符返回同一脱敏错误。
236    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    /// 业务作用:校验 transport producer 收据,并给共享 nonce 存储返回由签名时间窗决定的保留边界。
245    ///
246    /// 多副本接收端必须把返回值原样用于共享 claim,不能按接收时刻另算较短期限;否则未来偏移
247    /// 但仍合法的签名会在认证窗口结束前重新取得同一 nonce。
248    ///
249    /// 参数说明:
250    /// - `message`: 冻结 producer/path/timestamp/nonce/body/signature 的原始收据。
251    /// - `now_ms`: 接收端当前 Unix 毫秒。
252    ///
253    /// 返回:身份、路径、时间窗和 body 全部匹配时返回签名合法窗口的闭区间右端;任一不符返回
254    /// 同一脱敏错误。
255    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        // 只读取一次凭据快照;所有仍有效的 key 都参与常数时间 MAC 比较,旧材料到期立即失去验签权威。
271        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    /// 业务作用:生成一条 transport 投递专用的不可预测 nonce,供 replay guard 建立一次性门禁。
296    ///
297    /// 参数说明: 无。
298    ///
299    /// 返回:128 位随机 UUID 的 32 字符小写十六进制表示;调用方不得跨请求复用。
300    pub fn issue_nonce() -> String {
301        Uuid::new_v4().simple().to_string()
302    }
303}
304
305/// 业务作用:在 HMAC 时间窗内原子拒绝已经验真的 nonce,防止认证请求被原样重放。
306///
307/// 本结构有意使用有界内存:过期项在每次 claim 前清理,未过期项达到上限时 fail-closed,绝不
308/// 淘汰仍可重放的证据。它保护单进程 connector;多副本生产入口必须把 nonce claim 放入共享的
309/// 强一致存储,或由网关提供等价的一次性请求语义。
310pub 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/// 业务作用:维护 nonce 主索引与过期时间索引,使请求清理成本只与本次实际过期项数量相关。
321#[derive(Default)]
322struct ReplayClaims {
323    by_nonce: BTreeMap<(ServiceIdentity, String), u64>,
324    by_expiry: BTreeMap<u64, BTreeSet<(ServiceIdentity, String)>>,
325}
326
327impl ReplayClaims {
328    /// 业务作用:清理已越过 replay horizon 的 claim,而不在每次请求扫描全部活跃 nonce。
329    ///
330    /// 参数说明:
331    /// - `now_ms`: 当前接收时钟;仅清除过期点严格小于该值的记录。
332    ///
333    /// 返回:无;两个索引保持同步,仍在时间窗内的 claim 不被淘汰。
334    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            // 验签时间窗包含 `abs_diff == skew` 的边界,因此 claim 在 expires_at 当毫秒仍必须保留。
341            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    /// 业务作用:把首次出现的 nonce 同时写入主索引和过期索引,保证后续可对数定位与清理。
356    ///
357    /// 参数说明:
358    /// - `key`: producer 与 nonce 组成的信任边内唯一身份。
359    /// - `expires_at_ms`: 最晚仍需拒绝原报文的时间。
360    ///
361    /// 返回:无;调用方已在同一锁内确认 key 不存在且容量可用。
362    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/// 业务作用:导出单条 HTTP 信任边的低基数 replay 防护快照,区分攻击、重放与容量配置问题。
369#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
370pub struct SagaHttpReplayMetrics {
371    /// 当前仍在时间窗内的 nonce 数。
372    pub active_claims: u64,
373    /// 本 guard 的硬容量;达到后 fail-closed。
374    pub capacity: u64,
375    /// 历史首次验真并成功 claim 的请求数。
376    pub accepted_total: u64,
377    /// 历史 HMAC、时间窗或 nonce 编码认证失败数。
378    pub authentication_failed_total: u64,
379    /// 历史 exact replay 拒绝数。
380    pub replay_rejected_total: u64,
381    /// 历史容量耗尽拒绝数。
382    pub capacity_rejected_total: u64,
383    /// 历史 mutex 中毒后恢复并继续提供服务的次数。
384    pub lock_poison_recovered_total: u64,
385}
386
387impl SagaHttpReplayMetrics {
388    /// 业务作用:计算不使用浮点数的容量利用率,供固定阈值告警比较。
389    ///
390    /// 参数说明: 无。
391    ///
392    /// 返回:0..=1000 的千分比;非法零容量按 1000 返回以保持 fail-closed 可见性。
393    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/// 业务作用:限定 replay 指标允许使用的低基数平面名,禁止把 producer/path 等用户维度写入时序。
406#[derive(Debug, Clone, Copy, PartialEq, Eq)]
407pub enum SagaHttpReplayPlane {
408    /// Participant command 接收面。
409    Command,
410    /// Orchestrator result 接收面。
411    Result,
412    /// Orchestrator 管理与恢复面。
413    Management,
414}
415
416impl SagaHttpReplayPlane {
417    /// 业务作用:返回 Prometheus 指标使用的固定平面片段。
418    ///
419    /// 参数说明: 无。
420    ///
421    /// 返回:只可能是 `command`、`result` 或 `management`,不包含外部输入。
422    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/// 业务作用:聚合同一 transport 平面内互相隔离的 replay guards,同时保留最拥挤单边水位。
432#[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    /// 业务作用:合并一条独立信任边的快照,且不引入 producer/path 高基数 label。
447    ///
448    /// 参数说明:
449    /// - `metrics`: 单个 replay guard 在同一采集时钟下生成的快照。
450    ///
451    /// 返回:无;总量使用饱和加法,水位保留单边最大值而不是会掩盖热点的整体平均值。
452    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    /// 业务作用:把聚合快照渲染为稳定、无 label 的 Prometheus 文本,供告警规则直接引用。
475    ///
476    /// 参数说明:
477    /// - `plane`: 编译期封闭的 command/result/management 平面名。
478    ///
479    /// 返回:包含 guard 数、容量水位及认证/重放/容量/锁恢复分类计数的文本。
480    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
517/// 业务作用:渲染 HTTP command 确定性协议拒绝已持久化到 DLT 的低基数总量。
518///
519/// 参数说明:
520/// - `total`: 与 DLT 行同事务、按 event_id 只累计一次的 durable 历史总量。
521///
522/// 返回:固定指标名、无 label 的 Prometheus counter 文本,供轻量 connector 与告警合同共用。
523pub 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    /// 业务作用:构造有界 replay guard,限制攻击流量可占用的 nonce 记录数。
529    ///
530    /// 参数说明:
531    /// - `max_entries`: 同一进程允许保留的未过期 nonce 上限,必须大于零。
532    ///
533    /// 返回:上限合法时返回空 guard;零容量直接拒绝启动。
534    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    /// 业务作用:先校验完整 HMAC,再原子占用 producer/nonce,形成仅可消费一次的身份凭据。
550    ///
551    /// 参数说明:
552    /// - `authenticator`: 当前部署信任边绑定的 HMAC 认证器。
553    /// - `message`: 冻结 producer/path/timestamp/nonce/body/signature 的原始收据。
554    /// - `now_ms`: 接收端当前 Unix 毫秒。
555    ///
556    /// 返回:签名有效且 nonce 首次出现时成功;重放、容量耗尽或认证失败均脱敏拒绝。
557    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        // claim 必须保留到该签名时间窗的闭区间右端;若在等于右端时提前清理,原报文仍能通过
583        // `abs_diff <= skew` 并获得第二次执行机会。未来偏移到窗口上沿的签名最多占用约 2×skew。
584        claims.insert(key, expires_at_ms);
585        saturating_increment(&self.accepted_total);
586        Ok(())
587    }
588
589    /// 业务作用:读取并顺带清理一条信任边的 replay 防护指标,供控制面区分攻击与容量不足。
590    ///
591    /// 参数说明:
592    /// - `now_ms`: 指标采集时钟,用于剔除已经越过 replay horizon 的 active claim。
593    ///
594    /// 返回:不含 producer、nonce、path 或 payload 的低基数计数快照。
595    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    /// 业务作用:取得 claims 锁并在中毒时恢复受保护索引,避免一次 panic 永久关闭认证面。
610    ///
611    /// 参数说明: 无。
612    ///
613    /// 返回:可继续完成原子 claim/清理的锁 guard;发生过中毒时同步增加可观测计数。
614    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                // 本临界区只执行不可失败的内存索引更新;恢复 guard 后清除 poison,避免同一故障
620                // 被后续每个认证请求重复计数并持续污染运维判断。
621                self.claims.clear_poison();
622                poisoned.into_inner()
623            }
624        }
625    }
626}
627
628/// 业务作用:表示 Saga HTTP 收据拒绝的内部稳定分类,外部响应仍必须统一脱敏。
629#[derive(Debug, Clone, Copy, PartialEq, Eq)]
630pub enum SagaHttpMessageAuthFailure {
631    /// key、容量或时钟窗口配置非法,listener 不得 Ready。
632    ConfigurationInvalid,
633    /// HMAC、时间窗或 nonce 编码不匹配。
634    AuthenticationFailed,
635    /// producer/nonce 已在当前 replay horizon 内消费。
636    ReplayDetected,
637    /// 当前信任边的未过期 nonce 已达到硬容量。
638    CapacityExhausted,
639}
640
641/// 业务作用:携带可观测但不含凭据的内部认证失败分类,同时保持统一对外错误文本。
642#[derive(Debug, Clone, Copy, PartialEq, Eq)]
643pub struct SagaHttpMessageAuthError {
644    failure: SagaHttpMessageAuthFailure,
645}
646
647impl SagaHttpMessageAuthError {
648    /// 业务作用:读取安全的内部失败分类,驱动日志、指标和容量告警,不用于改变外部错误正文。
649    ///
650    /// 参数说明: 无。
651    ///
652    /// 返回:配置、认证、重放或容量耗尽中的稳定分类。
653    pub fn failure(self) -> SagaHttpMessageAuthFailure {
654        self.failure
655    }
656
657    /// 业务作用:构造启动配置拒绝。
658    ///
659    /// 参数说明: 无。
660    ///
661    /// 返回:不包含敏感配置值的 `ConfigurationInvalid` 错误。
662    fn configuration() -> Self {
663        Self {
664            failure: SagaHttpMessageAuthFailure::ConfigurationInvalid,
665        }
666    }
667
668    /// 业务作用:构造密码学或时钟认证拒绝。
669    ///
670    /// 参数说明: 无。
671    ///
672    /// 返回:不暴露具体失败字段的 `AuthenticationFailed` 错误。
673    fn authentication() -> Self {
674        Self {
675            failure: SagaHttpMessageAuthFailure::AuthenticationFailed,
676        }
677    }
678
679    /// 业务作用:构造 exact replay 拒绝。
680    ///
681    /// 参数说明: 无。
682    ///
683    /// 返回:可供内部指标分类的 `ReplayDetected` 错误。
684    fn replay() -> Self {
685        Self {
686            failure: SagaHttpMessageAuthFailure::ReplayDetected,
687        }
688    }
689
690    /// 业务作用:构造单信任边容量耗尽拒绝。
691    ///
692    /// 参数说明: 无。
693    ///
694    /// 返回:可供内部容量告警分类的 `CapacityExhausted` 错误。
695    fn capacity() -> Self {
696        Self {
697            failure: SagaHttpMessageAuthFailure::CapacityExhausted,
698        }
699    }
700}
701
702impl std::fmt::Display for SagaHttpMessageAuthError {
703    /// 业务作用:输出稳定的认证拒绝文案。
704    ///
705    /// 参数说明:
706    /// - `formatter`: 标准格式化目标。
707    ///
708    /// 返回:写入成功返回 `Ok`,格式化失败返回底层错误。
709    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
716/// 业务作用:以饱和语义增加认证计数,避免极端长生命周期进程发生 u64 回绕。
717///
718/// 参数说明:
719/// - `counter`: 待增加的低基数原子计数器。
720///
721/// 返回:无;并发竞争时由原子更新重试,不获取 claims 锁。
722fn saturating_increment(counter: &AtomicU64) {
723    let _ = counter.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
724        Some(current.saturating_add(1))
725    });
726}
727
728/// 业务作用:按固定域分隔顺序把身份、路径、时间、nonce 和 body 纳入 HMAC,防止字段拼接歧义。
729///
730/// 参数说明:
731/// - `mac`: 已由当前信任边 key 初始化的 HMAC。
732/// - `producer`: key 绑定的逻辑 producer。
733/// - `path`: 固定接收路径。
734/// - `timestamp_ms`: 发送时间。
735/// - `nonce`: 本次投递的一次性随机数。
736/// - `body`: 原始消息字节。
737///
738/// 返回:无;只更新本地 HMAC 状态,不记录或外发敏感内容。
739fn 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
759/// 业务作用:严格校验 nonce 的固定长度与小写十六进制编码,消除多种文本表示带来的重放歧义。
760///
761/// 参数说明:
762/// - `nonce`: 预期为 32 字符小写十六进制文本。
763///
764/// 返回:编码唯一且恰好表示 128 位随机数时为 `true`。
765fn 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
772/// 业务作用:严格解析 256 位小写十六进制 key 或签名,拒绝宽松大小写和截断输入。
773///
774/// 参数说明:
775/// - `value`: 预期为 64 字符小写十六进制文本。
776///
777/// 返回:合法时返回 32 字节;否则返回 `None`。
778fn 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
791/// 业务作用:解析单个小写十六进制半字节,不接受大写或其它可混淆字符。
792///
793/// 参数说明:
794/// - `value`: ASCII 字节。
795///
796/// 返回:合法半字节返回 0..15;否则返回 `None`。
797fn 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
805/// 业务作用:把 HMAC 字节编码为稳定小写十六进制 header 值。
806///
807/// 参数说明:
808/// - `bytes`: HMAC-SHA-256 输出。
809///
810/// 返回:不含前缀的 64 字符小写十六进制文本。
811fn 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}