FR-062 · License 運作原理 · 2026-09-06

License 是怎麼運作的

從「一張照怎麼被簽出來」到「客戶機器上怎麼被驗開」,把整條鏈的原理講清楚。這份文件回答的是 Why——為什麼是簽章不是加密、canonical JSON 為什麼非有不可、「先解壓再驗簽」的順序藏著什麼含意。規格條列請看 design.html#sign-verify(§4.2),本文是它的展開版。

數位簽章,不是加密 Ed25519 · 私鑰在簽發站 · 公鑰編譯進產品 含 CM-1572 壓縮炸彈的成因說明

變更紀錄

日期 變更 原因
2026-09-06 新建。從 design.md §4.2(7 個 bullet 的規格條列)展開為原理說明,補齊簽章 vs 加密的觀念澄清、canonical JSON 的存在理由、驗證順序與 CM-1572 的因果關係、機器指紋、兩種開通路徑對照、機制邊界的誠實說明 決策者提問「License 到底怎麼產生、怎麼加密驗證」,發現既有文件只有規格沒有原理,答不了

1. 一句話總覽

公司在內部簽發站用私鑰對一段授權內容(誰的照、什麼方案、幾個人、到什麼時候)簽名, 把「內容 + 簽名」包成一個檔交給客戶;客戶的系統裡編譯著對應的公鑰,收到檔就用公鑰驗一次簽名—— 驗得過,代表這份內容確實出自我們、而且一個字都沒被改過。

公司這邊:內容 ──私鑰簽名──▶ 照檔(內容+簽名)──交付──▶ 客戶
客戶那邊:照檔 ──公鑰驗簽──▶ 通過 → 開通;不過 → 拒絕

🔴 最重要的觀念:我們用的是數位簽章,不是加密

這個區別是理解整套設計的鑰匙,所以放在最前面。

照的內容,客戶看得到是沒關係的。

授權內容(租戶代碼、方案、人數上限、到期日)本來就是客戶花錢買的東西——他當然知道自己買了什麼。 我們要防的不是「被看見」,而是「被改掉」:把到期日從 2026 改成 2099、把 10 人改成 1000 人、 把沒買的模組加進清單。

加密解決的是「不讓人看」,簽章解決的是「改了就驗不過」。我們要的是後者。

程式碼裡對這件事講得很直接(license_center/src/license_center/core/crypto/engine.py 檔頭):

編碼打包防客戶隨手翻閱,不是加密(Host 版客戶持有整包程式碼,解碼邏輯終究可逆向,此設計本就不承諾密碼學保密)

這句話點出的是落地版的現實:客戶機房裡跑的是整包我們給的程式碼,解碼邏輯就在裡面, 再怎麼混淆終究拆得開。所以我們從一開始就沒有把「保密」當成承諾—— 照裡有一層 zlib 壓縮 + base64,效果是「隨手用文字編輯器打開看不到明文」, 擋的是好奇心不是攻擊者。真正的保證只有一條:竄改必然被抓到。


2. 簽章的原理:一把私鑰、一把公鑰

我們用的演算法是 Ed25519(橢圓曲線數位簽章),它有兩把成對的鑰匙:

私鑰(Private Key)

只存在於公司內部的簽發站(license_center repo)。 以 passphrase 加密成 PEM 檔存磁碟、權限 0600、絕不進版控。 能力:能簽。

公鑰(Public Key)

編譯進產品程式(jedi_license_runtime/common/public_keys.py 的 PUBLIC_KEYS)。 不放設定檔、不放 DB——放外面就能被替換。公開出去也無妨。 能力:只能驗,不能簽。

這個不對稱是整套機制的支點:

  • 客戶偽造不出照,因為簽名需要私鑰,而私鑰不在他手上、也不在產品裡
  • 就算客戶破解了管理員帳號也沒用——產品裡根本沒有簽發程式碼,只有「收照櫃台」
  • 就算公鑰被人拿去也沒差,公鑰只能拿來驗,驗不出新照

為什麼公鑰要編譯進去,不放設定檔

如果公鑰放在 .env 或 DB,客戶只要自己產一對鑰匙、把公鑰換成自己的, 就能拿自己簽的假照騙過驗證。編譯進執行檔不是絕對安全(FR-063 的 Nuitka 編譯是為了讓這件事更難), 但把攻擊成本從「改一行設定」拉高到「反編譯改二進位」,這個差距在實務上很有意義。

