Skip to main content

wanning_core/
anchor.rs

1//! 审计锚点(W-23):**所有者侧密钥**锚住 WAL 前缀,堵住完整性链的已知边界。
2//!
3//! W-21 的完整性链有一个诚实声明过的盲区(见 [`crate::wal`] 模块注释):最后一行
4//! 没有后继行引用它——**只改尾行内容**或**整体截尾**,链自身验不住。本模块给的
5//! 抓法:所有者用**自己的密钥**(不在任何 Wanning 进程手里)对「前 N 行内容 + 行数 +
6//! 链尾」算一个 HMAC-SHA256,锚点文件另行保管;此后任何时候再验——
7//!
8//! - 截尾 → 当前 WAL 行数 < 锚点行数,当场现形;
9//! - 改尾行(或前缀任何一行)→ 前 N 行内容哈希对不上锚点,当场现形;
10//! - 锚定后又合法追加了新行 → 前缀哈希不变,照常通过(锚的是前缀,不是整个文件)。
11//!
12//! **为什么这里用 HMAC 而 W-21 明说不用的那一个**:W-21 反对的是「密钥在写进程
13//! 手里」——写进程(agent)既能写日志又能算 MAC,防线形同虚设。锚点的密钥在
14//! **所有者**手里,写进程从头到尾接触不到;签名动作(`--anchor-sign`)是所有者拿着
15//! 密钥文件在闸外做的,闸的 MCP 工具面永不提供锚点能力(agent 能签就能伪造锚点)。
16//! 密钥保管是人的程序,和真实消费护栏(W-07)同一个信任边界。
17//!
18//! **内容哈希为什么是 SHA-256 而不是复用 FNV 链**:FNV-1a64 是确定性对账用的
19//! 非密码学哈希(64 位空间谈不上抗碰撞),对手方是「能写盘的 agent」时必须用
20//! 密码学哈希。锚点载荷里同时带上 FNV 链尾(人能和 W-22 回放页肉眼对账)与
21//! SHA-256 内容哈希(密码学强度),两者各司其职。
22//!
23//! **诚实边界(已由 v2 升级收口)**:HMAC 的验证方需要密钥——v1 锚点只有所有者
24//! (持密钥者)能验。W-31 起新增 **锚点 v2 = ed25519 非对称签名,公钥随锚点走,
25//! 第三方零密钥即可验**(独立 bin `wanning-anchor-verify`,见 demo crate 的
26//! `anchor_v2` 模块);本模块的 HMAC v1 模式保留(向后兼容,锚点文件带 version
27//! 字段)。v2 复用本模块的材料/载荷纪律与 `assert_wal_matches_anchor` 比对。
28//!
29//! 验证顺序即 fail-closed 顺序:先验锚点本身可信([`verify_anchor_file`]:MAC 对
30//! 不上 = 锚点不是所有者签的或锚点被改),再读 WAL 验完整性链,最后逐字段比对
31//! ([`assert_material_matches`])。任何一步不过都报错,绝不给出「部分通过」。
32
33use serde::{Deserialize, Serialize};
34
35use crate::error::CoreError;
36use crate::sha256::{hex, sha256};
37use crate::wal::{chain_value, WalRecord};
38
39/// 锚点文件 schema 名;版本变了就不认(fail-closed,不猜)。
40pub const ANCHOR_SCHEMA: &str = "wanning-anchor-v1";
41
42/// HMAC-SHA256 的块长(RFC 2104)。
43const HMAC_BLOCK: usize = 64;
44
45/// HMAC-SHA256(RFC 2104)。密钥任意长度:> 块长先哈希,不足块长补零。
46pub fn hmac_sha256(key: &[u8], message: &[u8]) -> [u8; 32] {
47    let mut block = [0u8; HMAC_BLOCK];
48    if key.len() > HMAC_BLOCK {
49        block[..32].copy_from_slice(&sha256(key));
50    } else {
51        block[..key.len()].copy_from_slice(key);
52    }
53    let mut inner = Vec::with_capacity(HMAC_BLOCK + message.len());
54    for byte in &block {
55        inner.push(byte ^ 0x36);
56    }
57    inner.extend_from_slice(message);
58    let inner_hash = sha256(&inner);
59
60    let mut outer = Vec::with_capacity(HMAC_BLOCK + 32);
61    for byte in &block {
62        outer.push(byte ^ 0x5c);
63    }
64    outer.extend_from_slice(&inner_hash);
65    sha256(&outer)
66}
67
68/// 锚定的材料:被锚住的 WAL 前缀的指纹。
69#[derive(Clone, Debug, PartialEq, Eq)]
70pub struct AnchorMaterial {
71    /// 被锚住的行数(锚点声明「这个 WAL 至少有过这么多行」)。
72    pub lines: u64,
73    /// 第 `lines` 行的 FNV 完整性链值(与 W-22 回放页展示的链尾肉眼可对)。
74    pub chain_tail: u64,
75    /// 前 `lines` 行 `rec` 规范 JSON(逐行各接一个换行)的 SHA-256——密码学强度
76    /// 的内容指纹,不依赖 FNV 的抗碰撞性。
77    pub records_sha256: [u8; 32],
78}
79
80/// 从逐行记录([`crate::wal::read_verified`] 的产出,或其前缀)独立重算锚定材料。
81///
82/// 链尾用与写侧完全相同的口径重算([`chain_value`]),不照抄调用方给的值——
83/// 锚点材料的每一项都是自己算的,这是「读侧独立重算」纪律的延续。
84pub fn material_from_records(records: &[(u64, WalRecord)]) -> Result<AnchorMaterial, CoreError> {
85    let mut chain = 0u64;
86    let mut content = Vec::new();
87    for (line_no, record) in records {
88        let rec_json = serde_json::to_string(record)
89            .map_err(|e| CoreError::AnchorInvalid(format!("记录序列化失败: {e}")))?;
90        chain = chain_value(chain, *line_no, &rec_json);
91        content.extend_from_slice(rec_json.as_bytes());
92        content.push(b'\n');
93    }
94    Ok(AnchorMaterial {
95        lines: records.len() as u64,
96        chain_tail: chain,
97        records_sha256: sha256(&content),
98    })
99}
100
101/// 锚点载荷的规范序列化。**手写不用 serde**:载荷是被签名的对象,它的字节必须
102/// 由我们逐字定义(serde 的键序/转义策略不属于本约定),签名与验证两端才有
103/// 同一份字节。换格式必须换 [`ANCHOR_SCHEMA`] 版本号。
104pub fn canonical_payload(material: &AnchorMaterial, anchored_at_unix: u64) -> String {
105    format!(
106        "WANNING-ANCHOR-v1\n\
107         lines={}\n\
108         chain_tail=0x{:016x}\n\
109         records_sha256={}\n\
110         anchored_at_unix={}",
111        material.lines,
112        material.chain_tail,
113        hex(&material.records_sha256),
114        anchored_at_unix
115    )
116}
117
118/// 锚点文件(落盘形态)。MAC 由所有者密钥对 [`canonical_payload`] 计算。
119#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
120#[serde(deny_unknown_fields)]
121pub struct AnchorFile {
122    pub schema: String,
123    /// 显式版本号(W-31 起加字段;缺省 = 1,序列化时省略——既有 v1 文件字节
124    /// 不漂移,旧文件(无此字段)也照常解析)。v2(ed25519)文件显式落 `"version": 2`。
125    #[serde(default = "default_anchor_version")]
126    #[serde(skip_serializing_if = "AnchorFile::version_is_implicit")]
127    pub version: u32,
128    pub lines: u64,
129    /// `0x` + 16 位十六进制(FNV 链尾)。
130    pub chain_tail_hex: String,
131    /// 64 位十六进制(前缀内容 SHA-256)。
132    pub records_sha256_hex: String,
133    pub anchored_at_unix: u64,
134    /// 64 位十六进制(HMAC-SHA256)。
135    pub mac_hex: String,
136}
137
138fn default_anchor_version() -> u32 {
139    1
140}
141
142impl AnchorFile {
143    /// version == 1 是既有 v1 格式的隐含值,序列化时省略(字节不漂移)。
144    fn version_is_implicit(version: &u32) -> bool {
145        *version == 1
146    }
147}
148
149/// 签出锚点文件。纯函数:同一材料 + 同一密钥 + 同一时刻 → 字节级同一锚点。
150pub fn sign_anchor(material: &AnchorMaterial, key: &[u8; 32], anchored_at_unix: u64) -> AnchorFile {
151    let payload = canonical_payload(material, anchored_at_unix);
152    let mac = hmac_sha256(key, payload.as_bytes());
153    AnchorFile {
154        schema: ANCHOR_SCHEMA.to_string(),
155        version: 1,
156        lines: material.lines,
157        chain_tail_hex: format!("0x{:016x}", material.chain_tail),
158        records_sha256_hex: hex(&material.records_sha256),
159        anchored_at_unix,
160        mac_hex: hex(&mac),
161    }
162}
163
164/// 验锚点文件本身可信:MAC 与所有者密钥对得上,字段读得懂。
165/// 通过则返回它声明的材料(交给 [`assert_material_matches`] 对 WAL)。
166pub fn verify_anchor_file(file: &AnchorFile, key: &[u8; 32]) -> Result<AnchorMaterial, CoreError> {
167    if file.schema != ANCHOR_SCHEMA {
168        return Err(CoreError::AnchorInvalid(format!(
169            "schema {:?} 不是 {:?}(版本不符不猜,换版要换验法)",
170            file.schema, ANCHOR_SCHEMA
171        )));
172    }
173    let records_sha256 = parse_hex_32(&file.records_sha256_hex)
174        .map_err(|e| CoreError::AnchorInvalid(format!("records_sha256_hex 读不懂: {e}")))?;
175    let chain_tail = parse_chain_tail(&file.chain_tail_hex)
176        .map_err(|e| CoreError::AnchorInvalid(format!("chain_tail_hex 读不懂: {e}")))?;
177    let material = AnchorMaterial {
178        lines: file.lines,
179        chain_tail,
180        records_sha256,
181    };
182    let payload = canonical_payload(&material, file.anchored_at_unix);
183    let claimed = parse_hex_32(&file.mac_hex)
184        .map_err(|e| CoreError::AnchorInvalid(format!("mac_hex 读不懂: {e}")))?;
185    let expected = hmac_sha256(key, payload.as_bytes());
186    if !constant_time_eq(&expected, &claimed) {
187        // 不泄露哪个字段「差一点」:MAC 不符就是整个锚点不可信。
188        return Err(CoreError::AnchorInvalid(
189            "锚点 MAC 与所有者密钥对不上——锚点不是所有者签的,或锚点文件被改过".to_string(),
190        ));
191    }
192    Ok(material)
193}
194
195/// 对比「当前 WAL」与「锚点」。**前缀锚语义**:锚住的是前 `anchored.lines` 行——
196/// 锚定后合法追加的新行不影响通过;截尾与改前缀内容都会在这里现形。
197///
198/// 前缀切片在本函数内部完成,不交给调用方:内容哈希是整段串联的 SHA-256,
199/// 「先算全量哈希再比」对追加后的 WAL 恒不成立(测试实证过这个坑)——
200/// 必须取**前 N 行**重算。调用方只需给 [`crate::wal::read_verified`] 的产出。
201pub fn assert_wal_matches_anchor(
202    records: &[(u64, WalRecord)],
203    anchored: &AnchorMaterial,
204) -> Result<(), CoreError> {
205    if (records.len() as u64) < anchored.lines {
206        return Err(CoreError::AnchorMismatch(format!(
207            "整体截尾:当前 WAL 只有 {} 行,锚点声明 {} 行——锚定之后的行不见了",
208            records.len(),
209            anchored.lines
210        )));
211    }
212    let actual = material_from_records(&records[..anchored.lines as usize])?;
213    if actual.records_sha256 != anchored.records_sha256 {
214        return Err(CoreError::AnchorMismatch(format!(
215            "前 {} 行内容与锚点不符——被锚定的部分在锚定后被改过\
216             (完整性链抓不住的尾行篡改/历史改写,锚点抓住了)",
217            anchored.lines
218        )));
219    }
220    if actual.chain_tail != anchored.chain_tail {
221        // 前缀内容哈希一致而链尾不一致,只可能是 FNV 碰撞或材料构造出错;
222        // 无论哪种都不是能放过去的事,如实报。
223        return Err(CoreError::AnchorMismatch(format!(
224            "链尾 0x{:016x} 与锚点声明的 0x{:016x} 不符(内容哈希一致而链尾不一致,\
225             属状态异常,fail-closed)",
226            actual.chain_tail, anchored.chain_tail
227        )));
228    }
229    Ok(())
230}
231
232/// 定长比较(MAC 比对用):不因首个不同字节提前返回。长度不等直接 false
233/// (长度不是秘密,MAC 恒为 32 字节)。
234fn constant_time_eq(a: &[u8; 32], b: &[u8; 32]) -> bool {
235    let mut diff = 0u8;
236    for (x, y) in a.iter().zip(b.iter()) {
237        diff |= x ^ y;
238    }
239    diff == 0
240}
241
242/// 解析 64 位十六进制为 32 字节(锚点密钥/哈希字段共用;`pub` 供 demo 的
243/// 密钥文件加载复用同一套严格校验,不另抄一份)。
244pub fn parse_hex_32(s: &str) -> Result<[u8; 32], String> {
245    let s = s.trim();
246    let bytes = parse_hex_bytes(s)?;
247    let arr: [u8; 32] = bytes
248        .try_into()
249        .map_err(|v: Vec<u8>| format!("需要 64 个十六进制字符(32 字节),实际 {} 字节", v.len()))?;
250    Ok(arr)
251}
252
253fn parse_chain_tail(s: &str) -> Result<u64, String> {
254    let s = s.trim();
255    let digits = s
256        .strip_prefix("0x")
257        .or_else(|| s.strip_prefix("0X"))
258        .ok_or_else(|| format!("缺少 0x 前缀: {s:?}"))?;
259    if digits.len() != 16 {
260        return Err(format!("需要 16 位十六进制,实际 {} 位", digits.len()));
261    }
262    u64::from_str_radix(digits, 16).map_err(|e| format!("十六进制解析失败: {e}"))
263}
264
265fn parse_hex_bytes(s: &str) -> Result<Vec<u8>, String> {
266    if !s.len().is_multiple_of(2) {
267        return Err("十六进制长度必须是偶数".to_string());
268    }
269    (0..s.len())
270        .step_by(2)
271        .map(|i| {
272            u8::from_str_radix(&s[i..i + 2], 16).map_err(|e| format!("位置 {i} 不是十六进制: {e}"))
273        })
274        .collect()
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280
281    /// RFC 4231 测试用例(HMAC-SHA256);向量另经本机 .NET `HMACSHA256`
282    /// 逐条交叉核验(W-23 取证,真实运行输出)。
283    /// 来源:<https://datatracker.ietf.org/doc/html/rfc4231>。
284    #[test]
285    fn rfc4231_test_cases() {
286        let cases: Vec<(&[u8], &[u8], &str)> = vec![
287            // TC1:key = 0x0b × 20,data = "Hi There"。
288            (
289                &[0x0b; 20],
290                b"Hi There",
291                "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7",
292            ),
293            // TC2:key = "Jefe"。
294            (
295                b"Jefe",
296                b"what do ya want for nothing?",
297                "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843",
298            ),
299            // TC3:key = 0xaa × 20,data = 0xdd × 50。
300            (
301                &[0xaa; 20],
302                &[0xdd; 50],
303                "773ea91e36800e46854db8ebd09181a72959098b3ef8c122d9635514ced565fe",
304            ),
305            // TC4:key = 0x01..0x19(25 字节),data = 0xcd × 50。
306            (
307                &[0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
308                 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19],
309                &[0xcd; 50],
310                "82558a389a443c0ea4cc819899f2083a85f0faa3e578f8077a2e3ff46729665b",
311            ),
312            // TC6:key(131 字节)超过块长 → 先哈希再 pads。
313            (
314                &[0xaa; 131],
315                b"Test Using Larger Than Block-Size Key - Hash Key First",
316                "60e431591ee0b67f0d8a26aacbf5b77f8e0bc6213728c5140546040f0ee37f54",
317            ),
318            // TC7:超块长 key + 超块长 data。
319            (
320                &[0xaa; 131],
321                b"This is a test using a larger than block-size key and a larger than block-size data. The key needs to be hashed before being used by the HMAC algorithm.",
322                "9b09ffa71b942fcb27635fbcd5b0e944bfdc63644f0713938a7f51535c3a35e2",
323            ),
324        ];
325        for (idx, (key, message, expected)) in cases.iter().enumerate() {
326            let actual = hex(&hmac_sha256(key, message));
327            assert_eq!(&actual, expected, "RFC 4231 用例 {}", idx + 1);
328        }
329    }
330
331    /// 边界:空密钥/空消息、恰好块长的密钥(向量为本机 .NET oracle 实算,W-23)。
332    #[test]
333    fn hmac_key_length_boundaries() {
334        assert_eq!(
335            hex(&hmac_sha256(b"", b"")),
336            "b613679a0814d9ec772f95d778c35fc5ff1697c493715653c6c712144292c5ad",
337            "空密钥+空消息(.NET oracle 实算)"
338        );
339        assert_eq!(
340            hex(&hmac_sha256(&[0x3a; 64], b"exact block size key")),
341            "a59ee14066ab0f880f654a760fbc54ebe0abcd27b31743e1a4e5378797470bb3",
342            "恰好 64 字节密钥(.NET oracle 实算)"
343        );
344    }
345
346    fn sample_records() -> Vec<(u64, WalRecord)> {
347        use crate::delegation::Delegation;
348        use crate::intent::SpendIntent;
349        use crate::wal::WalDecision;
350        let delegation = Delegation::new(
351            "d1",
352            "所有者",
353            "agent-1",
354            10_00,
355            1_000,
356            2_000,
357            "wanning-test",
358        );
359        vec![
360            (
361                1,
362                WalRecord::RegisterDelegation {
363                    ts: 1_500,
364                    delegation: delegation.clone(),
365                },
366            ),
367            (
368                2,
369                WalRecord::Decide {
370                    ts: 1_600,
371                    decision: WalDecision::Allow,
372                    delegation_id: "d1".into(),
373                    intent: SpendIntent::new("d1", 1, 500, "jd:shop-1", "grocery", "测试意图"),
374                    reason: None,
375                    budget_after_cents: 500,
376                },
377            ),
378        ]
379    }
380
381    #[test]
382    fn material_is_independent_recompute() {
383        // 材料重算与 WAL 写侧链尾同口径:两条记录后链尾非 0,行数/内容哈希就位。
384        let material = material_from_records(&sample_records()).expect("材料");
385        assert_eq!(material.lines, 2);
386        assert_ne!(material.chain_tail, 0);
387        // 空前缀:0 行、创世链 0、空内容哈希。
388        let empty = material_from_records(&[]).expect("空材料");
389        assert_eq!(empty.lines, 0);
390        assert_eq!(empty.chain_tail, 0);
391        assert_eq!(
392            hex(&empty.records_sha256),
393            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
394        );
395    }
396
397    #[test]
398    fn content_hash_changes_on_any_record_change() {
399        // 内容哈希是密码学强度的:改任何一个字节(比如 memo)必须变。
400        let mut tampered = sample_records();
401        if let WalRecord::Decide { intent, .. } = &mut tampered[1].1 {
402            intent.memo = "测试意图".to_string() + "改";
403        }
404        let a = material_from_records(&sample_records()).expect("材料");
405        let b = material_from_records(&tampered).expect("被改材料");
406        assert_ne!(a.records_sha256, b.records_sha256, "改内容必须改哈希");
407        assert_ne!(a.chain_tail, b.chain_tail, "链值同样变");
408    }
409
410    #[test]
411    fn payload_is_stable_and_field_complete() {
412        let material = material_from_records(&sample_records()).expect("材料");
413        let payload = canonical_payload(&material, 1_700_000_000);
414        // 逐行规范格式(签名两端共享的字节定义,写死在测试里)。
415        let expected = format!(
416            "WANNING-ANCHOR-v1\nlines=2\nchain_tail=0x{:016x}\nrecords_sha256={}\nanchored_at_unix=1700000000",
417            material.chain_tail,
418            hex(&material.records_sha256)
419        );
420        assert_eq!(payload, expected);
421        // 同输入同输出;anchored_at 变了载荷必须变(锚点的时刻属于被签内容)。
422        assert_eq!(payload, canonical_payload(&material, 1_700_000_000));
423        assert_ne!(payload, canonical_payload(&material, 1_700_000_001));
424    }
425
426    #[test]
427    fn sign_then_verify_roundtrip() {
428        let material = material_from_records(&sample_records()).expect("材料");
429        let key = [7u8; 32];
430        let file = sign_anchor(&material, &key, 1_700_000_000);
431        assert_eq!(file.schema, ANCHOR_SCHEMA);
432        let verified = verify_anchor_file(&file, &key).expect("同密钥验得过");
433        assert_eq!(verified, material);
434    }
435
436    #[test]
437    fn sign_is_deterministic() {
438        let material = material_from_records(&sample_records()).expect("材料");
439        let key = [9u8; 32];
440        let a = sign_anchor(&material, &key, 42);
441        let b = sign_anchor(&material, &key, 42);
442        assert_eq!(a, b, "同材料同密钥同时刻 → 同锚点");
443        let c = sign_anchor(&material, &[10u8; 32], 42);
444        assert_ne!(a, c, "换密钥锚点必须变");
445    }
446
447    #[test]
448    fn verify_rejects_wrong_key() {
449        let material = material_from_records(&sample_records()).expect("材料");
450        let file = sign_anchor(&material, &[1u8; 32], 42);
451        let err = verify_anchor_file(&file, &[2u8; 32]).unwrap_err();
452        assert!(
453            matches!(err, CoreError::AnchorInvalid(_)),
454            "错密钥 = 锚点不可信: {err}"
455        );
456    }
457
458    #[test]
459    fn verify_rejects_tampered_fields() {
460        let material = material_from_records(&sample_records()).expect("材料");
461        let key = [3u8; 32];
462        let file = sign_anchor(&material, &key, 42);
463
464        let mut lines = file.clone();
465        lines.lines = 3; // 行数被改 → 载荷变 → MAC 不符
466        assert!(matches!(
467            verify_anchor_file(&lines, &key),
468            Err(CoreError::AnchorInvalid(_))
469        ));
470
471        let mut anchored_at = file.clone();
472        anchored_at.anchored_at_unix = 43;
473        assert!(matches!(
474            verify_anchor_file(&anchored_at, &key),
475            Err(CoreError::AnchorInvalid(_))
476        ));
477
478        let mut schema = file.clone();
479        schema.schema = "wanning-anchor-v0".into();
480        assert!(matches!(
481            verify_anchor_file(&schema, &key),
482            Err(CoreError::AnchorInvalid(_))
483        ));
484
485        let mut mac = file.clone();
486        mac.mac_hex = "00".repeat(32);
487        assert!(matches!(
488            verify_anchor_file(&mac, &key),
489            Err(CoreError::AnchorInvalid(_))
490        ));
491    }
492
493    #[test]
494    fn match_semantics_prefix_truncation_and_tamper() {
495        let records = sample_records();
496        let anchored = material_from_records(&records).expect("锚定材料");
497
498        // 原样(或合法追加)→ 过。
499        assert!(assert_wal_matches_anchor(&records, &anchored).is_ok());
500        let mut grown = records.clone();
501        grown.push((3, records[1].1.clone()));
502        assert!(
503            assert_wal_matches_anchor(&grown, &anchored).is_ok(),
504            "锚定后追加新行,前缀锚照常通过"
505        );
506
507        // 截尾:当前只有 1 行 < 锚点 2 行 → 现形。
508        let err = assert_wal_matches_anchor(&records[..1], &anchored).unwrap_err();
509        assert!(
510            matches!(err, CoreError::AnchorMismatch(ref m) if m.contains("截尾")),
511            "截尾要点名截尾: {err}"
512        );
513
514        // 改被锚前缀的内容(哪怕行数够)→ 现形。
515        let mut tampered = grown.clone();
516        if let WalRecord::Decide { intent, .. } = &mut tampered[1].1 {
517            intent.amount_cents = 999;
518        }
519        let err = assert_wal_matches_anchor(&tampered, &anchored).unwrap_err();
520        assert!(
521            matches!(err, CoreError::AnchorMismatch(ref m) if m.contains("被改")),
522            "改前缀内容要现形: {err}"
523        );
524    }
525
526    #[test]
527    fn hex_parsing_is_strict() {
528        assert!(parse_hex_32(&"ab".repeat(32)).is_ok());
529        assert!(parse_hex_32(&"AB".repeat(32)).is_ok(), "大写也收");
530        assert!(parse_hex_32(&"ab".repeat(31)).is_err(), "长度不足拒");
531        assert!(parse_hex_32("zz").is_err(), "非十六进制拒");
532        assert_eq!(parse_chain_tail("0x0000000000000000").unwrap(), 0);
533        assert!(
534            parse_chain_tail("0000000000000000").is_err(),
535            "缺 0x 前缀拒"
536        );
537        assert!(parse_chain_tail("0x00").is_err(), "长度不对拒");
538    }
539}