Skip to content

Commit 16d1a00

Browse files
committed
feat(new tool): x509 Certificate Generator
Fix CorentinTh#857
1 parent e073b2b commit 16d1a00

File tree

4 files changed

+342
-1
lines changed

4 files changed

+342
-1
lines changed

src/tools/index.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { tool as base64FileConverter } from './base64-file-converter';
22
import { tool as base64StringConverter } from './base64-string-converter';
33
import { tool as basicAuthGenerator } from './basic-auth-generator';
44
import { tool as textToUnicode } from './text-to-unicode';
5+
import { tool as x509CertificateGenerator } from './x509-certificate-generator';
56
import { tool as pdfSignatureChecker } from './pdf-signature-checker';
67
import { tool as numeronymGenerator } from './numeronym-generator';
78
import { tool as macAddressGenerator } from './mac-address-generator';
@@ -81,7 +82,20 @@ import { tool as yamlViewer } from './yaml-viewer';
8182
export const toolsByCategory: ToolCategory[] = [
8283
{
8384
name: 'Crypto',
84-
components: [tokenGenerator, hashText, bcrypt, uuidGenerator, ulidGenerator, cypher, bip39, hmacGenerator, rsaKeyPairGenerator, passwordStrengthAnalyser, pdfSignatureChecker],
85+
components: [
86+
tokenGenerator,
87+
hashText,
88+
bcrypt,
89+
uuidGenerator,
90+
ulidGenerator,
91+
cypher,
92+
bip39,
93+
hmacGenerator,
94+
rsaKeyPairGenerator,
95+
x509CertificateGenerator,
96+
passwordStrengthAnalyser,
97+
pdfSignatureChecker,
98+
],
8599
},
86100
{
87101
name: 'Converter',
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { FileCertificate } from '@vicons/tabler';
2+
import { defineTool } from '../tool';
3+
4+
export const tool = defineTool({
5+
name: 'X509 certificate generator',
6+
path: '/x509-certificate-generator',
7+
description: 'Generate a self signed SSL/x509 certificate',
8+
keywords: ['x509', 'ssl', 'tls', 'self-signed', 'certificate', 'generator'],
9+
component: () => import('./x509-certificate-generator.vue'),
10+
icon: FileCertificate,
11+
createdAt: new Date('2024-02-25'),
12+
});
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { asn1, md, pki, random, util } from 'node-forge';
2+
import workerScript from 'node-forge/dist/prime.worker.min?url';
3+
4+
export { generateSSLCertificate };
5+
6+
function generateRSAPairs({ bits = 2048 }) {
7+
return new Promise<pki.rsa.KeyPair>((resolve, reject) =>
8+
pki.rsa.generateKeyPair({ bits, workerScript }, (err, keyPair) => {
9+
if (err) {
10+
reject(err);
11+
return;
12+
}
13+
14+
resolve(keyPair);
15+
}),
16+
);
17+
}
18+
19+
// a hexString is considered negative if it's most significant bit is 1
20+
// because serial numbers use ones' complement notation
21+
// this RFC in section 4.1.2.2 requires serial numbers to be positive
22+
// http://www.ietf.org/rfc/rfc5280.txt
23+
function toPositiveHex(hexString: string) {
24+
let mostSiginficativeHexAsInt = Number.parseInt(hexString[0], 16);
25+
if (mostSiginficativeHexAsInt < 8) {
26+
return hexString;
27+
}
28+
29+
mostSiginficativeHexAsInt -= 8;
30+
return mostSiginficativeHexAsInt.toString() + hexString.substring(1);
31+
}
32+
33+
async function generateSSLCertificate(config: {
34+
bits?: number
35+
password?: string
36+
commonName?: string
37+
countryName?: string
38+
city?: string
39+
state?: string
40+
organizationName?: string
41+
organizationalUnit?: string
42+
contactEmail?: string
43+
days?: number
44+
} = {}): Promise<{
45+
fingerprint: string
46+
publicKeyPem: string
47+
privateKeyPem: string
48+
certificatePem: string
49+
}> {
50+
const { privateKey, publicKey } = await generateRSAPairs(config);
51+
52+
const cert = pki.createCertificate();
53+
54+
cert.serialNumber = toPositiveHex(util.bytesToHex(random.getBytesSync(9))); // the serial number can be decimal or hex (if preceded by 0x)
55+
56+
cert.validity.notBefore = new Date();
57+
cert.validity.notAfter = new Date();
58+
cert.validity.notAfter.setDate(cert.validity.notBefore.getDate() + (config.days || 365));
59+
60+
const attrs = [{
61+
name: 'commonName',
62+
value: config.commonName,
63+
}, {
64+
name: 'countryName',
65+
value: config.countryName,
66+
}, {
67+
name: 'stateOrProvinceName',
68+
value: config.state,
69+
}, {
70+
name: 'localityName',
71+
value: config.city,
72+
}, {
73+
name: 'organizationName',
74+
value: config.organizationName,
75+
}, {
76+
name: 'organizationalUnitName',
77+
value: config.organizationalUnit,
78+
}, {
79+
name: 'emailAddress',
80+
value: config.contactEmail,
81+
}].filter(attr => attr.value !== null && attr.value?.trim() !== '');
82+
83+
cert.setSubject(attrs);
84+
cert.setIssuer(attrs);
85+
86+
cert.publicKey = publicKey;
87+
88+
cert.setExtensions([{
89+
name: 'basicConstraints',
90+
cA: true,
91+
}, {
92+
name: 'keyUsage',
93+
keyCertSign: true,
94+
digitalSignature: true,
95+
nonRepudiation: true,
96+
keyEncipherment: true,
97+
dataEncipherment: true,
98+
}]);
99+
100+
cert.sign(privateKey);
101+
102+
const fingerprint = md.sha1
103+
.create()
104+
.update(asn1.toDer(pki.certificateToAsn1(cert)).getBytes())
105+
.digest()
106+
.toHex()
107+
.match(/.{2}/g)?.join(':') ?? '';
108+
109+
const privateUnencryptedKeyPem = pki.privateKeyToPem(privateKey);
110+
111+
return {
112+
fingerprint,
113+
certificatePem: pki.certificateToPem(cert),
114+
publicKeyPem: pki.publicKeyToPem(publicKey),
115+
privateKeyPem: config?.password
116+
? pki.encryptRsaPrivateKey(privateKey, config?.password)
117+
: privateUnencryptedKeyPem,
118+
};
119+
}
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
<script setup lang="ts">
2+
import { generateSSLCertificate } from './x509-certificate-generator.service';
3+
import TextareaCopyable from '@/components/TextareaCopyable.vue';
4+
import { withDefaultOnErrorAsync } from '@/utils/defaults';
5+
import { computedRefreshableAsync } from '@/composable/computedRefreshable';
6+
import { useValidation } from '@/composable/validation';
7+
8+
const commonName = ref('test.com');
9+
const commonNameValidation = useValidation({
10+
source: commonName,
11+
rules: [
12+
{
13+
message: 'Common Name/Domain Name must not be empty',
14+
validator: value => value?.trim() !== '',
15+
},
16+
],
17+
});
18+
19+
const organizationName = ref('Test');
20+
const organizationalUnit = ref('');
21+
const days = ref(365);
22+
const password = ref('');
23+
const city = ref('Paris');
24+
const state = ref('FR');
25+
const country = ref('France');
26+
const contactEmail = ref('');
27+
const emptyCSR = { certificatePem: '', privateKeyPem: '', publicKeyPem: '', fingerprint: '' };
28+
29+
const [certs, refreshCerts] = computedRefreshableAsync(
30+
() => withDefaultOnErrorAsync(() => {
31+
if (!commonNameValidation.isValid) {
32+
return emptyCSR;
33+
}
34+
35+
return generateSSLCertificate({
36+
password: password.value,
37+
commonName: commonName.value,
38+
countryName: country.value,
39+
city: city.value,
40+
state: state.value,
41+
organizationName: organizationName.value,
42+
organizationalUnit: organizationalUnit.value,
43+
contactEmail: contactEmail.value,
44+
days: days.value,
45+
});
46+
},
47+
emptyCSR,
48+
), emptyCSR);
49+
</script>
50+
51+
<template>
52+
<div>
53+
<div mb-2>
54+
<n-form-item
55+
label="Common Name/Domain Name:"
56+
label-placement="top"
57+
:feedback="commonNameValidation.message"
58+
:validation-status="commonNameValidation.status"
59+
>
60+
<n-input
61+
v-model:value="commonName"
62+
placeholder="Common/Domain Name"
63+
/>
64+
</n-form-item>
65+
</div>
66+
67+
<div>
68+
<n-form-item
69+
label="Duration (days):"
70+
label-placement="left" label-width="100"
71+
>
72+
<n-input-number
73+
v-model:value="days"
74+
placeholder="Duration (days)"
75+
:min="1"
76+
/>
77+
</n-form-item>
78+
</div>
79+
80+
<div>
81+
<n-form-item
82+
label="Organization Name:"
83+
label-placement="left" label-width="100"
84+
>
85+
<n-input
86+
v-model:value="organizationName"
87+
placeholder="Organization Name"
88+
/>
89+
</n-form-item>
90+
</div>
91+
92+
<div>
93+
<n-form-item
94+
label="Organizational Unit:"
95+
label-placement="left" label-width="100"
96+
>
97+
<n-input
98+
v-model:value="organizationalUnit"
99+
placeholder="Organization Unit"
100+
/>
101+
</n-form-item>
102+
</div>
103+
104+
<div>
105+
<n-form-item
106+
label="State:"
107+
label-placement="left" label-width="100"
108+
>
109+
<n-input
110+
v-model:value="state"
111+
placeholder="State"
112+
/>
113+
</n-form-item>
114+
</div>
115+
116+
<div>
117+
<n-form-item
118+
label="City:"
119+
label-placement="left" label-width="100"
120+
>
121+
<n-input
122+
v-model:value="city"
123+
placeholder="City"
124+
/>
125+
</n-form-item>
126+
</div>
127+
128+
<div>
129+
<n-form-item
130+
label="Country:"
131+
label-placement="left" label-width="100"
132+
>
133+
<n-input
134+
v-model:value="country"
135+
placeholder="Country"
136+
/>
137+
</n-form-item>
138+
</div>
139+
140+
<div>
141+
<n-form-item
142+
label="Contact Email:"
143+
label-placement="left" label-width="100"
144+
>
145+
<n-input
146+
v-model:value="contactEmail"
147+
placeholder="Contact Email"
148+
/>
149+
</n-form-item>
150+
</div>
151+
152+
<div>
153+
<n-form-item
154+
label="Private Key passphrase:"
155+
label-placement="top"
156+
>
157+
<n-input
158+
v-model:value="password"
159+
type="password"
160+
show-password-on="mousedown"
161+
placeholder="Passphrase"
162+
/>
163+
</n-form-item>
164+
</div>
165+
166+
<div flex justify-center>
167+
<c-button @click="refreshCerts">
168+
Refresh Certificate
169+
</c-button>
170+
</div>
171+
172+
<n-divider />
173+
174+
<div v-if="commonNameValidation.isValid">
175+
<div>
176+
<h3>Certificate (PEM)</h3>
177+
<TextareaCopyable :value="certs.certificatePem" />
178+
</div>
179+
180+
<div>
181+
<h3>Fingerprint:</h3>
182+
<TextareaCopyable :value="certs.fingerprint" :word-wrap="true" />
183+
</div>
184+
185+
<div>
186+
<h3>Public key</h3>
187+
<TextareaCopyable :value="certs.publicKeyPem" :word-wrap="true" />
188+
</div>
189+
190+
<div>
191+
<h3>Private key</h3>
192+
<TextareaCopyable :value="certs.privateKeyPem" />
193+
</div>
194+
</div>
195+
</div>
196+
</template>

0 commit comments

Comments
 (0)