序號、鏈與承諾
回執如何構成防篡改、顯空洞的帳本——按渠道定序、哈希鏈、期間 Merkle 承諾與承諾鏈。
域分離:每個哈希都自報身分
系統中每個哈希都覆蓋「域標籤 ‖ NUL 字節 ‖ 數據」:
tagged(domain, data) = domain || 0x00 || data| 域標籤 | 產出 |
|---|---|
linkex.receipt.self.v1 | 回執的 self_hash,覆蓋規範化信封 |
linkex.receipt.sign.v1 | 被簽消息,覆蓋某哈希的原始 32 字節 |
linkex.receipt.leaf.v1 | Merkle 葉子,覆蓋回執自哈希 |
linkex.receipt.node.v1 | Merkle 內部節點,覆蓋兩個子哈希 |
linkex.commitment.v1 | 期間承諾的哈希,覆蓋規範化承諾 |
linkex.receipt.sample.v1 | 抽樣 HMAC 輸入,覆蓋大端序號 |
標籤取可讀的 ASCII 字串而非單字節前綴——「0x00 表示葉子」需要查且可能查錯,而標籤自明。NUL 分隔符在此無歧義:數據側總是規範化 JSON 或定長字節,而規範化 JSON 中絕不出現裸 NUL。
重新實現時值得知道的一處細節:承諾有自己的哈希域(它是另一類對象),卻沿用回執的簽名域——因為到達簽名的只是 32 字節,哈希域已使其無歧義。
序號:讓缺漏可見
回執由單一 sequencer **按渠道(流)**定序:
- 計費熱路徑只入隊事件——與扣費在同一個數據庫事務內提交(見實例保證);sequencer 異步取號簽發。
- 數據庫租約保證同一時刻每條流只有一個活躍 sequencer;
(channel_id, seq)唯一約束與來源事件唯一索引防止雙重定序。 - 因為失敗也定序,
seq上的空洞永遠是缺事件的證據——絕不是「我們只給成功編號」的產物。
序號運算直接在十進制字串上進行,不解析為定寬整數:seq 在線格式上無上界,而一個在 2⁶³ 處靜默回繞的比較,會在「其全部價值恰在於沒有空洞」的那項檢查上開一個空洞。
哈希鏈:讓改動可見
self_hash = "sha256:" + hex( SHA256( tagged("linkex.receipt.self.v1", canonical(envelope)) ) )
signature = base64( Ed25519_Sign( key, tagged("linkex.receipt.sign.v1", raw32(self_hash)) ) )每張回執的 prev_hash 指向同流上一張的 self_hash——且 prev_hash 位於信封之內,鏈接因此在被簽字節之內而非附掛在旁。改動任何一筆歷史回執,鏈在那一環斷開——對所有取過其下游任何內容的人都可見。
驗證一張回執是兩個互不蘊含的檢查:self_hash 須與信封相符(簽名有效但哈希過期,意味著信封在簽名後被改過);簽名須在信封指名的密鑰下對 self_hash 驗證通過。
鏈的連續性證明完整性,不證明真實性。 一條由毫不相干的密鑰端到端偽造的鏈能滿足全部連續性規則——自哈希算得上、鏈接對得上、序號連得上。所以每張回執還必須對照公開密鑰歷史逐條驗簽:
公開密鑰歷史
密鑰按流公布,帶半開序號區間 [from_seq, until_seq)——linkex.ai 的實時歷史在 /api/receipt/keys。按序號而非牆鐘時間限定,正是使輪換可驗證的關鍵:僅持一張回執的驗證方,只憑序號即可判定該用哪把密鑰,不引用任何時鐘——包括簽發方的。
一份公布的歷史必須滿足四條規則,各堵一種攻擊:
key_id必須是所附公鑰的指紋——否則歷史可以指名一把密鑰、公布另一把。- 流內區間不得重疊——兩把密鑰認領同一序位,偽造回執就能挑攻擊者手裡那把驗過。
- 流內區間不得留洞——解析不到密鑰的回執會無辜驗證失敗,而那看起來與篡改一模一樣。
- 開放區間必須是該流最後一條。
由真密鑰簽署、但落在該密鑰公布區間之外的回執一律拒絕——沒有區間檢查,一把洩露的退役密鑰可以無限鑄造回執,且每一張都驗得過。
期間承諾:封住一個窗口
按固定節奏,一條流在一個時間窗口內的全部回執聚合成 Merkle 樹,產出一份簽名的 PeriodCommitment。一份真實的——來自 /api/receipt/commitments:
{
"commitment": {
"spec": "linkex.period-commitment",
"spec_version": "1",
"issuer": "linkex.ai",
"stream": "ch:10",
"from_ms": "1788570074617",
"until_ms": "1788570434593",
"first_seq": "1",
"last_seq": "5",
"count": "5",
"root": "sha256:4c165425c1201c4f89f52be6df0738a8b7aef75d9c22803c628a412370b5da90",
"prev_commitment_hash": "sha256:0000…0000",
"key_id": "ed25519:3d1aa5da8d32…"
},
"hash": "sha256:6572a1f3124a…",
"signature": "ihHjOrnLjfQ9…"
}| 字段 | 為什麼在這裡 |
|---|---|
root | 窗口內回執按序號升序的 Merkle 根 |
first_seq / last_seq / count | 把窗口的範圍與其中任何空洞固定在承諾自身之內。 count 必須等於 last_seq − first_seq + 1;僅有根只能證明「屬於某個集合」,聲明的區間與計數才固定該集合的大小與位置——空洞於是有明確的位置,無法靠整期重發抹平 |
prev_commitment_hash | 承諾自身成鏈 |
key_id + 簽名 | 承諾本身也被簽名,密鑰出自同一份公開歷史 |
承諾的哈希覆蓋其完整規範化形式(linkex.commitment.v1 域);hash 與 signature 隨對象傳輸、不屬於被哈希的字節。構造承諾前先驗證整段鏈——對已斷裂的回執段作出承諾,等於簽發一份蓋了時間戳的「斷裂無妨」聲明。
要緊的 Merkle 構造細節
leaf = SHA256( tagged("linkex.receipt.leaf.v1", raw32(self_hash)) )
node = SHA256( tagged("linkex.receipt.node.v1", raw32(left) || raw32(right)) )- 葉子與內部節點用不同域標籤——二次原像防禦:不分隔的話,內部節點的兩個子節點可以被冒充為一個葉子。
- 節點哈希不可交換,且不能可交換——若哈希前先排序,位置 i 的證明在鏡像位置也能驗過。
- 奇數節點晉升,絕不自我複製。 複製即 CVE-2012-2459 的形狀:n 個葉子的樹與「末葉重複一次」的 n + 1 個葉子的樹根相同,兩個不同的聲明計數將共用一個承諾。一致性向量把三葉樹的兩個根都公布——正確的與錯誤的——因為複製式實現產出的根單看完全合理。
- 包含性證明記錄每一步兄弟節點在哪一側;聲明的路徑長度在任何哈希之前先行檢查——既限住惡意證明能施加的工作量,也把缺陷點名到位(「應為 3 步、實為 5 步」,而非只說「根不符」)。
包含性對照的是由信封重算的自哈希,絕不用存儲值——若信封被改而存儲的 self_hash 未動,那個存儲值確實在樹裡,包含性會在一條被篡改的回執旁報 PASS。重算堵住這種讀法;鏈檢查另行報告存儲哈希不符。
承諾鏈
同流相鄰承諾之間有兩個條件,且各抓不同的攻擊:
| 檢查 | 抓住 |
|---|---|
prev_commitment_hash 等於上一份承諾的哈希 | 某期被覆蓋同一區間的另一期替換 |
first_seq 等於上一份的 last_seq + 1 | 某期被丟棄且鏈被重建、鏈接依然對得上 |
驗證器分別報告二者——兩者同時變紅的報告說不出發生了哪一種。
承諾帳本公開且以規範化形式發布——正是被哈希的那個對象,十進制字串照舊——重算承諾哈希無需任何類型轉換:/api/receipt/commitments。
完備性三角
三個性質互相咬合(缺任何一個,論證即塌):
- 序號連續 + 失敗也簽 ⇒ 任何刪除或缺漏都留下可見空洞;
- 承諾記錄區間與計數 ⇒ 空洞被固定在已簽名的承諾之內——無法靠整期重發抹掉;
- 承諾被第三方固定在時間上 ⇒ 重發的期間與原件可區分。
第三條腿就是時間錨定。