Skip to content

Commit 6d42161

Browse files
fix: retry certificate generation on serial numbers OpenSSL rejects (#2)
1 parent 3e06f39 commit 6d42161

5 files changed

Lines changed: 213 additions & 6 deletions

File tree

generate.js

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
'use strict'
2+
3+
const { X509Certificate } = require('node:crypto')
4+
const selfsigned = require('selfsigned')
5+
6+
// `selfsigned` derives the certificate serial number from 9 random bytes and
7+
// runs them through its own `toPositiveHex()`, which clears the sign bit but
8+
// does not re-minimise the resulting DER INTEGER. Roughly 1 in 65536 draws end
9+
// up with two redundant leading zero bytes, and node-forge's encoder strips
10+
// only one of them (see the "should all leading bytes be stripped vs just one?"
11+
// TODO in its `asn1.js`), so the serial goes out as a positive INTEGER with
12+
// illegal padding.
13+
//
14+
// node-forge's own parser accepts that encoding, so the
15+
// `verifyCertificateChain()` check `selfsigned` runs before returning passes
16+
// and the pair looks fine. OpenSSL rejects it, so the certificate only blows
17+
// up later, as ERR_OSSL_ASN1_ILLEGAL_PADDING from the middle of a TLS
18+
// handshake. That made every consumer building a server from a freshly
19+
// generated pair intermittently fail (nodejs/undici#5245).
20+
//
21+
// Each serial number is drawn independently, so generating again is enough to
22+
// get past it: three attempts bring the odds down to about 1 in 2.8e14. All of
23+
// this can go away once `selfsigned` emits minimally encoded serial numbers.
24+
const ATTEMPTS = 3
25+
26+
// Returns the error OpenSSL refused the certificate with, or `null` if it
27+
// loads. node-forge parsing it successfully says nothing about OpenSSL.
28+
function loadError (cert) {
29+
try {
30+
new X509Certificate(cert) // eslint-disable-line no-new
31+
return null
32+
} catch (err) {
33+
return err
34+
}
35+
}
36+
37+
function unusable (cause) {
38+
return new Error(
39+
`could not generate a certificate OpenSSL can load in ${ATTEMPTS} attempts`,
40+
{ cause }
41+
)
42+
}
43+
44+
function generateSync (attrs, opts) {
45+
let lastError
46+
47+
for (let i = 0; i < ATTEMPTS; i++) {
48+
const pems = selfsigned.generate(attrs, opts)
49+
const err = loadError(pems.cert)
50+
51+
if (err === null) return pems
52+
53+
lastError = err
54+
}
55+
56+
throw unusable(lastError)
57+
}
58+
59+
function generate (attrs, opts, done) {
60+
let remaining = ATTEMPTS
61+
62+
selfsigned.generate(attrs, opts, function onPems (err, pems) {
63+
if (err) return done(err)
64+
65+
const loadErr = loadError(pems.cert)
66+
67+
if (loadErr === null) return done(null, pems)
68+
if (--remaining > 0) return selfsigned.generate(attrs, opts, onPems)
69+
70+
done(unusable(loadErr))
71+
})
72+
}
73+
74+
module.exports = { generate, generateSync }

index.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
const path = require('node:path')
44
const fs = require('node:fs')
55

6-
let selfsigned
6+
let generator
77
const keyPath = path.join(__dirname, 'key.pem')
88
const certPath = path.join(__dirname, 'cert.pem')
99

@@ -25,7 +25,7 @@ function generate ({ attr, opts } = { attr: [], opts: null }, done) {
2525
return promise
2626
}
2727

28-
if (!selfsigned) selfsigned = require('selfsigned')
28+
if (!generator) generator = require('./generate')
2929

30-
selfsigned.generate(attr, opts, done)
30+
generator.generate(attr, opts, done)
3131
}

install.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,15 @@
22

33
const fs = require('node:fs')
44
const path = require('node:path')
5-
const selfsigned = require('selfsigned')
5+
const { generateSync } = require('./generate')
66

77
const nodeVersion = Number(process.versions.node.split('.')[0])
88
const pems =
99
// Due to new version of openssl, we need to use a larger key size
1010
// for node 24 and above, otherwise the default key size is sufficient
1111
nodeVersion > 22
12-
? selfsigned.generate({}, { keySize: 2048 })
13-
: selfsigned.generate()
12+
? generateSync({}, { keySize: 2048 })
13+
: generateSync()
1414

