Skip to content

Commit e5ebdb1

Browse files
LtadrianAdrian Gracia
andauthored
SQC-352 SQC-353 Create cert upload command for client side mtls certificates and ca chain certificates (#7466)
Co-authored-by: Adrian Gracia <[email protected]>
1 parent 63a60bd commit e5ebdb1

File tree

11 files changed

+1322
-91
lines changed

11 files changed

+1322
-91
lines changed

.changeset/hip-cameras-yawn.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"wrangler": minor
3+
---
4+
5+
feat: implement the `wrangler cert upload` command
6+
7+
This command allows users to upload a mTLS certificate/private key or certificate-authority certificate chain.
8+
9+
For uploading mTLS certificate, run:
10+
11+
- `wrangler cert upload mtls-certificate --cert cert.pem --key key.pem --name MY_CERT`
12+
13+
For uploading CA certificate chain, run:
14+
15+
- `wrangler cert upload certificate-authority --ca-cert server-ca.pem --name SERVER_CA`

packages/wrangler/e2e/cert.test.ts

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
import { randomUUID } from "node:crypto";
2+
import * as forge from "node-forge";
3+
import { describe, expect, it } from "vitest";
4+
import { WranglerE2ETestHelper } from "./helpers/e2e-wrangler-test";
5+
import { normalizeOutput } from "./helpers/normalize";
6+
7+
// Generate X509 self signed root key pair and certificate
8+
function generateRootCertificate() {
9+
const rootKeys = forge.pki.rsa.generateKeyPair(2048);
10+
const rootCert = forge.pki.createCertificate();
11+
rootCert.publicKey = rootKeys.publicKey;
12+
rootCert.serialNumber = "01";
13+
rootCert.validity.notBefore = new Date();
14+
rootCert.validity.notAfter = new Date();
15+
rootCert.validity.notAfter.setFullYear(
16+
rootCert.validity.notBefore.getFullYear() + 10
17+
); // 10 years validity
18+
19+
const rootAttrs = [
20+
{ name: "commonName", value: "Root CA" },
21+
{ name: "countryName", value: "US" },
22+
{ shortName: "ST", value: "California" },
23+
{ name: "organizationName", value: "Localhost Root CA" },
24+
];
25+
rootCert.setSubject(rootAttrs);
26+
rootCert.setIssuer(rootAttrs); // Self-signed
27+
28+
rootCert.sign(rootKeys.privateKey, forge.md.sha256.create());
29+
30+
return { certificate: rootCert, privateKey: rootKeys.privateKey };
31+
}
32+
33+
// Generate X509 leaf certificate signed by the root
34+
function generateLeafCertificate(
35+
rootCert: forge.pki.Certificate,
36+
rootKey: forge.pki.PrivateKey
37+
) {
38+
const leafKeys = forge.pki.rsa.generateKeyPair(2048);
39+
const leafCert = forge.pki.createCertificate();
40+
leafCert.publicKey = leafKeys.publicKey;
41+
leafCert.serialNumber = "02";
42+
leafCert.validity.notBefore = new Date();
43+
leafCert.validity.notAfter = new Date();
44+
leafCert.validity.notAfter.setFullYear(2034, 10, 18);
45+
46+
const leafAttrs = [
47+
{ name: "commonName", value: "example.org" },
48+
{ name: "countryName", value: "US" },
49+
{ shortName: "ST", value: "California" },
50+
{ name: "organizationName", value: "Example Inc" },
51+
];
52+
leafCert.setSubject(leafAttrs);
53+
leafCert.setIssuer(rootCert.subject.attributes); // Signed by root
54+
55+
leafCert.sign(rootKey, forge.md.sha256.create()); // Signed using root's private key
56+
57+
const pemLeafCert = forge.pki.certificateToPem(leafCert);
58+
const pemLeafKey = forge.pki.privateKeyToPem(leafKeys.privateKey);
59+
60+
return { certificate: pemLeafCert, privateKey: pemLeafKey };
61+
}
62+
63+
// Generate self signed X509 CA root certificate
64+
function generateRootCaCert() {
65+
// Create a key pair (private and public keys)
66+
const keyPair = forge.pki.rsa.generateKeyPair(2048);
67+
68+
// Create a new X.509 certificate
69+
const cert = forge.pki.createCertificate();
70+
71+
// Set certificate fields
72+
cert.publicKey = keyPair.publicKey;
73+
cert.serialNumber = "01";
74+
cert.validity.notBefore = new Date();
75+
cert.validity.notAfter = new Date();
76+
cert.validity.notAfter.setFullYear(2034, 10, 18);
77+
78+
// Add issuer and subject fields (for a root CA, they are the same)
79+
const attrs = [
80+
{ name: "commonName", value: "Localhost CA" },
81+
{ name: "countryName", value: "US" },
82+
{ shortName: "ST", value: "California" },
83+
{ name: "localityName", value: "San Francisco" },
84+
{ name: "organizationName", value: "Localhost" },
85+
{ shortName: "OU", value: "SSL Department" },
86+
];
87+
cert.setSubject(attrs);
88+
cert.setIssuer(attrs);
89+
90+
// Add basic constraints and key usage extensions
91+
cert.setExtensions([
92+
{
93+
name: "basicConstraints",
94+
cA: true,
95+
},
96+
{
97+
name: "keyUsage",
98+
keyCertSign: true,
99+
digitalSignature: true,
100+
cRLSign: true,
101+
},
102+
]);
103+
104+
// Self-sign the certificate with the private key
105+
cert.sign(keyPair.privateKey, forge.md.sha256.create());
106+
107+
// Convert the certificate and private key to PEM format
108+
const pemCert = forge.pki.certificateToPem(cert);
109+
const pemPrivateKey = forge.pki.privateKeyToPem(keyPair.privateKey);
110+
111+
return { certificate: pemCert, privateKey: pemPrivateKey };
112+
}
113+
114+
describe("cert", () => {
115+
const normalize = (str: string) =>
116+
normalizeOutput(str, {
117+
[process.env.CLOUDFLARE_ACCOUNT_ID as string]: "CLOUDFLARE_ACCOUNT_ID",
118+
});
119+
const helper = new WranglerE2ETestHelper();
120+
// Generate root and leaf certificates
121+
const { certificate: rootCert, privateKey: rootKey } =
122+
generateRootCertificate();
123+
const { certificate: leafCert, privateKey: leafKey } =
124+
generateLeafCertificate(rootCert, rootKey);
125+
const { certificate: caCert } = generateRootCaCert();
126+
127+
// Generate filenames for concurrent e2e test environment
128+
const mtlsCertName = `mtls_cert_${randomUUID()}`;
129+
const caCertName = `ca_cert_${randomUUID()}`;
130+
131+
it("upload mtls-certificate", async () => {
132+
// locally generated certs/key
133+
await helper.seed({ "mtls_client_cert_file.pem": leafCert });
134+
await helper.seed({ "mtls_client_private_key_file.pem": leafKey });
135+
136+
const output = await helper.run(
137+
`wrangler cert upload mtls-certificate --name ${mtlsCertName} --cert mtls_client_cert_file.pem --key mtls_client_private_key_file.pem`
138+
);
139+
expect(normalize(output.stdout)).toMatchInlineSnapshot(`
140+
"Uploading mTLS Certificate mtls_cert_00000000-0000-0000-0000-000000000000...
141+
Success! Uploaded mTLS Certificate mtls_cert_00000000-0000-0000-0000-000000000000
142+
ID: 00000000-0000-0000-0000-000000000000
143+
Issuer: CN=Root CA,O=Localhost Root CA,ST=California,C=US
144+
Expires on 11/18/2034"
145+
`);
146+
});
147+
148+
it("upload certificate-authority", async () => {
149+
await helper.seed({ "ca_chain_cert.pem": caCert });
150+
151+
const output = await helper.run(
152+
`wrangler cert upload certificate-authority --name ${caCertName} --ca-cert ca_chain_cert.pem`
153+
);
154+
expect(normalize(output.stdout)).toMatchInlineSnapshot(`
155+
"Uploading CA Certificate ca_cert_00000000-0000-0000-0000-000000000000...
156+
Success! Uploaded CA Certificate ca_cert_00000000-0000-0000-0000-000000000000
157+
ID: 00000000-0000-0000-0000-000000000000
158+
Issuer: CN=Localhost CA,OU=SSL Department,O=Localhost,L=San Francisco,ST=California,C=US
159+
Expires on 11/18/2034"
160+
`);
161+
});
162+
163+
it("list cert", async () => {
164+
const output = await helper.run(`wrangler cert list`);
165+
const result = normalize(output.stdout);
166+
expect(result).toContain(
167+
`Name: mtls_cert_00000000-0000-0000-0000-000000000000`
168+
);
169+
expect(result).toContain(
170+
`Name: ca_cert_00000000-0000-0000-0000-000000000000`
171+
);
172+
});
173+
174+
it("delete mtls cert", async () => {
175+
const delete_mtls_cert_output = await helper.run(
176+
`wrangler cert delete --name ${mtlsCertName}`
177+
);
178+
expect(normalize(delete_mtls_cert_output.stdout)).toMatchInlineSnapshot(
179+
`
180+
"? Are you sure you want to delete certificate 00000000-0000-0000-0000-000000000000 (mtls_cert_00000000-0000-0000-0000-000000000000)?
181+
🤖 Using fallback value in non-interactive context: yes
182+
Deleted certificate 00000000-0000-0000-0000-000000000000 (mtls_cert_00000000-0000-0000-0000-000000000000) successfully"
183+
`
184+
);
185+
});
186+
187+
it("delete ca chain cert", async () => {
188+
const delete_ca_cert_output = await helper.run(
189+
`wrangler cert delete --name ${caCertName}`
190+
);
191+
expect(normalize(delete_ca_cert_output.stdout)).toMatchInlineSnapshot(
192+
`
193+
"? Are you sure you want to delete certificate 00000000-0000-0000-0000-000000000000 (ca_cert_00000000-0000-0000-0000-000000000000)?
194+
🤖 Using fallback value in non-interactive context: yes
195+
Deleted certificate 00000000-0000-0000-0000-000000000000 (ca_cert_00000000-0000-0000-0000-000000000000) successfully"
196+
`
197+
);
198+
});
199+
});

packages/wrangler/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@
101101
"@types/mime": "^3.0.4",
102102
"@types/minimatch": "^5.1.2",
103103
"@types/node": "catalog:default",
104+
"@types/node-forge": "^1.3.11",
104105
"@types/prompts": "^2.0.14",
105106
"@types/resolve": "^1.20.6",
106107
"@types/shell-quote": "^1.7.2",
@@ -133,6 +134,7 @@
133134
"minimatch": "^5.1.0",
134135
"mock-socket": "^9.3.1",
135136
"msw": "2.4.3",
137+
"node-forge": "^1.3.1",
136138
"open": "^8.4.0",
137139
"p-queue": "^7.2.0",
138140
"patch-console": "^1.0.0",

0 commit comments

Comments
 (0)