Skip to content

Commit b21ea3c

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

File tree

4 files changed

+327
-1
lines changed

4 files changed

+327
-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: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
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 password = ref('');
22+
const city = ref('Paris');
23+
const state = ref('FR');
24+
const country = ref('France');
25+
const contactEmail = ref('');
26+
const emptyCSR = { certificatePem: '', privateKeyPem: '', publicKeyPem: '', fingerprint: '' };
27+
28+
const [certs, refreshCerts] = computedRefreshableAsync(
29+
() => withDefaultOnErrorAsync(() => {
30+
if (!commonNameValidation.isValid) {
31+
return emptyCSR;
32+
}
33+
34+
return generateSSLCertificate({
35+
password: password.value,
36+
commonName: commonName.value,
37+
countryName: country.value,
38+
city: city.value,
39+
state: state.value,
40+
organizationName: organizationName.value,
41+
organizationalUnit: organizationalUnit.value,
42+
contactEmail: contactEmail.value,
43+
});
44+
},
45+
emptyCSR,
46+
), emptyCSR);
47+
</script>
48+
49+
<template>
50+
<div>
51+
<div mb-2>
52+
<n-form-item
53+
label="Common Name/Domain Name:"
54+
label-placement="top"
55+
:feedback="commonNameValidation.message"
56+
:validation-status="commonNameValidation.status"
57+
>
58+
<n-input
59+
v-model:value="commonName"
60+
placeholder="Common/Domain Name"
61+
/>
62+
</n-form-item>
63+
</div>
64+
65+
<div>
66+
<n-form-item
67+
label="Organization Name:"
68+
label-placement="left" label-width="100"
69+
>
70+
<n-input
71+
v-model:value="organizationName"
72+
placeholder="Organization Name"
73+
/>
74+
</n-form-item>
75+
</div>
76+
77+
<div>
78+
<n-form-item
79+
label="Organization Unit:"
80+
label-placement="left" label-width="100"
81+
>
82+
<n-input
83+
v-model:value="organizationalUnit"
84+
placeholder="Organization Unit"
85+
/>
86+
</n-form-item>
87+
</div>
88+
89+
<div>
90+
<n-form-item
91+
label="State:"
92+
label-placement="left" label-width="100"
93+
>
94+
<n-input
95+
v-model:value="state"
96+
placeholder="State"
97+
/>
98+
</n-form-item>
99+
</div>
100+
101+
<div>
102+
<n-form-item
103+
label="City:"
104+
label-placement="left" label-width="100"
105+
>
106+
<n-input
107+
v-model:value="city"
108+
placeholder="City"
109+
/>
110+
</n-form-item>
111+
</div>
112+
113+
<div>
114+
<n-form-item
115+
label="Country:"
116+
label-placement="left" label-width="100"
117+
>
118+
<n-input
119+
v-model:value="country"
120+
placeholder="Country"
121+
/>
122+
</n-form-item>
123+
</div>
124+
125+
<div>
126+
<n-form-item
127+
label="Contact Email:"
128+
label-placement="left" label-width="100"
129+
>
130+
<n-input
131+
v-model:value="contactEmail"
132+
placeholder="Contact Email"
133+
/>
134+
</n-form-item>
135+
</div>
136+
137+
<div>
138+
<n-form-item
139+
label="Private Key passphrase:"
140+
label-placement="top"
141+
>
142+
<n-input
143+
v-model:value="password"
144+
type="password"
145+
show-password-on="mousedown"
146+
placeholder="Passphrase"
147+
/>
148+
</n-form-item>
149+
</div>
150+
151+
<div flex justify-center>
152+
<c-button @click="refreshCerts">
153+
Refresh Certificate
154+
</c-button>
155+
</div>
156+
157+
<n-divider />
158+
159+
<div v-if="commonNameValidation.isValid">
160+
<div>
161+
<h3>Certifacate Signing Request</h3>
162+
<TextareaCopyable :value="certs.certificatePem" />
163+
</div>
164+
165+
<div>
166+
<h3>Fingerprint:</h3>
167+
<TextareaCopyable :value="certs.fingerprint" :word-wrap="true" />
168+
</div>
169+
170+
<div>
171+
<h3>Public key</h3>
172+
<TextareaCopyable :value="certs.publicKeyPem" :word-wrap="true" />
173+
</div>
174+
175+
<div>
176+
<h3>Private key</h3>
177+
<TextareaCopyable :value="certs.privateKeyPem" />
178+
</div>
179+
</div>
180+
</div>
181+
</template>

0 commit comments

Comments
 (0)