配布 CLI 成果物を Sigstore cosign で keyless 署名する — GitHub Actions OIDC・bundle 検証・改ざんテストの設計

重岡 正 · Fri, June 26, 2026

CLI を社内や個人へ out-of-band で配るとき、たとえば Google ドライブの共有リンクで zip を渡すとき、受け取った側はその zip が「本当にあの組織のワークフローから出たもので、途中で誰にも書き換えられていない」ことを確かめる術がありません。共有リンクは差し替えられるし、ファイル名は偽れるし、ダウンロード経路のどこかで中身がすり替わっても気づけない。npm の公開レジストリ経由なら provenance(来歴)が付きますが、その保護はレジストリ経路の中だけで、レジストリの外で配る zip までは届きません。

本記事では、その隙間を Sigstore cosign の keyless 署名で埋めます。題材は配布チャネルごとのレシピを集めた公開リポジトリ cli-distribution-recipessigstore-cosign レシピで、サンプル CLI @codenote-net/hello-cli の配布成果物、つまり npm の .tgz と Google ドライブ配布用の .zip の両方に署名します。ゴールはこうです。受け取った側が次のコマンドで検証に成功し、

cosign verify-blob \
  --bundle codenote-hello-<version>.zip.bundle \
  --certificate-identity "https://github.com/codenote-net/cli-distribution-recipes/.github/workflows/sign-hello-cli-artifacts.yml@refs/heads/main" \
  --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
  codenote-hello-<version>.zip

そして 1 バイトでも改ざんされた成果物は、同じコマンドで verify が落ちる。これを、長期保管する署名鍵を一切持たずに達成します。

このレシピは、同じリポジトリで先に作ったトークンレス公開のレシピと同じ姿勢で組み立てます。保管された秘密を持たず、身元は短命の OIDC で証明する。署名鍵を金庫に入れて守り続けるのではなく、そもそも守るべき鍵を持たない、という方向です。

なぜ keyless 署名なのか

cosign の署名には、伝統的な鍵ペア署名と、Sigstore の keyless 署名の 2 通りがあります。両者の違いは、トークンレス公開で「保管された秘密」を「短命な身元証明」へ置き換えたのと、まったく同じ構図です。

鍵ペア署名は、cosign generate-key-pair で作った秘密鍵で成果物に署名します。秘密鍵とそのパスワードは、生成し、保管し、ローテートし、漏洩から守り続けなければなりません。CI で使うなら、その鍵を Secrets に置くことになり、署名鍵そのものが単一障害点になります。

keyless 署名は、保管する秘密鍵を持ちません。署名のたびに次が起こります。

  • GitHub Actions がそのワークフロー実行の OIDC トークンを発行する
  • Sigstore の認証局 Fulcio が、その身元に紐づく短命の署名証明書を発行する
  • 証明書はその場限り(数分)で失効し、署名イベントは透明性ログ Rekor に記録される

つまり「鍵を守る」という運用そのものが消えます。守るべき長期鍵がどこにもないので、漏らしようがない。代わりに「誰が署名したか」は、ワークフロー実行の OIDC 身元として証明され、検証側はその身元を pin することで「このリポジトリのこのワークフローが署名した」ことまで確かめられます。

この性質ゆえ、本レシピは keyless を既定に置きます。オフライン環境や OIDC が使えない環境では鍵ペア署名も選べますが、そのときは前述の鍵管理コストを引き受けることになります。

設計 — 2 つの成果物に署名し、検証材料を横に添える

このレシピが署名するのは、リポジトリが生む 2 つの成果物です。

  • npm パッケージの tarball、codenote-net-hello-cli-<version>.tgz
  • Google ドライブ配布用の zip、codenote-hello-<version>.zip

どちらも cosign sign-blob で署名し、署名材料は .bundle ファイルにまとめます。bundle には、署名・証明書・Rekor のエントリといった cosign verify-blob が必要とするものが一式入っているので、検証側は bundle と成果物さえあれば検証できます。

