import "reflect-metadata"; // @peculiar/x509 2.x needs a Reflect polyfill, imported firstimport * as x509 from "@peculiar/x509"; // built on WebCrypto, runs in the browser unchanged export async function extractPublicKey(certificatePem) { const cert = new x509.X509Certificate(certificatePem); const spkiPem = cert.publicKey.toString("pem"); // SubjectPublicKeyInfo, "BEGIN PUBLIC KEY" const spkiDer = cert.publicKey.rawData; // the same SPKI as an ArrayBuffer const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", spkiDer)); const fingerprint = Array.from(digest, (b) => b.toString(16).padStart(2, "0")).join(":"); const pin = btoa(String.fromCharCode(...digest)); // A CryptoKey for crypto.subtle.verify: the certificate supplies the algorithm and curve const key = await cert.publicKey.export(); return { spkiPem, fingerprint, pin, algorithm: key.algorithm };} How it works
new x509.X509Certificate(pem)parses the certificate;cert.publicKey.rawDatais the SPKI DER andtoString("pem")theBEGIN PUBLIC KEYblock.crypto.subtle.digest("SHA-256", spki)gives the key fingerprint (45:ca:a7:e3:…:7b:5f) and, base64-encoded, the SPKI pin (Rcqn43J0…e18=).cert.publicKey.export()imports the key with the algorithm the certificate names (ECDSA,P-256), so you need not pass algorithm parameters yourself.- The run checks the PEM against
openssl pkey -in key.pem -pubout: identical, so the private key belongs to the certificate.
Gotchas
crypto.subtle.exportKey("raw", key)on an EC key returns the bare 65-byte point, not the SPKI; export"spki"for fingerprints, pins and PEM.importKey("spki", …)itself needs the algorithm and curve; the certificate is what tells you them, which is whycert.publicKey.export()is the simpler path.btoa(String.fromCharCode(...digest))is fine for a 32-byte digest, but not for large buffers — the spread hits the argument limit.