|
3 | 3 | const https = require('node:https') |
4 | 4 | const { once } = require('node:events') |
5 | 5 | const { test } = require('node:test') |
| 6 | +const { X509Certificate } = require('node:crypto') |
6 | 7 |
|
7 | 8 | const { Agent } = require('undici') |
| 9 | +const selfsigned = require('selfsigned') |
| 10 | +const forge = require('node-forge') |
8 | 11 |
|
9 | 12 | const client = new Agent({ |
10 | 13 | connect: { |
@@ -58,3 +61,132 @@ test('https-pem (generate)', async t => { |
58 | 61 | t.assert.strictEqual(response.statusCode, 200) |
59 | 62 | t.assert.strictEqual(await response.body.text(), 'foo') |
60 | 63 | }) |
| 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