New Windows Module: win_certificate_info (#64035)

* win_cert_stat initial commit with tests

* documentation fix.
first attempt windows server 2008 compatibility

* add formatted dates
removed debug tests

* make choices generic list

* return a list of certificates
use .net x509 store instead of PS cert provider

* fixed tests file

* fix timestamps returning null

* rename to win_certificate_info

* rename tests win_certificate_info

* return certificates as a sorted array
open the store with readonly privileges

* extensions always returned as an array
This commit is contained in:
Micah Hunsberger 2019-12-16 21:43:03 -05:00 committed by Jordan Borean
parent ae6fc265c9
commit a54e77193b
9 changed files with 591 additions and 0 deletions

View file

@ -0,0 +1,132 @@
#!powershell
# Copyright: (c) 2019, Micah Hunsberger
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
#AnsibleRequires -CSharpUtil Ansible.Basic
function ConvertTo-Timestamp($start_date, $end_date)
{
if ($start_date -and $end_date)
{
return (New-TimeSpan -Start $start_date -End $end_date).TotalSeconds
}
}
function Format-Date([DateTime]$date)
{
return $date.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssK')
}
function Get-CertificateInfo ($cert)
{
$epoch_date = Get-Date -Date "01/01/1970"
$cert_info = @{ extensions = @() }
$cert_info.friendly_name = $cert.FriendlyName
$cert_info.thumbprint = $cert.Thumbprint
$cert_info.subject = $cert.Subject
$cert_info.issuer = $cert.Issuer
$cert_info.valid_from = (ConvertTo-Timestamp -start_date $epoch_date -end_date $cert.NotBefore.ToUniversalTime())
$cert_info.valid_from_iso8601 = Format-Date -date $cert.NotBefore
$cert_info.valid_to = (ConvertTo-Timestamp -start_date $epoch_date -end_date $cert.NotAfter.ToUniversalTime())
$cert_info.valid_to_iso8601 = Format-Date -date $cert.NotAfter
$cert_info.serial_number = $cert.SerialNumber
$cert_info.archived = $cert.Archived
$cert_info.version = $cert.Version
$cert_info.has_private_key = $cert.HasPrivateKey
$cert_info.issued_by = $cert.GetNameInfo('SimpleName', $true)
$cert_info.issued_to = $cert.GetNameInfo('SimpleName', $false)
$cert_info.signature_algorithm = $cert.SignatureAlgorithm.FriendlyName
$cert_info.dns_names = [System.Collections.Generic.List`1[String]]@($cert_info.issued_to)
$cert_info.raw = [System.Convert]::ToBase64String($cert.GetRawCertData())
$cert_info.public_key = [System.Convert]::ToBase64String($cert.GetPublicKey())
if ($cert.Extensions.Count -gt 0)
{
[array]$cert_info.extensions = foreach ($extension in $cert.Extensions)
{
$extension_info = @{
critical = $extension.Critical
field = $extension.Oid.FriendlyName
value = $extension.Format($false)
}
if ($extension -is [System.Security.Cryptography.X509Certificates.X509BasicConstraintsExtension])
{
$cert_info.is_ca = $extension.CertificateAuthority
$cert_info.path_length_constraint = $extension.PathLengthConstraint
}
elseif ($extension -is [System.Security.Cryptography.X509Certificates.X509EnhancedKeyUsageExtension])
{
$cert_info.intended_purposes = $extension.EnhancedKeyUsages.FriendlyName -as [string[]]
}
elseif ($extension -is [System.Security.Cryptography.X509Certificates.X509KeyUsageExtension])
{
$cert_info.key_usages = $extension.KeyUsages.ToString().Split(',').Trim() -as [string[]]
}
elseif ($extension -is [System.Security.Cryptography.X509Certificates.X509SubjectKeyIdentifierExtension])
{
$cert_info.ski = $extension.SubjectKeyIdentifier
}
elseif ($extension.Oid.value -eq '2.5.29.17')
{
$sans = $extension.Format($true).Split("`r`n", [System.StringSplitOptions]::RemoveEmptyEntries)
foreach ($san in $sans)
{
$san_parts = $san.Split("=")
if ($san_parts.Length -ge 2 -and $san_parts[0].Trim() -eq 'DNS Name')
{
$cert_info.dns_names.Add($san_parts[1].Trim())
}
}
}
$extension_info
}
}
return $cert_info
}
$store_location_values = ([System.Security.Cryptography.X509Certificates.StoreLocation]).GetEnumValues() | ForEach-Object { $_.ToString() }
$spec = @{
options = @{
thumbprint = @{ type = "str"; required = $false }
store_name = @{ type = "str"; default = "My"; }
store_location = @{ type = "str"; default = "LocalMachine"; choices = $store_location_values; }
}
}
$module = [Ansible.Basic.AnsibleModule]::Create($args, $spec)
$thumbprint = $module.Params.thumbprint
$store_name = $module.Params.store_name
$store_location = [System.Security.Cryptography.X509Certificates.Storelocation]"$($module.Params.store_location)"
$store = New-Object -TypeName System.Security.Cryptography.X509Certificates.X509Store -ArgumentList $store_name, $store_location
$store.Open([System.Security.Cryptography.X509Certificates.OpenFlags]::ReadOnly)
$module.Result.exists = $false
$module.Result.certificates = @()
try
{
if ($null -ne $thumbprint)
{
$found_certs = $store.Certificates.Find([System.Security.Cryptography.X509Certificates.X509FindType]::FindByThumbprint, $thumbprint, $false)
}
else
{
$found_certs = $store.Certificates
}
if ($found_certs.Count -gt 0)
{
$module.Result.exists = $true
[array]$module.Result.certificates = $found_certs | ForEach-Object { Get-CertificateInfo -cert $_ } | Sort-Object -Property { $_.thumbprint }
}
}
finally
{
$store.Close()
}
$module.ExitJson()

View file

@ -0,0 +1,236 @@
#!/usr/bin/python
# -*- coding: utf-8 -*-
# Copyright: (c) 2016, Ansible, inc
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
# this is a windows documentation stub. actual code lives in the .ps1
# file of the same name
ANSIBLE_METADATA = {'metadata_version': '1.1',
'status': ['preview'],
'supported_by': 'community'}
DOCUMENTATION = r'''
---
module: win_certificate_info
version_added: "2.10"
short_description: Get information on certificates from a Windows Certificate Store
description:
- Returns information about certificates in a Windows Certificate Store.
options:
thumbprint:
description:
- The thumbprint as a hex string of a certificate to find.
- When specified, filters the I(certificates) return value to a single certificate
- See the examples for how to format the thumbprint.
type: str
required: no
store_name:
description:
- The name of the store to search.
- See U(https://docs.microsoft.com/en-us/dotnet/api/system.security.cryptography.x509certificates.storename)
for a list of built-in store names.
type: str
default: My
store_location:
description:
- The location of the store to search.
type: str
choices: [ CurrentUser, LocalMachine ]
default: LocalMachine
seealso:
- module: win_certificate_store
author:
- Micah Hunsberger (@mhunsber)
'''
EXAMPLES = r'''
- name: Obtain information about a particular certificate in the computer's personal store
win_certificate_info:
thumbprint: BD7AF104CF1872BDB518D95C9534EA941665FD27
register: mycert
# thumbprint can also be lower case
- name: Obtain information about a particular certificate in the computer's personal store
win_certificate_info:
thumbprint: bd7af104cf1872bdb518d95c9534ea941665fd27
register: mycert
- name: Obtain information about all certificates in the root store
win_certificate_info:
store_name: Root
register: ca
# Import a pfx and then get information on the certificates
- name: Import pfx certificate that is password protected
win_certificate_store:
path: C:\Temp\cert.pfx
state: present
password: VeryStrongPasswordHere!
become: yes
become_method: runas
register: mycert
- name: Obtain information on each certificate that was touched
win_certificate_info:
thumbprint: "{{ item }}"
register: mycert_stats
loop: "{{ mycert.thumbprints }}"
'''
RETURN = r'''
exists:
description:
- Whether any certificates were found in the store.
- When I(thumbprint) is specified, returns true only if the certificate mathing the thumbprint exists.
returned: success
type: bool
sample: true
certificates:
description:
- A list of information about certificates found in the store, sorted by thumbprint.
returned: success
type: list
elements: dict
contains:
archived:
description: Indicates that the certificate is archived.
type: bool
sample: false
dns_names:
description: Lists the registered dns names for the certificate.
type: list
elements: str
sample: [ '*.m.wikiquote.org', '*.wikipedia.org' ]
extensions:
description: The collection of the certificates extensions.
type: list
elements: dict
sample: [
{
"critical": false,
"field": "Subject Key Identifier",
"value": "88 27 17 09 a9 b6 18 60 8b ec eb ba f6 47 59 c5 52 54 a3 b7"
},
{
"critical": true,
"field": "Basic Constraints",
"value": "Subject Type=CA, Path Length Constraint=None"
},
{
"critical": false,
"field": "Authority Key Identifier",
"value": "KeyID=2b d0 69 47 94 76 09 fe f4 6b 8d 2e 40 a6 f7 47 4d 7f 08 5e"
},
{
"critical": false,
"field": "CRL Distribution Points",
"value": "[1]CRL Distribution Point: Distribution Point Name:Full Name:URL=http://crl.apple.com/root.crl"
},
{
"critical": true,
"field": "Key Usage",
"value": "Digital Signature, Certificate Signing, Off-line CRL Signing, CRL Signing (86)"
},
{
"critical": false,
"field": null,
"value": "05 00"
}
]
friendly_name:
description: The associated alias for the certificate.
type: str
sample: Microsoft Root Authority
has_private_key:
description: Indicates that the certificate contains a private key.
type: bool
sample: false
intended_purposes:
description: lists the intended applications for the certificate.
returned: enhanced key usages extension exists.
type: list
sample: [ "Server Authentication" ]
is_ca:
description: Indicates that the certificate is a certificate authority (CA) certificate.
returned: basic constraints extension exists.
type: bool
sample: true
issued_by:
description: The certificate issuer's common name.
type: str
sample: Apple Root CA
issued_to:
description: The certificate's common name.
type: str
sample: Apple Worldwide Developer Relations Certification Authority
issuer:
description: The certificate issuer's distinguished name.
type: str
sample: 'CN=Apple Root CA, OU=Apple Certification Authority, O=Apple Inc., C=US'
key_usages:
description:
- Defines how the certificate key can be used.
- If this value is not defined, the key can be used for any purpose.
returned: key usages extension exists.
type: list
elements: str
sample: [ "CrlSign", "KeyCertSign", "DigitalSignature" ]
path_length_constraint:
description:
- The number of levels allowed in a certificates path.
- If this value is 0, the certificate does not have a restriction.
returned: basic constraints extension exists
type: int
sample: 0
public_key:
description: The base64 encoded public key of the certificate.
type: str
cert_data:
description: The base64 encoded data of the entire certificate.
type: str
serial_number:
description: The serial number of the certificate represented as a hexadecimal string
type: str
sample: 01DEBCC4396DA010
signature_algorithm:
description: The algorithm used to create the certificate's signature
type: str
sample: sha1RSA
ski:
description: The certificate's subject key identifier
returned: subject key identifier extension exists.
type: str
sample: 88271709A9B618608BECEBBAF64759C55254A3B7
subject:
description: The certificate's distinguished name.
type: str
sample: 'CN=Apple Worldwide Developer Relations Certification Authority, OU=Apple Worldwide Developer Relations, O=Apple Inc., C=US'
thumbprint:
description:
- The thumbprint as a hex string of the certificate.
- The return format will always be upper case.
type: str
sample: FF6797793A3CD798DC5B2ABEF56F73EDC9F83A64
valid_from:
description: The start date of the certificate represented in seconds since epoch.
type: float
sample: 1360255727
valid_from_iso8601:
description: The start date of the certificate represented as an iso8601 formatted date.
type: str
sample: '2017-12-15T08:39:32Z'
valid_to:
description: The expiry date of the certificate represented in seconds since epoch.
type: float
sample: 1675788527
valid_to_iso8601:
description: The expiry date of the certificate represented as an iso8601 formatted date.
type: str
sample: '2086-01-02T08:39:32Z'
version:
description: The x509 format version of the certificate
type: int
sample: 3
'''

View file

@ -0,0 +1 @@
shippable/windows/group7

View file

@ -0,0 +1,3 @@
win_cert_dir: '{{win_output_dir}}\win_certificate .ÅÑŚÌβŁÈ [$!@^&test(;)]'
subj_thumbprint: 'BD7AF104CF1872BDB518D95C9534EA941665FD27'
root_thumbprint: 'BC05633694E675449136679A658281F17A191087'

View file

@ -0,0 +1,20 @@
-----BEGIN CERTIFICATE-----
MIIDKDCCAhCgAwIBAgIJAP1vIdGgMJv/MA0GCSqGSIb3DQEBCwUAMCgxGTAXBgNV
BAMMEHJvb3QuYW5zaWJsZS5jb20xCzAJBgNVBAYTAlVTMCAXDTE3MTIxNTA4Mzkz
MloYDzIwODYwMTAyMDgzOTMyWjAoMRkwFwYDVQQDDBByb290LmFuc2libGUuY29t
MQswCQYDVQQGEwJVUzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMmq
YT8eZY6rFQKnmScUGnnUH1tLQ+3WQpfKiWygCUSb1CNqO3J1u3pGMEqYM58LK4Kr
Mpskv7K1tCV/EMZqGTqXAIfSLy9umlb/9C3AhL9thBPn5I9dam/EmrIZktI9/w5Y
wBXn4toe+OopA3QkMQh9BUjUCPb9fdOI+ir7OGFZMmxXmiM64+BEeywM2oSGsdZ9
5hU378UBu2IX4+OAV8Fbr2l6VW+Fxg/tKIOo6Bs46Pa4EZgtemOqs3kxYBOltBTb
vFcLsLa4KYVu5Ge5YfB0Axfaem7PoP8IlMs8gxyojZ/r0o5hzxUcYlL/h8GeeoLW
PFFdiAS+UgxWINOqNXMCAwEAAaNTMFEwHQYDVR0OBBYEFLp9k4LmOnAR4ROrqhb+
CFdbk2+oMB8GA1UdIwQYMBaAFLp9k4LmOnAR4ROrqhb+CFdbk2+oMA8GA1UdEwEB
/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAGksycHsjGbXfWfuhQh+CvXk/A2v
MoNgiHtNMTGliVNgoVp1B1rj4x9xyZ8YrO8GAmv8jaCwCShd0B5Ul4aZVk1wglVv
lFAwb4IAZN9jv9+fw5BRzQ2tLhkVWIEwx6pZkhGhhjBvMaplLN5JwBtsdZorFbm7
wuKiUKcFAM28acoOhCmOhgyNNBZpZn5wXaQDY43AthJOhitAV7vph4MPUkwIJnOh
MA5GJXEqS58TE9z9pkhQnn9598G8tmOXyA2erAoM9JAXM3EYHxVpoHBb9QRj6WAw
XVBo6qRXkwjNEM5CbnD4hVIBsdkOGsDrgd4Q5izQZ3x+jFNkdL/zPsXjJFw=
-----END CERTIFICATE-----

View file

@ -0,0 +1,19 @@
-----BEGIN CERTIFICATE-----
MIIC0TCCAbkCCQC/MtOBa1UDpzANBgkqhkiG9w0BAQsFADAoMRkwFwYDVQQDDBBy
b290LmFuc2libGUuY29tMQswCQYDVQQGEwJVUzAgFw0xNzEyMTUwODU2MzBaGA8y
MDg2MDEwMjA4NTYzMFowKzEcMBoGA1UEAwwTc3ViamVjdC5hbnNpYmxlLmNvbTEL
MAkGA1UEBhMCVVMwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDszqdF
So3GlVP1xUnN4bSPrFRFiOl/Mqup0Zn5UJJUR9wLnRD+OLcq7kKin6hYqozSu7cC
+BnWQoq7vGSSNVqv7BqFMwzGJt9IBUQv0UqIQkA/duUdKdAiMn2PQRsNDnkWEbTj
4xsitItVNv84cDG0lkZBYyTgfyZlZLZWplkpUQkrZhoFCekZRJ+ODrqNW3W560rr
OUIh+HiQeBqocat6OdxgICBqpUh8EVo1iha3DXjGN08q5utg6gmbIl2VBaVJjfyd
wnUSqHylJwh6WCIEh+HXsn4ndfNWSN/fDqvi5I10V1j6Zos7yqQf8qAezUAm6eSq
hLgZz0odq9DsO4HHAgMBAAEwDQYJKoZIhvcNAQELBQADggEBAFK5mVIJ2D+kI0kk
sxnW4ibWFjzlYFYPYrZg+2JFIVTbKBg1YzyhuIKm0uztqRxQq5iLn/C/uponHoqF
7KDQI37KAJIQdgSva+mEuO9bZAXg/eegail2hN6np7HjOKlPu23s40dAbFrbcOWP
VbsBEPDP0HLv6OgbQWzNlE9HO1b7pX6ozk3q4ULO7IR85P6OHYsBBThL+qsOTzg/
gVknuB9+n9hgNqZcAcXBLDetOM9aEmYJCGk0enYP5UGLYpseE+rTXFbRuHTPr1o6
e8BetiSWS/wcrV4ZF5qr9NiYt5eD6JzTB5Rn5awxxj0FwMtrBu003lLQUWxsuTzz
35/RLY4=
-----END CERTIFICATE-----

View file

@ -0,0 +1,2 @@
dependencies:
- prepare_win_tests

View file

@ -0,0 +1,88 @@
### keys in files/ have been generated with
# generate root private key
# openssl genrsa -aes256 -out enckey.pem 2048
# openssl rsa -in envkey.pem -out root-key.pem
#
# generate root certificate
# openssl req -x509 -key root-key.pem -days 24855 -out root-vert.pem -subj "/CN=root.ansible.com/C=US"
#
# generate subject private key
# openssl genrsa -aes256 -out enckey.pem 2048
# openssl rsa -in enckey.pem -out subj-key.pem
#
# generate subject certificate
# openssl req -new -key subj-key.pem -out cert.csr -subj "/CN=subject.ansible.com/C=US"
# openssl x509 -req -in cert.csr -CA root-cert.pem -CAkey root-key.pem -CAcreateserial -out subj-cert.pem -days 24855
###
---
- name: ensure test dir is present
win_file:
path: '{{win_cert_dir}}\exported'
state: directory
- name: copy across test cert files
win_copy:
src: files/
dest: '{{win_cert_dir}}'
- name: subject cert imported to personal store
win_certificate_store:
path: '{{win_cert_dir}}\subj-cert.pem'
state: present
store_name: My
- name: root certificate imported to trusted root
win_certificate_store:
path: '{{win_cert_dir}}\root-cert.pem'
store_name: Root
state: present
- name: get raw root certificate
shell: 'cat root-cert.pem | grep "^[^-]"'
args:
chdir: '{{ role_path }}/files'
register: root_raw
delegate_to: localhost
- name: get public key of root certificate
shell: 'openssl x509 -pubkey -noout -in root-cert.pem | grep "^[^-]"'
args:
chdir: '{{ role_path }}/files'
register: root_pub
delegate_to: localhost
- name: get subject certificate
shell: 'cat subj-cert.pem | grep "^[^-]"'
args:
chdir: '{{ role_path }}/files'
register: subj_raw
delegate_to: localhost
- name: get public key of subject certificate
shell: 'openssl x509 -pubkey -noout -in subj-cert.pem | grep "^[^-]"'
args:
chdir: '{{ role_path }}/files'
register: subj_pub
delegate_to: localhost
- block:
- name: run tests
include_tasks: tests.yml
always:
- name: ensure subject cert removed from personal store
win_certificate_store:
thumbprint: '{{subj_thumbprint}}'
state: absent
store_name: My
- name: ensure root cert removed from trusted root
win_certificate_store:
thumbprint: '{{root_thumbprint}}'
state: absent
store_name: Root
- name: ensure test dir is deleted
win_file:
path: '{{win_cert_dir}}'
state: absent

View file

@ -0,0 +1,90 @@
---
- name: get stats on a store that doesn't exist
win_certificate_info:
store_name: teststore
register: test_store
- name: ensure exists is false
assert:
that:
- test_store.exists == false
- name: get stats on the root certificate store
win_certificate_info:
store_name: Root
register: root_store
- name: at least one certificate is returned
assert:
that:
- "root_store.exists"
- "root_store.certificates | length > 0"
- name: get stats on a certificate that doesn't exist
win_certificate_info:
thumbprint: ABC
register: actual
- name: ensure exists is false
assert:
that: actual.exists == false
- name: get stats on root certificate
win_certificate_info:
thumbprint: '{{ root_thumbprint }}'
store_name: Root
register: root_stats
- name: root certificate stats returned are expected values
assert:
that:
- root_stats.exists
- root_stats.certificates[0].archived == false
- root_stats.certificates[0].dns_names == [ 'root.ansible.com' ]
- root_stats.certificates[0].extensions|count == 3
- root_stats.certificates[0].has_private_key == false
- root_stats.certificates[0].issued_by == 'root.ansible.com'
- root_stats.certificates[0].issued_to == 'root.ansible.com'
- root_stats.certificates[0].issuer == 'C=US, CN=root.ansible.com'
- root_stats.certificates[0].path_length_constraint == 0
# - root_stats.certificates[0].public_key == (root_pub.stdout_lines|join())
- root_stats.certificates[0].raw == root_raw.stdout_lines|join()
- root_stats.certificates[0].serial_number == '00FD6F21D1A0309BFF'
- root_stats.certificates[0].signature_algorithm == 'sha256RSA'
- root_stats.certificates[0].ski == 'BA7D9382E63A7011E113ABAA16FE08575B936FA8'
- root_stats.certificates[0].subject == 'C=US, CN=root.ansible.com'
- root_stats.certificates[0].valid_from == 1513327172
- root_stats.certificates[0].valid_from_iso8601 == '2017-12-15T08:39:32Z'
- root_stats.certificates[0].valid_to == 3660799172
- root_stats.certificates[0].valid_to_iso8601 == '2086-01-02T08:39:32Z'
- root_stats.certificates[0].version == 3
- name: get stats on subject certificate
win_certificate_info:
thumbprint: '{{ subj_thumbprint }}'
register: subj_stats
- name: subject certificate stats returned are expected values
assert:
that:
- subj_stats.exists
- subj_stats.certificates[0].archived == false
- subj_stats.certificates[0].dns_names == [ 'subject.ansible.com' ]
- subj_stats.certificates[0].extensions|count == 0
- subj_stats.certificates[0].has_private_key == false
- subj_stats.certificates[0].issued_by == 'root.ansible.com'
- subj_stats.certificates[0].issued_to == 'subject.ansible.com'
- subj_stats.certificates[0].issuer == 'C=US, CN=root.ansible.com'
- subj_stats.certificates[0].path_length_constraint is undefined
# - subj_stats.certificates[0].public_key == subj_pub.stdout_lines|join()
- subj_stats.certificates[0].raw == subj_raw.stdout_lines|join()
- subj_stats.certificates[0].serial_number == '00BF32D3816B5503A7'
- subj_stats.certificates[0].signature_algorithm == 'sha256RSA'
- subj_stats.certificates[0].ski is undefined
- subj_stats.certificates[0].subject == 'C=US, CN=subject.ansible.com'
- subj_stats.certificates[0].valid_from == 1513328190
- subj_stats.certificates[0].valid_from_iso8601 == '2017-12-15T08:56:30Z'
- subj_stats.certificates[0].valid_to == 3660800190
- subj_stats.certificates[0].valid_to_iso8601 == '2086-01-02T08:56:30Z'
- subj_stats.certificates[0].version == 1