あわせて、成果物ごとに SHA-256 チェックサムファイル(.sha256)も生成します。これは cosign を入れられない環境向けの最小限の fallback です。ただしチェックサムが証明するのは「壊れていないこと」までで、「誰が作ったか」は証明しません。チェックサムファイル自体を信頼できる経路から入手できて初めて意味を持つので、可能なら必ず cosign 検証を優先します。

署名済みの配布セットは、最終的にこうなります。

codenote-net-hello-cli-<version>.tgz
codenote-net-hello-cli-<version>.tgz.bundle
codenote-net-hello-cli-<version>.tgz.sha256
codenote-hello-<version>.zip
codenote-hello-<version>.zip.bundle
codenote-hello-<version>.zip.sha256
VERIFY.md

VERIFY.md は、Google ドライブ配布を受け取った人向けの検証手順書です。ここで設計上ひとつ効かせているのが、VERIFY.md を zip の「中」ではなく「横」に置く点です。署名は zip のバイト列そのものに対して行うので、署名したあとに zip の中身を足すと署名が無効になってしまう。だから検証手順書は zip の外に、署名対象とは別ファイルとして添えます。

全体像

署名から検証までの流れを、先に図で示します。

flowchart TD
  A["メンテナが main で署名ワークフローを実行"] --> B["リポジトリを checkout"]
  B --> C["Node.js・npm・cosign を導入"]
  C --> D["npm pack で .tgz をビルド"]
  C --> E["同じ .tgz から Google ドライブ .zip をビルド"]
  D --> F["成果物ごとに SHA-256 を生成"]
  E --> F
  F --> G["GitHub Actions OIDC トークンを要求"]
  G --> H["Fulcio が短命証明書を発行し cosign sign-blob が bundle を作成"]
  H --> I["pin した identity・issuer で未改変の成果物を verify"]
  I --> J["コピーを改ざんし verify が落ちることを確認"]
  J --> K["成果物・.bundle・.sha256・VERIFY.md を upload"]
  K --> L["受け取り側が全ファイルをダウンロード"]
  L --> M["SHA-256 を照合"]
  M --> N["cosign verify-blob を実行"]
  N --> O{"identity と issuer が一致?"}
  O -- "いいえ" --> P["成果物を拒否"]
  O -- "はい" --> Q["インストール・再配布"]

左から右へ、署名側と検証側が対称に並んでいるのが見て取れます。以下、要所を順に作り込んでいきます。

署名ワークフローの中身

署名ワークフロー .github/workflows/sign-hello-cli-artifacts.ymlworkflow_dispatch で手動起動し、権限は最小に絞ります。

on:
  workflow_dispatch:
 
permissions:
  contents: read
  id-token: write
 
env:
  CERTIFICATE_IDENTITY: "https://github.com/codenote-net/cli-distribution-recipes/.github/workflows/sign-hello-cli-artifacts.yml@refs/heads/main"
  CERTIFICATE_OIDC_ISSUER: "https://token.actions.githubusercontent.com"

id-token: write が keyless 署名の肝です。これがないと GitHub Actions は OIDC トークンを発行できず、Fulcio から証明書を受け取れません。逆に言えば、署名に必要な権限はこれと checkout 用の contents: read だけで、署名鍵も cosign パスワードも GitHub Secrets には一切置きません。

ジョブの先頭では、起動 ref を main に縛ります。

      - name: Require main ref
        run: |
          set -euo pipefail
 
          if [ "$GITHUB_REF" != "refs/heads/main" ]; then
            echo "::error::This workflow signs release artifacts only from refs/heads/main. Current ref: $GITHUB_REF"
            exit 1
          fi

なぜこうするかは、後述する certificate identity の pin と表裏一体です。検証コマンドが想定する署名身元は ...sign-hello-cli-artifacts.yml@refs/heads/main に固定されているので、別ブランチから署名すると身元が食い違い、ドキュメントどおりの検証が通らなくなります。だから署名は main からしか行えないようにし、署名前に弾きます。

