iT邦幫忙

2026 iThome 鐵人賽

DAY 7
0
AI Engineering

邊做邊補:用一個 AI 代理 mesh 專案,補齊 AI 工程師該有的能力系列 第 9

Day 09|工具與協定|做:兩種語言要簽同一份 receipt,我才發現「用現成的序列化」是協定裡最危險的一句話

  • 分享至 

  • xImage
  •  

系列:「邊做邊補:用一個 AI 代理 mesh 專案,補齊 AI 工程師該有的能力」— 第 9 天
紀錄日期:2026-09-20
能力區:第 5 區 工具與協定(兼第 9 區 評估)| 類型:做

它是什麼

教材談「協定」時,多半在講 MCP 或 A2A 的訊息長什麼樣:有哪些欄位、誰發給誰、狀態怎麼轉。hello-agents 第 10 章和 Extra05 都是這個層次;12-factor 的第 4 條「工具就是結構化輸出」也是。它們很少往下多講一層:同一個訊息在兩種語言裡,bytes 一樣嗎?

平常不用管。JSON 解出來的物件一樣就好,誰在乎 key 的順序。但只要協定裡出現「簽章」這個字,這一層就從可以不管變成整個系統的地基——因為簽章是對 bytes 簽的,不是對「物件」簽的。兩邊 bytes 差一位,簽章就對不上;更糟的是它會在測試裡看起來都對,直到 Rust 簽的東西第一次交給 TypeScript 驗。

這篇記的是我今天把一份加密協定的第一層寫成程式時,在這裡踩到、然後用什麼方法擋住的事。

專案裡哪件事逼我用到

我的 mesh 把一個任務丟給好幾台裝置上的 AI CLI 跑,每台跑完交回一份 receipt——executable 的雜湊、pid、nonce 有沒有被回聲、輸出雜湊。上一篇(Day 08)講的是這份 receipt 的加密協定被三個 AI 審了十幾輪;今天協定凍結,開始寫。

第一層是 L4:每台裝置對自己的 receipt 用 Ed25519 簽名,prev_receipt_sha256 串成一條鏈,能從錨點往回驗。

問題在「每台裝置」。CLI 型的 worker 是 Rust daemon;手機是 Tauri App,跑 TypeScript。同一份 receipt 可能由 Rust 簽、TypeScript 驗,或反過來。所以 Rust 和 TS 對同一個 JSON 物件算出的 canonical bytes 必須逐 byte 相同

協定寫的是 RFC 8949 §4.2.1 的 deterministic CBOR。§7 的構件清單原本寫「Rust 用 ciborium、TS 用 cbor-x」——「用現成的」。

做之前我以為

我以為序列化是解決過的問題。挑一個有名的套件、開 canonical 模式、兩邊各呼叫一次,就完了。協定審了十六輪,沒有一個審查者對這一行提過意見,我自己也沒有。

做完的證據

第一件:兩個「現成的」都不是 RFC 8949 的 deterministic

RFC 8949 §4.2.1 對 map 的要求是:key 按編碼後的 bytes 排序。文字字串的 CBOR 編碼是「一個帶長度的標頭位元組,接內容」——所以排序實際上是先比長度,再比內容

ciboriumBTreeMap<String, _> 是按字串排序。兩個 key:"seq""at_unix"。字串排序 "at_unix" 在前(a < s);RFC 排序 "seq" 在前(3 bytes < 7 bytes)。

一個 receipt 有十幾個 key,這種對子不只一組。

cbor-x 有 canonical 選項,但它的 canonical 不是 RFC 8949 core deterministic(它的整數和浮點處理有自己的規則)。

兩個套件各自「canonical」,兩邊的 canonical 不是同一個東西。

第二件:六十行自己寫,兩邊各一份

支援的值域只有 receipt 需要的:null、bool、整數、字串、陣列、字串 key 的 map。浮點數直接拒絕——一個要被簽的結構裡出現浮點數是 bug,不是要四捨五入的東西。雜湊、簽章、公鑰全用小寫 hex 文字,不用 CBOR byte string,這樣 JSON 形式跟被簽形式帶的資訊一模一樣。

Rust:

Value::Object(map) => {
    // RFC 8949 §4.2.1: keys sorted by the bytewise order of their
    // deterministic encodings (length-first for text keys).
    let mut entries: Vec<(Vec<u8>, &Value)> = ...;
    for (k, val) in map {
        let mut kb = Vec::new();
        head(3, k.len() as u64, &mut kb);   // 標頭 + 長度
        kb.extend_from_slice(k.as_bytes());
        entries.push((kb, val));
    }
    entries.sort_by(|a, b| a.0.cmp(&b.0));
    ...
}

TS 同一段邏輯,compareBytesUint8Array。整數 ≥ 2³² 在 TS 要用除法切高低位,不能用位移(位移會截成 32 位)——這種東西就是「兩邊各寫一份」會各自踩到的坑,也是共用向量存在的理由。

第三件:共用測試向量,兩邊讀同一個檔

tests/vectors/canonical-cbor.json,十八條:RFC 8949 附錄 A 的標準案例、長度優先排序的反例、一個 CJK 字串、一個 receipt 形狀的 map。

