FR-062 · License 運作原理 · 2026-09-06
從「一張照怎麼被簽出來」到「客戶機器上怎麼被驗開」,把整條鏈的原理講清楚。這份文件回答的是 Why——為什麼是簽章不是加密、canonical JSON 為什麼非有不可、「先解壓再驗簽」的順序藏著什麼含意。規格條列請看 design.html#sign-verify(§4.2),本文是它的展開版。
| 日期 | 變更 | 原因 |
|---|---|---|
| 2026-09-06 | 新建。從 design.md §4.2(7 個 bullet 的規格條列)展開為原理說明,補齊簽章 vs 加密的觀念澄清、canonical JSON 的存在理由、驗證順序與 CM-1572 的因果關係、機器指紋、兩種開通路徑對照、機制邊界的誠實說明 | 決策者提問「License 到底怎麼產生、怎麼加密驗證」,發現既有文件只有規格沒有原理,答不了 |
公司在內部簽發站用私鑰對一段授權內容(誰的照、什麼方案、幾個人、到什麼時候)簽名, 把「內容 + 簽名」包成一個檔交給客戶;客戶的系統裡編譯著對應的公鑰,收到檔就用公鑰驗一次簽名—— 驗得過,代表這份內容確實出自我們、而且一個字都沒被改過。
公司這邊:內容 ──私鑰簽名──▶ 照檔(內容+簽名)──交付──▶ 客戶
客戶那邊:照檔 ──公鑰驗簽──▶ 通過 → 開通;不過 → 拒絕
這個區別是理解整套設計的鑰匙,所以放在最前面。
照的內容,客戶看得到是沒關係的。
授權內容(租戶代碼、方案、人數上限、到期日)本來就是客戶花錢買的東西——他當然知道自己買了什麼。 我們要防的不是「被看見」,而是「被改掉」:把到期日從 2026 改成 2099、把 10 人改成 1000 人、 把沒買的模組加進清單。
加密解決的是「不讓人看」,簽章解決的是「改了就驗不過」。我們要的是後者。
程式碼裡對這件事講得很直接(license_center/src/license_center/core/crypto/engine.py 檔頭):
編碼打包防客戶隨手翻閱,不是加密(Host 版客戶持有整包程式碼,解碼邏輯終究可逆向,此設計本就不承諾密碼學保密)
這句話點出的是落地版的現實:客戶機房裡跑的是整包我們給的程式碼,解碼邏輯就在裡面, 再怎麼混淆終究拆得開。所以我們從一開始就沒有把「保密」當成承諾—— 照裡有一層 zlib 壓縮 + base64,效果是「隨手用文字編輯器打開看不到明文」, 擋的是好奇心不是攻擊者。真正的保證只有一條:竄改必然被抓到。
我們用的演算法是 Ed25519(橢圓曲線數位簽章),它有兩把成對的鑰匙:
只存在於公司內部的簽發站(license_center repo)。 以 passphrase 加密成 PEM 檔存磁碟、權限 0600、絕不進版控。 能力:能簽。
編譯進產品程式(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,兩者並存, 等舊照全部到期換發完畢,才把舊公鑰從清單移除。
%%{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/>交付客戶"]
每一步的為什麼:
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 換個版本改了預設參數,全世界的照當場失效。
{
"format_version": 2,
"kid": "a1b2c3d4e5f60718",
"payload": "eJxLy8xLVUjOzytJLS4pVgQAKC4E...",
"signature": "MEUCIQDx3v...",
"alg": "Ed25519"
}kid 與 signature 在信封外層是刻意的——驗證端必須在解開 payload 之前就知道要用哪把鑰匙。 format_version 則是版本演進的閥門:驗證端看到 2 走 unwrap 路徑, 看不到這個鍵就當 v1 明文舊照走舊路徑(FR-062 開發期的測試照都是 v1,向下相容是刻意保留的)。
最外層再包一次,變成這樣的文字塊:
-----BEGIN GUIDANT LICENSE-----
eyJmb3JtYXRfdmVyc2lvbiI6Miwia2lkIjoiYTFiMmMzZDRlNWY2MDcxOCIsInBh
eWxvYWQiOiJlSnhMeTh4TFZVak96eXRKTFM0cFZnUUFLQzRFLi4uIn0K
-----END GUIDANT LICENSE-----
純外皮層,內層信封與簽章邏輯完全不動——armor_license 只是「整包 JSON → base64 → 加頭尾包行」。 存在的理由是交付現實:照要透過 email 寄給客戶,而 email 會做各種事—— 把 \n 換成 \r\n、在行尾加空白、轉寄時插入引用空行。 裸 JSON 經過這些處理就壞了,PEM 風格的文字塊則天生耐得住(PGP 用了三十年就是這個原因)。 解包端 dearmor_license 逐行 strip 後才 join 解碼,正是為了容忍這些污染。
%%{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 → 開通完成"]
為什麼順序是固定的
簽章簽的是解壓後的 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 打的正是這裡—— 一支免權限的端點接收使用者上傳的資料,並在驗真之前先解壓它。 這不是「權限設計錯了」,而是「權限放寬的地方,輸入處理要更嚴」。
落地版(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。
| 線上開通 | 離線開通 | |
|---|---|---|
| 客戶做什麼 | 在系統裡輸入我們給的開通序號,按下開通 | 在系統裡抄下機器碼,寄給我們;我們簽好照回寄;客戶上傳照檔 |
| 誰跟簽發站說話 | 客戶的 BE 代打我們的開通伺服器(LICENSE_ACTIVATION_SERVER_URL) |
沒有機器連線——人(email)就是傳輸層 |
| 適用情境 | 客戶機器對外網通得到 | 機房完全封閉、air-gapped 環境 |
| 客戶體驗 | 一次按鈕 | 兩趟往返,需要人工協調 |
線上開通拿回來的照,照樣走 activate_license 做完整驗章 + 指紋核對。 程式註解寫得直接:
開通伺服器已核對序號合法性,但收到照後仍走
activate_license完整驗章+(host 版)指紋核對, 不因來源是開通伺服器就信任(設計裁示:BE 是唯一信任邊界)。
這條原則的價值在於:驗證邏輯只有一份,不會出現「這條路徑漏檢查了某一項」的縫。 而且「開通伺服器」本身也是網路那頭的東西——中間人、DNS 劫持、設定被改指向假伺服器, 都會讓「來源可信」這個假設破功。唯一穩固的信任錨是編譯在自己執行檔裡的那把公鑰。
兩條路唯一的差別只有 event_type(online_activate vs offline_activate), 記在事件時間線上,方便日後追「這張照是怎麼進來的」。
前面說過的死鎖問題在這裡再強調一次:開通流程的所有端點 (查機器碼、上傳照、線上開通)都必須豁免於 license 執法與唯讀 gate, 否則客戶會卡在「因為沒開通所以不能開通」的迴圈裡。
攻擊手法:照過期了,客戶把伺服器系統時間調回一年前,讓過期照「復活」。
兩道防線一起擋:
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 連線。
落地版客戶手上有整包程式碼,理論上可以反編譯、把驗證邏輯整段拿掉——這套機制擋不住, 而且沒有任何純軟體方案擋得住。
這不是我們做得不夠好,是這類問題的本質: 當執行環境完全在對方控制之下,任何「在對方機器上執行的檢查」原則上都可以被繞過。 DRM 產業投入的資源比我們高幾個數量級,結論也是一樣的。
所以定位要講清楚:
| 這套機制防得住 | 這套機制防不住 |
|---|---|
| 無意識越權(不知道自己沒買這個模組) | 專業破解(反編譯、patch 二進位) |
| 順手改一下(把到期日改個數字、多加幾個人) | 有決心且有能力的內部人員 |
| 照被複製到別台機器繼續用 | — |
| 系統時間被調回去讓過期照復活 | — |
| 客戶自己簽一張假照 | — |
真正的防線是合約與商業關係,技術機制的角色是: ① 讓「順手改一下」這件事不可能發生(改了就驗不過,沒有灰色地帶); ② 竄改時留下證據(本地事件記錄 + 檔案指紋),成為舉證材料。
FR-063(Nuitka 編譯)與 FR-064(防竄改偵測)都是提高門檻,不是提供保證。 把攻擊成本從「五分鐘改設定」拉到「需要逆向工程能力與數天工時」, 在商業上已經足以讓絕大多數人選擇正常付費——這就是這類機制實際能兌現的價值。
design.md §4.2 的防線分層寫得很清楚,值得再引一次:
簽章防竄改 → 公鑰編譯進執行檔防替換 → 時鐘回撥偵測 → 蓄意破解(patch 產品程式碼)靠合約與稽核約束, 技術上留存竄改證據作舉證材料。破解管理員帳號也無法自行簽發——產品內沒有私鑰、沒有簽發程式碼, 只有「收照櫃台」。
| 內容 | 檔案 |
|---|---|
| 簽章引擎、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 |