WinRM 证书认证

WinRM 的证书验证,是一种使用 X.509 证书,而非用户名和密码,验证 Windows 主机的方法。

与基于 SSH 密钥的身份验证相比,证书身份验证确实有些缺点,比如:

  • 其只能映射到某个本地 Windows 用户,不能映射到域账户;
  • 用户名和密码必须映射到证书上,如果密码更改,则该证书就需要重新映射;
  • Windows 主机上的管理员可经由证书映射,检索该名本地用户的密码;
  • Ansible 无法使用加密的私钥,因此必须在未加密的情况下存储私钥;
  • Ansible 无法使用存储为变量的证书及私钥,他们必须是个文件;

Ansible 的配置

证书身份验证使用证书作为密钥,类似于 SSH 密钥对。公钥和私钥存储在 Ansible 控制节点上,用于身份验证。下面的示例显示了配置用于证书身份验证的一些主机变量:

# psrp
ansible_connection: psrp
ansible_psrp_auth: certificate
ansible_psrp_certificate_pem: /path/to/certificate/public_key.pem
ansible_psrp_certificate_key_pem: /path/to/certificate/private_key.pem

# winrm
ansible_connection: winrm
ansible_winrm_transport: certificate
ansible_winrm_cert_pem: /path/to/certificate/public_key.pem
ansible_winrm_cert_key_pem: /path/to/certificate/private_key.pem

默认情况下 Windows 主机上的证书验证未被启用,但可以通过在 PowerShell 中运行以下命令启用:

Set-Item -Path WSMan:\localhost\Service\Auth\Certificate -Value $true

由于 Ansible 所使用的 Python 底层库的限制,其中的私钥无法加密。

注意:要启用 TLS 1.3 连接下的证书验证,需要 Python 3.8+、3.7.1 或 3.6.7 版本,以及 Python 的软件包 urllib3>=2.0.7,或更新版本。

证书生成

使用证书验证的第一步,是要生成一张证书及私钥。证书生成必需带有以下属性:

  • Extended Key Usage 必须包含 clientAuth (1.3.6.1.5.5.7.3.2)
  • Subject Alternative Name 必须包含 userPrincipalName (1.3.6.1.4.1.311.20.2.3)otherName 条目。

userPrincipalName 的值可以是任何值,但在这个指南中,我们将使用值 $USERNAME@localhost,其中 $USERNAME 是该证书将映射到的用户名字。

此操作可通过多种方式完成,诸如 OpenSSL、PowerShell 或 Active Directory 证书服务。下面的示例展示了如何使用 OpenSSL 生成一张证书:

#!/usr/bin/env bash
# Set the username to the name of the user the certificate will be mapped to
USERNAME="local-user"

cat > openssl.conf << EOL
distinguished_name = req_distinguished_name

[req_distinguished_name]
[v3_req_client]
extendedKeyUsage = clientAuth
subjectAltName = otherName:1.3.6.1.4.1.311.20.2.3;UTF8:${USERNAME}@localhost
EOL

openssl req \
    -new \
    -sha256 \
    -subj "/CN=${USERNAME}" \
    -newkey rsa:2048 \
    -nodes \
    -keyout cert.key \
    -out cert.csr \
    -config openssl.conf \
    -reqexts v3_req_client

openssl x509 \
    -req \
    -in cert.csr \
    -sha256 \
    -out cert.pem \
    -days 365 \
    -extfile openssl.conf \
    -extensions v3_req_client \
    -key cert.key

rm openssl.conf cert.csr

以下示例展示了如何使用 PowerShell 生成一张证书:

# Set the username to the name of the user the certificate will be mapped to
$username = 'local-user'

$clientParams = @{
    CertStoreLocation = 'Cert:\CurrentUser\My'
    NotAfter          = (Get-Date).AddYears(1)
    Provider          = 'Microsoft Software Key Storage Provider'
    Subject           = "CN=$username"
    TextExtension     = @("2.5.29.37={text}1.3.6.1.5.5.7.3.2","2.5.29.17={text}upn=$username@localhost")
    Type              = 'Custom'
}
$cert = New-SelfSignedCertificate @clientParams
$certKeyName = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey(
    $cert).Key.UniqueName

