Skip to content

Commit d3f4897

Browse files
committed
Add support for local-ca-only expired TLS endpoint
1 parent 35c9794 commit d3f4897

4 files changed

Lines changed: 91 additions & 98 deletions

File tree

src/server.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,8 @@ async function generateTlsConfig(options: ServerOptions) {
4646
cert: defaultCert.cert,
4747
ca: caCert.cert,
4848
generateCertificate: (domain: string, mode?: CertMode) => {
49-
if (mode === 'self-signed') {
50-
return ca.generateSelfSignedCertificate(domain);
51-
}
49+
if (mode === 'self-signed') return ca.generateSelfSignedCertificate(domain);
50+
if (mode === 'expired') return ca.generateExpiredCertificate(domain);
5251
return ca.generateCertificate(domain);
5352
},
5453
acmeChallenge: () => undefined // Not supported
@@ -74,9 +73,8 @@ async function generateTlsConfig(options: ServerOptions) {
7473
cert: defaultCert.cert,
7574
ca: caCert.cert,
7675
generateCertificate: (domain: string, mode?: CertMode) => {
77-
if (mode === 'self-signed') {
78-
return ca.generateSelfSignedCertificate(domain);
79-
}
76+
if (mode === 'self-signed') return ca.generateSelfSignedCertificate(domain);
77+
if (mode === 'expired') return ca.generateExpiredCertificate(domain);
8078

8179
if (domain === rootDomain || domain.endsWith('.' + rootDomain)) {
8280
const cert = acmeCA.tryGetCertificateSync(domain);

src/tls-certificates/local-ca.ts

Lines changed: 45 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,11 @@ let KEY_PAIR: {
128128
length: number
129129
} | undefined;
130130

131+
interface CertGenerationOptions {
132+
selfSigned?: boolean;
133+
expired?: boolean;
134+
}
135+
131136
export class LocalCA {
132137
private caCert: forge.pki.Certificate;
133138
private caKey: forge.pki.PrivateKey;
@@ -177,128 +182,78 @@ export class LocalCA {
177182
domain = `*.${otherParts.join('.')}`;
178183
}
179184

180-
let cert = pki.createCertificate();
181-
182-
cert.publicKey = KEY_PAIR!.publicKey;
183-
cert.serialNumber = generateSerialNumber();
184-
185-
cert.validity.notBefore = new Date();
186-
// Make it valid for the last 24h - helps in cases where clocks slightly disagree.
187-
cert.validity.notBefore.setDate(cert.validity.notBefore.getDate() - 1);
188-
189-
cert.validity.notAfter = new Date();
190-
// Valid for the next year by default.
191-
cert.validity.notAfter.setFullYear(cert.validity.notAfter.getFullYear() + 1);
192-
193-
cert.setSubject([
194-
...(domain[0] === '*'
195-
? [] // We skip the CN (deprecated, rarely used) for wildcards, since they can't be used here.
196-
: [{ name: 'commonName', value: domain }]
197-
),
198-
{ name: 'countryName', value: this.options?.countryName ?? 'XX' }, // ISO-3166-1 alpha-2 'unknown country' code
199-
{ name: 'localityName', value: this.options?.localityName ?? 'Unknown' },
200-
{ name: 'organizationName', value: this.options?.organizationName ?? 'Testserver Test Cert' }
201-
]);
202-
cert.setIssuer(this.caCert.subject.attributes);
203-
204-
const policyList = forge.asn1.create(forge.asn1.Class.UNIVERSAL, forge.asn1.Type.SEQUENCE, true, [
205-
forge.asn1.create(forge.asn1.Class.UNIVERSAL, forge.asn1.Type.SEQUENCE, true, [
206-
forge.asn1.create(
207-
forge.asn1.Class.UNIVERSAL,
208-
forge.asn1.Type.OID,
209-
false,
210-
forge.asn1.oidToDer('2.5.29.32.0').getBytes() // Mark all as Domain Verified
211-
)
212-
])
213-
]);
214-
215-
cert.setExtensions([
216-
{ name: 'basicConstraints', cA: false, critical: true },
217-
{ name: 'keyUsage', digitalSignature: true, keyEncipherment: true, critical: true },
218-
{ name: 'extKeyUsage', serverAuth: true, clientAuth: true },
219-
{
220-
name: 'subjectAltName',
221-
altNames: [{
222-
type: 2,
223-
value: domain
224-
}]
225-
},
226-
{ name: 'certificatePolicies', value: policyList },
227-
{ name: 'subjectKeyIdentifier' },
228-
{
229-
name: 'authorityKeyIdentifier',
230-
// We have to calculate this ourselves due to
231-
// https://github.com/digitalbazaar/forge/issues/462
232-
keyIdentifier: (
233-
this.caCert as any // generateSubjectKeyIdentifier is missing from node-forge types
234-
).generateSubjectKeyIdentifier().getBytes()
235-
}
236-
]);
237-
238-
cert.sign(this.caKey, md.sha256.create());
239-
240-
const generatedCertificate = {
241-
key: pki.privateKeyToPem(KEY_PAIR!.privateKey),
242-
cert: pki.certificateToPem(cert),
243-
ca: pki.certificateToPem(this.caCert)
244-
};
245-
246-
// We cache in memory only - no need to persist these (unlike ACME etc)
247-
this.certInMemoryCache[domain] = generatedCertificate;
248-
249-
// Make sure this gets regenerated in 24 hours, in case this server is running persistently
250-
setTimeout(() => {
251-
delete this.certInMemoryCache[domain];
252-
}, 1000 * 60 * 60 * 24).unref();
253-
254-
return generatedCertificate;
185+
return this.generateCert(domain, domain);
255186
}
256187

257188
generateSelfSignedCertificate(domain: string) {
258-
const cacheKey = `${domain}:self-signed`;
189+
return this.generateCert(domain, `${domain}:self-signed`, { selfSigned: true });
190+
}
191+
192+
generateExpiredCertificate(domain: string) {
193+
return this.generateCert(domain, `${domain}:expired`, { expired: true });
194+
}
259195

196+
private generateCert(
197+
domain: string,
198+
cacheKey: string,
199+
options: CertGenerationOptions = {}
200+
): LocallyGeneratedCertificate {
260201
const cachedCert = this.certInMemoryCache[cacheKey];
261202
if (cachedCert) return cachedCert;
262203

263204
const cert = pki.createCertificate();
264-
265205
cert.publicKey = KEY_PAIR!.publicKey;
266206
cert.serialNumber = generateSerialNumber();
267207

268208
cert.validity.notBefore = new Date();
269-
cert.validity.notBefore.setDate(cert.validity.notBefore.getDate() - 1);
270-
271209
cert.validity.notAfter = new Date();
272-
cert.validity.notAfter.setFullYear(cert.validity.notAfter.getFullYear() + 1);
210+
211+
if (options.expired) {
212+
cert.validity.notBefore.setDate(cert.validity.notBefore.getDate() - 2);
213+
cert.validity.notAfter.setDate(cert.validity.notAfter.getDate() - 1);
214+
} else {
215+
cert.validity.notBefore.setDate(cert.validity.notBefore.getDate() - 1);
216+
cert.validity.notAfter.setFullYear(cert.validity.notAfter.getFullYear() + 1);
217+
}
273218

274219
const subject = [
275-
{ name: 'commonName', value: domain },
220+
...(domain[0] === '*'
221+
? [] // We skip the CN (deprecated, rarely used) for wildcards, since they can't be used here.
222+
: [{ name: 'commonName', value: domain }]
223+
),
276224
{ name: 'countryName', value: this.options?.countryName ?? 'XX' },
277225
{ name: 'localityName', value: this.options?.localityName ?? 'Unknown' },
278226
{ name: 'organizationName', value: this.options?.organizationName ?? 'Testserver Test Cert' }
279227
];
280228

281229
cert.setSubject(subject);
282-
cert.setIssuer(subject); // Self-signed: issuer = subject
230+
cert.setIssuer(options.selfSigned ? subject : this.caCert.subject.attributes);
283231

284-
cert.setExtensions([
232+
const extensions: any[] = [
285233
{ name: 'basicConstraints', cA: false, critical: true },
286234
{ name: 'keyUsage', digitalSignature: true, keyEncipherment: true, critical: true },
287235
{ name: 'extKeyUsage', serverAuth: true, clientAuth: true },
288-
{
289-
name: 'subjectAltName',
290-
altNames: [{ type: 2, value: domain }]
291-
},
236+
{ name: 'subjectAltName', altNames: [{ type: 2, value: domain }] },
292237
{ name: 'subjectKeyIdentifier' }
293-
]);
238+
];
239+
240+
if (!options.selfSigned) {
241+
extensions.push({
242+
name: 'authorityKeyIdentifier',
243+
// We have to calculate this ourselves due to
244+
// https://github.com/digitalbazaar/forge/issues/462
245+
keyIdentifier: (this.caCert as any).generateSubjectKeyIdentifier().getBytes()
246+
});
247+
}
294248

295-
cert.sign(KEY_PAIR!.privateKey, md.sha256.create()); // Self-signed: sign with own key
249+
cert.setExtensions(extensions);
250+
cert.sign(options.selfSigned ? KEY_PAIR!.privateKey : this.caKey, md.sha256.create());
296251

297252
const certPem = pki.certificateToPem(cert);
298253
const generatedCertificate = {
299254
key: pki.privateKeyToPem(KEY_PAIR!.privateKey),
300255
cert: certPem,
301-
ca: certPem // Self-signed: cert is its own CA
256+
ca: options.selfSigned ? certPem : pki.certificateToPem(this.caCert)
302257
};
303258

304259
this.certInMemoryCache[cacheKey] = generatedCertificate;

src/tls-handler.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@ import * as tls from 'tls';
22

33
import { ConnectionProcessor } from './process-connection.js';
44

5-
export const CERT_MODES = ['wrong-host', 'self-signed'] as const;
5+
export const CERT_MODES = ['wrong-host', 'self-signed', 'expired'] as const;
66
export type CertMode = typeof CERT_MODES[number];
77

88
// Modes that require special certificate generation (vs just domain remapping)
9-
const CERT_GENERATION_MODES = new Set<CertMode>(['self-signed']);
9+
const CERT_GENERATION_MODES = new Set<CertMode>(['self-signed', 'expired']);
1010

1111
export type CertGenerator = (domain: string, mode?: CertMode) => {
1212
key: string,

test/https.spec.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,4 +234,44 @@ Connection: keep-alive
234234

235235
});
236236

237+
describe("expired certificates", () => {
238+
239+
it("returns a certificate with validity dates in the past", async () => {
240+
const conn = tls.connect({
241+
port: serverPort,
242+
servername: 'expired.localhost',
243+
rejectUnauthorized: false
244+
});
245+
246+
const cert = await new Promise<tls.PeerCertificate>((resolve, reject) => {
247+
conn.on('secureConnect', () => resolve(conn.getPeerCertificate()));
248+
conn.on('error', reject);
249+
});
250+
conn.destroy();
251+
252+
const now = Date.now();
253+
expect(new Date(cert.valid_to).getTime()).to.be.lessThan(now);
254+
expect(new Date(cert.valid_from).getTime()).to.be.lessThan(now);
255+
});
256+
257+
it("can combine expired with protocol preferences", async () => {
258+
const conn = tls.connect({
259+
port: serverPort,
260+
servername: 'http2.expired.localhost',
261+
ALPNProtocols: ['http/1.1', 'h2'],
262+
rejectUnauthorized: false
263+
});
264+
265+
const [cert, protocol] = await new Promise<[tls.PeerCertificate, string | false]>((resolve, reject) => {
266+
conn.on('secureConnect', () => resolve([conn.getPeerCertificate(), conn.alpnProtocol]));
267+
conn.on('error', reject);
268+
});
269+
conn.destroy();
270+
271+
expect(new Date(cert.valid_to).getTime()).to.be.lessThan(Date.now());
272+
expect(protocol).to.equal('h2');
273+
});
274+
275+
});
276+
237277
});

0 commit comments

Comments
 (0)