続いて、ツール導入と成果物のビルドです。サードパーティの Action はすべてタグではなくコミット SHA で pin します。タグは可変なので、上流が書き換えられると意図しないコードを実行してしまうためです。

      - name: Install cosign
        uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6
        with:
          cosign-release: "v3.0.6"

cosign のバージョンも v3.0.6 で固定します。検証側との挙動差を避けるためです。ビルドステップは npm pack.tgz を作り、その同じ .tgz から Google ドライブ用の .zip を組み立てます。

肝心の署名と検証は、成果物ごとにループで回します。

      - name: Sign and verify artifacts
        run: |
          set -euo pipefail
 
          cd "$GITHUB_WORKSPACE/recipes/sigstore-cosign/dist"
          shopt -s nullglob
          artifacts=( *.tgz *.zip )
 
          for artifact in "${artifacts[@]}"; do
            sha256sum "$artifact" > "$artifact.sha256"
 
            cosign sign-blob \
              --yes \
              --bundle "$artifact.bundle" \
              "$artifact"
 
            cosign verify-blob \
              --bundle "$artifact.bundle" \
              --certificate-identity "$CERTIFICATE_IDENTITY" \
              --certificate-oidc-issuer "$CERTIFICATE_OIDC_ISSUER" \
              "$artifact"
 
            "$GITHUB_WORKSPACE/recipes/sigstore-cosign/tamper-verify-fails.sh" \
              "$artifact" \
              cosign verify-blob \
              --bundle "$artifact.bundle" \
              --certificate-identity "$CERTIFICATE_IDENTITY" \
              --certificate-oidc-issuer "$CERTIFICATE_OIDC_ISSUER"
          done
 
          sha256sum --check *.sha256

ここで CI が 3 つのことを連続で確かめているのが要点です。第 1 に、成果物に署名して bundle を作る。第 2 に、その bundle を pin した identity・issuer で検証して、未改変の成果物が通ることを確かめる。第 3 に、改ざんしたコピーで検証が落ちることを確かめる。署名が「ちゃんと検証に通る」ことと「改ざんを弾く」ことの両方を、配る前に CI 自身が証明するわけです。

改ざんテストを CI に組み込む

3 つめの「改ざんを弾く」確認は、署名にとって本質的です。署名が verify に通るだけなら、検証ロジックが常に成功を返すだけのザルでも見かけ上は満たせてしまう。改ざんされた成果物で確実に落ちることまで確かめて、はじめて署名が機能していると言えます。

tamper-verify-fails.sh は、成果物のコピーに数バイト書き足して、渡された検証コマンドが「失敗する」ことを期待します。失敗を期待するので、検証が成功してしまったら逆にスクリプトがエラーで落ちます。

#!/usr/bin/env sh
set -eu
 
ARTIFACT=$1
shift
TAMPERED_ARTIFACT="$ARTIFACT.tampered"
 
cp "$ARTIFACT" "$TAMPERED_ARTIFACT"
printf '\ntampered\n' >> "$TAMPERED_ARTIFACT"
 
if "$@" "$TAMPERED_ARTIFACT" >/dev/null 2>&1; then
  printf 'Tampered artifact unexpectedly passed verification: %s\n' "$ARTIFACT" >&2
  exit 1
fi
 
printf 'tamper failure ok: %s\n' "$ARTIFACT"

手元で同じことを確かめるなら、こうです。両方とも非ゼロで終了するはずです。

cp codenote-hello-<version>.zip tampered.zip
printf '\ntampered\n' >> tampered.zip
cosign verify-blob \
  --bundle codenote-hello-<version>.zip.bundle \
  --certificate-identity "https://github.com/codenote-net/cli-distribution-recipes/.github/workflows/sign-hello-cli-artifacts.yml@refs/heads/main" \
  --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
  tampered.zip