kid:換鑰的支點

照的信封上寫著一個 kid(key id),值是公鑰的 SHA-256 摘要前 16 個 hex 字元。 它的作用是告訴驗證端「這張照是用哪把鑰匙簽的」。

驗證端手上的 PUBLIC_KEYS 是一份清單不是一把鑰匙,收到照先看 kid 去清單裡查對應公鑰, 查不到就直接判無效。這樣設計是為了換鑰時不會一次弄死所有客戶: 新鑰上線後,舊照帶著舊 kid 仍然驗得過(清單裡還留著舊公鑰),新照用新 kid,兩者並存, 等舊照全部到期換發完畢,才把舊公鑰從清單移除。


3. 產生端:一張照是怎麼被簽出來的

%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#EEF2F3','labelBoxBorderColor':'#C7D1D2','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF'}}}%%
flowchart TD
  A["授權內容 dict<br/>租戶/方案/人數/模組/到期日/機器指紋…"] --> B["canonical JSON bytes<br/>key 排序、無多餘空白"]
  B --> C["私鑰 Ed25519 簽名"]
  C --> D["signature(base64)"]
  B --> E["zlib 壓縮 → base64<br/>= payload"]
  D --> F["v2 信封<br/>{format_version, kid, payload, signature, alg}"]
  E --> F
  F --> G["整包 JSON → base64 → PEM 風格文字塊<br/>-----BEGIN GUIDANT LICENSE-----"]
  G --> H["xxx.license 檔<br/>交付客戶"]
圖 1 — 簽發管線(License Center 端)

每一步的為什麼:

① canonical JSON:因為簽章簽的是 bytes,不是「意義」

Ed25519 簽的對象是一串位元組。同一個 dict 用不同方式序列化會產生不同的 bytes—— key 順序不同、有沒有空白、縮排幾格,出來就是不同的字串,驗簽自然對不上。

所以簽發端與驗證端必須對「怎麼把 dict 變成 bytes」有逐位元組一致的約定。 兩邊各自實作(兩個 repo 刻意零依賴),但用的是同一條規則:

json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")

sort_keys=True 固定欄位順序、separators=(",", ":") 拿掉所有多餘空白。 這就是 canonical(正規化)的意思——同一份內容永遠只有一種 bytes 表示法。 少了這一步,照可能今天簽得出明天驗不過,而且錯得莫名其妙。

② 簽的是「解壓後的原文」,不是壓縮後的字串

信封裡的 payload 欄位是壓縮打包過的,但簽章對象始終是解壓後的 canonical bytes。

這個選擇讓打包層與簽章層徹底解耦:哪天我們想換掉 zlib、改用別的壓縮法、甚至不壓了, 既有已出貨的照仍然驗得過(因為簽的內容沒變)。反過來如果簽的是壓縮後的字串, 換一個壓縮實作、甚至同一個 zlib 換個版本改了預設參數,全世界的照當場失效。

③ v2 信封:把該分開的東西分開

{
  "format_version": 2,
  "kid": "a1b2c3d4e5f60718",
  "payload":   "eJxLy8xLVUjOzytJLS4pVgQAKC4E...",
  "signature": "MEUCIQDx3v...",
  "alg": "Ed25519"
}

kid 與 signature 在信封外層是刻意的——驗證端必須在解開 payload 之前就知道要用哪把鑰匙。 format_version 則是版本演進的閥門:驗證端看到 2 走 unwrap 路徑, 看不到這個鍵就當 v1 明文舊照走舊路徑(FR-062 開發期的測試照都是 v1,向下相容是刻意保留的)。

④ PEM 外皮(v3):為了能安全地貼進 email

最外層再包一次,變成這樣的文字塊:

-----BEGIN GUIDANT LICENSE-----
eyJmb3JtYXRfdmVyc2lvbiI6Miwia2lkIjoiYTFiMmMzZDRlNWY2MDcxOCIsInBh
eWxvYWQiOiJlSnhMeTh4TFZVak96eXRKTFM0cFZnUUFLQzRFLi4uIn0K
-----END GUIDANT LICENSE-----

