├── .gitignore ├── README.md ├── ansible.cfg ├── inventory.ini ├── requirements.yml ├── scripts ├── generate_certs_openssl.sh ├── generate_certs_powershell.ps1 ├── generate_certs_pwsh.ps1 ├── generate_certs_python.py ├── reset_windows.ps1 └── setup_windows.ps1 ├── setup_certificate.yml ├── setup_windows.yml └── templates └── cert_inventory.ini.j2 /.gitignore: -------------------------------------------------------------------------------- 1 | cert/ 2 | cert_inventory.ini -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # WinRM Client Certificate Authentication 2 | This repo demonstrates how to create certificates for WinRM/WSMan client certificate authentication and how to configure Windows to setup the service side to allow those certificates for client authentication. 3 | It has some Ansible playbooks that can be used to do all the necessary steps plus some standalone scripts and background information to help you understand how certificate authentication works and is configured. 4 | 5 | ## Background 6 | WinRM authentication is typically done through the `Negotiate` protocol which attempts to use `Kerberos` authentication before falling back to `NTLM`. 7 | It is possible to use client certificates through the TLS X.509 client certificate authentication but the documentation around this is hard to come by and hard to understand. 8 | This repo will attempt to show how to both generate those certificates and how to configure the Windows host to use them for authentication. 9 | It will also show how those certificates can be used in Ansible to perform certificate authentication. 10 | 11 | Please keep in that certificate authentication does have its downsides such as: 12 | 13 | + it can only be mapped to a local Windows user, no domain accounts 14 | + the username and password must be mapped to the certificate, if the password changes, the cert will need to be re-mapped 15 | + an administrator on the Windows host can retrieve the local user password through the certificate mapping 16 | + the HTTP libraries used by `psrp` and `winrm` do not support 17 | + encrypted private keys, they must be stored without encryption 18 | + certs and private keys stored as a var, they must be a file 19 | 20 | Usually these points are blockers (the last one especially) but if you are still interested then read on. 21 | 22 | ## Requirements 23 | + Windows host with a HTTPS WinRM listener configured 24 | + Ansible collections 25 | + `ansible.windows` - Used to configured the Windows host 26 | + `community.crypto` - Used in `setup_certificate.yml` 27 | + Python libraries `winrm` and `psrp` for testing the connection 28 | 29 | To install the Python libraries we can run: 30 | 31 | ```bash 32 | python3 -m pip install pypsrp winrm 33 | ``` 34 | 35 | To install the required collections run 36 | 37 | ```bash 38 | ansible-galaxy collection install -r requirements.yml 39 | ``` 40 | 41 | If you are not using `setup_certificate.yml` to generate the certificates, then `community.crypto` will not be needed. 42 | 43 | ## How to run 44 | Before running we need to add in the inventory details for our Windows host. 45 | Edit [inventory.ini](./inventory.ini) and add the Windows host hostname/IP under the `[windows]` section. 46 | Also set the `ansible_user` and `ansible_password` value under the `[windows:vars]` section. 47 | We can verify that it worked by running `ansible -i inventory.ini windows -m ansible.windows.win_ping`. 48 | 49 | Once the inventory has been setup we run the following playbooks with the `CERT_USER` set to the Windows user we want to create that's mapped to the certificate: 50 | 51 | ```bash 52 | CERT_USER=AnsibleCertUser 53 | ansible-playbook -i inventory.ini setup_certificate.yml -e username=$CERT_USER 54 | ansible-playbook -i inventory.ini setup_windows.yml -e username=$CERT_USER 55 | ``` 56 | 57 | The first playbook [setup_certificate.yml](./setup_certificate.yml) is run on localhost and will create the CA and client authentication certificates/keys. 58 | When run, it will create the folder `cert` with the certificates and keys and the last task will contain a brief summary: 59 | 60 | ```yaml 61 | ok: [localhost] => 62 | msg: CA and Client Certificate has been generated at '/home/.../winrm-cert-auth/cert'. 63 | The password for both private keys is '...'. 64 | ``` 65 | 66 | You can also generate the certificate manually using OpenSSL or with PowerShell on the Windows host, see [Certificate Generation](#certificate-generation) for more details. 67 | 68 | The second playbook [setup_windows.yml](./setup_windows.yml) will configure the Windows host by creating the local user, setting up the certificates, and mapping the cert to the created user. 69 | When run, it will output the user details and also point to a generated inventory you can use to test certificate auth called `cert_inventory.ini`. 70 | 71 | ```yaml 72 | ok: [win-host] => 73 | msg: WinRM service and username have been configured. The remote user 'AnsibleCertUser' 74 | has been configured with the password '...'. Use the '/home/.../winrm-cert-auth/cert_inventory.ini' 75 | inventory file to connect to the remote host with the client certificate. 76 | ``` 77 | 78 | Finally we can test the certificate authentication by running: 79 | 80 | ```bash 81 | ansible -i cert_inventory.ini windows -m ansible.windows.win_command -a whoami 82 | ``` 83 | 84 | This will test out the certificate authentication for both the `psrp` and `winrm` connection plugin and should output `...\ansiblecertuser`. 85 | 86 | ## More Information 87 | WinRM certificate authentication works like TLS/HTTPS client certificate that are used more in enterprise environments. 88 | Typically in a normal TLS handshake, only the server sends its certificate but with client authentication the client will also provide its own certificate to prove its identity. 89 | 90 | > [!NOTE] 91 | > While it makes no difference to the end user it is important to note that WinRM's certificate auth uses a post handshake certificate request rather than forcing it as part of the initial TLS handshake. This allows WinRM to still be used for other authentication options over HTTPS and only require the cert if the client requests that method during authentication. 92 | 93 | For a client to provide the certificate it needs to have access to the private key and public certificate. 94 | Only the public certificate is sent across the wire, the private key is used to generate values that can prove to the server the client has the key without actually sending the key. 95 | 96 | When the client attempts to use certificate authentication with WinRM it: 97 | 98 | + builds the TLS context with the client cert and key ready to be used for authentication 99 | + sets the `Authorization` header with the value `http://schemas.dmtf.org/wbem/wsman/1/wsman/secprofile/https/mutual` 100 | + sends the WSMan request to the server 101 | + the server sees the `Authorization` header and requests a certificate on the TLS layer 102 | + the client provides the certificate public key and continues the exchange 103 | 104 | The server does the following checks based on the certificate provided by the client (there might be more checks I am missing): 105 | 106 | + the certificate is issued by a trusted Certificate Authority (CA) 107 | + if self signed, the cert must be trusted as a root CA 108 | + the certificate has an Extended Key Usage (EKU) of `clientAuth` (`1.3.6.1.5.5.7.3.2`) 109 | + the certificate itself is stored in the `LocalMachine` `TrustedPeople` certificate store 110 | + the Subject Alternative Name (SAN) contains an `otherName` entry for `userPrincipalName` (`1.3.6.1.4.1.311.20.2.3`) (typically with the value `username@localhost`) 111 | + a `WSMan:\localhost\ClientCertificate` mapping has a `Subject` with the same SAN value from the above 112 | + the username/password registered for the mapping above is a valid local user and the and the user can be logged in with the password 113 | + the standard authorization checks done by WinRM for the above user (Administrator, allowed to log onto network, not disabled, etc) 114 | 115 | If any of the above checks fail the authentication fails. 116 | If they all succeed, the WinRM task will be run as the user the certificate is mapped to. 117 | 118 | ### Certificate Generation 119 | The playbook [setup_certificate.yml](./setup_certificate.yml) can be used to generate a CA cert/key and a cert/key associated with a particular username. 120 | It is possible to use OpenSSL, PowerShell, or Python to generate these certificates if you don't wish to use the playbook. 121 | 122 | The first step is to generate a CA certificate which will be used to issue our client certificate. 123 | 124 | > [!NOTE] 125 | > While a CA is not strictly needed, it is done in this example to show you how this certificate can be generated and signed by any CA cert. 126 | > It is recommened to use a proper CA that is trusted in your environment, for example one issued by Active Directory Certificate Services (ADCS). 127 | 128 | Once we have a CA we can then generate a client certificate and key that is issued/signed by our CA key. 129 | The client certificate *MUST* have an EKU with `clientAuth` set and a SAN with an `otherName` value for a `userPrincipalName`. 130 | The certificate *SHOULD* set the subject to `CN=username` and the SAN `userPrincipalName` to `username@localhost` where the `username` is the local user we are mapping the cert to. 131 | While the subject and SAN should have these values they are not strictly necessary, the subject is not used and the SAN `userPrincipalName` is the value used in the WSMan mapping. 132 | 133 | The following scripts can be used to generate the CA and client certificates: 134 | 135 | + [bash - generate_certs_openssl.sh](./scripts/generate_certs_openssl.sh) - Requires OpenSSL 136 | + [powershell - generate_certs_powershell.ps1](./scripts/generate_certs_powershell.ps1) - Windows Only 5.1/7+ 137 | + [powershell 7+ - generate_certs_pwsh.ps1](./scripts/generate_certs_pwsh.ps1) - PowerShell 7.3+ Windows/Linux/macOS 138 | + [python - generate_certs_python.py](./scripts/generate_certs_python.py) - Requires the `cryptography` Python package 139 | 140 | > [!NOTE] 141 | > The PowerShell 5.1 script cannot generate PEM encoded private keys needed by Ansible. 142 | > It will instead generate the public cert `*.pem` and a PKCS12 `*.pfx` file. 143 | > Use the `openssl` commands below to extract the private key as a PEM file for use in Ansible. 144 | 145 | ```bash 146 | openssl pkcs12 -in client_cert.pfx -nocert -nodes -out client_cert_no_pass.key -passin "pass:$( [!NOTE] 164 | > Newer OpenSSL versions export a PFX using AES encryption. 165 | > Windows Server 2016 or older will fail to import these certificate types with an incorrect password error. 166 | > To create a PFX file using the older 3DES encryption you can use the following command 167 | 168 | ```bash 169 | openssl pkcs12 \ 170 | -export \ 171 | -certpbe PBE-SHA1-3DES \ 172 | -keypbe PBE-SHA1-3DES \ 173 | -macalg SHA1 \ 174 | -out cert.pfx \ 175 | -inkey cert.key \ 176 | -in cert.pem 177 | ``` 178 | 179 | Once we have a pfx file we can import that into Windows with: 180 | 181 | ```powershell 182 | $pfxPass = Read-Host -Prompt "Enter the pfx password" -AsSecureString 183 | $importParams = @{ 184 | CertStoreLocation = 'Cert:\CurrentUser\My' 185 | FilePath = 'cert.pfx' # Replace with the path to the pfx generated above 186 | Password = $pfxPass 187 | } 188 | $cert = Import-PfxCertificate @importParams 189 | $cert.Thumbprint 190 | ``` 191 | 192 | From there we can use the `-CertificateThumbprint $thumbprint` parameter on cmdlets like `Invoke-Command/Enter-PSSession` for the user we imported the certificate with. 193 | 194 | ### Windows Configuration 195 | Once the certificates have been generated we need to configure Windows to use those certificates. 196 | The following things must be done on Windows to configure the certificate authentication: 197 | 198 | + trust the CA that issued our client certificate 199 | + if using a self signed client certificate this will be the client certificate itself 200 | + trust the client certificate as a `TrustedPeople` cert 201 | + create the local user 202 | + creates a wsman certificate mapping entry that 203 | + sets the `Subject` to the SAN `userPrincipalName` entry of our client certificate 204 | + sets the `Uri` to `*` 205 | + sets the `Issuer` to the root CA thumbprint that issued our client certificate 206 | + provides the username/password of the local user to map the certificate toß 207 | + enabled the Certificate authentication option on the WSMan service 208 | 209 | The playbook [setup_windows.yml](./setup_windows.yml) is designed to do all this as part of the Ansible run but if you wish to do this manually through PowerShell you can use [setup_windows.ps1](./scripts/setup_windows.ps1) instead. 210 | 211 | > [!NOTE] 212 | > This script needs to be run as admin. 213 | 214 | ```powershell 215 | powershell.exe -NoProfile -ExecutionPolicy Bypass -File setup_windows.ps1 -CACert ca.pem -ClientCert client_cert.pem -Verbose 216 | ``` 217 | 218 | The script will create a local user based on the `Subject` of the `client_cert.pem` provided. 219 | The password for the user is not needed for anything but will be randomly generated and outputted to the console just in case you want to use it for something else. 220 | 221 | ### Reset Changes 222 | The PowerShell script [reset_windows.ps1](./scripts/reset_windows.ps1) can be run to undo the Windows configuration done by the playbook. 223 | This script will disable cert auth, remove any WSMan certificate mappings, delete the local user for each mapping, and remove the imported certifices. 224 | 225 | > [!WARNING] 226 | > Do not run this script if you have certificate authentication configured for any other users, it is designed to bring the WinRM service back to the factory state when it comes to certificate authentication make by this example repo. 227 | 228 | You can copy the script and run it on the Windows host or you can run it through Ansible: 229 | 230 | ```bash 231 | ansible \ 232 | -i inventory.ini \ 233 | windows \ 234 | -m ansible.windows.win_powershell \ 235 | -a '{"script": "{{ lookup(\"file\", \"scripts/reset_windows.ps1\") }}", "parameters": {"Verbose": true}}' 236 | ``` -------------------------------------------------------------------------------- /ansible.cfg: -------------------------------------------------------------------------------- 1 | [defaults] 2 | retry_files_enabled = false 3 | callback_result_format = yaml -------------------------------------------------------------------------------- /inventory.ini: -------------------------------------------------------------------------------- 1 | localhost ansible_connection=local ansible_python_interpreter={{ansible_playbook_python}} 2 | 3 | [windows] 4 | # Add your Windows host here 5 | 6 | [windows:vars] 7 | ansible_user=# Set the Windows username here 8 | ansible_password=# Set the Windows password here 9 | ansible_connection=psrp 10 | ansible_port=5985 11 | -------------------------------------------------------------------------------- /requirements.yml: -------------------------------------------------------------------------------- 1 | collections: 2 | - name: ansible.windows 3 | version: '>=2.4.0' # sensitive_parameters in ansible.windows.win_powershell 4 | - name: community.crypto -------------------------------------------------------------------------------- /scripts/generate_certs_openssl.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -o pipefail -eux 4 | 5 | if [ $# -lt 1 ]; then 6 | echo "Error: The username must be provided." 7 | echo "Usage: $0 " 8 | exit 1 9 | fi 10 | 11 | USERNAME="${1}" 12 | PASSWORD="$( openssl rand -base64 12 )" 13 | 14 | echo "${PASSWORD}" > cert_password 15 | 16 | echo "Generating CA certificate" 17 | cat > openssl.conf << EOL 18 | distinguished_name = req_distinguished_name 19 | 20 | [req_distinguished_name] 21 | [v3_ca] 22 | subjectKeyIdentifier = hash 23 | basicConstraints = critical, CA:true 24 | keyUsage = critical, keyCertSign 25 | EOL 26 | 27 | openssl genrsa \ 28 | -aes256 \ 29 | -out ca.key \ 30 | -passout pass:"${PASSWORD}" 31 | 32 | openssl req \ 33 | -new \ 34 | -sha256 \ 35 | -subj "/CN=WinRM Cert Auth CA" \ 36 | -newkey rsa:2048 \ 37 | -keyout ca.key \ 38 | -out ca.csr \ 39 | -config openssl.conf \ 40 | -reqexts v3_ca \ 41 | -passin pass:"${PASSWORD}" \ 42 | -passout pass:"${PASSWORD}" 43 | 44 | openssl x509 \ 45 | -req \ 46 | -in ca.csr \ 47 | -sha256 \ 48 | -out ca.pem \ 49 | -days 365 \ 50 | -key ca.key \ 51 | -extfile openssl.conf \ 52 | -extensions v3_ca \ 53 | -passin pass:"${PASSWORD}" 54 | 55 | echo "Generating CA certificate for ${USERNAME}" 56 | cat > openssl.conf << EOL 57 | distinguished_name = req_distinguished_name 58 | 59 | [req_distinguished_name] 60 | [v3_req_client] 61 | extendedKeyUsage = clientAuth 62 | subjectAltName = otherName:1.3.6.1.4.1.311.20.2.3;UTF8:${USERNAME}@localhost 63 | EOL 64 | 65 | openssl req \ 66 | -new \ 67 | -sha256 \ 68 | -subj "/CN=${USERNAME}" \ 69 | -newkey rsa:2048 \ 70 | -keyout client_cert.key \ 71 | -out client_cert.csr \ 72 | -config openssl.conf \ 73 | -reqexts v3_req_client \ 74 | -passin pass:"${PASSWORD}" \ 75 | -passout pass:"${PASSWORD}" 76 | 77 | openssl x509 \ 78 | -req \ 79 | -in client_cert.csr \ 80 | -sha256 \ 81 | -out client_cert.pem \ 82 | -days 365 \ 83 | -extfile openssl.conf \ 84 | -extensions v3_req_client \ 85 | -passin pass:"${PASSWORD}" \ 86 | -CA ca.pem \ 87 | -CAkey ca.key \ 88 | -CAcreateserial 89 | 90 | openssl rsa \ 91 | -in client_cert.key \ 92 | -out client_cert_no_pass.key \ 93 | -passin pass:"${PASSWORD}" 94 | 95 | rm openssl.conf 96 | rm client_cert.csr 97 | rm ca.csr 98 | rm ca.srl 99 | -------------------------------------------------------------------------------- /scripts/generate_certs_powershell.ps1: -------------------------------------------------------------------------------- 1 | #Requires -Version 5.1 2 | 3 | using namespace System.IO 4 | using namespace System.Security.Cryptography 5 | using namespace System.Security.Cryptography.X509Certificates 6 | 7 | [CmdletBinding()] 8 | param ( 9 | [Parameter(Mandatory, Position = 0)] 10 | [string] 11 | $UserName 12 | ) 13 | 14 | $ErrorActionPreference = 'Stop' 15 | 16 | Function Remove-CertificateAndKey { 17 | [CmdletBinding()] 18 | param ( 19 | [Parameter(Mandatory)] 20 | [X509Certificate2] 21 | $Certificate, 22 | 23 | [Parameter(Mandatory)] 24 | [string] 25 | $KeyName 26 | ) 27 | 28 | # This the path CNG uses to store the key 29 | $keyPath = [Path]::Combine($env:AppData, 'Microsoft', 'Crypto', 'Keys', $KeyName) 30 | Remove-Item -LiteralPath "Cert:\CurrentUser\My\$($Certificate.Thumbprint)" -Force 31 | if (Test-Path -LiteralPath $keyPath) { 32 | Remove-Item -LiteralPath $keyPath -Force 33 | } 34 | } 35 | 36 | $rng = [RandomNumberGenerator]::Create() 37 | try { 38 | $guidBytes = [byte[]]::new(16) 39 | $rng.GetBytes($guidBytes) 40 | $keyPass = [Guid]::new($guidBytes).Guid 41 | Set-Content -Path cert_password -Value $keyPass 42 | } 43 | finally { 44 | $rng.Dispose() 45 | } 46 | 47 | $ca = $caKeyName = $client = $clientKeyName = $null 48 | try { 49 | $caParams = @{ 50 | Extension = @( 51 | [X509BasicConstraintsExtension]::new($true, $false, 0, $true) 52 | [X509KeyUsageExtension]::new('KeyCertSign', $true) 53 | ) 54 | CertStoreLocation = 'Cert:\CurrentUser\My' 55 | NotAfter = (Get-Date).AddYears(1) 56 | Provider = 'Microsoft Software Key Storage Provider' 57 | Subject = 'CN=WinRM Cert Auth CA' 58 | Type = 'Custom' 59 | } 60 | Write-Verbose -Message "Creating CA certificate" 61 | $ca = New-SelfSignedCertificate @caParams 62 | 63 | # We need to get the key name, Export as a pfx seems to change the stored 64 | # key to an ephemeral one on the X509Certificate2. By doing it now we get 65 | # the actual stored key info. 66 | $caKeyName = [RSACertificateExtensions]::GetRSAPrivateKey($ca).Key.UniqueName 67 | 68 | $clientParams = @{ 69 | CertStoreLocation = 'Cert:\CurrentUser\My' 70 | NotAfter = $caParams.NotAfter 71 | Provider = 'Microsoft Software Key Storage Provider' 72 | Signer = $ca 73 | Subject = "CN=$UserName" 74 | TextExtension = @("2.5.29.37={text}1.3.6.1.5.5.7.3.2","2.5.29.17={text}upn=$UserName@localhost") 75 | Type = 'Custom' 76 | } 77 | Write-Verbose -Message "Creating client certificate for '$UserName'" 78 | $client = New-SelfSignedCertificate @clientParams 79 | $clientKeyName = [RSACertificateExtensions]::GetRSAPrivateKey($client).Key.UniqueName 80 | 81 | Set-Content -Path "ca.pem" -Value @( 82 | "-----BEGIN CERTIFICATE-----" 83 | [Convert]::ToBase64String($ca.RawData) -replace ".{64}", "$&`n" 84 | "-----END CERTIFICATE-----" 85 | ) 86 | $caPfxBytes = $ca.Export('Pfx', $keyPass) 87 | [File]::WriteAllBytes("$pwd\ca.pfx", $caPfxBytes) 88 | 89 | Set-Content -Path "client_cert.pem" -Value @( 90 | "-----BEGIN CERTIFICATE-----" 91 | [Convert]::ToBase64String($client.RawData) -replace ".{64}", "$&`n" 92 | "-----END CERTIFICATE-----" 93 | ) 94 | $clientPfxBytes = $client.Export('Pfx', $keyPass) 95 | [File]::WriteAllBytes("$pwd\client_cert.pfx", $clientPfxBytes) 96 | } 97 | finally { 98 | if ($ca) { 99 | Remove-CertificateAndKey -Certificate $ca -KeyName $caKeyName 100 | } 101 | if ($client) { 102 | Remove-CertificateAndKey -Certificate $client -KeyName $clientKeyName 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /scripts/generate_certs_pwsh.ps1: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env pwsh 2 | 3 | using namespace System.Formats.Asn1 4 | using namespace System.Security.Cryptography 5 | using namespace System.Security.Cryptography.X509Certificates 6 | 7 | #Requires -Version 7.3 8 | 9 | [CmdletBinding()] 10 | param ( 11 | [Parameter(Mandatory, Position = 0)] 12 | [string] 13 | $UserName 14 | ) 15 | 16 | $ErrorActionPreference = 'Stop' 17 | 18 | Function New-X509Certificate { 19 | [OutputType([X509Certificate2])] 20 | [CmdletBinding()] 21 | param ( 22 | [Parameter(Mandatory)] 23 | [string]$Subject, 24 | 25 | [Parameter()] 26 | [HashAlgorithmName] 27 | $HashAlgorithm = "SHA256", 28 | 29 | [Parameter()] 30 | [X509Certificate2] 31 | $Issuer, 32 | 33 | [Parameter()] 34 | [X509Extension[]] 35 | $Extension 36 | ) 37 | 38 | $key = [RSA]::Create(2048) 39 | $request = [CertificateRequest]::new( 40 | $Subject, 41 | $key, 42 | $HashAlgorithm, 43 | [RSASignaturePadding]::Pkcs1) 44 | 45 | 46 | $Extension | ForEach-Object { $request.CertificateExtensions.Add($_) } 47 | $request.CertificateExtensions.Add( 48 | [X509SubjectKeyIdentifierExtension]::new($request.PublicKey, $false)) 49 | 50 | if ($Issuer) { 51 | $request.CertificateExtensions.Add( 52 | [X509AuthorityKeyIdentifierExtension]::CreateFromCertificate($Issuer, $true, $true)) 53 | 54 | $notBefore = $Issuer.NotBefore 55 | $notAfter = $Issuer.NotAfter 56 | $serialNumber = [byte[]]::new(9) 57 | [System.Random]::new().NextBytes($serialNumber) 58 | 59 | $cert = $request.Create($Issuer, $notBefore, $notAfter, $serialNumber) 60 | 61 | # For whatever reason Create does not create an X509 cert with the private key. 62 | [RSACertificateExtensions]::CopyWithPrivateKey($cert, $key) 63 | } 64 | else { 65 | $notBefore = [DateTimeOffset]::UtcNow.AddDays(-1) 66 | $notAfter = [DateTimeOffset]::UtcNow.AddDays(365) 67 | $request.CreateSelfSigned($notBefore, $notAfter) 68 | } 69 | } 70 | 71 | $keyEncParameters = [PbeParameters]::new( 72 | 'Aes128Cbc', 73 | 'SHA256', 74 | 600000) 75 | 76 | $rng = [RandomNumberGenerator]::Create() 77 | try { 78 | $guidBytes = [byte[]]::new(16) 79 | $rng.GetBytes($guidBytes) 80 | $keyPass = [Guid]::new($guidBytes).Guid 81 | } 82 | finally { 83 | $rng.Dispose() 84 | } 85 | Set-Content cert_password -Value $keyPass 86 | 87 | Write-Verbose "Generating CA key" 88 | $caExt = @( 89 | [X509BasicConstraintsExtension]::new($true, $false, 0, $true) 90 | [X509KeyUsageExtension]::new("KeyCertSign", $true) 91 | ) 92 | $ca = New-X509Certificate -Subject "CN=WinRM Cert Auth CA" -Extension $caExt 93 | Set-Content ca.pem -Value $ca.ExportCertificatePem() 94 | 95 | $caKey = [RSACertificateExtensions]::GetRSAPrivateKey($ca) 96 | Set-Content ca.key -Value $caKey.ExportEncryptedPkcs8PrivateKeyPem($keyPass, $keyEncParameters) 97 | 98 | Write-Verbose "Generating client key for '$UserName'" 99 | $clientAuthUsageOids = [System.Security.Cryptography.OidCollection]::new() 100 | $null = $clientAuthUsageOids.Add([Oid]::FromFriendlyName("clientAuth", "EnhancedKeyUsage")) 101 | 102 | # .NET doesn't have a nice way to build this so we write the ASN.1 data manually. 103 | <# 104 | GeneralName ::= CHOICE { 105 | otherName [0] OtherName, 106 | rfc822Name [1] IA5String, 107 | dNSName [2] IA5String, 108 | x400Address [3] ORAddress, 109 | directoryName [4] Name, 110 | ediPartyName [5] EDIPartyName, 111 | uniformResourceIdentifier [6] IA5String, 112 | iPAddress [7] OCTET STRING, 113 | registeredID [8] OBJECT IDENTIFIER } 114 | 115 | OtherName ::= SEQUENCE { 116 | type-id OBJECT IDENTIFIER, 117 | value [0] EXPLICIT ANY DEFINED BY type-id } 118 | #> 119 | $asnWriter = [AsnWriter]::new('DER') 120 | $otherScope = $asnWriter.PushSequence() 121 | $valueTag = $asnWriter.PushSequence([Asn1Tag]::new('ContextSpecific', 0, $true)) 122 | $asnWriter.WriteObjectIdentifier("1.3.6.1.4.1.311.20.2.3") # MS userPrincipalName 123 | $utf8Tag = $asnWriter.PushSequence([Asn1Tag]::new('ContextSpecific', 0, $true)) 124 | $asnWriter.WriteCharacterString('UTF8String', "$UserName@localhost") 125 | $utf8Tag.Dispose() 126 | $valueTag.Dispose() 127 | $otherScope.Dispose() 128 | $upnSan = $asnWriter.Encode() 129 | 130 | $clientExt = @( 131 | [X509EnhancedKeyUsageExtension]::new($clientAuthUsageOids, $false) 132 | [X509SubjectAlternativeNameExtension]::new($upnSan, $false) 133 | ) 134 | $client = New-X509Certificate -Subject "CN=$UserName" -Issuer $ca -Extension $clientExt 135 | Set-Content client_cert.pem -Value $client.ExportCertificatePem() 136 | 137 | $clientKey = [RSACertificateExtensions]::GetRSAPrivateKey($client) 138 | Set-Content client_cert.key -Value $clientKey.ExportEncryptedPkcs8PrivateKeyPem($keyPass, $keyEncParameters) 139 | Set-Content client_cert_no_pass.key -Value $clientKey.ExportPkcs8PrivateKeyPem() 140 | -------------------------------------------------------------------------------- /scripts/generate_certs_python.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from __future__ import annotations 4 | 5 | import argparse 6 | import datetime 7 | import secrets 8 | import sys 9 | 10 | from cryptography import x509 11 | from cryptography.hazmat.primitives.asymmetric import rsa, types 12 | from cryptography.hazmat.primitives.hashes import SHA256 13 | from cryptography.hazmat.primitives.serialization import ( 14 | BestAvailableEncryption, 15 | Encoding, 16 | NoEncryption, 17 | PrivateFormat, 18 | ) 19 | 20 | 21 | def generate_ca( 22 | subject: str, 23 | ) -> tuple[x509.Certificate, rsa.RSAPrivateKey]: 24 | now = datetime.datetime.now(datetime.timezone.utc) 25 | 26 | ca_key_usage = x509.KeyUsage( 27 | digital_signature=False, 28 | content_commitment=False, 29 | key_encipherment=False, 30 | data_encipherment=False, 31 | key_agreement=False, 32 | key_cert_sign=True, 33 | crl_sign=False, 34 | encipher_only=False, 35 | decipher_only=False, 36 | ) 37 | ca_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) 38 | ca_name = x509.Name([x509.NameAttribute(x509.NameOID.COMMON_NAME, subject)]) 39 | ca_cert = ( 40 | x509.CertificateBuilder() 41 | .subject_name(ca_name) 42 | .issuer_name(ca_name) 43 | .public_key(ca_key.public_key()) 44 | .serial_number(x509.random_serial_number()) 45 | .not_valid_before(now) 46 | .not_valid_after(now + datetime.timedelta(days=365)) 47 | .add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True) 48 | .add_extension(ca_key_usage, critical=True) 49 | .add_extension( 50 | x509.SubjectKeyIdentifier.from_public_key(ca_key.public_key()), 51 | critical=False, 52 | ) 53 | .sign(ca_key, SHA256()) 54 | ) 55 | 56 | return ca_cert, ca_key 57 | 58 | 59 | def generate_client_cert( 60 | ca_cert: x509.Certificate, 61 | ca_key: rsa.RSAPrivateKey, 62 | subject: str, 63 | user_principal_name: str, 64 | ) -> tuple[x509.Certificate, rsa.RSAPrivateKey]: 65 | now = datetime.datetime.now(datetime.timezone.utc) 66 | 67 | ca_aki = x509.AuthorityKeyIdentifier.from_issuer_public_key(ca_cert.public_key()) # type: ignore[arg-type] 68 | 69 | # The UPN value is the ASN.1 encoded value. The type is predefined as 70 | # an OCTET_STRING and the length is the byte length of the OCTET_STRING 71 | # value. Technically this can fail if the length needs to be encoded in 72 | # more octets but for this POC it will do. 73 | b_upn = user_principal_name.encode() 74 | upn_value = b"\x0c" + int.to_bytes(len(b_upn), length=1, byteorder="big") + b_upn 75 | 76 | key = rsa.generate_private_key(public_exponent=65537, key_size=2048) 77 | name = x509.Name([x509.NameAttribute(x509.NameOID.COMMON_NAME, subject)]) 78 | cert = ( 79 | x509.CertificateBuilder() 80 | .subject_name(name) 81 | .issuer_name(ca_cert.subject) 82 | .public_key(key.public_key()) 83 | .serial_number(x509.random_serial_number()) 84 | .not_valid_before(now) 85 | .not_valid_after(now + datetime.timedelta(days=365)) 86 | .add_extension( 87 | x509.SubjectAlternativeName( 88 | [ 89 | x509.OtherName( 90 | x509.ObjectIdentifier("1.3.6.1.4.1.311.20.2.3"), 91 | upn_value, 92 | ), 93 | ] 94 | ), 95 | False, 96 | ) 97 | .add_extension( 98 | x509.ExtendedKeyUsage([x509.oid.ExtendedKeyUsageOID.CLIENT_AUTH]), False 99 | ) 100 | .add_extension(ca_aki, critical=False) 101 | .sign(ca_key, SHA256()) 102 | ) 103 | 104 | return cert, key 105 | 106 | 107 | def serialize_cert( 108 | cert: x509.Certificate, 109 | key: types.CertificateIssuerPrivateKeyTypes, 110 | filename: str, 111 | key_password: str, 112 | *, 113 | plaintext_key: bool = False, 114 | ) -> None: 115 | b_pub_key = cert.public_bytes(Encoding.PEM) 116 | b_key = key.private_bytes( 117 | encoding=Encoding.PEM, 118 | format=PrivateFormat.TraditionalOpenSSL, 119 | encryption_algorithm=BestAvailableEncryption(key_password.encode()), 120 | ) 121 | 122 | with open(f"{filename}.pem", mode="wb") as fd: 123 | fd.write(b_pub_key) 124 | 125 | with open(f"{filename}.key", mode="wb") as fd: 126 | fd.write(b_key) 127 | 128 | if plaintext_key: 129 | b_key = key.private_bytes( 130 | encoding=Encoding.PEM, 131 | format=PrivateFormat.TraditionalOpenSSL, 132 | encryption_algorithm=NoEncryption(), 133 | ) 134 | with open(f"{filename}_no_pass.key", mode="wb") as fd: 135 | fd.write(b_key) 136 | 137 | 138 | def parse_args( 139 | argv: list[str], 140 | ) -> argparse.Namespace: 141 | parser = argparse.ArgumentParser( 142 | prog=sys.argv[0], 143 | description="Generate WinRM client auth certificates.", 144 | ) 145 | 146 | parser.add_argument( 147 | "username", 148 | action="store", 149 | nargs=1, 150 | type=str, 151 | help="The username to generate the certificate for.", 152 | ) 153 | 154 | return parser.parse_args(argv) 155 | 156 | 157 | def main() -> None: 158 | args = parse_args(sys.argv[1:]) 159 | 160 | username = args.username[0] 161 | ca_cert, ca_key = generate_ca("WinRM Cert Auth CA") 162 | client_cert, client_key = generate_client_cert( 163 | ca_cert, ca_key, username, f"{username}@localhost" 164 | ) 165 | 166 | key_password = secrets.token_urlsafe(16) 167 | with open("cert_password", mode="w") as fd: 168 | fd.write(key_password) 169 | 170 | serialize_cert(ca_cert, ca_key, "ca", key_password) 171 | serialize_cert( 172 | client_cert, client_key, "client_cert", key_password, plaintext_key=True 173 | ) 174 | 175 | 176 | if __name__ == "__main__": 177 | main() 178 | -------------------------------------------------------------------------------- /scripts/reset_windows.ps1: -------------------------------------------------------------------------------- 1 | #Requires -RunAsAdministrator 2 | #Requires -Version 5.1 3 | 4 | [CmdletBinding()] 5 | param () 6 | 7 | Write-Verbose -Message "Disabling WSMan Certificate authentication" 8 | Set-Item -LiteralPath WSMan:\localhost\Service\Auth\Certificate -Value False 9 | 10 | Write-Verbose -Message "Removing temp certificate file" 11 | Remove-Item -Path C:\Windows\TEMP\*.pem 12 | 13 | Get-ChildItem -Path WSMan:\localhost\ClientCertificate | ForEach-Object { 14 | $info = [Ordered]@{} 15 | $_.Keys | ForEach-Object { 16 | $key, $value = $_ -split '=', 2 17 | $info[$key] = $value 18 | } 19 | Write-Verbose -Message "Processing cert map for '$($_.Name)' - Subject: '$($info.Subject)', Issuer: $($info.Issuer), URI: '$($info.URI)'" 20 | 21 | # The UserName value isn't exposed in the WSMan provider so we get it 22 | # through the registry. We use this to determine what user to remove. 23 | $mappedUser = Get-Item -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WSMAN\CertMapping\*' | ForEach-Object { 24 | if ( 25 | $_.GetValue('Subject', '') -eq $info.Subject -and 26 | $_.GetValue('Uri', '') -eq $info.URI -and 27 | ($user = $_.GetValue('UserName', '')) 28 | ) { 29 | Write-Verbose "Found mapped user entry '$user'" 30 | $user 31 | } 32 | } | Select-Object -First 1 33 | 34 | if ($mappedUser -and ($localUser = Get-LocalUser -Name $mappedUser -ErrorAction SilentlyContinue)) { 35 | Write-Verbose -Message "Removing local user '$mappedUser'" 36 | $localUser | Remove-LocalUser 37 | } 38 | 39 | if ($info.Subject) { 40 | Get-ChildItem -LiteralPath Cert:\LocalMachine\TrustedPeople | 41 | # UpnName is the userPrincipalName SAN entry. 42 | Where-Object { $_.GetNameInfo('UpnName', $false) -eq $info.Subject } | 43 | ForEach-Object { 44 | Write-Verbose -Message "Removing TrustedPeople entry for '$($_.Subject)' $($_.Thumbprint)" 45 | $_ | Remove-Item -Force 46 | } 47 | } 48 | 49 | if ($info.Issuer) { 50 | $issuerCert = Get-Item -LiteralPath "Cert:\LocalMachine\Root\$($info.Issuer)" -ErrorAction SilentlyContinue 51 | if ($issuerCert) { 52 | Write-Verbose -Message "Removing Issuer CA cert '$($issuerCert.Subject)' $($info.Issuer)" 53 | $issuerCert | Remove-Item -Force 54 | } 55 | } 56 | 57 | Write-Verbose -Message "Removing cert map '$($_.Name)'" 58 | $_ | Remove-Item -Force -Recurse 59 | } 60 | -------------------------------------------------------------------------------- /scripts/setup_windows.ps1: -------------------------------------------------------------------------------- 1 | using namespace System.Management.Automation 2 | using namespace System.Security.Cryptography 3 | using namespace System.Security.Cryptography.X509Certificates 4 | 5 | #Requires -RunAsAdministrator 6 | #Requires -Version 5.1 7 | 8 | [CmdletBinding()] 9 | param ( 10 | [Parameter(Mandatory)] 11 | [string] 12 | $CACert, 13 | 14 | [Parameter(Mandatory)] 15 | [string] 16 | $ClientCert 17 | ) 18 | 19 | $ErrorActionPreference = 'Stop' 20 | 21 | $caCertPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($CACert) 22 | try { 23 | $caCertObj = [X509Certificate2]::new($caCertPath) 24 | } 25 | catch { 26 | $_.ErrorDetails = "Failed to load CACert: $($_.Exception.InnerException.Message)" 27 | $PSCmdlet.WriteError($_) 28 | return 29 | } 30 | 31 | $clientCertPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($ClientCert) 32 | try { 33 | $clientCertObj = [X509Certificate2]::new($clientCertPath) 34 | } 35 | catch { 36 | $_.ErrorDetails = "Failed to load ClientCert: $($_.Exception.InnerException.Message)" 37 | $PSCmdlet.WriteError($_) 38 | return 39 | } 40 | 41 | Write-Verbose "Adding CA certificate to Cert:\LocalMachine\Root" 42 | $rootStore = Get-Item -LiteralPath Cert:\LocalMachine\Root 43 | try { 44 | $rootStore.Open([OpenFlags]::ReadWrite) 45 | $rootStore.Add($caCertObj) 46 | } 47 | finally { 48 | $rootStore.Dispose() 49 | } 50 | 51 | Write-Verbose "Adding client certificate to Cert:\LocalMachine\TrustedPeople" 52 | $trustedPeopleStore = Get-Item -LiteralPath Cert:\LocalMachine\TrustedPeople 53 | try { 54 | $trustedPeopleStore.Open([OpenFlags]::ReadWrite) 55 | $trustedPeopleStore.Add($clientCertObj) 56 | } 57 | finally { 58 | $trustedPeopleStore.Dispose() 59 | } 60 | 61 | # Get username and generate our password. 62 | # We use RandomNumberGenerator as the Guid type has no guarantees it uses an 63 | # RNG to generate the bytes. This is still not ideal but works for our example 64 | # purposes here. 65 | $userName = $clientCertObj.Subject.Substring(3) # Removes the 'CN=' prefix 66 | $rng = [RandomNumberGenerator]::Create() 67 | try { 68 | $guidBytes = [byte[]]::new(16) 69 | $rng.GetBytes($guidBytes) 70 | $userPass = [Guid]::new($guidBytes).Guid 71 | } 72 | finally { 73 | $rng.Dispose() 74 | } 75 | 76 | $createUserParams = @{ 77 | Name = $userName 78 | Description = "Test username for WinRM Certificate Auth" 79 | Password = ConvertTo-SecureString -AsPlainText -Force -String $userPass 80 | PasswordNeverExpires = $true 81 | UserMayNotChangePassword = $true 82 | } 83 | Write-Verbose -Message "Creating local user '$($createUserParams.Name)'" 84 | New-LocalUser @createUserParams | Add-LocalGroupMember -Group Administrators 85 | 86 | $certMapping = @{ 87 | Path = 'WSMan:\localhost\ClientCertificate' 88 | Subject = $clientCertObj.GetNameInfo('UpnName', $false) 89 | Issuer = $caCertObj.Thumbprint 90 | Credential = [PSCredential]::new($createUserParams.Name, $createUserParams.Password) 91 | Force = $true 92 | } 93 | Write-Verbose -Message "Creating WSMan username mapping for '$($certMapping.Subject)'" 94 | $null = New-Item @certMapping 95 | 96 | Write-Verbose -Message "Enabling WSMan Certificate auth" 97 | Set-Item -LiteralPath WSMan:\localhost\Service\Auth\Certificate -Value True 98 | 99 | Write-Host -Object "Created local user '$($createUserParams.Name)' with password '$userPass'" -ForegroundColor Green 100 | -------------------------------------------------------------------------------- /setup_certificate.yml: -------------------------------------------------------------------------------- 1 | - name: Setup Certificates 2 | hosts: localhost 3 | gather_facts: false 4 | tasks: 5 | - name: Verify required facts are setup 6 | assert: 7 | that: 8 | - username is defined 9 | 10 | - name: Create certificate output dir 11 | ansible.builtin.file: 12 | path: '{{ playbook_dir }}/cert' 13 | state: directory 14 | 15 | # This isn't used for WinRM, the modules just require a passphrase to be set. 16 | - name: Generate certificate key password 17 | ansible.builtin.set_fact: 18 | certificate_password: "{{ lookup('ansible.builtin.password', playbook_dir ~ '/cert/cert_password', length=15) }}" 19 | 20 | - name: Create CA private key 21 | community.crypto.openssl_privatekey: 22 | path: '{{ playbook_dir }}/cert/ca.key' 23 | passphrase: "{{ certificate_password }}" 24 | cipher: auto 25 | 26 | - name: Create CA CSR 27 | community.crypto.openssl_csr_pipe: 28 | privatekey_path: '{{ playbook_dir }}/cert/ca.key' 29 | privatekey_passphrase: "{{ certificate_password }}" 30 | common_name: WinRM Cert Auth CA 31 | use_common_name_for_san: false 32 | basic_constraints: 33 | - 'CA:TRUE' 34 | basic_constraints_critical: true 35 | key_usage: 36 | - keyCertSign 37 | key_usage_critical: true 38 | register: ca_csr 39 | changed_when: false 40 | 41 | - name: Create CA certificate from CSR 42 | community.crypto.x509_certificate: 43 | path: '{{ playbook_dir }}/cert/ca.pem' 44 | csr_content: "{{ ca_csr.csr }}" 45 | privatekey_path: '{{ playbook_dir }}/cert/ca.key' 46 | privatekey_passphrase: "{{ certificate_password }}" 47 | provider: selfsigned 48 | 49 | - name: Create Client certificate private key 50 | community.crypto.openssl_privatekey: 51 | path: '{{ playbook_dir }}/cert/client_cert.key' 52 | passphrase: '{{ certificate_password }}' 53 | cipher: auto 54 | 55 | - name: Create Client certificate CSR 56 | community.crypto.openssl_csr_pipe: 57 | privatekey_path: '{{ playbook_dir }}/cert/client_cert.key' 58 | privatekey_passphrase: '{{ certificate_password }}' 59 | # The common name does not need to be the username, we just use this 60 | # format by convention and to make it easy to identify who it maps to. 61 | common_name: '{{ username }}' 62 | extended_key_usage: 63 | - clientAuth 64 | # OID here represents userPrincipalName. 65 | # The format of the value isn't important, we only use this format by 66 | # convention. 67 | subject_alt_name: otherName:1.3.6.1.4.1.311.20.2.3;UTF8:{{ username }}@localhost 68 | register: cert_auth_csr 69 | changed_when: false 70 | 71 | - name: Create Client certificate from CSR 72 | community.crypto.x509_certificate: 73 | path: '{{ playbook_dir }}/cert/client_cert.pem' 74 | csr_content: "{{ cert_auth_csr.csr }}" 75 | provider: ownca 76 | ownca_path: '{{ playbook_dir }}/cert/ca.pem' 77 | ownca_privatekey_path: '{{ playbook_dir }}/cert/ca.key' 78 | ownca_privatekey_passphrase: "{{ certificate_password }}" 79 | ownca_not_after: +365d 80 | ownca_not_before: '-1d' 81 | 82 | # The Python requests library used in WinRM transports do not support 83 | # encrypted private keys, we need to generate a key without a passphrase. 84 | - name: Strip passphrase from client certificate key 85 | ansible.builtin.command: 86 | cmd: >- 87 | openssl rsa 88 | -in client_cert.key 89 | -out client_cert_no_pass.key 90 | -passin pass:{{ certificate_password }} 91 | chdir: '{{ playbook_dir }}/cert' 92 | creates: '{{ playbook_dir }}/cert/client_cert_no_pass.key' 93 | 94 | - name: Output summary 95 | ansible.builtin.debug: 96 | msg: >- 97 | CA and Client Certificate has been generated at '{{ playbook_dir }}/cert'. 98 | The password for both private keys is '{{ certificate_password }}'. 99 | -------------------------------------------------------------------------------- /setup_windows.yml: -------------------------------------------------------------------------------- 1 | - name: Setup WinRM Client Cert Authentication 2 | hosts: windows 3 | gather_facts: false 4 | 5 | tasks: 6 | - name: Verify required facts are setup 7 | assert: 8 | that: 9 | - username is defined 10 | 11 | - name: Check that the required files are present 12 | ansible.builtin.stat: 13 | path: '{{ playbook_dir }}/cert/{{ item }}' 14 | delegate_to: localhost 15 | run_once: true 16 | register: local_cert_stat 17 | loop: 18 | - ca.pem 19 | - client_cert.pem 20 | 21 | - name: Fail if local files have not been generated 22 | ansible.builtin.assert: 23 | that: 24 | - local_cert_stat.results[0].stat.exists 25 | - local_cert_stat.results[1].stat.exists 26 | 27 | - name: Generate local user password 28 | ansible.builtin.set_fact: 29 | user_password: "{{ lookup('ansible.builtin.password', playbook_dir ~ '/cert/user_password', length=15) }}" 30 | 31 | - name: Create local user 32 | ansible.windows.win_user: 33 | name: '{{ username }}' 34 | groups: 35 | - Administrators 36 | - Users 37 | update_password: always 38 | password: '{{ user_password }}' 39 | user_cannot_change_password: true 40 | password_never_expires: true 41 | 42 | - name: Copy across CA and client public certificates 43 | ansible.windows.win_copy: 44 | src: '{{ playbook_dir }}/cert/{{ item }}.pem' 45 | dest: C:\Windows\TEMP\{{ item }}.pem 46 | loop: 47 | - ca 48 | - client_cert 49 | 50 | - name: Import CA Cert as trusted root 51 | ansible.windows.win_certificate_store: 52 | path: C:\Windows\TEMP\ca.pem 53 | state: present 54 | store_location: LocalMachine 55 | store_name: Root 56 | 57 | - name: Trust client certificate as a trusted person 58 | ansible.windows.win_certificate_store: 59 | path: C:\Windows\TEMP\client_cert.pem 60 | state: present 61 | store_location: LocalMachine 62 | store_name: TrustedPeople 63 | register: client_cert_info 64 | 65 | - name: Enable WinRM Certificate auth 66 | ansible.windows.win_powershell: 67 | script: | 68 | $ErrorActionPreference = 'Stop' 69 | $Ansible.Changed = $false 70 | 71 | $authPath = 'WSMan:\localhost\Service\Auth\Certificate' 72 | if ((Get-Item -LiteralPath $authPath).Value -ne 'true') { 73 | Set-Item -LiteralPath $authPath -Value true 74 | $Ansible.Changed = $true 75 | } 76 | 77 | - name: Setup Client Certificate Mapping 78 | ansible.windows.win_powershell: 79 | parameters: 80 | Thumbprint: '{{ client_cert_info.thumbprints[0] }}' 81 | sensitive_parameters: 82 | - name: Credential 83 | username: '{{ username }}' 84 | password: '{{ user_password }}' 85 | script: | 86 | param( 87 | [Parameter(Mandatory)] 88 | [PSCredential] 89 | $Credential, 90 | 91 | [Parameter(Mandatory)] 92 | [string] 93 | $Thumbprint 94 | ) 95 | 96 | $ErrorActionPreference = 'Stop' 97 | $Ansible.Changed = $false 98 | 99 | $userCert = Get-Item -LiteralPath "Cert:\LocalMachine\TrustedPeople\$Thumbprint" 100 | $subject = $userCert.GetNameInfo('UpnName', $false) # SAN userPrincipalName 101 | 102 | $certChain = New-Object -TypeName Security.Cryptography.X509Certificates.X509Chain 103 | [void]$certChain.Build($userCert) 104 | $caThumbprint = $certChain.ChainElements.Certificate[-1].Thumbprint 105 | 106 | $mappings = Get-ChildItem -LiteralPath WSMan:\localhost\ClientCertificate | 107 | Where-Object { 108 | $mapping = $_ | Get-Item 109 | "Subject=$subject" -in $mapping.Keys 110 | } 111 | 112 | if ($mappings -and "issuer=$($caThumbprint)" -notin $mappings.Keys) { 113 | $null = $mappings | Remove-Item -Force -Recurse 114 | $mappings = $null 115 | $Ansible.Changed = $true 116 | } 117 | 118 | if (-not $mappings) { 119 | $certMapping = @{ 120 | Path = 'WSMan:\localhost\ClientCertificate' 121 | Subject = $subject 122 | Issuer = $caThumbprint 123 | Credential = $Credential 124 | Force = $true 125 | } 126 | $null = New-Item @certMapping 127 | $Ansible.Changed = $true 128 | } 129 | 130 | - name: Generate inventory file that can be used for WinRM Certificate Auth 131 | ansible.builtin.template: 132 | src: cert_inventory.ini.j2 133 | dest: '{{ playbook_dir }}/cert_inventory.ini' 134 | delegate_to: localhost 135 | run_once: true 136 | 137 | - name: Output summary 138 | ansible.builtin.debug: 139 | msg: >- 140 | WinRM service and username have been configured. The remote user 141 | '{{ username }}' has been configured with the password 142 | '{{ user_password }}'. Use the '{{ playbook_dir }}/cert_inventory.ini' 143 | inventory file to connect to the remote host with the client 144 | certificate. 145 | -------------------------------------------------------------------------------- /templates/cert_inventory.ini.j2: -------------------------------------------------------------------------------- 1 | [windows:children] 2 | psrp 3 | winrm 4 | 5 | [psrp] 6 | {% for host in ansible_play_hosts_all %} 7 | {{ inventory_hostname }}-psrp ansible_host={{ hostvars[host]['ansible_host'] | default(inventory_hostname) }} 8 | {% endfor %} 9 | 10 | [psrp:vars] 11 | ansible_connection=psrp 12 | ansible_port=5986 13 | ansible_psrp_auth=certificate 14 | ansible_psrp_cert_validation=ignore 15 | ansible_psrp_certificate_pem=cert/client_cert.pem 16 | ansible_psrp_certificate_key_pem=cert/client_cert_no_pass.key 17 | 18 | [winrm] 19 | {% for host in ansible_play_hosts_all %} 20 | {{ inventory_hostname }}-winrm ansible_host={{ hostvars[host]['ansible_host'] | default(inventory_hostname) }} 21 | {% endfor %} 22 | 23 | [winrm:vars] 24 | ansible_connection=winrm 25 | ansible_port=5986 26 | ansible_winrm_transport=certificate 27 | ansible_winrm_server_cert_validation=ignore 28 | ansible_winrm_cert_pem=cert/client_cert.pem 29 | ansible_winrm_cert_key_pem=cert/client_cert_no_pass.key 30 | --------------------------------------------------------------------------------