こんにちは、技術戦略部 SREグループのカンタンです!
データベースのパスワードやAPIサーバの認証情報など秘密情報をきちんと管理しないと漏洩が発生しセキュリティインシデントに繋がる可能性が高いでしょう。
数ヶ月前から、SREグループが扱っている秘密情報の管理方法をgit-cryptからsopsに切り替えました。運用が楽になり、セキュリティレベルが向上し、非常に満足しているためsops自体とSREグループの使い方を紹介させていただきたいと思います。
SREグループでは、Terraform用の設定やKubernetesで動いているサービスの環境変数など秘密情報を暗号化した上で一つのgitリポジトリで一元管理しています。秘密情報を利用する際、暗号化ファイルを復号してからgrepなどで検索したり、Terraformプロジェクトを適用したり、環境変数をKubernetesのSecretに反映したりしています。
暗号化の仕組みとしてgit-cryptを数年利用していましたが、 以下のような課題がありました:
更に、開発者ができることをもっと広げたいと言う意図もあって、今までSREしか管理できなかった秘密情報を開発者に直接管理してもらうための安全なやり方を探していました。
こういった懸念を解決するため、秘密情報の管理方法を完全にリニューアルすることになりました。
秘密情報を管理するには主に2つのやり方があるかと思います。秘密情報を暗号化した上でgitリポジトリに保存するか、秘密情報をAWS Secret Manager、GCP Secret Manager、HashiCorp Vault など外部サービスに預けて管理する。それぞれのやり方のメリットとデメリットを次にまとめていきます。
メリット
デメリット
例:AWS Secret Manager、GCP Secret Manager、HashiCorp Vault
メリット
デメリット
gitでの管理の方がメリットが多いため、秘密情報を引き続きgitで管理することにしました。ただし懸念が多かったgit-cryptからsopsというツールに切り替えたことで、管理方法がもっとセキュアになったうえ、運用が非常に楽になりました。これからsopsについて説明させていただきたいと思います!
sops is an editor of encrypted files that supports YAML, JSON, ENV, INI and BINARY formats and encrypts with AWS KMS, GCP KMS, Azure Key Vault, age, and PGP. (demo)
sopsは様々な機能が詰まったファイルを暗号化するためのツールです。
初期設定後、sops file.yaml のようなコマンドを実行するだけでテキストエディタが開いて、秘密情報を記入して保存した後に以下のような暗号化ファイルが自動的に作成されます。
myapp1: ENC[AES256_GCM,data:Tr7o=,iv:1=,aad:No=,tag:k=]
app2:
db:
user: ENC[AES256_GCM,data:CwE4O1s=,iv:2k=,aad:o=,tag:w==]
password: ENC[AES256_GCM,data:p673w==,iv:YY=,aad:UQ=,tag:A=]
# private key for secret operations in app2
key: |-
ENC[AES256_GCM,data:Ea3kL5O5U8=,iv:DM=,aad:FKA=,tag:EA==]
an_array:
- ENC[AES256_GCM,data:v8jQ=,iv:HBE=,aad:21c=,tag:gA==]
- ENC[AES256_GCM,data:X10=,iv:o8=,aad:CQ=,tag:Hw==]
- ENC[AES256_GCM,data:KN=,iv:160=,aad:fI4=,tag:tNw==]
sops:
kms:
- created_at: 1441570389.775376
enc: CiC....Pm1Hm
arn: arn:aws:kms:us-east-1:656532927350:key/920aff2e-c5f1-4040-943a-047fa387b27e
- created_at: 1441570391.925734
enc: Ci...awNx
arn: arn:aws:kms:ap-southeast-1:656532927350:key/9006a8aa-0fa6-4c14-930e-a2dfb916de1d
pgp:
- fp: 85D77543B3D624B63CEA9E6DBC17301B491B3F21
created_at: 1441570391.930042
enc: |
-----BEGIN PGP MESSAGE-----
hQIMA0t4uZHfl9qgAQ//UvGAwGePyHuf2/zayWcloGaDs0MzI+zw6CmXvMRNPUsA
...=oJgS
-----END PGP MESSAGE-----
sops: キーの下に、そのファイルを暗号化するために利用されたバックエンド(AWS KMS、GPGなど)と暗号化キーのパブリック情報が保存されています。同じ sops file.yaml コマンドをもう一度実行すると、sopsがその情報をファイルから抽出して、バックエンドを使ってファイルを復号しテキストエディタに表示してくれます。sops: キーの上の情報が暗号化されているため、ファイルをgitで管理しても問題ないです。
sopsで特に注目したい特徴が下記になります:
sopsを利用する場合、暗号化・復号の仕組みを理解しないと暗号化キーの発行単位やファイルの分け方など判断しづらい部分があるためしっかり説明したいと思います。AWS KMSを使った場合の説明になりますが、GCPなど他のバックエンドも同じです。
sopsはエンベロープ暗号化を利用してファイルを暗号化しています。sopsを利用する前にAWS KMSなどを使ってマスターキーを作成します。暗号化ファイルを作成する際にsopsがランダムなデータキーを生成し、秘密情報をそのデータキーで暗号化しファイルに保存しています。データキーを安全に保存できるように、マスターキーを使ってデータキーを暗号化してファイルにも保存しています。
復号する際、ファイルに含まれている暗号化されたデータキーをマスターキーで復号します。復号されたデータキーを使ってファイルの中身を復号します。
sopsでは同じファイルを複数マスターキーで管理できます。そうすることで、片方のマスターキーへのアクセスができなくなったとしても、もう一つのマスターキーにアクセスさえできれば復号可能になります。ある意味バックアップとして使える機能です(AWS KMSで管理しつつ、例えばオフラインで管理するAge鍵をバックアップとして設定しておく)。バックアップ以外の目的もあります:例えばチームごとにマスターキーを発行して、同じファイルを複数マスターキーで管理することでファイルを複数チームに共有できます。
仕組み自体が以下のようになります:
マスターキーにアクセスできるユーザがそのキーで暗号化されたファイルを全て復号できます。
AgeやGPG鍵をマスターキーとして使う場合、その鍵を物理的にアクセスする必要があります(ローカルに落とす必要があります)。あるユーザに権限を付与するには、実際のマスターキーを共有する形になるため、マスターキーの漏洩リスクがそれなりに高いです。
AWS KMS (GCP KMSも同様)の場合、マスターキーがAWSの中にしか存在していなくて、sopsを実行する際に暗号化したい内容をAWS KMSに渡して暗号化してもらったり、復号したい内容をAWS KMSに渡して復号してもらったりしています。マスターキーの内容を知る必要がなくて、暗号化と復号をAWS KMSに任せています。あるユーザに権限を付与するには、マスターキーに対してのAWS KMS Encrypt/Decrypt権限を付与すれば大丈夫です。マスターキーの内容を物理的にアクセスする必要がないため、マスターキーの漏洩リスクが0と近いのと、ユーザのAWS認証情報が漏洩した場合にそのユーザのAWS KMS権限を落とせば復号できなくなります。また、ファイルを復号する際にAWS CloudTrailにイベントが残るため、監査ログが取れます。
普段の利用に関しては、AgeやGPG鍵よりもAWS KMSやGCP KMSを使った方がセキュアです。
sopsはとても便利なツールで、機能も多くて様々な利用方法がありますが、これから先はMoTでの使い方を紹介したいと思います。
「権限付与」のところで説明があったように、マスターキーにアクセスできるユーザがそのキーで暗号化されたファイルを全て復号できます。
全てのファイルを単純に同じマスターキーで暗号化してしまうと、全てのファイルの復号権限を付与するかしないかという選択肢しかなくて、細かいアクセス制御を実現できないです。逆にファイルをそれぞれ別のマスターキーで暗号化すれば、ファイル単位の権限を付与できるようになりますが、ファイル数分のマスターキーを発行する必要があります。その場合マスターキーの管理が大変になるのと、AWS KMSを使うとキーごとに月額$1の料金が発生しますのでファイルが多いとそれなりの値段になります。
そこでAWS KMSの「暗号化コンテキスト」機能が登場します。暗号化コンテキストは environment=development, team=team-a のような自由に決めれるkey-valueのリストです。AWS KMSで暗号化する時、暗号化したい内容と合わせて暗号化コンテキストを渡すことで、復号する際に全く同じ暗号化コンテキストを渡さないと復号できないようになっています。更に、AWS IAMポリシーを定義する際に暗号化コンテキストを考慮したアクセス権限を付与できます。以下のサンプルの場合、 environment=development, team=team-a 暗号化コンテキストが指定された場合のみ権限を付与することになり、別の暗号化コンテキストが渡された場合は権限エラーになります。
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AccessKMSKey",
"Effect": "Allow",
"Action": [
"kms:Encrypt",
"kms:Decrypt",
"kms:ReEncrypt*",
"kms:GenerateDataKey*",
"kms:DescribeKey"
],
"Resource": "arn:aws:kms:ap-northeast-1:ACCOUNT_ID:key/KMS_KEY_ID",
"Condition": {
"StringEquals": {
"kms:EncryptionContext:environment": "development",
"kms:EncryptionContext:team": "team-a"
}
}
}
]
}
sopsコマンドのオプションに暗号化コンテキストを渡すことでファイルを特定のコンテキストで暗号化できます。
sops --encryption-context environment:development,team:team-a file.yaml
以下のような .sops.yaml ファイルを作れば、暗号化ルールを定義できてコマンドラインに何も指定しなくても良くなります。
creation_rules:
- path_regex: /development/team-a/.* # ファイルパスのregex
key_groups:
- kms:
- arn: 'AWS_KMS_MASTER_KEY_ARN'
context: # 暗号化コンテキスト
environment: development
team: team-a
- path_regex: /development/team-b/.*
key_groups:
- kms:
- arn: 'AWS_KMS_MASTER_KEY_ARN'
context: # 暗号化コンテキスト
environment: development
team: team-b
- path_regex: /production/team-a/.*
key_groups:
- kms:
- arn: 'AWS_KMS_MASTER_KEY_ARN'
context:
environment: production
team: team-a
- path_regex: /production/team-b/.*
key_groups:
- kms:
- arn: 'AWS_KMS_MASTER_KEY_ARN'
context: # 暗号化コンテキスト
environment: production
team: team-b
この機能を使えば、ファイルごとに適切な暗号化コンテキストを設定すれば一つのAWS KMSマスターキーだけで全てのファイルを暗号化しても問題ないです。権限付与も非常に柔軟になります:「dev環境の全てのファイルの権限」、「prod環境の特定のチームのファイルの権限」、「あるチームの全てのファイルの権限」など。
sopsを利用する際にSREグループで考えた方針を以下にまとめています。
ファイルを平文状態でローカルで待たないようにしていますが、秘密情報をgrepで検索したい時やTerraformを実行したい時など一時的に復号したい場合もあります。
あるフォルダの全てのファイルを一時的に復号したり、変更のあったファイルだけを再暗号化したり、gitコンフリクトを自動的に解決したりするスクリプトをいくつか用意しています。
参考まで、ファイルの一括復号と再暗号化スクリプトサンプルを以下に記載します。
利用: .sops.yaml が設定されているフォルダから実行
./modify.sh my-folder
modify.sh
#!/usr/bin/env bash
set -euo pipefail
search_paths=${@:-.}
# --------------------
# Helpers
# --------------------
to_encrypted_file() {
decrypted_file=$1
echo "${decrypted_file/\.secret/.secret.enc}"
}
to_decrypted_file() {
encrypted_file=$1
echo "${encrypted_file/secret\.enc/secret}"
}
find_unencrypted_secret_files() {
search_paths=$@
find $search_paths* -type f \( -name '*\.secret*' ! -name '*\.secret\.enc*' \)
}
find_encrypted_secret_files() {
search_paths=$@
find $search_paths* -type f -name '*\.secret\.enc*'
}
confirm() {
message=$1
read -p "$message (Y/N)" -n 1 -r
echo
[[ $REPLY =~ ^[Yy]$ ]]
}
# --------------------
# Encryption
# --------------------
decrypt_file() {
encrypted_file=$1
decrypted_file="$(to_decrypted_file $encrypted_file)"
echo "Decrypt file $encrypted_file into $decrypted_file"
# clean previous file
rm -f $decrypted_file
# decrypt
set +e
sops --config /dev/null --decrypt "$encrypted_file" > "$decrypted_file"
decryption_status=$?
set -e
if [[ "$decryption_status" != "0" ]]; then
rm -f $decrypted_file
exit 1
fi
}
reencrypt_file() {
decrypted_file=$1
encrypted_file="$(to_encrypted_file $decrypted_file)"
echo "Encrypt file $decrypted_file into $encrypted_file"
# update encrypted file
NEW_DECRYPTED=$decrypted_file EDITOR=./reencryption-editor.sh sops "$encrypted_file"
# delete previous file
echo "Delete file ${decrypted_file}"
rm "$decrypted_file"
}
# reencrypt then delete decrypted secrets
reencrypt_files () {
decryption_time=$1
echo "--------------------"
echo "Reencrypting secrets..."
unencrypted_files="$(find_unencrypted_secret_files $search_paths)"
while read -r file; do
if [[ "$file" == "" ]]; then continue; fi
file_modification_time=$(/usr/bin/stat -t %s -f %m $file)
if [ $file_modification_time -gt $decryption_time ]; then
reencrypt_file $file
else
echo "Skipping not-modified file $file"
fi
done <<< "$(echo -e "$unencrypted_files")"
echo "------- done -------"
}
# If anything goes wrong, then delete decrypted secrets
handle_trap_exit () {
exit_code=$?
echo "--------------------"
echo "Deleting unencrypted secrets..."
unencrypted_files="$(find_unencrypted_secret_files $search_paths)"
while read -r file; do
if [[ "$file" == "" ]]; then continue; fi
echo "Delete file $file"
rm $file
done <<< "$(echo -e "$unencrypted_files")"
echo "------- done -------"
if [ "$exit_code" != "0" ]; then
echo "Error when decrypting secrets"
exit 1
fi
}
# --------------------
# Process
# --------------------
# dry-run
found_files=0
echo "Checking paths ${search_paths}"
echo "--------------------"
encrypted_files="$(find_encrypted_secret_files $search_paths)"
while read -r file; do
if [[ "$file" == "" ]]; then continue; fi
echo "[dry-run] $file will be decrypted"
found_files=1
done <<< "$(echo -e "$encrypted_files")"
if [ "${found_files}" == "0" ]; then
echo "No files to decrypt!"
exit 0
fi
echo "--------------------"
found_files=0
unencrypted_files="$(find_unencrypted_secret_files $search_paths)"
while read -r file; do
if [[ "$file" == "" ]]; then continue; fi
echo "[dry-run] $file will be deleted"
found_files=1
done <<< "$(echo -e "$unencrypted_files")"
if [ "${found_files}" != "0" ]; then
echo "--------------------"
fi
# confirmation
confirm "All non-encrypted secrets will be overriden, continue ?"
# execution
trap handle_trap_exit EXIT
num_processes=10
i=0
while read -r file; do
if [[ "$file" == "" ]]; then continue; fi
i=$((i%num_processes)); ((i++==0)) && wait
decrypt_file $file &
done <<< "$(echo -e "$encrypted_files")"
wait
decryption_time="$(date +%s)"
echo "------- done -------"
echo "Secrets are now decrypted and can be modified"
read -p "Press a key to reencrypt files and close this program..." -n 1 -r
reencrypt_files $decryption_time
reencryption-editor.sh
#!/usr/bin/env bash
set -eu
# copy new decrypted into file
cp $NEW_DECRYPTED $1
サンプル実行
$ ls secrets/ Thu Nov 17 13:42:08 2022
app1.secret.enc.yaml passwords.secret.enc.yaml
$ ./modify.sh secrets 28.6s Thu Nov 17 13:41:02 2022
Checking paths secrets
--------------------
[dry-run] secrets/passwords.secret.enc.yaml will be decrypted
[dry-run] secrets/app1.secret.enc.yaml will be decrypted
--------------------
All non-encrypted secrets will be overriden, continue ? (Y/N)y
Decrypt file secrets/passwords.secret.enc.yaml into secrets/passwords.secret.yaml
Decrypt file secrets/app1.secret.enc.yaml into secrets/app1.secret.yaml
------- done -------
Secrets are now decrypted and can be modified
Press a key to reencrypt files and close this program...
# -------
# 別ターミナルで、復号されたsecrets/passwords.secret.yamlファイルを編集する
# 編集後、元のターミナルでキーを押す
# -------
--------------------
Reencrypting secrets...
Skipping not-modified file secrets/app1.secret.yaml
Encrypt file secrets/passwords.secret.yaml into secrets/passwords.secret.enc.yaml
Delete file secrets/passwords.secret.yaml
------- done -------
--------------------
Deleting unencrypted secrets...
Delete file secrets/app1.secret.yaml
------- done -------
秘密情報を管理するにはsopsがとても便利なツールで大変おすすめです。他のエンジニアにアクセス権限を柔軟に付与できるようになり、平文状態の秘密情報がローカルに保存されなくなり、既存ツールを全く変えずに今までのTerraformやKubernetes運用を継続でき、SREグループでsopsに切り替えてから数ヶ月経っていますが非常に満足しています。
最後にまとめると、sopsとAWS KMSを併用すると以下のメリットがあります:
秘密情報の管理方法に悩んでいる方へ、是非sopsを検討してみてください!この記事がご参考になれば幸いです!
興味のある方は 採用ページ も見ていただけると嬉しいです。
Twitter @mot_techtalk のフォローもよろしくお願いします!