FR-064 · Tamper Detection — 設計定案 · 2026-08-15

程式被動過,就終結服務、鎖定產品

設計定案版: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)。

設計定案 D1–D10 前作:FR-063(Nuitka 編譯,已完結) 後續:FR-065(Installer) 復用:FR-062 LC 簽驗體系 Notion 母案 CM-1188

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

變更紀錄

日期 變更 對應
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. 需求背景與端到端流程

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 定位與已知限制(已與決策者對齊)

這不是 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 解鎖。

%%{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 — 防竄改端到端時序(build → 啟動 → runtime → 鎖定 → 解鎖)

1.5 元件架構

%%{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
圖 2 — 元件架構:原廠側(LC+build)與客戶側(產品 binary)

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


2. 決策定案(D1–D10)

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、不掛 RLScm_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_503001datamachine_fingerprinttamper_event_iddetected_at);FE 認得該 error_code 即整站導向鎖定頁。unlock 核銷不走殼,維持既有 FS token 檔核銷流程(D6)。同時定案:forensic 證據規格擴充——tamper 事件不符檔案各帶 mtimectimeownersize(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 缺口)

以下事實皆經實碼/實測驗證(詳見討論稿 §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 定案的時刻。

🔴 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_dotenvRUN_MODE 解析 → validate_required_env()(缺項一次列全 raise)→ create_app()REGISTERED_APPS 逐一 import。啟動 gate 掛 validate_required_env() 之後、create_app() 之前(D3)。
  • schedulercore/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. 詳細設計

4.1 manifest 格式(dist 內兩個檔)

// 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 只吃 coreresources,抽查 job 吃全部。
  • 簽章不設效期——manifest 跟產物版本走:新版新 image 新 manifest,跨版本只共用公鑰(kid 輪替機制既有)。

4.2 tamper 落點

DB:新全域單例語意表(比照 license_clock_watermarkpublic schema、不掛 RLS、cm_app 可寫):

-- 草案形狀,最終 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)

{
  "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_503001datamachine_fingerprinttamper_event_iddetected_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_metadatatrigger_context;發不出不重試不阻塞。

4.5 啟動 gate(D1/D3)

  • 位置:main.py validate_required_env() 之後、create_app() 之前;api/socketio 兩模式共用(統一入口 main.py,RUN_MODE 分流前的共同路徑)。
  • 步驟:讀 FS tamper 標記(存在即拒啟)→ verify_payload(manifest 信封)(硬編公鑰)→ 逐檔比對 coreresources 兩層 → 任一失敗寫 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 子需求 × 子任務)

依賴鏈:.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 擴充:detailfile_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. 端到端驗收

母案卡(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 抽層影響。