---
title: "防竄改偵測 — 需求討論稿 (FR-064)"
brand: "Guidant AI · **FR-064** 防竄改偵測"
eyebrow: "FR-064 · Tamper Detection — 需求討論稿 · 2026-08-15（v1 待審）"
h1: "程式被動過，就終結服務、鎖定產品"
lede: "三階段落地版戰線第二棒：FR-063 已把整套 BE 編成機器碼出貨（Harbor `guidant-ai-be:1.14.0`），本案接著回答「**產物被竄改怎麼辦**」——build 期逐檔 SHA-256 manifest 以原廠私鑰簽章、啟動驗章＋逐檔比對、runtime 低頻抽查；一旦偵測竄改，寫入 tamper 紀錄（DB＋檔案系統雙落點）並立即終結，之後每次啟動視同無 license 拒啟。唯一解鎖途徑＝原廠簽發的一次性 **unlock token**（離線信封，air-gapped 環境也成立）。簽驗體系**復用 FR-062 License Center**，不另建 PKI。"
chips: [
  {text: "待決策 D1–D8（8 項）", kind: warn},
  {text: "前作：FR-063（Nuitka 編譯，已完結）", kind: accent},
  {text: "後續：FR-065（Installer）", kind: accent},
  {text: "復用：FR-062 LC 簽驗體系", kind: ok},
  {text: "Notion 母案 CM-1188", kind: plain}
]
footer: "FR-064 · 防竄改偵測 — 需求討論稿 · 2026-08-15 v1 待審 · 前作：FR-063（Nuitka 編譯）· 後續：FR-065（Installer）· 待決策 D1–D8 · 探脈事實來源：FR-062 LC／BE 簽驗實碼、FR-063 build 管線與產物實測"
---

## 需求分析：這一棒要守什麼、承認守不住什麼 {#why nav="需求分析"}

### 1.1 戰線位置

本案是「三階段落地版」戰線的第二棒（Notion 母案 **CM-1188**）：

| 棒次 | 案號 | 內容 | 狀態 |
|------|------|------|------|
| 第一棒 | FR-063 | Nuitka 編譯——整套 BE 編成機器碼出貨，產物 Harbor `guidant-ai-be:1.14.0`（bake `cc7714d2`） | **已完結** |
| 第二棒 | **FR-064** | **防竄改偵測（本案）**——偵測產物被竄改 → 終結服務並鎖定產品；唯一解鎖＝原廠一次性 unlock token | 討論中 |
| 第三棒 | FR-065 | Installer——客戶端安裝／升級體驗 | 未開 |

FR-063 解決「原始碼不外流」（客戶拿到的是機器碼），本案解決「機器碼被動手腳怎麼辦」——兩者合起來才構成落地版的完整保護面。

### 1.2 需求一句話

> 偵測程式被竄改 → 終結服務並鎖定產品；唯一解鎖 ＝ 原廠簽發的一次性 unlock token。

拆開是四個動詞：

::::::: grid2
::: {.card .ok}
#### ① 偵測

build 期產生逐檔 SHA-256 manifest，以原廠私鑰簽章；產品端**啟動時**驗章＋逐檔比對，**runtime** 由 APScheduler 低頻抽查（隨機 N＋固定核心清單）。檢查點分散多處、互相驗證，拉高「全拔乾淨」的成本。
:::

::: {.card .crit}
#### ② 終結

偵測到竄改，立即 exit——不降級、不告警後繼續跑。半殘狀態繼續服務比停機更危險（資料正確性無法保證，且等於容忍竄改）。
:::

::: {.card .crit}
#### ③ 鎖定

tamper 事件雙落點（DB＋檔案系統）。之後每次啟動先查 tamper 紀錄，**存在即視同無 license 拒啟**——把「重啟就好」這條路封掉。
:::

::: {.card .ok}
#### ④ 解鎖

原廠簽發一次性 unlock token（LC 私鑰簽的離線信封：machine_fingerprint＋nonce＋效期），產品端離線驗章＋本地記已用 nonce 防重放。**離線環境必須成立**——被鎖的機器可能根本連不到原廠。
:::
:::::::

### 1.3 定位與已知限制（已與決策者對齊）

