Share Notes

chundev

View the Project on GitHub latteouka/share-notes

瀏覽器上傳憑證、host 端安裝——5 層 validation 的 trust boundary 設計

日期:2026-05-23


TL;DR

設計「使用者從瀏覽器上傳 TLS 憑證 → host 端用 systemd 跑 install 腳本」這種 feature 時,validation 必須分 5 層、每層假設前一層被繞過:① 瀏覽器檔案預檢、② server PEM 解析預覽、③ tRPC mutation 入口再驗、④ host 端 openssl modulus 配對驗(唯一能驗私鑰的層)、⑤ 安裝後 nginx 健檢 + 自動 rollback。每層擋的是不同類型的問題,不能合併。


背景

需求:客戶 IT 從瀏覽器上傳 server 憑證 → host 端的 root daemon(例如 systemd timer 啟動的安裝腳本)寫進 nginx 的 cert 目錄、重啟 nginx。

兩個關鍵限制決定了 trust 模型:

  1. 私鑰留 host——CSR 對應的 private key 是先前在 host 用 openssl genrsa 產的,永不離開 host。瀏覽器只經手 CSR(公開物)跟簽好的憑證(公開物)。
  2. 整條鏈跨多重 trust boundary——browser(無信任)→ React state(受控但可改)→ tRPC server(半信)→ 共享 spool volume → host root daemon → nginx process。每個 hop 都是潛在攻擊面。

光靠單一驗證點防不住。下面拆 5 層。


5 層 validation 全表

在哪 檢查什麼 不通過 →
① 瀏覽器檔案預檢 client side, FileReader 檔案 ≤ 1MB、含 -----BEGIN CERTIFICATE----- UI toast 報錯、state 不變、永不送 server
② server PEM 預覽解析 tRPC inspect query crypto.X509Certificate 解析、未過期、chain checkIssued 完整 預覽卡顯紅字錯誤、「安裝」按鈕保持 disabled(gated on inspect.data.valid
③ mutation 入口再驗(不信前端) tRPC installCert mutation 第一行 重做 ② 的完整檢查;對選填的 chain string 也檢查 PEM header throw TRPCError({code:"BAD_REQUEST"})根本不寫 spool、不建 audit row
④ openssl 權威配對(驗私鑰) host install script [1/5] openssl x509 -modulus \| md5 vs openssl rsa -modulus \| md5——cert 公鑰的 modulus 必須等於 host 端 private key 的 modulus print ERROR: cert 與 key 不配對exit 1完全不碰 nginx cert 目錄
⑤ 安裝後健檢 + 自動 rollback host install script [5/5] 寫入後 docker compose restart nginx + curl -sk https://localhost/;2xx/3xx/401/404 = ✅,其他 = ❌ 自動 cp .bak-<ts> 回原檔、再 restart、再 curl 確認——還原到安裝前的可用狀態

Trust boundary 分析:為什麼每層不能合併

為什麼 ③ 必須重做 ② 的事?

② 在瀏覽器的 UI 邏輯(disable 按鈕、顯紅字)對「正常使用者」有效,但對攻擊者完全無效

所以 server-side mutation handler 必須假設「client 可能是惡意的」,把 inspect 在 mutation 入口再跑一次。這是「不信前端」的標準防禦——任何接受外部請求的 server endpoint 都要這樣做,不限於 cert 場景。

為什麼 ② 跟 ④ 不能合併?

它們驗的是不同事實

驗的事 在哪能驗 為什麼
「PEM 內容合法、沒過期、issuer/leaf chain 連得起來」 server 端 (用 Node crypto) 不需要私鑰
「這張 cert 是這台 host 先前產的 CSR 簽出來的」 host 端 (用 openssl modulus) 必須對比 cert 公鑰跟 host 端 server.key 的公鑰;只有 host 看得到 server.key

舉幾個邊界例子:

私鑰從來不離開 host 是設計核心,這也順帶決定了 server 沒法做配對驗——如果 server 端能驗配對,代表 server 端拿得到私鑰,那麼任何 server-side compromise(SQL injection、RCE)都會洩漏私鑰。「server 不該有私鑰」與「server 沒法做配對」是同一個決定的兩面。

為什麼還需要 ⑤?

①②③④ 全通過、cert 寫入 nginx 目錄、docker compose restart nginx——nginx 還是有可能起不來。原因例如:

如果 nginx 起不來、又沒 rollback,操作者(沒 host 權限的客戶 IT)會看到網站掛掉、自己救不回來。所以 install 腳本最後一步是:寫入後 restart → curl healthcheck → 不健康就把剛備份的 .bak 還原 + 再 restart + 再 curl。寫法很短:

# [3/5] 一定先備份
BACKED_UP=0
[ -f "$CERT_DIR/fullchain.pem" ] && {
  cp "$CERT_DIR/fullchain.pem" "$CERT_DIR/fullchain.pem.bak-$TS"
  BACKED_UP=1
}

# [4/5] 寫入新 cert
cp "$NEW_CERT" "$CERT_DIR/fullchain.pem"

# [5/5] 重啟 + 健檢,失敗自動 rollback
CODE=000
if cc_restart_nginx; then sleep 2; CODE=$(cc_healthcheck); fi
if cc_healthcheck_ok "$CODE"; then exit 0; fi

echo "❌ nginx 健檢失敗 (HTTP $CODE) — 自動回退..." >&2
if [ "$BACKED_UP" -eq 1 ]; then
  cp "$CERT_DIR/fullchain.pem.bak-$TS" "$CERT_DIR/fullchain.pem"
  cc_restart_nginx; sleep 2
fi
exit 1

關鍵設計:cc_restart_nginx 包進 if不讓 set -e 在 restart 失敗時直接 abort——abort 就跳過後面的 rollback 了,那是最糟結果(cert 換了、nginx 掛了、沒人救)。


學到的事


參考資料