---
title: "下載檔加密 — 設計文件 (FR-169)"
brand: "Guidant AI · **FR-169** 下載檔加密"
eyebrow: "FR-169 · 設計文件 v1 · 2026-10-29（D1～D8 定案；10 張卡 6 批；母卡 CM-3223）"
h1: "下載檔加密"
subtitle: "下載下來的檔案自己帶鎖；密碼系統產、畫面顯示一次；獨立小套件兩邊接；管理員開關預設開"
lede: "報表、證據包下載到電腦後是明文，別人走到這台電腦雙擊就能看。本案讓每份下載出去的檔自己帶鎖：**Word、Excel、PDF 用檔案格式內建的開啟密碼**，雙擊會先問密碼；**多檔、圖片、log 打成 AES-256 加密 zip**。密碼由瀏覽器端產生、按下載前**只顯示一次**，不存、不寄信、不進 log。加密寫成獨立小套件 `jedi-file-protect`，主系統 16 支匯出端點與 Agent 主控台各自一行接上，Agent 離線照常。使用者永遠不能選「不加密」；系統管理員在安全政策有一個開關、出貨預設開、關閉時寫稽核並常駐警示。**不做撤銷、到期、IRM／DRM。**"
chips: [
  {text: "D1～D8 定案", kind: ok},
  {text: "10 張卡 6 批；套件 1／BE 4／FE 4／Agent 1", kind: accent},
  {text: "新套件 jedi-file-protect 零框架相依", kind: accent},
  {text: "密碼只在瀏覽器與那一次請求的 header 出現", kind: accent},
  {text: "陷阱：PDF 必須 AES-256 R6，R5 比 RC4 還好破", kind: crit},
  {text: "陷阱：Agent 沒收到開關值要視為開啟，不是關閉", kind: crit}
]
footer: "FR-169 · 設計文件 v1 · 2026-10-29 · 討論稿：[discussion.md](discussion.md) · 取捨：docs/analysis/2026-10-29-download-file-encryption-approach.md · 沿革見 LOG 與 git log"
---

