csrkit
v0.1.0
Published
build pkcs#10 certificate signing requests on webcrypto, zero deps
Readme
csrkit
build pkcs#10 certificate signing requests on webcrypto. zero deps.
npm i csrkitwhy
to get a certificate you have to hand the ca a csr, and generating one in
javascript means either shelling out to openssl or pulling in a pki library
with ten dependencies. neither works on cloudflare workers, deno deploy or
anywhere else without node builtins — which is exactly where people now want
to mint their own certificates.
a csr is a small der structure and one signature. crypto.subtle already
does the signature, so the only real work is the encoding.
use
import { createCsr, generateKeys, privateKeyPem } from 'csrkit'
const keys = await generateKeys() // p-256 by default
const csr = await createCsr({
names: ['example.com', 'www.example.com', '*.api.example.com'],
subject: { O: 'Kokomo Games', C: 'RW' },
keys
})
csr.pem // -----BEGIN CERTIFICATE REQUEST-----
csr.der // Uint8Array
csr.base64url // what acme's finalize endpoint wants
await privateKeyPem(keys) // keep this next to the certificateevery name goes into the subjectAltName, which is the only place modern clients look. the common name is set to the first name as well, for anything old that still reads it.
algorithms
await generateKeys('P-256') // default
await generateKeys('P-384')
await generateKeys('RSA-2048')
await generateKeys('RSA-4096')or bring your own CryptoKeyPair from crypto.subtle.generateKey. ECDSA
signatures are re-encoded from webcrypto's r||s into the der SEQUENCE that
x.509 requires — getting that wrong is the usual reason a hand rolled csr is
rejected.
for acme
const csr = await createCsr({ names: order.identifiers.map((i) => i.value), keys })
await fetch(order.finalize, { /* jws */ body: JSON.stringify({ csr: csr.base64url }) })base64url is already unpadded and url safe, which is the exact shape rfc
8555 asks for.
names are checked before anything is signed
a csr with a bad name is rejected by the ca after a round trip, so it is
cheaper to catch it here. these all throw CsrError with code BAD_NAME:
- an empty name, or one over 253 characters
- a label over 63 characters, or an empty label from a double dot
- whitespace or a null byte
- non ascii — punycode it first
- a partial wildcard like
part*.example.com - a wildcard that is not the leftmost label
*.example.com is fine.
correctness
21 tests, and the ones that count run the output through openssl:
openssl req -in out.csr -noout -verify
Certificate request self-signature verify OKthat is asserted for p-256, p-384 and rsa-2048, along with the subject, the
signature algorithm, and every subjectAltName appearing in openssl req
-text. one test flips a byte in the signature and asserts openssl then
rejects it, so the check is proving something. the exported private key is
read back by openssl pkey.
api
createCsr({ names, subject?, keys })→{ der, pem, base64url }generateKeys(algorithm?)→CryptoKeyPairprivateKeyPem(keys)→ pkcs#8 pemtoPem(bytes, label)/toBase64/toBase64Url
subject fields: CN, C, ST, L, O, OU, E.
runtime
needs global crypto.subtle, so node 20+, or any of workers, deno and bun.
license
MIT