純外皮層,內層信封與簽章邏輯完全不動——armor_license 只是「整包 JSON → base64 → 加頭尾包行」。 存在的理由是交付現實:照要透過 email 寄給客戶,而 email 會做各種事—— 把 \n 換成 \r\n、在行尾加空白、轉寄時插入引用空行。 裸 JSON 經過這些處理就壞了,PEM 風格的文字塊則天生耐得住(PGP 用了三十年就是這個原因)。 解包端 dearmor_license 逐行 strip 後才 join 解碼,正是為了容忍這些污染。


4. 客戶端:一張照是怎麼被驗開的

%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#EEF2F3','labelBoxBorderColor':'#C7D1D2','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF'}}}%%
flowchart TD
  A[".license 檔"] --> B["拆 PEM 外皮 dearmor"]
  B --> C["base64 解碼 payload"]
  C --> D["zlib 解壓 → canonical bytes"]
  D --> E["json.loads → payload dict"]
  E --> F{"看信封 kid<br/>查 PUBLIC_KEYS"}
  F -->|查無| X1["拒絕:未知的 kid"]
  F -->|查到公鑰| G{"公鑰驗 signature<br/>對 canonical bytes"}
  G -->|不合| X2["拒絕:簽章驗證失敗(可能遭竄改)"]
  G -->|通過| H{"issued_at 晚於現在?"}
  H -->|是| X3["拒絕:時間異常"]
  H -->|否| I{"host 版:<br/>機器指紋相符?"}
  I -->|不符| X4["拒絕:綁定他機/尚未開通"]
  I -->|相符或 SaaS| J["寫入 DB → 開通完成"]
圖 2 — 驗證管線(產品端)

🔴 「先解壓再驗簽」的順序不能反,而這正是 CM-1572 的來源

為什麼順序是固定的

簽章簽的是解壓後的 canonical bytes(見上一節 ②)。所以驗證端要驗簽, 就必須先把 payload 解壓出來,才有東西可驗。

順序是被簽章對象的選擇決定的,沒有調換空間。

這個順序的代價

「先解壓」意味著:在確認這份檔案是不是真的之前,系統就已經做了一件耗資源的事。

zlib 有一個放大特性——幾 KB 的壓縮資料可以解出 1GB 的內容(俗稱壓縮炸彈 / zip bomb)。 攻擊者只要丟一個精心構造的小檔案到開通端點,系統會乖乖解壓、吃光記憶體, 而「簽章根本不對」這件事要等到解壓完才會被發現——擋得住的那道門,開在耗資源那一步的後面。

這就是 FR-076 License 鏈資安掃描抓出的 CM-1572(壓縮炸彈 DoS)。

修法不是調換順序(順序改不了),而是在解壓那一步加上限:

MAX_PAYLOAD_BYTES = 16 * 1024 * 1024        # 16 MiB 解壓上限
MAX_PACKED_BASE64_LEN = MAX_PAYLOAD_BYTES * 2  # 解碼前先看字串長度就擋掉一批

16 MiB 這個數字是有來由的:一般 license payload 只有幾百 bytes, 但同一套簽章引擎也在簽 FR-064 的完整性 manifest(全產物逐檔 hash 清單), 實測 v1.19.0b1 已達 912,242 bytes 且會隨產物成長。取實測最大值的約 18 倍, 對正常憑證是天文數字般的餘裕,對攻擊者則把「1GB 放大空間」砍成「16MB」——記憶體不再可能被無界耗盡。

而 MAX_PACKED_BASE64_LEN 是更前面的一道:base64 編碼會放大約 1.33 倍, 所以光看字串長度就能擋掉多數炸彈,連 b64decode 都不必做。這是「把便宜的檢查排在昂貴的操作之前」。

開通端點為什麼只擋登入、不擋權限

離線開通(/license/activation/upload)與線上開通(/license/activation/online) 這兩支端點任何登入用戶都能打,不要求管理員角色。這是刻意的設計,不是漏掉守門:

開通當下租戶通常還沒有照。而「誰是管理員」這件事在系統裡本身就受 license 管控—— 要求管理員權限會陷入死鎖:沒開通就沒管理員、沒管理員就不能開通。

同理,這兩支端點還必須豁免於「無照唯讀 gate」,否則同樣鎖死。

