Skip to content

Commit

Permalink
Merge pull request #175 from lipsumar/add-option-to-constrain-root-ca
Browse files Browse the repository at this point in the history
Add option to constrain root CA to permitted domains
  • Loading branch information
pimterry authored Jul 10, 2024
2 parents 5778dd0 + 9589ecd commit fc2f82e
Show file tree
Hide file tree
Showing 2 changed files with 180 additions and 6 deletions.
75 changes: 69 additions & 6 deletions src/util/tls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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);
Expand All @@ -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(),
Expand Down
111 changes: 111 additions & 0 deletions test/ca.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>((resolve) => server.listen(4430, resolve));

const req = localhostRequest({hostname: "hello.example.com", port: 4430});
return new Promise<void>((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<void>((resolve) => server.listen(4430, resolve));

const req = localhostRequest({hostname: "hello.other.com", port: 4430});
return new Promise<void>((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);
});
Expand Down Expand Up @@ -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('');
});

});
});

0 comments on commit fc2f82e

Please sign in to comment.