---
title: "發布簽章規格 (FR-167)"
brand: "Guidant AI · **FR-167** 線上更新"
eyebrow: "FR-167 · 技術規格 · 2026-11-01"
h1: "發布簽章"
subtitle: "發布清單的 v2 信封、canonical 與 Ed25519、kid、印章一正一備與換鑰、guidant-release CLI 全部子命令、主系統與主機的驗證順序、測試章與正式章現況、威脅對照"
lede: "客戶主機要不要裝一個更新包，只看一件事：這份發布清單是不是用「客戶機上已經裝好的印樣」驗得過。私鑰只在決策者電腦，更新站與 CI 都沒有；客戶端不信任更新站、上傳者或包裡帶來的任何鑰。本頁以 update-center `cli/` 與主系統 `package_verifier.py`、`release_keys.sh` 的程式碼為準。"
---

## 信任鏈一覽 {#overview nav="一覽"}

| 角色 | 手上有什麼 | 能做什麼 | 不能做什麼 |
|---|---|---|---|
| 決策者電腦 | 發布印章私鑰（PKCS8 加密 PEM，passphrase 只在腦中） | `guidant-release publish` 蓋章 | — |
| GitLab CI | `register-draft` 專用 CI token、R2 上傳金鑰 | 登記草稿、上傳包與**未簽**清單 | 發布、撤回、簽任何東西 |
| 更新站 | LC 公鑰表、R2 預簽用金鑰 | 認客戶、選版、原樣轉交簽好的清單 | 簽清單、改清單（改了客戶驗不過） |
| 客戶主系統（BE） | 本機 `/etc/guidant/release-keys/<kid>.pub`（容器內唯讀掛載） | 驗清單、驗包、寫更新單 | 信任更新站回的雜湊或鑰 |
| 客戶主機（shell） | 同一個資料夾的印樣、`openssl`、`python3` 或 `perl` | 升級第 0 步與預檢再驗一次 | 用包裡帶來的鑰驗包裡的清單 |

```{.mermaid cap="圖 1 — 一版從出包到客戶裝上的簽章路徑"}
%%{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'}}}%%
sequenceDiagram
  autonumber
  participant CI as GitLab CI
  participant US as 更新站
  participant R2 as R2
  participant DM as 決策者 CLI
  participant BE as 客戶主系統
  participant H as 客戶主機
  CI->>US: register-draft（未簽 release.json 的欄位）
  US-->>CI: upload.bundle／sha256／manifest 三個 key
  CI->>R2: 上傳整包、.sha256、未簽 release.json
  DM->>US: GET /admin/releases/＜v＞（Access）
  US-->>DM: 詳情（version、sha256、size、min_base…、內部先裝狀態）
  Note over DM: 核欄位合理、內部先裝全成功<br/>輸入版號確認、passphrase 解鎖<br/>buildPayload → canonical → Ed25519<br/>用 .pub 自驗
  DM->>US: POST …/publish {signed_manifest, kid}
  BE->>US: POST /v1/check
  US-->>BE: releases[].signed_manifest（原樣）
  Note over BE: 本機印樣驗章 → 版號遞增 → 主版號 → 可直升
  BE->>US: POST /v1/download-url
  US-->>BE: 15 分鐘預簽網址
  BE->>R2: 下載整包
  Note over BE: 大小與 sha256 等於驗過章的清單
  BE->>H: 寫更新單（包＋清單放 incoming/）
  Note over H: 第 0 步與預檢：release_keys_canonical 再驗章、<br/>sha256 三方相符
```

## 信封 v2 格式 {#envelope nav="信封"}

格式與 LC `core/crypto/engine.py` 的 `sign_payload` 同一套 v2 信封（`cli/envelope.ts:1-10`）：

```json
{"alg":"Ed25519","format_version":2,"kid":"<主簽 kid>","payload":"<base64(zlib(canonical))>","signature":"<base64>",
 "cosignatures":[{"alg":"Ed25519","kid":"<新章 kid>","signature":"<base64>"}]}
```

