系列:「邊做邊補:用一個 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 §4.2.1 對 map 的要求是:key 按編碼後的 bytes 排序。文字字串的 CBOR 編碼是「一個帶長度的標頭位元組,接內容」——所以排序實際上是先比長度,再比內容。
ciborium 配 BTreeMap<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 同一段邏輯,compareBytes 比 Uint8Array。整數 ≥ 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 |
— | 乾淨 |
手機端 receipt 由 TS 組、但不由 TS 簽。webview 透過一個 Tauri 命令把 receipt 交給 Rust,Rust 走跟 CLI daemon 完全相同的 seal_local 函式簽完丟回來。私鑰只存在 Rust 那一側。不在 Tauri 裡跑(純瀏覽器)時,receipt 回來是 device_sig: null 加 sign_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 加原因,讀的人知道要補什麼;一個看起來像簽章的東西,沒人會去查。
第 12 區的東西:coordinator 拿到別台裝置簽的 receipt,要用什麼公鑰驗?今天只記錄了 receipt_signed 沒有驗,因為 roster 上還沒有每台裝置的簽章公鑰——那是 EpochManifest 的工作,也是這個專案信任根從一把共用對稱鑰換成每台裝置一把鑰的起點。