Skip to content

Commit fc2f82e

Browse files
authored
Merge pull request #175 from lipsumar/add-option-to-constrain-root-ca
Add option to constrain root CA to permitted domains
2 parents 5778dd0 + 9589ecd commit fc2f82e

File tree

2 files changed

+180
-6
lines changed

2 files changed

+180
-6
lines changed

src/util/tls.ts

Lines changed: 69 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import * as fs from 'fs/promises';
33
import { v4 as uuid } from "uuid";
44
import * as forge from 'node-forge';
55

6-
const { pki, md, util: { encode64 } } = forge;
6+
const { asn1, pki, md, util } = forge;
77

88
export type CAOptions = (CertDataOptions | CertPathOptions);
99

@@ -63,7 +63,10 @@ export async function generateCACertificate(options: {
6363
commonName?: string,
6464
organizationName?: string,
6565
countryName?: string,
66-
bits?: number
66+
bits?: number,
67+
nameConstraints?: {
68+
permitted?: string[]
69+
}
6770
} = {}) {
6871
options = _.defaults({}, options, {
6972
commonName: 'Mockttp Testing CA - DO NOT TRUST - TESTING ONLY',
@@ -98,11 +101,23 @@ export async function generateCACertificate(options: {
98101
{ name: 'organizationName', value: options.organizationName }
99102
]);
100103

101-
cert.setExtensions([
104+
const extensions: any[] = [
102105
{ name: 'basicConstraints', cA: true, critical: true },
103106
{ name: 'keyUsage', keyCertSign: true, digitalSignature: true, nonRepudiation: true, cRLSign: true, critical: true },
104-
{ name: 'subjectKeyIdentifier' }
105-
]);
107+
{ name: 'subjectKeyIdentifier' },
108+
];
109+
const permittedDomains = options.nameConstraints?.permitted || [];
110+
if(permittedDomains.length > 0) {
111+
extensions.push({
112+
critical: true,
113+
id: '2.5.29.30',
114+
name: 'nameConstraints',
115+
value: generateNameConstraints({
116+
permitted: permittedDomains,
117+
}),
118+
})
119+
}
120+
cert.setExtensions(extensions);
106121

107122
// Self-issued too
108123
cert.setIssuer(cert.subject.attributes);
@@ -116,9 +131,57 @@ export async function generateCACertificate(options: {
116131
};
117132
}
118133

134+
135+
type GenerateNameConstraintsInput = {
136+
/**
137+
* Array of permitted domains
138+
*/
139+
permitted?: string[];
140+
};
141+
142+
/**
143+
* Generate name constraints in conformance with
144+
* [RFC 5280 § 4.2.1.10](https://datatracker.ietf.org/doc/html/rfc5280#section-4.2.1.10)
145+
*/
146+
function generateNameConstraints(
147+
input: GenerateNameConstraintsInput
148+
): forge.asn1.Asn1 {
149+
const domainsToSequence = (ips: string[]) =>
150+
ips.map((domain) => {
151+
return asn1.create(asn1.Class.UNIVERSAL, asn1.Type.SEQUENCE, true, [
152+
asn1.create(
153+
asn1.Class.CONTEXT_SPECIFIC,
154+
2,
155+
false,
156+
util.encodeUtf8(domain)
157+
),
158+
]);
159+
});
160+
161+
const permittedAndExcluded: forge.asn1.Asn1[] = [];
162+
163+
if (input.permitted && input.permitted.length > 0) {
164+
permittedAndExcluded.push(
165+
asn1.create(
166+
asn1.Class.CONTEXT_SPECIFIC,
167+
0,
168+
true,
169+
domainsToSequence(input.permitted)
170+
)
171+
);
172+
}
173+
174+
return asn1.create(
175+
asn1.Class.UNIVERSAL,
176+
asn1.Type.SEQUENCE,
177+
true,
178+
permittedAndExcluded
179+
);
180+
}
181+
119182
export function generateSPKIFingerprint(certPem: PEM) {
120183
let cert = pki.certificateFromPem(certPem.toString('utf8'));
121-
return encode64(
184+
return util.encode64(
122185
pki.getPublicKeyFingerprint(cert.publicKey, {
123186
type: 'SubjectPublicKeyInfo',
124187
md: md.sha256.create(),

test/ca.spec.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,83 @@ nodeOnly(() => {
2929
await expect(fetch('https://localhost:4430')).to.have.responseText('signed response!');
3030
});
3131

32+
describe("constrained CA", () => {
33+
let constrainedCA: CA;
34+
let constrainedCaCert: string;
35+
36+
function localhostRequest({ hostname, port }: { hostname: string; port: number }) {
37+
return https.request({
38+
hostname,
39+
port,
40+
ca: [constrainedCaCert],
41+
lookup: (_, options, callback) => {
42+
if (options.all) {
43+
callback(null, [{ address: "127.0.0.1", family: 4 }]);
44+
} else {
45+
callback(null, "127.0.0.1", 4);
46+
}
47+
},
48+
});
49+
}
50+
51+
beforeEach(async () => {
52+
const rootCa = await generateCACertificate({
53+
nameConstraints: { permitted: ["example.com"] },
54+
});
55+
constrainedCaCert = rootCa.cert;
56+
constrainedCA = new CA(rootCa);
57+
});
58+
59+
it("can generate a valid certificate for a domain included in a constrained CA", async () => {
60+
61+
const { cert, key } = constrainedCA.generateCertificate("hello.example.com");
62+
63+
server = https.createServer({ cert, key }, (req: any, res: any) => {
64+
res.writeHead(200);
65+
res.end("signed response!");
66+
});
67+
await new Promise<void>((resolve) => server.listen(4430, resolve));
68+
69+
const req = localhostRequest({hostname: "hello.example.com", port: 4430});
70+
return new Promise<void>((resolve, reject) => {
71+
req.on("response", (res) => {
72+
expect(res.statusCode).to.equal(200);
73+
res.on("data", (data) => {
74+
expect(data.toString()).to.equal("signed response!");
75+
resolve();
76+
});
77+
});
78+
req.on("error", (err) => {
79+
reject(err);
80+
});
81+
req.end();
82+
});
83+
84+
});
85+
86+
it("can not generate a valid certificate for a domain not included in a constrained CA", async () => {
87+
const { cert, key } = constrainedCA.generateCertificate("hello.other.com");
88+
89+
server = https.createServer({ cert, key }, (req: any, res: any) => {
90+
res.writeHead(200);
91+
res.end("signed response!");
92+
});
93+
await new Promise<void>((resolve) => server.listen(4430, resolve));
94+
95+
const req = localhostRequest({hostname: "hello.other.com", port: 4430});
96+
return new Promise<void>((resolve, reject) => {
97+
req.on("error", (err) => {
98+
expect(err.message).to.equal("permitted subtree violation");
99+
resolve();
100+
});
101+
req.on("response", (res) => {
102+
expect.fail("Unexpected response received");
103+
});
104+
req.end();
105+
});
106+
});
107+
});
108+
32109
afterEach((done) => {
33110
if (server) server.close(done);
34111
});
@@ -176,5 +253,39 @@ nodeOnly(() => {
176253
expect(errors.join('\n')).to.equal('');
177254
});
178255

256+
it("should generate a CA cert constrained to a domain that pass lintcert checks", async function(){
257+
this.retries(3); // Remote server can be unreliable
258+
259+
const caCertificate = await generateCACertificate({
260+
nameConstraints: {
261+
permitted: ['example.com']
262+
}
263+
});
264+
265+
const { cert } = caCertificate;
266+
267+
const response = await ignoreNetworkError(
268+
fetch('https://crt.sh/lintcert', {
269+
method: 'POST',
270+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
271+
body: new URLSearchParams({'b64cert': cert})
272+
}),
273+
{ context: this }
274+
);
275+
276+
const lintOutput = await response.text();
277+
278+
const lintResults = lintOutput
279+
.split('\n')
280+
.map(line => line.split('\t').slice(1))
281+
.filter(line => line.length > 1);
282+
283+
const errors = lintResults
284+
.filter(([level]) => level === 'ERROR')
285+
.map(([_level, message]) => message);
286+
287+
expect(errors.join('\n')).to.equal('');
288+
});
289+
179290
});
180291
});

0 commit comments

Comments
 (0)