Google ドライブ配布への組み込み

このレシピは、既存のGoogle ドライブ配布レシピを壊さずに、署名付きの配布セットを別レシピとして足しています。署名なしの素の Google ドライブレシピは recipes/google-drive/ にそのまま残し、署名付きは recipes/google-drive-with-cosign/ として並べる構成です。

署名付きで配るときは、次の 4 ファイルを必ずワンセットで Google ドライブに上げ、同じ相手に共有します。

codenote-hello-<version>.zip
codenote-hello-<version>.zip.bundle
codenote-hello-<version>.zip.sha256
VERIFY.md

zip だけを .bundle.sha256VERIFY.md 抜きで公開してはいけません。検証材料が欠けた zip は、署名なしレシピと同じく検証不能だからです。前述のとおり VERIFY.md を zip の中ではなく横に置くのは、署名後に zip を変更すると署名が無効になるためです。

検証 — identity と issuer を pin する

受け取った側は、4 ファイルを同じディレクトリに落として VERIFY.md の手順に従います。中身はこの 2 段です。まずチェックサムを照合し、

shasum -a 256 -c codenote-hello-<version>.zip.sha256

次に keyless 署名を検証します。

cosign verify-blob \
  --bundle codenote-hello-<version>.zip.bundle \
  --certificate-identity "https://github.com/codenote-net/cli-distribution-recipes/.github/workflows/sign-hello-cli-artifacts.yml@refs/heads/main" \
  --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
  codenote-hello-<version>.zip

ここで絶対に省いてはいけないのが、--certificate-identity--certificate-oidc-issuer の 2 つの pin です。この 2 つを外すと、検証は「何らかの信頼できる身元が署名した」ことまでしか証明できません。Fulcio は OIDC で認証された誰の身元に対しても証明書を発行するので、identity を pin しないと、まったく別の人物が自分の GitHub アカウントで署名した成果物でも検証を通してしまいます。「このリポジトリのこのワークフローが署名した」ことを言うには、identity と issuer を厳密に固定する必要があります。

両方のコマンドが成功して初めて、インストールへ進みます。

