Initial commit: CertTools SSL certificate toolkit

Made-with: Cursor
This commit is contained in:
Denis
2026-03-26 18:12:39 +03:00
commit b2f8cbdb0e
34 changed files with 6975 additions and 0 deletions

1725
server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

23
server/package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "cert-tools-server",
"version": "1.0.0",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
},
"dependencies": {
"express": "^4.21.0",
"cors": "^2.8.5",
"multer": "^1.4.5-lts.1",
"node-forge": "^1.3.1"
},
"devDependencies": {
"@types/express": "^5.0.0",
"@types/cors": "^2.8.17",
"@types/multer": "^1.4.12",
"@types/node-forge": "^1.3.11",
"typescript": "^5.6.0",
"tsx": "^4.19.0"
}
}

26
server/src/index.ts Normal file
View File

@@ -0,0 +1,26 @@
import express from 'express';
import cors from 'cors';
import path from 'path';
import certRoutes from './routes/certificates';
const app = express();
const PORT = process.env.PORT || 3001;
app.use(cors());
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
app.use('/api', certRoutes);
// Serve frontend in production
if (process.env.NODE_ENV === 'production') {
const clientDist = path.join(__dirname, '../../client/dist');
app.use(express.static(clientDist));
app.get('*', (_req, res) => {
res.sendFile(path.join(clientDist, 'index.html'));
});
}
app.listen(PORT, () => {
console.log(`CertTools server running on http://localhost:${PORT}`);
});

View File

@@ -0,0 +1,99 @@
import { Router, Request, Response } from 'express';
import multer from 'multer';
import {
parseCertificate,
decodePfx,
matchKeyToCert,
parseCsr,
verifyChain,
} from '../services/certService';
const router = Router();
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 10 * 1024 * 1024 },
});
router.post('/decode/pfx', upload.single('file'), (req: Request, res: Response) => {
try {
if (!req.file) {
res.status(400).json({ error: 'No file uploaded' });
return;
}
const password = req.body.password || '';
const result = decodePfx(req.file.buffer, password);
res.json(result);
} catch (e: any) {
const message = e.message?.includes('Invalid password')
? 'Invalid password or corrupted PFX file'
: e.message || 'Failed to decode PFX file';
res.status(400).json({ error: message });
}
});
router.post('/decode/pem', (req: Request, res: Response) => {
try {
const { pem } = req.body;
if (!pem) {
res.status(400).json({ error: 'No PEM data provided' });
return;
}
const pemRegex = /-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g;
const pems = pem.match(pemRegex) || [];
if (pems.length === 0) {
res.status(400).json({ error: 'No valid PEM certificates found in the input' });
return;
}
const certificates = pems.map((p: string) => parseCertificate(p));
res.json({ certificates });
} catch (e: any) {
res.status(400).json({ error: e.message || 'Failed to decode PEM' });
}
});
router.post('/match', (req: Request, res: Response) => {
try {
const { certificate, privateKey } = req.body;
if (!certificate || !privateKey) {
res.status(400).json({ error: 'Both certificate and private key are required' });
return;
}
const result = matchKeyToCert(certificate, privateKey);
res.json(result);
} catch (e: any) {
res.status(400).json({ error: e.message || 'Failed to compare key and certificate' });
}
});
router.post('/decode/csr', (req: Request, res: Response) => {
try {
const { pem } = req.body;
if (!pem) {
res.status(400).json({ error: 'No CSR data provided' });
return;
}
const result = parseCsr(pem);
res.json(result);
} catch (e: any) {
res.status(400).json({ error: e.message || 'Failed to decode CSR' });
}
});
router.post('/chain/verify', (req: Request, res: Response) => {
try {
const { pem } = req.body;
if (!pem) {
res.status(400).json({ error: 'No certificate chain provided' });
return;
}
const result = verifyChain(pem);
res.json(result);
} catch (e: any) {
res.status(400).json({ error: e.message || 'Failed to verify chain' });
}
});
export default router;