| 欄位 | 型別 | 必填 | 說明 |
|---|---|---|---|
| `format_version` | number | 是 | 固定 `2` |
| `alg` | string | 是 | 固定 `"Ed25519"` |
| `kid` | string | 是 | 主簽章的 kid；必須等於 payload 內的 `kid` |
| `payload` | string | 是 | `base64(zlib.deflate(canonical))` |
| `signature` | string | 是 | `base64(Ed25519(canonical))`——**簽的是 canonical 原文，不是壓縮後的字串** |
| `cosignatures` | `[{kid, alg, signature}]` | 否 | 只在換鑰中出現；新章對**同一份 canonical** 再簽一次 |

**序列化**：信封本身也用 canonical JSON 輸出（`serializeEnvelope`，鍵排序、無空白，`cli/envelope.ts:93-95`）。之後更新站與客戶端都把它當**字串原樣**傳遞，不 parse 再 stringify，免得改到一個位元組。更新站存在 `product_releases.signed_manifest`，主機上存在包旁 `guidant-ai-<版>.release.json`。

**大小上限**：信封與解壓後的 canonical 各 64KB（更新站 `MAX_SIGNED_MANIFEST`；BE `MAX_ENVELOPE_BYTES`、`MAX_CANONICAL_BYTES`，`package_verifier.py:29-30`）。BE 解壓用 `decompressobj` 帶上限，超過就丟（解壓炸彈）。

## payload 與 canonical {#payload nav="payload"}

`buildPayload`（`cli/envelope.ts:53-74`）只挑契約欄位；更新站回的詳情還有 `status`、`r2_key` 等，那些不蓋進章。

| 欄位 | 型別 | 必有 | 來源 | 說明 |
|---|---|---|---|---|
| `payload_type` | string | 是 | 固定 | `"release_manifest"`。同一套信封也用在授權檔、完整性 manifest；另立型別防止互相頂替 |
| `version` | string | 是 | 更新站詳情 | |
| `major` | number | 是 | 更新站詳情 | CLI 核過等於版號第一段 |
| `min_base_version` | string | 是 | 更新站詳情 | |
| `sha256` | string | 是 | 更新站詳情 | 整包雜湊，64 hex |
| `size` | number | 是 | 更新站詳情 | 整包位元組數 |
| `has_security_fix` | boolean | 是 | 更新站詳情 | |
| `issued_at` | string | 是 | CLI 當下時間 | `2026-10-29T08:00:00+00:00` 格式：秒、`+00:00`、不帶毫秒（`isoSeconds`，`cli/commands.ts:46-48`），Python 3.10 以前的 `fromisoformat` 不收 `Z` |
| `kid` | string | 是 | 主力章 | |
| `min_agent_version` | string | 有值才放 | 更新站詳情 | 沒值時**不放這個鍵**（不放 null），舊清單 canonical 不變 |
| `ops_sha256`、`ops_size` | string、number | 兩個都有值才放 | 呼叫端傳入 | 維運指令小檔的雜湊與大小；缺一就兩個都不放 |
| `endorsed_keys` | `[{kid, pubkey}]` | 換鑰中才放 | 新章 | `pubkey` 是 base64 的 32 bytes 公鑰 |

**canonical** = payload 的鍵排序、無空白 JSON，與 Python `json.dumps(sort_keys=True, separators=(",",":"), ensure_ascii=False)` 逐位元組一致（`canonicalJson`，`src/licenses.ts:70-78`，CLI 直接 import 同一支）。陷阱：JS 與 Python 對浮點數輸出不同（`1.0` vs `1`），payload 裡沒有浮點欄位，所以一致；新加欄位時不可加浮點數。

