iT邦幫忙

2026 iThome 鐵人賽

DAY 27
0

最常見的產金鑰方式是在自己的筆電上跑 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 跑出來的。


1. 三分鐘版

① 開一個只有 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)

Part 1 — 快樂路徑

2. 第 1 步:最小權限的身分

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。

2.1 不要用管道那個 ServiceAccount

管道的 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 那條要另外處理
本文沒做。

3. 第 2 步:密碼也在叢集內產

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...

ENCRYPTEDscrypt那組密碼是私鑰在靜態時的唯一保護。 詳細見 §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 在不在
  • 只印長度不印值——Job 的 log 會被人看、會被收集
  • 已存在就跳過——保持冪等。重跑一次設定不該靜默改動叢集狀態
    (換掉它其實不會讓既有金鑰解不開,理由見 §6.2——但那不構成可以亂換的理由)

4. 第 3 步:keygen Job

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。

4.1 產出的 Secret

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。

5. 第 4 步:決定 tlog

Sigstore 的預設行為是每次簽章都把記錄上傳到一個叫 Rekor 的公開透明度日誌。
好處是不可否認性——外部的人可以查到「某個時間點有人簽了這個 digest」。

--tlog-upload=false 關掉這個上傳。

5.1 先分清楚「限制」和「決定」

關掉它常見的兩個理由:

# 理由 性質
不想把內部映像的名稱、tag、digest 送上公開日誌 決定
離線環境連不出去,不關會 timeout 限制

先量一次自己的環境,不要假設:

$ # 從管道的 namespace 裡打
DNS  OK    rekor.sigstore.dev → 34.36.47.134
HTTP OK    200  (425ms)

這個環境連得到,所以理由二在這裡不成立——這條線走內部 registry 閉環是刻意的設計選擇,
不是防火牆逼出來的。

混為一談的話,讀者看到自己的環境「反正連得到」,就會以為不用關。
而理由一跟網路通不通完全無關。

理由一成立的話關掉,然後評估後果。

5.2 關掉之後失去什麼

有 tlog --tlog-upload=false
誰簽的 ✅(有私鑰的人)
什麼時候簽的 ⚠️ 有一個時間,但硬度有限 ❌ 沒有
第三方見證
私鑰外洩後能不能分辨新舊簽章 ⚠️ 取決於上一格
驗證指令 cosign verify ... 要多帶 --insecure-ignore-tlog=true

⚠️ 「什麼時候簽的」那一格,有 tlog 也不算滿分。 Rekor 的時間來自它自己的時鐘,
可被竄改而不留痕跡——§10.1 引了上游的原句。

所以 §10 的 RFC 3161 時間戳不是「補回」這一格,是做得比它更硬:
TSA 的時間是被簽過的,Rekor 的不是。

那個旗標名字裡有 insecure 不是隨便取的。細節在 §9。


Part 2 — 細節探討

6. 為什麼「私鑰不落地」不夠

很多做法把 COSIGN_PASSWORD 從一個既有的 Secret 讀,理由是
「一次性 manifest 本身不留明碼」。那句話對,但停在半路——那個 Secret 是怎麼來的?

如果它是 oc create secret --from-literal 建的,密碼就經過本機了。

私鑰            全程不離開叢集     ✅
保護私鑰的密碼   經過本機          ❌
                    ↓
        這條防線的強度 = 兩者中較弱的那個

「私鑰不落地」如果只做到私鑰本身,那是把鎖做得很好、鑰匙放在門口。

6.1 密碼確實會被複製進金鑰 Secret

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 隨機值談離線破解沒有意義——不印的理由單純是
祕密的衍生資訊沒有必要外流,而且印出來也不增加任何說明價值。
要證明兩個祕密相同,比對就夠了。

6.2 而且簽章根本不會再讀那份輸入

--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 裡     ← 這一點誰都改不了

7. 沒有 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)。

7.1 三件事疊在一起

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 失敗。

7.2 查錯上的陷阱

cosign 映像是 distroless,沒有 shell——你不能 oc exec 進去 ls -la / 看權限。
要看只能靠別的手段:讀 image config 的 WorkingDir
或另外掛一個有 shell 的容器共用同一個 volume。

7.3 同一支二進位,不同子指令要求不同

generate-key-pair    要寫本地檔(cosign.pub)      →  需要可寫目錄
sign                 只跟 registry 和 k8s API 講話 →  不需要

不能因為其中一個要,就假設另一個也要;反過來也不行。這只能一個一個試。

8. cosign 把 Secret 建成 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」這麼輕的動作。

8.1 它跟 §7 疊起來很難查

第一次跑因為缺 workingDir 失敗(Secret 其實建好了)
        → 你以為沒成功 → 重跑
        → 撞上 immutable → 又一個紅的、訊息完全不同的錯誤

兩個獨立的問題疊成一個看起來很像「金鑰產不出來」的現象。

