Windows Service Certificates and Private Keys
Windows Service Certificates and Private Keys
Certificate not found and private key unavailable fail at different layers. Confirm the service's real identity and exact store before testing an operation with the associated key. An administrator seeing the certificate in a management console proves neither.
The examples use Windows Server 2022 Datacenter and PowerShell 5.1.20348.5386. First compare CurrentUser/My with LocalMachine/My under SYSTEM, then inspect an ordinary signer's access to a different machine certificate. Store selection and key authorization are separate checks.
1. Locate the failure
| Result | First checks |
|---|---|
| Found=0 in the chosen store | Real identity, StoreLocation/StoreName, thumbprint, filtering |
| Found=1, HasPrivateKey=False | Public-only import versus correctly deployed private key |
| HasPrivateKey=True, open/sign fails | Association, provider, key existence, authorization, execution context |
| Local signing verifies, TLS fails | EKU, dates, chain, remote trust, protocols, application configuration |
HasPrivateKey describes an association, not successful key use by any identity. Conversely, Invalid provider type specified alone is not sufficient reason to recreate a key; inspect the provider and controlled comparison.
2. Certificate stores
Lookup is scoped by location and store name. Opening CurrentUser/My does not automatically search LocalMachine/My. CurrentUser follows the application's identity; SYSTEM's user store is not the signed-in administrator's.
The first lab used a short-lived self-signed RSA 2048 certificate, thumbprint 2A44F88401A4001547AD6B8F834E40FB01C8446A:
| Phase | Store opened | Found | HasPrivateKey | Signature/local verification |
|---|---|---|---|---|
| Baseline | SYSTEM's CurrentUser/My | 1 | True | 256 bytes / True |
| Fault | LocalMachine/My | 0 | — | CertificateNotFound |
| Control | Original CurrentUser/My | 1 | True | 256 bytes / True |
| Added to machine store | LocalMachine/My | 1 | True | 256 bytes / True |
Accepted=True in the function meant only that a fixed payload was signed and locally verified. It was not a remote HTTP acceptance or TLS client-authentication test. The certificate remained usable; the lookup scope changed.
Capture identity and candidates inside the service context rather than substituting the deployment account's view:
[Security.Principal.WindowsIdentity]::GetCurrent().Name
[Security.Principal.WindowsIdentity]::GetCurrent().User.Value
Get-ChildItem Cert:\CurrentUser\My,Cert:\LocalMachine\My |
Select-Object PSParentPath,Subject,Thumbprint,HasPrivateKey,NotAfterServices can use identity-managed user stores or machine stores according to supported configuration/deployment. Neither all services must use LocalMachine nor all interactive applications must use CurrentUser is a valid blanket rule. Machine-store deployment does not automatically authorize every service identity to use its private key.
3. PFX deployment
The original experiment added the associated certificate object to LocalMachine/My. SYSTEM still resolved the key and signed. This same-machine/SYSTEM observation does not prove that X509Store.Add universally migrates user keys into machine storage, or that copying a .cer carries a private key.
Use Import-PfxCertificate for PFX deployment and Import-Certificate for public certificate files. Before importing, confirm the application reads LocalMachine/My, verify the PFX source, and preserve existing certificate/key state:
$pfx = Read-Host 'PFX file path'
$password = Read-Host 'PFX password' -AsSecureString
$imported = Import-PfxCertificate -FilePath $pfx `
-CertStoreLocation 'Cert:\LocalMachine\My' -Password $password
$imported | Select-Object Subject,Thumbprint,HasPrivateKey,NotAfter
Remove-Variable passwordDo not add Exportable by default or embed the PFX password. Import, persistence, and exportability are deployment choices followed by application-identity validation. Hardware keys may not support a PFX migration at all. See Import-PfxCertificate.
4. Private-key failure
The second certificate lived in LocalMachine/My, thumbprint 931116F267D3BE2C676C7EEF07759DDE1A3EC0DE, using Microsoft Software Key Storage Provider. Ordinary local account KBEW06Signer produced:
| Phase | Target key-file authorization | Certificate/association | Result |
|---|---|---|---|
| Baseline | User Read Allow present | Found, HasPrivateKey=True | 256-byte signature |
| Fault | Only that Allow ACE removed | Still found, still True | GetRSAPrivateKey failed |
| Repair | Prior ACL restored | Same thumbprint/identity | 256 bytes, verification True |
The actual error was:
Exception calling "GetRSAPrivateKey" with "1" argument(s): "Invalid provider type specified.
"SYSTEM and Administrators retained management access, and an administrator could use the key. After restoration, public-key checks verified both baseline and repaired signatures. The controlled change localizes this failure to the ordinary account's key access, not trust-chain deployment or algorithm replacement.
The outer scheduled task still reported LastTaskResult=0 because the script caught the signing exception, recorded it, and exited normally. Propagate business failure to the scheduler/monitoring rather than treating task success as signature success.
5. Locate the key file
Microsoft software-provider machine/shared CNG keys and legacy CSP MachineKeys use different paths. Third-party, smart-card, and hardware providers require their supported management mechanism, not a guessed filesystem ACL. See the CNG storage model.
In administrator Windows PowerShell, follow the certificate association to its provider/UniqueName before reading one file's ACL:
$ErrorActionPreference='Stop'
$Thumbprint='931116F267D3BE2C676C7EEF07759DDE1A3EC0DE'
$cert=Get-Item "Cert:\LocalMachine\My\$Thumbprint"
$cert|Select-Object Subject,Thumbprint,HasPrivateKey,NotAfter
$rsa=[Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($cert)
try {
if ($rsa -isnot [Security.Cryptography.RSACng]) { throw 'Expected an RSA CNG key' }
$key=$rsa.Key
if (-not $key.IsMachineKey) { throw 'Expected a machine key' }
if ($key.Provider.Provider -ne 'Microsoft Software Key Storage Provider') {
throw 'Use the actual provider management tool'
}
$KeyPath=Join-Path "$env:ProgramData\Microsoft\Crypto\Keys" $key.UniqueName
if (-not (Test-Path -LiteralPath $KeyPath -PathType Leaf)) { throw 'Key file missing' }
[pscustomobject]@{Provider=$key.Provider.Provider;UniqueName=$key.UniqueName;KeyPath=$KeyPath}
Get-Acl -LiteralPath $KeyPath|Select-Object Owner,Sddl,Access
} finally {
if ($rsa) { $rsa.Dispose() }
}The code explicitly requires a machine RSACng key from Microsoft Software Key Storage Provider. Stop and use the appropriate mechanism for other types. Do not choose the newest file or apply one path to user, CSP, and CNG keys indiscriminately. If administrators cannot open it either, investigate key deployment, association, provider, and existence before adding signer permissions.
6. Grant key access
Log the actual signing identity. A service, task, IIS pool, deployment user, and administrator shell can all differ.
Using the confirmed KeyPath, this adds Read for the local test signer and saves the DACL without resetting the directory or other keys:
$Account="$env:COMPUTERNAME\KBEW06Signer"
$BackupFile=Join-Path $env:TEMP ('private-key-acl-'+[guid]::NewGuid().ToString('N')+'.sddl')
$identity=New-Object Security.Principal.NTAccount $Account
$sid=$identity.Translate([Security.Principal.SecurityIdentifier])
$acl=Get-Acl -LiteralPath $KeyPath
$sections=[Security.AccessControl.AccessControlSections]::Access
$before=$acl.GetSecurityDescriptorSddlForm($sections)
if (Test-Path -LiteralPath $BackupFile) { throw 'Backup already exists' }
$before|Set-Content -LiteralPath $BackupFile -Encoding ASCII
$rule=New-Object Security.AccessControl.FileSystemAccessRule $sid,'Read','Allow'
$acl.AddAccessRule($rule)
Set-Acl -LiteralPath $KeyPath -AclObject $acl
Get-Acl -LiteralPath $KeyPath|Select-Object Sddl,Access
$BackupFileMove the backup to an administrator-controlled location. Inspect equivalent pre-existing ACEs and user/group Deny entries before changing anything; existing grants are not newly introduced experiment state. This grant enables key use, so its scope must match an identity authorized to sign. Do not grant Everyone or add the account to Administrators to bypass the error.
Renewal can associate a different private key. Validate authorization for the new certificate/thumbprint/key rather than assuming the old deployment proves renewal works.
7. Sign and verify
Avoid retaining previously opened key handles. Run this fixed-thumbprint/payload test through the original service/task identity:
$ErrorActionPreference='Stop'
$Thumbprint='931116F267D3BE2C676C7EEF07759DDE1A3EC0DE'
$cert=Get-Item "Cert:\LocalMachine\My\$Thumbprint"
$rsa=$null
$pub=$null
try {
$rsa=[Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($cert)
if (-not $rsa) { throw 'RSA private key unavailable' }
$data=[Text.Encoding]::UTF8.GetBytes('KBEW06 payload')
$hash=[Security.Cryptography.HashAlgorithmName]::SHA256
$padding=[Security.Cryptography.RSASignaturePadding]::Pkcs1
$sig=$rsa.SignData($data,$hash,$padding)
$pub=[Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPublicKey($cert)
$verified=$pub.VerifyData($data,$sig,$hash,$padding)
if (-not $verified) { throw 'Signature verification failed' }
[pscustomobject]@{
Identity=[Security.Principal.WindowsIdentity]::GetCurrent().Name
Thumbprint=$cert.Thumbprint
HasPrivateKey=$cert.HasPrivateKey
SignatureBytes=$sig.Length
Verified=$verified
}
} finally {
if ($rsa) { $rsa.Dispose() }
if ($pub) { $pub.Dispose() }
}The repaired control returned SignatureBytes=256 and Verified=True. The length reflects RSA 2048, not a universal algorithm criterion. This code covers RSA/SHA256/PKCS#1 v1.5; ECDSA needs its corresponding API.
For TLS client authentication, repeat the original request and inspect selection, remote acceptance, and chain validation. Producing a signature does not establish remote trust. Root imports, disabling validation, and widening key ACLs affect different layers and are not substitutes.
8. Restore authorization
If no concurrent changes occurred and the path still refers to the same key, restore the saved DACL:
$acl=Get-Acl -LiteralPath $KeyPath
$saved=(Get-Content -LiteralPath $BackupFile -Raw).Trim()
$acl.SetSecurityDescriptorSddlForm($saved,[Security.AccessControl.AccessControlSections]::Access)
Set-Acl -LiteralPath $KeyPath -AclObject $acl
$restored=(Get-Acl -LiteralPath $KeyPath).GetSecurityDescriptorSddlForm([Security.AccessControl.AccessControlSections]::Access)
if ($restored -ne $saved) { throw 'DACL restoration mismatch' }
$restoredThis reverses DACL changes since the snapshot and must not overwrite another operator's new grant. Windows can normalize descriptors: the original first restoration encountered a CREATOR OWNER-related string mismatch. Preserve evidence and compare SIDs, allow/deny, rights, inheritance, and effective access instead of repeatedly resetting the whole ACL.
Clean up test certificates by exact thumbprint. Delete associated keys only when created by this experiment and confirmed unused elsewhere; several certificates can reference one key. Removing a store copy is not blanket authorization to DeleteKey. Production repair usually corrects store selection or authorization while preserving certificates and evidence.
