Initial commit: CertTools SSL certificate toolkit
Made-with: Cursor
This commit is contained in:
1725
server/package-lock.json
generated
Normal file
1725
server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
server/package.json
Normal file
23
server/package.json
Normal 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
26
server/src/index.ts
Normal 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}`);
|
||||
});
|
||||
99
server/src/routes/certificates.ts
Normal file
99
server/src/routes/certificates.ts
Normal 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;
|
||||
324
server/src/services/certService.ts
Normal file
324
server/src/services/certService.ts
Normal 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
17
server/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user