見出し画像

HashiCorp Vaultで署名付きSSH公開鍵を生成

時のうつろいというものは早いもので、HashiCorp Boundaryの記事を投稿してから早3ヶ月。「次回以降、これらの課題に取り組んでいきます!」と宣言しっぱなしになっていましたので、今回は「SSHの秘密鍵の管理」に取り組みます。


はじめに

「SSHの秘密鍵の管理」は、前回のBoundaryの記事で残っていた課題の一つです。

・SSHの秘密鍵の管理
 ・Boundaryはトンネルを張るだけであり、SSH接続時の秘密鍵の管理について考慮が必要です

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認証にてログインが可能です。

Vaultのログイン画面

宛先サーバの設定

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認証に関する課題」についてはまだ触れていないため、また次回以降の記事で紹介予定です。

この記事が気に入ったらサポートをしてみませんか?