> 狀態：**已收尾**（母卡與十二張子卡 Done；STG／POC 未上、Agent image 未出包）｜日期：2026-10-29｜母卡 [CM-3223](https://app.notion.com/p/FR-169-Agent-8-5-408346da4cd081eeb138ef3e49d3240e)、子卡 CM-3224～CM-3231、補卡 CM-3280～CM-3281｜前作 FR-166／166.1（Agent 主控台、分模組下載／匯出能力點、心跳下發模型、Agent 稽核 logging 管線）、FR-133（錯誤追蹤遮罩）、FR-145.3（結案報告草稿浮水印）
> 開發 branch：四個 repo（BE、FE、evidence-agent、jedi-python-package）2026-10-29 皆在 `feature/FR-139`；runner 開工 `git branch --show-current` 再確認，不對就停下問，不切

> 🔴 **這份是設計決策，不是現況。** 上線後的畫面與 API 現況看 SPEC 頁（各匯出頁的「下載檔案會加密」段、[安全政策設定](../../spec-site/current/system-admin/security-policy.md)）；使用者怎麼開加密檔看使用手冊 `docs/user-manual/encrypted-download-guide.md`；套件用法看 jedi-file-protect README。

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

| 日期 | 變更 | 對應 |
|---|---|---|
| 2026-10-29 | 初版：討論稿 v3 D1～D8 全照建議定案；8 張卡 5 批；同日開卡 | 母卡 CM-3223，子卡 CM-3224～CM-3231 |
| 2026-10-29 | 母卡層總驗通過；補批 6 兩張卡（D5 的全站常駐橫幅、Agent 稽核 encrypted 欄進主系統）；三支套件發版 | CM-3280、CM-3281；jedi-file-protect 0.1.0、jedi-iam 1.10.0、jedi-masking 0.1.1 |
| 2026-10-29 | D5 第三道「畫面上標未加密」裁示不做（系統無稽核事件頁）；批 6 驗收通過、四 repo 合回 | CM-3281 復核、決策者裁示 |
| 2026-10-29 | 決策者實測證據附件下載沒加密：範圍漏了套件自帶的上傳檔下載 route。首腦直修 jedi-file-upload 1.5.0 `download_responder`＋BE 注入＋FE 12 處；三支套件匯出端點另開批 7～8 | 首腦直修；CM-3286、CM-3287 |
| 2026-10-29 | 決策者指定**不加密**的三類匯出：責任分工設定、控制項對照（4 個出口）、任務批次——要離線改再匯回，匯入端讀不了加密檔；後端 `send_unprotected` 照樣記稽核 encrypted=false。「哪些出口要加密」的設定功能先不做 | 首腦直修 |
| 2026-10-29 | 第三方元件掃描頁 SBOM 檢視器改走 inline（畫面解析 JSON，不經加密），下載鈕照舊加密 | 首腦直修 |
| 2026-10-29 | 批 7～8 完成：jedi-log／jedi-issue／jedi-task-platform 匯出加 `download_responder` 掛點並發版，主系統以 `responder_for(source)` 注入（操作日誌是自產 zip，`unzip=True` 重包成一個 AES zip） | CM-3286、CM-3287；jedi-log 1.4.0、jedi-issue 1.5.0、jedi-task-platform 1.5.0 |
| 2026-10-29 | **D3 補**：包 zip 的單檔外層檔名去掉原副檔名（`截圖.zip`，裡面仍是 `截圖.png`）；jedi-file-protect 0.2.1 | 首腦直修 |
| 2026-10-29 | **D7 補**：預覽本身不改造，但 PDF 預覽藏掉瀏覽器內建檢視器的下載／列印工具列、列印印空白，免得有預覽權的人從預覽拿到沒密碼的檔；順帶修 FR-166 CM-3198 的 iframe `sandbox` 回歸（Chrome 全部 PDF 預覽哭臉） | 首腦直修，FE 72ed484c、03e6acfb |
| 2026-10-29 | **D3 改判**：7z → AES-256 zip（決策者實測 .7z 內建解壓打不開）；jedi-file-protect 0.2.0；`needs_7zip` 欄位名沿用、語意改「要裝第三方解壓工具」。檔名清單明文一事決策者裁示接受（畫面上本來就看得到檔名），zip 內外都用原檔名，不做中性檔名＋對照表 | 首腦直修 |

---

## 1. 需求背景與端到端 {#background nav="背景"}

**要擋的只有一件事**：檔案下載到電腦後，別人走到這台電腦雙擊就能開。不擋檔案離開系統後的收回、到期、轉傳（決策者裁示先不做撤銷），不擋截圖與拍照。

**端到端（一次下載）**：使用者按匯出 → 前端問開關（快取）→ 開啟則對話框在瀏覽器端產 16 碼密碼、只顯示一次、複製後「下載」才亮 → 請求帶 `X-File-Password` header（不進 URL）→ 後端照常產檔 → 呼叫 `jedi-file-protect` 依副檔名加密（pdf／docx／xlsx 原生密碼；其他與多檔 AES zip）並蓋浮水印 → 寫 `FILE_DOWNLOADED` 稽核（不含密碼）→ 回檔帶 `Cache-Control: no-store`、`X-Filename`、`X-File-Protected`、`X-File-Needs-7zip` → 對話框關閉、密碼清掉。

Agent 主控台同一條，差別是開關值來自最後一次心跳、稽核事件先寫本機 `system_logs` 再隨心跳回主系統。

### 做完之後

- 任何「匯出／下載」按鈕都跳同一個對話框；下載到的 Word、Excel、PDF 雙擊要密碼；證據包、診斷包、圖片包是 AES `.zip`，要 7-Zip／WinZip／Keka 輸入密碼開。
- 安全政策多一個「檔案下載保護」開關，預設開；關掉時頁頂與系統總覽常駐警示、稽核事件一筆、之後每筆下載標「未加密」。
- 稽核事件頁看得到誰、何時、下載了哪份、有沒有加密。
- 一支新套件 `jedi-file-protect`，之後診斷包、資料庫備份下載、授權檔匯出都能直接用。

## 2. 分工概述（30 秒版） {#overview nav="概述"}

| 批 | 做什麼 | 產出 | 完成怎麼判定（決策者檢查法） |
|---|---|---|---|
| **1 套件** | `jedi-file-protect`：產密碼、三種加密、浮水印 | 一支能 `pip install` 的套件 | 套件 tests 全綠，含兩個突變測試（改 R5 紅、zip 拿掉 WZ_AES 紅） |
| **2 看到效果** | BE helper＋開關＋心跳＋事件碼；FE 對話框元件＋composable＋安全政策段；兩邊先接稽核計畫與結案報告 | 按匯出跳對話框、下載到的檔要密碼 | DEV 稽核計畫按匯出 Word：對話框顯示密碼、複製後下載鈕才亮、Word 開要密碼；安全政策關開關後不跳直接給檔、頁頂有警示 |
| **3 全部接上** | BE 其餘端點；FE 其餘 24 處；Agent 下載與心跳 | 任何下載都加密，Agent 離線照常 | 翻每個有下載鈕的頁面都跳對話框；拔 Agent 網路後主控台下載照常要密碼 |
| **4 主控台前端** | FE `console/` 三頁接同一個對話框 | 主控台與主站長一樣 | 主控台檔案頁下載跳同一個對話框 |
| **5 文件** | SPEC、使用手冊「如何開啟加密檔」、套件 README | 客戶看得懂怎麼開 | 手冊頁四種格式各一段 |

依賴：1 → 2（B1、F1 平行）→ 3（B2、F2、A1 平行）→ 4 → 5。**決策者功課**：發版前確認 jedi-iam（加一個政策鍵）與新套件 jedi-file-protect 兩支套件發版。

## 3. 決策定案（D1～D8） {#decisions nav="決策定案"}

| # | 題目 | 定案 | 理由 | 排除 |
|---|---|---|---|---|
| **D1**（[CM-3231](https://app.notion.com/p/FR-169-D1-SPEC-README-design-md-BE-408346da4cd081d6acdacc683b071306)） | 套件名 | `jedi-file-protect` | 做的不只加密，浮水印也在；對到 Office「保護文件」用語 | `jedi-file-crypto`（只講加密）、`jedi-export-guard` |
| **D2** | 密碼形態 | 16 碼隨機英數分四段（`Kq7m-Xw2p-Rt9v-Bn4z`），去 0／O／1／l／I，`secrets` 產 | 檔案密碼可離線無限次試，六碼數字幾分鐘破；分段好對照、純英數不被密碼框吃符號 | 五單字密語（要自備單字表、強度較低）、20 碼含符號（難抄） |
| **D3** | 多檔與非文件容器 | AES-256 zip（`pyzipper`，WinZip AE-2；內容加密、檔名清單明文；禁 ZipCrypto） | 決策者實測 .7z 用內建解壓打不開、使用者不知道怎麼辦，裁示用大家認得的 zip；代價是檔名外露，且 Windows／macOS 內建解壓一樣開不了 AES zip、仍要裝工具 | 7z（header 加密連檔名一起藏，但副檔名陌生）、ZipCrypto zip（內建能開但已被已知明文攻破，等於沒鎖）、單張圖片轉加密 PDF |
| **D4** | 浮水印 | PDF 與 docx 頁尾小字「下載者 暱稱（帳號） · 時間 · Guidant AI」；xlsx 與 zip 不蓋 | 擋「自己傳出去」的追責；滿版斜字影響可讀；Excel 沒有頁 | 滿版斜字、不做 |
| **D5** | 誰能選不加密 | 使用者永遠不能；管理員一個開關、預設開；關閉寫稽核、系統管理員全站頂部常駐橫幅（每筆下載的稽核紀錄記 encrypted=false；「畫面上標未加密」因系統沒有稽核事件頁，決策者裁示不做） | e2e／教育訓練、客戶既有 DLP、匯出餵別系統三種情境要有正規出口；預設開＋三道警示壓掉「被關掉」風險。反悔條件：客戶普遍一裝就關則改不可關 | 三選一含「使用者可選」（會習慣性點掉）、寫死強制（沒有正規出口使用者會繞） |
| **D6** | 開關值到 Agent | 掛 `heartbeat_extras`（照 FR-166 D23），Agent 存本機 `data/file_protect.json`，離線沿用；**沒帶視為開啟** | 一份真相、掛點現成、離線語意 FR-166 已接受 | Agent 自己一份設定、Agent 一律加密不吃開關 |
| **D7** | 預設線上看 | 本案不做預覽器改造；既有 PDF 預覽藏掉瀏覽器內建檢視器的工具列（`#toolbar=0`）、列印印空白 | 每個匯出頁加預覽器範圍是本案好幾倍；但預覽框那顆原生下載鈕給的是沒密碼的 PDF，等於繞過加密，藏掉是一行的事。擋不住右鍵與 Ctrl+S，要全擋得 pdf.js 自繪 | pdf.js 自繪（另開卡）、後端轉圖片（截圖本來就擋不住，為擋開發者工具付的成本不划算） |
| **D8** | 密碼在哪產 | 瀏覽器端 `crypto.getRandomValues`，放 header `X-File-Password` 送後端；後端收不到回 400 不靜默吐明文 | 密碼只在瀏覽器與那一次請求出現；URL 會進 access log 與歷史。`getRandomValues` 不要求 secure context（與 `randomUUID` 不同），F1 驗收仍要在 http＋IP 實測 | 後端產回給前端（多一處會留痕） |

## 4. 現況接入點盤點 {#inventory nav="現況盤點"}

| 元件 | 現況 | 本案動作 | 卡 |
|---|---|---|---|
| BE 匯出端點 | 16 支 route 各自 `send_file`；`ap_export`、`ssp_export`、`mf_ssp_export` 各有一份 `_send_attachment` 複本 | 抽成 `api/common/file_protect.py::send_protected`，各端點一行呼叫；三份複本刪掉 | B1、B2 |
| 上傳檔下載 `GET /file/download/<uid>`（jedi-file-upload 自帶 route：證據附件、程序書、前置資料、問卷與意見回饋附件） | 套件 `send_file` 直出；FE 用簽票 `?st=` 開新分頁（帶不了密碼 header） | 套件 1.5.0 加選填 `download_responder` 掛點，主系統注入 `send_protected`；FE 改走 `useProtectedDownload().downloadFile`、簽票明文路徑刪除 | 首腦直修 |
| 其他套件自帶的人用匯出（jedi-api-log 操作日誌、jedi-issue 意見回饋、jedi-task-platform 任務批次） | 各自 `send_file` | 同一形狀開掛點、主系統注入 | P2、B4 |
| 加密能力 | 主系統有 `cryptography`（Fernet 欄位加密）、`pypdf`、LibreOffice；無 Office 加密、無 AES zip | 新套件帶 `msoffcrypto-tool`、`pyzipper`，重用 `pypdf` | P1 |
| 浮水印 | 結案報告「草稿」浮水印是 docx 範本變數 | 套件提供通用頁尾浮水印；結案報告定稿版也走套件 | P1 |
| 安全政策 | `system_configs` 群 `RUNTIME_CONFIG`；型別與預設在 jedi-iam `domain/security_policy.py`（`BOOL_KEYS` 只有 `MFA_REQUIRED`）；FE 頁四段 | jedi-iam 加布林鍵 `FILE_DOWNLOAD_PROTECTION=True`；FE 登入段後加 InputSwitch 與關閉警示 | B1、F1 |
| 心跳下發 | `core/plugins/remote_agent.py` `heartbeat_extras` 已掛 vulndb、tool_schemas、ldap_config；Agent 側 `ldap_source.py` 是 observer 範本 | 多掛 `_agent_file_protect.py`；Agent 新 `file_protect_sync.py` | B1、A1 |
| 稽核事件 | `audit(logger, name, EventCode, **fields)`；事件碼最大 6228；Agent 側 FR-166.1 D6 接 logging 管線 | 新 `FILE_DOWNLOADED`（6229）；Agent `AGENT_FILE_DOWNLOADED` 既有、加 `encrypted` 欄 | B1、A1 |
| FE 下載 | 26 處 `responseType: 'blob'` 散在 composable、service、view；檔名用 `filenameFromHeaders`、下載用 `downloadBlob` | 抽 `useProtectedDownload` 一支，全部改呼叫；對話框元件一個 | F1、F2 |
| Agent 主控台 | 後端 `core/console/files.py`、`ops_routes.py` 下載；前端在 FE repo `console/`（Files／Runs／Ops） | 後端接套件與心跳；前端 reuse 主站元件 | A1、F3 |
| 下載權限 | FR-166.1 D2／D3：下載分模組看各自 download 能力點（7 顆）＋匯出 export 顆，出貨零角色，上傳者本人免；Agent 主控台看 `download-files` 範圍 | 不動；加密對話框掛在「已通過下載守門」之後，誰看得到下載鈕由 FR-166.1 決定 | — |

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

### 5.1 套件 jedi-file-protect（P1）

```
generate_password() -> str                     # 19 字元：4 段 × 4 碼英數，'-' 相連，去 0O1lI
protect(data: bytes, filename: str, password: str, *, watermark: WatermarkInfo | None = None) -> ProtectedFile
ProtectedFile(data: bytes, filename: str, needs_7zip: bool, encrypted: bool)
```

分派：`.pdf` → pypdf `encrypt(user_password, algorithm="AES-256")`（R6，禁 `AES-256-R5`／RC4）；`.docx/.xlsx/.pptx` → msoffcrypto agile；其他副檔名與多檔（傳 `list[(name, bytes)]`）→ py7zr `SevenZipFile(password=…, header_encryption=True)`，回 `.7z`。浮水印只對 pdf／docx 生效，內容「{nickname}（{login}） · {YYYY-MM-DD HH:mm} · Guidant AI」頁尾小字。零框架相依，照 jedi-masking 形狀；`publish.sh` 推 Nexus 等令。

### 5.2 主系統 helper（B1）

`send_protected(buf, filename, mimetype, *, actor)`：① 讀 `request.headers["X-File-Password"]`；② `read_root_config_value("RUNTIME_CONFIG","FILE_DOWNLOAD_PROTECTION")`，關閉→跳過加密；③ 開啟且無 header → `BadRequestError(GRC_400xxx 缺少檔案密碼)`；④ 呼叫套件；⑤ `audit(FILE_DOWNLOADED, filename, format, encrypted, source)`；⑥ `send_file` ＋ headers：`Cache-Control: no-store`、`X-Filename`（URL 編碼）、`X-File-Protected: 0|1`、`X-File-Needs-7zip: 0|1`，`Access-Control-Expose-Headers` 補這三個。**密碼不得出現在任何 log 字串**：helper 內不 log header，錯誤訊息不帶值；FR-133 遮罩規則補 `X-File-Password` header 名。

### 5.3 前端共用（F1）

`useProtectedDownload().download(url, params, fallbackName)`：讀 `securityPolicyStore.fileDownloadProtection`（登入後載一次，安全政策頁存檔後刷新）→ 開啟則開 `ProtectedDownloadDialog`（props：`needs7zip` 由呼叫端依格式預判，zip／多檔為 true）→ 使用者按複製（`navigator.clipboard`，失敗退回 `execCommand`）→ 下載鈕亮 → axios blob 帶 header → `downloadBlob` → 對話框關、密碼變數清空。密碼不進 Pinia、不進 localStorage。

### 5.4 Agent（A1、F3）

後端 `core/console/file_protect.py` 與主系統 helper 同介面、獨立實作（主系統那支綁 Flask-RESTful／EventCode／`read_root_config_value`）。心跳 observer `file_protect_sync.py` 照 `ldap_source.py`：收 `data.file_protect.enabled` 寫 `data/file_protect.json`；**key 不存在（舊主系統）不動、檔案不存在視為 true**。前端 `console/` 直接 import 主站 `ProtectedDownloadDialog` 與 composable。

### 5.5 安全規則對照

| 規則 | 怎麼滿足 |
|---|---|
| 祕密不進 log／錯誤追蹤 | helper 不 log header；FR-133 遮罩加 `X-File-Password`；驗收 `grep X-File-Password log/app.log` 為 0 |
| 輸出不被快取 | `Cache-Control: no-store` |
| 密碼學不自創 | 全走格式標準：PDF AES-256 R6、Office agile、zip AES-256（WinZip AE-2）；`secrets` 產亂數 |
| 新端點守門 | 不開新端點；既有端點守門不動 |
| 暫存檔 | 套件全程 bytes 在記憶體；LibreOffice 轉檔的暫存由既有路徑負責刪 |

## 6. 拆分（12 張實作卡、8 批） {#breakdown nav="拆分"}

**批（Stage）＝工作坊同時開幾個小弟一起跑的那一組**。底表在 [`handoff/cards.py`](handoff/cards.py)，兩個斷言已過（依賴在更早的批；同批同 repo 不改同一檔）。

**登記型檔只讓一張卡碰**：BE `event_code.py`、`remote_agent.py` adapters、jedi-iam `security_policy.py` 都在 B1；FE 主站 i18n 由 F1 一次佔好 key（含 F2 用的）；`api.js` 不動（端點不變）。

**套件異動（發版前統一確認）**：新 `jedi-file-protect`、jedi-iam（加一鍵）；開發期 path dependency，不 commit path 改動。

| 批 | 卡 | repo | reuse／extend／new＋理由 | 改哪些既有檔 | 新開哪些檔 | 依賴 | 驗收 |
|---|---|---|---|---|---|---|---|
| 1 | **P1**（[CM-3224](https://app.notion.com/p/FR-169-P1-jedi-file-protect-PDF-Office-7z-PDF-docx-jedi-python-package-408346da4cd081b88e64e505bbee51b9)） 新套件 jedi-file-protect：產密碼、PDF／Office／7z 三種加密、PDF 與 docx 頁尾浮水印（零框架相依） | jedi 套件 | new：照 jedi-masking 形狀開一支「一件事、零框架相依」的小套件（只相依 pypdf、msoffcrypto-tool、py7zr、python-docx、cryptography）。既有 `cryptography` Fernet 是欄位加密不是檔案加密，不適用；既有結案報告「草稿」浮水印是 docx 範本變數，只適用該範本，故套件另提供通用頁尾浮水印。政策、稽核、密碼顯示畫面都不進套件（宿主的事） | — | jedi-file-protect/pyproject.toml；jedi-file-protect/jedi_file_protect/__init__.py；jedi-file-protect/jedi_file_protect/password.py（generate_password：16 碼英數四段、去 0O1lI、`secrets`）；jedi-file-protect/jedi_file_protect/protect.py（protect(data, filename, password, *, watermark=None) → ProtectedFile(data, filename, needs_7zip: bool)；依副檔名分派）；jedi-file-protect/jedi_file_protect/pdf.py（pypdf AES-256 R6；禁 RC4／R5）；jedi-file-protect/jedi_file_protect/office.py（msoffcrypto agile 加密 docx／xlsx／pptx）；jedi-file-protect/jedi_file_protect/archive.py（py7zr AES-256、header 加密，多檔與其他副檔名）；jedi-file-protect/jedi_file_protect/watermark.py（PDF 頁尾小字、docx 頁尾小字）；jedi-file-protect/tests/；jedi-file-protect/README.md；jedi-file-protect/publish.sh | — | 套件 tests：pdf 加密後 `pypdf` 不帶密碼開啟 raise、帶密碼開啟成功且 `/R` 為 6；docx／xlsx 加密後 `msoffcrypto` 回 `is_encrypted()` True 且用密碼解回原 bytes；7z 不帶密碼 `py7zr` 列檔名 raise（header 加密）；password 1 萬次無 0O1lI、長度 19、四段；突變：把 pdf 改 R5 測試紅、把 7z header 加密拿掉測試紅；`pip show` 相依不含 Flask／SQLAlchemy；浮水印：PDF 每頁最後一行含下載者字串、docx footer 含同字串 |
| 2 | **B1**（[CM-3225](https://app.notion.com/p/FR-169-B1-helper-FILE_DOWNLOAD_PROTECTION-FILE_DOWNLOADED--408346da4cd0811682abdde066261b25)） 主系統：出檔前保護 helper、安全政策開關 FILE_DOWNLOAD_PROTECTION、心跳下發、FILE_DOWNLOADED 事件碼、先接稽核計畫與結案報告兩個端點 | BE | new：`common/util/file_protect.py` 的 `send_protected(buf, filename, mimetype, *, request, watermark_actor)`——讀 header `X-File-Password`、查開關、呼叫套件、寫稽核、回 `send_file`＋`Cache-Control: no-store`＋`X-Filename`＋`X-File-Protected`；取代各端點手寫的 `_send_attachment`（ap_export、ssp_export、mf_ssp_export 三支已各有一份，本卡抽成一份）。extend：jedi-iam `security_policy.py` `BOOL_KEYS`／`_STATIC_DEFAULTS` 加 `FILE_DOWNLOAD_PROTECTION=True`（套件改動，開發期 path dependency）；`core/plugins/remote_agent.py` `heartbeat_extras` 加 `file_protect_heartbeat_extra`；`event_code.py` 加 `FILE_DOWNLOADED`；`ap_export_route.py`、`closing_report_route.py` 改呼叫 helper | jedi-iam/jedi_iam/domain/security_policy.py（BOOL_KEYS、_STATIC_DEFAULTS）；pyproject.toml（jedi-file-protect pin、jedi-iam path 開發期）；common/enum/event_code.py；core/plugins/remote_agent.py（heartbeat_extras）；api/project/routes/ap_export_route.py；api/closing_report/routes/closing_report_route.py；app/system_config/service/security_policy_app_service.py（開關關閉寫稽核 SECURITY_POLICY_UPDATED 帶 file_download_protection=False） | common/util/file_protect.py；core/plugins/_agent_file_protect.py（心跳 extra：`{"file_protect": {"enabled": bool}}`）；test/test_file_protect_helper.py | P1 | DEV 重啟後：`GET /ap/<uid>/export?format=docx` 不帶 header 回 400（GRC_400xxx 新碼「缺少檔案密碼」），帶 `X-File-Password` 回 docx 且 Word 要密碼、回應 header 有 `Cache-Control: no-store` 與 `X-File-Protected: 1`；`format=zip` 回 `.7z` 且 7-Zip 要密碼、`X-File-Needs-7zip: 1`；結案報告 pdf 同；稽核事件頁出現 FILE_DOWNLOADED（使用者、檔名、encrypted=true）且 payload 無密碼；`grep -rn X-File-Password log/app.log` 為 0；安全政策 PUT `FILE_DOWNLOAD_PROTECTION=false` 後同端點不帶 header 直接給明文、事件 encrypted=false；心跳回應 `data.file_protect.enabled` 跟著變；守衛測試綠；回寫明講 jedi-iam 已改、發版前要確認 |
| 2 | **F1**（[CM-3226](https://app.notion.com/p/FR-169-F1-composable-useProtectedDownload-FE-408346da4cd081408322e4c7a6dadd16)） 主站共用 composable useProtectedDownload ＋「密碼只顯示一次」對話框元件 ＋ 安全政策開關段 ＋ 先接稽核計畫與結案報告 | FE | new：`src/composables/useProtectedDownload.js`（先問 `/security-policy` 開關快取；開啟→開 `ProtectedDownloadDialog` 產密碼（`crypto.getRandomValues`，16 碼四段去 0O1lI）→確認後 axios blob 帶 `X-File-Password`；關閉→直接下載；檔名用既有 `filenameFromHeaders`、下載用既有 `downloadBlob`），取代各 composable 內重複的 axios blob 段。new：`src/components/common/ProtectedDownloadDialog.vue`（照雛形①：提示列、密碼框、複製後下載鈕才亮、7z 提醒依 `needs7zip` 顯示）。extend：`SecurityPolicyForm.vue` 登入段後加「檔案下載保護」InputSwitch（照 MFA 那段形狀）＋關閉時頁頂 Message 警示；`useApExportDownload.js`、`useClosingReport.js` 改走新 composable。i18n 兩語言 key 本卡一次佔好（含 F2 會用的） | src/composables/useApExportDownload.js；src/composables/useClosingReport.js；src/views/security-policy/SecurityPolicyForm.vue；src/config/locales/i18n/zh-tw/security-policy.json；src/config/locales/i18n/en/security-policy.json；src/config/locales/i18n/zh-tw/common.json；src/config/locales/i18n/en/common.json | src/composables/useProtectedDownload.js；src/components/common/ProtectedDownloadDialog.vue | P1 | 稽核計畫頁按匯出 Word：對話框顯示 19 字元密碼、「下載」鈕灰到按「複製」才亮、下載後對話框關閉；DevTools Network 看請求 header 有 `X-File-Password`、URL 無密碼；`localStorage`／Pinia 無密碼；再按一次密碼不同；匯出 ZIP 對話框多 7-Zip 提醒；安全政策頁多一個開關、關掉存檔後頁頂出現警示、再按匯出不跳對話框；`grep -rn border-left src/components/common/ProtectedDownloadDialog.vue` 為 0；`http://<IP>:5173` 非 localhost 開頁實測 `crypto.getRandomValues` 可用 |
| 3 | **B2**（[CM-3227](https://app.notion.com/p/FR-169-B2-send_protected-SSP-RACI-AI-SSP-PoA-M-408346da4cd081dbaa21d99a4f89c3ba)） 主系統其餘匯出端點全部改走 send_protected（SSP、控制項對照、RACI、風險登錄、前置資料、診斷包、AI 呼叫紀錄、SSP 控制實作、範本下載、來源掃描、PoA&M、稽核結果、通過報告、Agent 報告與檔案包、弱點庫包除外） | BE | extend：每支端點把自己的 `send_file`／`_send_attachment` 換成 B1 的 `send_protected` 一行；刪掉 ssp_export、mf_ssp_export 各自的 `_send_attachment` 複本。不接：`agent_vulndb_route`（Agent 機器對機器拉的包，不是人下載）、`oscal_framework_version_route` OSCAL json 下載（框架公開內容，非機敏；回寫列出）、`poam_objective_route` json 若為機器匯出同上 | api/oscal/routes/ssp/ssp_export_route.py；api/module_frame/routes/mf_ssp_export_route.py；api/oscal/routes/control_mapping_route.py；api/governance/routes/raci_defaults_io_route.py；api/governance/routes/risk_register_route.py；api/pre_engagement/routes/file_route.py；api/support/routes/diagnostic_bundle_route.py；api/ai_call_log/routes/ai_call_log_route.py；api/oscal/routes/ssp/ssp_control_impl_import_route.py；api/module_frame/routes/ssp_import_template_route.py；api/module_frame/routes/module_frame_template_import_route.py；api/source_scan/routes/scan_route.py；api/project/routes/ar_workbook_route.py；api/project/routes/pass_report_route.py；api/project/routes/poam_objective_route.py；api/project_summary_report/routes/project_summary_report_route.py；api/remote_agent/routes/agent_local_run_route.py；api/remote_agent/routes/agent_file_route.py | — | B1 | `grep -rn 'send_file\|as_attachment' api/ | grep -v file_protect | grep -v vulndb | grep -v framework_version` 只剩回寫列出的例外；每支端點實打一次：不帶 header 400、帶 header 回加密檔；`tar.gz` 診斷包改回 `.7z`；SSP 匯出 docx／pdf／odt 三格式都要密碼（odt 走 7z 包，回寫註明）；守衛測試綠 |
| 3 | **F2**（[CM-3228](https://app.notion.com/p/FR-169-F2-24-useProtectedDownload-service-blob-axios-blob-composable-FE-408346da4cd081c69657ed5e58414848)） 主站其餘 24 處下載全部改走 useProtectedDownload（service 層 blob 與頁面內 axios blob 都收進 composable） | FE | extend：`src/service/*Service.js` 內回 blob 的方法改回 `{url, params}` 讓 composable 打，或直接在頁面改呼叫 `useProtectedDownload().download(url, params, fallbackName)`；頁面內自己寫 axios blob 的段落刪掉改呼叫。不接：裝置匯入範本、使用者匯入範本這類「空白範本」（非機敏，回寫列出）；確認每個下載按鈕只剩一條路徑 | src/composables/useAgentLocalRuns.js；src/composables/useArExportDownload.js；src/composables/useClosingReportBundle.js；src/composables/useCraLegalDocsExport.js；src/composables/usePassReportDownload.js；src/service/ControlMappingService.js；src/service/GovernanceService.js；src/service/ModuleFrameTemplateService.js；src/service/PreEngagementService.js；src/service/SourceScanService.js；src/service/SspService.js；src/service/SupportService.js；src/views/action-log/UserLogs.vue；src/views/ai-call-log/AiCallLogList.vue；src/views/ai-call-log/AiUsageSummary.vue；src/views/device/DeviceExportMenu.vue；src/views/feedback/system/SystemFeedbackManage.vue；src/views/project-summary-report/ProjectSummaryReportForm.vue；src/views/project/ProjectPlanningView.vue；src/views/project/RoundPoamPlanView.vue | — | F1 | `grep -rln "responseType: 'blob'" src | grep -v useProtectedDownload` 只剩回寫列出的例外；翻一遍每個有下載鈕的頁面都跳對話框；`ProjectPlanningView.vue` 3,540 行超標只准淨減，新邏輯不往裡加 |
| 3 | **A1**（[CM-3229](https://app.notion.com/p/FR-169-A1-Agent-jedi-file-protect-file_protect-AGENT_FILE_DOWNLOADED--408346da4cd08110a54ff10876b8072e)） Agent 主控台：檔案與報告下載走 jedi-file-protect、心跳收 file_protect 開關離線沿用、本機稽核 AGENT_FILE_DOWNLOADED 帶 encrypted | evidence-agent | extend：`core/console/files.py` 兩條下載與 `ops_routes.py` 診斷包下載改呼叫一支 Agent 側 helper `core/console/file_protect.py`（讀 header、查本機快取的開關、呼叫套件、稽核；與主系統 helper 同介面但不共用程式——主系統 helper 綁 Flask-RESTful 與 EventCode）；心跳 observer 照 `ldap_source.py` 形狀收 `data.file_protect`，寫 `data/file_protect.json`，沒帶（舊主系統）視為開啟；`pyproject.toml` 加 `jedi-file-protect`。前端在 FE repo `console/`，由 F3 做 | core/console/files.py；core/console/ops_routes.py；core/console/heartbeat.py（掛 observer）；pyproject.toml | core/console/file_protect.py；core/file_protect_sync.py（心跳 observer＋本機快取）；tests/test_file_protect.py | P1 | Agent 本機：下載不帶 header 400、帶 header 回加密檔；拔主系統後重啟 Agent 仍加密（讀快取）；主系統關開關→下一跳後不加密、`data/file_protect.json` 為 false；開回來下一跳恢復；從未連過主系統的新 Agent 預設加密；`system_logs` 有 AGENT_FILE_DOWNLOADED 帶 encrypted 欄且無密碼；套件 tests 綠 |
| 4 | **F3**（[CM-3230](https://app.notion.com/p/FR-169-F3-Agent-ProtectedDownloadDialog-reuse-F1-FE-408346da4cd081fd82d1d98f153036da)） Agent 主控台前端：檔案頁、執行紀錄、診斷包下載接 ProtectedDownloadDialog（reuse F1 元件，不另刻） | FE | reuse：`console/` 直接 import `src/components/common/ProtectedDownloadDialog.vue` 與 `useProtectedDownload`（主控台與主站同一個 Vite 專案，可共用；若 console 有獨立 i18n 命名空間則把 key 複製一份）；開關值從 Agent 既有主控台狀態端點讀（A1 若沒暴露就加一個欄位，回寫註明）；不新刻第二個對話框 | console/views/Files.vue；console/views/Runs.vue（或執行紀錄對話框）；console/views/Ops.vue（診斷包）；console/i18n（若獨立） | — | F1、A1 | 主控台檔案頁下載跳同一個對話框（含 7z 提醒）；離線狀態下仍跳、仍能下載加密檔；主系統關開關後下一跳對話框不跳；`grep -rn 'ProtectedDownloadDialog' console/` 命中且 `console/` 內無第二份對話框元件 |
| 5 | **D1** 文件：SPEC 頁（匯出類頁面各加一段「下載會加密」、安全政策頁）、使用手冊「如何開啟加密檔」、套件 README、design.md 開頭交棒句 | BE | extend：`docs/spec-site/current/` 對應頁加段落與檔頭變更紀錄一行；使用手冊新增一頁「下載的檔案要密碼？」（Word／Excel／PDF／7z 四種怎麼開、7-Zip／Keka 下載連結、忘記密碼重新匯出） | docs/spec-site/current/system-admin/security-policy.md；docs/spec-site/current/（匯出類頁面，runner grep '匯出' 列出）；docs/user-manual/ | docs/user-manual/<開啟加密檔>.md | B2、F2、F3 | MkDocs build 無警告；手冊頁有四種格式各一段與截圖位置；`grep -n "原本\|改判\|第 [0-9] 棒" <改到的 spec 頁>` 為 0 |
| 6 | **B3**（[CM-3280](https://app.notion.com/p/FR-169-B3-Agent-encrypted-_FIELDS-bool-_clean-SPEC-BE-408346da4cd081b0bf86c7f11f00c0ab)） Agent 下載稽核的 encrypted 欄位進主系統（_FIELDS 白名單加一欄、bool 不被 _clean 丟掉）＋ SPEC／手冊補總覽警示與未加密標記 | BE | extend：`core/plugins/_agent_console_audit.py` `_FIELDS` 加 `encrypted`；`_clean` 目前把 bool 一律丟成 None（`git log -L` 查原因），encrypted 要能進 message（轉 `true`／`false` 字串或 encrypted 專用處理），不要另寫第二支 sink。文件：F4 做完後 security-policy.md 與手冊兩處補回「總覽常駐警示、稽核頁標未加密」（D1 第 2 輪為照實寫先拿掉） | core/plugins/_agent_console_audit.py（_FIELDS、_clean 對 bool）；test/test_agent_console_audit_sink.py；docs/spec-site/current/system-admin/security-policy.md；docs/user-manual/encrypted-download-guide.md；docs/spec-site/current/user-manual/encrypted-download-guide.md | — | A1、D1 | Agent 主控台下載一次後，主系統稽核事件頁 AGENT_FILE_DOWNLOADED 那筆 message 含 `encrypted=true`（開關關閉時 `encrypted=false`）；既有 sink 測試全綠且新增一條「encrypted 進 message、沒帶時不出現」；兩份手冊 diff 一致 |
| 6 | **F4**（[CM-3281](https://app.notion.com/p/FR-169-F4-D5-FE-408346da4cd081abb5aceca0b24f25b2)） D5 另兩道警示：系統管理員看得到的「檔案下載保護已關閉」頂部橫幅（總覽與全站常駐）＋ 稽核事件頁每筆下載標「未加密」 | FE | reuse：`securityPolicyStore.protectionOn`（F2 已改讀 `/system/file-download-protection`）、`LicenseExpiryBanner` 掛在 `AppLayout.vue` 的位置與寫法（照它新開一支 `FileProtectionOffBanner.vue`，不塞進 LicenseExpiryBanner）、i18n 已預留 `protected_download.unprotected_badge` 與 `security_policy.file_download_protection_off_warning`。extend：`UserLogs.vue` request／message 欄對 FILE_DOWNLOADED 與 AGENT_FILE_DOWNLOADED 且 `encrypted=false` 的列加一顆 Tag（severity warning）。誰看得到橫幅：用側欄對「安全政策」頁的同一個能力點判斷，不自己發明角色判斷 | src/layout/AppLayout.vue（掛一行）；src/views/action-log/UserLogs.vue | src/components/security/FileProtectionOffBanner.vue | F1、F2 | 安全政策關掉存檔後：總覽與任何頁頂部出現常駐橫幅（系統管理員帳號看得到、一般使用者看不到），開回來橫幅消失；稽核事件頁 FILE_DOWNLOADED／AGENT_FILE_DOWNLOADED 且 encrypted=false 的列有「未加密（保護已關閉）」Tag，encrypted=true 的列沒有；`grep -rn border-left` 新檔為 0；vitest 既有綠 |
| 7 | **P2**（[CM-3286](https://app.notion.com/p/FR-169-P2-download_responder-jedi-api-log-jedi-issue-jedi-task-plat-408346da4cd0810783def034904ff851)） 三支套件的人用匯出端點加 download_responder 形狀的掛點：jedi-api-log 操作日誌匯出、jedi-issue 意見回饋匯出、jedi-task-platform 任務批次匯出（主系統注入 send_protected） | jedi 套件 | extend：照 jedi-file-upload 1.5.0 `FileUploadAdapters.download_responder` 的形狀，三支套件各在 adapters 加一個選填 `download_responder(file_obj, file_name, mimetype)`，route 的 `send_file` 改成「有注入交給它、沒注入照舊」；不在套件裡認得加密政策。不接：jedi-iam 使用者匯入範本與 TOTP QR 圖（非機敏）、jedi-asset 裝置匯入範本、jedi-evidence-classification 預覽（inline 不落磁碟）、jedi-detection 報告（主系統 Agent 報告端點已接） | jedi-api-log/.../api_log_route.py ExportApiLogsRoute；jedi-issue/.../feedback_route.py FeedbackExportRoute；jedi-task-platform/.../job_import_route.py JobExportRoute；三支套件 plugin/contract.py（adapters 欄位）、README adapters 表、pyproject version、tests | — | 無 | 三支套件 contract 測試各加一條「注入 responder 時交給它、未注入 send_file」；版號各進中版號（功能）；回寫列出每支套件 adapters 欄位名與未接清單；不發版、不推 Nexus（發版由首腦統一做） |
| 8 | **B4**（[CM-3287](https://app.notion.com/p/FR-169-B4-download_responder-send_protected-FE-useProtectedDow-408346da4cd08147b5c6f3817231ef1d)） 主系統注入三支套件的 download_responder（操作日誌、意見回饋、任務批次匯出改走 send_protected）＋FE 三處下載改走 useProtectedDownload | BE | reuse：`core/plugins/_file_upload_download.protected_download_responder` 直接給三支套件用（同一支函式、source 改成各自代號可加參數）；三支 core/plugins/<pkg>.py build_adapters 各加一行。FE：操作日誌匯出、意見回饋匯出、任務批次匯出三處改 `useProtectedDownload().download(...)`（FE 改動另開一張卡或由首腦決定併卡，本卡只列 BE） | core/plugins/api_log.py；core/plugins/issue.py；core/plugins/task.py；core/plugins/_file_upload_download.py（source 參數化）；pyproject.toml（三支套件釘版，發版後） | — | P2 | DEV 重啟後三個匯出端點不帶密碼 400、帶密碼回加密檔且 X-File-Protected: 1；稽核 source 各自代號；`grep -rn send_file( .venv/lib/python3.11/site-packages/jedi_*/ --include=*.py | grep -v tests` 剩下的全是不接清單上的 |

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

1. DEV 稽核計畫頁按匯出 Word → 對話框 → 複製 → 下載 → 雙擊 Word 要密碼，貼上才開；按取消不產檔。
2. 匯出 ZIP → AES `.zip`，Windows 檔案總管與 macOS 內建解壓開不了、7-Zip／Keka 要密碼；檔名清單看得到、內容解不開。
3. DevTools：請求 header 有 `X-File-Password`、URL 無密碼、回應 `Cache-Control: no-store`；`log/app.log` grep 密碼字串為 0。
4. 稽核事件頁：`FILE_DOWNLOADED` 帶使用者、檔名、`encrypted=true`，payload 無密碼。
5. 安全政策關開關 → 頁頂與總覽出現「檔案下載保護已關閉」、稽核一筆 → 再匯出不跳對話框直接給明文、事件 `encrypted=false` → 開回來全恢復。
6. Agent：主控台檔案頁下載跳同一個對話框；拔網路重啟 Agent 仍加密；主系統關開關後下一跳不加密；從未連線的新 Agent 預設加密。
7. 開 PDF 每頁頁尾有下載者與時間。
8. 翻一遍所有有下載鈕的頁面，沒有一個直接給明文。
9. 預覽 PDF／docx／xlsx：畫面上沒有瀏覽器的下載／列印工具列，文件照常捲動縮放；主控台執行紀錄 HTML 報告預覽照舊、腳本不執行。

## 8. 風險與陷阱 {#risks nav="風險"}

- **PDF R5**：pypdf `algorithm` 參數有 `AES-256-R5`，選錯比 RC4 還好破；P1 突變測試鎖 R6。
- **Agent 沒收到開關值**：必須視為開啟。反過來寫（預設關）會讓從未連線的新 Agent 整台不加密。
- **密碼進 log**：任何一處 `logger.info(request.headers)` 都會漏；helper 不 log header，FR-133 遮罩補 header 名。
- **`navigator.clipboard` 在 http＋IP 不存在**（memory `feedback_secure_context_only_browser_apis_break_on_http_ip`）：複製鈕要退回 `execCommand('copy')`；`crypto.getRandomValues` 本身可用但 F1 要實測。
- **e2e 與教育訓練會被打到**：測試環境用管理員開關關掉，不另開測試後門。
- **jedi-iam 發版**：`BOOL_KEYS` 加鍵是套件改動，主系統 pin 要跟著升；發版前決策者確認。
- **LibreOffice 轉出的 odt**：沒有原生密碼，走 AES zip 包；B2 回寫註明。

## 9. 這份文件的定位 {#positioning nav="定位"}

| 你想知道 | 看哪份 |
|---|---|
| 為什麼這樣決定、排除了什麼 | 本文件 §3、[討論稿](discussion.md)、`docs/analysis/2026-10-29-download-file-encryption-approach.md` |
| 畫面長什麼樣 | [雛形](prototype/index.html) |
| 哪一棒做了什麼、卡號 | `handoff/FR-169-LOG.md`（開卡後建） |
| 上線後現況 | SPEC 頁（D1 卡補） |
