From 4d79cdcb77be3c0bef739165d056f9b5f8621426 Mon Sep 17 00:00:00 2001 From: Emmanuel Pire Date: Fri, 5 Jul 2024 14:16:11 +0200 Subject: [PATCH 1/7] add option contrainToDomains to generateCACertificate --- src/util/tls.ts | 87 +++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 81 insertions(+), 6 deletions(-) diff --git a/src/util/tls.ts b/src/util/tls.ts index 6d6819b81..460d0beb4 100644 --- a/src/util/tls.ts +++ b/src/util/tls.ts @@ -3,7 +3,7 @@ import * as fs from 'fs/promises'; import { v4 as uuid } from "uuid"; import * as forge from 'node-forge'; -const { pki, md, util: { encode64 } } = forge; +const { asn1, pki, md, util } = forge; export type CAOptions = (CertDataOptions | CertPathOptions); @@ -63,7 +63,8 @@ export async function generateCACertificate(options: { commonName?: string, organizationName?: string, countryName?: string, - bits?: number + bits?: number, + contrainToDomains?: string[] } = {}) { options = _.defaults({}, options, { commonName: 'Mockttp Testing CA - DO NOT TRUST - TESTING ONLY', @@ -98,11 +99,21 @@ export async function generateCACertificate(options: { { name: 'organizationName', value: options.organizationName } ]); - cert.setExtensions([ + const extensions: any[] = [ { name: 'basicConstraints', cA: true, critical: true }, { name: 'keyUsage', keyCertSign: true, digitalSignature: true, nonRepudiation: true, cRLSign: true, critical: true }, - { name: 'subjectKeyIdentifier' } - ]); + { name: 'subjectKeyIdentifier' }, + ]; + if(options.contrainToDomains && options.contrainToDomains.length > 0) { + extensions.push({ + critical: true, + name: 'nameConstraints', + value: generateNameConstraints({ + permitted: options.contrainToDomains, + }), + }) + } + cert.setExtensions(extensions); // Self-issued too cert.setIssuer(cert.subject.attributes); @@ -116,9 +127,73 @@ export async function generateCACertificate(options: { }; } + +type GenerateNameConstraintsInput = { + /** + * Array of excluded domains + */ + excluded?: string[]; + + /** + * Array of permitted domains + */ + permitted?: string[]; +}; + +/** + * Generate name constraints in conformance with + * [RFC 5280 ยง 4.2.1.10](https://datatracker.ietf.org/doc/html/rfc5280#section-4.2.1.10) + */ +function generateNameConstraints( + input: GenerateNameConstraintsInput +): forge.asn1.Asn1 { + const ipsToSequence = (ips: string[]) => + ips.map((domain) => { + return asn1.create(asn1.Class.UNIVERSAL, asn1.Type.SEQUENCE, true, [ + asn1.create( + asn1.Class.CONTEXT_SPECIFIC, + 2, + false, + util.encodeUtf8(domain) + ), + ]); + }); + + const permittedAndExcluded: forge.asn1.Asn1[] = []; + + if (input.permitted !== undefined) { + permittedAndExcluded.push( + asn1.create( + asn1.Class.CONTEXT_SPECIFIC, + 0, + true, + ipsToSequence(input.permitted) + ) + ); + } + + if (input.excluded !== undefined) { + permittedAndExcluded.push( + asn1.create( + asn1.Class.CONTEXT_SPECIFIC, + 1, + true, + ipsToSequence(input.excluded) + ) + ); + } + + return asn1.create( + asn1.Class.UNIVERSAL, + asn1.Type.SEQUENCE, + true, + permittedAndExcluded + ); +} + export function generateSPKIFingerprint(certPem: PEM) { let cert = pki.certificateFromPem(certPem.toString('utf8')); - return encode64( + return util.encode64( pki.getPublicKeyFingerprint(cert.publicKey, { type: 'SubjectPublicKeyInfo', md: md.sha256.create(), From 336468ef9d5524a7999d9917b55a2acdc315bb83 Mon Sep 17 00:00:00 2001 From: Emmanuel Pire Date: Mon, 8 Jul 2024 11:11:25 +0200 Subject: [PATCH 2/7] address PR feedback --- src/util/tls.ts | 31 +++++++++---------------------- 1 file changed, 9 insertions(+), 22 deletions(-) diff --git a/src/util/tls.ts b/src/util/tls.ts index 460d0beb4..73e49d974 100644 --- a/src/util/tls.ts +++ b/src/util/tls.ts @@ -64,7 +64,9 @@ export async function generateCACertificate(options: { organizationName?: string, countryName?: string, bits?: number, - contrainToDomains?: string[] + nameConstraints?: { + permitted?: string[] + } } = {}) { options = _.defaults({}, options, { commonName: 'Mockttp Testing CA - DO NOT TRUST - TESTING ONLY', @@ -104,12 +106,13 @@ export async function generateCACertificate(options: { { name: 'keyUsage', keyCertSign: true, digitalSignature: true, nonRepudiation: true, cRLSign: true, critical: true }, { name: 'subjectKeyIdentifier' }, ]; - if(options.contrainToDomains && options.contrainToDomains.length > 0) { + const permittedDomains = options.nameConstraints?.permitted || []; + if(permittedDomains.length > 0) { extensions.push({ critical: true, name: 'nameConstraints', value: generateNameConstraints({ - permitted: options.contrainToDomains, + permitted: permittedDomains, }), }) } @@ -129,11 +132,6 @@ export async function generateCACertificate(options: { type GenerateNameConstraintsInput = { - /** - * Array of excluded domains - */ - excluded?: string[]; - /** * Array of permitted domains */ @@ -147,7 +145,7 @@ type GenerateNameConstraintsInput = { function generateNameConstraints( input: GenerateNameConstraintsInput ): forge.asn1.Asn1 { - const ipsToSequence = (ips: string[]) => + const domainsToSequence = (ips: string[]) => ips.map((domain) => { return asn1.create(asn1.Class.UNIVERSAL, asn1.Type.SEQUENCE, true, [ asn1.create( @@ -161,24 +159,13 @@ function generateNameConstraints( const permittedAndExcluded: forge.asn1.Asn1[] = []; - if (input.permitted !== undefined) { + if (input.permitted && input.permitted.length > 0) { permittedAndExcluded.push( asn1.create( asn1.Class.CONTEXT_SPECIFIC, 0, true, - ipsToSequence(input.permitted) - ) - ); - } - - if (input.excluded !== undefined) { - permittedAndExcluded.push( - asn1.create( - asn1.Class.CONTEXT_SPECIFIC, - 1, - true, - ipsToSequence(input.excluded) + domainsToSequence(input.permitted) ) ); } From 66cc03276e3348e8f6fd928b5edd5e082867f28b Mon Sep 17 00:00:00 2001 From: Emmanuel Pire Date: Tue, 9 Jul 2024 12:56:48 +0200 Subject: [PATCH 3/7] add tests --- src/util/tls.ts | 1 + test/ca.spec.ts | 108 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+) diff --git a/src/util/tls.ts b/src/util/tls.ts index 73e49d974..e08e0708a 100644 --- a/src/util/tls.ts +++ b/src/util/tls.ts @@ -110,6 +110,7 @@ export async function generateCACertificate(options: { if(permittedDomains.length > 0) { extensions.push({ critical: true, + id: '2.5.29.30', name: 'nameConstraints', value: generateNameConstraints({ permitted: permittedDomains, diff --git a/test/ca.spec.ts b/test/ca.spec.ts index 71dd899dc..eeb645b12 100644 --- a/test/ca.spec.ts +++ b/test/ca.spec.ts @@ -29,6 +29,80 @@ nodeOnly(() => { await expect(fetch('https://localhost:4430')).to.have.responseText('signed response!'); }); + describe("constrained CA", () => { + let constrainedCA: CA; + let constrainedCaCert: string; + + beforeEach(async () => { + const rootCa = await generateCACertificate({ + nameConstraints: { permitted: ["example.com"] }, + }); + constrainedCaCert = rootCa.cert; + constrainedCA = new CA(rootCa); + }); + + it("can generate a valid certificate for a domain included in a constrained CA", async () => { + + const { cert, key } = constrainedCA.generateCertificate("hello.example.com"); + + server = https.createServer({ cert, key }, (req: any, res: any) => { + res.writeHead(200); + res.end("signed response!"); + }); + await new Promise((resolve) => server.listen(4430, resolve)); + + await new Promise((resolve) => { + const req = https.request( + { + hostname: "hello.example.com", + port: 4430, + ca: [constrainedCaCert], + lookup: (hostname, options, callback) => { + callback(null, "127.0.0.1", 4); + }, + }, + (res) => { + expect(res.statusCode).to.equal(200); + res.on("data", (data) => { + expect(data.toString()).to.equal("signed response!"); + resolve(); + }); + } + ); + req.end(); + }); + + }); + + it("can not generate a valid certificate for a domain not included in a constrained CA", async () => { + const { cert, key } = constrainedCA.generateCertificate("hello.other.com"); + + server = https.createServer({ cert, key }, (req: any, res: any) => { + res.writeHead(200); + res.end("signed response!"); + }); + await new Promise((resolve) => server.listen(4430, resolve)); + + await new Promise((resolve) => { + const req = https.request( + { + hostname: "hello.other.com", + port: 4430, + ca: [constrainedCaCert], + lookup: (hostname, options, callback) => { + callback(null, "127.0.0.1", 4); + }, + }, + ); + req.on("error", (err) => { + expect(err.message).to.equal("permitted subtree violation"); + resolve(); + }) + req.end(); + }); + }); + }); + afterEach((done) => { if (server) server.close(done); }); @@ -176,5 +250,39 @@ nodeOnly(() => { expect(errors.join('\n')).to.equal(''); }); + it("should generate a CA cert constrained to a domain that pass lintcert checks", async function(){ + this.retries(3); // Remote server can be unreliable + + const caCertificate = await generateCACertificate({ + nameConstraints: { + permitted: ['example.com'] + } + }); + + const { cert } = caCertificate; + + const response = await ignoreNetworkError( + fetch('https://crt.sh/lintcert', { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({'b64cert': cert}) + }), + { context: this } + ); + + const lintOutput = await response.text(); + + const lintResults = lintOutput + .split('\n') + .map(line => line.split('\t').slice(1)) + .filter(line => line.length > 1); + + const errors = lintResults + .filter(([level]) => level === 'ERROR') + .map(([_level, message]) => message); + + expect(errors.join('\n')).to.equal(''); + }); + }); }); \ No newline at end of file From 3727ff440d86afe77a6e90cc2c9964a5d731901b Mon Sep 17 00:00:00 2001 From: Emmanuel Pire Date: Tue, 9 Jul 2024 14:16:27 +0200 Subject: [PATCH 4/7] fix lookup callback for node 22 --- test/ca.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/ca.spec.ts b/test/ca.spec.ts index eeb645b12..a082e773e 100644 --- a/test/ca.spec.ts +++ b/test/ca.spec.ts @@ -58,7 +58,7 @@ nodeOnly(() => { port: 4430, ca: [constrainedCaCert], lookup: (hostname, options, callback) => { - callback(null, "127.0.0.1", 4); + callback(null, [{ address: "127.0.0.1", family: 4 }]); }, }, (res) => { @@ -90,7 +90,7 @@ nodeOnly(() => { port: 4430, ca: [constrainedCaCert], lookup: (hostname, options, callback) => { - callback(null, "127.0.0.1", 4); + callback(null, [{ address: "127.0.0.1", family: 4 }]); }, }, ); From ecec11b70c2186bb604ac22631d067fe73d1c642 Mon Sep 17 00:00:00 2001 From: Emmanuel Pire Date: Tue, 9 Jul 2024 14:28:56 +0200 Subject: [PATCH 5/7] add support for all node versions --- test/ca.spec.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/test/ca.spec.ts b/test/ca.spec.ts index a082e773e..587e90b04 100644 --- a/test/ca.spec.ts +++ b/test/ca.spec.ts @@ -7,8 +7,10 @@ import { expect, fetch, ignoreNetworkError, nodeOnly } from "./test-utils"; import { CA, generateCACertificate } from '../src/util/tls'; +const nodeMajorVersion = parseInt(process.versions.node.split('.')[0], 10); + nodeOnly(() => { - describe("Certificate generation", () => { + describe.only("Certificate generation", () => { const caKey = fs.readFile(path.join(__dirname, 'fixtures', 'test-ca.key'), 'utf8'); const caCert = fs.readFile(path.join(__dirname, 'fixtures', 'test-ca.pem'), 'utf8'); @@ -58,7 +60,11 @@ nodeOnly(() => { port: 4430, ca: [constrainedCaCert], lookup: (hostname, options, callback) => { - callback(null, [{ address: "127.0.0.1", family: 4 }]); + if (nodeMajorVersion <= 18) { + callback(null, "127.0.0.1", 4); + } else { + callback(null, [{ address: "127.0.0.1", family: 4 }]); + } }, }, (res) => { @@ -90,7 +96,11 @@ nodeOnly(() => { port: 4430, ca: [constrainedCaCert], lookup: (hostname, options, callback) => { - callback(null, [{ address: "127.0.0.1", family: 4 }]); + if (nodeMajorVersion <= 18) { + callback(null, "127.0.0.1", 4); + } else { + callback(null, [{ address: "127.0.0.1", family: 4 }]); + } }, }, ); From 483bb5cdcd705864b779163f45b5dfff13a0b616 Mon Sep 17 00:00:00 2001 From: Emmanuel Pire Date: Tue, 9 Jul 2024 15:38:29 +0200 Subject: [PATCH 6/7] remove only --- test/ca.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/ca.spec.ts b/test/ca.spec.ts index 587e90b04..4bcbc7566 100644 --- a/test/ca.spec.ts +++ b/test/ca.spec.ts @@ -10,7 +10,7 @@ import { CA, generateCACertificate } from '../src/util/tls'; const nodeMajorVersion = parseInt(process.versions.node.split('.')[0], 10); nodeOnly(() => { - describe.only("Certificate generation", () => { + describe("Certificate generation", () => { const caKey = fs.readFile(path.join(__dirname, 'fixtures', 'test-ca.key'), 'utf8'); const caCert = fs.readFile(path.join(__dirname, 'fixtures', 'test-ca.pem'), 'utf8'); From 9589ecda20b6bd2d4500196212fff83498be6ec3 Mon Sep 17 00:00:00 2001 From: Emmanuel Pire Date: Wed, 10 Jul 2024 10:03:51 +0200 Subject: [PATCH 7/7] refactor spec --- test/ca.spec.ts | 73 ++++++++++++++++++++++--------------------------- 1 file changed, 33 insertions(+), 40 deletions(-) diff --git a/test/ca.spec.ts b/test/ca.spec.ts index 4bcbc7566..ee659d97f 100644 --- a/test/ca.spec.ts +++ b/test/ca.spec.ts @@ -7,8 +7,6 @@ import { expect, fetch, ignoreNetworkError, nodeOnly } from "./test-utils"; import { CA, generateCACertificate } from '../src/util/tls'; -const nodeMajorVersion = parseInt(process.versions.node.split('.')[0], 10); - nodeOnly(() => { describe("Certificate generation", () => { const caKey = fs.readFile(path.join(__dirname, 'fixtures', 'test-ca.key'), 'utf8'); @@ -35,6 +33,21 @@ nodeOnly(() => { let constrainedCA: CA; let constrainedCaCert: string; + function localhostRequest({ hostname, port }: { hostname: string; port: number }) { + return https.request({ + hostname, + port, + ca: [constrainedCaCert], + lookup: (_, options, callback) => { + if (options.all) { + callback(null, [{ address: "127.0.0.1", family: 4 }]); + } else { + callback(null, "127.0.0.1", 4); + } + }, + }); + } + beforeEach(async () => { const rootCa = await generateCACertificate({ nameConstraints: { permitted: ["example.com"] }, @@ -53,28 +66,18 @@ nodeOnly(() => { }); await new Promise((resolve) => server.listen(4430, resolve)); - await new Promise((resolve) => { - const req = https.request( - { - hostname: "hello.example.com", - port: 4430, - ca: [constrainedCaCert], - lookup: (hostname, options, callback) => { - if (nodeMajorVersion <= 18) { - callback(null, "127.0.0.1", 4); - } else { - callback(null, [{ address: "127.0.0.1", family: 4 }]); - } - }, - }, - (res) => { - expect(res.statusCode).to.equal(200); - res.on("data", (data) => { - expect(data.toString()).to.equal("signed response!"); - resolve(); - }); - } - ); + const req = localhostRequest({hostname: "hello.example.com", port: 4430}); + return new Promise((resolve, reject) => { + req.on("response", (res) => { + expect(res.statusCode).to.equal(200); + res.on("data", (data) => { + expect(data.toString()).to.equal("signed response!"); + resolve(); + }); + }); + req.on("error", (err) => { + reject(err); + }); req.end(); }); @@ -89,25 +92,15 @@ nodeOnly(() => { }); await new Promise((resolve) => server.listen(4430, resolve)); - await new Promise((resolve) => { - const req = https.request( - { - hostname: "hello.other.com", - port: 4430, - ca: [constrainedCaCert], - lookup: (hostname, options, callback) => { - if (nodeMajorVersion <= 18) { - callback(null, "127.0.0.1", 4); - } else { - callback(null, [{ address: "127.0.0.1", family: 4 }]); - } - }, - }, - ); + const req = localhostRequest({hostname: "hello.other.com", port: 4430}); + return new Promise((resolve, reject) => { req.on("error", (err) => { expect(err.message).to.equal("permitted subtree violation"); resolve(); - }) + }); + req.on("response", (res) => { + expect.fail("Unexpected response received"); + }); req.end(); }); });