Remplacer ADCS par Vault de Hashicorp ?
Et si on pouvait remplacer ADCS par Vault de Hashicorp ?
Le principal intérêt de ADCS est une très bonne intégration dans un domaine géré par Active Directory, où après installation et configuration, l’ensemble des membres du domaine, contrôleurs de domaine inclus, vont pouvoir obtenir de manière sécurisé un certificat en leur nom. Le renouvellement est également automatique. On se retrouve avec très peu d’effort une infrastructure qui est compatible avec les smart logons (qui regroupe les logins par carte à puce et par biométrie).
L’objectif va être de voir s’il est possible de remplacer ces certificats par Windows, et jusqu’où ?
Pré-requis
On part sur une PKI Vault déjà prête, et que j’ai détaillée ici, avec les mêmes pré-requis et les mêmes variables :
vault_domain=vault.example.com
VAULT_ADDR="https://$vault_domain"
VAULT_TOKEN="hvs.xxxxxxxxxxxxxxxx"
vault_org=Kveer
vault_loc=Paris
vault_country=FR
On rajoutera également un compte administrateur du domaine.
Créer une sub CA pour mon domaine Windows
La première étape va être bien sur de créer une autorité intermédiaire, pour mon domaine Windows.
engine=pki-kveer-home-g1
engine_ca=pki-kveer-root-e1
csr=$(mktemp)
crt=$(mktemp)
vault secrets enable -path=$engine -max-lease-ttl=365d pki
vault write $engine/config/crl auto_rebuild=true enable_delta=true
vault write $engine/config/cluster \
path=http://$vault_domain/v1/$engine \
aia_path=http://$vault_domain/v1/$engine
vault write $engine/config/urls enable_templating=true
vault write $engine/config/urls \
crl_distribution_points={{cluster_aia_path}}/crl \
delta_crl_distribution_points={{cluster_aia_path}}/crl/delta \
issuing_certificates={{cluster_aia_path}}/issuer/{{issuer_id}}/der \
ocsp_servers={{cluster_path}}/oscp
Elle sera émise par Kveer Root E1 et aura une durée de vie de 5 ans, parce que c’est une autorité intermédiaire et non racine. On ajoute max_path_length=0 pour éviter que cette autorité puisse enfanter d’autres autorités de certification.
engine=pki-kveer-home-g1
vault write -format=json $engine/intermediate/generate/internal \
common_name="$vault_org Home G1" \
ou="Home network" organization="$vault_org" locality="$vault_loc" country="$vault_country" \
key_type=ec key_bits=384 `
exclude_cn_from_sans=true | jq -r .data.csr | tee $csr
vault write -format=json $engine_ca/root/sign-intermediate csr=@$csr format=pem_bundle \
use_csr_values=true max_path_length=0 \
ttl=1825d \
| jq -r '.data.certificate' > $crt
vault write $engine/intermediate/set-signed certificate=@$crt
Rôles pour l’autorité de certification du domaine Windows
On définie ensuite trois rôles.
Le premier permet d’émettre des certificats à destination des contrôleurs de domaines. En imitant le template ADCS Kerberos Authentication, ce certificat doit avoir l’EKU KDC Authentication identifié par l’OID 1.3.6.1.5.2.3.5.
Pour être valablement reconnu, ces certificats doivent également avoir 3 subject alternative names :
- leur FQDN : DC1.exemple.com
- le nom de domaine du domaine AD : exemple.com
- le nom de domaine NETBIOS : EXAMPLE
Le second, à destination des ordinateurs membres du domaine, est un certificat beaucoup plus standard avec les enhanced key usages authentification server et authentification client activé mais avec l’EKU Smart card logon identifié par l’OID 1.3.6.1.4.1.311.20.2.2.
Le dernier, à destination des utilisateurs. Il est identique à celui pour les ordinateurs sans l’EKU Server authentication et avec une clé RSA. Il pourra être utilisé dans une smart card pour que l’utilisateur puisse s’authentifier. Sur le papier, une clé EC devrait passer sans problème mais je n’ai pas réussi à le faire fonctionner sur ma Yubikey (en testant avec certutil -scinfo).
engine=pki-kveer-home-g1
# pour le DC
vault write $engine/roles/ad_dc_manual \
ttl=365d max_ttl=365d key_type=ec \
allow_localhost=false allow_wildcard_certificates=false allow_ip_sans=false allowed_uri_sans=false \
ext_key_usage=ExtKeyUsageServerAuth,ExtKeyUsageClientAuth \
ext_key_usage_oids=1.3.6.1.5.2.3.5 \
key_usage=DigitalSignature,KeyEncipherment \
ou="Home network" organization="$vault_org" locality="$vault_loc" country="$vault_country" \
allowed_domains=example.com,EXAMPLE allow_bare_domains=true allow_subdomains=true
# pour les ordinateurs membres
vault write $engine/roles/ad_members_manual \
ttl=365d max_ttl=365d key_type=ec \
allow_localhost=false allow_wildcard_certificates=false allow_ip_sans=false allowed_uri_sans=false \
ext_key_usage=ExtKeyUsageServerAuth,ExtKeyUsageClientAuth \
ext_key_usage_oids=1.3.6.1.4.1.311.20.2.2 \
key_usage=DigitalSignature,KeyEncipherment \
ou="Home network" organization="$vault_org" locality="$vault_loc" country="$vault_country" \
allowed_domains=example.com allow_subdomains=true
# pour les utilisateurs
vault write $engine/roles/ad_users_manual \
ttl=365d max_ttl=365d key_type=rsa key_bits=2048 \
allow_localhost=false allow_wildcard_certificates=false allow_ip_sans=false allowed_uri_sans=false \
ext_key_usage=ExtKeyUsageClientAuth \
ext_key_usage_oids=1.3.6.1.4.1.311.20.2.2 \
key_usage=DigitalSignature,KeyEncipherment \
ou="Home network" organization="$vault_org" locality="$vault_loc" country="$vault_country" \
allow_any_name=true enforce_hostnames=false server_flag=false
Récupération des certificats publiques des autorités de certifications
Nous allons récupérer depuis vault les certificats publiques de la CA que nous venons de créer ainsi que de l’autorité racine qui l’a créé.
engine=pki-kveer-home-g1
vault read -format=json $engine/cert/ca | jq -r .data.certificate > /tmp/kveer-home-g1-ca.cer
vault read -format=json pki-kveer-root-e1/cert/ca | jq -r .data.certificate > /tmp/kveer-root-e1.cer
Le second certificat kveer-root-e1.cer devra être installé dans le magasin de certificat Windows Trusted Root Certification Authorities (Cert:\LocalMachine\Root).
Le premier certificat devra être installé à deux endroits :
- le magasin de certificat Windows
Intermediate Certification Authorities(Cert:\LocalMachine\CA). - le magasin NTAuthCA
Pour l’installation dans le magasin NTAuthCA, il faudra être domain admin et utiliser l’une des deux méthodes suivantes :
- la CLI
- La MMC Enterprise PKI (pkiview.msc) en faisant un clic-droit sur Enterprise PKI puis “Manage AD Containers” puis en sélectionnant l’onglet NTAuthCertificates

Vous ne devriez avoir qu’un seul certificat listé.
Pour la méthode CLI :
certutil -dspublish -f kveer-home-g1-ca.cer NTAuthCA
Générer le certificat DC
Assurez-vous que les deux certificats d’autorités sont bien installé dans les magasins de certificats avant de passer à cette étape. Un petit gpupdate si vous les déployez par GPO peut aider.
La génération va être complètement manuelle, mais grâce à ce qui a déjà été défini dans le role kerberod_dc, on aura juste à spécifier le common_name et les alt_names.
engine=pki-kveer-home-g1
vault write $engine/issue/ad_dc_manual common_name=DC1.example.com alt_names=example.com,EXAMPLE
Si la commande est bien passée, Vault va afficher le certificat généré ainsi que sa clée privée. Gardez le shell ouvert jusqu’à ce que le certificat soit correctement installé, c’est la seule copie de la clé privée.
On récupère et on concatène le contenu de private_key et certificate dans un fichier par exemple nommé dc1.pem, puis dans un shell avec OpenSSL :
openssl pkcs12 -export -in dc1.pem -out dc1.p12
rm dc1.pem
On copie dc1.p12 sur le DC, puis dans une fenêtre PowerShell sur le DC :
Import-PfxCertificate C:\dc1.p12 Cert:\LocalMachine\My\ -Password (Read-Host "PFX password: " -AsSecureString)
Si le contrôleur de domaine n’avait pas de certificat valide jusqu’à présent, le changement devrait être immédiat. Dans l’event viewer, au niveau de Kerberos-Ke-Distribution-Center, un event 200 indique qu’aucun certificat adapté n’a été trouvé :

En revanche, un event 302 indique qu’un certificat a été trouvé et indique lequel :

Générer le certificat pour les membres
Pour les ordinateurs membres, on procédera à l’identique, si ce n’est qu’on utilisera le rôle members_ad pour la génération initiale.
vault write $engine/issue/ad_members_manual common_name=COMPUTER1.example.com
On crée le fichier PEM, on le converti en P12, on le transfert dans un endroit accessible par l’ordinateur membre, puis on l’importe avec Import-PfxCertificate ou avec la MMC en GUI.
Automatiser le renouvellement sur les membres (FAIL)
Maintenant qu’on sait générer un certificat et l’installer sur un ordinateur member de domaine, est-il possible que cette ordinateur puisse renouveler son certificat de manière autonome ?
J’ai deux leviers intéressant dans Vault :
- l’authenfication par certificat client en mTLS
- les policies de Vault peuvent être templatés
L’idée va être donc de récupérer le CN= du certificat à renouveler, via l’authentification mTLS, et l’injecter dans une policy pour restreindre les certificats que peut demander l’ordinateur.
On va déjà récupérer la policy, et pour ça, la CLI va nous y aider, avec le flag -output-policy, qui au lieu d’exécuter la requête, va afficher la policy nécessaire pour autoriser cette requête :
engine=pki-kveer-home-g1
veovis@anubis:~$ vault write -output-policy $engine/issue/members_ad common_name=COMPUTER1.example.com
path "pki-kveer-home-g1/issue/ad_members_manual" {
capabilities = ["create", "update"]
}
On l’adapte pour restreindre le paramètre common_name, ce qui donnerait la policy:
path "pki-kveer-home-g1/issue/ad_members_manual" {
capabilities = ["create", "update"]
required_parameters = ["common_name"]
allowed_parameters = {
"common_name" = ["{{identity.entity.aliases.auth_cert_path.name}}"]
}
}
Mais comme indiqué sur la issue 6202, le templating ne fonctionne que sur le path et non sur la valeur des paramètres (le body).
Est-ce foutu pour autant ? Non, car j’ai négligé un paramètre du secret engine PKI : allowed_domains_template. On va donc pouvoir cloner le role members_ad mais avec ce paramètre actif et qui sera utilisé dans un contexte de renouvellement.
Afin d’avoir la bonne variable de template, nous allons d’abord configurer l’authentification par certificat. En effet, le template va permettre de spécifier le login (ici le CN=) mais également depuis quel méthode d’authentification ce login a été obtenu.
Configurer l’authentification par certificat
Chaque méthode d’authentification a un chemin unique. Je vais nommer cert_kveer-home-g1 la méthode d’authentification par certificat et où Kveer Home G1 est la CA qui aura émise les certificats clients. Ce nommage permet d’avoir une vue claire sur l’ensemble des méthodes d’authentifications ainsi que de pouvoir les identifier simplement par leur nom.
On aura besoin du certificat publique Kveer Home G1 au format PEM Base64.
auth=cert_kveer-home-g1
auth_cert_ca_name=kveer-home-g1
vault auth enable -path=$auth cert
vault write auth/$auth/config oscp_cahce_size=100 role_cache_size=50
vault write auth/$auth/certs/$auth_cert_ca_name display_name="Kveer Home G1" certificate=@ca-kveer-home-g1.pem
On récupère l’accessor (l’id unique) de cette méthode d’authentifcation fraichement créée :
# vault auth list -format=json | jq -r '.["'$auth'/"].accessor'
auth_cert_7ee38141
Désormais, lorsqu’on s’authentifiera avec un certificat client, il sera possible d’identifier et de reconnaître l’utilisateur au sein de Vault avec la variable {{identity.entity.aliases.auth_cert_7ee38141.name}}, où :
auth_cert_7ee38141est l’accessor de la méthode d’authentification.nameest le nom de l’alias de l’entité authentifiée, c’est-à-dire ici le CN=
Automatiser le renouvellement sur les membres (seconde tentative)
Comme évoqué, nous allons utiliser le paramètre allowed_domains_template. Cela va changer le rôle, et donc on va avoir deux rôles :
- un rôle pour émettre le certificat manuellement, avec
allowed_domains=home.kveer.fr - un rôle pour renouveler le certificat par le destinataire, avec
allowed_domains_template=trueetallowed_domains={{identity.entity.aliases.auth_cert_7ee38141.name}}
On crée donc un nouveau rôle pour le renouvellement automatique :
vault write $engine/roles/ad_members_auto \
ttl=365d max_ttl=365d key_type=ec \
allow_localhost=false allow_wildcard_certificates=false allow_ip_sans=false allowed_uri_sans=false \
ext_key_usage=ExtKeyUsageServerAuth,ExtKeyUsageClientAuth \
key_usage=DigitalSignature,KeyEncipherment \
ou="Home network" organization="$vault_org" locality="$vault_loc" country="$vault_country" \
allow_subdomains=false allow_bare_domains=true \
allowed_domains_template=true allowed_domains="{{identity.entity.aliases.auth_cert_7ee38141.name}}"
Puis la policy pour autoriser l’utilisation de ce rôle lorsqu’on s’authentifie avec un certificat du même nom :
engine=pki-kveer-home-g1
policy="$(mktemp).hcl"
auth_cert_ca_name=kveer-home-g1
cat <<EOF > $policy
path "$engine/issue/ad_members_auto" {
capabilities = ["create", "update"]
required_parameters = ["common_name"]
}
EOF
# importe la policy
vault policy write cert-pki-kveer-home-g1-auto-renew $policy
# attache la policy à l'authentification par certificat
vault write auth/$auth/certs/$auth_cert_ca_name token_policies=cert-pki-kveer-home-g1-auto-renew
rm "$policy"
Script côté client
La configuration côté Vault étant achevée, on va maintenant s’attaquer au client.
Voyons déjà à quoi ressemble le login sous Vault pour extraire le token généré. On va pour cela empreinter (ou créer un certificat test de courte durée, par exemple avec ttl=1d) et se logger avec curl.
engine=pki-kveer-home-g1
payload="$(mktemp).json"
auth_cert_ca_name=kveer-home-g1
cat <<EOF > $payload
{
"name": "$auth_cert_ca_name"
}
EOF
curl -X POST --cert computer1.pem --key computer1-key.pem --data @$payload https://vault.example.com/v1/auth/cert_kveer-home-g1/login | jq
Si l’authentification est bien passée, on obtient le json suivant :
{
"request_id": "f732af11-9aad-46eb-b730-38060b76a14a",
"lease_id": "",
"renewable": false,
"lease_duration": 0,
"data": null,
"wrap_info": null,
"warnings": null,
"auth": {
"client_token": "hvs.CAESIOW3kSNX7d1w2SM0viGXwyyg4C05kcejXUONrThBhP-sGh4KHGh2cy5RSjU0MndZdlVpTU1WRDB3MmFCbzZ2b1Q",
"accessor": "FkeIBPSfFdntNgJoQ5G6uDl3",
"policies": [
"cert-pki-kveer-home-g1-auto-renew",
"default"
],
"token_policies": [
"cert-pki-kveer-home-g1-auto-renew",
"default"
],
"metadata": {
"authority_key_id": "e1:6b:80:d5:22:5e:5a:81:c9:69:1b:e8:d5:83:34:3b:cf:c0:b3:3e",
"cert_name": "kveer-home-g1",
"common_name": "COMPUTER1.example.com",
"serial_number": "147448893051040231367696995252699854451777723874",
"subject_key_id": "f5:31:68:be:e5:a6:a8:ac:d6:b4:62:84:f9:d8:61:a3:89:19:cb:0c"
},
"lease_duration": 3600,
"renewable": true,
"entity_id": "2eee2a5b-2f11-44e4-9ac2-1fa01bbcbfe2",
"token_type": "service",
"orphan": true,
"mfa_requirement": null,
"num_uses": 0
},
"mount_type": ""
}
Il n’y aura plus qu’à récupérer le token avec le JPath .auth.client_token.
$vaultUri = 'https://vault.example.com'
$vaultLoginPath = "$vaultUri/v1/auth/cert_kveer-home-g1/login"
$vaultCertRenew = "$vaultUri/v1/pki-kveer-home-g1/issue/ad_members_auto"
$r = Invoke-RestMethod -Uri $vaultLoginPath -Method Post -Certificate $c -ContentType application/json
$token = $r.auth.client_token
$body = @{"common_name" = "COMPUTER1.home.kveer.fr"} | ConvertTo-Json
$headers = @{"X-Vault-Token" = $token} | ConvertTo-Json
$r2 = Invoke-RestMethod -Uri $vaultCertRenew -Headers $headers -Body $body -Method Post -ContentType application/json
$cert = $r2.data.certificate
$key = $r2.data.private_key
Et là ça coince. On a bien le nouveau certificat et sa clé privée dans respectivement $cert et $key mais ils sont au format PEM Base64, et Windows n’accepte que le format PKCS12 pour les imports/exports de certificats.
J’ai tenté de faire la conversion en PowerShell, sans installer une autre dépendance, mais le PowerShell livré avec Windows, y compris Windows Server 2025, est basé sur le .NET Framework 4.8. Ce vieux machin ne supporte ni PEM, ni les certificats reposant sur une courbe elliptique.
Avec $key la clée privée en PEM Base64, telle que définie dans le script précédent, on retire l’en-tête/footer, on reconverti en byte. Connaissant déjà la structure ASN.1 auquel s’attendre,de cette clé privée, on récupère directement la taille de la “vraie” clé privée D à l’octet 6, qui devrait être de 32 octets pour la courbe nistP256.
Juste derrière se trouve la clé privée D.
À la fin, on retrouvera concatené Q.X et Q.Y, sachant que D, Q.X et Q.Y ont tous la même taille.
--- title: PEM-encoded EC private key --- packet 0-1: "PKIMessage" 2-4: "PKIHeader" 5: "??" 6: "PKIBody length" 7-38: "D (private key)" 39-50: "curve name" 51-56: "Q container header" 57-88: "Q.X" 89-120: "Q.Y"
Ce parsing de bourrin me permet d’extraire la clé privée et l’importer dans un objet ECDSACng proprement.
$a = $key.Split("`r`n") | Where-Object { $_ -notmatch '^-----.*-----$' }
$b = [Convert]::FromBase64String($a -join '')
# EC key only
# this ugly code is because PowerShell 5 is based on .NET Framework 4.8, too old to manipulate PEM directly
# use OpenSSL or ASN.1 parser or BouncyCastle if you need something more robust
$keyLength = $b[6]
$rkey = $b[7..($keyLength+6)]
# The public part, Q.X and Q.Y are at the end and are the same length as D
[byte[]] $qx = $b[($b.Length-(2*$keyLength))..($b.Length-$keyLength-1)]
[byte[]] $qy = $b[($b.Length-$keyLength)..($b.Length-1)]
$ecparam = New-Object System.Security.Cryptography.ECParameters
$ecparam.D = $rkey
$ecparam.Curve = [System.Security.Cryptography.ECCurve+NamedCurves]::nistP256
$q = New-Object System.Security.Cryptography.ECPoint
$q.X = $qx
$q.Y = $qy
$ecparam.Q = $q
$ec = [System.Security.Cryptography.ECDsa]::Create([System.Security.Cryptography.ECCurve+NamedCurves]::nistP256)
$ec.ImportParameters($ecparam)
$certFile = [IO.Path]::GetTempFileName()
$cert | Out-File "$certFile.cer"
$x509 = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2 $certFile
$x509.PrivateKey = $ec
$pfx = $x509.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Pfx, "azerqsdf")
Set-Content -Path "$certFile.pfx" -Value $pfx -Encoding Byte -NoNewline
Malheureusement ça casse à l’instruction $cert.PrivateKey = $ec avec une exception assez claire :
Exception setting “PrivateKey”: “Only asymmetric keys that implement ICspAsymmetricAlgorithm are supported.”
En .NET Framework 4.8, il n’y a que les clé RSA et DSA qui ont un support propre. Jusqu’au jour où Microsoft décidera d’inclure un PowerShell moderne et préinstallé dans Windows, on va passer au plan B : OpenSSL.
En collant tous les bouts :
- récupération du certificat actuel
- installation de la dépendance OpenSSL
- test de la date d’expiration
- cas où l’on a plusieurs certificats qui matchent
- cleaning des secrets dès que possible et du mieux possible
On arrive à ce script qu’il suffira d’ajouter au planificateur de tâche en exécution quotidienne :
<#PSScriptInfo
.VERSION 1.0.0.0
.GUID 53571078-9de5-4a9d-9125-71cb737c2b1b
.AUTHOR Veovis
#>
<#
.SYNOPSIS
Refresh the Kerberos certificate
#>
# === User variables ==========================================================
$vaultUri = 'https://vault.example.com'
$vaultLoginPath = "$vaultUri/v1/auth/cert_kveer-home-g1/login"
$vaultCertRenew = "$vaultUri/v1/pki-kveer-home-g1/issue/members_ad_renew"
$issuer = 'CN=Kveer Home G1, OU=Home network, O=Kveer, L=Paris, C=FR'
$authPath = 'kveer-home-g1'
$renewDaysBeforeExpiration = '10'
# === Script variables ========================================================
# to find the correct VC redist: https://github.com/slproweb/openssl-packagers/blob/main/ms-windows/templates/3.1/x64/openssl-light.iss
$vcRedistUrl = 'https://aka.ms/vs/16/release/vc_redist.x64.exe'
$openSslUrl = 'https://slproweb.com/download/Win64OpenSSL_Light-3_6_0.exe'
$OPENSSL = "$env:ProgramFiles\OpenSSL-Win64\bin\openssl.exe"
$CertStore = 'Cert:\LocalMachine\My\'
$now = [DateTime]::Now
# === Functions ===============================================================
function Get-CurrentCert() {
# matching on CA subject. CA auth key should be better but harder
# | Where-Object { $_.Oid.Value -eq '2.5.29.35' }
$ce = ls $CertStore `
| Where-Object Issuer -EQ $issuer `
| Where-Object { $_.HasPrivateKey } `
| Where-Object NotAfter -GT $now `
| Where-Object NotBefore -LT $now `
| Sort-Object NotAfter -Descending `
| Select-Object -First 1
return $ce
}
function Generate-RandomPassword([int] $size) {
return -join ((65..90) + (97..122) | Get-Random -Count $size | % {[char]$_})
}
function Test-OpensslIsInstalled() {
#$res = Get-Command -Name openssl.exe -CommandType Application -ErrorAction SilentlyContinue
#return $null -ne $res
return Test-Path $OPENSSL
}
function Install-OpenSSL() {
$wc = New-Object System.Net.WebClient
$opensslInstaller = [IO.Path]::GetTempFileName() + '.exe'
$vcRedistInstaller = [IO.Path]::GetTempFileName() + '.exe'
$wc.DownloadFile($openSslUrl, $opensslInstaller)
#$wc.DownloadFile($vcRedistUrl, $vcRedistInsatller)
$wc.Dispose()
$wr = [System.Net.WebRequest]::Create($vcRedistUrl)
$fs = New-Object System.IO.FileStream @($vcRedistInstaller, [System.IO.FileMode]::Create)
$rs = $wr.GetResponse()
$rs.GetResponseStream().CopyTo($fs)
$rs.Dispose()
$fs.Dispose()
Start-Process -FilePath $vsRedistInstaller -ArgumentList @('/install', '/passive') -Wait
Start-Process -FilePath $opensslInstaller -ArgumentList @('/NOICONS', '/SILENT') -Wait
Remove-Item $opensslInstaller
Remove-Item $vcRedistInstaller
}
function Connect-Vault([System.Security.Cryptography.X509Certificates.X509Certificate2]$c) {
$r = Invoke-RestMethod -Uri $vaultLoginPath -Method Post -Certificate $c -ContentType application/json #-Body "{ `"name`": `"$authPath`"}"
$token = $r.auth.client_token
return $token
}
# === Script ==================================================================
# PowerShell ISE that I used to debug has a different behavior regarding TLS version used
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + [Net.SecurityProtocolType]::Tls13
if (-Not (Test-OpensslIsInstalled)) {
Write-Host "OpenSSL not found. Installing..."
Install-OpenSSL
}
$c = Get-CurrentCert
if ($null -eq $c) {
Write-Error "No certificate to renew found"
Exit 2
}
if ($c.NotAfter -gt $now.AddDays($renewDaysBeforeExpiration)) {
Write-Error "Certificate expiration is far enough to not renew now."
Exit 0
}
$token = Connect-Vault $c
if ($null -eq $token) {
Write-Error "Can't authenticate on Vault with certificate $($c.Subject) (thumbprint: $($c.Thumbprint))"
Exit 1
}
$cn = ($c.Subject.Split(',') | Where-Object { $_ -ilike 'CN=*' }).Trim().Substring(3)
$body = @{"common_name" = "$cn"; "ttl"="1d"} | ConvertTo-Json
$headers = @{"X-Vault-Token" = $token}
$r2 = Invoke-RestMethod -Uri $vaultCertRenew -Headers $headers -Body $body -Method Post -ContentType application/json
$token = $null
$cert = $r2.data.certificate
$key = $r2.data.private_key
# openssl pem -> pfx
$certTmpFile = [IO.Path]::GetTempFileName()
Set-Content -Path "$certTmpFile.cer" -Value $cert
Set-Content -Path "$certTmpFile.key" -Value $key
$pass = Generate-RandomPassword 8
& $OPENSSL pkcs12 -export -in "$certTmpFile.cer" -inkey "$certTmpFile.key" -out "$certTmpFile.pfx" -passout pass:$pass
Remove-Item "$certTmpFile.cer"
Remove-Item "$certTmpFile.key"
# import pfx
$spass = ConvertTo-SecureString $pass -Force -AsPlainText
$pass = $null
Import-PfxCertificate -FilePath "$certTmpFile.pfx" -CertStoreLocation $CertStore -Password $spass
$spass.Clear()
Remove-Item "$certTmpFile.pfx"
The End?
Pas exactement. Depuis la KB5014754 publiée en mai 2022 et totalement enforce depuis septembre 2025, Microsoft a changé le mapping entre un certificate et un utilisateur/ordinateur. Microsoft considère désormais que le mapping sur la seule base du CN= est faible et donc déconseillé et désactivé.
Pour avoir un mapping fort, il reste deux méthodes :
- un mapping sur le SID de l’utilisateur/ordinateur à travers une nouvelle extension X509v3 avec l’OID 1.3.6.1.4.1.311.25.2
- un mapping explicite avec l’attribute LDAP altSecurityIdentities sur le Subject Key Identifier, le SHA1 de la clé publique ou le couple Issuer+Serial Number qui a l’avantage d’être plus lisible
Dans le premier cas, qui est fait autoamtiquement par ADCS, je ne peux tout simplement pas imiter ce champs dans Vault.
Dans le second cas, cela demande une intervention manuelle d’un Domain Admin, à chaque fois qu’un certificat est émis, cet attribut étant critique.
Au cas où vous auriez configuré Vault pour ne supporter que le TLS 1.3, vous aurez peut-être à l’activer pour l’utiliser sous Windows :
Set-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.3\Client\' -Name DisabledByDefault -Value 0
Set-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.3\Client\' -Name Enabled -Value 1
Et on pourra utiliser ce script pour mettre à jour les altSecurityIdentities automatiquement. Ce script pourrait être planifié 5 minutes après celui du renouvellement automatique, avec :
- une policy pour la lecture des certificats dans Vault
- un compte domain admin pour modifier côté AD.
Ce script n’est vraiment pas scalable, ça tiendra à l’aise sur un petit domaine mais risque de montrer rapidement des difficultés avec un nombre croissant de certificats.
⚠ C’est un script Powershell 5, il marchera avec le powershell nativement installé sur Windows, il ne fonctionnera pas avec PowerShell Core.
$env:VAULT_ADDR='https://vault.example.com'
$env:VAULT_TOKEN=$(Read-Host)
$engine='pki-kveer-home-g1'
$now = [DateTime]::Now
$VAULT = '.\vault.exe'
$certs = & $VAULT list -format=json $engine/certs | ConvertFrom-Json
$serials = @{}
$x509 = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2
$issuer = $null
# collecting serials from valid certificates
foreach ($serial in $certs) {
#$serial = $_
Write-Debug "Handling certificate serial=$serial"
$cert = & $VAULT read -format=json $engine/cert/$serial | ConvertFrom-Json
if ($cert.data.revocation_time -ne 0) {
Write-Debug "Certificate is revoked"
continue
}
$pemCert = [Convert]::FromBase64String(($cert.data.certificate.Replace("`r","").Split("`n") | Where-Object { $_ -notmatch '^-----.*-----$' }) -join '')
$x509.Import($pemCert)
# pwsh7:
# $x509 = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2 ($pemCert)
if ($x509.NotAfter -lt $now) {
Write-Debug "Certificate is expired"
continue
}
$cn = ($x509.Subject.Split(',') | Where-Object { $_ -like 'CN=*' }).Trim().SubString(3)
$isUser = $null -eq ($x509.EnhancedKeyUsageList | Where-Object { $_.ObjectId -eq '1.3.6.1.5.5.7.3.1' })
if ($null -eq $issuer) {
$a = $x509.Issuer.Replace(", ",",").Split(',').Trim()
[array]::Reverse($a)
$issuer = $a -join ','
}
if ($serials[$cn] -eq $null) {
$serials[$cn] = @{
IsUser = $isUser;
Serials = New-Object System.Collections.Generic.HashSet[string]
}
}
$serials[$cn].Serials.Add($x509.SerialNumber.ToLower())
}
$x509.Dispose()
function Reverse-SerialNumber([Parameter(Mandatory,ValueFromPipeline)][string]$sn) {
$a = [System.Linq.Enumerable]::Range(0, $sn.Length/2) | Select-Object @{N='byte';E={$sn.Substring(2*$_, 2)}} | Select-Object -ExpandProperty byte
[Array]::Reverse($a)
$a -join ''
}
# updating the altSecurityIdentities LDAP attribute
$serials.Keys | foreach {
$cname = $_
#$reversedSN = [System.Linq.Enumerable]::Range(0, $x509.SerialNumber.Length/2) | Select-Object @{N='byte';E={$x509.SerialNumber.Substring(2*$_, 2)}} | Select-Object -ExpandProperty byte
$ids = $serials[$cname].Serials | Reverse-SerialNumber | Select-Object @{N="id";E={"X509:<I>$issuer<SR>$_"} } | Select-Object -ExpandProperty id
$repl = @{'altSecurityIdentities'=[string[]]$ids}
if ($serials[$cname].IsUser) {
Get-ADUser -LDAPFilter "(cn=$cname)" | Set-ADUser -Replace $repl
} else {
Get-ADComputer -LDAPFilter "(dNSHostName=$cname)" | Set-ADComputer -Replace $repl
}
}
Pour répondre à la question “Peut-on remplacer ADCS par Vault”, la réponse est oui, mais pas avec le même côté pratique, ce qui pourra être un point négatif en entreprise.
Automatiser la première émission de certificat ?
Et si Kerberos pouvait servir à automatiser le premier certificat ? Configurons l’authentification Kerberos et voyons ce que ça donne.
Configuration
On aura besoin de deux comptes Windows :
- 1 compte avec un servicePrincipalName pour que Vault puisse tenir le rôle de serveur de service et pour lequel nous génèrerons un fichier keytab
- 1 compte basique pour faire des requêtes LDAP sur l’AD.
On suppose que le domaine est example.com, son royaume est donc EXAMPLE.COM et son nom Netbios est EXAMPLE. On suppose aussi que le contrôleur de domaine est proprement configuré, avec l’accès LDAP protégé avec un certificat SSL (STARTTLS ou LDAPS).
Sur le contrôleur de domaine :
New-ADComputer VAULT
setspn -S HTTP/vault.example.com vault
ktpass /princ HTTP/vault.example.com@EXAMPLE.COM /mapuser EXAMPLE\vault +rndPass /out vault.keytab /ptype KRB5_NT_PRINCPAL /crypto AES128-SHA1 -SetUpn +Answer
$p = [Guid]::NewGuid()
$p2 = $p | ConvertTo-SecureString -Force -AsPlainText
New-ADUser vault-ldap -AccountPassword $p2 -CannotChangePassword:$true
On garde $p pour la suite, et on récupère le fichier vault.keytab, qu’on converti en base64. Côté Vault maintenant :
vault auth enable \
-passthrough-request-headers=Authorization \
-allowed-response-headers=www-authenticate \
kerberos
vault write auth/kerberos/config \
keytab=@/tmp/vault.keytab.base64 \
service_account="HTTP/vault.example.com"
vault write auth/kerberos/config/ldap \
binddn=vault-ldap@example.com bindpass=$p \
userattr=sAMAccountName userdn="DC=EXAMPLE,DC=COM" upndomain= \
groupattr=sAMAccountName groupDN="DC=EXAMPLE,DC=COM" \
groupfilter="(&(objectClass=group)(member:1.2.840.113556.1.4.1941:={{.UserDN}}))" \
url=ldap://ad1.example.com \
starttls=true certificate=@root-ca.pem tls_min_version=tls12
Test
Et maintenant on peut tester un login avec un compte utilisateur :
PS C:\Users\veovis> (Invoke-RestMethod -Uri https://vault.example.com/v1/auth/kerberos/login -UseDefaultCredentials -Method Post).auth
client_token : hvs.CAESIHQI4DyIcdMmOP4FrtiKTmB9hZRq8163nFYUjP1eoh09Gh4KHGh2cy5sUUx3UFR4d01pNjZiQktVZndjdVQwVTI
accessor : 15l07JlDaspoAHKmUo6H27gG
policies : {default}
token_policies : {default}
metadata : @{domain=EXAMPLE.COM; user=veovis}
lease_duration : 2764800
renewable : False
entity_id : a0eef165-3324-44e8-8adb-8020c564fc72
token_type : service
orphan : True
mfa_requirement :
num_uses : 0
Ainsi qu’un compte ordinateur. On pourra utiliser psexec pour avoir un shell avec le compte NT AUTHORITY\SYSTEM :
PS C:\WINDOWS\system32> (Invoke-RestMethod -Uri https://vault.home.kveer.fr/v1/auth/kerberos/login -UseDefaultCredentials -Method Post).auth
client_token : hvs.CAESIGVugKmQTuzImUnGa4Ivqk85NXl_D7jbi0WMoZBbq4qCGh4KHGh2cy41YVQycktQZTlCbFVWRTQycjEwUHF6U1Y
accessor : EQzmi78t7egCPx6YVDb9nYHI
policies : {default}
token_policies : {default}
metadata : @{domain=EXAMPLE.COM; user=ANUBIS$}
lease_duration : 2764800
renewable : False
entity_id : 5ec0bc0b-474a-4451-8425-9dbc6117195d
token_type : service
orphan : True
mfa_requirement :
num_uses : 0
Dans les deux cas, ça marche. À noter que l’authentification avec un compte ordinateur ne fonctionne que si upndomain n’est pas setté. Dans l’état actuel et comme on peut le voir dans le code source de la verison 1.20.4, la requête LDAP fait une recherche WHERE userPrincipalName= dès que upndomain n’est pas vide.
Si on regarde maintenant les entitées et aliases générés :
$ vault read -format=json identity/entity/id/a0eef165-3324-44e8-8adb-8020c564fc72
{
"request_id": "4c0dd38c-e858-427a-a514-2560d2d4ccb7",
"lease_id": "",
"lease_duration": 0,
"renewable": false,
"data": {
"aliases": [
{
"canonical_id": "a0eef165-3324-44e8-8adb-8020c564fc72",
"creation_time": "2025-10-25T15:22:36.851390078Z",
"custom_metadata": null,
"id": "0bf4a7a1-5f97-0a8a-fb2d-1066346b84e9",
"last_update_time": "2025-10-25T15:22:36.851390078Z",
"local": false,
"merged_from_canonical_ids": null,
"metadata": null,
"mount_accessor": "auth_kerberos_1e7b011d",
"mount_path": "auth/kerberos/",
"mount_type": "kerberos",
"name": "veovis"
}
],
"creation_time": "2025-10-25T15:22:36.851380799Z",
"direct_group_ids": [],
"disabled": false,
"group_ids": [],
"id": "a0eef165-3324-44e8-8adb-8020c564fc72",
"inherited_group_ids": [],
"last_update_time": "2025-10-25T15:22:36.851380799Z",
"merged_entity_ids": null,
"metadata": null,
"name": "entity_a321a3fa",
"namespace_id": "root",
"policies": []
},
"warnings": null,
"mount_type": "identity"
}
$ vault read -format=json identity/entity/id/5ec0bc0b-474a-4451-8425-9dbc6117195d
{
"request_id": "8ab3bbb1-23a6-49f6-a8ea-0b6cb40e42f4",
"lease_id": "",
"lease_duration": 0,
"renewable": false,
"data": {
"aliases": [
{
"canonical_id": "5ec0bc0b-474a-4451-8425-9dbc6117195d",
"creation_time": "2025-10-25T16:06:02.669232916Z",
"custom_metadata": null,
"id": "eff432a0-fa47-4ed6-bbff-d9c4699e1165",
"last_update_time": "2025-10-25T16:06:02.669232916Z",
"local": false,
"merged_from_canonical_ids": null,
"metadata": null,
"mount_accessor": "auth_kerberos_1e7b011d",
"mount_path": "auth/kerberos/",
"mount_type": "kerberos",
"name": "ANUBIS$"
}
],
"creation_time": "2025-10-25T16:06:02.669217677Z",
"direct_group_ids": [],
"disabled": false,
"group_ids": [],
"id": "5ec0bc0b-474a-4451-8425-9dbc6117195d",
"inherited_group_ids": [],
"last_update_time": "2025-10-25T16:06:02.669217677Z",
"merged_entity_ids": null,
"metadata": null,
"name": "entity_a03cc325",
"namespace_id": "root",
"policies": []
},
"warnings": null,
"mount_type": "identity"
}
Tant pour l’utilisateur que pour le compte ordinateur, ceux-ci ont un alias correspondant à leur SAM account name. Si pour l’utilisateur, on pourrait s’en sortir en faisant un rôle de certificat en prenant le SAM account name et en le collant au nom de domaine hard-codé example.com, ça va être moins évident pour les compte ordinateur à cause du $, qu’on ne pourra pas virer, or c’est surtout pour les comptes ordinateur que ça va être utilisé.
Malheureusement Kerberos, en l’état, ne permettra pas d’automatiser la première émission de certificat.
Source
- Active Directory Kerberos KDC certificate selection
- Consolidating Windows Active Directory Domain Controller Certificates
- msPKI-Certificate-Name-Flag Attribute sur les templates ADCS
- Configure domain controller certificates
- How to import third-party certification authority (CA) certificates into the Enterprise NTAuth store
- KB5014754: Certificate-based authentication changes on Windows domain controllers