應用程式不應在設定檔中要求密碼、API 金鑰或權杖。

設定描述行為。它應存放在 git、程式碼審查、錯誤報告,以及開發人員的機器中。

機密授予權限。它需要受限的存取權限與獨立的輪替機制。

將兩者放在同一個檔案中,會將不同的生命週期與受眾耦合在一起。如果輪替密碼需要重新產生應用程式設定,表示介面已過度耦合。

NixOS 包含 110 種因應措施

Section titled “NixOS contains 110 workarounds for this”

我們審核了 nixpkgs 在 commit 141f212 時,所有處理真實機密的 445 個 NixOS 模組,並依據機密值的最終去向進行分類。

機密值的最終去向模組數比例
在執行時合併到設定檔中11025%
內嵌到 /nix/store 中的設定檔429%
以環境變數形式傳遞16136%
留在應用程式開啟的獨立檔案中5813%
透過 systemd credentials 載入5312%
作為命令列引數傳遞194%
分類不明確2

值得關注的數字是 110。有四分之一的模組安全地取得機密,卻因為應用程式只接受這種介面,而將機密複製到設定中。

這些模組使用 envsubstreplace-secretjqyqsed 或自訂程式碼,在啟動時組裝受限制的檔案。結果可能安全,但每個模組都必須負責應用程式專屬且具安全敏感性的膠水程式碼,只為了將兩個本應分開的輸入結合在一起。

這並非 NixOS 特有。相同的因應措施也會以 entrypoint 腳本、Helm template、init container 或 CI 插值步驟的形式出現在其他平台上。

附帶一提,有 42 個模組會將機密內嵌到全域可讀的 /nix/store 中。這個直接的安全問題已在 nixpkgs issue #24288 中追蹤。而這 110 個執行時合併的案例則說明更廣泛的問題:即使部署作者避免了外洩,缺少分離仍會產生額外工作。

為機密提供專屬介面

Section titled “Give secrets their own interface”

應用程式應透過專屬的執行階段通道接受機密值,例如:

  • password_filetoken_file 設定;
  • systemd credential;
  • 範圍明確的環境變數;
  • 或外部機密提供者。

這些機制並非同等安全:環境變數可能被繼承、引數可能出現在程序清單中,而檔案仍需正確的權限。分離所保證的是,部署者不必再製造另一個帶有機密的設定版本。

原則很簡單,但在各種環境中實作並不容易。本機開發可能使用系統金鑰環、CI 可能使用環境變數,而正式環境可能使用 1Password 或 Vault。若沒有共同的抽象層,每個環境都需要自己的命名、查詢、驗證與注入膠水程式碼。

我在 Cachix 中犯下的錯誤

Section titled “How I got it wrong in Cachix”

Cachix 過去將其驗證權杖與各快取的簽署金鑰儲存在 ~/.config/cachix/cachix.dhall,與快取名稱及其他設定放在一起。這樣很方便,但即使檔案中大部分是普通設定,仍必須將整個檔案視為機密。

典型的檔案會直接混合兩者:

{ authToken = "XXX-AUTH-TOKEN"

, binaryCaches =

[ { name = "mycache"

, secretKey = "XXX-SIGNING-KEY"

}

]

}

快取名稱是設定;驗證權杖與簽署金鑰則是機密。你無法分享快取設定而不同時分享憑證。

devenv 2.2 透過 SecretSpec 將權杖分離。專案宣告 CACHIX_AUTH_TOKEN,devenv 從設定的提供者解析它,並將值傳遞給 Cachix,而不會加入 devenv 的設定中。

Cachix PR #737 透過 SecretSpec Haskell SDK 將相同的邊界帶入用戶端。它從 SecretSpec 解析 CACHIX_AUTH_TOKENCACHIX_SIGNING_KEY,並可將它們儲存在使用者選擇的提供者中,而非 cachix.dhall。現有的環境變數與設定檔仍保留較高的優先順序以維持相容性。此 PR 仍在開放中,尚未包含在已發行的 Cachix 版本中。

這正是 SecretSpec 所要解決的問題:設定宣告需求,而每個環境選擇值的存放位置。

宣告一次,隨處解析

Section titled “Declare once, resolve anywhere”

SecretSpec 透過讓 secretspec.toml 成為應用程式所需內容的宣告(而不儲存值)來實現這種分離:

[project]

name = "myapp"

[profiles.production]

DATABASE_URL = { description = "Postgres connection string" }

STRIPE_API_KEY = { description = "Stripe secret key" }

Providers 決定值的存放位置。開發者可以使用系統金鑰環、CI 可以使用環境變數,而正式環境可以使用 1Password、Vault/OpenBao 或雲端機密管理員,而無需更改宣告。

現有應用程式可以在啟動時接收解析後的值:

secretspec run -- ./myapp

應用程式也可以透過 SecretSpec SDKs 直接解析,支援 Rust、Python、Go、Ruby、Node.js/TypeScript、Haskell、PHP 與 C#,所有語言共用相同的解析器,讓行為在各語言間保持一致。

Providers 負責機密值的來源。SDKs 為應用程式提供慣用的取用方式。設定則維持為所需內容的可分享宣告。

讓邊界更實用

Section titled “Making the boundary practical”

如果你維護應用程式,請停止將密碼與權杖加入普通設定結構。改為接受檔案參照、credential、環境變數或提供者。

對於 NixOS,SecretSpec issue #65 追蹤如何透過官方整合宣告與解析機密,而無需每個模組都實作替換膠水程式碼。

在開發人員機器、CI 與正式環境之間保持一致的機密處理,過去需要只有專門的平台團隊才能建置的基礎架構。任何規模的專案都應該能夠在不必先建立自己的機密平台的情況下,將機密與設定分離。