• 作成:

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年にしました。

期限を短くする本来の意味は、

  1. 漏洩に気づかなかった場合の保険
  2. 暗号アルゴリズムの陳腐化対策
  3. 鍵管理を見直す強制イベント

ですが、 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を導入したあとは再起動したほうが良いのかもしれませんね。

副鍵のパスフレーズを削除する決断

最初はパスフレーズを設定していたのですが、以下の理由から削除することにしました。

  1. 主鍵は暗号化USBメモリに隔離済み: 物理的に保護されている
  2. パスフレーズはパスワードマネージャに保存: どうせ同じマシンにある
  3. 外に持ち出すマシンはディスク暗号化済み: 物理アクセスへの防御がある

パスフレーズによるセキュリティは「パスフレーズを脳内に記憶している」前提のモデルであり、パスワードマネージャからコピペする運用ではその前提が崩れます。

攻撃者が秘密鍵ファイルにアクセスできる状態なら、同じマシンのパスワードマネージャやメモリ上のキャッシュにもアクセスできる可能性が高いです。

そして私は脳内に保存できる程度のパスフレーズに意味はないと考えています。

結果としてセキュリティ上の意味はほぼないのに手間だけ増える状態になっていました。

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で公開鍵を管理しているため保守や拡張も比較的容易だと思います。漏洩時も対処が比較的スムーズに出来るでしょう。