最常見的產金鑰方式是在自己的筆電上跑 cosign generate-key-pair,
再 oc create secret 把它塞進叢集。
問題是私鑰在你的磁碟上留過。就算事後刪掉:
~/.bash_history 可能留著指令
編輯器的暫存 / swap 可能留著內容
備份 / 同步軟體 可能已經複製走了
SSD 的 wear leveling 刪除不等於抹除
讀完你能做到:用一次性 Job 讓私鑰全程不離開叢集,
而且保護私鑰的那組密碼也一樣——後者是很多做法漏掉的一半。
另外算清楚關掉 transparency log 之後失去什麼、能拿什麼補。
① 一條管道已經有簽章的 Task,帶著 --key=k8s://<ns>/<secret>
② cosign 映像已經在你的私有 registry 裡(它在 ghcr.io,不在 Docker Hub)
③ 叢集套用 restricted-v2 之類會指派隨機 UID 的 SCC —— 這決定了 §7
④ 叢集有一個帶 shell 和 oc 的映像可用(本文用 openshift/cli)
⚠️ 前提 ① 和這篇有先有後的關係:簽章 Task 寫得出來,但沒有這篇產的 Secret 就跑不起來。
順序是「寫好 Task → 做這篇 → 回去實跑」。
環境:OpenShift 4.21.14、cosign v2.6.4。數字都是 2026-08-22 跑出來的。
① 開一個只有 create/get secrets 的 ServiceAccount → 不要用管道那個
② 先跑一個 Job 在叢集內產密碼 → 這是多數做法漏掉的一半
③ keygen Job 要給 workingDir + emptyDir → 否則 Secret 建好但 Job 紅字
④ 決定 tlog,並把失去的那一格算出來 → 有東西補得回來
這件事不在 DAG 上:
一次性(做完就沒了)
pwgen Job ──► Secret <password>
keygen Job ──► Secret <cosign-key>
│
常駐 ▼
簽章 Task ──► --key=k8s://<ns>/<cosign-key>
金鑰產生不該是 pipeline 的一部分:pipeline 每次 commit 都跑,
而產金鑰要 create secrets 權限,管道不該長期持有。
四個會咬人的地方:
| 私鑰不落地,密碼卻落地了 | 那組密碼是私鑰靜態時的唯一保護(§6) |
| Secret 建好了,Job 卻是 Failed | 三件事疊在一起(§7) |
| 重跑會撞到 immutable | 跟 §7 疊起來會看起來像「金鑰產不出來」(§8) |
| 兩邊都關了還是有對外連線 | 跟 tlog 無關的另一條(§11) |
apiVersion: v1
kind: ServiceAccount
metadata: { name: cosign-setup, namespace: ci }
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata: { name: cosign-setup, namespace: ci }
rules:
- apiGroups: [""]
resources: ["secrets"]
verbs: ["create", "get", "update", "patch"]
四個動詞對應 cosign generate-key-pair k8s:// 的流程:先 get 看存不存在,
不存在就 create,存在就 update。
驗一次,確認邊界在該在的地方:
$ oc auth can-i create secrets --as=system:serviceaccount:ci:cosign-setup -n ci
yes
$ oc auth can-i delete secrets --as=system:serviceaccount:ci:cosign-setup -n ci
no
$ oc auth can-i get pods --as=system:serviceaccount:ci:cosign-setup -n ci
no
沒有 delete 是刻意的,理由見 §8。
管道的 SA 通常綁 ClusterRole/edit:
$ oc auth can-i get secrets --as=system:serviceaccount:ci:pipeline -n ci
yes ← edit 涵蓋 secrets 的全部動作,包含 delete
它能做這件事,但它能做的遠不只這件事。產金鑰是一次性作業,
沒有理由跑在一個長期存在、權限又大的身分底下。
⚠️ 要誠實一點: 管道 SA 綁 edit 的話,它本來就有 create(也有 delete)。
所以開一個 cosign-setup 的實際收益是不再往外擴,而不是把既有權限縮小。
簽章 Task 用
--key=k8s://讀那個 Secret,靠的也是管道 SA 的edit——
它只需要get一個特定的 Secret。§2 收的是這條路的權限,管道 SA 那條要另外處理,
本文沒做。
cosign generate-key-pair 需要一個 COSIGN_PASSWORD。常見做法是先在本機建一個 Secret:
oc create secret generic cosign-password-secret --from-literal=password='...'
⚠️ 密碼就這樣經過了本機、進了 shell history。 那組密碼是什麼?看產出的私鑰:
-----BEGIN ENCRYPTED SIGSTORE PRIVATE KEY-----
eyJrZGYiOnsibmFtZSI6InNjcnlwdCIsInBhcmFtcyI6eyJOIjo2NTUzNiwiciI6...
ENCRYPTED、scrypt。那組密碼是私鑰在靜態時的唯一保護。 詳細見 §6。
所以先跑一個 Job 把密碼也產在叢集內:
apiVersion: batch/v1
kind: Job
metadata: { name: cosign-pwgen, namespace: ci }
spec:
ttlSecondsAfterFinished: 600
template:
spec:
serviceAccountName: cosign-setup # 跟 keygen 共用,同一個信任邊界
restartPolicy: Never
containers:
- name: pwgen
image: image-registry.openshift-image-registry.svc:5000/openshift/cli:latest
command: ["/bin/sh","-c"]
args:
- |
set -e
if oc get secret cosign-password-secret -n ci >/dev/null 2>&1; then
echo "已存在,不覆蓋。"
exit 0
fi
PW=$(head -c 32 /dev/urandom | base64 | tr -d '\n')
echo "產生密碼:長度 ${#PW} 字元" # 只印長度,不印值
oc create secret generic cosign-password-secret \
-n ci --from-literal=password="$PW" >/dev/null
unset PW
echo "已建立 cosign-password-secret"
產生密碼:長度 44 字元
已建立 cosign-password-secret
三個細節:
head -c 32 /dev/urandom | base64——純 coreutils,不依賴 openssl 在不在apiVersion: batch/v1
kind: Job
metadata: { name: cosign-keygen, namespace: ci }
spec:
ttlSecondsAfterFinished: 600
backoffLimit: 0
template:
spec:
serviceAccountName: cosign-setup
restartPolicy: Never
imagePullSecrets: [{ name: <registry 憑證> }]
containers:
- name: keygen
image: <registry>/ghcr-proxy/sigstore/cosign/cosign@sha256:0d4ede48...
env:
- name: COSIGN_PASSWORD
valueFrom:
secretKeyRef: { name: cosign-password-secret, key: password }
command: ["/ko-app/cosign"]
args: ["generate-key-pair", "k8s://ci/cosign-key"]
workingDir: /work # ← 不能省,見 §7
volumeMounts:
- { name: work, mountPath: /work }
volumes:
- name: work
emptyDir: {}
k8s://ci/cosign-key 是 cosign 對 Kubernetes 的原生介面——它直接在該 namespace
建 Secret,不經過檔案系統。
Successfully created secret cosign-key in namespace ci
Public key written to cosign.pub
phase=Succeeded exit=0 scc=restricted-v2 uid=1000860000
Job: Complete 1/1 4s
⚠️ workingDir + emptyDir 那兩行看起來多餘,少了它 Job 會失敗——
而且是「Secret 其實建好了,但狀態顯示 Failed」那種最難查的失敗。理由見 §7。
immutable = true
cosign.key 653 bytes -----BEGIN ENCRYPTED SIGSTORE PRIVATE KEY-----(scrypt)
cosign.pub 178 bytes EC 公鑰
cosign.password 44 bytes ← 就是 §3 產的那組
公鑰是非機密的,把它 commit 進 repo——任何人都能拿它驗證,不必先跟叢集要東西。
immutable 那一行是 cosign 自己加的,影響很大,見 §8。
Sigstore 的預設行為是每次簽章都把記錄上傳到一個叫 Rekor 的公開透明度日誌。
好處是不可否認性——外部的人可以查到「某個時間點有人簽了這個 digest」。
--tlog-upload=false 關掉這個上傳。
關掉它常見的兩個理由:
| # | 理由 | 性質 |
|---|---|---|
| 一 | 不想把內部映像的名稱、tag、digest 送上公開日誌 | 決定 |
| 二 | 離線環境連不出去,不關會 timeout | 限制 |
先量一次自己的環境,不要假設:
$ # 從管道的 namespace 裡打
DNS OK rekor.sigstore.dev → 34.36.47.134
HTTP OK 200 (425ms)
這個環境連得到,所以理由二在這裡不成立——這條線走內部 registry 閉環是刻意的設計選擇,
不是防火牆逼出來的。
混為一談的話,讀者看到自己的環境「反正連得到」,就會以為不用關。
而理由一跟網路通不通完全無關。
理由一成立的話關掉,然後評估後果。
| 有 tlog | --tlog-upload=false |
|
|---|---|---|
| 誰簽的 | ✅ | ✅(有私鑰的人) |
| 什麼時候簽的 | ⚠️ 有一個時間,但硬度有限 | ❌ 沒有 |
| 第三方見證 | ✅ | ❌ |
| 私鑰外洩後能不能分辨新舊簽章 | ⚠️ 取決於上一格 | ❌ |
| 驗證指令 | cosign verify ... |
要多帶 --insecure-ignore-tlog=true |
⚠️ 「什麼時候簽的」那一格,有 tlog 也不算滿分。 Rekor 的時間來自它自己的時鐘,
可被竄改而不留痕跡——§10.1 引了上游的原句。
所以 §10 的 RFC 3161 時間戳不是「補回」這一格,是做得比它更硬:
TSA 的時間是被簽過的,Rekor 的不是。
那個旗標名字裡有 insecure 不是隨便取的。細節在 §9。
很多做法把 COSIGN_PASSWORD 從一個既有的 Secret 讀,理由是
「一次性 manifest 本身不留明碼」。那句話對,但停在半路——那個 Secret 是怎麼來的?
如果它是 oc create secret --from-literal 建的,密碼就經過本機了。
私鑰 全程不離開叢集 ✅
保護私鑰的密碼 經過本機 ❌
↓
這條防線的強度 = 兩者中較弱的那個
「私鑰不落地」如果只做到私鑰本身,那是把鎖做得很好、鑰匙放在門口。
cosign generate-key-pair k8s:// 執行後,會把那組密碼一併寫進產出 Secret 的cosign.password 欄位。比對兩者——但不要直接印出來比,用 SHA-256:
SHA-256
cosign-password-secret.password → ████████████████████
cosign-key.cosign.password → ████████████████████
→ 完全相同
本文不印那個雜湊值。這組密碼是 32 bytes 的
/dev/urandom,
對 256-bit 隨機值談離線破解沒有意義——不印的理由單純是
祕密的衍生資訊沒有必要外流,而且印出來也不增加任何說明價值。
要證明兩個祕密相同,比對就夠了。
--key=k8s://<ns>/<name> 這條路上,cosign 會自己讀 Secret 裡的 cosign.password。
實測:把 Task 的 COSIGN_PASSWORD 環境變數整個拿掉再簽一次——
$ cosign sign --key=k8s://ci/cosign-key ... (沒有 COSIGN_PASSWORD)
Pushing signature to: ...
exit 0
照樣成功。所以簽章 Task 裡那個 COSIGN_PASSWORD 是多餘的(無害,但可以拿掉)。
⚠️ 只有簽章那一支多餘。§4 的 keygen Job 那個不能刪——
產金鑰時它是用來加密新私鑰的輸入,不是拿來讀既有 Secret 的。
也就是說:
keygen 之前 cosign-password-secret 是輸入
keygen 之後 沒有東西再讀它 —— 簽章走的是金鑰 Secret 自己那一份
⚠️ 這件事值得知道,因為它決定了「那個密碼 Secret 能不能刪」這類問題的答案。
(但它同時也表示:刪掉它不會讓你比較安全——密碼已經在金鑰 Secret 裡了。)
所以三層改善要分清楚:
用 secretKeyRef 而不是明碼 manifest 層級不留明碼
密碼也在叢集內產(§3) 密碼從來沒有存在於叢集之外
密碼終究會出現在金鑰 Secret 裡 ← 這一點誰都改不了
workingDir 會跑不完第一次跑,如果照最小寫法(沒有 workingDir、沒有 emptyDir):
Successfully created secret cosign-key in namespace ci
Error: open cosign.pub: permission denied
error during command execution: open cosign.pub: permission denied
phase=Failed exit=1
Secret 建好了,然後 Job 失敗。
這是最糟的一種結果——東西產出來了,但狀態顯示失敗。
看 oc get jobs 的人會以為沒成功,然後去重跑;重跑會撞上另一個問題(§8)。
1. generate-key-pair k8s://... 除了寫 Secret,還會把公鑰寫一份到 cwd
2. cosign 映像沒有設 WorkingDir → cwd = /
3. restricted-v2 指派隨機 UID → uid=1000860000,寫不進 /
單獨看每一條都不奇怪,疊在一起才會炸。用比較寬鬆的 SCC 或 root 跑的話碰不到。
修法就是 §4 那兩行:
workingDir: /work
volumeMounts:
- { name: work, mountPath: /work }
那個檔寫進 emptyDir,Job 結束就跟著消失。我們要的是 Secret,
那個檔只是 cosign 順手寫的副產品——但少了可寫的地方,它會讓整個 Job 失敗。
cosign 映像是 distroless,沒有 shell——你不能 oc exec 進去 ls -la / 看權限。
要看只能靠別的手段:讀 image config 的 WorkingDir,
或另外掛一個有 shell 的容器共用同一個 volume。
generate-key-pair 要寫本地檔(cosign.pub) → 需要可寫目錄
sign 只跟 registry 和 k8s API 講話 → 不需要
不能因為其中一個要,就假設另一個也要;反過來也不行。這只能一個一個試。
immutable第二次跑(Secret 已存在):
Error: updating secret cosign-key in ns ci: Secret "cosign-key" is invalid:
data: Forbidden: field is immutable when `immutable` is set
$ oc get secret cosign-key -n ci -o jsonpath='{.immutable}'
true
這是好性質:
| ✅ 重跑 Job 不會靜默換掉金鑰 | 重跑前後的公鑰比對過,完全相同 |
| ✅ 失敗是大聲的 | 不會是預設覆蓋、然後你事後才發現簽章對不上 |
| ⚠️ 但輪替金鑰要先明確刪掉 Secret | 沒有「就地更新」這個選項 |
| ⚠️ 而且 Job 會一直顯示 Failed | 只要 Secret 還在,重跑就是紅的 |
而 §2 那個 SA 沒有 delete 權限。所以:
輪替金鑰 = 要一個權限更高的人、刻意去刪掉那個 Secret
金鑰輪替不該是「重跑一次 Job」這麼輕的動作。
第一次跑因為缺 workingDir 失敗(Secret 其實建好了)
→ 你以為沒成功 → 重跑
→ 撞上 immutable → 又一個紅的、訊息完全不同的錯誤
兩個獨立的問題疊成一個看起來很像「金鑰產不出來」的現象。
先看 Secret 在不在,再看 Job 的訊息。
§2 的 Role 給了 update / patch,但那條路實際上永遠走不通——
Secret 是 immutable 的,「存在就更新」那一支必定失敗。create + get 就夠。
本文保留四個動詞,理由是不想讓 Role 綁死在
「cosign 現在建的是 immutable Secret」這個實作細節上——
那不是文件保證的行為,是量出來的。
⚠️ 收成兩個動詞有一個代價:§8 那個 field is immutable 訊息之所以出得來,
正是因為 SA 有 update。拿掉之後第二次跑會變成 RBAC Forbidden,
§8.1 的診斷路徑會換一組完全不同的訊息。
不論保留幾個,都要知道自己給出去的東西有一部分是死的。
關掉 tlog 之後,簽章證明的是「有人拿著這把私鑰簽過」,
不再是「某某時間點有人簽過,而且有公開紀錄可查」。
差別在私鑰外洩的情境:
有 tlog 攻擊者簽的東西沒有 Rekor 記錄,或記錄的時間戳在外洩之後
→ 可以劃一條線,線之前的簽章仍然可信
沒有 tlog 攻擊者簽出來的東西,跟你正常簽的一模一樣
→ 只能把所有簽章一起作廢
⚠️ 但這條線的硬度取決於時間戳可不可信,而 Rekor 的時間有一個結構性的弱點:
它來自 log 自己的時鐘,不在 append-only 結構裡(§10.1)——你得信任 log 的營運方。
偷到你私鑰的人多半拿不到 Rekor 的寫入權,所以在這個威脅模型下,
這一格的風險落在信任邊界那一側,跟攻擊者的能力無關。
真正撐得住那條線的是 §10 的 TSA:它的時間是被簽過的,不必信任任何人的時鐘。
| 補償 | 可行性 |
|---|---|
| 自架私有 Rekor | 技術上可行,但要維運一個帶資料庫的服務 |
| 用 PipelineRun 紀錄當時間軸 | ✅ 已經有了。哪個 commit、什麼時候簽的,管道記著 |
| 靠 registry 禁止 tag 覆蓋當防竄改 | 取決於你的 writePolicy;允許覆寫的話這條不成立 |
| RFC 3161 時間戳 | ⚠️ 唯一能把「什麼時候簽的」那一格做硬的東西,見 §10 |
| 縮短金鑰生命週期 + 有輪替流程 | 值得列成待辦 |
⚠️ PipelineRun 紀錄跟 Rekor 的差別要講清楚:它是內部紀錄,能拿到叢集寫入權的人也能改它。
它擋外部竄改,不擋內部。
「劃一條線」靠的是時間戳,而時間戳不必來自 Rekor。cosign 支援 RFC 3161 的
時間戳授權中心(TSA),而且跟 --tlog-upload=false 是兩組獨立的旗標——
可以不上傳 Rekor,但保留第三方時間戳。
v2.6.4 兩邊的旗標都在(--help 實測):
簽:
--timestamp-server-url='' e.g. https://freetsa.org/tsr
--timestamp-client-cacert / -cert / -key ← 連 TSA 用 mTLS 的話
驗:
--use-signed-timestamps=false verify rfc3161 timestamps
--timestamp-certificate-chain=''
這點反直覺。Sigstore 自己講得很直白:
this timestamp comes from Rekor's internal clock, which is not externally verifiable,
and a timestamp is not a part of the node that goes into the append-only data structure
that backs Rekor, meaning the timestamp is mutable in Rekor without detection
TSA 的時間戳是被簽過的,所以「the time becomes immutable and verifiable」。
Rekor 的時間 來自它自己的時鐘,不在 append-only 結構裡 → 可被竄改而不留痕跡
TSA 的時間 被 TSA 的金鑰簽過 → 不可變、可驗證
--record-creation-timestamp 搞混--record-creation-timestamp=false:
set the createdAt timestamp in the signature artifact to the time it was created;
by default, cosign sets this to the zero value
那是自己寫給自己看的時間。拿著私鑰的人想寫什麼都可以。
它預設是 zero value 反而是對的——一個不可信的時間戳比沒有時間戳更糟。
| 做法 | 代價 |
|---|---|
公開 TSA(freetsa.org、商業 CA) |
又是一條對外連線 |
自架 sigstore/timestamp-authority |
要維運一個服務,但比自架 Rekor 輕——Rekor 要資料庫,TSA 只要一把簽章金鑰 |
那條對外連線跟 Rekor 那條送出去的東西差很多:
Rekor 映像名稱、tag、digest → 內部命名規則和交付頻率都露了
TSA 簽章位元組的雜湊 → 對方不知道你在簽什麼
上游把這個做法叫 countersigning:
cosign has chosen to sign the artifact signature, a process called countersigning.
The signing is done over the raw signature bytes ...
Signing over the signature ensures that the signature, not the artifact,
was created at a certain time
這一句讓 §9 那段推論更緊:私鑰外洩要劃的那條線,要證明的正是
「這個簽章是什麼時候產生的」,而 countersigning 蓋的剛好就是這個。
⚠️ 本文沒有驗證它在 v2.6.4 + --key + 自架 registry
這個組合下實際跑不跑得起來。
方向是明確的:§5.2 那張表的「什麼時候簽的」不是死路,只是要多做一件事。
簽的時候 --tlog-upload=false 不上傳 Rekor
驗的時候 --insecure-ignore-tlog 不查 Rekor
↓
但 cosign verify 仍然會去 Sigstore 的 TUF repo 抓 trusted_root.json
兩邊都關了,還是有一條對外連線。它是軟相依,抓失敗不影響驗證結果,
但它會出現在 egress 稽核紀錄裡。
--tlog-upload=false處理的是「簽章資訊不外傳」,那件事它確實做到了。
但如果你以為它等於「這條線不對外發封包」,那不成立。
ttlSecondsAfterFinished: 600 讓 Job 跑完十分鐘後自動清掉,叢集不留殘骸。很好。
但如果你的環境重啟會清掉 pod log:
Job 自動清除 + 重啟清 log = 這件事跑完就沒有任何現場可以回頭看
而金鑰產生正好是那種「一年跑一次、出事時最想知道當時發生什麼」的作業。
做法:跑完先把 log 撈下來留底,再讓 ttl 自己清。
logs/
cosign-keygen.log 第一次(缺 workingDir,Secret 建好但 exit 1)
cosign-keygen-rerun.log 第二次(撞 immutable)
cosign-keygen-clean.log 第三次(乾淨,exit 0)
三份都留著,因為那兩個失敗的訊息比成功的有價值得多。
ttlSecondsAfterFinished保留是對的,但不要靠它之後還查得到東西。
自動清理和可稽核性是兩個相反方向的需求,要各自處理。
edit 太大,本文沒改。k8s://ns/name 的用法,以及產出 Secret 的三個欄位(含 cosign.password)--insecure-ignore-tlog 的語意ttlSecondsAfterFinished —— §12 的自動清理