J'ai travaillé sur un service d'authentification qui délivrait des certificats client en mTLS.
Stack Go, PKI interne, flux classique : l'utilisateur s'enrôle, le backend génère une paire
ECDSA P-256, emballe cert + clé dans un .p12, l'utilisateur l'importe dans
son navigateur. macOS, Firefox, Linux — tout marchait. Puis quelqu'un a testé sur Windows + Edge.
certutil -importPFX disait "SUCCESS". Le certificat apparaissait dans le magasin
personnel. Mais quand Edge tentait le handshake mTLS : rien. Pas d'erreur, juste un
TLS handshake failure côté serveur et un popup qui ne proposait même pas le cert.
Le genre de bug qui te fait douter de ta PKI entière. Sauf que la PKI n'avait rien.
CryptoAPI vs CNG : deux mondes, un seul OS
Windows maintient deux key storage providers en parallèle :
- CryptoAPI / CSP (Cryptographic Service Provider) — le legacy. RSA, 3DES, SHA-1. "Deprecated" depuis Vista. Toujours là en 2026.
- CNG / KSP (Key Storage Provider) — le moderne. ECDSA, AES-GCM, SHA-256+. C'est là que ta clé doit atterrir pour de la crypto post-2010.
Quand tu importes un .p12, Windows doit décider dans quel provider ranger
la clé privée. Cette décision ne dépend pas du type de clé (ECDSA devrait aller dans CNG, non ?).
Elle dépend des attributs du key bag dans le conteneur PKCS#12.
Si le key bag ne porte pas d'attribut explicite de routage, Windows applique un fallback. Et ce fallback, c'est le legacy CSP.
Ta clé ECDSA P-256 atterrit dans un provider qui ne sait pas ce qu'est ECDSA. Elle est stockée, elle apparaît, mais elle est inutilisable pour signer.
Le rôle du chiffrement du conteneur
Ici, ça devient vicieux. Le format PKCS#12 (RFC 7292) permet plusieurs schémas de chiffrement :
- PBES1 avec PBE-SHA1-3DES (OID
1.2.840.113549.1.12.1.3) — SHA-1 + 3DES. Crypto des années 2000. - PBES2 avec AES-256-CBC (via PKCS#5 v2.1) — SHA-256 + AES. Le choix moderne.
Quand tu utilises Go et golang.org/x/crypto/pkcs12, le mode Modern
génère du PBES2/AES-256. C'est le bon choix du point de vue crypto. Sauf que certaines
versions de certutil, en voyant un conteneur PBES2, ajustent leur heuristique
de routage — et la clé atterrit dans le legacy CSP au lieu du CNG KSP.
Tu as fait l'effort d'utiliser du chiffrement moderne, et c'est précisément ça qui casse le routage de la clé.
Le workaround toxique
La "solution" que tu trouves partout : régénérer le .p12 en PBES1/PBE-SHA1-3DES
avec du RC2-40 pour le cert bag.
# NE FAIS PAS CA
openssl pkcs12 -export -legacy -in cert.pem -inkey key.pem -out client.p12
SHA-1, 3DES, RC2-40. Tu passes ton pentest, tu perds ton audit. Finding garanti : "Private key container encrypted with deprecated cryptographic algorithms." RC2-40 c'est 40 bits de sécurité effective. En 2026, c'est du texte clair avec des étapes supplémentaires.
Le vrai fix : l'attribut MS CSP Name
La solution propre : ajouter un attribut PKCS#12 sur le key bag qui dit explicitement à Windows "range cette clé dans le CNG KSP".
L'attribut s'appelle Microsoft CSP Name, OID 1.3.6.1.4.1.311.17.1.
Malgré son nom historique "CSP", quand sa valeur est Microsoft Software Key Storage Provider,
Windows route la clé vers CNG.
// Pattern conceptuel - l'idee : on ajoute l'attribut Microsoft KSP
// sur le safeBag contenant la cle privee, avant de marshaler le .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"),
},
}
Le bmpString c'est de l'UTF-16LE null-terminated — parce que Microsoft utilise
un encodage de chaîne différent du reste du monde PKCS#12.
Avec cet attribut en place :
- Le conteneur reste chiffré en PBES2/AES-256 (audit content)
certutilimporte la clé dans le CNG KSP (ECDSA fonctionne)- Edge voit le certificat et l'utilise pour le handshake mTLS
Pourquoi c'est pas documenté
J'ai cherché. La doc Microsoft sur le sujet est un mélange de pages MSDN circa 2008 et
de blog posts d'employés MS qui n'existent plus. L'OID 1.3.6.1.4.1.311.17.1
est mentionné dans la spec PKCS#12 de Microsoft, mais sans expliquer le mécanisme de routage.
Le comportement "PBES2 → legacy CSP fallback" n'est documenté nulle part explicitement.
La plupart des PKI d'entreprise contournent le problème sans le savoir : templates ADCS
qui forcent le KSP côté serveur, ou MDM qui fait le routage lui-même. Quand tu génères
tes .p12 from scratch côté applicatif, tu es tout seul.
Le test de non-régression
Tu ne peux pas facilement tester l'import Windows dans une CI Linux. Mais tu peux valider
la structure du .p12 — un test structurel plutôt que 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")
}
}
}
}
Le test ne valide pas que Windows fait la bonne chose. Il valide que ton code produit un conteneur qui a les bonnes propriétés pour que Windows fasse la bonne chose. Quand le comportement dépend d'un système tiers que tu ne contrôles pas, ancre tes tests sur les propriétés structurelles que tu peux vérifier.
Conclusion
Le format du conteneur est un choix de sécurité. Pas juste le contenu — quelle courbe,
quelle longueur de clé — mais l'emballage. Un .p12 identique en contenu peut avoir
deux comportements radicalement différents selon ses attributs.
"certutil dit OK" ne veut rien dire. L'import réussit au sens où les octets sont stockés. Mais "stocké" et "utilisable" sont deux choses différentes quand il y a deux providers en compétition. Le workaround legacy est un piège : tu échanges un bug fonctionnel contre un finding de sécurité. Le fix propre existe, il est juste moins googlable.
Ce bug m'a fait réaliser autre chose : sur le même service, le endpoint de login avait un problème bien plus subtil. Un timing oracle de 50ms qui leakait la présence des utilisateurs. C'est le sujet du prochain article.