::: {.callout .decided}
**這不是 DRM 軍備競賽，是「拉高成本＋留下痕跡」**

client-side 完整性驗證**無法對抗有決心的逆向團隊**——驗證邏輯本身就在客戶手上的機器碼裡，理論上總是拆得掉。本案的目標定位是：

- **99% 的人覺得不值得**：拆解需要同時處理 binary 內的驗證邏輯、分散的檢查點、簽章公鑰——成本遠超「試試看改兩個檔」的程度。
- **1% 蓄意者留下痕跡**：tamper 紀錄、被替換的檔案本身，都是合約求償的證據。本產品是 **B2B 具名法人**場景——客戶是簽了約的公司，不是匿名散戶；**法律層才是真實防線**，技術層負責讓違約「有痕跡、可舉證」。
- **離線環境必須成立**：客戶環境可能完全 air-gapped，沒有「打回原廠回報」這條線。所有偵測、鎖定、解鎖都必須在無網路下自洽。
:::

## 現況盤點：可復用 vs 缺口 {#inventory nav="現況盤點"}

以下事實皆經實碼／實測驗證（FR-062 LC 與 BE 簽驗實碼、FR-063 build 管線與產物），是後面所有決策項的基礎。

### 2.1 FR-062 簽驗體系（本案的復用底座）

| 元件 | 座標 | 現況 |
|------|------|------|
| 簽章演算法 | Ed25519，簽 canonical JSON bytes（`sort_keys` ＋緊湊分隔） | LC 端 `src/license_center/core/crypto/engine.py:56 sign_license()`；BE 端有**逐位元組一致**的 canonical 實作 `common/license/engine.py:90` |
| kid | 公鑰 SHA-256 hex 前 16 chars | `keys.py:21 compute_kid()` |
| 私鑰 | PKCS8 PEM ＋ passphrase（env `LICENSE_CENTER_KEY_PASSPHRASE`；air-gapped 回退表單輸入） | LC 端持有，產品端永不落地 |
| 信封格式 | v2 信封（kid / payload zlib+b64 / signature）＋ v3 armor（PEM 風格） | 兩端已互通 |
| 產品端公鑰 | `common/license/public_keys.py` **硬編源碼**（現三把 DEV/STG/POC） | 隨 binary 編譯，改公鑰＝改 binary |
| 驗章核心 | `common/license/engine.py:138 verify_license()` | **純函式** |
| 機器指紋 | `common/license/machine_fingerprint.py compute_machine_fingerprint()` | 現成，unlock token 直接用 |
| 一次性碼借鏡 | LC `activation_code`（一次性、`activated_at` 判已用、重用 409）三件套 | 模式可抄，但它是**線上模型**——unlock 要改離線信封（見 D6） |

**執法慣例**（license 側已定型、本案沿用同語氣）：fail-closed；總開關 `LICENSE_ENFORCEMENT_ENABLED`；驗失敗 log 留證（content_fingerprint＋reason）。當時「表格化留證」被註記為「留給後續階段」——**FR-064 正是該階段**。

### 2.2 缺口（要動的地方）

::: {.callout .warn}
**缺口一：簽驗函式型別綁死 License 商務欄位**

`sign_license()` / `verify_license()` 目前綁死 License 的 18 個商務 key。要做到母案卡要求的「**與 license 驗證共用同一 code path、同一把公鑰**」，必須抽出泛用層 `sign_payload(dict) → envelope` / `verify_payload(envelope) → dict`，License 與 manifest（以及 unlock token）都改走它——**LC 與 BE 兩端都要動**。詳見 D2。
:::

::: {.callout .warn}
**缺口二：啟動期驗證掛點目前不存在**

license 驗章只在「照匯入」時觸發；`create_app()` 裡**沒有任何啟動驗章**。且 `verify_license` 依賴 DB 的 clock_watermark——manifest 驗章必須走**無 DB 依賴**的路徑（啟動時 DB 可能還沒 ready，且驗證本身不該依賴可被客戶操作的儲存）。詳見 D3。
:::

::: {.callout .warn}
**缺口三：tamper 是機器級，現有 license 表形狀不合**