# Exports the public cert.pem and key cert.pfx
Set-Content -Path "cert.pem" -Value @(
    "-----BEGIN CERTIFICATE-----"
    [Convert]::ToBase64String($cert.RawData) -replace ".{64}", "$&`n"
    "-----END CERTIFICATE-----"
)
$certPfxBytes = $cert.Export('Pfx', '')
[System.IO.File]::WriteAllBytes("$pwd\cert.pfx", $certPfxBytes)

# Removes the private key and cert from the store after exporting
$keyPath = [System.IO.Path]::Combine($env:AppData, 'Microsoft', 'Crypto', 'Keys', $certKeyName)
Remove-Item -LiteralPath "Cert:\CurrentUser\My\$($cert.Thumbprint)" -Force
Remove-Item -LiteralPath $keyPath -Force

由于 PowerShell 无法生成 PKCS8 PEM 的私钥,因此我们需要使用 OpenSSL,将该 cert.pfx 文件转换为一个 PEM 私钥:

openssl pkcs12 \
    -in cert.pfx \
    -nocerts \
    -nodes \
    -passin pass: |
    sed -ne '/-BEGIN PRIVATE KEY-/,/-END PRIVATE KEY-/p' > cert.key

其中 cert.pem 为公钥,cert.key 为明文的私钥。这两个文件必须能被 Ansible 控制节点访问,以用作身份验证。私钥则无需存在于 Windows 节点上。

Windows 的配置

一旦公钥和私钥生成,我们就需要在 Windows 主机上导入并信任公钥,及配置用户映射。Windows 主机无需访问私钥,只需能访问公钥 cert.pem 以配置证书验证。

导入证书到证书存储

要让 Windows 信任该证书,就必须将其导入 LocalMachine\TrustedPeople 证书存储区。咱们可通过运行以下脚本完成这一操作:

