Skip to content

Commit 61dcf16

Browse files
committed
Add local CA support for OCSP stapling & revocation
1 parent 497ee8f commit 61dcf16

8 files changed

Lines changed: 783 additions & 6 deletions

File tree

package-lock.json

Lines changed: 13 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
"homepage": "https://github.com/httptoolkit/testserver#readme",
3232
"dependencies": {
3333
"@httptoolkit/util": "^0.1.2",
34+
"@peculiar/asn1-ocsp": "^2.6.0",
3435
"@peculiar/asn1-schema": "^2.6.0",
3536
"@peculiar/asn1-x509": "^2.6.0",
3637
"@peculiar/x509": "^1.14.3",

src/server.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,10 +60,11 @@ async function generateTlsConfig(options: ServerOptions) {
6060
key: defaultCert.key,
6161
cert: defaultCert.cert,
6262
ca: caCert.cert,
63+
localCA: ca,
6364
generateCertificate: async (domain: string, mode?: CertMode) => {
6465
if (mode === 'self-signed') return await ca.generateSelfSignedCertificate(domain);
6566
if (mode === 'expired') return await ca.generateExpiredCertificate(domain);
66-
// 'revoked' mode requires ACME - falls through to normal cert without it
67+
if (mode === 'revoked') return await ca.generateRevokedCertificate(domain);
6768
return await ca.generateCertificate(domain);
6869
},
6970
acmeChallenge: () => undefined // Not supported
@@ -88,6 +89,7 @@ async function generateTlsConfig(options: ServerOptions) {
8889
key: defaultCert.key,
8990
cert: defaultCert.cert,
9091
ca: caCert.cert,
92+
localCA: ca,
9193
generateCertificate: async (domain: string, mode?: CertMode) => {
9294
if (mode === 'self-signed') return await ca.generateSelfSignedCertificate(domain);
9395

@@ -99,10 +101,10 @@ async function generateTlsConfig(options: ServerOptions) {
99101
}
100102

101103
if (mode === 'revoked') {
102-
// Try to get a revoked ACME cert; fall back to normal cert if not yet available
104+
// Try to get a revoked ACME cert; fall back to LocalCA revoked cert
103105
const revokedAcmeCert = acmeCA.tryGetRevokedCertificateSync(rootDomain);
104106
if (revokedAcmeCert) return revokedAcmeCert;
105-
// No LocalCA fallback for revoked - just use normal cert until ACME one is ready
107+
return await ca.generateRevokedCertificate(domain);
106108
}
107109

108110
if (domain === rootDomain || domain.endsWith('.' + rootDomain)) {

src/tls-certificates/local-ca.ts

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Buffer } from 'buffer';
2+
import { createOcspResponse } from './ocsp.js';
23

34
import * as x509 from '@peculiar/x509';
45
import * as asn1X509 from '@peculiar/asn1-x509';
@@ -145,9 +146,23 @@ export async function generateCACertificate(options: {
145146

146147
// Generates a unique serial number for a certificate as a hex string:
147148
function generateSerialNumber() {
148-
return 'A' + crypto.randomUUID().replace(/-/g, '');
149-
// We add a leading 'A' to ensure it's always positive (not 'F') and always
150-
// valid (e.g. leading 000 is bad padding, and would be unparseable).
149+
// Use digit prefix (0-7) to ensure high bit is clear (naturally positive per RFC 5280).
150+
// Prefixes 0-7 have MSB=0: e.g. '1' = 0001, so '1x...' is naturally positive.
151+
return '1' + crypto.randomUUID().replace(/-/g, '');
152+
}
153+
154+
// Check if a certificate's domain indicates it should be treated as revoked
155+
function isRevokedCert(cert: x509.X509Certificate): boolean {
156+
const sanExt = cert.getExtension(x509.SubjectAlternativeNameExtension);
157+
if (!sanExt) return false;
158+
159+
for (const name of sanExt.names.items) {
160+
if (name.type === 'dns') {
161+
const parts = (name.value as string).split('.');
162+
if (parts.includes('revoked')) return true;
163+
}
164+
}
165+
return false;
151166
}
152167

153168
// We share a single keypair across all certificates in this process, and
@@ -248,6 +263,41 @@ export class LocalCA {
248263
return this.generateCert(domain, `${domain}:expired`, { expired: true });
249264
}
250265

266+
async generateRevokedCertificate(domain: string): Promise<LocallyGeneratedCertificate> {
267+
return this.generateCert(domain, `${domain}:revoked`);
268+
}
269+
270+
async getOcspResponse(certDer: Buffer): Promise<Buffer | null> {
271+
// Parse the certificate to get its serial number
272+
const certPem = `-----BEGIN CERTIFICATE-----\n${certDer.toString('base64')}\n-----END CERTIFICATE-----`;
273+
let cert: x509.X509Certificate;
274+
try {
275+
cert = new x509.X509Certificate(certPem);
276+
} catch {
277+
return null;
278+
}
279+
280+
if (isRevokedCert(cert)) {
281+
// Certificate is revoked - return revoked OCSP response
282+
return await createOcspResponse({
283+
cert,
284+
issuerCert: this.caCert,
285+
issuerKey: this.caKey,
286+
status: 'revoked',
287+
revocationTime: new Date(), // Use current time as approximation
288+
revocationReason: 1 // keyCompromise
289+
});
290+
}
291+
292+
// Certificate not revoked - return good status
293+
return await createOcspResponse({
294+
cert,
295+
issuerCert: this.caCert,
296+
issuerKey: this.caKey,
297+
status: 'good'
298+
});
299+
}
300+
251301
private async generateCert(
252302
domain: string,
253303
cacheKey: string,

src/tls-certificates/ocsp.ts

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
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+
// OIDs
9+
const OID_SHA1 = '1.3.14.3.2.26';
10+
const OID_SHA256_WITH_RSA = '1.2.840.113549.1.1.11';
11+
12+
// CRL Reasons (RFC 5280)
13+
export enum RevocationReason {
14+
unspecified = 0,
15+
keyCompromise = 1,
16+
cACompromise = 2,
17+
affiliationChanged = 3,
18+
superseded = 4,
19+
cessationOfOperation = 5,
20+
certificateHold = 6,
21+
removeFromCRL = 8,
22+
privilegeWithdrawn = 9,
23+
aACompromise = 10
24+
}
25+
26+
interface OcspResponseOptions {
27+
cert: x509.X509Certificate;
28+
issuerCert: x509.X509Certificate;
29+
issuerKey: CryptoKey;
30+
status: 'good' | 'revoked' | 'unknown';
31+
revocationTime?: Date;
32+
revocationReason?: RevocationReason;
33+
thisUpdate?: Date;
34+
nextUpdate?: Date;
35+
}
36+
37+
async function sha1Hash(data: BufferSource): Promise<ArrayBuffer> {
38+
return await crypto.subtle.digest('SHA-1', data);
39+
}
40+
41+
function createCertId(
42+
cert: x509.X509Certificate,
43+
issuerCert: x509.X509Certificate,
44+
issuerNameHash: ArrayBuffer,
45+
issuerKeyHash: ArrayBuffer
46+
): 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+
);
54+
55+
return new asn1Ocsp.CertID({
56+
hashAlgorithm: new asn1X509.AlgorithmIdentifier({
57+
algorithm: OID_SHA1,
58+
parameters: null
59+
}),
60+
issuerNameHash: new asn1Schema.OctetString(issuerNameHash),
61+
issuerKeyHash: new asn1Schema.OctetString(issuerKeyHash),
62+
serialNumber: serialBytes
63+
});
64+
}
65+
66+
function createCertStatus(
67+
status: 'good' | 'revoked' | 'unknown',
68+
revocationTime?: Date,
69+
revocationReason?: RevocationReason
70+
): asn1Ocsp.CertStatus {
71+
if (status === 'good') {
72+
// For CHOICE types, pass the selected option in the constructor
73+
return new asn1Ocsp.CertStatus({ good: null });
74+
} else if (status === 'revoked') {
75+
// Don't set revocationReason for now - simplify to debug
76+
const revoked = new asn1Ocsp.RevokedInfo({
77+
revocationTime: revocationTime || new Date()
78+
});
79+
return new asn1Ocsp.CertStatus({ revoked });
80+
} else {
81+
return new asn1Ocsp.CertStatus({ unknown: null });
82+
}
83+
}
84+
85+
async function createSingleResponse(
86+
options: OcspResponseOptions,
87+
issuerNameHash: ArrayBuffer,
88+
issuerKeyHash: ArrayBuffer
89+
): Promise<asn1Ocsp.SingleResponse> {
90+
const thisUpdate = options.thisUpdate || new Date();
91+
92+
const singleResponse = new asn1Ocsp.SingleResponse({
93+
certID: createCertId(options.cert, options.issuerCert, issuerNameHash, issuerKeyHash),
94+
certStatus: createCertStatus(options.status, options.revocationTime, options.revocationReason),
95+
thisUpdate
96+
});
97+
98+
if (options.nextUpdate) {
99+
singleResponse.nextUpdate = options.nextUpdate;
100+
}
101+
102+
return singleResponse;
103+
}
104+
105+
async function createResponseData(
106+
issuerCert: x509.X509Certificate,
107+
responses: asn1Ocsp.SingleResponse[]
108+
): Promise<asn1Ocsp.ResponseData> {
109+
// Parse the issuer's name from its certificate
110+
const issuerCertAsn1 = asn1Schema.AsnConvert.parse(issuerCert.rawData, asn1X509.Certificate);
111+
const issuerName = issuerCertAsn1.tbsCertificate.subject;
112+
113+
return new asn1Ocsp.ResponseData({
114+
responderID: new asn1Ocsp.ResponderID({ byName: issuerName }),
115+
producedAt: new Date(),
116+
responses
117+
});
118+
}
119+
120+
async function signResponseData(
121+
responseData: asn1Ocsp.ResponseData,
122+
issuerKey: CryptoKey
123+
): Promise<ArrayBuffer> {
124+
// Serialize the response data
125+
const responseDataDer = asn1Schema.AsnConvert.serialize(responseData);
126+
127+
// Sign with SHA-256 + RSA
128+
const signature = await crypto.subtle.sign(
129+
{
130+
name: "RSASSA-PKCS1-v1_5",
131+
hash: "SHA-256"
132+
},
133+
issuerKey,
134+
responseDataDer
135+
);
136+
137+
return signature;
138+
}
139+
140+
async function createBasicOcspResponse(
141+
responseData: asn1Ocsp.ResponseData,
142+
issuerKey: CryptoKey,
143+
issuerCert: x509.X509Certificate
144+
): Promise<asn1Ocsp.BasicOCSPResponse> {
145+
const signature = await signResponseData(responseData, issuerKey);
146+
147+
// Create bit string from signature ArrayBuffer
148+
const signatureBitString = new Uint8Array(signature);
149+
150+
const basicResponse = new asn1Ocsp.BasicOCSPResponse({
151+
tbsResponseData: responseData,
152+
signatureAlgorithm: new asn1X509.AlgorithmIdentifier({
153+
algorithm: OID_SHA256_WITH_RSA,
154+
parameters: null
155+
}),
156+
signature: signatureBitString
157+
});
158+
159+
// Include the issuer certificate in the response
160+
const issuerCertDer = issuerCert.rawData;
161+
basicResponse.certs = [asn1Schema.AsnConvert.parse(issuerCertDer, asn1X509.Certificate)];
162+
163+
return basicResponse;
164+
}
165+
166+
export async function createOcspResponse(options: OcspResponseOptions): Promise<Buffer> {
167+
// Hash the issuer's distinguished name
168+
const issuerCertAsn1 = asn1Schema.AsnConvert.parse(options.issuerCert.rawData, asn1X509.Certificate);
169+
const issuerNameDer = asn1Schema.AsnConvert.serialize(issuerCertAsn1.tbsCertificate.subject);
170+
const issuerNameHash = await sha1Hash(issuerNameDer);
171+
172+
// Hash the issuer's public key
173+
const issuerPublicKeyInfo = issuerCertAsn1.tbsCertificate.subjectPublicKeyInfo;
174+
const issuerKeyBytes = new Uint8Array(issuerPublicKeyInfo.subjectPublicKey);
175+
const issuerKeyHash = await sha1Hash(issuerKeyBytes);
176+
177+
const singleResponse = await createSingleResponse(options, issuerNameHash, issuerKeyHash);
178+
const responseData = await createResponseData(options.issuerCert, [singleResponse]);
179+
const basicResponse = await createBasicOcspResponse(responseData, options.issuerKey, options.issuerCert);
180+
181+
// Wrap in OCSPResponse
182+
const basicResponseDer = asn1Schema.AsnConvert.serialize(basicResponse);
183+
const ocspResponse = new asn1Ocsp.OCSPResponse({
184+
responseStatus: asn1Ocsp.OCSPResponseStatus.successful,
185+
responseBytes: new asn1Ocsp.ResponseBytes({
186+
responseType: asn1Ocsp.id_pkix_ocsp_basic,
187+
response: new asn1Schema.OctetString(basicResponseDer)
188+
})
189+
});
190+
191+
const der = asn1Schema.AsnConvert.serialize(ocspResponse);
192+
return Buffer.from(der);
193+
}
194+
195+
// Parse an OCSP request to extract the CertID
196+
export function parseOcspRequest(requestDer: Buffer): {
197+
issuerNameHash: string;
198+
issuerKeyHash: string;
199+
serialNumber: string;
200+
} | null {
201+
try {
202+
const request = asn1Schema.AsnConvert.parse(requestDer, asn1Ocsp.OCSPRequest);
203+
const firstRequest = request.tbsRequest.requestList[0];
204+
const certId = firstRequest.reqCert;
205+
206+
// OctetString has a buffer property that contains the actual data
207+
const issuerNameHash = Buffer.from(certId.issuerNameHash.buffer).toString('hex');
208+
const issuerKeyHash = Buffer.from(certId.issuerKeyHash.buffer).toString('hex');
209+
const serialNumber = Buffer.from(certId.serialNumber).toString('hex');
210+
211+
return { issuerNameHash, issuerKeyHash, serialNumber };
212+
} catch (e) {
213+
console.error('Failed to parse OCSP request:', e);
214+
return null;
215+
}
216+
}

0 commit comments

Comments
 (0)