tamper 事件是**機器級／安裝級**，不是租戶級。DB 落點語意最接近 `public.license_clock_watermark`（全域單例、不掛 RLS），**不適合**套 tenant_licenses 那種掛 RLS 的形狀。要開新表。詳見 D5。
:::

### 2.3 FR-063 產物與 build 管線（manifest 的對象）

**build 管線**：`scripts/build/build_release.sh` 四步——deps → resources（.mo）→ freeze（DI 靜態清單＋version bake）→ compile（Nuitka → dist ＋ excluded 套件複製 ＋ `assert_protection` 零 .py ＋資料檔存在斷言）。**manifest 產生的建議插入點：Step 4 尾端、全部斷言通過之後、進 `build_image` 之前**——那是 dist 定案的時刻。

::: {.callout .crit}
**🔴 build_image 的 context 是 rsync 後另一份**

`build_image.sh` 用 rsync 複製出另一份 context（排除 `.env*` / `log` / `static` / `smoke-*.log` / `__pycache__`）。**manifest 的排除規則必須與 rsync 排除規則一致**，否則簽了進不了 image 的檔、或 image 裡有沒簽的檔，啟動驗證必對不上。manifest ＋簽章檔要一併帶進 context。
:::

**產物分四層**（這個分層直接決定 D1 的驗證分層）：

| 層 | 內容 | 規模 | 竄改攻擊價值 |
|----|------|------|--------------|
| ① 業務資產 | binary 本體＋Nuitka .so（零 .py） | 數百檔 | **最高**——業務邏輯、授權執法都在這 |
| ② 隨版資料檔 | translations .mo、`ssp_cmmc_template.docx`、cmmc canon json ×2、`resources/download` 5 檔、project_summary_report templates html | 數百～千餘檔 | 中——改資料檔可影響產出正確性 |
| ③ 第三方原樣附帶 | EXCLUDE_COMPILE_PKGS 11 支（pymupdf/fitz、pandas、numpy、fontTools、pdfminer、Crypto、PIL、grpc、lxml、rapidfuzz）＋相依封閉集合＋dist-info＋.libs | **2–4 萬檔量級，.py 佔大宗** | 低——但 .py 可讀可改，是「最容易下手」的面 |
| ④ volume | `/app/static` `/app/log` `/home/guidant` `/opt/guidant/pki` `/tmp` | 動態 | **必排除**——客戶正常寫入，納入即誤報 |

全 dist 啟動逐檔掃描估**數秒～十數秒 I/O**——這是 D1 分層驗證的動機。

::: {.callout .crit}
**🔴 威脅前提：「產物唯讀」目前是意圖，不是事實**

Dockerfile `COPY --chown=1000:1000`，entrypoint 以 root 起、只 chown 四個掛載點後 `setpriv` 降 1000 跑 binary——**`/app` 的 owner 就是執行者本人**，非 root 進程對產物**可寫**。竄改門檻目前是零。這是 D4（/app 唯讀化）的直接動機：驗證是「事後偵測」，唯讀化是「事前擋掉」，兩者互補。
:::

### 2.4 啟動序與 scheduler（掛點）

**啟動序**（`main.py`）：`load_dotenv` → `RUN_MODE` 解析（SystemExit fail-fast）→（socketio: monkey_patch）→ `validate_required_env()`（缺項一次列全 raise ConfigError）→ `create_app()` → `REGISTERED_APPS` 逐一 import（RuntimeError fail-fast）。

**manifest 驗證最自然的位置：`validate_required_env()` 之後、`create_app()` 之前**——同層級、同 fail-fast 語氣、不依賴 DB/DI。

**scheduler**（`core/scheduler.py init_scheduler`）：api 模式現有 4 jobs（drive_sync 5s／webhook_renewer 6h／framework_cleanup cron／license_expiry cron），既有 pattern＝app_context → DI container → session_scope。**完整性抽查 job 不需 DB/DI，可省 container**。新增 job 要同步 L173 啟動摘要字串與 `main.py` 檔頭模式裁剪表。

### 2.5 可復用 vs 缺口總表

