diff --git a/src/util/tls.ts b/src/util/tls.ts index 6d6819b81..e08e0708a 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,10 @@ export async function generateCACertificate(options: { commonName?: string, organizationName?: string, countryName?: string, - bits?: number + bits?: number, + nameConstraints?: { + permitted?: string[] + } } = {}) { options = _.defaults({}, options, { commonName: 'Mockttp Testing CA - DO NOT TRUST - TESTING ONLY', @@ -98,11 +101,23 @@ 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' }, + ]; + const permittedDomains = options.nameConstraints?.permitted || []; + if(permittedDomains.length > 0) { + extensions.push({ + critical: true, + id: '2.5.29.30', + name: 'nameConstraints', + value: generateNameConstraints({ + permitted: permittedDomains, + }), + }) + } + cert.setExtensions(extensions); // Self-issued too cert.setIssuer(cert.subject.attributes); @@ -116,9 +131,57 @@ export async function generateCACertificate(options: { }; } + +type GenerateNameConstraintsInput = { + /** + * 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 domainsToSequence = (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 && input.permitted.length > 0) { + permittedAndExcluded.push( + asn1.create( + asn1.Class.CONTEXT_SPECIFIC, + 0, + true, + domainsToSequence(input.permitted) + ) + ); + } + + 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(), diff --git a/test/ca.spec.ts b/test/ca.spec.ts index 71dd899dc..ee659d97f 100644 --- a/test/ca.spec.ts +++ b/test/ca.spec.ts @@ -29,6 +29,83 @@ nodeOnly(() => { await expect(fetch('https://localhost:4430')).to.have.responseText('signed response!'); }); + describe("constrained CA", () => { + 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"] }, + }); + 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)); + + 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(); + }); + + }); + + 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)); + + 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(); + }); + }); + }); + afterEach((done) => { if (server) server.close(done); }); @@ -176,5 +253,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