先看 Secret 在不在,再看 Job 的訊息。

8.2 那個 Role 給多了

§2 的 Role 給了 update / patch,但那條路實際上永遠走不通——
Secret 是 immutable 的,「存在就更新」那一支必定失敗。create + get 就夠。

本文保留四個動詞,理由是不想讓 Role 綁死在
「cosign 現在建的是 immutable Secret」這個實作細節上——
那不是文件保證的行為,是量出來的。

⚠️ 收成兩個動詞有一個代價:§8 那個 field is immutable 訊息之所以出得來,
正是因為 SA 有 update。拿掉之後第二次跑會變成 RBAC Forbidden
§8.1 的診斷路徑會換一組完全不同的訊息

不論保留幾個,都要知道自己給出去的東西有一部分是死的。

9. 關掉 tlog 之後失去什麼

關掉 tlog 之後,簽章證明的是「有人拿著這把私鑰簽過」,
不再是「某某時間點有人簽過,而且有公開紀錄可查」。

差別在私鑰外洩的情境:

有 tlog     攻擊者簽的東西沒有 Rekor 記錄,或記錄的時間戳在外洩之後
            → 可以劃一條線,線之前的簽章仍然可信

沒有 tlog   攻擊者簽出來的東西,跟你正常簽的一模一樣
            → 只能把所有簽章一起作廢

⚠️ 但這條線的硬度取決於時間戳可不可信,而 Rekor 的時間有一個結構性的弱點:
它來自 log 自己的時鐘,不在 append-only 結構裡(§10.1)——你得信任 log 的營運方。

偷到你私鑰的人多半拿不到 Rekor 的寫入權,所以在這個威脅模型下,
這一格的風險落在信任邊界那一側,跟攻擊者的能力無關。
真正撐得住那條線的是 §10 的 TSA:它的時間是被簽過的,不必信任任何人的時鐘。

9.1 能拿什麼補

補償 可行性
自架私有 Rekor 技術上可行,但要維運一個帶資料庫的服務
用 PipelineRun 紀錄當時間軸 ✅ 已經有了。哪個 commit、什麼時候簽的,管道記著
靠 registry 禁止 tag 覆蓋當防竄改 取決於你的 writePolicy;允許覆寫的話這條不成立
RFC 3161 時間戳 ⚠️ 唯一能把「什麼時候簽的」那一格做硬的東西,見 §10
縮短金鑰生命週期 + 有輪替流程 值得列成待辦

⚠️ PipelineRun 紀錄跟 Rekor 的差別要講清楚:它是內部紀錄,能拿到叢集寫入權的人也能改它。
它擋外部竄改,不擋內部。

10. RFC 3161 時間戳

「劃一條線」靠的是時間戳,而時間戳不必來自 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=''

10.1 TSA 的時間比 Rekor 的可信

這點反直覺。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 的金鑰簽過                          →  不可變、可驗證

10.2 別跟 --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 反而是對的——一個不可信的時間戳比沒有時間戳更糟

10.3 代價,以及它跟 tlog 送出去的東西不同

做法 代價
公開 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 那張表的「什麼時候簽的」不是死路,只是要多做一件事。

11. 「閉環」還有一個洞

簽的時候   --tlog-upload=false        不上傳 Rekor
驗的時候   --insecure-ignore-tlog     不查 Rekor
                     ↓
   但 cosign verify 仍然會去 Sigstore 的 TUF repo 抓 trusted_root.json

兩邊都關了,還是有一條對外連線。它是軟相依,抓失敗不影響驗證結果,
但它會出現在 egress 稽核紀錄裡。

--tlog-upload=false 處理的是「簽章資訊不外傳」,那件事它確實做到了。
但如果你以為它等於「這條線不對外發封包」,那不成立。

12. 一次性資源的紀錄問題

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 保留是對的,但不要靠它之後還查得到東西。
自動清理和可稽核性是兩個相反方向的需求,要各自處理。

13. 這篇沒涵蓋的

  • 金鑰輪替流程:§8 說明了為什麼輪替要刻意,但沒有寫出那個流程。
  • TSA 實作:§10 只確認旗標存在,沒有跑通。
  • 收緊簽章 Task 的讀取權限:§2.1 提到管道 SA 的 edit 太大,本文沒改。
  • 自架 Rekor:列在補償表裡,沒有做。

14. 參考文件

cosign 與 Kubernetes Secret

  • cosign:Key Management
    —— k8s://ns/name 的用法,以及產出 Secret 的三個欄位(含 cosign.password)

RFC 3161 時間戳

Rekor 與透明度日誌

Kubernetes


上一篇
Day 26:數位簽章硬化——在沒有 shell 的映像裡簽章
系列文
防範軟體供應鏈攻擊:從零打造具備硬性阻擋能力的雲原生 CI/CD 流水線27
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言