| 面向 | 可復用 | 缺口 |
|------|--------|------|
| 簽章／驗章 | Ed25519 canonical JSON、kid、v2/v3 信封、`verify_license` 純函式骨架 | 型別綁死 License 18 key → 抽 `sign_payload`/`verify_payload` 泛用層（D2） |
| 公鑰配送 | `public_keys.py` 硬編源碼慣例（隨 binary 編譯） | 無——直接沿用（D3） |
| 機器指紋 | `compute_machine_fingerprint()` 現成 | 無 |
| 一次性碼 | LC activation_code 三件套模式 | 線上模型 → 改離線簽章信封（D6） |
| 執法語氣 | fail-closed、log 留證慣例 | 啟動掛點不存在；tamper 表不存在（D3、D5） |
| build 管線 | build_release.sh 四步結構、斷言機制 | manifest 產生步驟；排除規則與 rsync 對齊 |
| scheduler | init_scheduler 掛 job 機制 | 抽查 job 本體（D8） |
| 檔案系統防線 | entrypoint chown 機制已存在 | 產物 owner＝執行者，需唯讀化（D4） |

## 端到端流程 {#flow nav="端到端流程"}

全鏈：build 簽章 → 啟動驗證 → runtime 抽查 → tamper 鎖定 → unlock 解鎖。

```{.mermaid cap="圖 1 — 防竄改端到端時序（build → 啟動 → runtime → 鎖定 → 解鎖）"}
%%{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
    participant BLD as 原廠 build 管線
    participant LC as License Center（私鑰）
    participant APP as 產品 binary（客戶端）
    participant SCH as APScheduler 抽查 job
    participant ST as tamper 落點（DB＋FS）

    Note over BLD,LC: Build 期（原廠側）
    BLD->>BLD: Step 4 尾端：dist 定案後逐檔 SHA-256 → manifest
    BLD->>LC: sign_payload(manifest, type=manifest)
    LC-->>BLD: 簽章信封（kid＋signature）
    BLD->>BLD: manifest＋信封放入 dist → build_image → Harbor

    Note over APP,ST: 啟動期（客戶端，每次啟動）
    APP->>ST: 查 tamper 紀錄（DB＋FS 任一存在？）
    alt 已有 tamper 紀錄
        ST-->>APP: 存在
        APP->>APP: 視同無 license → 拒啟 exit
    else 無紀錄
        APP->>APP: verify_payload(manifest 信封)（硬編公鑰，無 DB 依賴）
        APP->>APP: 逐檔比對 ①業務＋②資料檔（D1 分層）
        alt hash 不符或驗章失敗
            APP->>ST: 寫入 tamper 事件（雙落點）
            APP->>APP: 立即 exit
        else 全部通過
            APP->>APP: create_app() → 正常服務
        end
    end

    Note over SCH,ST: Runtime（低頻抽查）
    loop 每 45min＋jitter（D8）
        SCH->>SCH: 隨機 N（③第三方）＋固定核心（①②全量）重算 hash
        alt 單檔不符
            SCH->>ST: 寫入 tamper 事件（雙落點）
            SCH->>APP: 立即 exit（終結服務）
        end
    end

    Note over LC,APP: 解鎖（唯一途徑，離線成立）
    APP-->>LC: 客戶回報 machine_fingerprint＋tamper_event_id（電話／郵件等人工通道）
    LC->>LC: 簽發 unlock token｛type:unlock, fingerprint, event_id, nonce, expires_at｝
    LC-->>APP: token 檔交付（離線可攜）
    APP->>APP: verify_payload(token)＋比對 fingerprint＋查本地已用 nonce
    APP->>ST: 清除 tamper 紀錄＋記錄 nonce 已用（防重放）
    APP->>APP: 下次啟動恢復正常驗證流程
```

## 架構：LC 簽發端 vs 產品端 {#arch nav="架構"}

