WinRM 证书认证

WinRM 证书认证是一种使用 X.509 证书而不是用户名和密码来对 Windows 主机进行身份验证的方法。

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

  • 它只能映射到本地 Windows 用户,不能映射到域帐户

  • 必须将用户名和密码映射到证书,如果密码更改,则需要重新映射证书

  • Windows 主机上的管理员可以通过证书映射检索本地用户密码

  • Ansible 无法使用加密的私钥,它们必须存储为未加密状态

  • Ansible 无法使用存储为变量的证书和私钥,它们必须是文件

Ansible 配置

证书身份验证使用证书作为密钥,类似于 SSH 密钥对。公钥和私钥存储在 Ansible 控制节点上,用于身份验证。以下示例显示了为证书身份验证配置的 hostvars

# 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 必须包含 otherName 条目用于 userPrincipalName (1.3.6.1.4.1.311.20.2.3)

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

这可以通过多种方法完成,例如 OpenSSL、PowerShell 或 Active Directory 证书服务。以下示例显示了如何使用 OpenSSL 生成证书

# 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 证书存储中。您可以通过运行以下命令来完成此操作

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

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

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

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

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

将证书映射到本地帐户

将证书导入 LocalMachine\TrustedPeople 存储后,WinRM 服务可以创建证书和本地帐户之间的映射。这是通过运行以下命令完成的

# Will prompt for the password of the user.
$credential = Get-Credential local-user

$cert = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new("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 条目中 userPrincipalName 的值。Issuer 是颁发我们证书的 CA 证书的指纹。Credential 是我们将证书映射到的本地用户的用户名和密码。

使用 Ansible

以下 Ansible playbook 可用于创建本地用户并将提供的证书映射到用于证书身份验证。它需要被命名为 username 并且 cert_pem 变量设置为要创建的用户名称以及生成的公钥 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
        }