I worked on an authentication service that issued client certificates for mTLS.
Go stack, internal PKI, the usual flow: user enrolls, backend generates an ECDSA P-256
key pair, bundles cert + key into a .p12, user imports it into their browser.
macOS, Firefox, Linux — everything worked. Then someone tested on Windows + Edge.
certutil -importPFX said "SUCCESS". The certificate appeared in the personal
store. But when Edge attempted the mTLS handshake: nothing. No error, just a
TLS handshake failure server-side and a popup that didn't even offer the cert.
The kind of bug that makes you doubt your entire PKI. Except the PKI was fine.
CryptoAPI vs CNG: two worlds, one OS
Windows maintains two key storage providers side by side:
- CryptoAPI / CSP (Cryptographic Service Provider) — the legacy one. RSA, 3DES, SHA-1. "Deprecated" since Vista. Still there in 2026.
- CNG / KSP (Key Storage Provider) — the modern one. ECDSA, AES-GCM, SHA-256+. Where your key needs to land for any post-2010 crypto.
When you import a .p12, Windows must decide which provider gets the private key.
This decision doesn't depend on the key type (ECDSA should go to CNG, right?).
It depends on the key bag attributes in the PKCS#12 container.
If the key bag carries no explicit routing attribute, Windows applies a fallback. And that fallback is the legacy CSP.
Your ECDSA P-256 key lands in a provider that doesn't know what ECDSA is. It's stored, it shows up, but it's unusable for signing.
The container encryption's role
Here's where it gets vicious. The PKCS#12 format (RFC 7292) supports multiple encryption schemes:
- PBES1 with PBE-SHA1-3DES (OID
1.2.840.113549.1.12.1.3) — SHA-1 + 3DES. Year 2000 crypto. - PBES2 with AES-256-CBC (via PKCS#5 v2.1) — SHA-256 + AES. The modern choice.
When you use Go and golang.org/x/crypto/pkcs12, the Modern mode
generates PBES2/AES-256. That's the right crypto choice. Except some versions of
certutil, upon seeing a PBES2 container, adjust their routing heuristic
— and the key ends up in the legacy CSP instead of CNG KSP.
You made the effort to use modern encryption, and that's precisely what breaks the key routing.
The toxic workaround
The "solution" you find everywhere: regenerate the .p12 with PBES1/PBE-SHA1-3DES
and RC2-40 for the cert bag.
# DO NOT DO THIS
openssl pkcs12 -export -legacy -in cert.pem -inkey key.pem -out client.p12
SHA-1, 3DES, RC2-40. You pass your pentest, you fail your audit. Guaranteed finding: "Private key container encrypted with deprecated cryptographic algorithms." RC2-40 is 40 bits of effective security. In 2026, that's plaintext with extra steps.
The real fix: the MS CSP Name attribute
The clean solution: add a PKCS#12 attribute to the key bag that explicitly tells Windows "put this key in CNG KSP".
The attribute is called Microsoft CSP Name, OID 1.3.6.1.4.1.311.17.1.
Despite its historical "CSP" name, when its value is Microsoft Software Key Storage Provider,
Windows routes the key to CNG.
// Conceptual pattern - the idea: add the Microsoft KSP attribute
// to the safeBag containing the private key, before marshaling the .p12.
var msCspNameOID = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 311, 17, 1}
attrs := []pkcs12.Attribute{
{
Type: msCspNameOID,
Value: bmpString("Microsoft Software Key Storage Provider"),
},
}
The bmpString is UTF-16LE null-terminated — because Microsoft uses a different
string encoding than the rest of the PKCS#12 world.
With this attribute in place:
- The container stays encrypted with PBES2/AES-256 (audit-happy)
certutilimports the key into CNG KSP (ECDSA works)- Edge sees the certificate and uses it for the mTLS handshake
Why isn't this documented
I looked. Microsoft's documentation on this is a mix of MSDN pages from 2008 and
blog posts by former MS employees whose pages no longer exist. The OID
1.3.6.1.4.1.311.17.1 is mentioned in Microsoft's PKCS#12 spec but without
explaining the routing mechanism. The "PBES2 → legacy CSP fallback" behavior
is documented nowhere explicitly.
Most enterprise PKIs work around this without knowing it: ADCS templates that force
the KSP server-side, or MDM that handles routing itself. When you generate your
.p12 files from scratch on the application side, you're on your own.
The regression test
You can't easily test Windows import in a Linux CI. But you can validate the
.p12 structure — a structural test rather than behavioral:
func TestP12HasKSPAttribute(t *testing.T) {
p12Bytes := generateTestP12(t)
bags, err := pkcs12.DecodeBags(p12Bytes, "test-password")
if err != nil {
t.Fatal(err)
}
for _, bag := range bags {
if bag.IsKeyBag() {
found := false
for _, attr := range bag.Attributes {
if attr.Type.Equal(msCspNameOID) {
found = true
break
}
}
if !found {
t.Fatal("key bag missing MS KSP attribute " +
"- Windows will route to legacy CSP")
}
}
}
}
The test doesn't validate that Windows does the right thing. It validates that your code produces a container with the right properties for Windows to do the right thing. When behavior depends on a third-party system you don't control, anchor your tests on the structural properties you can verify.
Conclusion
The container format is a security decision. Not just the content — which curve,
which key length — but the packaging. An identical .p12 in content can have
two radically different behaviors depending on its attributes.
"certutil says OK" means nothing. Import succeeds in the sense that bytes are stored. But "stored" and "usable" are two different things when two providers are competing. The legacy workaround is a trap: you trade a functional bug for a security finding. The clean fix exists, it's just less googlable.
This bug made me realize something else: on the same service, the login endpoint had a far more subtle issue. A 50ms timing oracle leaking user existence. That's the subject of the next article.