Rust 測試用 CARGO_MANIFEST_DIR/../tests/vectors/… 讀;TS 測試用 __dirname/../../../tests/vectors/… 讀。哪一邊變了,另一邊不會知道,但它自己會紅

第四件:第三個實作抓到我手算錯的兩處

向量的期望 hex 是我手算的。手算完我用 Python 寫了第三個編碼器(二十行,同樣的規則),把十八條全部重算——抓到兩處錯

fix cjk text        66e694b6e68d9a -> 66e694b6e6939a     ← 「據」的 UTF-8 我寫錯一個 byte
fix receipt-shaped  a76176016373657107676761746577617 96 -> a761760163736571076767...(完整)
                                                            ← 我根本沒算完就放了半截

要是沒有第三個實作,Rust 和 TS 會一起對著錯的答案變綠。兩個實作互相對不算對過,因為兩個都是我寫的,帶著同一顆腦袋的誤解。第三個實作用另一種語言、另一個時間寫,才有機會不同。這跟第 9 區評估的道理是同一個:評估者不能跟被評者共用同一個盲點。

第五件:測試結果

測試 結果
Rust mesh_crypto::canonical RFC 附錄 A 標量、長度優先排序、插入順序無關、拒絕浮點、十八條共用向量 5/5
Rust mesh_crypto::receipt_chain 簽章覆蓋 prev/seq、鏈存取、T-CRYPTO-005 篡改一位、T-CRYPTO-015 錨點規則、缺頁與輪替誠實停下 5/5
TS canonicalCbor 十八條共用向量、插入順序、拒絕非整數、2³² 以上的 8-byte 標頭、sha256 22/22
TS meshExecutor 既有 37 條 + 非 Tauri 環境 receipt 老實標 device_sig: null 38/38
tsc --noEmit、Tauri crate cargo check 乾淨

第六件:鑰匙不進 JavaScript

手機端 receipt 由 TS 組、但不由 TS 簽。webview 透過一個 Tauri 命令把 receipt 交給 Rust,Rust 走跟 CLI daemon 完全相同的 seal_local 函式簽完丟回來。私鑰只存在 Rust 那一側。不在 Tauri 裡跑(純瀏覽器)時,receipt 回來是 device_sig: nullsign_error: "no-device-key: not running inside the app"

這也是「協定」層的決定:協定說 device 簽,程式就要讓 device 只有一種簽法。兩種簽法(Rust 一種、TS 一種)等於兩個要對齊的實作,跟上面 CBOR 的問題是同一種。

對照教材

hello-agents Extra05 講 MCP 的 JSON-RPC 訊息格式,12-factor 第 4 條講「工具就是結構化輸出」——兩者都把「結構」當終點。這篇往下一層:結構一樣不等於 bytes 一樣;有簽章的協定要把 bytes 釘死。

aie-book 第 4 章講評估時說 evaluator 要獨立於被評的系統。今天的第三個編碼器就是這件事在序列化層的版本:不是為了多一個實作,是為了多一個不共用誤解的實作。

RFC 8949 §4.2.1 本身只有一頁。值得花十分鐘讀原文,而不是讀某個套件對它的詮釋。

今天真正學到的

第一,協定裡「用現成的序列化」這一句,是整份文件裡最容易被審查者放過、也最容易在跨語言時炸掉的一句。審查者看欄位、看威脅模型、看簽章覆蓋範圍,不會去查那個套件的 canonical 是哪一種 canonical。

第二,六十行自己寫加十八條共用向量,比追兩個套件的版本便宜。這跟這個專案「開源堆疊+薄核心」的方向不衝突——薄核心的意思不是「什麼都借」,是簽章覆蓋的東西自己寫,其他都借

第三,共用向量只保證兩邊一致,不保證兩邊都對。要對,得有第三個來源;今天它抓到兩個錯。

第四,「不簽就說不簽」比「盡量簽」重要。device_sig: null 加原因,讀的人知道要補什麼;一個看起來像簽章的東西,沒人會去查。

如果你也想這樣做

  1. 協定裡任何會被簽的結構,先寫下它的 canonical 規則的出處(RFC 章節),不寫套件名。
  2. 兩種語言各寫一個最小編碼器,值域只放你真的需要的型別;不需要的(浮點、tag、byte string)直接拒絕。
  3. 共用向量放 repo 根目錄,兩邊的測試都讀它;向量的期望值用第三種語言重算一次再放進去。
  4. 簽章只有一種實作。其他語言透過 IPC 呼叫它,不重寫。

明天

第 12 區的東西:coordinator 拿到別台裝置簽的 receipt,要用什麼公鑰驗?今天只記錄了 receipt_signed 沒有驗,因為 roster 上還沒有每台裝置的簽章公鑰——那是 EpochManifest 的工作,也是這個專案信任根從一把共用對稱鑰換成每台裝置一把鑰的起點。


上一篇
Day 08|工程判斷|做:讓三個 AI 審我的協定,十二輪之後我學到的不是密碼學
下一篇
Day 10|能力地圖|做:重貼十二區,把做過的事放回正確格子
系列文
邊做邊補:用一個 AI 代理 mesh 專案,補齊 AI 工程師該有的能力10
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言