HashiCorp Vaultで署名付きSSH公開鍵を生成
時のうつろいというものは早いもので、HashiCorp Boundaryの記事を投稿してから早3ヶ月。「次回以降、これらの課題に取り組んでいきます!」と宣言しっぱなしになっていましたので、今回は「SSHの秘密鍵の管理」に取り組みます。
はじめに
「SSHの秘密鍵の管理」は、前回のBoundaryの記事で残っていた課題の一つです。
Boundary(のCommunityバージョン)は宛先サーバとのトンネルを張るだけなので、宛先サーバへSSHする場合にはパスワード認証や公開鍵認証など、通常のSSHで利用する認証方式を用いる必要があります。
しかし、何台もあるサーバに対して、ユーザ毎のパスワードや公開鍵を設定するのは非常に手間がかかるため、今回はHashiCorp VaultのSigned SSH certificates機能を利用して、その手間を省きます。
機能の詳細については、HashiCorp社の記事を日本語訳されているラック社の良い記事がありますので、ご参照ください。
環境の構築
構成
前回の記事で構築したBoundary ServerにVaultを同居させます。
クライアント端末からの通信はALBが受け取り、Vaultのアクティブノードに転送します。(Vaultはアクティブ・スタンバイ構成となります)
AWS環境を構築するTerraform
前回の記事で作成したTerraformに追記していきます。
まずはALBの定義です。特徴的な点としては、ALB/Vault間もTLS通信を行うため、protocolにHTTPSを指定しています。
resource "aws_lb_target_group" "vault" {
name = "vault"
target_type = "instance"
port = 8200
protocol = "HTTPS"
vpc_id = data.aws_vpc.main.id
health_check {
protocol = "HTTPS"
path = "/v1/sys/health"
matcher = "200"
}
}
resource "aws_lb_target_group_attachment" "vault" {
for_each = local.boundary_instances
target_group_arn = aws_lb_target_group.vault.arn
target_id = aws_instance.boundary[each.key].id
}
resource "aws_lb_listener_rule" "vault" {
listener_arn = data.aws_lb_listener.https-listener.arn
action {
type = "forward"
target_group_arn = aws_lb_target_group.vault.arn
}
condition {
host_header {
values = ["vault.example.com"]
}
}
}
次にセキュリティグループの定義です。前回の記事で作成したaws_security_group.boundaryにingressルールを追加します。
ingress {
description = "Vault api"
from_port = 8200
to_port = 8200
protocol = "tcp"
security_groups = [data.aws_security_group.alb.id]
}
ingress {
description = "Vault cluster"
from_port = 8201
to_port = 8201
protocol = "tcp"
security_groups = [aws_security_group.boundary-cluster.id]
}
次にVaultが使用するKMSを定義します。Vaultを起動すると始めに秘密鍵が存在しないSealという状態となり、データベースに保管されている暗号済データを復号することができません。これを自動的にUnseal状態にするauto-unseal機能を利用するために、KMSを用います。
resource "aws_kms_key" "vault" {
description = "Vault auto-unseal key"
deletion_window_in_days = 10
}
最後に前回の記事で作成したdata.aws_iam_policy_document.boundaryのresourcesにaws_kms_key.vault.arnを追加します。
data "aws_iam_policy_document" "boundary" {
statement {
actions = [
"kms:DescribeKey",
"kms:GenerateDataKey",
"kms:Decrypt",
"kms:Encrypt",
"kms:ListKeys",
"kms:ListAliases"
]
effect = "Allow"
resources = [
aws_kms_key.root.arn,
aws_kms_key.worker_auth.arn,
aws_kms_key.recovery.arn,
aws_kms_key.vault.arn
]
}
}
後はTerraformを実行してAWSのリソースを作成します。
$ terraform plan
$ terraform apply
VaultをインストールするAnsible Playbook
今回もRoleを使用してPlaybookを書いていきます。
./roles/vault/tasks/main.yml
---
# Vaultをインストール
- dnf:
name: vault
state: present
notify: Restart vault service
# Vaultの設定ファイルをテンプレートから作成
- template:
src: "vault.hcl.j2"
dest: "/etc/vault.d/vault.hcl"
owner: vault
group: vault
mode: '0640'
notify: Restart vault service
# VaultでTLS通信するための秘密鍵を作成
- community.crypto.openssl_privatekey:
path: /opt/vault/tls/server.key
size: 2048
owner: vault
group: vault
notify: Restart vault service
# VaultでTLS通信するための自己署名証明書を作成
- community.crypto.x509_certificate:
path: /opt/vault/tls/server.crt
privatekey_path: /opt/vault/tls/server.key
provider: selfsigned
owner: vault
group: vault
notify: Restart vault service
./roles/vault/handlers/main.yml
---
- name: Restart vault service
systemd:
name: vault.service
state: restarted
daemon_reload: yes
enabled: yes
./roles/vault/vars/main.yml
パスワードを含むので、例によってansible-vault encrypt_stringなどで暗号化しておきましょう。
---
psql_password: "<PostgreSQL Password>"
kms_key_id: "<auto-unseal用のKMSのキーID>"
./roles/vault/templates/vault.hcl.j2
設定例の記事も多く存在するので、ここでは各設定の説明は割愛させていただきます。
ui = true
cluster_addr = "https://{{ ansible_default_ipv4.address }}:8201"
api_addr = "https://{{ ansible_default_ipv4.address }}:8200"
storage "postgresql" {
connection_url = "postgresql://vault:{{ psql_password }}@psql.example.com/vault"
ha_enabled = true
}
listener "tcp" {
address = "{{ ansible_default_ipv4.address }}:8200"
tls_cert_file = "/opt/vault/tls/server.crt"
tls_key_file = "/opt/vault/tls/server.key"
tls_min_version = "tls12"
}
seal "awskms" {
kms_key_id = "{{ kms_key_id }}"
}
後は、このRoleを呼び出すようにsite.ymlなどに追記して、ansible-playbookを実行します
Vaultを設定するTerraform
Terraformのファイルを作成する前に、VaultをインストールしたホストにログインしてVaultを初期化します。
initコマンドを実行したときに表示されるトークンは、Vault Providerの定義で利用します。
$ export VAULT_ADDR=https://vault.example.com
$ vault operator init
TerraformとVault Providerの定義です。
tokenには一つ前のinitコマンド実行時に表示されたトークンを入力します。
terraform {
backend "<任意のバックエンドをどうぞ>" {
...
}
required_providers {
boundary = {
source = "hashicorp/vault"
}
}
}
provider "vault" {
address = "https://vault.example.com"
token = <init時に取得したトークン>
}
まずはOIDC認証の定義です。
前回に引き続きGoogle WorkspaceをIdPとする設定になりますが、VaultはBoundaryと異なりユーザ/グループ情報の取得に対応しています。Googleでサービスアカウントを作成して、provider_configに必要情報を入力します。(gsuite_admin_impersonateにはGoogle Workspaceの管理者のメールアドレスを入力します)
トークンのTTLは、Boundaryの標準値に合わせて168時間(7日)としました。
allowed_redirect_urisはWebUIのURLと、クライアント端末上のvaultコマンド向けのURLをそれぞれ設定します。
resource "vault_jwt_auth_backend" "google" {
path = "Google"
type = "oidc"
oidc_discovery_url = "https://accounts.google.com"
oidc_client_id = "<クライアントID>"
oidc_client_secret = "<クライアントシークレット>"
bound_issuer = "https://accounts.google.com"
default_role = "default"
tune {
listing_visibility = "unauth"
default_lease_ttl = "168h"
max_lease_ttl = "168h"
token_type = "default-service"
}
provider_config = {
provider = "gsuite"
gsuite_service_account = file("./files/<サービスアカウントのJSONキー>")
gsuite_admin_impersonate = "admin@example.com"
fetch_groups = true
fetch_user_info = true
}
}
resource "vault_jwt_auth_backend_role" "google" {
backend = vault_jwt_auth_backend.google.path
role_name = vault_jwt_auth_backend.google.default_role
user_claim = "sub"
groups_claim = "groups"
role_type = "oidc"
allowed_redirect_uris = [
"https://vault.example.com/ui/vault/auth/Google/oidc/callback",
"http://localhost:8250/oidc/callback"
]
}
次はグループの定義です。
vault_identity_group_aliasのnameにGoogle Workspaceのグループのメールアドレスを入力することで、OIDC認証を実施したときに自動的にVaultのグループへ登録されるようになります。
resource "vault_identity_group" "developer" {
name = "developer"
type = "external"
policies = [vault_policy.developer.name]
}
resource "vault_identity_group_alias" "developer" {
name = "developer@example.com"
mount_accessor = vault_jwt_auth_backend.google.accessor
canonical_id = vault_identity_group.developer.id
}
肝となるSSHシークレットエンジンの定義です。
今回は以下の設定をしています。
allowed_users
宛先サーバのec2-userユーザへのSSHにのみ利用可能
permit-pty
SSHのターミナル利用を許可
permit-port-forwarding
SSHポートフォワーディングを許可
ttl
署名付き公開鍵は署名してから24時間のみ有効
resource "vault_mount" "ssh" {
path = "ssh"
type = "ssh"
}
resource "vault_ssh_secret_backend_ca" "ssh" {
backend = vault_mount.ssh.path
generate_signing_key = true
}
resource "vault_ssh_secret_backend_role" "example" {
name = "example-role"
backend = vault_mount.ssh.path
key_type = "ca"
allow_user_certificates = true
allowed_users = "ec2-user"
default_extensions = {
permit-pty = ""
permit-port-forwarding = ""
}
default_user = "ec2-user"
ttl = "86400"
}
最後にポリシーの定義です。
一つ前で定義したSSHシークレットエンジンを利用したSSH公開鍵の署名のみ実行できるポリシーとしています。
このポリシーは、グループの定義で設定したvault_identity_group.developerのpoliciesに指定しています。
resource "vault_policy" "developer" {
name = "developer"
policy = <<EOT
path "ssh/sign/*" {
capabilities = ["update"]
}
EOT
}
後はTerraformを実行してVaultに設定を流し込みます。
$ terraform plan
$ terraform apply
問題が無ければ、ALBで指定したドメインにブラウザでアクセスすると、Web UIが表示されて、OIDC認証にてログインが可能です。
宛先サーバの設定
SSHしたい宛先サーバにおいて、署名付きSSH公開鍵で認証できるようにsshdの設定を変更します。
SSHシークレットエンジンのCA公開鍵は自由に取得可能なので、curlで取得します。
今回はBoundary経由でのみ署名付きSSH公開鍵を利用可能とするため、Match条件を利用します。
# curl -o /etc/ssh/trusted-user-ca-keys.pem https://vault.example.com/v1/ssh/public_key
# echo -e "\nMatch Address <Boundary Server AのIPアドレス>,<Boundary Server BのIPアドレス>\n\tTrustedUserCAKeys /etc/ssh/trusted-user-ca-keys.pem\n\tAuthorizedKeysCommand none" >> /etc/ssh/sshd_config
# tail -3 /etc/ssh/sshd_config
Match Address <Boundary Server AのIPアドレス>,<Boundary Server BのIPアドレス>
TrustedUserCAKeys /etc/ssh/trusted-user-ca-keys.pem
AuthorizedKeysCommand none
# systemctl reload sshd.service
宛先サーバへSSHする
最後に宛先サーバへSSHできるか確認します。
まずはクライアント端末にVaultのクライアントをインストールし、環境変数VAULT_ADDRを設定します。
$ brew install hashicorp/tap/vault
$ export VAULT_ADDR=https://vault.example.com
vault loginを実行すると、自動的にブラウザが開きGoogle Workspaceを利用したOIDC認証が実施されます。
$ vault login -method=oidc -path="Google/"
問題なくログインできたら、クライアント端末のSSH公開鍵を署名します。
ssh-keygen -Lfで署名付きSSH公開鍵を確認すると、vault_ssh_secret_backend_role.exampleで設定した通り、署名から24時間有効、ec2-userユーザのみ許可、SSHポートフォワーディングの許可、SSHターミナルの許可が指定されていることが確認できます。
$ vault write -field=signed_key ssh/sign/example-role public_key=@$HOME/.ssh/id_ed25519.pub > ~/.ssh/id_ed25519-signed.pub
$ ssh-keygen -Lf ~/.ssh/id_ed25519-signed.pub
/Users/example/.ssh/id_ed25519-signed.pub:
Type: ssh-ed25519-cert-v01@openssh.com user certificate
Public key: ED25519-CERT SHA256:<省略>
Signing CA: RSA SHA256:<省略> (using rsa-sha2-256)
Key ID: "vault-Google-<省略>"
Serial: <省略>
Valid: from 2024-09-27T10:12:58 to 2024-09-28T10:13:28
Principals:
ec2-user
Critical Options: (none)
Extensions:
permit-port-forwarding
permit-pty
最後にboundaryコマンドを利用しつつ宛先サーバへSSHします。
-i 引数で秘密鍵と署名付き公開鍵の両方を指定することでログインすることができます。
$ boundary connect ssh ttcp_ydR33hKmnE -- -l ec2-user -i ~/.ssh/id_ed25519 -i ~/.ssh/id_ed25519-signed.pub
, #_
~\_ ####_ Amazon Linux 2023
~~ \_#####\
~~ \###|
~~ \#/ ___ https://aws.amazon.com/linux/amazon-linux-2023
~~ V~' '->
~~~ /
~~._. _/
_/ _/
_/m/'
Last login: Sat Sep 10 13:28:23 2024 from 192.0.2.12
[ec2-user@target-server ~]$
おわりに
前回と今回の記事を合わせることで、インターネットに公開されていないサーバへの接続(Boundary)と、クライアント端末上の任意のSSH公開鍵を用いたSSH接続(Vault)を実現することができました。
また、これらの権限制御はIdPとのOIDC認証によって、動的に管理することが可能なため、メンバー入退場時の負担軽減に繋がります。
前回の記事の「おわりに」に記載した「OIDC認証に関する課題」についてはまだ触れていないため、また次回以降の記事で紹介予定です。