Core/Get-TestCertificate.ps1
|
function Get-TestCertificate { <# .SYNOPSIS Resolves a signing certificate from a thumbprint, a path, or an object .DESCRIPTION Certificates arrive three ways - a thumbprint, a PFX, or an object - and they all end up here, so the rules about what makes a certificate usable live in one place. A certificate is usable for app-only authentication only if it carries a private key that can actually sign. Three things go wrong in practice and all three are checked, because each produces a failure much later and much less clearly: - The public half is installed but the private key is not. Get-ChildItem finds the certificate, everything looks right, and signing throws a null reference. - The certificate has expired, or is not valid yet. Entra, for example, answers AADSTS700027 which names the thumbprint but not the reason. - The private key is held by a provider that will not perform RSA-SHA256, which is what a CNG key marked for key exchange only does. The store search covers CurrentUser then LocalMachine. That order matters: a certificate present in both is almost always the same certificate, and the user store copy is the one whose private key the current process can reach without elevation. .PARAMETER Thumbprint SHA-1 thumbprint, with or without spaces, case-insensitive .PARAMETER Path Path to a PFX file .PARAMETER Password Password protecting the PFX file .PARAMETER Certificate An already-loaded certificate object, returned as-is after validation .OUTPUTS System.Security.Cryptography.X509Certificates.X509Certificate2 .EXAMPLE PS> Get-TestCertificate -Thumbprint '9FC871A73BCE94325FD5E05DA33CBFF651E0A467' DESCRIPTION: Finds the certificate in the current user's personal store OUTPUT: The X509Certificate2, with its private key USE CASE: The normal path, called by Connect-TestEnvironment .NOTES Author: Jeffrey Stuhr Blog: https://www.techbyjeff.net LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/ #> [CmdletBinding(DefaultParameterSetName = 'Thumbprint')] [OutputType([System.Security.Cryptography.X509Certificates.X509Certificate2])] param( [Parameter(Mandatory = $true, ParameterSetName = 'Thumbprint')] [string]$Thumbprint, [Parameter(Mandatory = $true, ParameterSetName = 'Path')] [string]$Path, [Parameter(ParameterSetName = 'Path')] [securestring]$Password, [Parameter(Mandatory = $true, ParameterSetName = 'Object')] [System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate ) $resolved = $null switch ($PSCmdlet.ParameterSetName) { 'Object' { $resolved = $Certificate } 'Path' { if (-not (Test-Path -LiteralPath $Path)) { Write-Error "Certificate file not found: $Path" -ErrorAction Stop return } $full = (Resolve-Path -LiteralPath $Path).ProviderPath try { # X509KeyStorageFlags.Exportable is deliberately NOT set. The key only has to # sign, and a key loaded exportable can be written back out of the process by # anything that later gets hold of the object. $resolved = if ($Password) { [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($full, $Password) } else { [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($full) } } catch { throw (New-Object System.Exception("Could not load the certificate at ${full}: $($_.Exception.Message)", $_.Exception)) } } 'Thumbprint' { # Portals print thumbprints with spaces and the certificate store # displays them uppercase; accept whatever was pasted. $wanted = ($Thumbprint -replace '[^0-9A-Fa-f]', '').ToUpperInvariant() if ($wanted.Length -ne 40) { Write-Error "A SHA-1 thumbprint is 40 hex characters; got $($wanted.Length) after stripping separators." -ErrorAction Stop return } foreach ($storePath in 'Cert:\CurrentUser\My', 'Cert:\LocalMachine\My') { $found = Get-ChildItem -Path $storePath -ErrorAction SilentlyContinue | Where-Object { $_.Thumbprint -eq $wanted } | Select-Object -First 1 if ($found) { Write-Verbose "Found certificate $wanted in $storePath" $resolved = $found break } } if (-not $resolved) { Write-Error ("No certificate with thumbprint $wanted in Cert:\CurrentUser\My or " + "Cert:\LocalMachine\My. Import the PFX, or pass -CertificatePath instead.") -ErrorAction Stop return } } } if (-not $resolved.HasPrivateKey) { Write-Error ("Certificate $($resolved.Thumbprint) has no private key. Only the public half is " + "installed, which is enough to verify a signature and not enough to make one. Import the " + "PFX rather than the CER.") -ErrorAction Stop return } $now = [DateTime]::Now if ($now -gt $resolved.NotAfter) { Write-Error ("Certificate $($resolved.Thumbprint) expired on $($resolved.NotAfter). The directory will " + "reject an assertion signed with it (AADSTS700027).") -ErrorAction Stop return } if ($now -lt $resolved.NotBefore) { Write-Error ("Certificate $($resolved.Thumbprint) is not valid until $($resolved.NotBefore).") -ErrorAction Stop return } # Ask for the RSA key now rather than at signing time. A CNG key created for key exchange # only returns here and then fails inside SignData, by which point the error names a # cryptographic operation rather than the certificate that cannot do it. $rsa = $null try { $rsa = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($resolved) } catch { Write-Verbose "GetRSAPrivateKey threw: $($_.Exception.Message)" } if (-not $rsa) { Write-Error ("Certificate $($resolved.Thumbprint) has a private key that is not RSA, or is held by " + "a provider this process cannot use for signing. Client assertions must be RS256.") -ErrorAction Stop return } return $resolved } |