Skip to content

Commit bb9ca3f

Browse files
committed
Fix OCSP stapling on public certs
1 parent ab3145e commit bb9ca3f

7 files changed

Lines changed: 271 additions & 35 deletions

File tree

src/endpoints/tls/cert-modes.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export const revoked: TlsEndpoint = {
2525
},
2626
meta: {
2727
path: 'revoked',
28-
description: 'Serves a revoked TLS certificate (reported via OCSP).',
28+
description: 'Serves a revoked TLS certificate (by stapled OCSP for local root, or CRL for public root).',
2929
examples: ['https://revoked.testserver.host/'],
3030
group: tlsCertificateModes
3131
}

src/tls-certificates/local-ca.ts

Lines changed: 41 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,10 @@ export class LocalCA {
260260

261261
private certInMemoryCache: { [domain: string]: LocallyGeneratedCertificate | undefined } = {};
262262

263+
// A self-signed cert is its own issuer, so it can answer OCSP for itself - but only while we
264+
// still hold its key. Kept by serial, and dropped alongside the cached cert it belongs to.
265+
private selfSignedCerts = new Map<string, { cert: x509.X509Certificate, key: CryptoKey }>();
266+
263267
private intermediateCA?: Promise<CaMaterial>;
264268

265269
private clientAuthCA?: Promise<CaMaterial>;
@@ -640,26 +644,48 @@ export class LocalCA {
640644

641645
this.certInMemoryCache[cacheKey] = generatedCertificate;
642646

647+
if (options.selfSigned) {
648+
this.selfSignedCerts.set(certificate.serialNumber, {
649+
cert: certificate,
650+
key: leafKeyPair.privateKey as CryptoKey
651+
});
652+
}
653+
643654
setTimeout(() => {
644655
delete this.certInMemoryCache[cacheKey];
656+
this.selfSignedCerts.delete(certificate.serialNumber);
645657
}, 1000 * 60 * 60 * 24).unref();
646658

647659
return generatedCertificate;
648660
}
649661

650-
// An OCSP response must be signed by (and its CertID derived from) the cert's actual
651-
// issuer. Non-self-signed leaves are issued by the intermediate, so resolve that;
652-
// fall back to the root for anything else.
653-
private async resolveOcspIssuer(
662+
// An OCSP response must be signed by (and its CertID derived from) the cert's actual issuer,
663+
// so we can only answer for certificates issued by a key we hold: one of our own CAs, or a
664+
// self-signed cert we generated, which issued itself. Anything else (notably real ACME certs)
665+
// gets no answer, rather than one naming an issuer unrelated to the served chain.
666+
private async findOcspIssuer(
654667
cert: x509.X509Certificate
655-
): Promise<{ cert: x509.X509Certificate, key: CryptoKey }> {
656-
if (this.intermediateCA) {
657-
const intermediate = await this.intermediateCA;
658-
if (cert.issuer === intermediate.cert.subject) {
659-
return { cert: intermediate.cert, key: intermediate.key };
660-
}
668+
): Promise<{ cert: x509.X509Certificate, key: CryptoKey } | undefined> {
669+
const selfSigned = this.selfSignedCerts.get(cert.serialNumber);
670+
if (selfSigned?.cert.equal(cert)) return selfSigned;
671+
672+
const candidates = [
673+
...(this.intermediateCA ? [await this.intermediateCA] : []),
674+
{ cert: this.caCert, key: this.caKey }
675+
];
676+
677+
for (const candidate of candidates) {
678+
if (cert.issuer !== candidate.cert.subject) continue;
679+
680+
const signedByCandidate = await cert.verify({
681+
publicKey: candidate.cert.publicKey,
682+
signatureOnly: true
683+
}).catch(() => false);
684+
685+
if (signedByCandidate) return { cert: candidate.cert, key: candidate.key };
661686
}
662-
return { cert: this.caCert, key: this.caKey };
687+
688+
return undefined;
663689
}
664690

665691
async getOcspResponse(certDer: Buffer): Promise<Buffer | null> {
@@ -672,7 +698,10 @@ export class LocalCA {
672698
return null;
673699
}
674700

675-
const { cert: issuerCert, key: issuerKey } = await this.resolveOcspIssuer(cert);
701+
const issuer = await this.findOcspIssuer(cert);
702+
if (!issuer) return null;
703+
704+
const { cert: issuerCert, key: issuerKey } = issuer;
676705

677706
if (isRevokedCert(cert)) {
678707
// Certificate is revoked - return revoked OCSP response

src/tls-certificates/ocsp.ts

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -38,19 +38,29 @@ async function sha1Hash(data: BufferSource): Promise<ArrayBuffer> {
3838
return await crypto.subtle.digest('SHA-1', data);
3939
}
4040

41+
// The CertID serial is a DER INTEGER, so a serial whose leading byte has the high bit set
42+
// needs a zero pad - without it the response identifies a negative, i.e. different, serial.
43+
function serialNumberBytes(cert: x509.X509Certificate): Uint8Array {
44+
const serialHex = cert.serialNumber.replace(/\s/g, '');
45+
const paddedHex = serialHex.length % 2 === 0 ? serialHex : `0${serialHex}`;
46+
47+
const bytes = Uint8Array.from(
48+
paddedHex.match(/.{2}/g)!.map(byte => parseInt(byte, 16))
49+
);
50+
51+
if ((bytes[0]! & 0x80) === 0) return bytes;
52+
53+
const signedBytes = new Uint8Array(bytes.length + 1);
54+
signedBytes.set(bytes, 1);
55+
return signedBytes;
56+
}
57+
4158
function createCertId(
4259
cert: x509.X509Certificate,
43-
issuerCert: x509.X509Certificate,
4460
issuerNameHash: ArrayBuffer,
4561
issuerKeyHash: ArrayBuffer
4662
): asn1Ocsp.CertID {
47-
// Get serial number as hex string and convert to bytes
48-
const serialHex = cert.serialNumber;
49-
// Remove any spaces and ensure even length
50-
const cleanSerial = serialHex.replace(/\s/g, '');
51-
const serialBytes = new Uint8Array(
52-
cleanSerial.match(/.{1,2}/g)!.map(byte => parseInt(byte, 16))
53-
);
63+
const serialBytes = serialNumberBytes(cert);
5464

5565
return new asn1Ocsp.CertID({
5666
hashAlgorithm: new asn1X509.AlgorithmIdentifier({
@@ -90,7 +100,7 @@ async function createSingleResponse(
90100
const thisUpdate = options.thisUpdate || new Date();
91101

92102
const singleResponse = new asn1Ocsp.SingleResponse({
93-
certID: createCertId(options.cert, options.issuerCert, issuerNameHash, issuerKeyHash),
103+
certID: createCertId(options.cert, issuerNameHash, issuerKeyHash),
94104
certStatus: createCertStatus(options.status, options.revocationTime, options.revocationReason),
95105
thisUpdate
96106
});

src/tls-handler.ts

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,6 @@ import { PROXY_PROTOCOL } from './proxy-protocol.js';
1616
import { TLS_CLIENT_HELLO } from './tls-client-hello.js';
1717
import { tlsConnectionsTotal } from './metrics.js';
1818

19-
const secureContextCache = new SecureContextCache();
20-
2119
function calculateContextCacheKey(
2220
domain: string,
2321
certOptions: CertOptions,
@@ -78,7 +76,11 @@ function proactivelyRefreshDomains(rootDomain: string, domains: string[], certGe
7876
}
7977

8078
class TlsConnectionHandler {
81-
79+
80+
// Cached contexts are built from this handler's own CA, so they can't be shared with
81+
// another handler - it would serve certs whose issuer it knows nothing about.
82+
private secureContextCache = new SecureContextCache();
83+
8284
// To keep Node happy, we need a TLS server attached to our sockets in some cases
8385
// to enable some features (like OCSP). This'll do:
8486
private ocspServer = new EventEmitter();
@@ -90,18 +92,14 @@ class TlsConnectionHandler {
9092
this.ocspServer.on('OCSPRequest', async (
9193
certificate: Buffer,
9294
_issuer: Buffer,
93-
callback: (err: Error | null, response: Buffer) => void
95+
callback: (err: Error | null, response?: Buffer) => void
9496
) => {
9597
try {
9698
const ocspResponse = await this.tlsConfig.localCA!.getOcspResponse(certificate);
97-
if (ocspResponse) {
98-
callback(null, ocspResponse);
99-
} else {
100-
callback(null, Buffer.alloc(0));
101-
}
99+
callback(null, ocspResponse ?? undefined);
102100
} catch (e) {
103101
console.error('OCSP response generation error', e);
104-
callback(null, Buffer.alloc(0));
102+
callback(null, undefined);
105103
}
106104
});
107105
}
@@ -132,7 +130,7 @@ class TlsConnectionHandler {
132130
const cacheKey = calculateContextCacheKey(certDomain, certOptions, tlsOptions)
133131
+ (requireClientCert ? '|client-cert' : '');
134132

135-
const secureContext = await secureContextCache.getOrCreate(cacheKey, async () => {
133+
const secureContext = await this.secureContextCache.getOrCreate(cacheKey, async () => {
136134
const cert = await this.tlsConfig.generateCertificate(certDomain, certOptions);
137135

138136
const servedCert = certOptions.incompleteChain

test/ocsp-helpers.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import * as x509 from '@peculiar/x509';
2+
import * as asn1Ocsp from '@peculiar/asn1-ocsp';
3+
import * as asn1X509 from '@peculiar/asn1-x509';
4+
import * as asn1Schema from '@peculiar/asn1-schema';
5+
6+
const crypto = globalThis.crypto;
7+
8+
// Read a CertID serial the way a client does: as a signed DER INTEGER, so that a missing
9+
// sign pad shows up as the negative (i.e. wrong) serial that it is.
10+
export function readSignedInteger(bytes: BufferSource): bigint {
11+
const buffer = Buffer.from(ArrayBuffer.isView(bytes)
12+
? bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)
13+
: bytes
14+
);
15+
16+
const magnitude = BigInt(`0x${buffer.toString('hex')}`);
17+
return (buffer[0]! & 0x80)
18+
? magnitude - (1n << BigInt(buffer.length * 8))
19+
: magnitude;
20+
}
21+
22+
export function parseSingleResponse(response: Buffer) {
23+
const parsed = asn1Schema.AsnConvert.parse(response, asn1Ocsp.OCSPResponse);
24+
const basicResponse = asn1Schema.AsnConvert.parse(
25+
parsed.responseBytes!.response.buffer,
26+
asn1Ocsp.BasicOCSPResponse
27+
);
28+
return basicResponse.tbsResponseData.responses[0]!;
29+
}
30+
31+
// The CertID hashes a client would calculate for certs issued by this issuer
32+
export async function certIdHashes(issuerCert: x509.X509Certificate) {
33+
const asn1 = asn1Schema.AsnConvert.parse(issuerCert.rawData, asn1X509.Certificate);
34+
const nameHash = await crypto.subtle.digest(
35+
'SHA-1',
36+
asn1Schema.AsnConvert.serialize(asn1.tbsCertificate.subject)
37+
);
38+
const keyHash = await crypto.subtle.digest(
39+
'SHA-1',
40+
new Uint8Array(asn1.tbsCertificate.subjectPublicKeyInfo.subjectPublicKey)
41+
);
42+
return {
43+
nameHash: Buffer.from(nameHash).toString('hex'),
44+
keyHash: Buffer.from(keyHash).toString('hex')
45+
};
46+
}
47+
48+
export function toDer(certPem: string) {
49+
return Buffer.from(new x509.X509Certificate(certPem).rawData);
50+
}

test/ocsp.spec.ts

Lines changed: 106 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import * as asn1Ocsp from '@peculiar/asn1-ocsp';
44
import * as asn1X509 from '@peculiar/asn1-x509';
55
import * as asn1Schema from '@peculiar/asn1-schema';
66
import { createOcspResponse, parseOcspRequest, RevocationReason } from '../src/tls-certificates/ocsp.js';
7-
import { generateCACertificate } from '../src/tls-certificates/local-ca.js';
7+
import { generateCACertificate, LocalCA } from '../src/tls-certificates/local-ca.js';
8+
import { extractLeafCertificate } from '../src/tls-certificates/cert-definitions.js';
9+
import { certIdHashes, parseSingleResponse, readSignedInteger, toDer } from './ocsp-helpers.js';
810

911
const crypto = globalThis.crypto;
1012

@@ -179,7 +181,26 @@ describe("OCSP response generation", () => {
179181

180182
// Convert the serial number to hex for comparison
181183
const serialHex = Buffer.from(certId.serialNumber).toString('hex');
182-
expect(serialHex.toLowerCase()).to.equal(cert.serialNumber.toLowerCase().replace(/\s/g, ''));
184+
expect(serialHex.toLowerCase().replace(/^00/, ''))
185+
.to.equal(cert.serialNumber.toLowerCase().replace(/\s/g, ''));
186+
});
187+
188+
it("encodes a high-bit serial number as a positive integer", async () => {
189+
// 0xA1... has its top bit set, so without a sign pad this reads back as a negative
190+
// number - i.e. a CertID for a certificate other than the one being served.
191+
expect(cert.serialNumber.toLowerCase()).to.equal('a123456789abcdef');
192+
193+
const response = await createOcspResponse({
194+
cert,
195+
issuerCert: caCert,
196+
issuerKey: caKey,
197+
status: 'good'
198+
});
199+
200+
const { certID } = parseSingleResponse(response);
201+
202+
expect(readSignedInteger(certID.serialNumber))
203+
.to.equal(BigInt(`0x${cert.serialNumber}`));
183204
});
184205

185206
it("includes thisUpdate timestamp", async () => {
@@ -290,3 +311,86 @@ describe("OCSP response generation", () => {
290311
expect(parsed!.serialNumber).to.equal('010203');
291312
});
292313
});
314+
315+
describe("OCSP responses from the local CA", () => {
316+
317+
let localCA: LocalCA;
318+
let otherCA: LocalCA;
319+
320+
before(async () => {
321+
localCA = await LocalCA.create(await generateCACertificate());
322+
otherCA = await LocalCA.create(await generateCACertificate({ commonName: 'Other CA' }));
323+
});
324+
325+
it("answers for a certificate it issued, naming the issuing intermediate", async () => {
326+
const generated = await localCA.generateCertificate('example.testserver.host', {});
327+
const leafPem = extractLeafCertificate(generated.cert);
328+
329+
const response = await localCA.getOcspResponse(toDer(leafPem));
330+
expect(response).to.not.be.null;
331+
332+
const { certID } = parseSingleResponse(response!);
333+
const leaf = new x509.X509Certificate(leafPem);
334+
const intermediate = new x509.X509Certificate(await localCA.getIntermediateCertificatePem());
335+
const expectedHashes = await certIdHashes(intermediate);
336+
337+
expect(leaf.issuer).to.equal(intermediate.subject);
338+
expect(Buffer.from(certID.issuerNameHash.buffer).toString('hex')).to.equal(expectedHashes.nameHash);
339+
expect(Buffer.from(certID.issuerKeyHash.buffer).toString('hex')).to.equal(expectedHashes.keyHash);
340+
expect(readSignedInteger(certID.serialNumber)).to.equal(BigInt(`0x${leaf.serialNumber}`));
341+
});
342+
343+
it("refuses to answer for a certificate issued by another CA", async () => {
344+
// This is what a real ACME-issued cert looks like to us: we have no standing to say
345+
// anything about it, so we must not staple a response naming one of our own CAs.
346+
const foreignCert = await otherCA.generateCertificate('example.testserver.host', {});
347+
348+
const response = await localCA.getOcspResponse(toDer(extractLeafCertificate(foreignCert.cert)));
349+
expect(response).to.be.null;
350+
});
351+
352+
it("answers for a self-signed certificate it generated, naming the cert itself", async () => {
353+
// A self-signed cert is its own issuer, so signing with its own key is exactly what
354+
// RFC 6960 asks for - and we still hold that key.
355+
const generated = await localCA.generateCertificate('example.testserver.host', {
356+
selfSigned: true
357+
});
358+
const leafPem = extractLeafCertificate(generated.cert);
359+
360+
const response = await localCA.getOcspResponse(toDer(leafPem));
361+
expect(response).to.not.be.null;
362+
363+
const { certID } = parseSingleResponse(response!);
364+
const leaf = new x509.X509Certificate(leafPem);
365+
const expectedHashes = await certIdHashes(leaf);
366+
367+
expect(leaf.issuer).to.equal(leaf.subject);
368+
expect(Buffer.from(certID.issuerNameHash.buffer).toString('hex')).to.equal(expectedHashes.nameHash);
369+
expect(Buffer.from(certID.issuerKeyHash.buffer).toString('hex')).to.equal(expectedHashes.keyHash);
370+
expect(readSignedInteger(certID.serialNumber)).to.equal(BigInt(`0x${leaf.serialNumber}`));
371+
});
372+
373+
it("reports a self-signed revoked certificate as revoked", async () => {
374+
const generated = await localCA.generateCertificate('revoked.testserver.host', {
375+
selfSigned: true
376+
});
377+
378+
const response = await localCA.getOcspResponse(toDer(extractLeafCertificate(generated.cert)));
379+
expect(response).to.not.be.null;
380+
381+
expect(parseSingleResponse(response!).certStatus.revoked).to.exist;
382+
});
383+
384+
it("refuses to answer for a self-signed certificate generated elsewhere", async () => {
385+
const foreignCert = await otherCA.generateCertificate('example.testserver.host', {
386+
selfSigned: true
387+
});
388+
389+
const response = await localCA.getOcspResponse(toDer(extractLeafCertificate(foreignCert.cert)));
390+
expect(response).to.be.null;
391+
});
392+
393+
it("refuses to answer for data that isn't a certificate", async () => {
394+
expect(await localCA.getOcspResponse(Buffer.from('not a certificate'))).to.be.null;
395+
});
396+
});

0 commit comments

Comments
 (0)