Skip to content

Commit 2430ce3

Browse files
committed
Add support for incomplete-chain TLS endpoint (ACME & local)
1 parent ba7c65e commit 2430ce3

6 files changed

Lines changed: 235 additions & 9 deletions

File tree

‎src/endpoints/tls/cert-modes.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,21 @@ export const untrustedRoot: TlsEndpoint = {
6262
}
6363
};
6464

65+
export const incompleteChain: TlsEndpoint = {
66+
sniPart: 'incomplete-chain',
67+
configureCertOptions() {
68+
return {
69+
incompleteChain: true
70+
};
71+
},
72+
meta: {
73+
path: 'incomplete-chain',
74+
description: 'Serves a TLS certificate without the required intermediate certificate.',
75+
examples: ['https://incomplete-chain.testserver.host/'],
76+
group: tlsCertificateModes
77+
}
78+
};
79+
6580
export const wrongHost: TlsEndpoint = {
6681
sniPart: 'wrong-host',
6782
configureCertOptions() {

‎src/tls-certificates/cert-definitions.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ export interface CertOptions {
66
selfSigned?: boolean;
77

88
overridePrefix?: string;
9+
10+
// This is a presentation difference only - cert is the same, we just don't
11+
// send the full chain with it.
12+
incompleteChain?: boolean;
913
}
1014

1115
export function calculateCertCacheKey(domain: string, options: CertOptions) {
@@ -20,4 +24,12 @@ export function calculateCertCacheKey(domain: string, options: CertOptions) {
2024
}
2125

2226
return `${domain}+${parts.join('+')}`;
27+
}
28+
29+
const CERT_PEM_PATTERN = /-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g;
30+
31+
export function extractLeafCertificate(certChainPem: string): string {
32+
const leaf = certChainPem.match(CERT_PEM_PATTERN)?.[0];
33+
if (!leaf) throw new Error('Could not find any certificate in chain PEM');
34+
return leaf + '\n';
2335
}

‎src/tls-certificates/local-ca.ts‎

Lines changed: 85 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,8 @@ function calculateCacheKey(domain: string, options: CertOptions) {
175175
return `${domain}+${([
176176
'expired',
177177
'revoked',
178-
'selfSigned'
178+
'selfSigned',
179+
'incompleteChain'
179180
] as const).filter((k: keyof CertOptions) => options[k]).join('+')}`
180181
}
181182

@@ -199,15 +200,24 @@ const KEY_PAIR_ALGO = {
199200
interface CertGenerationOptions {
200201
selfSigned?: boolean;
201202
expired?: boolean;
203+
incompleteChain?: boolean;
202204
}
203205

206+
type IntermediateCA = {
207+
cert: x509.X509Certificate,
208+
key: CryptoKey,
209+
pem: string
210+
};
211+
204212
export class LocalCA {
205213
private caCert: x509.X509Certificate;
206214
private caKey: CryptoKey;
207215
private options: CAOptions;
208216

209217
private certInMemoryCache: { [domain: string]: LocallyGeneratedCertificate | undefined } = {};
210218

219+
private intermediateCA?: Promise<IntermediateCA>;
220+
211221
private constructor(
212222
private caCertPem: string,
213223
caCert: x509.X509Certificate,
@@ -243,6 +253,60 @@ export class LocalCA {
243253
return new LocalCA(caOptions.cert.toString(), caCert, caKey, caOptions);
244254
}
245255

256+
private getIntermediateCA(): Promise<IntermediateCA> {
257+
if (!this.intermediateCA) {
258+
this.intermediateCA = this.createIntermediateCA();
259+
}
260+
return this.intermediateCA;
261+
}
262+
263+
private async createIntermediateCA(): Promise<IntermediateCA> {
264+
const keyPair = await crypto.subtle.generateKey(
265+
{ ...KEY_PAIR_ALGO, modulusLength: this.options.keyLength || 2048 },
266+
true,
267+
["sign", "verify"]
268+
) as CryptoKeyPair;
269+
270+
const subjectDistinguishedName = new x509.Name([
271+
{ C: [this.options.countryName ?? 'XX'] },
272+
{ O: ['Testserver'] },
273+
{ CN: ['Test Intermediate CA'] }
274+
]).toString();
275+
276+
const notBefore = new Date();
277+
notBefore.setDate(notBefore.getDate() - 1);
278+
const notAfter = new Date();
279+
notAfter.setFullYear(notAfter.getFullYear() + 1);
280+
281+
const cert = await x509.X509CertificateGenerator.create({
282+
serialNumber: generateSerialNumber(),
283+
subject: subjectDistinguishedName,
284+
issuer: this.caCert.subject,
285+
notBefore,
286+
notAfter,
287+
signingAlgorithm: KEY_PAIR_ALGO,
288+
publicKey: keyPair.publicKey as CryptoKey,
289+
signingKey: this.caKey,
290+
extensions: [
291+
new x509.BasicConstraintsExtension(true, 0, true),
292+
new x509.KeyUsagesExtension(
293+
x509.KeyUsageFlags.keyCertSign |
294+
x509.KeyUsageFlags.digitalSignature |
295+
x509.KeyUsageFlags.cRLSign,
296+
true
297+
),
298+
await x509.SubjectKeyIdentifierExtension.create(keyPair.publicKey as CryptoKey, false),
299+
await x509.AuthorityKeyIdentifierExtension.create(this.caCert, false)
300+
]
301+
});
302+
303+
return {
304+
cert,
305+
key: keyPair.privateKey as CryptoKey,
306+
pem: cert.toString('pem')
307+
};
308+
}
309+
246310
async generateCertificate(
247311
domain: string,
248312
options: CertGenerationOptions
@@ -282,9 +346,16 @@ export class LocalCA {
282346
}
283347

284348
const subjectDistinguishedName = new x509.Name(subjectJsonNameParams).toString();
349+
350+
const intermediate = options.incompleteChain && !options.selfSigned
351+
? await this.getIntermediateCA()
352+
: undefined;
353+
285354
const issuerDistinguishedName = options.selfSigned
286355
? subjectDistinguishedName
287-
: this.caCert.subject;
356+
: intermediate
357+
? intermediate.cert.subject
358+
: this.caCert.subject;
288359

289360
const notBefore = new Date();
290361
const notAfter = new Date();
@@ -325,7 +396,10 @@ export class LocalCA {
325396

326397
// We don't include SubjectKeyIdentifierExtension as that's no longer recommended
327398
if (!options.selfSigned) {
328-
extensions.push(await x509.AuthorityKeyIdentifierExtension.create(this.caCert, false));
399+
extensions.push(await x509.AuthorityKeyIdentifierExtension.create(
400+
intermediate ? intermediate.cert : this.caCert,
401+
false
402+
));
329403
}
330404

331405
const certificate = await x509.X509CertificateGenerator.create({
@@ -336,7 +410,11 @@ export class LocalCA {
336410
notAfter,
337411
signingAlgorithm: KEY_PAIR_ALGO,
338412
publicKey: leafKeyPair.publicKey,
339-
signingKey: options.selfSigned ? leafKeyPair.privateKey as CryptoKey : this.caKey,
413+
signingKey: options.selfSigned
414+
? leafKeyPair.privateKey as CryptoKey
415+
: intermediate
416+
? intermediate.key
417+
: this.caKey,
340418
extensions
341419
});
342420

@@ -346,7 +424,9 @@ export class LocalCA {
346424
await crypto.subtle.exportKey("pkcs8", leafKeyPair.privateKey as CryptoKey),
347425
"PRIVATE KEY"
348426
),
349-
cert: certPem,
427+
cert: intermediate
428+
? `${certPem.trimEnd()}\n${intermediate.pem.trimEnd()}\n`
429+
: certPem,
350430
ca: options.selfSigned ? certPem : this.caCertPem // Use cached CA cert PEM
351431
};
352432

‎src/tls-handler.ts‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { getExtensionData } from 'read-tls-client-hello';
88

99
import { ConnectionProcessor } from './process-connection.js';
1010
import { LocalCA } from './tls-certificates/local-ca.js';
11-
import { CertOptions, calculateCertCacheKey } from './tls-certificates/cert-definitions.js';
11+
import { CertOptions, calculateCertCacheKey, extractLeafCertificate } from './tls-certificates/cert-definitions.js';
1212
import { SecureContextCache } from './tls-certificates/secure-context-cache.js';
1313
import { tlsEndpoints } from './endpoints/endpoint-index.js';
1414
import { PROXY_PROTOCOL } from './proxy-protocol.js';
@@ -23,10 +23,11 @@ function calculateContextCacheKey(
2323
tlsOptions: tls.SecureContextOptions
2424
): string {
2525
const certKey = calculateCertCacheKey(domain, certOptions);
26+
const chainKey = certOptions.incompleteChain ? '|incomplete-chain' : '';
2627
const tlsKey = Object.keys(tlsOptions).length > 0
2728
? '|' + JSON.stringify(tlsOptions, Object.keys(tlsOptions).sort())
2829
: '';
29-
return certKey + tlsKey;
30+
return certKey + chainKey + tlsKey;
3031
}
3132

3233
function getCertExpiry(certPem: string): number {
@@ -171,11 +172,19 @@ class TlsConnectionHandler {
171172

172173
const secureContext = await secureContextCache.getOrCreate(cacheKey, async () => {
173174
const cert = await this.tlsConfig.generateCertificate(certDomain, certOptions);
175+
176+
const servedCert = certOptions.incompleteChain
177+
? extractLeafCertificate(cert.cert)
178+
: cert.cert;
179+
const servedCa = certOptions.incompleteChain
180+
? undefined
181+
: cert.ca;
182+
174183
return {
175184
context: tls.createSecureContext({
176185
key: cert.key,
177-
cert: cert.cert,
178-
ca: cert.ca,
186+
cert: servedCert,
187+
ca: servedCa,
179188
...tlsOptions
180189
}),
181190
// Temporary certs (e.g. local CA fallback while ACME pending) get short cache

‎test/incomplete-chain.spec.ts‎

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import * as net from 'net';
2+
import * as tls from 'tls';
3+
4+
import { expect } from 'chai';
5+
import { DestroyableServer, makeDestroyable } from 'destroyable-server';
6+
7+
import { extractLeafCertificate } from '../src/tls-certificates/cert-definitions.js';
8+
import { createTestServer } from './test-helpers.js';
9+
10+
const fakeCert = (body: string) =>
11+
`-----BEGIN CERTIFICATE-----\n${body}\n-----END CERTIFICATE-----`;
12+
13+
describe("extractLeafCertificate", () => {
14+
15+
it("returns only the leaf from a leaf+intermediates bundle", () => {
16+
const bundle = [fakeCert('LEAF'), fakeCert('INTERMEDIATE'), fakeCert('ROOT')].join('\n');
17+
18+
const leaf = extractLeafCertificate(bundle);
19+
20+
expect(leaf).to.include('LEAF');
21+
expect(leaf).to.not.include('INTERMEDIATE');
22+
expect(leaf).to.not.include('ROOT');
23+
});
24+
25+
it("returns the single certificate when there is no intermediate", () => {
26+
const leaf = extractLeafCertificate(fakeCert('LEAF'));
27+
expect(leaf).to.include('LEAF');
28+
});
29+
30+
it("throws when no certificate is present", () => {
31+
expect(() => extractLeafCertificate('not a certificate')).to.throw();
32+
});
33+
34+
});
35+
36+
describe("incomplete-chain certificates", () => {
37+
38+
let server: DestroyableServer;
39+
let serverPort: number;
40+
41+
beforeEach(async () => {
42+
server = makeDestroyable(await createTestServer());
43+
await new Promise<void>((resolve) => server.listen(resolve));
44+
serverPort = (server.address() as net.AddressInfo).port;
45+
});
46+
47+
afterEach(async () => {
48+
await server.destroy();
49+
});
50+
51+
const connectAndGetChain = (servername: string, alpn?: string[]) =>
52+
new Promise<{ cert: tls.DetailedPeerCertificate, protocol: string | false | null }>((resolve, reject) => {
53+
const conn = tls.connect({
54+
port: serverPort,
55+
servername,
56+
...(alpn ? { ALPNProtocols: alpn } : {}),
57+
rejectUnauthorized: false
58+
});
59+
conn.on('secureConnect', () => {
60+
resolve({ cert: conn.getPeerCertificate(true), protocol: conn.alpnProtocol });
61+
conn.destroy();
62+
});
63+
conn.on('error', reject);
64+
});
65+
66+
// Walk the chain the server actually presented, counting distinct certificates.
67+
const presentedChainLength = (leaf: tls.DetailedPeerCertificate) => {
68+
const seen = new Set<string>();
69+
let cert: tls.DetailedPeerCertificate | undefined = leaf;
70+
while (cert?.fingerprint256 && !seen.has(cert.fingerprint256)) {
71+
seen.add(cert.fingerprint256);
72+
cert = cert.issuerCertificate;
73+
}
74+
return seen.size;
75+
};
76+
77+
it("serves the leaf certificate for the requested domain", async () => {
78+
const { cert } = await connectAndGetChain('incomplete-chain.localhost');
79+
80+
expect(cert.subject.CN).to.equal('incomplete-chain.localhost');
81+
expect(cert.subjectaltname).to.equal('DNS:incomplete-chain.localhost');
82+
});
83+
84+
it("issues the leaf via a genuine intermediate CA", async () => {
85+
const { cert } = await connectAndGetChain('incomplete-chain.localhost');
86+
87+
expect(cert.issuer.CN).to.equal('Test Intermediate CA');
88+
expect(cert.issuer.CN).to.not.equal(cert.subject.CN);
89+
});
90+
91+
it("presents no intermediate certificates alongside the leaf", async () => {
92+
const { cert } = await connectAndGetChain('incomplete-chain.localhost');
93+
94+
// The intermediate is omitted, so the client receives a single certificate
95+
// and cannot build a complete chain even if it trusts the root.
96+
expect(presentedChainLength(cert)).to.equal(1);
97+
});
98+
99+
it("can combine incomplete-chain with protocol preferences", async () => {
100+
const { cert, protocol } = await connectAndGetChain(
101+
'http2--incomplete-chain.localhost',
102+
['http/1.1', 'h2']
103+
);
104+
105+
expect(cert.subject.CN).to.equal('http2--incomplete-chain.localhost');
106+
expect(protocol).to.equal('h2');
107+
});
108+
109+
});

‎test/tls-required.spec.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ describe("TLS-required endpoints over plain HTTP", () => {
3232
'self-signed',
3333
'untrusted-root',
3434
'wrong-host',
35+
'incomplete-chain',
3536
'http2',
3637
'http1'
3738
]) {

0 commit comments

Comments
 (0)