**ops 兩欄的現況限制**：CI 產的未簽 `release.json` 有 `ops_sha256`／`ops_size`（`scripts/build/release_json.sh:11`、`:155`），但 `register-draft` 不收這兩欄、D1 沒有對應欄位、更新站詳情也不回，所以**正式 CLI `publish` 路徑簽出來的清單沒有 ops 兩欄**。客戶主機遇到沒有 ops 欄位的清單會退回從整包抽維運指令（較慢、功能正常，`self_replace.sh` `sr_ops_verified`）。測試期發布是直接用測試章對 188 產出的 `release.json` 簽，所以帶得到這兩欄。

**演算法**：Ed25519（Node `crypto.sign(null, canonical, key)`），簽 canonical 的 UTF-8 bytes。

**kid** = 公鑰原始 32 bytes 的 sha256 前 16 個小寫 hex（`computeKid`，`cli/keystore.ts:42-44`），與 LC 同一套規則。可以用 openssl 從 `.pub` 算：

```bash
openssl pkey -pubin -in <kid>.pub -outform DER | tail -c 32 | shasum -a 256 | cut -c1-16
```

## 印章（金鑰） {#keys nav="印章"}

### 本機保存 {#keystore nav="本機保存"}

| 項目 | 規格 | 來源 |
|---|---|---|
| 資料夾 | 預設 `~/.guidant-release`，`--keys` 可換；建立時 `700` | `cli/keystore.ts:29-31`、`:66-69` |
| 角色表 | `keys.json`：`{keys:[{kid, role, created_at, revoked_at?, endorsed_in?}]}`，`600`，先寫暫存再 rename | `:58-64` |
| 角色 | `primary`（主力）、`backup`（備用）、`next`（換鑰中的新章）、`revoked`（作廢） | `:15` |
| 私鑰檔 | `<kid>.key`：PKCS8 PEM，passphrase 加密（Node 產出為 PBES2＋AES-256-CBC），`600`；`flag: "wx"`，同名檔已存在就失敗，絕不覆蓋 | `:71-81` |
| 公鑰檔 | `<kid>.pub`：SPKI PEM，`644`。客戶主機用 `openssl pkeyutl -verify -pubin -inkey <kid>.pub` 直接吃 | `:79` |
| passphrase | 至少 12 bytes；只從 TTY 讀（stdin 不是終端機就拒絕），不收參數、不讀環境變數；關 echo 逐位元組收，連 `*` 都不印；回 `Buffer` 用完 `fill(0)` | `:26`、`cli/prompt.ts` |
| 解鎖檢查 | 私鑰檔權限比 600 寬就拒絕；passphrase 錯或檔壞給白話錯誤；解開後再算一次 kid，與檔名不符就拒絕（檔名被改過） | `:83-99` |

### 一正一備 {#primary-backup nav="一正一備"}

`init-keys` 一次產兩把：主力章與備用章，**兩把 passphrase 必須不同**（同一個 passphrase 外流時備用會一起失守，`cli/commands.ts:89-92`）。產完印出封存說明：

1. 備用章私鑰複製到兩個離線隨身碟，分兩地保管。
2. 備用章 passphrase 手寫密封，與隨身碟分開放，不存在任何電腦、手機、雲端、聊天紀錄。
3. 兩份都確認讀得到後，刪掉這台電腦上的備用章私鑰（`.pub` 留著）。
4. 兩個 `.pub` 都交給主產品放進 `scripts/installer/release-keys/`。

客戶機**同時認兩把印樣**，所以主力章出事時 `promote-backup` 立刻生效、不用背書。

### 公鑰進產品 {#keys-into-product nav="公鑰進產品"}

| 位置 | 內容 | 誰寫 |
|---|---|---|
| BE repo `scripts/installer/release-keys/<kid>.pub` | 隨出貨包的印樣；`PROD_KIDS` 一行一個正式章 kid | 人工交付（決策者給 `.pub`） |
| 出貨包 `guidant-ai-<版>/release-keys/` | 同上，打包時帶入；ops 小檔也帶 | `build_bundle.sh` |
| 客戶主機 `/etc/guidant/release-keys/<kid>.pub` | root `755`／檔 `644` | `release_keys_install`（`scripts/installer/lib/release_keys.sh:99-127`），升級第 0 步換指令時呼叫（`self_replace.sh:180`） |
| 主系統容器 `/etc/guidant/release-keys`（唯讀掛載） | 同上 | `docker/production/docker-compose.yml:257`；BE 設定 `GUIDANT_RELEASE_KEYS_DIR`（預設該路徑，`config/config.py:209`） |