View File

@@ -0,0 +1,324 @@
import forge from 'node-forge';
export interface CertificateInfo {
subject: Record<string, string>;
issuer: Record<string, string>;
serialNumber: string;
validFrom: string;
validTo: string;
daysRemaining: number;
isExpired: boolean;
sans: string[];
fingerprints: {
sha1: string;
sha256: string;
};
publicKey: {
algorithm: string;
bits: number;
};
signatureAlgorithm: string;
isSelfSigned: boolean;
version: number;
keyUsage: string[];
extKeyUsage: string[];
pem: string;
isCA: boolean;
}
export interface PfxResult {
certificates: CertificateInfo[];
privateKeyPem: string | null;
}
export interface MatchResult {
match: boolean;
certModulus: string;
keyModulus: string;
}
export interface CsrInfo {
subject: Record<string, string>;
publicKey: {
algorithm: string;
bits: number;
};
sans: string[];
isSignatureValid: boolean;
pem: string;
}
export interface ChainVerifyResult {
certificates: CertificateInfo[];
isValid: boolean;
errors: string[];
chainOrder: string[];
}
function extractAttributes(attrs: forge.pki.CertificateField[]): Record<string, string> {
const result: Record<string, string> = {};
for (const attr of attrs) {
const name = attr.shortName || attr.name || attr.type;
if (name && attr.value) {
result[name] = attr.value as string;
}
}
return result;
}
function getFingerprint(cert: forge.pki.Certificate, algorithm: 'sha1' | 'sha256'): string {
const der = forge.asn1.toDer(forge.pki.certificateToAsn1(cert)).getBytes();
const md = algorithm === 'sha256' ? forge.md.sha256.create() : forge.md.sha1.create();
md.update(der);
return md.digest().toHex().match(/.{2}/g)!.join(':').toUpperCase();
}
function getKeyUsage(cert: forge.pki.Certificate): string[] {
const ext = cert.getExtension('keyUsage') as any;
if (!ext) return [];
const usages: string[] = [];
if (ext.digitalSignature) usages.push('Digital Signature');
if (ext.nonRepudiation) usages.push('Non Repudiation');
if (ext.keyEncipherment) usages.push('Key Encipherment');
if (ext.dataEncipherment) usages.push('Data Encipherment');
if (ext.keyAgreement) usages.push('Key Agreement');
if (ext.keyCertSign) usages.push('Certificate Sign');
if (ext.cRLSign) usages.push('CRL Sign');
return usages;
}
function getExtKeyUsage(cert: forge.pki.Certificate): string[] {
const ext = cert.getExtension('extKeyUsage') as any;
if (!ext) return [];
const usages: string[] = [];
if (ext.serverAuth) usages.push('Server Authentication');
if (ext.clientAuth) usages.push('Client Authentication');
if (ext.codeSigning) usages.push('Code Signing');
if (ext.emailProtection) usages.push('Email Protection');
if (ext.timeStamping) usages.push('Time Stamping');
return usages;
}
function getSANs(cert: forge.pki.Certificate): string[] {
const ext = cert.getExtension('subjectAltName') as any;
if (!ext || !ext.altNames) return [];
return ext.altNames.map((an: any) => {
if (an.type === 2) return `DNS: ${an.value}`;
if (an.type === 7) return `IP: ${an.ip || an.value}`;
if (an.type === 1) return `Email: ${an.value}`;
if (an.type === 6) return `URI: ${an.value}`;
return an.value;
});
}
function checkIsCA(cert: forge.pki.Certificate): boolean {
const ext = cert.getExtension('basicConstraints') as any;
return ext ? !!ext.cA : false;
}
const SIG_OID_MAP: Record<string, string> = {
'1.2.840.113549.1.1.4': 'MD5 with RSA',
'1.2.840.113549.1.1.5': 'SHA-1 with RSA',
'1.2.840.113549.1.1.11': 'SHA-256 with RSA',
'1.2.840.113549.1.1.12': 'SHA-384 with RSA',
'1.2.840.113549.1.1.13': 'SHA-512 with RSA',
'1.2.840.10045.4.3.2': 'ECDSA with SHA-256',
'1.2.840.10045.4.3.3': 'ECDSA with SHA-384',
'1.2.840.10045.4.3.4': 'ECDSA with SHA-512',
};
function getSignatureAlgorithm(cert: forge.pki.Certificate): string {
const oid = (cert as any).signatureOid || (cert as any).siginfo?.algorithmOid || '';
return SIG_OID_MAP[oid] || oid || 'Unknown';
}
function getPublicKeyInfo(publicKey: forge.pki.PublicKey): { algorithm: string; bits: number } {
const rsa = publicKey as forge.pki.rsa.PublicKey;
if ((rsa as any).n) {
return { algorithm: 'RSA', bits: (rsa as any).n.bitLength() };
}
return { algorithm: 'Unknown', bits: 0 };
}
export function parseCertificate(pem: string): CertificateInfo {
const cert = forge.pki.certificateFromPem(pem);
const now = new Date();
const validTo = cert.validity.notAfter;
const daysRemaining = Math.floor((validTo.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
return {
subject: extractAttributes(cert.subject.attributes),
issuer: extractAttributes(cert.issuer.attributes),
serialNumber: cert.serialNumber.toUpperCase(),
validFrom: cert.validity.notBefore.toISOString(),
validTo: cert.validity.notAfter.toISOString(),
daysRemaining,
isExpired: daysRemaining < 0,
sans: getSANs(cert),
fingerprints: {
sha1: getFingerprint(cert, 'sha1'),
sha256: getFingerprint(cert, 'sha256'),
},
publicKey: getPublicKeyInfo(cert.publicKey),
signatureAlgorithm: getSignatureAlgorithm(cert),
isSelfSigned: cert.isIssuer(cert),
version: cert.version + 1,
keyUsage: getKeyUsage(cert),
extKeyUsage: getExtKeyUsage(cert),
pem: forge.pki.certificateToPem(cert).trim(),
isCA: checkIsCA(cert),
};
}
export function decodePfx(buffer: Buffer, password: string): PfxResult {
const derBytes = forge.util.decode64(buffer.toString('base64'));
const asn1 = forge.asn1.fromDer(derBytes);
const p12 = forge.pkcs12.pkcs12FromAsn1(asn1, false, password);
const certificates: CertificateInfo[] = [];
let privateKeyPem: string | null = null;
const certBags = p12.getBags({ bagType: forge.pki.oids.certBag });
const certs = certBags[forge.pki.oids.certBag] || [];
for (const bag of certs) {
if (bag.cert) {
const pem = forge.pki.certificateToPem(bag.cert);
certificates.push(parseCertificate(pem));
}
}
const shroudedKeyBags = p12.getBags({ bagType: forge.pki.oids.pkcs8ShroudedKeyBag });
const shroudedKeys = shroudedKeyBags[forge.pki.oids.pkcs8ShroudedKeyBag] || [];
if (shroudedKeys.length > 0 && shroudedKeys[0].key) {
privateKeyPem = forge.pki.privateKeyToPem(shroudedKeys[0].key).trim();
}
if (!privateKeyPem) {
const keyBags = p12.getBags({ bagType: forge.pki.oids.keyBag });
const keys = keyBags[forge.pki.oids.keyBag] || [];
if (keys.length > 0 && keys[0].key) {
privateKeyPem = forge.pki.privateKeyToPem(keys[0].key).trim();
}
}
// Sort: end-entity first, then intermediates, then root CA
certificates.sort((a, b) => {
if (!a.isCA && b.isCA) return -1;
if (a.isCA && !b.isCA) return 1;
if (a.isSelfSigned && !b.isSelfSigned) return 1;
if (!a.isSelfSigned && b.isSelfSigned) return -1;
return 0;
});
return { certificates, privateKeyPem };
}
export function matchKeyToCert(certPem: string, keyPem: string): MatchResult {
const cert = forge.pki.certificateFromPem(certPem);
const privateKey = forge.pki.privateKeyFromPem(keyPem);
const pubKey = cert.publicKey as forge.pki.rsa.PublicKey;
const certModulus = (pubKey as any).n.toString(16);
const keyModulus = (privateKey as any).n.toString(16);
return {
match: certModulus === keyModulus,
certModulus: certModulus.substring(0, 48) + '...',
keyModulus: keyModulus.substring(0, 48) + '...',
};
}
export function parseCsr(pem: string): CsrInfo {
const csr = forge.pki.certificationRequestFromPem(pem);
const sans: string[] = [];
const extensionRequest = (csr as any).getAttribute?.({ name: 'extensionRequest' });
if (extensionRequest && extensionRequest.extensions) {
for (const ext of extensionRequest.extensions) {
if (ext.name === 'subjectAltName' && ext.altNames) {
for (const an of ext.altNames) {
if (an.type === 2) sans.push(`DNS: ${an.value}`);
else if (an.type === 7) sans.push(`IP: ${an.ip || an.value}`);
else if (an.type === 1) sans.push(`Email: ${an.value}`);
}
}
}
}
let isSignatureValid = false;
try {
isSignatureValid = csr.verify();
} catch {
isSignatureValid = false;
}
return {
subject: extractAttributes(csr.subject.attributes),
publicKey: csr.publicKey ? getPublicKeyInfo(csr.publicKey) : { algorithm: 'Unknown', bits: 0 },
sans,
isSignatureValid,
pem: forge.pki.certificationRequestToPem(csr).trim(),
};
}
export function verifyChain(pemChain: string): ChainVerifyResult {
const pemRegex = /-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g;
const pems = pemChain.match(pemRegex) || [];
if (pems.length === 0) {
return { certificates: [], isValid: false, errors: ['No certificates found in input'], chainOrder: [] };
}
const certificates = pems.map(p => parseCertificate(p));
const forgeCerts = pems.map(p => forge.pki.certificateFromPem(p));
const errors: string[] = [];
// Sort: end-entity → intermediates → root
const indices = certificates.map((_, i) => i);
indices.sort((a, b) => {
const ca = certificates[a], cb = certificates[b];
if (!ca.isCA && cb.isCA) return -1;
if (ca.isCA && !cb.isCA) return 1;
if (ca.isSelfSigned && !cb.isSelfSigned) return 1;
if (!ca.isSelfSigned && cb.isSelfSigned) return -1;
return 0;
});
const sortedCerts = indices.map(i => certificates[i]);
const sortedForge = indices.map(i => forgeCerts[i]);
// Verify chain links
for (let i = 0; i < sortedForge.length - 1; i++) {
try {
const issuerFound = sortedForge.find((c, j) => {
if (j === i) return false;
try { return c.verify(sortedForge[i]); } catch { return false; }
});
if (!issuerFound) {
errors.push(`Cannot find issuer for "${sortedCerts[i].subject.CN || 'Unknown'}"`);
}
} catch (e: any) {
errors.push(`Verification error for "${sortedCerts[i].subject.CN}": ${e.message}`);
}
}
for (const cert of sortedCerts) {
if (cert.isExpired) {
errors.push(`Certificate "${cert.subject.CN || 'Unknown'}" has expired`);
}
}
const chainOrder = sortedCerts.map(c => {
if (!c.isCA) return `End Entity: ${c.subject.CN || 'Unknown'}`;
if (c.isSelfSigned) return `Root CA: ${c.subject.CN || 'Unknown'}`;
return `Intermediate CA: ${c.subject.CN || 'Unknown'}`;
});
return {
certificates: sortedCerts,
isValid: errors.length === 0,
errors,
chainOrder,
};
}

17
server/tsconfig.json Normal file
View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}