{{#import import_cert.ps1}}

如果该证书是自签名的,或由某个主机不信任的 CA 签发,则咱们需要将该 CA 证书,导入受信任的根存储。由于我们的示例使用了一张自签名证书,因此我们将把该证书,作为一个受信任 CA 导入,但在生产环境中,我们将导入签发该证书的 CA。

$store = Get-Item -LiteralPath Cert:\LocalMachine\Root
$store.Open('ReadWrite')
$store.Add($cert)
$store.Dispose()

将证书映射到某名本地用户

一旦该证书已导入 LocalMachine\TrustedPeople 存储,WinRM 服务就可以创建该证书与某个本地账户之间的映射。这通过运行以下 PowerShell 脚本完成:

# Will prompt for the password of the user.
param ($username)
$credential = Get-Credential "$username"

$cert = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new("$HOME\cert.pem")
$certChain = [System.Security.Cryptography.X509Certificates.X509Chain]::new()
[void]$certChain.Build($cert)
$caThumbprint = $certChain.ChainElements.Certificate[-1].Thumbprint

$certMapping = @{
    Path       = 'WSMan:\localhost\ClientCertificate'
    Subject    = $cert.GetNameInfo('UpnName', $false)
    Issuer     = $caThumbprint
    Credential = $credential
    Force      = $true
}
New-Item @certMapping

其中 Subject 是证书 SAN 条目 1userPrincipalName 的值。Issuer 是签发咱们证书的 CA 证书指纹。Credential 是我们要将证书映射到的本地用户的用户名与密码。

译注

  1. 证书 SAN 条目,the certificate SAN entry,Subject Alternative Name 证书是一种特殊的 SSL/TLS 证书,允许在一个证书下保护多个主机名或域名。

参考:The Essential Guide to SAN Certificates

  1. 经测试,原文中:
$cert = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new("cert.pem")

应写作:

$cert = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new("$HOME\cert.pem")

否则会报出 Execption calling ".ctor" with "1" argument(s): "The system cannot find the path specified." 错误。且由于脚本中的 Get-Credential "$username",而会弹出一个要求输入该账号密码的交互式弹窗,因此需要在 Windows 主机上手动运行。

Get-Credential 弹窗

map_cert_and_user.ps1 -username administrator 运行该 PowerShell 脚本成功后的输出如下:

> .\map_cert_and_user.ps1 -username administrator


   WSManConfig: Microsoft.WSMan.Management\WSMan::localhost\ClientCertificate

Type            Keys                                Name
----            ----                                ----
Container       {URI=*, Issuer=388697C2DDD757812... ClientCertificate_9346497

使用 Ansible

下面的 Ansible playbook 可用于创建一名本地用户并映射所提供的证书,以用于证书身份验证。其需要以调用分别设置要创建的用户名及所生成公钥 PEM 文件路径的 usernamecert_pem 变量调用。这个 playbook 期望 cert_pem 为自签名证书,若使用的是由某个 CA 签发的证书,咱们将必须对其进行编辑,以便将其复制并导入 LocalMachine\Root 存储空间。

- name: Setup WinRM Client Cert Authentication
  hosts: windows
  gather_facts: false

  tasks:
  - name: Verify required facts are setup
    ansible.builtin.assert:
      that:
      - cert_pem is defined
      - username is defined

  - name: Check that the required files are present
    ansible.builtin.stat:
      path: '{{ cert_pem }}'
    delegate_to: localhost
    run_once: true
    register: local_cert_stat

  - name: Fail if cert PEM is not present
    ansible.builtin.assert:
    that:
    - local_cert_stat.stat.exists

  - name: Generate local user password
    ansible.builtin.set_fact:
      user_password: "{{ lookup('ansible.builtin.password', playbook_dir ~ '/user_password', length=15) }}"

  - name: Create local user
    ansible.windows.win_user:
      name: '{{ username }}'
      groups:
      - Administrators
      - Users
      update_password: always
      password: '{{ user_password }}'
      user_cannot_change_password: true
      password_never_expires: true

  - name: Copy across client certificate
    ansible.windows.win_copy:
      src: '{{ cert_pem }}'
      dest: C:\Windows\TEMP\cert.pem

  - name: Import client certificate
    ansible.windows.win_certificate_store:
      path: C:\Windows\TEMP\cert.pem
      state: present
      store_location: LocalMachine
      store_name: '{{ item }}'
    register: client_cert_info
    loop:
    - Root
    - TrustedPeople

  - name: Enable WinRM Certificate auth
    ansible.windows.win_powershell:
      script: |
        $ErrorActionPreference = 'Stop'
        $Ansible.Changed = $false

        $authPath = 'WSMan:\localhost\Service\Auth\Certificate'
        if ((Get-Item -LiteralPath $authPath).Value -ne 'true') {
            Set-Item -LiteralPath $authPath -Value true
            $Ansible.Changed = $true
        }

  - name: Setup Client Certificate Mapping
    ansible.windows.win_powershell:
      parameters:
        Thumbprint: '{{ client_cert_info.results[0].thumbprints[0] }}'
      sensitive_parameters:
      - name: Credential
        username: '{{ username }}'
        password: '{{ user_password }}'
      script: |
        param(
            [Parameter(Mandatory)]
            [PSCredential]
            $Credential,

            [Parameter(Mandatory)]
            [string]
            $Thumbprint
        )

        $ErrorActionPreference = 'Stop'
        $Ansible.Changed = $false

        $userCert = Get-Item -LiteralPath "Cert:\LocalMachine\TrustedPeople\$Thumbprint"
        $subject = $userCert.GetNameInfo('UpnName', $false)  # SAN userPrincipalName

        $certChain = New-Object -TypeName Security.Cryptography.X509Certificates.X509Chain
        [void]$certChain.Build($userCert)
        $caThumbprint = $certChain.ChainElements.Certificate[-1].Thumbprint

        $mappings = Get-ChildItem -LiteralPath WSMan:\localhost\ClientCertificate |
            Where-Object {
                $mapping = $_ | Get-Item
                "Subject=$subject" -in $mapping.Keys
            }

        if ($mappings -and "issuer=$($caThumbprint)" -notin $mappings.Keys) {
            $null = $mappings | Remove-Item -Force -Recurse
            $mappings = $null
            $Ansible.Changed = $true
        }

        if (-not $mappings) {
            $certMapping = @{
                Path = 'WSMan:\localhost\ClientCertificate'
                Subject = $subject
                Issuer = $caThumbprint
                Credential = $Credential
                Force = $true
            }
            $null = New-Item @certMapping
            $Ansible.Changed = $true
        }

(End)

Last change: 2025-03-07, commit: 10791c6

小额打赏,赞助 xfoss.com 长存......

微信 | 支付宝

若这里内容有帮助到你,请选择上述方式向 xfoss.com 捐赠。