```{.mermaid cap="圖 2 — 元件架構：原廠側（LC＋build）與客戶側（產品 binary）"}
%%{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 LR
  subgraph FACTORY["原廠側"]
    direction TB
    subgraph LCBOX["License Center（獨立 repo／DB）"]
      SP["sign_payload 泛用簽章層（D2 新抽）<br/>type: license / manifest / unlock_token"]
      PK["Ed25519 私鑰<br/>PKCS8＋passphrase"]
      UT["unlock token 簽發<br/>CLI 或後台頁（D6）"]
      SP --- PK
      UT --> SP
    end
    subgraph BUILDBOX["build 管線（build_release.sh）"]
      MF["Step 4 尾端：<br/>逐檔 SHA-256 → manifest<br/>排除規則＝rsync 排除"]
      MF -->|"送簽"| SP
      IMG["build_image → Harbor<br/>manifest＋信封入 dist"]
      MF --> IMG
    end
  end

  subgraph CLIENT["客戶側（產品 binary，可完全離線）"]
    direction TB
    VP["verify_payload 泛用驗章層（D2）<br/>公鑰硬編 public_keys.py（D3）"]
    BOOT["啟動 gate（main.py）<br/>env 驗證後、create_app 前<br/>①②層全驗（D1）"]
    RT["APScheduler 抽查 job（D8）<br/>隨機 N＋固定核心<br/>api／socketio 兩模式都跑"]
    TS["tamper 落點（D5）<br/>DB 全域單例表＋FS volume 檔"]
    RO["/app 唯讀化（D4）<br/>chown root:root＋--read-only"]
    UV["unlock 驗證（D6）<br/>fingerprint 比對＋nonce 防重放"]
    BOOT --> VP
    RT --> VP
    BOOT -->|"偵測竄改"| TS
    RT -->|"偵測竄改"| TS
    TS -->|"存在即拒啟"| BOOT
    UV -->|"清除紀錄"| TS
    UV --> VP
  end

  IMG -.->|"image 出貨"| CLIENT
  UT -.->|"token 檔離線交付"| UV
```

**共用一把鑰匙、一條 code path**：圖中 `sign_payload` / `verify_payload` 是 D2 要抽的泛用層——license、manifest、unlock token 三種 payload 走同一簽驗實作、同一把公鑰。拆掉驗證的人等於同時拆掉 license，「拆一個等於拆兩個」。

## 資料模型草案 {#datamodel nav="資料模型"}

以下為草案形狀，實作前隨 D1–D8 拍板調整。

### 5.1 manifest 格式（dist 內兩個檔）

```json
// integrity-manifest.json（信封 payload 解開後的形狀）
{
  "type": "manifest",
  "product": "guidant-ai-be",
  "version": "1.15.0",
  "built_at": "2026-08-20T00:00:00Z",
  "layers": {
    "core":      {"files": {"guidant-ai-be": "sha256hex", "app/....so": "..."}},
    "resources": {"files": {"config/translations/....mo": "..."}},
    "thirdparty": {"files": {"pandas/....py": "..."}}
  }
}
```

- 信封本身照 FR-062 v2/v3 格式（kid＋zlib+b64 payload＋signature），另存一檔（如 `integrity-manifest.sig`）。
- **manifest 與簽章檔自身不入清單**（驗不了自己，見 D7）；④ volume 路徑一律排除。
- `layers` 分層是為了 D1：啟動 gate 只吃 `core`＋`resources`，抽查 job 吃全部。

### 5.2 tamper 落點

**DB**：新全域單例語意表（比照 `license_clock_watermark` 形狀——`public` schema、**不掛 RLS**、`cm_app` 可寫）：

```sql
-- 草案，非最終 DDL
CREATE TABLE public.integrity_tamper_events (
    id            BIGSERIAL PRIMARY KEY,
    event_uid     VARCHAR(64) NOT NULL UNIQUE,   -- unlock token 綁定用
    detected_at   TIMESTAMPTZ NOT NULL,
    detected_by   VARCHAR(16) NOT NULL,          -- boot | scheduler
    machine_fingerprint VARCHAR(128) NOT NULL,
    detail        JSONB NOT NULL,                -- 檔案路徑、預期/實際 hash、驗章 reason
    unlocked_at   TIMESTAMPTZ,                   -- unlock 成功時間；NULL＝仍鎖定
    unlock_nonce  VARCHAR(64)                    -- 已用 nonce 記錄（防重放）
);
```

**檔案系統**：持久 volume 下的標記檔（候選 `/opt/guidant/pki/` 下，D5 拍板），內容含 event_uid＋detected_at＋detail 摘要——DB 被還原時 FS 仍在，FS 被刪時 DB 仍在，兩者**任一存在即拒啟**。

### 5.3 unlock token payload