unzip codenote-hello-<version>.zip -d codenote-hello
cd codenote-hello
npm install -g ./*.tgz
codenote-hello
Ohayou gozaimasu, Konnichiwa, Konbanwa!

なお、ワークフローのファイル名・リポジトリ・署名 ref のいずれかを変えたら、ワークフローの CERTIFICATE_IDENTITY、レシピの README、VERIFY.md を同じ変更でまとめて更新しなければなりません。このレシピには、その食い違いを CI で検出する check-certificate-identity.sh が同梱されていて、ワークフローが宣言する identity とドキュメント側の identity が一致しているかを署名前に照合します。pin した文字列がドキュメントと CI で食い違うと、検証手順そのものが壊れるからです。

人間とエージェント向けの検証チェックリスト

検証は人間だけが行うとは限りません。CLI を取得して使う AI エージェントも、同じ規律で検証すべきです。レシピは両者向けにチェックリストを用意しています。

人間向けは、こうです。

  1. 成果物・.bundle.sha256 をダウンロードする
  2. shasum -a 256 -c <artifact>.sha256 を実行する
  3. pin した certificate identity・issuer で cosign verify-blob を実行する
  4. 両方が成功したときだけインストール・再配布する

エージェント向けは、もう一段はっきり書きます。

  1. .bundle.sha256 が欠けていたらインストールを拒否する
  2. チェックサム検証を実行する
  3. cosign verify-blob を実行する
  4. certificate identity が次の文字列に完全一致することを要求する
https://github.com/codenote-net/cli-distribution-recipes/.github/workflows/sign-hello-cli-artifacts.yml@refs/heads/main
  1. OIDC issuer が https://token.actions.githubusercontent.com に完全一致することを要求する
  2. 検証エラーや identity 不一致は、どれも hard failure として扱う

エージェントにとっての要点は、「検証エラーを警告として握りつぶさない」ことです。identity がわずかでも違えば、それは別人の署名であって、無条件に拒否すべき対象です。

ローカル smoke test

keyless 署名の証明書身元は、署名ワークフローの中でしか発行されません。とはいえ PR を出す前に、OIDC を要しない部分、つまりビルド・署名・検証・改ざん・チェックサムの一連が手元で通ることは確かめたい。そのための smoke test が同梱されています。

recipes/sigstore-cosign/local-smoke-test.sh

このスクリプトは mise を使って aqua 経由で sigstore/cosign@3.0.6 を取り、一時ディレクトリにローカルの鍵ペアを作って、.tgz.zip を署名・検証・改ざん・チェックサム照合まで回します。あくまでローカルの cosign smoke test であって、GitHub Actions の keyless 証明書身元は検証しません。その身元は署名ワークフローの中でしか発行されないからです。ここがローカルとワークフローの責務の境目です。

限界と既知の制約

このレシピも万能ではありません。配る前に、制約を明示的に把握しておきます。

  • 署名イベントは公開される: keyless 署名は Rekor という公開の透明性ログに記録される。public リポジトリなら問題ないが、署名の事実そのものが公開になる点は把握しておく。
  • 証明するのは来歴と整合性であって、ビルド時完全性ではない: 署名は「署名したバイト列が、その身元から出て、改ざんされていない」ことを言うだけで、ビルド環境やすべての依存・runner 入力の完全性までは保証しない。そこまで踏み込むなら隔離ビルド(SLSA Build L3 以上)が別途要る。これは npm provenance と同じ caveat。
  • 検証には cosign の導入が要る: ツール依存が増える。SHA-256 チェックサムは最小限の fallback だが、来歴は証明しない。
  • 本番署名は main から: certificate identity が refs/heads/main に pin されているので、署名ワークフローは main から走らせる必要がある。identity を変えるなら、ワークフロー・README・VERIFY.md を同時に更新する。
  • transport は依然 Google ドライブ: 誰がダウンロードできるかは Google ドライブの共有設定が決める。署名は「中身の真正性」を足すだけで、「アクセス制御」は別レイヤー。

これらは欠陥というより、設計上のトレードオフです。要件が前提を超えたら、OS レベルのコード署名(macOS の notarization・Windows の Authenticode)や、より厳密なビルド完全性へ段階的に引き上げる合図だと捉えてください。

まとめ

out-of-band で配る成果物に、保管鍵を持たずに真正性と整合性を足す。本記事で組み立てたのは、その最小構成です。要点を整理します。

  • 署名は GitHub Actions の OIDC を使う cosign の keyless 署名で行い、長期保管する署名鍵をゼロにする。Fulcio が短命証明書を発行し、署名イベントは Rekor に残る。
  • npm の .tgz と Google ドライブの .zip の両方に署名し、署名材料は .bundle にまとめる。cosign が入らない環境向けに SHA-256 を fallback として添える。
  • 検証では --certificate-identity--certificate-oidc-issuer を必ず pin する。pin しないと「誰かが署名した」ことしか言えず、「このワークフローが署名した」ことを言えない。
  • 改ざんテストを CI に組み込み、未改変が通ることと改ざんが落ちることの両方を、配る前に証明する。
  • 検証手順書 VERIFY.md は zip の中ではなく横に置く。署名後に zip を変えると署名が無効になるため。
  • keyless は来歴と整合性を保証するが、ビルド時完全性は保証しない。そこまで要るなら隔離ビルドへ進む。

守るべき鍵を持たなければ、鍵を漏らすこともない。署名鍵の金庫を守り続ける運用を、短命な OIDC 身元の検証に置き換える。2026 年に成果物をレジストリの外へ配るなら、これくらいの真正性の担保を既定にしておく価値があります。

以上、配布 CLI 成果物を cosign で keyless 署名する設計を、現場からお送りしました。

参考情報