systemd の認証情報でサービスの秘密情報を分離し、更新を検証する
同じ物理サーバーで API サービス、同期ジョブ、監視用コレクターを動かしていると、サービスのトークンが起動スクリプトや環境変数ファイル、デプロイ記録に散在しがちです。プロセスの引数の確認、障害ログの出力、設定ファイルのアーカイブをきっかけに、露出範囲が広がることもあります。本記事では Debian 12 で systemd の LoadCredential を使い、秘密情報の元ファイルを管理者の管理下にあるディレクトリに置いたまま、指定したサービスだけに実行時のコピーを渡します。そのうえで、繰り返し実施できる確認手順を使い、ローテーションを検証します。
一、認証情報の分離で守れる範囲を明確にする
ここで扱うのは「サービスが秘密情報をどのように受け取るか」です。秘密情報の発行や一元保管、リモート認証のプロトコルは対象ではありません。CloudDDR の物理サーバーでは、OS とデータをユーザー自身が管理します。支払い後にコンソールで接続用の認証情報を確認し、SSH でシステムに接続します。以下の操作には管理者権限が必要です。まずはテスト用サービスで手順を完了し、業務サービスの起動方法を直接変更しないでください。
環境変数は、秘密情報の受け渡し方法として標準的に採用するには適していません。デバッグ出力や子プロセスの環境、診断用アーカイブに含まれる可能性があるためです。起動引数も、プロセスを調べるツールに記録されることがあります。LoadCredential では、システムのサービスマネージャーが元ファイルを読み取り、サービスの起動時に認証情報ディレクトリを提供します。プログラムは CREDENTIALS_DIRECTORY を使ってファイルの場所を特定します。
認証情報ファイルの分離では、すでに管理者権限を取得したプロセスには対抗できません。また、アプリケーションが秘密情報をログに書き出すことも防げません。保護の範囲には、ファイルの権限、サービスアカウント、アプリケーションの出力動作をすべて含める必要があります。
この例の元ファイルは平文のままです。LoadCredential を使っても、保存時の暗号化が自動的に行われるわけではありません。サーバー全体のバックアップ、管理者によるアクセス、秘密情報の発行記録も、機密データとして扱う必要があります。
二、専用アカウントとアクセスを制限した元ファイルを用意する
独立した検証環境を準備する
systemctl --version を確認します。この例では Debian 12 が提供する systemd を使用し、Python 3 をインストールします。アカウント名が既存の業務で使われていないことを確認してください。以下のアカウント作成コマンドは、アカウントがまだ存在しない場合に限って実行します。
sudo apt-get update
sudo apt-get install python3
id credential-check
sudo useradd --system --no-create-home --shell /usr/sbin/nologin credential-check
sudo install -d -o root -g root -m 0700 /etc/service-secrets
sudo sh -c 'umask 077; printf "%s" "fixture-token-alpha" > /etc/service-secrets/api-token'
sudo stat -c '%U %G %a %n' /etc/service-secrets /etc/service-secrets/api-token
id でアカウントが存在しないと確認できた場合だけ、useradd を実行します。すでに存在する場合は用途を調べ、用途不明のアカウントを流用しないでください。想定する権限はディレクトリが 700、ファイルが 600 で、所有者と所有グループはいずれも root です。例の文字列はアクセス権を持たないテストデータなので、コマンド履歴に残っても問題ありません。実際のトークンは、このようにコマンドへ直接書き込んではいけません。
実際の値は、管理されたファイルの受け渡しによって取得し、管理者権限で所定の場所に配置します。エディターの一時ファイル、端末へのエコー表示、トレースを有効にしたシェルを使って秘密情報を扱わないでください。サービスアカウントからは、元ファイルを読み取れない状態にします。
sudo -u credential-check test -r /etc/service-secrets/api-token
printf '%s\n' "$?"
ここで想定する終了コードは 1 です。0 だった場合は、ディレクトリの権限、ファイルの ACL、アカウントの所属グループを調べ、問題を解消するまでデプロイを進めないでください。
三、プログラムには実行時の認証情報だけを読み取らせる
親ディレクトリを作成し、以下のプログラムを /usr/local/libexec/credential-check.py として保存します。所有者を root、権限を 755 に設定してください。ディレクトリにもプログラムにも、サービスアカウントからの書き込みを許可してはいけません。プログラムが出力するのは固定のエラーメッセージだけです。検証のために新たな漏えい経路を作らないよう、トークン、その長さ、ダイジェストは表示しません。
import os
from pathlib import Path
directory = os.environ.get("CREDENTIALS_DIRECTORY")
if not directory:
raise SystemExit("Credential directory missing")
try:
token = (Path(directory) / "api-token").read_bytes()
except OSError:
raise SystemExit("Credential unavailable") from None
if not token or b"\n" in token or b"\r" in token:
raise SystemExit("Credential format rejected")
この例では、トークンが空でなく、改行を含まないことを条件としています。これは、すべての秘密情報の形式に共通するルールではありません。実際のプログラムでは、接続先のプロトコルに従って検証してください。たとえば、複数行の PEM データを許容する場合、このチェックはそのまま使えません。
/etc/systemd/system/credential-check.service を作成します。
[Unit]
Description=Service credential validation
[Service]
Type=oneshot
User=credential-check
Group=credential-check
LoadCredential=api-token:/etc/service-secrets/api-token
ExecStart=/usr/bin/python3 /usr/local/libexec/credential-check.py
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
UMask=0077
これは単発で実行する検証ユニットなので、正常終了後に inactive 状態になるのは正常な結果です。OS 起動時の自動起動を有効にする必要はありません。また、テストのために業務サービスの再起動ポリシーを流用すべきでもありません。実際のアプリケーションも同じ方法でファイルを読み取れますが、ファイルシステムの制限は、業務で書き込みが必要なディレクトリと照らし合わせて検証する必要があります。そのまま無条件にコピーしないでください。
四、読み取りと認証の結果を分けて検証する
sudo systemctl daemon-reload
sudo systemctl start credential-check.service
sudo systemctl show credential-check.service -p Result -p ExecMainStatus
sudo journalctl -u credential-check.service -n 20 --no-pager
想定する結果は Result=success、ExecMainStatus=0 です。これは、サービスアカウントが実行時ディレクトリ経由で認証情報を読み取れることを示します。一方、先のチェックでは元ファイルを直接読み取れないことを確認しています。この状態を証明するために、cat、環境変数全体の出力、プロセスメモリのダンプを使わないでください。
このチェックは、トークンが有効であることを意味しません。実際のサービスに組み込んだ後は、業務データを変更しない認証リクエストも実行し、レスポンスのステータスと想定した権限を確認する必要があります。認証に失敗した場合は、認証情報の形式、接続先で新しいトークンが有効化されているか、アプリケーションが実際に再起動されたか、リクエストが古い接続やキャッシュを使っていないかを、それぞれ確認します。
検証記録には、ユニットのバージョン、操作の順序、終了コード、認証の結果だけを保存し、トークンの値は残しません。特定のローテーションと関連付ける必要がある場合は、トークンのダイジェストではなく、内部の変更管理番号を使います。ログを確認する際は、異常時の処理でリクエストヘッダーや設定全体が出力されていないことも確認してください。
五、アトミックな置き換えで更新し、失敗時の復旧を確認する
元ファイルを更新しても、実行中に使われている認証情報のコピーは変わりません。ローテーションは、新しい値の取得、形式の検証、アトミックな置き換え、サービスの再起動、認証の確認という順序で行い、最後に接続先のルールに従って古い値を失効させます。daemon-reload を実行するだけでは、実行中のサービスが使う秘密情報は更新されません。
以下のコマンドは、テストデータだけを更新します。一時ファイルと置き換え先のファイルを同じディレクトリに置き、同一ファイルシステム内で名前の変更が行われるようにしています。
sudo sh -c 'umask 077; printf "%s" "fixture-token-beta" > /etc/service-secrets/api-token.next'
sudo chown root:root /etc/service-secrets/api-token.next
sudo chmod 0600 /etc/service-secrets/api-token.next
sudo mv -f /etc/service-secrets/api-token.next /etc/service-secrets/api-token
sudo systemctl restart credential-check.service
sudo systemctl show credential-check.service -p Result -p ExecMainStatus
続いて、失敗時の動作を検証します。テスト用の元ファイルを空ファイルに置き換えてから検証ユニットを起動し、終了コードが非ゼロになり、固定の形式エラーメッセージが出ることを確認します。空ではないテスト値に戻して再度起動すると、成功するはずです。これにより、秘密情報が欠けているときにアプリケーションが何も知らせず動作を続けないことを確認できます。本番用のトークンで破壊的なテストを行わないでください。
実際のローテーションの前には、接続先が新旧両方のトークンを同時に受け付けられるか確認します。受け付けられない場合は、連携した切り替え手順を設計する必要があります。古い値がすでに失効していれば、古いファイルに戻しても認証は復旧しません。古い値を保持するのは、管理された切り戻し期間内だけに限定し、同等の権限で保護してください。業務用ディレクトリにコピーを長期間残してはいけません。
検証が終わったら、テスト用ユニットを停止して無効化し、ユニット、スクリプト、テスト用の認証情報を削除します。daemon-reload を実行し、一時ファイルが残っていないことも確認してください。最終的な成果物は、再確認できる一連の手順です。一般アカウントは元ファイルを読めず、対象サービスは実行時の認証情報を読めること、不正な値では明確に失敗すること、更新後の実際の認証が成功すること、ログに機密の値が含まれないことを確認できる手順にします。
よくある質問
LoadCredential を使えば管理者からも秘密情報を隠せますか?
隠せません。管理者や十分な権限を持つプロセスは、元ファイルやサービスのメモリを読み取れる場合があります。起動引数、環境変数、一般アカウントへの露出を減らす仕組みであり、管理者に対する秘匿境界ではありません。
元ファイルを置き換えると、稼働中のサービスにも反映されますか?
自動では反映されません。LoadCredential はサービス起動時に情報を読み込みます。元ファイルをアトミックに置き換えてサービスを再起動し、新しい秘密情報による認証結果を確認します。daemon-reload だけでは更新されません。
AMD EPYC 7543P 搭載の専用物理サーバー。月額$447から。