```json
{
  "type": "unlock_token",
  "machine_fingerprint": "…",     // 必須與被鎖機器一致
  "tamper_event_id": "event_uid", // 綁定特定事件
  "nonce": "隨機 64 hex",          // 一次性，用過即記錄
  "issued_at": "…",
  "expires_at": "…"               // 短效期（如 72h）
}
```

同樣走 v2/v3 信封＋LC 私鑰簽章；產品端 `verify_payload` 驗章 → 比對 fingerprint → 查本地已用 nonce → 三關全過才清除 tamper 紀錄。

## 階段拆分草案 {#phases nav="階段拆分"}

子需求雛形（實際開卡與順序待 D1–D8 拍板後定案）：

| 子需求 | 內容 | Repo | 依賴 |
|--------|------|------|------|
| FR-064.1 簽驗共用層 | 抽 `sign_payload`/`verify_payload` 泛用層（payload 帶 type）；License 既有簽發／驗證改走泛用層＋迴歸驗證 | LC＋BE | 無（是其他全部的前提，D2） |
| FR-064.2 build 期 manifest | build_release.sh Step 4 尾端產 manifest＋送簽；排除規則與 rsync 對齊；manifest＋信封入 image context | BE（build scripts）＋LC | 064.1 |
| FR-064.3 啟動驗證 gate | main.py env 驗證後掛驗章＋①②層逐檔比對；tamper 紀錄檢查（存在即拒啟）；tamper 表 migration＋FS 落點 | BE | 064.1、064.2 |
| FR-064.4 runtime 抽查 | APScheduler job（隨機 N＋固定核心）；api/socketio 兩模式；偵測即雙落點＋exit | BE | 064.3 |
| FR-064.5 unlock 流程 | LC 端簽發功能（CLI 或後台頁，D6）；產品端驗 token＋防重放＋清紀錄 | LC＋BE | 064.1、064.3 |
| FR-064.6 /app 唯讀化 | entrypoint chown root:root /app；驗證 runtime 無寫產物行為；部署文件補 --read-only 建議 | BE（Docker）| 可平行（D4） |
| FR-064.7 E2E 驗收 | 竄改一檔→啟動拒啟／runtime 抽中→exit→拒啟→unlock 復活 全鏈實測；License 既有流程迴歸 | test repo＋實機 | 全部 |

## 待決策 D1–D8 {#decisions nav="待決策"}

::: {.callout .pending}
**D1 保護範圍分層——啟動全驗①②，③第三方只走 runtime 抽查？**

產物四層（§2.3）：①業務（數百檔）②隨版資料（數百～千餘檔）③第三方（2–4 萬檔）④volume（必排除）。方案：

- **A（建議）分層**：啟動 gate 全驗①②（秒級可接受）；③全部納 manifest 但只由 runtime 抽查覆蓋。
- B 全量啟動驗：最嚴，但啟動多花十數秒 I/O，且每次重啟都付。
- C 只驗業務①：最快，但資料檔（模板／canon json）被改會影響產出正確性而完全無感。

[**建議**：採 A。全驗第三方是把啟動拖慢十數秒換不到等值防護——第三方 .py 被竄改的攻擊價值低於業務碼，交給抽查長期覆蓋即可。]{.rec}
:::

::: {.callout .pending}
**D2 簽驗共用層抽象——抽 sign_payload / verify_payload 泛用層？**

現況 `sign_license()`/`verify_license()` 型別綁死 License 18 個商務 key（§2.2 缺口一）。抽泛用層 `sign_payload(dict)→envelope` / `verify_payload(envelope)→dict`，payload 帶 `type` 欄位區分 license / manifest / unlock_token；LC 與 BE 兩端同步改，License 既有路徑做迴歸驗證。

風險：動 LC 端屬跨 repo 異動；License 既有簽發／驗證需完整迴歸。

[**建議**：必做——這是「共用同一 code path 同一把公鑰」的實作前提，也是「拆一個等於拆兩個」防護敘事的基礎。不抽就是第二套簽驗實作，違反不重複造輪子鐵則。]{.rec}
:::

::: {.callout .pending}
**D3 啟動驗證錨點——main.py env 驗證後、create_app 前？**

