systemd-creds を使ったクレデンシャル管理
先日 Software Design 2022-11 月号で systemd-creds というツールの存在を知り興味を持ったので少し触ってみました。
systemd-creds を利用したクレデンシャル管理は systemd v250 から利用可能です。v250 は 2021-12 にリリースされたもので現時点ではまだ含まれていない Linux ディストリビューションも多そうですが、例えば Ubuntu だと 22.10 であれば v251 が入っているようです。
以下は Arch Linux で systemd v251 で試した実行結果を載せています。
$ systemctl --version
systemd 251 (251.6-2-arch)
+PAM +AUDIT -SELINUX -APPARMOR -IMA +SMACK +SECCOMP +GCRYPT +GNUTLS +OPENSSL +ACL +BLKID +CURL +ELFUTILS +FIDO2 +IDN2 -IDN +IPTC +KMOD +LIBCRYPTSETUP +LIBFDISK +PCRE2 -PWQUALITY +P11KIT -QRENCODE +TPM2 +BZIP2 +LZ4 +XZ +ZLIB +ZSTD -BPF_FRAMEWORK +XKBCOMMON +UTMP -SYSVINIT default-hierarchy=unified
systemd サービスにおけるクレデンシャルの扱い方
過去 systemd の仕組みの範疇でサービスにクレデンシャルを渡すためには、ユニットファイルに Environment
ディレクティブで直書きする、外部ファイルで (適切なアクセス制御付きで) 管理して EnvironmentFile
ディレクティブで指定する、といった方法が考えられました。しかし前者は systemctl show
で簡単にクレデンシャルが漏洩します。後者は StackOverflow 等でよく見る解決策ですが、systemd では以下のような理由でそもそも環境変数によるクレデンシャル管理を推奨していません (man 5 systemd.exec
の Environment ディレクティブより)。
- サービスに定義された環境変数は D-Bus や IPC 等で非特権プロセスから見える
- 機密情報であることがわかりにくい
- 子プロセスに伝搬する
(ただ一番目については具体的に何を指しているかはわからない。ある StackOverflow の回答 では、systemd がサービス開始時に設定を D-Bus にブロードキャストはするが、環境変数自体を流すような仕組みがあるわけではないとしている。-> 追記1)
こうした方法に代わり、systemd v250 からはサービスにクレデンシャルを安全に渡すための方法として LoadCredentialEncrypted
や SetCredentialEncrypted
ディレクティブを使用することができます。LoadCredentialEncrypted
は予めクレデンシャルを暗号化したファイルを用意しそのパスを指定、サービス起動時に復号したファイルとして渡すという動きをします。SetCredentialEncrypted
も似ていますが、こちらには暗号化したファイルのパスではなく暗号化した文字列を直接指定します。
systemd-creds によるクレデンシャル管理
systemd-creds はこの暗号化のために使用されるツールです。暗号化は AES256-GCM により行われ、その鍵は /var/lib/systemd/credential.secret
か (利用可能ならば) TPM2、あるいはその両方です。デフォルトでは両方を使おうとするようです。
システムで TPM2 が利用可能かは systemd-creds has-tpm2
により確認できます。
$ systemd-creds has-tpm2
yes
+firmware
+driver
+system
暗号化は systemd-creds encrypt
で行います。--name
は省略可能なオプションでここで指定した値をキーとして LoadCredentialEncrypted
に利用します。省略時は暗号化ファイル名が使われます。
# 暗号化ファイルを生成
# credential.plain.txt が平文ファイル、credential.txt に Base64 エンコーディングされた暗号化ファイルが生成される
$ sudo systemd-creds encrypt --name credential.txt credential.plain.txt credential.txt
# 平文ファイルを削除
$ shred -u cred-plain.txt
ここではお試し実行として systemd-run
により bash を実行します。
# クレデンシャルを渡しつつ bash サービス実行
$ sudo systemd-run -t -p DynamicUser=yes -p LoadCredentialEncrypted=credential.txt:$(pwd)/credential.txt /bin/bash
Running as unit: run-u867.service
Press ^] three times within 1s to disconnect TTY.
実行した bash から復号されたファイルを確認します。
サービスからは CREDENTIALS_DIRECTORY
環境変数でクレデンシャルを格納したディレクトリがわかります。
$ ls -l ${CREDENTIALS_DIRECTORY}/
-r-------- 1 run-u867 root 6 Nov 5 15:55 credential.txt
$ cat ${CREDENTIALS_DIRECTORY}/credential.txt
hello
ls -l
の結果からわかるように、ファイルはサービスを実行するユーザのみがアクセスでき、かつ read-only となるように調整されます。なおここでは DynamicUser
を指定したのでサービス用に一時的なユーザ (run-u867) が作成されています。
マウントを確認するとこのディレクトリには ramfs が使用されています。ramfs はスワップされない RAM ディスクなのでクレデンシャルの保存には適しているということだと思います。
$ mount -l
...
ramfs on /run/credentials/run-u867.service type ramfs (ro,nosuid,nodev,noexec,relatime,mode=700)
...
systemd の資料を読むとクレデンシャルをこのように管理するときは同時に PrivateMounts=true
の設定が推奨されています。これはサービス用に mount namespace が新しく切るための既存の設定で、以下のように有効にすることで別サービスからのクレデンシャルファイルへのアクセスを防ぐことができます。
# あるターミナルで
(terminal1) $ sudo systemd-run -t -p PrivateTmp=true -p PrivateMounts=true -p DynamicUser=yes -p LoadCredential=abc:/etc/hosts /bin/bash
Running as unit: run-u311.service
...
# 別のターミナルで
(terminal2) $ sudo systemd-run -t -p PrivateTmp=true -p PrivateMounts=true -p DynamicUser=yes -p LoadCredential=abc:/etc/hosts /bin/bash
Running as unit: run-u316.service
...
# それぞれでは自分の credential しか見れない
(terminal1) $ ls /run/credentials/
run-u314.service
(terminal2) $ ls /run/credentials/
run-u316.service
# なおホストからは両方見える (DynamicUser=yes なので専用のユーザが動的に作成されている)
$ ls -l /run/credentials/
total 0
dr-x------ 2 run-u314 root 0 Nov 3 18:03 run-u314.service
dr-x------ 2 run-u316 root 0 Nov 3 18:03 run-u316.service
参考
追記1: D-Bus によるサービス定義の取得
ここでの疑問に対してコメントを頂けたので少しこの点について自分でも確認してみました。
これは systemctl show などでunitの属性をみるとEnvironment=の定義が見えることを言っていると思います
SYSTEMD_LOG_LEVEL=debug
環境変数付きで systemctl show
を実行したデバッグログ例:
# my-sample は確認のために用意したサービス
$ SYSTEMD_LOG_LEVEL=debug systemctl show my-sample
running_in_chroot(): Permission denied
Bus n/a: changing state UNSET → OPENING
sd-bus: starting bus by connecting to /run/dbus/system_bus_socket...
Bus n/a: changing state OPENING → AUTHENTICATING
Successfully forked off '(pager)' as PID 151302.
Skipping PR_SET_MM, as we don't have privileges.
Found cgroup2 on /sys/fs/cgroup/, full unified hierarchy
Failed to execute 'pager', using next fallback pager: No such file or directory
Pager executable is "less", options "FRSXMK", quit_on_interrupt: yes
Showing one /org/freedesktop/systemd1/unit/my_2dsample_2eservice
Bus n/a: changing state AUTHENTICATING → HELLO
Sent message type=method_call sender=n/a destination=org.freedesktop.DBus path=/org/freedesktop/DBus interface=org.freedesktop.DBus member=Hello cookie=1 reply_cookie=0 signature=n/a error-name=n/a error-message=n/a
Got message type=method_return sender=org.freedesktop.DBus destination=:1.3284 path=n/a interface=n/a member=n/a cookie=1 reply_cookie=1 signature=s error-name=n/a error-message=n/a
Bus n/a: changing state HELLO → RUNNING
Sent message type=method_call sender=n/a destination=org.freedesktop.systemd1 path=/org/freedesktop/systemd1/unit/my_2dsample_2eservice interface=org.freedesktop.DBus.Properties member=GetAll cookie=2 reply_cookie=0 signature=s error-name=n/a error-message=n/a
Got message type=method_return sender=:1.2 destination=:1.3284 path=n/a interface=n/a member=n/a cookie=27878 reply_cookie=2 signature=a{sv} error-name=n/a error-message=n/a
... (以下通常通りユニットの設定出力)
Environment=foo=bar
この中で以下のログからサービスの情報取得のために D-Bus が利用されていること、また送信メッセージの詳細がわかります (可読性のために整形済)。
Sent message
type=method_call
sender=n/a
destination=org.freedesktop.systemd1
path=/org/freedesktop/systemd1/unit/my_2dsample_2eservice
interface=org.freedesktop.DBus.Properties member=GetAll
cookie=2
reply_cookie=0
signature=s
error-name=n/a
error-message=n/a
これを踏まえて自分で dbus-send
してみると systemctl show
と同様な情報が取得できます。
$ dbus-send --print-reply --system \
--dest=org.freedesktop.systemd1 \
/org/freedesktop/systemd1/unit/my_2dsample_2eservice \
--type=method_call \
org.freedesktop.DBus.Properties.GetAll \
string:
method return time=1668239124.727145 sender=:1.2 -> destination=:1.3368 serial=28854 reply_serial=2
array [
...
dict entry(
string "Environment"
variant array [
string "foo=bar"
]
)
...
]