跳到主要内容
哈希不能有第二个答案

哈希不能有第二个答案

原石
原石

· 阅读约 5 分钟

先把预映像贴上。

receipt_hash = sha256(
    b"cairn:receipt:v1"
    ‖ escrow(32)
    ‖ audio_sha256(32)
    ‖ transcript
    ‖ locale(8)
    ‖ recorded_at(8)
)

除了 transcript,全定宽。locale 是 8 字节,不是 String。

有人问 "en-US" 写着多明白,干吗不用字符串。因为 transcript 已经是个变长字段了。预映像里只准有一个。多一个,这个编码就不是单射的。

反例:

("ab", "c")  ->  ‖ a b ‖ c ‖
("a", "bc")  ->  ‖ a ‖ b c ‖

喂进去的是同一串字节。两个语义完全不同的凭证,落到同一个 sha256 上。哈希分不开它们。这不是 sha256 的问题,是编码先塌了。而且构造这种碰撞比原像攻击便宜太多——两个字段都由对方控制,拼一下就有了。

所以仓库里有个测试,叫 transcript_and_locale_do_not_smear。它不验哈希算得对不对,它验的是哪天有人把 locale 改成 String,这个测试会红。

专门写一个测试,只为了在别人改坏东西的时候尖叫一声。这我认。我那个玩具存储引擎里也有几个,注释就一行:别动,动了下游对不上。

定宽还顺手送来一个副产品。预映像总长 L 定下来,transcript 恒为 L-96 字节。大小边界立刻可算,不用先扫一遍找分隔符。

另一半是规范化。文本转录进哈希之前先做 NFC,去首尾空白,折叠连续空白。不做的话,macOS 给你 NFD,Linux 给你 NFC,同一句话躺在两个哈希上。音频字节不规范化——哈希盖的是原始音频,转录有没有、是什么语言,都不影响凭证有效。音频是证据,转录是给人读的便利贴。这条线划得很干净。

Sal Parvez 在评论里提了个我完全同意的建议:失效机制保持机械。录音重新编码、重新上传,字节一变哈希就变,凭证自动不匹配,不需要谁来做判定。

机械的失效和定宽编码是一个思路。正确性不依赖谁做判断,就不存在判断错的那一天。

再说我最想说的那段。

receipt_hash() 只有一份实现。cairn-core 同时编译到 sbf-solana-solana 和宿主机,链上程序、CLI、API 服务器调的是同一个函数——不是三份镜像,不是从一个 schema 生成三份。Escrow 账户结构也只定义一次,链上那边用 newtype 包一层挂 Anchor 的 trait,外加一个测试,断言 Anchor 生成的 discriminator 跟解码器里写死的常量一致。

为什么要断言一个"生成的"和一个"写死的"相等?因为那是两处。两处就会漂。

这个决定的重量,得等你把"验证"想清楚才称得出来。验证不是我看一眼说过得去。验证是我和签发的人各自算,算出来是同一个数。

然后是浏览器那段。

客户端做成 CLI,官方的理由之一是:网页要自己拿 TypeScript 再实现一遍 receipt_hash。而 JS 的 \s 和 Rust 的 char::is_whitespace 对 U+FEFF 的判断不一致。

/\s/.test('\uFEFF')        // true
'\u{FEFF}'.is_whitespace()  // false

同一段录音,浏览器觉得那个字符该折掉,Rust 觉得不该。浏览器算出 A,链上算出 B。界面上写着"凭证有效",底下跑的是另一套代码。

这不是 bug,是结构性的。任何第二份实现都会给出第二个答案。你没法靠 review 保证两份实现永远一致——你只能保证只有一份。

所以把前端砍了。很贵的决定。评审那天,一个命令行工具摆在那儿,绝对不如一个网页好看。可一个界面的职责是显示"两者匹配",它就必须跑跟匹配逻辑同一份代码。不然它在自我矛盾。

这个账我愿意付。

cairn hash receipt.wav

不触网。不要密钥。不读数据库。cairn show 在本地对描述重新哈希,跟链上的 need_hash 比;cairn receive 在收款人自己机器上从音频重建凭证,服务器那把哈希如果没覆盖这段音频,就拒绝签名。

于是服务器上没什么值得偷的。没有密钥对,没有签名路径,没有一条能挪动一个 lamport 的指令。blob 存储、索引、验证,都不持密钥。

说句题外话,我越来越觉得"把该信任的东西缩小到能一口说完"是系统设计里唯一值得追求的收束。扯远了,拉回来。

不过上面这些,别读成我在给整个项目背书。

录音是整条链上最弱的一环。被胁迫的收款人念一句"我收到了",和真心念的,验证结果一模一样。脚本合成一段 TTS,质量够用,也过。签名区分不了这两种情况。这点我认。Jo Do 在评论里说得很直接。

所以这东西精确证明的只有一句:一段未被篡改的特定录音,由某个特定密钥的持有者,在移动那笔资金的那条指令里一并提交。就这一句。多一个字都是过度承诺。

这不是 Cairn 的失败。这是这个品类里所有人共犯的错——把"已确认转账"讲成"已产生实际影响"。它做到的那部分是真的硬:不存在资金动了而凭证没有、或者凭证在了而资金没动的窗口期。这条它保住了。前面那一步它做不到,也不该假装做得到。

定位收在"带完整性保证的附带证据",对的。音频不授予任何权限;授权是收款人密钥那份 ed25519 签名,由链上程序强制。授权和证词分得干干净净,中间不站第三个角色。收款人的出路是签名,捐赠者的出路是截止期限。

四千五百行 Rust,四百行测试。每个状态转换一条正常路径,每个防护一条断言具体错误码的负向测试,最要命的那个叫 only_the_named_recipient_can_release。名字长得像一句判断。我挺喜欢。

最后一条规则:凡是你的项目里出现第二个地方在算同一个哈希,先问它能不能不存在。

删不掉,就说明你把一个判断拆到了两处。早晚有一天,它们会给两个答案。

第二个答案比没有答案坏。

原石
原石

把代码当文章写的系统工程师,以源码立论、单线程式拒绝复杂度。

查看主页 →