GPGを導入して署名用サブキーを端末ごとに分けて運用することにしました
背景
これまでGPGは使っていなかったのですが、今回新規に導入することにしました。
導入理由は主に以下の通りです。
Gitのcommitに署名したかった
GitHubでVerifiedバッジがついているとかっこいい。というのは冗談として、一部の厳格なリポジトリ、例えば、 gentoo/gentoo: [MIRROR] Official Gentoo ebuild repository のようなリポジトリにコミットするには署名が原則として求められるからです。いやまあ今はもうGentooは使ってないのですが、そういうリポジトリにまた遭遇するかもしれません。
sops-nixでdotfilesのシークレットを管理したかった
sops-nix を使えばシークレットを暗号化しつつgitで管理できます。これまでのncaq/dotfilesでは、 APIキーやトークンはリポジトリ外部に置いて読み書きしていたので、管理が完全にnixマネージドで行われなくて不便でした。
メールやメッセージなどに署名したい
今はまだやってないのですが、そのうち署名したり暗号化メッセージを受け取りたいと思っています。
安全な暗号化
IPAなどは脆弱性報告の際に、報告者が公開鍵を持っていればメールの暗号化を求めてくることがあります。それがなくてもまともな公開鍵方式の暗号化が出来ると嬉しいですね。まだこれを書いている段階ではGitHub以外の鍵サーバに公開鍵は投稿していないので、それは安全には出来ないのですが。
設計
せっかく新規に作るので、モダンなed25519を使い、署名用のサブキーは端末ごとに分けて運用することにしました。
鍵の構成
最終的に以下のような構成にしました。
マスターキー [C] ed25519
├── バックアップ1: LUKS暗号化USBメモリ(タンスの奥)
└── バックアップ2: paperkey(紙に印刷)
署名サブキー [S] ed25519 × 4(端末ごとに分離)
├── bullet用
├── creep用
├── seminar用
└── SSD0086用(WSL2)
暗号化サブキー [E] cv25519(全端末で共通)
鍵の記号の意味は以下の通りです。
| 記号 | 役割 | 用途 |
|---|---|---|
| [C] | Certify | 他の鍵への署名、サブキーの追加・失効 |
| [S] | Sign | Gitコミット署名、ファイル署名 |
| [E] | Encrypt | ファイル暗号化(sopsなど) |
USBメモリのバックアップはもう少し増やそうかなとも考えています。もしくは安いHDDとかで物理的特性を分けるとか。
なぜ署名キーを端末ごとに分けるのか
署名サブキーを端末ごとに分けることで、漏洩時に「どの端末から漏れたか」が特定出来るので、その端末の対処を迅速に行えます。
なぜ暗号化キーは分けないのか
署名と暗号化では「誰がキーを選ぶか」が違います。
| 署名 | 暗号化 | |
|---|---|---|
| 誰が選ぶ | 自分(署名する側) | 相手(暗号化する側) |
| 端末分け | 自然 | 不自然 |
暗号化は送信側がどのキーを使うか選ぶ必要があります。送信側が「この人は今この端末を使っていて…」なんて知るわけがないですし、 sopsでもどうせ全キーを列挙することになるので、だったら1つでいいという判断です。
GitHubとサブキーの関係
GitHubに登録するのは公開鍵全体(マスター + 全サブキー)なので、どのサブキーで署名しても、そのサブキーがマスターキーに紐づいていることを検証できます。各マシンに別々の署名サブキーを配布しても、全部同じGPGキーとして認識されて、ちゃんとVerifiedになります。
アルゴリズムの選択
RSA vs 楕円曲線
ed25519/cv25519を選択しました。署名が高速で小さく、現代的で推奨されています。
YubiKeyへのバックアップは断念
YubiKey 5を偶然持っていたので、 YubiKey 5にマスターキーを格納してバックアップにしようと考えていたのですが、私が持っているYubiKey 5のファームウェアが5.1.2で、 ed25519対応は5.2.3以降なので断念しました。 YubiKeyはファームウェア更新不可なので、どうしようもないですね。
代わりにUSBメモリで普通にバックアップする方針にしました。 YubiKeyを使うより楕円暗号を優先したい。
YubiKeyがまともに使えるなら、後述のパスフレーズ問題も解決できるので、 gpg-agentのキャッシュ期限とかを見直して使っても良いかもしれません。
有効期限について
有効期限は5年にしました。
期限を短くする本来の意味は、
- 漏洩に気づかなかった場合の保険
- 暗号アルゴリズムの陳腐化対策
- 鍵管理を見直す強制イベント
ですが、 ed25519が5年で破られる可能性は極めて低いですし、パスフレーズ + オフライン保管で漏洩リスクは低いです。ラップトップなどは同じマシンを5年も使わないだろうと思うので、漏洩リスクが比較的高いラップトップのことを考えて5年の期限にしました。
セットアップ
作業環境の準備
安全のため、一時的なGNUPGホームで作業しました。
export GNUPGHOME=$(mktemp -d)
cat > "$GNUPGHOME/gpg-agent.conf" << EOF
pinentry-program $(which pinentry-qt)
EOF
gpgconf --kill gpg-agent
pinentry-gnome3だとモーダルダイアログになってしまい、他のウィンドウが開けません。そうするとKeePassXCからパスフレーズをコピペ出来ません。よってpinentry-qtを使うようにしました。
マスターキー生成
gpg --quick-generate-key "ncaq <ncaq@ncaq.net>" ed25519 cert 5y
サブキー追加
# 暗号化用(共通)
gpg --quick-add-key <KEYID> cv25519 encr 5y
# 署名用(端末ごと)
gpg --quick-add-key <KEYID> ed25519 sign 5y # bullet用
gpg --quick-add-key <KEYID> ed25519 sign 5y # creep用
gpg --quick-add-key <KEYID> ed25519 sign 5y # seminar用
確認すると以下のようになります。
pub ed25519/42248C7D0FB73D57 2026-01-05 [C] [有効期限: 2031-01-04]
7DDE3BC405DC58D94BF661D342248C7D0FB73D57
uid [ 究極 ] ncaq <ncaq@ncaq.net>
sub cv25519/C65B759E5D5B3E95 2026-01-05 [E] [有効期限: 2031-01-04]
sub ed25519/33F5EB0E553A2EFB 2026-01-05 [S] [有効期限: 2031-01-04] ← bullet
sub ed25519/60635905E8D66388 2026-01-05 [S] [有効期限: 2031-01-04] ← creep
sub ed25519/562EE3E571A37489 2026-01-05 [S] [有効期限: 2031-01-04] ← seminar
バックアップ作成
# 完全バックアップ(マスター + 全サブキー)
gpg --export-secret-keys --armor <KEYID> > master-secret-key.asc
# 公開鍵
gpg --export --armor <KEYID> > public-key.asc
# 失効証明書
gpg --gen-revoke --armor --output revoke-certificate.asc <KEYID>
# paperkey用(紙印刷用)
gpg --export-secret-keys <KEYID> | paperkey > paperkey-backup.txt
失効証明書は漏れると誰でも鍵を失効させられるので、秘密鍵と同等に大事に保管します。
これを暗号化したUSBメモリに保存します。さらに紙に印刷してタンスの奥に保管します。
端末ごとのサブキーをエクスポート
暗号化キーと各端末の署名キーだけをエクスポートします。
!をつけることで特定のサブキーだけ指定できます。
# bullet用(暗号化 + bullet署名)
gpg --export-secret-subkeys --armor C65B759E5D5B3E95! 33F5EB0E553A2EFB! > subkeys-bullet.asc
# creep用(暗号化 + creep署名)
gpg --export-secret-subkeys --armor C65B759E5D5B3E95! 60635905E8D66388! > subkeys-creep.asc
# seminar用(暗号化 + seminar署名)
gpg --export-secret-subkeys --armor C65B759E5D5B3E95! 562EE3E571A37489! > subkeys-seminar.asc
各端末への配布
一時的なGNUPGHOME環境ではなく通常の環境でインポートします。
# インポート
gpg --import public-key.asc
gpg --import subkeys-bullet.asc
# 信頼度設定
gpg --edit-key <KEYID>
gpg> trust
# 5 (ultimate) を選択
gpg> quit
# 確認
gpg --list-secret-keys
# sec# ed25519 2026-01-05 [C] ← # = マスターなし
# ssb cv25519 2026-01-05 [E]
# ssb ed25519 2026-01-05 [S] ← bullet用だけ
sec#になっていればマスターキーの秘密鍵がないことを示しています。サブキーの秘密鍵だけが入っている状態です。
NixOS/home-manager設定
GPG設定
{ pkgs, ... }:
{
programs.gpg = {
enable = true;
publicKeys = [
{
trust = "ultimate";
source = ../ncaq-public-key.asc;
}
];
scdaemonSettings = {
disable-ccid = true;
reader-port = "Yubico YubiKey";
};
};
services.gpg-agent = with pkgs; {
enable = true;
enableSshSupport = true;
pinentry.package = pinentry-qt;
defaultCacheTtl = 157680000; # 5年
maxCacheTtl = 157680000;
};
home.packages = with pkgs; [
paperkey
pinentry-qt
];
}
キャッシュ時間を5年にしているのは、 gpg-agentのキャッシュは再起動で消えるので実質的には意味がないのですが、起動中はパスフレーズを聞かれないようにするためです。
一応YubiKeyの設定も入れていますが、ファームウェアの問題で使えません。
自分の公開鍵はGitHubのdotfilesリポジトリのncaq-public-key.ascに置いています。これで新しくサブキーを追加した場合も簡単に他の端末に配布できます。
Git設定(端末ごとに署名鍵を分ける)
{
inputs,
hostName ? null,
...
}:
let
signingKeys = {
bullet = "33F5EB0E553A2EFB";
creep = "60635905E8D66388";
seminar = "562EE3E571A37489";
SSD0086 = "B3630E320567F75A";
};
signingKey = signingKeys.${hostName} or null;
in
{
programs.git = {
enable = true;
signing = {
key = signingKey;
signByDefault = signingKey != null;
};
settings = {
user = {
name = "ncaq";
email = "ncaq@ncaq.net";
};
# 他の設定...
};
};
}
hostNameをhome-managerのモジュールに渡すことで、端末ごとに異なる署名鍵を設定できるようにしています。
sops-nixの設定例
GPG鍵をセットアップした主な目的の一つであるsops-nixの具体例を紹介します。
.sops.yamlの設定
リポジトリのルートに.sops.yamlを配置して、どの鍵で暗号化するかを定義します。
keys:
- &ncaq_gpg 7DDE3BC405DC58D94BF661D342248C7D0FB73D57
creation_rules:
- path_regex: secrets/.*\.yaml$
key_groups:
- pgp:
- *ncaq_gpg
sops-nixの基本設定
NixOS側でGPG鍵の場所を指定します。
# nixos/core/sops.nix
{
pkgs,
username,
...
}:
{
sops.gnupg = {
home = "/home/${username}/.gnupg";
sshKeyPaths = [ ];
};
environment.systemPackages = [ pkgs.sops ];
}
home-manager側も同様です。
# home/package/sops.nix
{
username,
...
}:
{
sops.gnupg.home = "/home/${username}/.gnupg";
}
シークレットの作成と使用
シークレットファイルの作成
sopsコマンドでシークレットファイルを作成・編集します。
sops secrets/github-mcp-server.yaml
エディタが開くので、YAMLでシークレットを記述します。
pat: ghp_xxxxxxxxxxxxxxxxxxxxx
保存すると自動的に暗号化されます。暗号化されたファイルはgitにそのままコミットできます。
NixOS/home-managerでの使用
シークレットを定義して、アプリケーションから参照します。
# シークレットの定義
sops.secrets."github-mcp-server/pat" = {
sopsFile = ../../secrets/github-mcp-server.yaml;
key = "pat";
};
# ラッパースクリプトでシークレットを環境変数として渡す
github-mcp-server-wrapper = pkgs.writeShellApplication {
name = "github-mcp-server-wrapper";
runtimeInputs = [ pkgs.github-mcp-server ];
text = ''
if [[ -r ${config.sops.secrets."github-mcp-server/pat".path} ]]; then
GITHUB_PERSONAL_ACCESS_TOKEN="$(< ${config.sops.secrets."github-mcp-server/pat".path})"
export GITHUB_PERSONAL_ACCESS_TOKEN
fi
exec github-mcp-server "$@"
'';
};
config.sops.secrets."...".pathで復号されたシークレットのパスが得られます。実行時にGPG鍵で自動的に復号されるため、
APIトークンなどを安全にgit管理できるようになります。
トラブルシューティング
pinentryが見つからない
一時的なGNUPGHOMEで作業しているとgpg-agentが本来の設定を見ないため発生します。
cat > "$GNUPGHOME/gpg-agent.conf" << EOF
pinentry-program $(which pinentry-qt)
EOF
gpgconf --kill gpg-agent
NixOSのdotfilesでの設定をした直後に署名しようとすると失敗する
EmacsのMagitからgit commitしようとした際に、
Inappropriate ioctl for deviceエラーが発生することがありました。
これはgpg-agentが起動時に間違ったTTY情報を保持していた可能性があります。 gpg-agentを再起動することで解決しました。
gpgconf --kill gpg-agent
一度GPGを導入したあとは再起動したほうが良いのかもしれませんね。
副鍵のパスフレーズを削除する決断
最初はパスフレーズを設定していたのですが、以下の理由から削除することにしました。
- 主鍵は暗号化USBメモリに隔離済み: 物理的に保護されている
- パスフレーズはパスワードマネージャに保存: どうせ同じマシンにある
- 外に持ち出すマシンはディスク暗号化済み: 物理アクセスへの防御がある
パスフレーズによるセキュリティは「パスフレーズを脳内に記憶している」前提のモデルであり、パスワードマネージャからコピペする運用ではその前提が崩れます。
攻撃者が秘密鍵ファイルにアクセスできる状態なら、同じマシンのパスワードマネージャやメモリ上のキャッシュにもアクセスできる可能性が高いです。
そして私は脳内に保存できる程度のパスフレーズに意味はないと考えています。
結果としてセキュリティ上の意味はほぼないのに手間だけ増える状態になっていました。
gpg-agentのキャッシュは再起動で消えるので、パスフレーズを設定していると再起動のたびに入力が必要になります。これは実用的ではないので、副鍵のパスフレーズを空にしました。
gpg --edit-key ncaq@ncaq.net
gpg> passwd
# 現在のパスフレーズを入力
# 新しいパスフレーズは空のまま2回決定
gpg> save
主鍵がPCに入っていない(sec#状態)場合、ローカルの副鍵のパスフレーズを変更しても、
USBメモリに保管してある主鍵のバックアップには影響しません。鍵束のコピーごとにパスフレーズは独立しています。
YubiKeyがまともに使えるなら話は別になるかもしれません。それなら起動後最初の一回はタッチするだけで良いので。
新しい端末を追加する場合
新しい端末(例: SSD0086)を追加する際は、 USBメモリからマスターキーを復元して署名サブキーを追加します。
というかSSD0086のサブキーを発行するのを忘れていたので、早くもUSBメモリから復元して追加しました。
export GNUPGHOME=$(mktemp -d)
# pinentry設定
cat > "$GNUPGHOME/gpg-agent.conf" << EOF
pinentry-program $(which pinentry-qt)
EOF
gpgconf --kill gpg-agent
# マスターキー復元
gpg --import master-secret-key.asc
# 新しい署名サブキー追加
gpg --quick-add-key <KEYID> ed25519 sign 5y
# 確認して新しいサブキーIDをメモ
gpg --list-keys --keyid-format long <KEYID>
# 新端末用にエクスポート
gpg --export-secret-subkeys --armor <暗号化キーID>! <新署名キーID>! > subkeys-new-device.asc
# 公開鍵を更新
gpg --export --armor <KEYID> > public-key.asc
# USBメモリのバックアップも更新
# GitHubなどインターネット上の公開鍵も更新
# dotfilesの`ncaq-public-key.asc`も更新してコミット
まとめ
GPG鍵をed25519で新規作成し、サブキーを端末ごとに分けて運用することにしました。
- マスターキーはオフライン保管(USBメモリ + paperkey)
- 署名サブキーは端末ごとに分離
- 暗号化サブキーは共通
- 副鍵のパスフレーズは削除
この構成により、セキュリティを維持しつつ、日常的な署名作業の手間を最小限に抑えることができます。また、dotfilesで公開鍵を管理しているため保守や拡張も比較的容易だと思います。漏洩時も対処が比較的スムーズに出来るでしょう。
hatena-bookmark