驗證放 `validate_required_env()` 之後、`create_app()` 之前（§2.4）：同層級、同 fail-fast 語氣、**無 DB 依賴**（有別於 verify_license 要 clock_watermark）。公鑰沿用 `public_keys.py` 硬編慣例、烤進 binary。

[**建議**：採此錨點。技術上可以更早（load_dotenv 前），但更早驗不到什麼多的東西、還要放棄 env 設定能力，收益低。]{.rec}
:::

::: {.callout .pending}
**D4 /app 唯讀化——entrypoint 改 chown root:root？**

現況 `/app` owner＝執行者（uid 1000），非 root 進程對產物可寫（§2.3 威脅前提）。方案：entrypoint 改 chown root:root /app（產物歸 root、進程跑 1000 → 檔案系統層寫不了），部署文件加 `--read-only` 建議。

前置驗證：需確認 Nuitka binary 與附帶套件 runtime **無寫產物目錄的行為**——③層有 .py，若有直譯器附帶場景要確認 `__pycache__` 寫入；有寫入需求則此案要調整。

[**建議**：做——最便宜的第一道防線，與驗證互補（唯讀化擋事前、驗證抓事後）。先跑一輪完整功能冒煙確認無寫入行為再上。]{.rec}
:::

::: {.callout .pending}
**D5 tamper 落點與抹除成本——雙落點；「可洗掉」接不接受？**

DB＝新全域單例表（比照 license_clock_watermark：不掛 RLS、cm_app 可寫，§5.2）；FS 落點候選 `/opt/guidant/pki`（已是持久掛載、語意相近）——是否再藏第二份 FS 落點待議。

已知限制：**容器重建＋DB 還原可以洗掉全部紀錄**。在「拉高成本＋留痕跡」定位下這是否接受？或加碼（如 tamper 時把事件指紋織進下次 license 驗證流程，讓「洗紀錄」也留下可比對的斷點）？

[**建議**：雙落點照母案卡做，接受「可洗掉」的邊界並明文寫入已知限制——會做到「重建容器＋還原 DB」的人已屬蓄意，蓄意本身在 B2B 合約場景就是求償事由；加碼方案留作後續強化選項不進本案。]{.rec}
:::

::: {.callout .pending}
**D6 unlock token 離線信封——LC 端簽發介面做 CLI 還是後台頁？**

token 形狀已定（§5.3）：LC 簽 `{type:unlock, machine_fingerprint, tamper_event_id, nonce, expires_at}`，產品端離線驗章＋比對 fingerprint＋本地已用 nonce 防重放。LC activation_code 的線上三件套模式不適用（被鎖機器可能連不到 LC），故改離線信封。

待選：LC 端簽發功能的形式——**CLI 就好**（原廠內部人員操作，量極少）vs **後台頁**（LC 已有後台，加一頁）。

[**建議**：採離線信封；簽發介面 CLI 起步即可（tamper 解鎖是極低頻、原廠內部操作，後台頁等有實際頻率再補）。]{.rec}
:::

::: {.callout .pending}
**D7 驗證邊界承認——binary 外的東西不驗？**

manifest＋簽章檔放 dist 內、**自身不入清單**（驗不了自己）；entrypoint、Dockerfile、image 層**不在 client 端驗證範圍**——binary 外的東西沒有可信錨點去驗。防線改由三件事承擔：D4 唯讀化＋驗證邏輯本身是機器碼＋檢查點分散互驗。

[**建議**：承認邊界並明文寫入已知限制。image 層簽章驗證（cosign／digest pinning）屬 FR-065 installer 的範圍，不在本案硬撐。]{.rec}
:::

::: {.callout .pending}
**D8 抽查參數——寫死還是可設定？**

預設建議：interval **45min＋jitter**；每輪隨機 **N=200**（③第三方層）＋**固定核心清單**（binary 本體＋全部業務 .so＋資料檔，即①②全量）；**api／socketio 兩模式都跑**（socketio 也是攻擊面）；判定＝**單檔 hash 不符即觸發 tamper**（不設寬容度）。

待選：參數全部可 env 覆寫，還是寫死在 binary？

[**建議**：寫死——「可設定」等於「可關閉」（env 是客戶可控面，interval 設成 10 年就是實質關閉）。要調參數走發版。]{.rec}
:::
