feat(new tool): SSL Certificate Converter

Fix #1245
Handle JKS, PEM, DER, P12 as input
Handle PEM and DER as output
This commit is contained in:
sharevb 2024-09-22 12:18:51 +02:00 committed by ShareVB
parent f5c4ab19bc
commit e62ca2295c
9 changed files with 1045 additions and 9 deletions

10
components.d.ts vendored
View file

@ -130,19 +130,24 @@ declare module '@vue/runtime-core' {
MetaTagGenerator: typeof import('./src/tools/meta-tag-generator/meta-tag-generator.vue')['default'] MetaTagGenerator: typeof import('./src/tools/meta-tag-generator/meta-tag-generator.vue')['default']
MimeTypes: typeof import('./src/tools/mime-types/mime-types.vue')['default'] MimeTypes: typeof import('./src/tools/mime-types/mime-types.vue')['default']
NavbarButtons: typeof import('./src/components/NavbarButtons.vue')['default'] NavbarButtons: typeof import('./src/components/NavbarButtons.vue')['default']
NCheckbox: typeof import('naive-ui')['NCheckbox'] NCode: typeof import('naive-ui')['NCode']
NCollapseTransition: typeof import('naive-ui')['NCollapseTransition'] NCollapseTransition: typeof import('naive-ui')['NCollapseTransition']
NConfigProvider: typeof import('naive-ui')['NConfigProvider'] NConfigProvider: typeof import('naive-ui')['NConfigProvider']
NDivider: typeof import('naive-ui')['NDivider'] NDivider: typeof import('naive-ui')['NDivider']
NEllipsis: typeof import('naive-ui')['NEllipsis'] NEllipsis: typeof import('naive-ui')['NEllipsis']
NForm: typeof import('naive-ui')['NForm']
NFormItem: typeof import('naive-ui')['NFormItem']
NH1: typeof import('naive-ui')['NH1'] NH1: typeof import('naive-ui')['NH1']
NH3: typeof import('naive-ui')['NH3'] NH3: typeof import('naive-ui')['NH3']
NIcon: typeof import('naive-ui')['NIcon'] NIcon: typeof import('naive-ui')['NIcon']
NLayout: typeof import('naive-ui')['NLayout'] NLayout: typeof import('naive-ui')['NLayout']
NLayoutSider: typeof import('naive-ui')['NLayoutSider'] NLayoutSider: typeof import('naive-ui')['NLayoutSider']
NMenu: typeof import('naive-ui')['NMenu'] NMenu: typeof import('naive-ui')['NMenu']
NRadio: typeof import('naive-ui')['NRadio']
NRadioGroup: typeof import('naive-ui')['NRadioGroup']
NScrollbar: typeof import('naive-ui')['NScrollbar']
NSpace: typeof import('naive-ui')['NSpace'] NSpace: typeof import('naive-ui')['NSpace']
NTable: typeof import('naive-ui')['NTable'] NSwitch: typeof import('naive-ui')['NSwitch']
NumeronymGenerator: typeof import('./src/tools/numeronym-generator/numeronym-generator.vue')['default'] NumeronymGenerator: typeof import('./src/tools/numeronym-generator/numeronym-generator.vue')['default']
OtpCodeGeneratorAndValidator: typeof import('./src/tools/otp-code-generator-and-validator/otp-code-generator-and-validator.vue')['default'] OtpCodeGeneratorAndValidator: typeof import('./src/tools/otp-code-generator-and-validator/otp-code-generator-and-validator.vue')['default']
PasswordStrengthAnalyser: typeof import('./src/tools/password-strength-analyser/password-strength-analyser.vue')['default'] PasswordStrengthAnalyser: typeof import('./src/tools/password-strength-analyser/password-strength-analyser.vue')['default']
@ -164,6 +169,7 @@ declare module '@vue/runtime-core' {
SlugifyString: typeof import('./src/tools/slugify-string/slugify-string.vue')['default'] SlugifyString: typeof import('./src/tools/slugify-string/slugify-string.vue')['default']
SpanCopyable: typeof import('./src/components/SpanCopyable.vue')['default'] SpanCopyable: typeof import('./src/components/SpanCopyable.vue')['default']
SqlPrettify: typeof import('./src/tools/sql-prettify/sql-prettify.vue')['default'] SqlPrettify: typeof import('./src/tools/sql-prettify/sql-prettify.vue')['default']
SslCertConverter: typeof import('./src/tools/ssl-cert-converter/ssl-cert-converter.vue')['default']
StringObfuscator: typeof import('./src/tools/string-obfuscator/string-obfuscator.vue')['default'] StringObfuscator: typeof import('./src/tools/string-obfuscator/string-obfuscator.vue')['default']
SvgPlaceholderGenerator: typeof import('./src/tools/svg-placeholder-generator/svg-placeholder-generator.vue')['default'] SvgPlaceholderGenerator: typeof import('./src/tools/svg-placeholder-generator/svg-placeholder-generator.vue')['default']
TemperatureConverter: typeof import('./src/tools/temperature-converter/temperature-converter.vue')['default'] TemperatureConverter: typeof import('./src/tools/temperature-converter/temperature-converter.vue')['default']

View file

@ -44,6 +44,7 @@
"@tiptap/vue-3": "2.0.3", "@tiptap/vue-3": "2.0.3",
"@types/figlet": "^1.5.8", "@types/figlet": "^1.5.8",
"@types/markdown-it": "^13.0.7", "@types/markdown-it": "^13.0.7",
"@types/sshpk": "^1.17.4",
"@vicons/material": "^0.12.0", "@vicons/material": "^0.12.0",
"@vicons/tabler": "^0.12.0", "@vicons/tabler": "^0.12.0",
"@vueuse/core": "^10.3.0", "@vueuse/core": "^10.3.0",
@ -67,6 +68,7 @@
"highlight.js": "^11.7.0", "highlight.js": "^11.7.0",
"iarna-toml-esm": "^3.0.5", "iarna-toml-esm": "^3.0.5",
"ibantools": "^4.3.3", "ibantools": "^4.3.3",
"jks-js": "^1.1.3",
"js-base64": "^3.7.6", "js-base64": "^3.7.6",
"json5": "^2.2.3", "json5": "^2.2.3",
"jwt-decode": "^3.1.2", "jwt-decode": "^3.1.2",
@ -87,6 +89,7 @@
"qrcode": "^1.5.1", "qrcode": "^1.5.1",
"randexp": "^0.5.3", "randexp": "^0.5.3",
"sql-formatter": "^13.0.0", "sql-formatter": "^13.0.0",
"sshpk": "^1.18.0",
"ua-parser-js": "^1.0.35", "ua-parser-js": "^1.0.35",
"ulid": "^2.3.0", "ulid": "^2.3.0",
"unicode-emoji-json": "^0.4.0", "unicode-emoji-json": "^0.4.0",
@ -139,6 +142,7 @@
"unplugin-icons": "^0.17.0", "unplugin-icons": "^0.17.0",
"unplugin-vue-components": "^0.25.0", "unplugin-vue-components": "^0.25.0",
"vite": "^4.4.9", "vite": "^4.4.9",
"vite-plugin-node-polyfills": "^0.22.0",
"vite-plugin-pwa": "^0.16.0", "vite-plugin-pwa": "^0.16.0",
"vite-plugin-vue-markdown": "^0.23.5", "vite-plugin-vue-markdown": "^0.23.5",
"vite-svg-loader": "^4.0.0", "vite-svg-loader": "^4.0.0",

736
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -2,6 +2,7 @@ import { tool as base64FileConverter } from './base64-file-converter';
import { tool as base64StringConverter } from './base64-string-converter'; import { tool as base64StringConverter } from './base64-string-converter';
import { tool as basicAuthGenerator } from './basic-auth-generator'; import { tool as basicAuthGenerator } from './basic-auth-generator';
import { tool as emailNormalizer } from './email-normalizer'; import { tool as emailNormalizer } from './email-normalizer';
import { tool as sslCertConverter } from './ssl-cert-converter';
import { tool as asciiTextDrawer } from './ascii-text-drawer'; import { tool as asciiTextDrawer } from './ascii-text-drawer';
@ -164,7 +165,15 @@ export const toolsByCategory: ToolCategory[] = [
}, },
{ {
name: 'Network', name: 'Network',
components: [ipv4SubnetCalculator, ipv4AddressConverter, ipv4RangeExpander, macAddressLookup, macAddressGenerator, ipv6UlaGenerator], components: [
ipv4SubnetCalculator,
ipv4AddressConverter,
ipv4RangeExpander,
macAddressLookup,
macAddressGenerator,
ipv6UlaGenerator,
sslCertConverter,
],
}, },
{ {
name: 'Math', name: 'Math',

View file

@ -0,0 +1,12 @@
import { ShieldChevron } from '@vicons/tabler';
import { defineTool } from '../tool';
export const tool = defineTool({
name: 'SSL Certificate converter',
path: '/ssl-cert-converter',
description: 'Convert SSL Certificate from different formats',
keywords: ['ssl', 'certificate', 'crt', 'pkcs', 'p12', 'pem', 'der', 'jks', 'converter'],
component: () => import('./ssl-cert-converter.vue'),
icon: ShieldChevron,
createdAt: new Date('2024-08-15'),
});

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,119 @@
import type { Buffer } from 'node:buffer';
import {
parseCertificate,
} from 'sshpk';
import type {
Certificate,
CertificateFormat,
} from 'sshpk';
import * as forge from 'node-forge';
import jks from 'jks-js';
function convertPKCS12ToPem(p12base64: forge.Bytes | forge.util.ByteBuffer, password: string) {
const p12Asn1 = forge.asn1.fromDer(p12base64, false);
const p12 = forge.pkcs12.pkcs12FromAsn1(p12Asn1, false, password);
const pemKey = getKeyFromP12(p12);
const { pemCertificate, commonName } = getCertificateFromP12(p12);
return { pemKey, pemCertificate, commonName };
}
function getKeyFromP12(p12: forge.pkcs12.Pkcs12Pfx) {
const keyData = p12.getBags({ bagType: forge.pki.oids.pkcs8ShroudedKeyBag });
let pkcs8Key = keyData[forge.pki.oids.pkcs8ShroudedKeyBag]![0];
if (!pkcs8Key) {
pkcs8Key = keyData[forge.pki.oids.keyBag]![0];
}
if (!pkcs8Key?.key) {
throw new TypeError('Unable to get private key.');
}
return forge.pki.privateKeyToPem(pkcs8Key.key);
}
function getCertificateFromP12(p12: any) {
const certData = p12.getBags({ bagType: forge.pki.oids.certBag });
const certificate = certData[forge.pki.oids.certBag][0];
const pemCertificate = forge.pki.certificateToPem(certificate.cert);
const commonName = certificate.cert.subject.attributes[0].value;
return { pemCertificate, commonName };
}
export function convertCertificate(
inputKeyOrCertificateValue: string | Buffer,
password: string) {
const canParse = (value: any, parseFunction: (value: any) => any) => {
try {
return parseFunction(value);
}
catch (e: any) {
// console.log(e);
return null;
}
};
const cert = canParse(inputKeyOrCertificateValue, (value) => {
for (const format of ['openssh', 'pem', 'x509']) {
try {
return parseCertificate(value, format as CertificateFormat);
}
catch {
}
}
return null;
}) as Certificate;
if (cert) {
return [{
alias: '#default',
key: null,
der: canParse(cert, c => c.toBuffer('x509')),
pem: cert.toString('pem'),
}];
}
const pkcs12 = canParse(inputKeyOrCertificateValue, (value) => {
return convertPKCS12ToPem(forge.util.createBuffer(value, 'raw'), password);
});
if (pkcs12) {
return [{
alias: pkcs12.commonName,
key: pkcs12.pemKey,
der: canParse(pkcs12.pemCertificate, pemCert => parseCertificate(pemCert, 'pem').toBuffer('x509')),
pem: pkcs12.pemCertificate,
}];
}
const parsedJKS = canParse(inputKeyOrCertificateValue, (value) => {
return jks.toPem(
value,
password,
);
});
if (parsedJKS) {
return Object.entries(parsedJKS).map(([k, v]) => {
if (typeof v === 'string') {
return {
alias: k,
key: null,
der: canParse(v, pemCert => parseCertificate(pemCert, 'pem').toBuffer('x509')),
pem: v,
};
}
const { cert, key } = v as { cert: string; key: string };
return {
alias: k,
key,
der: canParse(cert, pemCert => parseCertificate(pemCert, 'pem').toBuffer('x509')),
pem: cert,
};
});
}
return null;
}

View file

@ -0,0 +1,112 @@
<script setup lang="ts">
import { Buffer } from 'node:buffer';
import { convertCertificate } from './ssl-cert-converter.service';
const inputKeyOrCertificate = ref('');
const fileInput = ref() as Ref<Buffer>;
const passphrase = ref('');
const inputType = ref<'file' | 'content'>('file');
async function onUpload(file: File) {
if (file) {
fileInput.value = Buffer.from(await file.arrayBuffer());
inputKeyOrCertificate.value = '';
}
}
function downloadFile(data: ArrayBuffer | string, fileName: string, fileType?: string) {
const blob = new Blob(
[typeof data === 'string' ? new TextEncoder().encode(data) : data],
{ type: fileType || 'application/octet-stream' });
const downloadUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = downloadUrl;
a.download = fileName;
document.body.appendChild(a);
a.click();
URL.revokeObjectURL(downloadUrl);
}
const convertedCertificates = computed(() => {
const inputContent = inputKeyOrCertificate.value;
const file = fileInput.value;
let inputKeyOrCertificateValue: string | Buffer = '';
if (inputType.value === 'file' && file) {
inputKeyOrCertificateValue = file;
}
else if (inputType.value === 'content' && inputContent) {
inputKeyOrCertificateValue = inputContent;
}
return convertCertificate(inputKeyOrCertificateValue, passphrase.value);
});
</script>
<template>
<div>
<c-card>
<n-radio-group v-model:value="inputType" name="radiogroup" mb-2 flex justify-center>
<n-space>
<n-radio
value="file"
label="File"
/>
<n-radio
value="content"
label="Content"
/>
</n-space>
</n-radio-group>
<c-file-upload
v-if="inputType === 'file'"
title="Drag and drop a PEM, DER, JKS or PKCS#12 file here, or click to select a file"
@file-upload="onUpload"
/>
<c-input-text
v-if="inputType === 'content'"
v-model:value="inputKeyOrCertificate"
label="Paste your Certificate/Store:"
placeholder="Your Certificate/Store..."
multiline
rows="8"
data-test-id="input"
/>
</c-card>
<c-input-text
v-model:value="passphrase"
label="Passphrase (for encrypted certificate/store):"
placeholder="Passphrase (for encrypted certificate/store)..."
type="password"
data-test-id="pass"
/>
<n-divider />
<c-alert v-if="!convertedCertificates">
Please provide an input or enter the good password!
</c-alert>
<c-card v-for="(cert, ix) in convertedCertificates" :key="ix" :title="cert.alias">
<n-form-item v-if="cert.key" label="Key (PEM)">
<textarea-copyable :value="cert.key" />
</n-form-item>
<n-form-item v-if="cert.pem" label="Certificate (PEM)">
<textarea-copyable :value="cert.pem" />
</n-form-item>
<div flex justify-center gap-1>
<c-button v-if="cert.der" @click="downloadFile(cert.der, `${cert.alias}.der`)">
Download DER
</c-button>
<c-button v-if="cert.pem" @click="downloadFile(cert.pem, `${cert.alias}.pem`)">
Download PEM
</c-button>
<c-button v-if="cert.key" @click="downloadFile(cert.key, `${cert.alias}.key.pem`)">
Download Key (PEM)
</c-button>
</div>
</c-card>
</div>
</template>

View file

@ -15,6 +15,7 @@ import { VitePWA } from 'vite-plugin-pwa';
import markdown from 'vite-plugin-vue-markdown'; import markdown from 'vite-plugin-vue-markdown';
import svgLoader from 'vite-svg-loader'; import svgLoader from 'vite-svg-loader';
import { configDefaults } from 'vitest/config'; import { configDefaults } from 'vitest/config';
import { nodePolyfills } from 'vite-plugin-node-polyfills'
const baseUrl = process.env.BASE_URL ?? '/'; const baseUrl = process.env.BASE_URL ?? '/';
@ -97,6 +98,7 @@ export default defineConfig({
resolvers: [NaiveUiResolver(), IconsResolver({ prefix: 'icon' })], resolvers: [NaiveUiResolver(), IconsResolver({ prefix: 'icon' })],
}), }),
Unocss(), Unocss(),
nodePolyfills(),
], ],
base: baseUrl, base: baseUrl,
resolve: { resolve: {