但門開著,就代表門後的東西要自己防。 CM-1572 打的正是這裡—— 一支免權限的端點接收使用者上傳的資料,並在驗真之前先解壓它。 這不是「權限設計錯了」,而是「權限放寬的地方,輸入處理要更嚴」。


5. 綁機器:讓照複製到別台機器就失效

落地版(Host)限定——SaaS 版的照不綁機器,根本不呼叫這個模組。

指紋怎麼算

/etc/machine-id            (首選:systemd 標準,Linux 開機安裝時產生)
  ↓ 沒有就退
/var/lib/dbus/machine-id   (較舊發行版的等價來源)
  ↓ 再沒有就退
uuid.getnode()             (MAC 位址推導,僅作本機測試備援)
  ↓
SHA-256 → 64 字元 hex = 機器指紋

為什麼選 machine-id:它跨重開機穩定,換掉容器、重裝程式、升級版本都不會變, 只有換機器才會變——這正是我們要的判準。相對地像 PID、IP、主機名都會隨行程或設定漂移, 拿來綁機器只會讓客戶三天兩頭打電話說「系統突然說我照無效」。

為什麼取 SHA-256 不直接用原值:不外洩原始 machine-id, 避免指紋本身變成另一種可回推硬體資訊的洩漏面。

怎麼用

照裡寫著一個 machine_fingerprint 欄位。開通與啟動時,系統算一次本機指紋跟照裡的比對—— 不符就拒絕。於是把照複製到另一台機器上,驗簽會過(內容沒被改嘛),但指紋核對過不了。

程式碼裡還特別把「指紋為空」跟「指紋不符」分成兩種錯誤,因為兩者的出路完全相反:

情況 意思 客戶該做什麼
照裡指紋是空的 新發、尚未開通的照(簽發時還不知道要綁哪台) 走開通流程(輸序號或離線開通)
照裡指紋與本機不符 這張照綁在別台機器上 找 License Center 換綁

早期兩者共用同一個錯誤碼,結果 user 實測時拿著未開通的新照, 被訊息誤導成「綁錯機」跑去申請換綁——這才拆成兩碼(CM-1165)。

已知坑:容器裡的 machine-id 可能是空的

Debian base image 的 /etc/machine-id 是空檔(不是不存在), 讀出來是空字串,於是退到第三順位的 MAC 推導——而容器 MAC 每次重建都變, 指紋跟著漂移,照就三天兩頭失效。

解法是把宿主的 machine-id 掛進容器。細節見 docs/claude/memory/feedback_container_machine_id_empty_fallback_mac_drifts.md。


6. 兩種開通方式

線上開通 離線開通
客戶做什麼 在系統裡輸入我們給的開通序號,按下開通 在系統裡抄下機器碼,寄給我們;我們簽好照回寄;客戶上傳照檔
誰跟簽發站說話 客戶的 BE 代打我們的開通伺服器(LICENSE_ACTIVATION_SERVER_URL) 沒有機器連線——人(email)就是傳輸層
適用情境 客戶機器對外網通得到 機房完全封閉、air-gapped 環境
客戶體驗 一次按鈕 兩趟往返,需要人工協調

設計要點一:兩條路最後走同一支驗證

線上開通拿回來的照,照樣走 activate_license 做完整驗章 + 指紋核對。 程式註解寫得直接:

開通伺服器已核對序號合法性,但收到照後仍走 activate_license 完整驗章+(host 版)指紋核對, 不因來源是開通伺服器就信任(設計裁示:BE 是唯一信任邊界)。

這條原則的價值在於:驗證邏輯只有一份,不會出現「這條路徑漏檢查了某一項」的縫。 而且「開通伺服器」本身也是網路那頭的東西——中間人、DNS 劫持、設定被改指向假伺服器, 都會讓「來源可信」這個假設破功。唯一穩固的信任錨是編譯在自己執行檔裡的那把公鑰。

兩條路唯一的差別只有 event_type(online_activate vs offline_activate), 記在事件時間線上,方便日後追「這張照是怎麼進來的」。

設計要點二:兩條路都要能在「還沒有照」的狀態下走通

前面說過的死鎖問題在這裡再強調一次:開通流程的所有端點 (查機器碼、上傳照、線上開通)都必須豁免於 license 執法與唯讀 gate, 否則客戶會卡在「因為沒開通所以不能開通」的迴圈裡。


7. 時鐘回撥防護:把系統時間調回去也沒用