`release_keys_install` 的規則：

- **只加不減**：同名不覆蓋（kid 是公鑰指紋，同名等於同鑰）、不刪舊的——舊客戶機要靠舊印樣驗下一版。
- 整批先檢查再裝：符號連結、檔名不是 kid、不是 Ed25519 公鑰（含誤放私鑰）任一筆不合格就整批拒絕、什麼都不裝。
- 包內沒有 `release-keys/` 不算錯。

**出包守門**：`build_bundle.sh` 要求 `release-keys/PROD_KIDS` 至少列一個 kid 且對應 `.pub` 存在，否則失敗；只有 `--skip-prod-key-check`（內部測試包，不可出貨）能略過（`scripts/build/build_bundle.sh:174-191`）。

**撤銷的現況**：主機端沒有「刪印樣」的機制。作廢一把章（換鑰完成、主力外洩）只影響**之後簽出來的清單**；已裝在客戶機上的舊印樣仍會被認。外洩情境要靠「不再用外洩的章簽新版」＋「新版號遞增」讓舊章簽的東西沒有用武之地（見威脅對照）。

## guidant-release CLI {#cli nav="CLI"}

決策者電腦上的發布工具，Node 22 直接跑 `.ts`（`cli/guidant-release.ts`）。在 update-center repo 裡 `npm ci` 後 `npm run cli -- <命令>`，或 `npm link` 後打 `guidant-release <命令>`。

**共用選項**：

| 選項 | 說明 |
|---|---|
| `--site <網址>` | 更新站；也可設 `GUIDANT_RELEASE_SITE`。必須 https，只有 `127.0.0.1`／`localhost` 可 http（`cli/site.ts:50-60`） |
| `--keys <資料夾>` | 印章資料夾，預設 `~/.guidant-release` |
| `--access-token-file <檔>` | 本機 `wrangler dev` 手測用的 Access 憑證檔（必須 600）；不給就跑 `cloudflared access token -app=<site>` 取 |
| `--finish` | 只給 `rotate-key` 用 |

**對更新站的身分**：正式站送 `cf-access-token`（Access 驗過換成 `Cf-Access-Jwt-Assertion` 轉給 Worker）；本機 `127.0.0.1` 直接送 `Cf-Access-Jwt-Assertion`。**絕不送 `Authorization`**（更新站會當 CI token、publish 一律 403）。`redirect: "manual"`：Access 沒登入回 302 時當成「要先登入」處理。access token 與 passphrase 一樣不印、不寫檔。

### 子命令 {#subcommands nav="子命令"}

| 命令 | 連更新站 | 要輸入 | 做什麼 | 來源 |
|---|---|---|---|---|
| `init-keys` | 否 | 主力章 passphrase ×2、備用章 passphrase ×2 | 資料夾已有 `keys.json` 就拒絕；產主力＋備用、寫 `keys.json`、印封存說明 | `cli/commands.ts:83-126` |
| `publish <版號>` | 是 | 輸入版號確認、主力章 passphrase（換鑰中多問新章） | 見下方流程 | `:165-213` |
| `withdraw <版號>` | 是 | 輸入版號確認 | 取詳情；已撤回就拒絕；`POST …/withdraw` | `:217-227` |
| `rotate-key` | 否 | 新章 passphrase ×2 | 已在換鑰中、或沒有主力章就拒絕；產 `role: next` 的新章，印接下來三步 | `:231-250` |
| `rotate-key --finish` | 否 | — | 新章沒有 `endorsed_in`（還沒隨任何一版發出）就拒絕；舊主力 → `revoked`、新章 → `primary` | `:252-267` |
| `promote-backup` | 否 | 備用章 passphrase、輸入舊主力 kid 確認 | 換鑰中不准；先解鎖備用章確認可用，再作廢主力、備用轉主力 | `:273-292` |
| `sync-keys` | 是 | — | 每把章 `POST /admin/keys {kid, pubkey(base64 32B), status}`；`next` 登記成 `backup`；站上已作廢的作廢章視為已同步 | `:297-313` |
| `list-keys` | 否 | — | 列 kid、角色、建立／作廢時間、新章是否已隨版發出 | `:315-322` |

