import "reflect-metadata"; // @peculiar/x509 2.x needs a Reflect polyfill, imported firstimport * as x509 from "@peculiar/x509"; // built on WebCrypto; works in browsers unchanged async function createSelfSigned() { const algorithm = { name: "ECDSA", namedCurve: "P-256", hash: "SHA-256" }; const keys = await crypto.subtle.generateKey(algorithm, true, ["sign", "verify"]); const notBefore = new Date(); const cert = await x509.X509CertificateGenerator.createSelfSigned({ name: "CN=localhost", // no serialNumber: a random positive 128-bit one is generated notBefore, notAfter: new Date(notBefore.getTime() + 365 * 86_400_000), keys, signingAlgorithm: algorithm, extensions: [ new x509.SubjectAlternativeNameExtension([{ type: "dns", value: "localhost" }, { type: "ip", value: "127.0.0.1" }]), new x509.BasicConstraintsExtension(false, undefined, true), new x509.KeyUsagesExtension(x509.KeyUsageFlags.digitalSignature, true), new x509.ExtendedKeyUsageExtension([x509.ExtendedKeyUsage.serverAuth]), ], }); const pkcs8 = await crypto.subtle.exportKey("pkcs8", keys.privateKey); return { certPem: cert.toString("pem"), keyPem: x509.PemConverter.encode(pkcs8, "PRIVATE KEY") };}How it works
crypto.subtle.generateKeymakes a P-256 key pair; the second argument,true, makes it extractable so that the private key can be saved.X509CertificateGenerator.createSelfSignedsigns with the private key and usesnameas subject and issuer;notAfterisnotBeforeplus 365 days in milliseconds.- The extensions are passed as objects; their last argument,
true, marks basicConstraints and keyUsage as critical. exportKey("pkcs8")withPemConverter.encode(…, "PRIVATE KEY")gives key.pem andcert.toString("pem")gives cert.pem — ready for a download link or a local development server.
Gotchas
- WebCrypto's ECDSA
signreturns the raw r‖s pair — 64 bytes for P-256 — while X.509 needs a DER-encoded signature; @peculiar/x509 converts it, which is one reason not to assemble certificates by hand. - A key generated with
extractable: falsecannot be saved:exportKeyfails with anInvalidAccessError(key is not extractablein Node.js). That fits a certificate that only lives in this browser, with theCryptoKeykept in IndexedDB. serialNumberis a hex string. Leaving it out, or passing an all-zero value, makes the library pick 16 random bytes; a fixed value such as"01"is used as is, and RFC 5280 requires serials to be unique per issuer.- The library needs a Reflect polyfill: import
reflect-metadatabefore@peculiar/x509, or it throws at load time withtsyringe requires a reflect polyfill.