攻擊手法:照過期了,客戶把伺服器系統時間調回一年前,讓過期照「復活」。

兩道防線一起擋:

① 未來照拒收

issued_at 晚於當下系統時間 → 直接拒絕。

擋的是反方向的把戲:把時間調到很後面去換一張「未來簽發」的照, 或拿到一張日期在未來的照想提前用。

② 浮水印只進不退

DB 裡有一張全域單例表 public.license_clock_watermark(恆一列 id=1), 記錄系統見過最晚的 issued_at。

推進用 GREATEST(max_seen_issued_at, :issued_at) 語意—— 只會往前,永遠不會倒退,重跑舊照也推不回去。

驗章時拿當下的 issued_at 跟浮水印比:早於浮水印超過 24 小時容差就判定時鐘被回撥。 留容差(CLOCK_ROLLBACK_TOLERANCE)是因為 NTP 校時、時區設定、虛擬機掛起恢復都可能造成小幅偏移, 零容差會把正常客戶誤傷成攻擊者。

浮水印的讀寫刻意放在 infra 層(clock_watermark_repo.py),驗章引擎維持純函式不碰 DB—— 這樣同一支 verify_payload 才能同時服務 license、manifest、unlock_token 三種憑證, 其中有些(例如啟動 gate 的完整性驗證)跑在 create_app() 之前、根本還沒有 DB 連線。


8. 這套機制的邊界(誠實段)

落地版客戶手上有整包程式碼,理論上可以反編譯、把驗證邏輯整段拿掉——這套機制擋不住, 而且沒有任何純軟體方案擋得住。

這不是我們做得不夠好,是這類問題的本質: 當執行環境完全在對方控制之下,任何「在對方機器上執行的檢查」原則上都可以被繞過。 DRM 產業投入的資源比我們高幾個數量級,結論也是一樣的。

所以定位要講清楚:

這套機制防得住 這套機制防不住
無意識越權(不知道自己沒買這個模組) 專業破解(反編譯、patch 二進位)
順手改一下(把到期日改個數字、多加幾個人) 有決心且有能力的內部人員
照被複製到別台機器繼續用 —
系統時間被調回去讓過期照復活 —
客戶自己簽一張假照 —

真正的防線是合約與商業關係,技術機制的角色是: ① 讓「順手改一下」這件事不可能發生(改了就驗不過,沒有灰色地帶); ② 竄改時留下證據(本地事件記錄 + 檔案指紋),成為舉證材料。

FR-063(Nuitka 編譯)與 FR-064(防竄改偵測)都是提高門檻,不是提供保證。 把攻擊成本從「五分鐘改設定」拉到「需要逆向工程能力與數天工時」, 在商業上已經足以讓絕大多數人選擇正常付費——這就是這類機制實際能兌現的價值。

design.md §4.2 的防線分層寫得很清楚,值得再引一次:

簽章防竄改 → 公鑰編譯進執行檔防替換 → 時鐘回撥偵測 → 蓄意破解(patch 產品程式碼)靠合約與稽核約束, 技術上留存竄改證據作舉證材料。破解管理員帳號也無法自行簽發——產品內沒有私鑰、沒有簽發程式碼, 只有「收照櫃台」。


9. 事實來源與延伸閱讀

內容 檔案
簽章引擎、v2 信封、PEM 外皮、canonical JSON license_center/src/license_center/core/crypto/engine.py(檔頭 docstring 資訊量最大)
金鑰管理、kid 計算、passphrase 存檔 license_center/src/license_center/core/crypto/keys.py
客戶端驗章(含解壓上限) jedi-license-runtime/jedi_license_runtime/common/engine.py、common/constant.py
機器指紋 jedi-license-runtime/jedi_license_runtime/common/machine_fingerprint.py
兩種開通端點 jedi-license-runtime/jedi_license_runtime/api/license_route.py
開通與驗證的商務流程 jedi-license-runtime/jedi_license_runtime/app/service/license_verification_service.py
時鐘浮水印 jedi-license-runtime/jedi_license_runtime/infra/clock_watermark_repo.py
規格條列(本文的上游) design.html §4.1 照檔格式 / §4.2 簽章與驗證 / §4.9 金鑰生命週期
到期管線、Plan、模組授權等其他面向 design.html §4.3–§4.10