-
Notifications
You must be signed in to change notification settings - Fork 0
feat(git): sign commits with a Secure Enclave key on M4Pro #10
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 4 commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
5aeac9c
docs(git): add Secure Enclave commit signing runbook
xrryx 68c085e
feat(git): sign commits with a Secure Enclave key on M4Pro
xrryx e5a5648
feat(git): verify commit signatures locally via allowedSignersFile
xrryx 5a04866
docs(git): address Secure Enclave review feedback
xrryx 71fd516
docs(git): verify exported Secure Enclave handles
xrryx File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,267 @@ | ||
| # Secure Enclave によるコミット署名 | ||
|
|
||
| Git のコミット署名鍵を macOS の Secure Enclave で管理する。1Password の SSH agent | ||
| (`op-ssh-sign`) からの移行。**署名のみ**が対象で、push/pull の SSH 認証は 1Password のまま。 | ||
|
|
||
| 参考: <https://www.mizdra.net/entry/2026/08/07/101542> | ||
|
|
||
| ## 仕組み | ||
|
|
||
| - `sc_auth` で Secure Enclave 内に CTK identity (秘密鍵 + 自己署名証明書) を作る。 | ||
| - `/usr/lib/ssh-keychain.dylib` が、その identity を **FIDO セキュリティキー**として | ||
| OpenSSH に見せる。鍵タイプは `sk-ecdsa-sha2-nistp256@openssh.com`。 | ||
| - `ssh-keygen` に `SSH_SK_PROVIDER` で dylib を教えるだけなので、**常駐エージェントは不要** | ||
| (Secretive のような GUI アプリも login item も要らない)。 | ||
| - `~/.ssh/id_git_sign` は鍵ハンドルであって秘密鍵ではない。秘密鍵は Secure Enclave から出せない。 | ||
| ただし現在の `-t none` は使用時認証を行わないため、同じユーザー権限で動くプロセスは | ||
| ユーザー操作なしで署名を要求できる。Secure Enclave が防ぐのは秘密鍵の持ち出しであり、 | ||
| ローカルプロセスによる鍵の使用ではない。 | ||
|
|
||
| ### 前提 | ||
|
|
||
| - macOS 14 (Sonoma) 以降。動作確認は macOS 26.5.2 / OpenSSH 10.2p1。 | ||
| - **`ssh-keygen` は常に `/usr/bin/ssh-keygen` (Apple ビルド) を絶対パスで呼ぶこと。** | ||
| `ssh-keychain.dylib` は Apple ビルドの OpenSSH と組み合わせる前提で、nix の `pkgs.openssh` | ||
| では動く保証がない。現状 `ssh-keygen` は PATH 上に `/usr/bin` のものしか無いので bare でも | ||
| 同じだが、将来 nix 側で openssh を入れると静かに壊れる。 | ||
|
|
||
| ## 現在の構成 | ||
|
|
||
| | 項目 | 値 | | ||
| |---|---| | ||
| | M4Pro の signer | Secure Enclave (`hosts/M4Pro/default.nix` で共有設定を上書き) | | ||
| | M4Air の signer | 1Password (`home/git.nix` の共有既定値) | | ||
| | identity label | `git-sign` | | ||
| | private key protection | `none` (署名時の Touch ID なし) | | ||
| | public key hash (SHA1) | `D93C59C037113F3418EAAB2E4EA5277EB34DCE1B` (現在の M4Pro の値) | | ||
| | SSH fingerprint | `SHA256:AsbsNi30tI7r4kUqNSHew9DI86raUYeHeYP3MLCjpck` (現在の M4Pro の値) | | ||
| | 証明書有効期限 | **2036/08/07** (作成時は 2027/08/10 = 1年。下記手順で10年に更新済み) | | ||
| | GitHub signing key | M4Pro: id=1103015 `Secure Enclave (M4Pro, signing)` / M4Air: id=805051 `1Password (signing)` | | ||
| | allowed signers | `home/git.nix` の `allowedSignerKeys` から生成 | | ||
|
|
||
| `~/.ssh/allowed_signers` は Home Manager が Nix store へのシンボリックリンクとして管理する。 | ||
| 鍵を追加・変更するときはこのファイルを直接編集せず、`home/git.nix` を更新して rebuild する。 | ||
|
|
||
| ### Git 側の設定 | ||
|
|
||
| `ssh-keygen` に `SSH_SK_PROVIDER` を渡すラッパーが要る。Git は `gpg.ssh.program` に | ||
| 環境変数を渡せないため、`hosts/M4Pro/default.nix` の `sshSign` でラッパーを生成している。 | ||
|
|
||
| `user.signingkey` は 1Password 時代のリテラル公開鍵ではなく、**ハンドルファイルのパス** | ||
| (`~/.ssh/id_git_sign`) を指定する。sk 鍵はハンドルが無いと署名できない。 | ||
|
|
||
| この鍵は M4Pro にしか存在しないので、`home/git.nix` (全ホスト共有) に直接書くと | ||
| **M4Air が次の rebuild でコミット不能になる**。そのため共有設定は 1Password を | ||
| `lib.mkDefault` のまま維持し、M4Pro のホスト設定だけで鍵と signer を上書きしている。 | ||
|
|
||
| ### Signing key のローテーション | ||
|
|
||
| GitHub の `id=805051` `1Password (signing)` は M4Air が現在も使う**現役鍵**なので、 | ||
| M4Air を移行するまでは削除しない。GitHub は一度 Verified にしたコミットの検証記録を | ||
| repository network 内で永続化するため、登録鍵を後から削除しても、その記録がある過去コミットは | ||
| Verified のままになる。ただし削除後に初めて push するコミットや、別の repository network で | ||
| 初めて検証するコミットには登録鍵が必要になる。 | ||
|
|
||
| ローカル検証は GitHub と異なり、毎回 `~/.ssh/allowed_signers` を参照する。鍵を作り直したら | ||
| `home/git.nix` の `allowedSignerKeys` に新しい公開鍵を**追記**し、過去の署名を検証するために | ||
| 古い鍵も残す。鍵が全ホストで退役した後は、古い鍵の先頭に | ||
| `valid-before="YYYYMMDDHHMMSSZ"` を付けると、切り替え前の署名だけを有効にできる。 | ||
| `allowedSignerKeys` では | ||
| `"valid-before=\"YYYYMMDDHHMMSSZ\" ssh-ed25519 AAAA..."` のように引用符をエスケープする。 | ||
|
|
||
| 参考: | ||
|
|
||
| - [GitHub: Persistent commit signature verification](https://docs.github.com/en/authentication/managing-commit-signature-verification/about-commit-signature-verification#persistent-commit-signature-verification) | ||
| - [Git: `gpg.ssh.allowedSignersFile`](https://git-scm.com/docs/git-config#Documentation/git-config.txt-gpgsshallowedSignersFile) | ||
|
|
||
| ## 新規セットアップ | ||
|
|
||
| ```bash | ||
| # 1. Secure Enclave に鍵を作る | ||
| # -k p-256-ne : ECDSA P-256 / non-exportable (Secure Enclave は P-256 のみ対応) | ||
| # -t none : 使用時認証なし。同じユーザー権限のプロセスは操作なしで署名できる | ||
| # 使用時認証が必要なら -t bio にして署名ごとに Touch ID を要求する | ||
| sc_auth create-ctk-identity -l git-sign -k p-256-ne -t none -N git-sign | ||
|
|
||
| # 2. SSH 鍵ハンドルを書き出す (カレントディレクトリに生成されるので注意) | ||
| cd "$(mktemp -d)" | ||
| /usr/bin/ssh-keygen -w /usr/lib/ssh-keychain.dylib -K -N "" | ||
| mv id_ecdsa_sk_rk ~/.ssh/id_git_sign | ||
| mv id_ecdsa_sk_rk.pub ~/.ssh/id_git_sign.pub | ||
|
|
||
| # 3. GitHub に Signing Key として登録する | ||
| # Authentication key の枠に入れても署名検証は有効にならない | ||
| gh api -X POST /user/ssh_signing_keys \ | ||
| -f title="Secure Enclave ($(scutil --get LocalHostName), signing)" \ | ||
| -f key="$(cat ~/.ssh/id_git_sign.pub)" | ||
|
|
||
| # 4. dotfiles に記録する値を表示する | ||
| cat ~/.ssh/id_git_sign.pub | ||
| /usr/bin/ssh-keygen -lf ~/.ssh/id_git_sign.pub | ||
| sc_auth list-ctk-identities -t sha1 -e hex | ||
| ``` | ||
|
|
||
| 公開鍵の出力を `home/git.nix` の `allowedSignerKeys` に追記する。過去コミットの検証に必要なので、 | ||
| 既存の鍵は削除しない。併せて fingerprint、public key hash、GitHub key ID を上の「現在の構成」に | ||
| 反映する。`home/git.nix` とこの文書を保存した後、Home Manager を適用する。 | ||
|
|
||
| ```bash | ||
| # 5. dotfiles を反映する | ||
| nix run "$HOME/.dotfiles#switch" | ||
| ``` | ||
|
|
||
| `-t none` でも署名には FIDO の user-presence フラグが立つが、provider が操作なしで付けるため、 | ||
| 対話的な user presence の証明にはならない。`allowed_signers` に `no-touch-required` を付ける必要はなく、 | ||
| 付けると OpenSSH が `unknown key option` で落ちる。 | ||
|
|
||
| 作成直後の証明書は**有効期限が1年**しかないので、続けて「証明書の更新」を実行して | ||
| 10年に伸ばしておくこと。 | ||
|
|
||
| ## 証明書の更新 | ||
|
|
||
| ### 期限の正体 | ||
|
|
||
| 期限切れになるのは **Secure Enclave の鍵そのものではなく、CTK identity をラップする | ||
| X.509 証明書**。`sc_auth` に有効期間を指定するオプションは無く、作成から**1年固定**。 | ||
|
|
||
| SSH はこの証明書を使わない (生の公開鍵しか見ない) ので、**証明書を差し替えても SSH 公開鍵は | ||
| 変わらない**。よって GitHub 側の再登録は不要。 | ||
|
|
||
| この identity は既に10年証明書 (2036/08/07) に更新済みなので、当面この作業は不要。 | ||
| 新しいマシンで作り直したときは、作成直後に一度この手順を通しておくとよい。 | ||
|
|
||
| 期限切れ後の挙動は未確認。`man ssh-keychain` に | ||
|
|
||
| > By default, all **valid** ... identities from all SmartCards and persistent tokens | ||
| > currently available in the system are provided. | ||
|
|
||
| とあり、期限切れの identity が OpenSSH に提供されなくなる可能性がある。`-t none` にしている | ||
| ため Touch ID の失敗としては現れず、**ある日いきなり `git commit` が署名エラーで落ちる** | ||
| 形になる。`commit.gpgsign = true` なのでコミットが一切できなくなる。 | ||
|
|
||
| ### 手順 | ||
|
|
||
| ```bash | ||
| # 1. 更新前の状態を控える (あとで fingerprint 不変を確認するため) | ||
| sc_auth list-ctk-identities -t sha1 -e hex | ||
| sc_auth list-ctk-identities -t ssh | ||
|
|
||
| # 2. CSR を作る | ||
| # -h に渡すのは list-ctk-identities のデフォルト表示 = SHA1 (40桁)。 | ||
| # -t sha256 -e hex の値を渡すと "No identity found for hash" で落ちる。 | ||
| # 直前の出力から CN=git-sign の identity に対応する値をコピーする。 | ||
| HASH='<git-sign identity の SHA1 40桁>' | ||
| if [[ ! $HASH =~ ^[0-9A-Fa-f]{40}$ ]]; then | ||
| echo "HASH は40桁のSHA1を指定すること" >&2 | ||
| exit 1 | ||
| fi | ||
| cd "$(mktemp -d)" | ||
| sc_auth create-ctk-csr -h "$HASH" -f renew.csr -N git-sign | ||
|
|
||
| # 3. 使い捨て CA で 10 年署名する | ||
| # 証明書は識別のための入れ物にすぎず、SSH の信頼には一切関与しない。 | ||
| # LibreSSL の openssl x509 -req に -subj は無い。CN は手順 2 の -N で入れる。 | ||
| openssl req -x509 -newkey rsa:2048 -keyout ca.key -out ca.crt \ | ||
| -days 3650 -nodes -subj "/CN=git-sign-ca" | ||
| openssl x509 -req -in renew.csr -CA ca.crt -CAkey ca.key -CAcreateserial \ | ||
| -out renew.crt -days 3650 | ||
|
|
||
| # 4. 取り込む | ||
| sc_auth import-ctk-certificate -f renew.crt | ||
|
|
||
| # 5. Valid To が伸び、public key hash / SSH fingerprint が変わっていないことを確認 | ||
| sc_auth list-ctk-identities | ||
| sc_auth list-ctk-identities -t ssh | ||
| ``` | ||
|
|
||
| ### go/no-go 検証 (必須) | ||
|
|
||
| 手順 5 の `Valid=YES` は `sc_auth` の見解であって、`ssh-keychain.dylib` が同じ判断をするとは | ||
| 限らない。**既存のハンドルで実際に署名できることを確認するまで、更新は完了とみなさないこと。** | ||
|
|
||
| ```bash | ||
| export SSH_SK_PROVIDER=/usr/lib/ssh-keychain.dylib | ||
| cd "$(mktemp -d)" | ||
| printf 'renew check\n' > msg.txt | ||
| /usr/bin/ssh-keygen -Y sign -f ~/.ssh/id_git_sign -n git msg.txt | ||
| printf '%s %s\n' "$(git config user.email)" "$(cat ~/.ssh/id_git_sign.pub)" > allowed_signers | ||
| /usr/bin/ssh-keygen -Y verify -f allowed_signers -I "$(git config user.email)" \ | ||
| -n git -s msg.txt.sig < msg.txt | ||
| ``` | ||
|
|
||
| `Good "git" signature` が出れば成功。失敗した場合は下の「identity 再作成」に進む。 | ||
|
|
||
| ## フォールバック: identity 再作成 | ||
|
|
||
| 証明書更新が効かない場合は identity ごと作り直す。この場合 **SSH 公開鍵が変わる**ので | ||
| GitHub への再登録が必要になる。 | ||
|
|
||
| ```bash | ||
| # 1. 先に古い identity を削除する ★重要 | ||
| # identity が 2 本あると ssh-keygen -K が両方を同じ id_ecdsa_sk_rk に書こうとして | ||
| # 上書きプロンプトで詰まる。KEYCHAIN_CERTIFICATES で絞る方法は -w の Secure Key 経路では | ||
| # 効かなかった。 | ||
| cat ~/.ssh/id_git_sign.pub | ||
| sc_auth list-ctk-identities -t sha1 -e hex | ||
| OLD_HASH='<削除する git-sign identity の SHA1 40桁>' | ||
| if [[ ! $OLD_HASH =~ ^[0-9A-Fa-f]{40}$ ]]; then | ||
| echo "OLD_HASH は40桁のSHA1を指定すること" >&2 | ||
| exit 1 | ||
| fi | ||
| printf '削除対象: %s\n' "$OLD_HASH" | ||
| sc_auth delete-ctk-identity -h "$OLD_HASH" | ||
|
|
||
| # 2. 「新規セットアップ」を最初からやり直す。GitHub への新鍵登録と | ||
| # allowedSignerKeys への追記、Home Manager の反映まで実施する | ||
|
|
||
| # 3. 新旧の GitHub signing key を確認する | ||
| # id=805051 は M4Air が現役利用している間は削除しない | ||
| gh api /user/ssh_signing_keys --jq '.[] | "\(.id)\t\(.title)"' | ||
| ``` | ||
|
|
||
| ## 動作確認 | ||
|
|
||
| ```bash | ||
| # ローカル | ||
| git log --show-signature -1 | ||
|
|
||
| # GitHub 側 (Verified バッジが付くか) | ||
| gh api /repos/<owner>/<repo>/commits/<sha> --jq '.commit.verification' | ||
| # => {"verified": true, "reason": "valid", ...} | ||
| ``` | ||
|
|
||
| `gh` で signing key を操作するには `admin:ssh_signing_key` スコープが要る: | ||
|
|
||
| ```bash | ||
| gh auth refresh -h github.com -s admin:ssh_signing_key | ||
| ``` | ||
|
|
||
| ## トラブルシューティング | ||
|
|
||
| | 症状 | 原因 | | ||
| |---|---| | ||
| | `No identity found for hash` | `-h` に SHA256 や別端末の値を渡している。対象 identity の SHA1 (40桁) を使う | | ||
| | `ssh-keygen -K` が上書きプロンプトで止まる | CTK identity が複数ある。不要なものを削除する | | ||
| | `allowed_signers:1: bad options: unknown key option` | `no-touch-required` を書いている。不要 | | ||
| | `No principal matched` | 新しい公開鍵が `home/git.nix` の `allowedSignerKeys` に無いか、rebuild が未実施 | | ||
| | コミットが署名エラーで落ちる | `~/.ssh/id_git_sign` の消失、`ssh-keychain.dylib` のパス変更、identity の消失を先に疑う。`sc_auth list-ctk-identities` で存在と `Valid` を確認 | | ||
| | nix で openssh を入れた直後に壊れた | `ssh-keygen` が nix 側に解決されている。`/usr/bin/ssh-keygen` を絶対パスで呼ぶ | | ||
|
|
||
| ## 検証済み/未検証の区別 | ||
|
|
||
| このドキュメントのうち実測で確認したもの: | ||
|
|
||
| - 新規セットアップ一式 (鍵作成 → 書き出し → GitHub/`allowed_signers` 登録 → 署名・検証) | ||
| - 証明書更新の全工程を本番 identity `git-sign` で実行し、以下を確認 (2026-08-10): | ||
| - 有効期限が 2027/08/10 → 2036/08/07 に伸びた | ||
| - public key hash / SSH fingerprint / CN が**すべて不変** | ||
| - **更新後も既存ハンドルで署名でき**、`ssh-keygen -Y verify` と `git log --show-signature` が通る | ||
| - 更新後のコミットを push して GitHub 側も `verified=true` / `reason=valid` | ||
| - → CA 署名の証明書を import しても `ssh-keychain.dylib` は identity を提供し続ける | ||
| - ハッシュ形式の罠、`-K` のファイル名衝突、`no-touch-required` が弾かれること | ||
|
|
||
| 未検証: | ||
|
|
||
| - 証明書が期限切れになったときに実際どうなるか (10年更新済みなので当面到達しない) | ||
| - `sc_auth` が発行する自己署名証明書以外を入れた状態での、SSH 以外の用途 (smartcard ログイン等) | ||
| への影響。ここでは署名にしか使っていないため未確認 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When the keychain already contains another ECDSA-SK identity, this command does not select
git-sign: the checked CLI usage isssh-keygen -K [-a rounds] [-w provider], with no identity selector, and the fallback section itself notes that multiple identities target the sameid_ecdsa_sk_rkfilename. The export can therefore stop at an overwrite prompt or leave the subsequentmvinstalling an unrelated handle, after which GitHub registration and the M4Pro signing configuration use the wrong key. Add a preflight/abort for additional identities or verify that the exported public key matches the newly createdgit-signidentity before moving it.Useful? React with 👍 / 👎.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ssh-keygen -Kが identity を選択できず、provider 上の resident key を一括で書き出す点を踏まえ、既存 CTK identity がないことを fail-closed で確認する preflight を追加しました。さらに、入力を閉じて上書きプロンプトを失敗させ、生成ファイルの存在と、書き出された公開鍵の fingerprint が直前に作成したgit-signidentity と一致することを確認してから配置するよう修正しました。fallback でも、再実行前に残存 CTK identity を確認し、必要な identity が残る場合は中止することを明記しました。実機では同名衝突時に終了コード255で停止して既存ファイルが変化しないことと、現在の
git-signidentity と既存ハンドルの fingerprint が一致することを確認しています。71fd516