1515
fs.writeFileSync(path.join(__dirname, 'key.pem'), pems.private)
1616
fs.writeFileSync(path.join(__dirname, 'cert.pem'), pems.cert)

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
},
5454
"devDependencies": {
5555
"@types/node": "^24.2.0",
56+
"node-forge": "^1.3.1",
5657
"snazzy": "^9.0.0",
5758
"standard": "^17.0.0",
5859
"standard-version": "^9.5.0",

tests/index.js

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,11 @@
33
const https = require('node:https')
44
const { once } = require('node:events')
55
const { test } = require('node:test')
6+
const { X509Certificate } = require('node:crypto')
67

78
const { Agent } = require('undici')
9+
const selfsigned = require('selfsigned')
10+
const forge = require('node-forge')
811

912
const client = new Agent({
1013
connect: {
@@ -58,3 +61,132 @@ test('https-pem (generate)', async t => {
5861
t.assert.strictEqual(response.statusCode, 200)
5962
t.assert.strictEqual(await response.body.text(), 'foo')
6063
})
64+
65+
// `selfsigned` clears the sign bit of the 9 random bytes it draws for the
66+
// serial number without re-minimising the DER INTEGER, and node-forge strips
67+
// only one of the redundant leading zero bytes. These 9 bytes are one of the
68+
// roughly 1 in 65536 draws that come out as `00 00 01 ...` and leave a
69+
// positive INTEGER with illegal padding, which OpenSSL refuses to load.
70+
const ILLEGAL_PADDING_SEED = '\x80\x00\x01\x02\x03\x04\x05\x06\x07'
71+
72+
// Turns the next `attempts` serial numbers into the pathological one above.
73+
// Returns a getter for how many of them were actually drawn, so a test can
74+
// tell whether it exercised the bad path at all.
75+
function forceIllegalSerialNumber (t, attempts = 1) {
76+
const getBytesSync = forge.random.getBytesSync
77+
let remaining = attempts
78+
79+
forge.random.getBytesSync = function (count) {
80+
// 9 bytes are only ever drawn for the serial number
81+
if (count === 9 && remaining > 0) {
82+
remaining--
83+
return ILLEGAL_PADDING_SEED
84+
}
85+
86+
return getBytesSync.call(this, count)
87+
}
88+
t.after(() => { forge.random.getBytesSync = getBytesSync })
89+
90+
return () => attempts - remaining
91+
}
92+
93+
test('selfsigned on its own produces a certificate OpenSSL rejects', async t => {
94+
const drawn = forceIllegalSerialNumber(t)
95+
96+
const { cert } = await new Promise((resolve, reject) => {
97+
selfsigned.generate(undefined, { keySize: 1024 }, (err, pems) => {
98+
if (err) return reject(err)
99+
resolve(pems)
100+
})
101+
})
102+
t.assert.strictEqual(drawn(), 1, 'the serial number seed was not drawn')
103+
104+
let err
105+
try {
106+
new X509Certificate(cert) // eslint-disable-line no-new
107+
} catch (e) {
108+
err = e
109+
}
110+
111+
if (err === undefined) {
112+
// Nothing left to work around: `generate.js` can go away and `index.js`
113+
// can call `selfsigned.generate()` directly again.
114+
t.diagnostic('selfsigned no longer emits non-minimal serial numbers')
115+
return
116+
}
117+
118+
t.assert.strictEqual(err.code, 'ERR_OSSL_ASN1_ILLEGAL_PADDING')
119+
})
120+
121+
test('https-pem (generate) draws a new serial number when OpenSSL rejects one', async t => {
122+
const pem = require('..')
123+
const drawn = forceIllegalSerialNumber(t)
124+
125+
const { key, cert } = await pem.generate({ opts: { keySize: 1024 } })
126+
t.assert.strictEqual(drawn(), 1, 'the serial number seed was not drawn')
127+
128+
t.assert.ok(key)
129+
// A minimally encoded positive INTEGER never starts with a zero byte
130+
const parsed = new X509Certificate(cert)
131+
t.assert.doesNotMatch(parsed.serialNumber, /^00/)
132+
})
133+
134+
test('https-pem (generate) survives consecutive rejected serial numbers', async t => {
135+
const pem = require('..')
136+
const drawn = forceIllegalSerialNumber(t, 2)
137+
138+
const { cert } = await pem.generate({ opts: { keySize: 1024 } })
139+
t.assert.strictEqual(drawn(), 2, 'not every serial number seed was drawn')
140+
141+
new X509Certificate(cert) // eslint-disable-line no-new
142+
})
143+
144+
test('https-pem (generate) gives up instead of returning an unusable pair', async t => {
145+
const pem = require('..')
146+
forceIllegalSerialNumber(t, Infinity)
147+
148+
await t.assert.rejects(
149+
pem.generate({ opts: { keySize: 1024 } }),
150+
err => {
151+
t.assert.match(err.message, /could not generate a certificate OpenSSL can load/)
152+
t.assert.strictEqual(err.cause.code, 'ERR_OSSL_ASN1_ILLEGAL_PADDING')
153+
return true
154+
}
155+
)
156+
})
157+
158+
test('https-pem (default) ships a certificate OpenSSL can load', t => {
159+
const pem = require('..')
160+
161+
t.assert.ok(pem.key)
162+
const cert = new X509Certificate(pem.cert)
163+
t.assert.doesNotMatch(cert.serialNumber, /^00/)
164+
})
165+
166+
test('https-pem (generate) serves over TLS past a rejected serial number', async t => {
167+
const pem = require('..')
168+
forceIllegalSerialNumber(t)
169+
170+
const pems = await pem.generate({
171+
attr: [{ name: 'commonName', value: 'localhost' }],
172+
opts: { keySize: 2048 }
173+
})
174+
175+
const server = https.createServer(pems, function (req, res) {
176+
res.end('foo')
177+
})
178+
179+
server.listen()
180+
await once(server, 'listening')
181+
t.after(() => server.close())
182+
183+
const response = await client.request({
184+
origin: `https://localhost:${server.address().port}`,
185+
path: '/',
186+
method: 'GET'
187+
})
188+
189+
t.plan(2)
190+
t.assert.strictEqual(response.statusCode, 200)
191+
t.assert.strictEqual(await response.body.text(), 'foo')
192+
})

0 commit comments

Comments
 (0)