WinRM Certificate Authentication
WinRM certificate authentication is a method of authenticating to a Windows host using X.509 certificates instead of a username and password.
Certificate authentication does have some disadvantages compared to SSH key based authentication such as:
it can only be mapped to a local Windows user, no domain accounts
the username and password must be mapped to the certificate, if the password changes, the cert will need to be re-mapped
an administrator on the Windows host can retrieve the local user password through the certificate mapping
Ansible cannot use encrypted private keys, they must be stored without encryption
Ansible cannot use the certs and private keys stored as a var, they must be a file
Ansible Configuration
Certificate authentication uses certificates as keys similar to SSH key pairs. The public and private key is stored on the Ansible control node to use for authentication. The following example shows the hostvars configured for certificate authentication:
# 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
Certificate authentication is not enabled by default on a Windows host but can be enabled by running the following in PowerShell:
Set-Item -Path WSMan:\localhost\Service\Auth\Certificate -Value $true
The private key cannot be encrypted due to a limitation of the underlying Python library used by Ansible.
Note
For enabling certificate authentication with a TLS 1.3 connection, Python 3.8+, 3.7.1, or 3.6.7 and Python package urllib3>=2.0.7 or newer are required.
Certificate Generation
The first step of using certificate authentication is to generate a certificate and private key. The certificate must be generated with the following properties:
Extended Key Usage
must includeclientAuth (1.3.6.1.5.5.7.3.2)
Subject Alternative Name
must includeotherName
entry foruserPrincipalName (1.3.6.1.4.1.311.20.2.3)
The userPrincipalName
value can be anything but in this guide we will use the value $USERNAME@localhost
where $USERNAME
is the name of the user that the certificate will be mapped to.
This can be done through a variety of methods, such as OpenSSL, PowerShell, or Active Directory Certificate Services. The following example shows how to generate a certificate using 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
The following example shows how to generate a certificate using 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
As PowerShell cannot generate a PKCS8 PEM private key, we need to use OpenSSL to convert the cert.pfx
file to a PEM private key:
openssl pkcs12 \
-in cert.pfx \
-nocerts \
-nodes \
-passin pass: |
sed -ne '/-BEGIN PRIVATE KEY-/,/-END PRIVATE KEY-/p' > cert.key
The cert.pem
is the public key and the cert.key
is the plaintext private key. These files must be accessible by the Ansible control node to use for authentication. The private key does not need to be present on the Windows node.
Windows Configuration
Once the public and private key has been generated we need to import and trust the public key and configure the user mapping on the Windows host.
The Windows host does not need access to the private key, only the public key cert.pem
needs to be accessible to configure the certificate authentication.
Import Certificate to the Certificate Store
For Windows to trust the certificate it must be imported into the LocalMachine\TrustedPeople
certificate store. You can do this by running the following:
$cert = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new("cert.pem")
$store = Get-Item -LiteralPath Cert:\LocalMachine\TrustedPeople
$store.Open('ReadWrite')
$store.Add($cert)
$store.Dispose()
If the cert is self-signed, or issued by a CA that is not trusted by the host, you will need to import the CA certificate into the trusted root store. As our example uses a self-signed cert, we will import that certificate as a trusted CA but in a production environment you would import the CA that signed the certificate.
$cert = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new("cert.pem")
$store = Get-Item -LiteralPath Cert:\LocalMachine\Root
$store.Open('ReadWrite')
$store.Add($cert)
$store.Dispose()
Mapping Certificate to a Local Account
Once the certificate has been imported into the LocalMachine\TrustedPeople
store, the WinRM service can create the mapping between the certificate and a local account. This is done by running the following:
# 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
The Subject
is the value of the userPrincipalName
in the certificate SAN entry. The Issuer
is the thumbprint of the CA certificate that issued our certificate. The Credential
is the username and password of the local user we are mapping the certificate to.
Using Ansible
The following Ansible playbook can be used to create a local user and map the certificate provided to use for certificate authentication. It needs to be called username
and cert_pem
variable set to the name of the user to create and the path to the public key PEM file that was generated. This playbook expects cert_pem
to be a self signed certificate, if using a certificate issued by a CA, you will have to edit it so it copies that across and imports it to the LocalMachine\Root
store instead.
- 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
}