---
title: "防竄改偵測 — 設計文件 (FR-064)"
brand: "Guidant AI · **FR-064** 防竄改偵測"
eyebrow: "FR-064 · Tamper Detection — 設計定案 · 2026-08-15"
h1: "程式被動過，就終結服務、鎖定產品"
lede: "設計定案版：D1–D10 全數拍板（2026-08-15），D11 補充定案（2026-08-16）。Build 期逐檔 SHA-256 manifest 以原廠私鑰簽章（復用 FR-062 LC 簽驗體系，抽泛用 `sign_payload`/`verify_payload` 共用層）；啟動 gate 分層驗證（①業務＋②資料檔全驗，③第三方交 runtime 抽查）；偵測竄改 → tamper 雙落點＋起 lockdown 殼鎖定服務（D11，取代純 exit），之後每次啟動視同無 license 拒啟；唯一解鎖＝原廠一次性 unlock token（離線信封，air-gapped 成立）。加碼：/app 唯讀化（D4）、在線 best-effort 回報 LC（D9）、抽查二次確認防毛刺（D10）、forensic 存證擴充與主機層審計邊界明文（D11）。"
chips: [
  {text: "設計定案 D1–D10", kind: ok},
  {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 設計定案（D1–D10）· 前作：FR-063（Nuitka 編譯）· 後續：FR-065（Installer）· 討論稿：discussion.html"
---

> 狀態：**設計定案（D1–D10 已拍板）**｜建立日期：2026-08-15｜FR-063 Nuitka 編譯續作（三階段落地版第二棒）
> 討論稿（含現況盤點全文）：[`discussion.html`](./discussion.html)｜Notion 母案 CM-1188

## 變更紀錄 {#changelog nav="變更紀錄"}

| 日期 | 變更 | 對應 |
|------|------|------|
| 2026-08-15 | 初版設計定案：討論稿 D1–D8 拍板（D1 採分層 A；D8 抽查週期由原提案 45min 調整為 **4h＋jitter 0–60min**，並加業務路徑輕量埋點）；新增 D9（在線 best-effort 回報 LC）、D10（抽查二次確認）兩項決策一併定案 | FR-064 母案 CM-1188 |
| 2026-08-16 | 新增 **D11**：鎖定溝通改採 lockdown 殼（B 方案，取代純 exit）＋forensic 存證 snapshot 擴充＋「是誰改的」邊界明文與契約措辭建議；§4.4 全段改寫；§5 新增 FR-064.8 段（T-8.1–T-8.4，本卡 CM-1234 為 T-8.4） | CM-1234 |

---

## 1. 需求背景與端到端流程 {#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——客戶端安裝／升級體驗；image 層簽章驗證（cosign／digest）劃歸此棒 | 未開 |

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

### 1.2 需求一句話

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

四個動詞：**偵測**（build 期簽章 manifest ＋ 啟動驗證 ＋ runtime 抽查 ＋ 業務埋點）、**終結**（偵測即 exit，不降級不告警後續跑）、**鎖定**（tamper 雙落點，之後啟動視同無 license 拒啟）、**解鎖**（原廠一次性 unlock token，離線信封）。

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

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

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

- **99% 的人覺得不值得**：拆解需同時處理 binary 內驗證邏輯、分散檢查點、簽章公鑰。
- **1% 蓄意者留下痕跡**：tamper 紀錄與被替換檔案是合約求償證據；本產品是 **B2B 具名法人**場景，**法律層才是真實防線**。
- **離線環境必須成立**：客戶環境可能完全 air-gapped，所有偵測、鎖定、解鎖必須在無網路下自洽（D9 在線回報是 best-effort 加分項，不是依賴）。
:::

**業界對照定位**：Linux 生態沒有 OS 級 codesign 強制執行；容器層 image 簽章（cosign／digest pinning）明確劃歸 FR-065 installer。本案＝**應用層 self-integrity check**，是 B2B 落地軟體的標準做法；縱深防禦 = 部署時驗 image（FR-065）＋ 運行時 self-check（本案）。

### 1.4 端到端流程

全鏈：build 簽章 → 啟動驗證 → runtime 抽查＋業務埋點 → tamper 鎖定（＋best-effort 回報 LC）→ 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->>LC: best-effort 回報事件（D9，發不出不重試）
            APP->>APP: 立即 exit
        else 全部通過
            APP->>APP: create_app() → 正常服務
        end
    end

    Note over SCH,ST: Runtime（低頻抽查＋業務埋點）
    loop 每 4h＋jitter 0–60min（D8，每輪重抽）
        SCH->>SCH: 固定核心（①②全量）＋隨機 N 個③層檔重算 hash
        alt 單檔不符
            SCH->>SCH: 立即複算同檔一次（D10 二次確認）
            alt 兩次皆不符
                SCH->>ST: 寫入 tamper 事件（雙落點）
                SCH->>LC: best-effort 回報事件（D9）
                SCH->>APP: 立即 exit（終結服務）
            end
        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＋核對 event_id＋查本地已用 nonce
    APP->>ST: 清除 tamper 紀錄＋記錄 nonce 已用（防重放）
    APP->>APP: 下次啟動恢復正常驗證流程
```

### 1.5 元件架構

```{.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 簽發（D6）<br/>傾向後台頁（62.10 internal API 慣例）"]
      RCV["tamper 事件接收端點（D9 新增）<br/>62.10 internal API 形狀"]
      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/>4h＋jitter；固定核心＋隨機 N<br/>api／socketio 兩模式都跑"]
    HP["業務埋點 2–3 處（D8）<br/>license 匯入／報告匯出順驗核心小集合"]
    TS["tamper 落點（D5）<br/>DB 全域單例表＋FS volume 檔"]
    RO["/app 唯讀化（D4）<br/>chown root:root＋--read-only"]
    UV["unlock 驗證（D6）<br/>fingerprint＋event_id＋nonce 防重放"]
    BOOT --> VP
    RT --> VP
    HP --> VP
    BOOT -->|"偵測竄改"| TS
    RT -->|"D10 二次確認後"| TS
    HP -->|"偵測竄改"| TS
    TS -->|"存在即拒啟"| BOOT
    UV -->|"清除紀錄"| TS
    UV --> VP
  end

  IMG -.->|"image 出貨"| CLIENT
  UT -.->|"token 檔離線交付"| UV
  TS -.->|"best-effort 回報（D9）"| RCV
```

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

---

## 2. 決策定案（D1–D10） {#decisions nav="決策定案"}

2026-08-15 決策者全數拍板。每項含定案內容、理由、被排除方案。

| # | 決策 | 定案 |
|---|------|------|
| **D1** | 保護範圍分層 | **採 A 分層驗證**。啟動 gate 全驗①業務 .so＋②隨版資料檔（數百～千餘檔，秒級可接受）；③第三方 2–4 萬檔**全部納 manifest** 但只由 runtime 抽查覆蓋；④volume 掛載**絕不入清單**（客戶正常寫入面，是唯一系統性誤報源）。**排除 B** 全量啟動驗——啟動多付十數秒 I/O 換不到等值防護（第三方 .py 攻擊價值低於業務碼）；**排除 C** 只驗業務——資料檔（模板／canon json）被改會影響產出正確性而完全無感。 |
| **D2** | 簽驗共用層 | **准**。LC＋BE 兩端抽泛用 `sign_payload(dict)→envelope` / `verify_payload(envelope)→dict`，payload 帶 `type` 欄（license / manifest / unlock）；三種憑證共用同一引擎、同一把公鑰——這是「拆一個等於拆兩個」防護敘事的實作前提，不抽就是第二套簽驗實作（違反不重複造輪子鐵則）。**約束**：license 既有簽發／驗證必須跑完整迴歸；已出貨 license 檔格式**向後相容**（v2 信封／v3 armor 不變）。 |
| **D3** | 啟動驗證錨點 | **main.py `validate_required_env()` 之後、`create_app()` 之前**。同層級、同 fail-fast 語氣（一次列全、raise 終止）；公鑰沿用 `common/license/public_keys.py` 硬編慣例（隨 binary 編譯）；驗證**無 DB／DI 依賴**——tamper 歷史檢查此時讀**檔案落點**，DB 落點起服務後補寫。排除更早錨點（load_dotenv 前）——驗不到更多東西還放棄 env 設定能力，收益低。 |
| **D4** | /app 唯讀化 | **做**。entrypoint 改產物歸 `root:root`、進程跑 uid 1000——檔案系統層直接寫不進（現況 `/app` owner＝執行者本人，竄改門檻是零）；部署文件建議加 docker `--read-only`。**實作前置驗證**：確認 Nuitka binary 與③層附帶套件 runtime **無寫產物目錄行為**（③層有 .py，`__pycache__` 寫入疑慮）——若發現寫入需求需回報調整方案，不硬上。 |
| **D5** | tamper 雙落點 | **DB＋檔案系統雙落點**。DB＝新全域單例表（比照 `license_clock_watermark` 形狀：`public` schema、**不掛 RLS**、`cm_app` 可寫——tamper 是機器級不是租戶級）；FS＝持久 volume（`/opt/guidant/pki` 或同級）標記檔。任一存在即拒啟。**已知限制明文寫入**：容器重建＋DB 還原可洗掉本地紀錄——在「拉高成本＋留痕跡」定位下接受（會做到這步的人已屬蓄意，蓄意本身在 B2B 合約場景就是求償事由）；且 **D9 在線回報使有網環境的紀錄存於 LC 端不可洗**，補上這個洞。 |
| **D6** | unlock token 離線信封 | **LC 私鑰簽 `{type:unlock, machine_fingerprint, tamper_event_id, nonce, expires_at(短效)}`**。產品端離線驗章＋比對本機指紋（`compute_machine_fingerprint()` 現成）＋核對 tamper_event_id＋本地記已用 nonce 防重放；**一次性、不可跨機**。誤鎖與真竄改走同一路徑（原廠人工研判後簽發）。排除 LC activation_code 線上三件套模式——被鎖機器可能連不到 LC，必須離線成立。LC 端簽發形式（CLI vs 後台頁）**留實作棒依 LC 既有形狀選，傾向後台頁**沿用 62.10 internal API 慣例。 |
| **D7** | 驗證邊界（承認） | manifest＋簽章檔放 dist 內、**自身不入清單**（驗不了自己）；entrypoint／Dockerfile／image 層**不在 client 端驗證範圍**——binary 外的東西沒有可信錨點。防線＝D4 唯讀化＋驗證邏輯本身是機器碼＋檢查點分散互驗。**image 簽章驗證（cosign／digest pinning）明確劃歸 FR-065 installer**，不在本案硬撐。 |
| **D8** | 抽查參數 | **週期＝4 小時＋每輪隨機 jitter 0–60 分**（每輪重抽亂數，防節奏可預測）；每輪內容＝固定核心（①②全量）＋隨機 N 個③層檔；**業務路徑輕量埋點 2–3 處**（如 license 匯入、報告匯出時順驗核心小集合，毫秒級——把檢查時機綁使用者行為而非時鐘）；api／socketio 兩模式都跑；**參數寫死不開放設定**（可設定＝可關閉，env 是客戶可控面）。**排除原提案 45min**——過頻：抽查是事後抓不是即時擋，時效差數小時防護價值幾乎無差；「改完改回」型攻擊本就靠 D4＋啟動 gate 抓，不是抽查的任務。 |
| **D9** | 在線 best-effort 回報 LC（新增） | **做**。偵測 tamper 時嘗試把事件（machine_fingerprint／事件ID／檔案／時間／hash 差異）回報 LC 留存——**發得出就發、發不出不重試、不阻塞、不影響鎖定流程**；LC 加接收端點（走 62.10 internal API 形狀）。價值：在線環境的 tamper 紀錄存於客戶碰不到的 LC 端，補 D5「本地紀錄可洗掉」的洞。 |
| **D10** | 抽查二次確認（新增） | **做**。runtime 抽查發現 hash 不符 → **立即複算同檔一次，兩次皆不符才判 tamper**（排除 I/O 瞬間毛刺造成誤鎖）；啟動 gate 不需此緩衝（啟動期無併發 I/O 干擾，且拒啟成本遠低於運行中誤殺）。 |
| **D11** | 鎖定溝通機制＋forensic 證據規格（新增，2026-08-16） | **採 B 方案：lockdown 殼**。gate 判定拒啟時**不直接 exit**，改起 stdlib 極簡 HTTP responder（無 Flask／DI／DB，零業務功能、零寫入面）——任何請求一律回鎖定資訊 HTML＋JSON（`error_code=INTEGRITY_503001`，`data` 帶 `machine_fingerprint`／`tamper_event_id`／`detected_at`）；FE 認得該 error_code 即整站導向鎖定頁。unlock 核銷**不走殼**，維持既有 FS token 檔核銷流程（D6）。同時定案：**forensic 證據規格擴充**——tamper 事件不符檔案各帶 `mtime`／`ctime`／`owner`／`size`（mtime 是最接近竄改行為時間點的證據）；觸發脈絡依偵測來源分級 best-effort 蒐集：業務埋點→當下登入帳號＋來源 IP；runtime 抽查→活躍 session 快照（帳號／IP／最後活動時間）；boot gate→僅檔案 metadata＋`app.log` 尾段（此時無使用者 session 可查）；三落點（FS 標記檔／DB `integrity_tamper_events`／D9 回報 LC）同步吃擴充欄位。**邊界明文**：「是誰改了檔案」在應用層不可知——竄改動作走 SSH／`docker exec` 等主機層管道，不經過產品業務邏輯，產品拿不到操作者身分；forensic 擴充蒐集的是「檔案／時間／（若有）業務 session」證據，不是「竄改者是誰」的答案。**理由**：web 產品純 exit 時使用者只見連線失敗（timeout／connection refused），會被歸因為產品品質問題而非安全事件；lockdown 頁正式呈現 `tamper_event_id` 給客戶，同時滿足「留痕跡」與「B2B 法律敘事」——契約條款應綁「產品完整性驗證失敗即構成違約事由」，而非「查明竄改行為人」，前者產品證據鏈自足可主張，後者舉證做不到。**排除方案**：**A（純 exit，原 4.4 版本）**——使用者體感等同故障，無法承載留痕跡與法律敘事的定位；**C（半套：僅寫 log 不起殼，靠人工事後翻 log 告知客戶）**——鎖定當下客戶端零可見資訊，錯失「當場出示 event_id」的證據呈現時機，且仰賴原廠事後人工介入才看得到，不符「產品自足證據鏈」目標。 |

---

## 3. 現況接入點盤點（可復用 vs 缺口） {#inventory nav="現況盤點"}

以下事實皆經實碼／實測驗證（詳見討論稿 §2），是本設計的基礎。

### 3.1 FR-062 簽驗體系（復用底座）

| 元件 | 座標 | 現況 → FR-064 動作 |
|------|------|------|
| 簽章演算法 | Ed25519，canonical JSON bytes（`sort_keys`＋緊湊分隔）；LC `src/license_center/core/crypto/engine.py:56 sign_license()`、BE `common/license/engine.py:90` 逐位元組一致 | 沿用，抽泛用層（D2） |
| kid | 公鑰 SHA-256 hex 前 16 chars（`keys.py:21 compute_kid()`） | 沿用；跨版本共用公鑰，kid 輪替機制既有 |
| 私鑰 | PKCS8 PEM＋passphrase（env `LICENSE_CENTER_KEY_PASSPHRASE`；air-gapped 回退表單輸入）；LC 端持有，產品端永不落地 | 沿用 |
| 信封格式 | v2 信封（kid／payload zlib+b64／signature）＋v3 armor，兩端互通 | 沿用；license 檔格式向後相容（D2 約束） |
| 產品端公鑰 | `common/license/public_keys.py` 硬編源碼（現三把 DEV/STG/POC），隨 binary 編譯 | 沿用（D3） |
| 驗章核心 | `common/license/engine.py:138 verify_license()` 純函式，但型別綁死 License 18 商務 key | **缺口**：抽 `sign_payload`/`verify_payload` 泛用層，LC＋BE 兩端都動（D2） |
| 機器指紋 | `common/license/machine_fingerprint.py compute_machine_fingerprint()` | 現成，unlock token 直接用（D6） |
| 一次性碼借鏡 | LC activation_code 三件套（一次性／`activated_at` 判已用／重用 409） | 模式可抄，但它是線上模型——unlock 改離線信封（D6） |
| 執法慣例 | fail-closed；總開關 `LICENSE_ENFORCEMENT_ENABLED`；驗失敗 log 留證 | 沿用同語氣；「表格化留證」當時註記留給後續階段——**FR-064 正是該階段** |

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

**build 管線**：`scripts/build/build_release.sh` 四步（deps → resources → freeze → compile＋斷言）。**manifest 產生插入點：Step 4 尾端、全部斷言通過之後、進 `build_image` 之前**——dist 定案的時刻。

::: {.callout .crit}
**🔴 manifest 排除規則必須與 build_image.sh 的 rsync 排除一致**

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

**產物四層**（直接決定 D1 分層）：

| 層 | 內容 | 規模 | 竄改攻擊價值 | 驗證覆蓋 |
|----|------|------|--------------|----------|
| ① 業務資產 | binary 本體＋Nuitka .so（零 .py） | 數百檔 | 最高 | 啟動 gate 全驗＋抽查固定核心＋業務埋點 |
| ② 隨版資料檔 | translations .mo、`ssp_cmmc_template.docx`、cmmc canon json ×2、`resources/download` 5 檔、report templates html | 數百～千餘檔 | 中 | 啟動 gate 全驗＋抽查固定核心 |
| ③ 第三方原樣附帶 | EXCLUDE_COMPILE_PKGS 11 支＋相依封閉集合＋dist-info＋.libs | 2–4 萬檔，.py 佔大宗 | 低（但最易下手） | 全納 manifest，runtime 隨機抽查長期覆蓋 |
| ④ volume | `/app/static` `/app/log` `/home/guidant` `/opt/guidant/pki` `/tmp` | 動態 | — | **絕不入清單**（唯一系統性誤報源） |

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

- **啟動序**（`main.py`）：`load_dotenv` → `RUN_MODE` 解析 → `validate_required_env()`（缺項一次列全 raise）→ `create_app()` → `REGISTERED_APPS` 逐一 import。啟動 gate 掛 `validate_required_env()` 之後、`create_app()` 之前（D3）。
- **scheduler**（`core/scheduler.py init_scheduler`）：api 模式現有 4 jobs，既有 pattern＝app_context → DI container → session_scope。**完整性抽查 job 是唯一不需 DB／DI 的 job，可省 container**。新增 job 要同步 L173 啟動摘要字串與 `main.py` 檔頭模式裁剪表。
- **檔案系統防線**：現況 Dockerfile `COPY --chown=1000:1000`、entrypoint root 起 chown 四掛載點後 `setpriv` 降 1000——`/app` owner＝執行者本人，**產物可寫、竄改門檻為零**。這是 D4 的直接動機（唯讀化擋事前、驗證抓事後，互補）。

---

## 4. 詳細設計 {#design nav="詳細設計"}

### 4.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 吃全部。
- **簽章不設效期**——manifest 跟產物版本走：新版新 image 新 manifest，跨版本只共用公鑰（kid 輪替機制既有）。

### 4.2 tamper 落點

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

```sql
-- 草案形狀，最終 DDL 依 sql-migration skill 落地
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 | hook
    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` 或同級）下的標記檔，內容含 event_uid＋detected_at＋detail 摘要。**任一存在即拒啟**——DB 被還原時 FS 仍在，FS 被刪時 DB 仍在。

**寫入時序**（配合 D3 無 DB 依賴）：啟動 gate 偵測竄改時先寫 FS 落點再 exit；DB 落點由「下次任何成功起服務的路徑」不存在——故 boot 偵測的事件以 FS 為主落點，scheduler／埋點偵測（服務已起、DB 可用）雙落點同時寫。啟動期 tamper 歷史檢查讀 FS；服務起來後（scheduler 首輪）將 FS 紀錄補同步進 DB。

**已知限制（明文承認，D5）**：容器重建＋DB 還原可洗掉本地紀錄。「拉高成本留痕跡」定位下接受；D9 在線回報使有網環境的紀錄存於 LC 端不可洗。

### 4.3 unlock token（D6）

```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 → 核對 tamper_event_id → 查本地已用 nonce——全過才清除 tamper 紀錄（FS＋DB）並記錄 nonce 已用。誤鎖與真竄改走同一路徑（原廠人工研判後簽發）。LC 端簽發介面留實作棒依 LC 既有形狀選，傾向後台頁（62.10 internal API 慣例）。

### 4.4 鎖定與使用者溝通（D11，2026-08-16 改版）

#### 4.4.1 為何從純 exit 改成 lockdown 殼

原設計（D3/D5）偵測竄改後直接 `exit`——容器停掉、連線失敗。問題在於 **web 產品的使用者看不到「服務被鎖定」這件事，只看到打不開**：瀏覽器顯示 timeout／connection refused，這在使用者認知裡與「產品當機」「網路問題」「伺服器爆掉」無法區分。對 B2B 客戶而言，這會被歸因為**產品品質問題**，而非「偵測到竄改，服務依約定停止」的安全事件——恰好抹掉了本案要留的痕跡與法律敘事。

**D11 拍板：改採 B 方案——lockdown 殼**。gate（或 runtime 抽查／業務埋點）判定拒啟時，**不直接 exit**，改**啟動一個 stdlib 極簡 HTTP responder** 取代正常服務：

- 用 Python 標準庫（如 `http.server`）起，**不掛 Flask、不接 DI container、不連 DB**——零業務功能、零寫入面，能力面等於「回一頁靜態內容」。
- 監聽原本業務服務的 port，**任何路徑、任何方法的請求一律回同一份鎖定資訊**：HTML（給瀏覽器直接看的頁面）＋JSON（給 FE SPA 呼叫 API 判斷用）。
- JSON 回應固定 envelope：`error_code = INTEGRITY_503001`，`data` 帶 `machine_fingerprint`／`tamper_event_id`／`detected_at` 三欄——與正常 API error envelope 同形狀，FE 可用既有錯誤處理管線接。
- **FE 認到 `INTEGRITY_503001` 即整站跳轉鎖定頁**（不是個別 API 呼叫顯示錯誤 toast，是整站導頁——因為此時背後根本沒有真正的應用服務可用）。
- **unlock 核銷不走這個殼**——殼是純唯讀展示層，沒有寫入面；unlock 仍是 D6 既定流程：原廠簽發 token 檔 → 產品端**下次啟動**時（走正常 boot 流程，不是連到殼上操作）核銷四關 → 清除 tamper 紀錄 → 恢復正常服務。殼只負責「鎖定期間讓使用者看得懂發生什麼事」，不負責解鎖動作本身。

**排除方案**（詳見 §2 D11 決策列）：**A（純 exit）**——即原始設計，體感等同故障，留不住法律敘事；**C（半套：只寫 log 不起殼）**——鎖定當下客戶端零可見資訊，喪失「當場出示 event_id」的即時證據呈現。

#### 4.4.2 forensic 證據規格（存證 snapshot 擴充）

tamper 事件不再只記「哪個檔案 hash 不符」，擴充為完整存證 snapshot：

**檔案層**：每個不符檔案除預期／實際 hash 外，額外記錄：
- `mtime`（修改時間）——**最接近竄改行為發生時間點的證據**，是四個欄位裡證據力最高的一個
- `ctime`（inode 變更時間，如 owner／權限被動過也會反映）
- `owner`（uid/gid，異常 owner 本身可能就是線索——例如竄改流程用了非預期身分寫入）
- `size`（檔案大小，佐證是否為整檔替換 vs 局部修改）

**觸發脈絡（依偵測來源分級 best-effort，不強求做不到的事）**：

| 偵測來源 | 蒐集內容 | 理由 |
|---|---|---|
| 業務埋點（HP，D8） | 當下登入帳號＋來源 IP | 此時有完整 request context，是三者中證據最完整的情境 |
| runtime 抽查（SCH，D8/D10） | 活躍 session 快照（帳號／IP／最後活動時間） | 抽查非綁在某個 request 上，沒有「當下操作者」，退而求活躍 session 列表當旁證 |
| boot gate（BOOT，D3） | 僅檔案 metadata＋`app.log` 尾段 | 此時服務尚未起、無 DB／無 session 可查，能拿到的只有檔案本身與上次運行留下的 log |

三個落點（FS 標記檔、DB `integrity_tamper_events.detail`、D9 回報 LC 的事件內容）**同步吃這份擴充**——`detail` JSONB 欄位結構見 §4.2，新增 `file_metadata`（mtime/ctime/owner/size per 檔）與 `trigger_context`（依來源分級的欄位，缺席即代表該來源拿不到，不硬填）兩個子物件。

#### 4.4.3 邊界明文：「是誰改了檔案」在應用層不可知

**必須向客戶與內部團隊明講的限制**：本案偵測與存證的能力邊界止於「產品業務層」。實際竄改動作（替換 `.so`、改資料檔、動 manifest 之外的檔案）**發生在主機層**——SSH 登入後手動改、`docker exec` 進容器操作、或直接改 volume 掛載來源——**這些管道完全不經過產品的任何業務邏輯**，因此產品**沒有、也不可能有**「是誰做的」這個答案。D11 的 forensic 擴充蒐集的是「檔案證據」（mtime 等）與「若剛好有業務 session 在跑時的旁證」，**不是身分溯源機制**，兩者不可混淆。

**契約措辭建議**：條款不應寫成「查明竄改行為人即可求償」——這在應用層舉證上做不到，會讓契約條款落空。建議措辭方向是**綁「產品完整性驗證失敗」這個客觀事實**：

> 「產品完整性驗證失敗（即觸發本產品之防竄改鎖定機制）即構成違約事由，客戶應負相關責任，不以查明具體操作人員為前提。」

這個措辭下，產品證據鏈（manifest 簽章比對失敗、tamper_event_id、檔案 metadata、時間戳）本身就足以主張「完整性驗證失敗」這個事實，**不需要額外證明「誰做的」**——後者留給客戶內部（配合下方主機層審計建議）或司法程序視需要另行調查，不是本產品的責任範疇。

**主機層審計是客戶責任範疇**：產品能做的止於「偵測到＋留下檔案證據」；要往上溯源到「哪個帳號、哪個 session 做的」，需要客戶主機層自己的審計機制（如 `auditd` 監控產物目錄的寫入事件）。落地版部署文件補一節具體建議，見 `docs/features/FR-063-2608-nuitka-packaging/deployment-env.md` §9（新增）。

#### 4.4.4 D9 回報（沿用，補充欄位）

- D9 回報：偵測當下 best-effort POST LC 接收端點，事件內容含 4.4.2 擴充後的 `file_metadata`／`trigger_context`；發不出不重試不阻塞。

### 4.5 啟動 gate（D1／D3）

- 位置：`main.py` `validate_required_env()` 之後、`create_app()` 之前；api／socketio 兩模式共用（統一入口 `main.py`，RUN_MODE 分流前的共同路徑）。
- 步驟：讀 FS tamper 標記（存在即拒啟）→ `verify_payload(manifest 信封)`（硬編公鑰）→ 逐檔比對 `core`＋`resources` 兩層 → 任一失敗寫 FS 落點＋D9 回報＋exit。
- 失敗訊息比照 `validate_required_env()` 語氣：一次列全（驗章失敗 reason／不符檔案清單）、raise 終止。
- 效能預算：①②層數百～千餘檔，秒級。

### 4.6 runtime 抽查與業務埋點（D8／D10）

- **抽查 job**：APScheduler interval job，**4h＋每輪隨機 jitter 0–60min**（每輪重抽）；內容＝固定核心（①②全量）＋隨機 N 個③層檔；api／socketio 兩模式都跑；不需 DB／DI（偵測後寫 DB 時才開 session）。
- **D10 二次確認**：單檔 hash 不符 → 立即複算同檔一次，兩次皆不符才判 tamper；啟動 gate 不套此緩衝。
- **業務埋點 2–3 處**：低頻高價值業務路徑（候選：license 匯入、報告匯出）順驗「核心小集合」（binary 本體＋執法相關 .so 等個位數檔案，毫秒級）——檢查時機綁使用者行為而非時鐘，讓「算好抽查節奏下手」失效。
- **參數寫死在 binary，不開放 env 設定**（可設定＝可關閉）。要調參數走發版。
- 新增 job 同步 `core/scheduler.py` 啟動摘要字串與 `main.py` 檔頭模式裁剪表。

### 4.7 /app 唯讀化（D4）

- entrypoint 改：產物歸 `root:root`（chown 只留四個 volume 掛載點給 uid 1000），進程照舊 `setpriv` 降 1000 跑——檔案系統層寫不進產物。
- 部署文件補 docker `--read-only`（＋必要 tmpfs）建議。
- **前置驗證**：完整功能冒煙確認 binary 與③層套件 runtime 無寫產物目錄行為（`__pycache__` 疑慮）；發現寫入需求→回報調整，不硬上。

---

## 5. 拆分（7 子需求 × 子任務） {#phases nav="拆分"}

依賴鏈：**.1 簽驗共用層是全部的前提** → .2 build manifest → .3 啟動 gate → .4 抽查／.5 unlock（可並行）；.6 可全程平行；.7 收口。跨 repo（LC）項目已標注。

### FR-064.1 簽驗共用層（D2）— 依賴：無 — Repo：**LC＋BE**

| # | 子任務 | 驗收 | 依賴 |
|---|--------|------|------|
| T-1.1 | LC 端抽 `sign_payload(dict)→envelope` 泛用層（payload 帶 type），`sign_license` 改走泛用層 | LC license 簽發迴歸綠；新簽 license 檔與舊版驗證端互通 | — |
| T-1.2 | BE 端抽 `verify_payload(envelope)→dict` 泛用層，`verify_license` 改走泛用層 | BE license 驗證迴歸綠；**已出貨 license 檔（v2/v3）驗證通過**（向後相容） | — |

### FR-064.2 build 期 manifest 產生與簽章 — 依賴：064.1 — Repo：BE（build scripts）＋LC

| # | 子任務 | 驗收 | 依賴 |
|---|--------|------|------|
| T-2.1 | build_release.sh Step 4 尾端：dist 逐檔 SHA-256 → 分層 manifest（core/resources/thirdparty；④排除；manifest＋sig 自身排除）→ 送 LC 簽章 → 兩檔入 dist | 一次完整 build 產出 manifest＋sig；抽驗數檔 hash 正確；分層歸屬正確 | T-1.1 |
| T-2.2 | 排除規則與 build_image.sh rsync 排除對齊（單一真相：共用排除清單或斷言比對）；manifest＋sig 帶進 image context | image 內檔案集合與 manifest 清單 diff 為零（除自身兩檔） | T-2.1 |

### FR-064.3 啟動 gate＋tamper 落地與鎖定 — 依賴：064.1、064.2 — Repo：BE

| # | 子任務 | 驗收 | 依賴 |
|---|--------|------|------|
| T-3.1 | tamper 表 migration（比照 clock_watermark 形狀，含 GRANT）＋FS 落點讀寫模組 | migration 套 DEV 過；FS 標記檔寫入／讀取／存在判定單元驗證 | — |
| T-3.2 | main.py 啟動 gate：FS tamper 檢查（存在即拒啟）→ verify_payload → ①②層逐檔比對 → 失敗寫 FS＋exit；拒啟訊息含 fingerprint＋event_id | 改一檔→啟動被拒且訊息完整；未改→正常啟動；api/socketio 兩模式皆生效 | T-1.2、T-2.1、T-3.1 |
| T-3.3 | FS→DB 紀錄補同步（服務起後首輪） | boot 期寫入的 FS 事件在服務起來後出現在 DB 表 | T-3.1、T-3.2 |

### FR-064.4 runtime 抽查＋業務埋點（D8／D10）— 依賴：064.3 — Repo：BE

| # | 子任務 | 驗收 | 依賴 |
|---|--------|------|------|
| T-4.1 | APScheduler 抽查 job：4h＋jitter 0–60min 每輪重抽；固定核心＋隨機 N；D10 二次確認；偵測→雙落點＋exit；同步啟動摘要字串與模式裁剪表 | 運行中改檔→下輪抽查（或縮短間隔測試）觸發 exit；兩模式都掛；單次 I/O 毛刺不誤鎖（模擬驗證） | T-3.2 |
| T-4.2 | 業務埋點 2–3 處（license 匯入／報告匯出等）順驗核心小集合 | 埋點路徑觸發驗證；核心檔被改時該操作即觸發 tamper；正常路徑延遲毫秒級 | T-3.2 |

### FR-064.5 unlock token 全鏈（D6）— 依賴：064.1、064.3 — Repo：**LC＋BE**

| # | 子任務 | 驗收 | 依賴 |
|---|--------|------|------|
| T-5.1 | **LC**：unlock token 簽發功能（傾向後台頁，沿 62.10 internal API 慣例；形式實作時定）——輸入 fingerprint＋event_id，產短效 token 檔 | 簽出的 token 檔產品端可驗；效期／nonce 欄位正確 | T-1.1 |
| T-5.2 | **BE**：token 核銷四關（驗章→fingerprint→event_id→nonce）＋清 tamper 紀錄（FS＋DB）＋記 nonce | 正確 token→解鎖成功可重啟；錯 fingerprint／錯 event／重放 nonce／過期 各自被拒 | T-1.2、T-3.1 |

### FR-064.6 /app 唯讀化＋D9 回報 LC — 依賴：可平行（.6b 依賴 064.3 落點模組）— Repo：BE（Docker）＋LC

| # | 子任務 | 驗收 | 依賴 |
|---|--------|------|------|
| T-6.1 | 前置驗證：完整功能冒煙確認 runtime 無寫產物目錄行為（`__pycache__` 疑慮）；有寫入→回報調整 | 冒煙報告：產物目錄 mtime／新檔掃描零變動 | — |
| T-6.2 | entrypoint 改產物 root:root＋部署文件補 --read-only 建議 | 容器內以 uid1000 嘗試改產物→Permission denied；服務正常 | T-6.1 |
| T-6.3 | **LC**：tamper 事件接收端點（62.10 internal API 形狀）；**BE**：偵測時 best-effort 回報（不重試不阻塞） | 有網→事件現於 LC；斷網→鎖定流程不受影響、無阻塞 | T-3.2（BE 側）|

### FR-064.7 E2E 驗收 — 依賴：全部 — Repo：test repo＋實機

| # | 子任務 | 驗收 | 依賴 |
|---|--------|------|------|
| T-7.1 | 母案卡五條驗收全鏈實測（見 §6）＋License 既有流程迴歸 | §6 五條全過；license 匯入／驗證迴歸綠 | .1–.6 全部 |

### FR-064.8 lockdown 殼＋forensic 存證擴充（D11）— 依賴：見各子任務 — Repo：BE＋FE＋docs

D11 為 2026-08-16 補充定案，拆四張子卡；T-8.1／T-8.3 涉及既有 gate／落點程式碼，需等主鏈（.1–.6）程式面完成後開工，T-8.2（FE）契約已定可先行，T-8.4（本段文件）為前置。

| # | 子任務 | 驗收 | 依賴 |
|---|--------|------|------|
| T-8.1 | lockdown 殼實作：偵測拒啟時起 stdlib HTTP responder 取代 exit（無 Flask/DI/DB），固定回鎖定 HTML＋JSON（`INTEGRITY_503001`＋三欄 data） | 觸發拒啟後打任意路徑皆回鎖定頁；殼程序本身無 DB／DI 依賴（cold 環境也能起）；unlock 核銷流程不受影響（仍走下次啟動正常 boot） | CM-1224/1225/1228（主鏈 gate／落點程式碼完成） |
| T-8.2 | FE 認 `INTEGRITY_503001` 整站導向鎖定頁 | 任一 API 回該 error_code → 整站跳轉；鎖定頁顯示 fingerprint／event_id | 契約已定，可先行 |
| T-8.3 | forensic 擴充：`detail` 補 `file_metadata`（mtime/ctime/owner/size）＋`trigger_context`（依來源分級）；三落點（FS／DB／D9 回報）同步吃擴充 | 觸發不符時 DB `detail` 含新欄位；boot／runtime／業務埋點三種來源各自產出對應分級內容 | CM-1224/1225/1228（落點模組完成） |
| T-8.4 | 本卡（CM-1234）：design.md §4.4／§2／§5 文件落地＋部署文件補主機層審計建議節 | 本段文件與拍板內容一致；deployment-env.md 新增節可讀 | — |

---

## 6. 端到端驗收 {#acceptance nav="端到端驗收"}

母案卡（CM-1188）五條驗收條件，展開為可執行步驟：

**1. 改檔拒啟**：以出貨 image 起容器確認正常服務 → 停容器 → 修改①層任一 .so（或②層任一資料檔）一個 byte → 重啟 → 預期：啟動被拒、log 列出不符檔案與 reason、顯示 machine_fingerprint＋tamper_event_id、FS 落點出現標記檔。

**2. 運行中改檔觸發**：正常運行中修改固定核心清單內一檔 →（測試時縮短抽查間隔或觸發業務埋點路徑）→ 預期：D10 複算兩次皆不符 → tamper 雙落點寫入 → 服務立即 exit；有網情境 LC 端出現該事件（D9）。

**3. 重啟被拒（鎖定持續）**：承 2，直接重啟容器 → 預期：不進入驗證流程即拒啟（tamper 紀錄存在＝視同無 license）；再測「容器重建但 volume 保留」→ 仍拒啟（FS 落點生效）。

**4. unlock 恢復**：LC 以該機 fingerprint＋event_id 簽發 unlock token → 產品端核銷 → 預期：tamper 紀錄清除、nonce 記錄、重啟恢復正常服務；負面案：同 token 重放被拒、他機 token 被拒、過期 token 被拒。

**5. 正常無誤報**：未竄改的出貨 image 連續運行 ≥24h（跨多輪抽查）＋完整功能冒煙（含④volume 大量寫入：上傳檔案、log 滾動）→ 預期：零 tamper 事件、零誤鎖；啟動時間增量在秒級預算內。

另加迴歸底線：License 既有簽發／匯入／驗證全鏈（含已出貨舊 license 檔）不受 D2 抽層影響。