CI 呼叫的 `register-draft` 不在 CLI 裡，由 BE `scripts/build/publish_draft.sh` 打（見 [更新站規格](update-site-spec.md#register-draft)）。

### publish 流程 {#publish-flow nav="publish 流程"}

```{.mermaid cap="圖 2 — guidant-release publish：兩道關都在送出前、在決策者電腦上判"}
%%{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["讀 keys.json：找主力章、有沒有換鑰中的新章"] --> B["GET /admin/releases/＜v＞"]
  B --> C{"欄位合理？<br/>版號一致、major＝版號第一段、sha256 64 hex、<br/>size 正整數、min_base／min_agent 版號格式、has_security_fix 布林"}
  C -- 否 --> X["拒絕，什麼都不送"]
  C -- 是 --> D{status}
  D -- withdrawn --> X
  D -- draft --> E{"內部先裝：清單非空、<br/>每台 installed 且 result＝succeeded"}
  E -- 否 --> X
  E -- 是 --> F
  D -- published --> F["同版重簽"]
  F --> G{"輸入版號確認"}
  G -- 不符 --> X
  G -- 符 --> H["passphrase 解鎖主力章（換鑰中再解新章）"]
  H --> I["buildPayload → canonical → 主簽＋副簽"]
  I --> J{"用 .pub 驗剛蓋的章"}
  J -- 不過 --> X
  J -- 過 --> K["POST …/publish {signed_manifest, kid}"]
  K --> L{"回 status＝published？"}
  L -- 否 --> X
  L -- 是 --> M["新章第一次隨版發出：記 endorsed_in"]
```

- 第一道關「內部先裝」比更新站嚴：更新站允許空清單，**CLI 空清單一律拒絕**（沒人裝過不發，`checkInternalInstalls`，`cli/commands.ts:133-143`）。
- 第二道關「自驗」：私鑰檔與 `.pub` 對不上（拿錯檔）在這裡擋，不是讓每個客戶都驗不過（`verifyRelease`，`cli/envelope.ts:109-126`）。自驗也核 `payload_type`、外層 kid 等於 payload kid、每個副簽章。
- 更新站回的欄位**不被信任**：任何欄位不合常理就不簽（`checkDetail`，`:145-156`）。
- 被拒時不會問 passphrase、什麼都不送。
- 成功後 CLI 印「清單 90 天到期，到期前要重跑 publish 重新蓋章」（`MANIFEST_VALID_DAYS = 90`，`cli/envelope.ts:21`）。**客戶端已不檢查清單有效期**（見下節），這行提示與後台「快過期，請重簽」標仍按 90 天計算，但客戶不會因此拒收，重簽不是必要動作。

### 換鑰 {#rotation nav="換鑰"}

換印章只能藏在一次對章通過的更新裡：客戶機用它現在認的舊印樣驗過一版，那一版的包裡帶著新印樣。

```{.mermaid cap="圖 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'}}}%%
stateDiagram-v2
  state "只有舊章（primary）" as S0
  state "換鑰中：舊章 primary＋新章 next" as S1
  state "新章已隨版發出（endorsed_in 有值）" as S2
  state "新章 primary，舊章 revoked" as S3
  [*] --> S0
  S0 --> S1: rotate-key（新章 .pub 交給主產品進 release-keys/）
  S1 --> S2: publish 一版：舊章主簽、新章副簽、endorsed_keys 帶新印樣
  S2 --> S3: rotate-key --finish
  S3 --> [*]: sync-keys；舊章私鑰確認不需要後銷毀
```

- 換鑰中 publish：舊章主簽（`signature`）、新章副簽（`cosignatures`），payload 的 `endorsed_keys` 寫新印樣——被舊章簽進去＝舊章背書新章。
- 只認舊章的客戶機照常驗主簽章，**不看** `cosignatures`（BE 與 shell 都只驗主簽章）。
- 新章還沒隨任何一版發出就 `--finish` 會被拒：不然之後只用新章簽的清單，舊客戶機全部驗不過、再也升不上來。
- `endorsed_keys` 目前沒有客戶端讀取；新印樣能被客戶機認，靠的是那一版**包內** `release-keys/` 經 `release_keys_install` 裝上去。

### 主力章出事 {#promote nav="主力章出事"}

1. 把封存的備用章私鑰放回印章資料夾（`chmod 600`）。
2. `guidant-release promote-backup`：先用備用章 passphrase 解鎖確認可用，再輸入舊主力 kid 確認；舊主力作廢、備用轉主力。客戶機本來就同時認兩把印樣，立刻生效。
3. `sync-keys`。之後沒有備用章：補一把新備用章（產章、隨包發出、封存）CLI 尚未支援。

## 客戶端驗證 {#client-verify nav="客戶端驗證"}

### 主系統（BE） {#be-verify nav="主系統"}

`app/product_update/service/package_verifier.py`。只信本機 `GUIDANT_RELEASE_KEYS_DIR` 的印樣；更新站、上傳者、包裡帶來的鑰一律不信。

| 順序 | 檢查 | 不過時 reason | 來源 |
|---|---|---|---|
| 0 | 信封是字串、≤64KB；`format_version = 2`、`alg = Ed25519`；`payload` 解 base64、帶上限解壓；`signature` 嚴格 base64 | `bad_signature` | `_verified_payload`，`:140-152` |
| 1 | **kid 查本機印樣**：kid 必須 `^[0-9a-f]{16}$`（kid 是路徑的一段，防 `../`）；`<kid>.pub` 不可是符號連結、必須是檔、≤4KB、必須是 Ed25519 公鑰 | `bad_signature`（訊息：印章不在本機認得的印樣裡） | `_load_release_key`，`:112-123` |
| 2 | **簽章**：Ed25519 驗 canonical；`payload_type = release_manifest`；payload `kid` = 外層 `kid` | `bad_signature` | `:156-165` |
| — | 內容格式：必要欄位齊、`issued_at` 帶時區、`version` 與 `major` 一致、`sha256` 64 hex、`size > 0`、版號欄位格式；`ops_*` 格式不對當作沒有 | `bad_signature`（內容格式不對） | `_manifest_from_payload`，`:176-200` |
| 3 | **版號遞增**：現行版號可辨識，且清單版號（semver）＞現行；不可降版或重裝 | `not_newer` | `verify_manifest`，`:206-218` |
| 4 | **主版號相同**：換主版號要重新授權 | `major_mismatch` | 同上 |
| — | 可直升：起點 ≥ 清單 `min_base_version`（更新站漏排必經版時在這裡擋） | `not_newer` | `check_min_base`，`product_update_flow.py:467-471` |
| 5 | **下載完核包**：檔案大小與 sha256 都要等於驗過章的清單；更新站或上傳者另外報的雜湊不採用 | `checksum` | `verify_package_file`，`:234-241` |

**清單有效期：沒有**。檔頭明寫「清單不設有效期：客戶隔一年才升、離線檔放很久都要裝得起來；防重放舊版靠版號只能往上＋主版號＋本機印樣」（`package_verifier.py:3`）；程式裡沒有 `MANIFEST_VALID_DAYS`，`issued_at` 只解析、不判。主機端 `upgrade_order.sh:97` 同樣註明「清單不設有效期（issued_at 不判）」。

呼叫點：

- `/v1/check` 回來後逐段驗（`_parse_available`，`product_update_flow.py:217-231`）：每段以前一段的版號為起點驗 3、4 與可直升，驗不過的那段整段不認、記原因。
- 寫更新單前再驗一次（`verify_ready_package`，`upgrade_order.py:146-162`）：清單版號必須等於包的版號，然後驗 1～5。
- 讀「已經裝上的這一版」的清單（取 `min_agent_version`）用 `read_signed_manifest`：只驗章、不看版號，不可拿來決定收不收包（`:221-223`）。

### 主機（shell） {#shell-verify nav="主機"}

`scripts/installer/lib/release_keys.sh`。主系統容器無 docker socket，真正動手升級的是主機上的 `guidantai`；它在第 0 步（換成新版的自己）與預檢**再驗一次**，因為包與清單放在主系統可寫的 `incoming/`。

| 步驟 | 做法 | 來源 |
|---|---|---|
| 解信封 | `python3` 優先，否則 `perl` 的 `JSON::PP`＋`MIME::Base64`＋`Compress::Zlib`；寫出 `canonical` 與 raw `sig`，印外層 kid、canonical 頂層 `payload_type`、頂層 `kid`。不用 grep（換鑰清單的 `endorsed_keys` 裡也有 `"kid"`，grep 會取錯層） | `rk_envelope_parse`，`:44-65` |
| 驗章 | `openssl pkeyutl -verify -pubin -inkey /etc/guidant/release-keys/<kid>.pub -rawin -in canonical -sigfile sig`；kid 必須 16 hex | `release_keys_verify_file`，`:25-31` |
| 型別與 kid | `payload_type == release_manifest` 且 canonical `kid` == 外層 kid | `release_keys_verify_envelope`，`:72-86` |
| 讀欄位 | 一律從驗過的 canonical 讀（`release_keys_canonical`），不讀信封外層 | `:95-97` |
| 包雜湊 | 實際 sha256 = 包旁 `.sha256` = 清單 `sha256`，三方相符；清單 `version` 格式合法 | `self_replace_verify`，`self_replace.sh` |
| ops 小檔 | 雜湊比對清單裡的 `ops_sha256`；小檔旁的 `.sha256` 不採用；清單沒有 ops 欄位就退回從整包抽 | `sr_ops_verified`，`self_replace.sh` |
| 預檢 | 缺 python3 與 perl 模組時預檢直接失敗（否則每一包都會被當成簽章驗證失敗，症狀不指向這裡）；可直升起始版也從驗過章的 canonical 讀 | `upgrade_preflight.sh` `pf_check_package`、`pf_read_min_base` |

`cosignatures` 不驗：舊客戶機只認主簽章，換鑰清單帶它與 `endorsed_keys` 照樣驗得過。

## 測試章與正式章現況 {#key-status nav="章的現況"}

| 項目 | 現況（2026-11-01 實查） |
|---|---|
| BE repo `scripts/installer/release-keys/` | 只有 `b91819f86eb4b4f8.pub`（**測試章**；用 openssl 從檔案算 kid 核對相符） |
| `release-keys/PROD_KIDS` | 不存在 → 正式出包會被 `build_bundle.sh` 擋，測試期出包都帶 `--skip-prod-key-check` |
| 測試章私鑰 | 首腦在決策者電腦用 openssl 產，放 `~/.guidant-release-test/`（600、無 passphrase、不入任何 repo），只供內部測試；不是 `guidant-release init-keys` 產的，沒有備用章 |
| 正式章 | **尚未產生**：要決策者親自跑 `guidant-release init-keys`，交付兩個 `.pub` 進 `release-keys/` 並寫 `PROD_KIDS` |
| 更新站已發布版 | 1.27.1～1.27.10 共 10 版，`kid` 全是 `b91819f86eb4b4f8` |
| 更新站印樣表 `signing_keys` | 0 列（未跑 `sync-keys`；不影響客戶驗章） |
| 發布方式 | 測試期以測試章直接簽 188 產出的 `release.json`、直接寫 D1；未走 CLI `publish` 與內部先裝 |

**換正式章時要注意**：已裝測試期包的機器 `/etc/guidant/release-keys/` 只有測試章。第一個正式章簽的版本要能被這些機器接受，必須先用測試章簽一版「包內帶正式章 `.pub`」的過渡版（等同一次換鑰），否則這些機器會以「印章不在本機認得的印樣裡」拒收；或由人工把正式章 `.pub` 放進這些機器。客戶出貨機一開始就裝正式章，沒有這個問題。

## 威脅對照 {#threats nav="威脅對照"}

| 情境 | 攻擊者能做什麼 | 不能做什麼（為什麼） |
|---|---|---|
| **更新站被攻破**（Worker 程式或 D1 被改） | 對 `check` 回「沒有新版」（拖延更新）；停發下載網址；把客戶回報資料讀走（只有授權編號、指紋、版號，無客戶名稱）；把某版撤回 | 讓客戶裝上改過的包：清單簽章用本機印樣驗，改一個位元組就不過；改 `sha256` 欄位沒用，客戶只信驗過章的清單 |
| 同上 | 回一份**舊的**真簽清單（重放） | 讓客戶降版或重裝：版號必須嚴格大於現行，且主版號相同 |
| 同上 | 把下載網址指向別的主機 | 主系統只接受 R2 帳號端點主機（`DOWNLOAD_HOSTS`）；即使下載到別的東西也過不了 sha256 |
| 同上 | 在 `signing_keys` 登記假印樣 | 那張表客戶端不看 |
| **R2 被改**（包或 `.sha256` 被換） | 讓下載失敗 | 讓客戶裝上：包的 sha256 要等於驗過章的清單 |
| **CI token 外洩** | 登記新的草稿版號；覆蓋**尚未發布**的草稿 | 發布（publish 只收 Access 身分）；覆蓋已發布或已撤回的版（409 `not_draft`）；草稿只對內部先裝機器可見 |
| **Access 帳號被盜** | 撤回版本、改拒絕名單、指定內部先裝、送一份 publish | 簽出客戶認得的清單（沒有私鑰）；送去的偽造清單客戶驗不過 |
| **授權檔或身分票外洩** | 冒用那台機器的身分查版、拿下載網址 | 用別台機器指紋查（照綁指紋）；拿到包也只是我們發布的原版 |
| **發布私鑰檔外洩**（未含 passphrase） | — | 解開私鑰：PKCS8 以 passphrase 加密 |
| **私鑰＋passphrase 都外洩** | 簽出客戶認得的清單 | — 。處置：`promote-backup` 切到備用章，之後只用備用章簽、版號持續往上；主機端沒有「刪印樣」機制，被盜章簽的清單在客戶機上仍驗得過，只能靠版號遞增讓它簽的舊版號失去作用、並由拒絕名單／撤回限制更新站發放 |
| **客戶機上的人放入假印樣** | 讓該機接受自己簽的包 | 需要 root（`/etc/guidant/release-keys/` root 755、檔 644）；擁有 root 本來就能直接改系統 |
| **借簽**：拿 LC 簽的授權檔或完整性 manifest 頂替發布清單 | — | `payload_type` 必須是 `release_manifest`（BE 與 shell 都擋）；而且發布印樣與 LC 簽發鑰是不同的鑰 |

## 相關文件 {#refs nav="相關"}

- [更新站規格](update-site-spec.md)：publish／register-draft 端點、身分驗法
- [更新站資料表](update-site-schema-spec.md)：`signed_manifest`、`signing_keys` 欄位
- [升級指令規格](upgrade-runner-spec.md)：主機第 0 步與預檢
- [更新中心 API 規格](update-center-api-spec.md)
- [Agent 跟進規格](agent-rollout-spec.md)：`min_agent_version` 的用法
- [時序圖](sequence-diagrams-spec.md)
- [發版操作手冊](release-runbook.md)
- [設計文件](design.md) §5.4、§5.8
