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

16
client/index.html Normal file
View File

@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🔐</text></svg>" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CertTools — SSL Certificate Toolkit</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2786
client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

26
client/package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "cert-tools-client",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.0",
"react-dom": "^18.3.0",
"lucide-react": "^0.460.0",
"clsx": "^2.1.1"
},
"devDependencies": {
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.0",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.15",
"typescript": "^5.6.0",
"vite": "^6.0.0"
}
}

6
client/postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

93
client/src/App.tsx Normal file
View File

@@ -0,0 +1,93 @@
import { useState, useEffect } from 'react';
import { FileKey, FileText, KeyRound, FileLock, Link } from 'lucide-react';
import { Header } from './components/Header';
import { PfxDecoder } from './components/PfxDecoder';
import { PemDecoder } from './components/PemDecoder';
import { KeyMatcher } from './components/KeyMatcher';
import { CsrDecoder } from './components/CsrDecoder';
import { ChainVerifier } from './components/ChainVerifier';
const tools = [
{ id: 'pfx', name: 'PFX Decoder', shortName: 'PFX', icon: FileKey, description: 'Extract certs & key from PFX' },
{ id: 'pem', name: 'Certificate Decoder', shortName: 'Cert', icon: FileText, description: 'Decode PEM certificates' },
{ id: 'match', name: 'Key Matcher', shortName: 'Match', icon: KeyRound, description: 'Verify key matches cert' },
{ id: 'csr', name: 'CSR Decoder', shortName: 'CSR', icon: FileLock, description: 'Decode signing requests' },
{ id: 'chain', name: 'Chain Verifier', shortName: 'Chain', icon: Link, description: 'Validate cert chain' },
] as const;
type ToolId = (typeof tools)[number]['id'];
function getInitialDarkMode(): boolean {
const saved = localStorage.getItem('cert-tools-dark');
if (saved !== null) return saved === 'true';
return window.matchMedia('(prefers-color-scheme: dark)').matches;
}
export default function App() {
const [activeTool, setActiveTool] = useState<ToolId>('pfx');
const [darkMode, setDarkMode] = useState(getInitialDarkMode);
useEffect(() => {
localStorage.setItem('cert-tools-dark', String(darkMode));
}, [darkMode]);
const renderTool = () => {
switch (activeTool) {
case 'pfx': return <PfxDecoder />;
case 'pem': return <PemDecoder />;
case 'match': return <KeyMatcher />;
case 'csr': return <CsrDecoder />;
case 'chain': return <ChainVerifier />;
}
};
return (
<div className={darkMode ? 'dark' : ''}>
<div className="min-h-screen bg-slate-50 dark:bg-slate-950 text-slate-900 dark:text-slate-100 transition-colors">
<Header darkMode={darkMode} onToggleDark={() => setDarkMode(!darkMode)} />
{/* Tool Navigation */}
<nav className="border-b bg-white/50 dark:bg-slate-900/50 backdrop-blur-sm">
<div className="max-w-6xl mx-auto px-4">
<div className="flex gap-1 overflow-x-auto py-2 -mb-px scrollbar-none">
{tools.map((tool) => {
const Icon = tool.icon;
const isActive = activeTool === tool.id;
return (
<button
key={tool.id}
onClick={() => setActiveTool(tool.id)}
className={`flex items-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium whitespace-nowrap transition-all duration-200 ${
isActive
? 'bg-blue-600 text-white shadow-md shadow-blue-500/20'
: 'text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800'
}`}
title={tool.description}
>
<Icon className="w-4 h-4" />
<span className="hidden sm:inline">{tool.name}</span>
<span className="sm:hidden">{tool.shortName}</span>
</button>
);
})}
</div>
</div>
</nav>
{/* Main Content */}
<main className="max-w-6xl mx-auto px-4 py-8">
{renderTool()}
</main>
{/* Footer */}
<footer className="border-t mt-auto">
<div className="max-w-6xl mx-auto px-4 py-4">
<p className="text-xs text-center text-slate-400 dark:text-slate-500">
CertTools All certificate processing is done server-side. No data is stored.
</p>
</div>
</footer>
</div>
</div>
);
}

64
client/src/api.ts Normal file
View File

@@ -0,0 +1,64 @@
import type { PfxResult, PemDecodeResult, MatchResult, CsrInfo, ChainVerifyResult } from './types';
const API_BASE = '/api';
async function handleResponse<T>(response: Response): Promise<T> {
if (!response.ok) {
const body = await response.json().catch(() => ({ error: 'Unknown error' }));
throw new Error(body.error || `Request failed with status ${response.status}`);
}
return response.json();
}
export async function decodePfx(file: File, password: string): Promise<PfxResult> {
const formData = new FormData();
formData.append('file', file);
formData.append('password', password);
const response = await fetch(`${API_BASE}/decode/pfx`, {
method: 'POST',
body: formData,
});
return handleResponse<PfxResult>(response);
}
export async function decodePem(pem: string): Promise<PemDecodeResult> {
const response = await fetch(`${API_BASE}/decode/pem`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pem }),
});
return handleResponse<PemDecodeResult>(response);
}
export async function matchKeyToCert(certificate: string, privateKey: string): Promise<MatchResult> {
const response = await fetch(`${API_BASE}/match`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ certificate, privateKey }),
});
return handleResponse<MatchResult>(response);
}
export async function decodeCsr(pem: string): Promise<CsrInfo> {
const response = await fetch(`${API_BASE}/decode/csr`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pem }),
});
return handleResponse<CsrInfo>(response);
}
export async function verifyChain(pem: string): Promise<ChainVerifyResult> {
const response = await fetch(`${API_BASE}/chain/verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pem }),
});
return handleResponse<ChainVerifyResult>(response);
}

View File

@@ -0,0 +1,191 @@
import { useState } from 'react';
import { ChevronDown, ChevronRight, ShieldCheck, ShieldAlert, Clock, Building2, Globe } from 'lucide-react';
import { CopyButton } from './CopyButton';
import type { CertificateInfo as CertInfo } from '../types';
interface CertificateInfoProps {
cert: CertInfo;
index?: number;
defaultExpanded?: boolean;
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
function InfoRow({ label, value, mono }: { label: string; value: string | number; mono?: boolean }) {
return (
<div className="flex flex-col sm:flex-row sm:items-start gap-1 sm:gap-3 py-2 border-b border-slate-100 dark:border-slate-700/40 last:border-0">
<span className="text-xs font-medium text-slate-500 dark:text-slate-400 sm:w-40 shrink-0 uppercase tracking-wide">
{label}
</span>
<span className={`text-sm text-slate-800 dark:text-slate-200 break-all ${mono ? 'font-mono text-xs' : ''}`}>
{value}
</span>
</div>
);
}
export function CertificateInfoCard({ cert, index, defaultExpanded = true }: CertificateInfoProps) {
const [expanded, setExpanded] = useState(defaultExpanded);
const [showPem, setShowPem] = useState(false);
const roleLabel = !cert.isCA
? 'End Entity'
: cert.isSelfSigned
? 'Root CA'
: 'Intermediate CA';
const roleBadge = !cert.isCA ? 'badge-blue' : cert.isSelfSigned ? 'badge-amber' : 'badge-blue';
return (
<div className="card overflow-hidden">
<button
onClick={() => setExpanded(!expanded)}
className="w-full flex items-center gap-3 p-4 hover:bg-slate-50 dark:hover:bg-slate-700/30 transition-colors text-left"
>
{expanded ? (
<ChevronDown className="w-4 h-4 text-slate-400 shrink-0" />
) : (
<ChevronRight className="w-4 h-4 text-slate-400 shrink-0" />
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
{index !== undefined && (
<span className="text-xs font-mono text-slate-400">#{index + 1}</span>
)}
<h3 className="text-sm font-semibold text-slate-900 dark:text-white truncate">
{cert.subject.CN || 'Unknown CN'}
</h3>
<span className={roleBadge}>{roleLabel}</span>
{cert.isExpired ? (
<span className="badge-red">
<ShieldAlert className="w-3 h-3" /> Expired
</span>
) : cert.daysRemaining <= 30 ? (
<span className="badge-amber">
<Clock className="w-3 h-3" /> {cert.daysRemaining}d left
</span>
) : (
<span className="badge-green">
<ShieldCheck className="w-3 h-3" /> Valid
</span>
)}
</div>
{!expanded && (
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
Issued by {cert.issuer.CN || cert.issuer.O || 'Unknown'} &middot; Expires {formatDate(cert.validTo)}
</p>
)}
</div>
</button>
{expanded && (
<div className="border-t px-4 pb-4">
{/* Subject */}
<div className="mt-3">
<h4 className="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider mb-1 flex items-center gap-1.5">
<Globe className="w-3.5 h-3.5" /> Subject
</h4>
<div className="bg-slate-50 dark:bg-slate-900/40 rounded-lg p-3">
{Object.entries(cert.subject).map(([key, val]) => (
<InfoRow key={key} label={key} value={val} />
))}
</div>
</div>
{/* Issuer */}
<div className="mt-3">
<h4 className="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider mb-1 flex items-center gap-1.5">
<Building2 className="w-3.5 h-3.5" /> Issuer
</h4>
<div className="bg-slate-50 dark:bg-slate-900/40 rounded-lg p-3">
{Object.entries(cert.issuer).map(([key, val]) => (
<InfoRow key={key} label={key} value={val} />
))}
</div>
</div>
{/* Validity */}
<div className="mt-3 bg-slate-50 dark:bg-slate-900/40 rounded-lg p-3">
<InfoRow label="Valid From" value={formatDate(cert.validFrom)} />
<InfoRow label="Valid To" value={formatDate(cert.validTo)} />
<InfoRow
label="Days Remaining"
value={cert.isExpired ? `Expired ${Math.abs(cert.daysRemaining)} days ago` : `${cert.daysRemaining} days`}
/>
</div>
{/* SANs */}
{cert.sans.length > 0 && (
<div className="mt-3">
<h4 className="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider mb-1">
Subject Alternative Names ({cert.sans.length})
</h4>
<div className="flex flex-wrap gap-1.5">
{cert.sans.map((san, i) => (
<span key={i} className="px-2 py-1 bg-slate-100 dark:bg-slate-700/50 rounded text-xs font-mono text-slate-700 dark:text-slate-300">
{san}
</span>
))}
</div>
</div>
)}
{/* Technical Details */}
<div className="mt-3 bg-slate-50 dark:bg-slate-900/40 rounded-lg p-3">
<InfoRow label="Serial Number" value={cert.serialNumber} mono />
<InfoRow label="Version" value={`v${cert.version}`} />
<InfoRow label="Signature" value={cert.signatureAlgorithm} />
<InfoRow label="Public Key" value={`${cert.publicKey.algorithm} ${cert.publicKey.bits} bits`} />
<InfoRow label="Self-signed" value={cert.isSelfSigned ? 'Yes' : 'No'} />
</div>
{/* Key Usage */}
{(cert.keyUsage.length > 0 || cert.extKeyUsage.length > 0) && (
<div className="mt-3 bg-slate-50 dark:bg-slate-900/40 rounded-lg p-3">
{cert.keyUsage.length > 0 && (
<InfoRow label="Key Usage" value={cert.keyUsage.join(', ')} />
)}
{cert.extKeyUsage.length > 0 && (
<InfoRow label="Ext Key Usage" value={cert.extKeyUsage.join(', ')} />
)}
</div>
)}
{/* Fingerprints */}
<div className="mt-3 bg-slate-50 dark:bg-slate-900/40 rounded-lg p-3">
<InfoRow label="SHA-256" value={cert.fingerprints.sha256} mono />
<InfoRow label="SHA-1" value={cert.fingerprints.sha1} mono />
</div>
{/* PEM */}
<div className="mt-3">
<button
onClick={() => setShowPem(!showPem)}
className="text-xs font-medium text-blue-600 dark:text-blue-400 hover:underline"
>
{showPem ? 'Hide' : 'Show'} PEM
</button>
{showPem && (
<div className="mt-2 relative">
<pre className="bg-slate-900 dark:bg-slate-950 text-green-400 p-4 rounded-lg text-xs overflow-x-auto whitespace-pre-wrap break-all">
{cert.pem}
</pre>
<div className="absolute top-2 right-2">
<CopyButton text={cert.pem} />
</div>
</div>
)}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,151 @@
import { useState } from 'react';
import { Link, Loader2, Trash2, CheckCircle2, AlertTriangle, XCircle } from 'lucide-react';
import { CertificateInfoCard } from './CertificateInfo';
import { verifyChain } from '../api';
import type { ChainVerifyResult } from '../types';
export function ChainVerifier() {
const [pem, setPem] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [result, setResult] = useState<ChainVerifyResult | null>(null);
const handleVerify = async () => {
if (!pem.trim()) return;
setLoading(true);
setError('');
setResult(null);
try {
const data = await verifyChain(pem);
setResult(data);
} catch (e: any) {
setError(e.message);
} finally {
setLoading(false);
}
};
const handleClear = () => {
setPem('');
setError('');
setResult(null);
};
return (
<div className="space-y-6">
<div className="card p-6">
<div className="flex items-center gap-2 mb-4">
<Link className="w-5 h-5 text-blue-500" />
<h2 className="text-lg font-semibold text-slate-900 dark:text-white">
Certificate Chain Verifier
</h2>
</div>
<p className="text-sm text-slate-500 dark:text-slate-400 mb-5">
Paste a full certificate chain (multiple PEM certificates) to verify the chain order and validity.
</p>
<div className="space-y-4">
<div>
<label className="label">Certificate Chain (PEM)</label>
<textarea
value={pem}
onChange={(e) => setPem(e.target.value)}
placeholder={"-----BEGIN CERTIFICATE-----\n(end-entity certificate)\n-----END CERTIFICATE-----\n\n-----BEGIN CERTIFICATE-----\n(intermediate CA)\n-----END CERTIFICATE-----\n\n-----BEGIN CERTIFICATE-----\n(root CA)\n-----END CERTIFICATE-----"}
rows={12}
className="textarea-field"
/>
</div>
<div className="flex gap-3">
<button onClick={handleVerify} disabled={!pem.trim() || loading} className="btn-primary">
{loading && <Loader2 className="w-4 h-4 animate-spin inline mr-2" />}
Verify Chain
</button>
{(pem || result) && (
<button onClick={handleClear} className="btn-secondary flex items-center gap-1.5">
<Trash2 className="w-3.5 h-3.5" />
Clear
</button>
)}
</div>
</div>
</div>
{error && (
<div className="p-4 rounded-lg bg-red-50 dark:bg-red-500/10 border border-red-200 dark:border-red-500/20">
<p className="text-sm text-red-700 dark:text-red-400">{error}</p>
</div>
)}
{result && (
<div className="space-y-4">
{/* Verification Status */}
<div
className={`card p-5 border-2 ${
result.isValid
? 'border-emerald-300 dark:border-emerald-500/30 bg-emerald-50/50 dark:bg-emerald-500/5'
: 'border-red-300 dark:border-red-500/30 bg-red-50/50 dark:bg-red-500/5'
}`}
>
<div className="flex items-start gap-3">
{result.isValid ? (
<CheckCircle2 className="w-6 h-6 text-emerald-500 shrink-0 mt-0.5" />
) : (
<XCircle className="w-6 h-6 text-red-500 shrink-0 mt-0.5" />
)}
<div>
<h3 className={`text-base font-bold ${
result.isValid ? 'text-emerald-700 dark:text-emerald-400' : 'text-red-700 dark:text-red-400'
}`}>
{result.isValid ? 'Chain is Valid' : 'Chain Validation Failed'}
</h3>
{result.errors.length > 0 && (
<ul className="mt-2 space-y-1">
{result.errors.map((err, i) => (
<li key={i} className="flex items-start gap-1.5 text-sm text-red-600 dark:text-red-400">
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
{err}
</li>
))}
</ul>
)}
</div>
</div>
</div>
{/* Chain Order */}
{result.chainOrder.length > 0 && (
<div className="card p-5">
<h3 className="text-sm font-semibold text-slate-900 dark:text-white mb-3">Chain Order</h3>
<div className="space-y-2">
{result.chainOrder.map((item, i) => (
<div key={i} className="flex items-center gap-3">
<div className="flex flex-col items-center">
<div className={`w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold text-white ${
i === 0 ? 'bg-blue-500' : i === result.chainOrder.length - 1 ? 'bg-amber-500' : 'bg-slate-400'
}`}>
{i + 1}
</div>
{i < result.chainOrder.length - 1 && (
<div className="w-px h-4 bg-slate-300 dark:bg-slate-600" />
)}
</div>
<span className="text-sm text-slate-700 dark:text-slate-300">{item}</span>
</div>
))}
</div>
</div>
)}
{/* Certificates */}
<h3 className="text-sm font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider">
Certificates ({result.certificates.length})
</h3>
{result.certificates.map((cert, i) => (
<CertificateInfoCard key={i} cert={cert} index={i} defaultExpanded={false} />
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,44 @@
import { useState, useCallback } from 'react';
import { Copy, Check } from 'lucide-react';
interface CopyButtonProps {
text: string;
className?: string;
label?: string;
}
export function CopyButton({ text, className = '', label }: CopyButtonProps) {
const [copied, setCopied] = useState(false);
const handleCopy = useCallback(async () => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
const ta = document.createElement('textarea');
ta.value = text;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
}, [text]);
return (
<button
onClick={handleCopy}
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium rounded-md
transition-all duration-200
${copied
? 'bg-emerald-50 dark:bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
: 'bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300 hover:bg-slate-200 dark:hover:bg-slate-600'
} ${className}`}
>
{copied ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
{label || (copied ? 'Copied' : 'Copy')}
</button>
);
}

View File

@@ -0,0 +1,170 @@
import { useState } from 'react';
import { FileLock, Loader2, Trash2, CheckCircle2, XCircle } from 'lucide-react';
import { CopyButton } from './CopyButton';
import { decodeCsr } from '../api';
import type { CsrInfo } from '../types';
export function CsrDecoder() {
const [pem, setPem] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [result, setResult] = useState<CsrInfo | null>(null);
const handleDecode = async () => {
if (!pem.trim()) return;
setLoading(true);
setError('');
setResult(null);
try {
const data = await decodeCsr(pem);
setResult(data);
} catch (e: any) {
setError(e.message);
} finally {
setLoading(false);
}
};
const handleClear = () => {
setPem('');
setError('');
setResult(null);
};
return (
<div className="space-y-6">
<div className="card p-6">
<div className="flex items-center gap-2 mb-4">
<FileLock className="w-5 h-5 text-blue-500" />
<h2 className="text-lg font-semibold text-slate-900 dark:text-white">
CSR Decoder
</h2>
</div>
<p className="text-sm text-slate-500 dark:text-slate-400 mb-5">
Paste a PEM-encoded Certificate Signing Request to view its contents and verify its signature.
</p>
<div className="space-y-4">
<div>
<label className="label">CSR (PEM)</label>
<textarea
value={pem}
onChange={(e) => setPem(e.target.value)}
placeholder={"-----BEGIN CERTIFICATE REQUEST-----\n...\n-----END CERTIFICATE REQUEST-----"}
rows={10}
className="textarea-field"
/>
</div>
<div className="flex gap-3">
<button onClick={handleDecode} disabled={!pem.trim() || loading} className="btn-primary">
{loading && <Loader2 className="w-4 h-4 animate-spin inline mr-2" />}
Decode CSR
</button>
{(pem || result) && (
<button onClick={handleClear} className="btn-secondary flex items-center gap-1.5">
<Trash2 className="w-3.5 h-3.5" />
Clear
</button>
)}
</div>
</div>
</div>
{error && (
<div className="p-4 rounded-lg bg-red-50 dark:bg-red-500/10 border border-red-200 dark:border-red-500/20">
<p className="text-sm text-red-700 dark:text-red-400">{error}</p>
</div>
)}
{result && (
<div className="card p-6 space-y-4">
<h3 className="text-sm font-semibold text-slate-900 dark:text-white">CSR Details</h3>
{/* Signature Verification */}
<div
className={`flex items-center gap-2 p-3 rounded-lg ${
result.isSignatureValid
? 'bg-emerald-50 dark:bg-emerald-500/10 border border-emerald-200 dark:border-emerald-500/20'
: 'bg-red-50 dark:bg-red-500/10 border border-red-200 dark:border-red-500/20'
}`}
>
{result.isSignatureValid ? (
<>
<CheckCircle2 className="w-5 h-5 text-emerald-500" />
<span className="text-sm font-medium text-emerald-700 dark:text-emerald-400">
CSR signature is valid
</span>
</>
) : (
<>
<XCircle className="w-5 h-5 text-red-500" />
<span className="text-sm font-medium text-red-700 dark:text-red-400">
CSR signature verification failed
</span>
</>
)}
</div>
{/* Subject */}
<div>
<h4 className="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider mb-2">
Subject
</h4>
<div className="bg-slate-50 dark:bg-slate-900/40 rounded-lg p-3">
{Object.entries(result.subject).map(([key, val]) => (
<div key={key} className="flex gap-3 py-1.5 border-b border-slate-100 dark:border-slate-700/40 last:border-0">
<span className="text-xs font-medium text-slate-500 dark:text-slate-400 w-20 shrink-0 uppercase">
{key}
</span>
<span className="text-sm text-slate-800 dark:text-slate-200">{val}</span>
</div>
))}
</div>
</div>
{/* Public Key */}
<div className="bg-slate-50 dark:bg-slate-900/40 rounded-lg p-3">
<div className="flex gap-3 py-1.5">
<span className="text-xs font-medium text-slate-500 dark:text-slate-400 w-20 shrink-0 uppercase">
Algorithm
</span>
<span className="text-sm text-slate-800 dark:text-slate-200">
{result.publicKey.algorithm} {result.publicKey.bits} bits
</span>
</div>
</div>
{/* SANs */}
{result.sans.length > 0 && (
<div>
<h4 className="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider mb-2">
Subject Alternative Names ({result.sans.length})
</h4>
<div className="flex flex-wrap gap-1.5">
{result.sans.map((san, i) => (
<span key={i} className="px-2 py-1 bg-slate-100 dark:bg-slate-700/50 rounded text-xs font-mono text-slate-700 dark:text-slate-300">
{san}
</span>
))}
</div>
</div>
)}
{/* PEM */}
<div>
<div className="flex items-center justify-between mb-2">
<h4 className="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider">
PEM
</h4>
<CopyButton text={result.pem} />
</div>
<pre className="bg-slate-900 dark:bg-slate-950 text-green-400 p-4 rounded-lg text-xs overflow-x-auto whitespace-pre-wrap break-all">
{result.pem}
</pre>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,84 @@
import { useState, useCallback, useRef } from 'react';
import { Upload, FileKey, X } from 'lucide-react';
interface FileUploadProps {
onFileSelect: (file: File) => void;
accept?: string;
selectedFile: File | null;
onClear: () => void;
}
export function FileUpload({ onFileSelect, accept = '.pfx,.p12', selectedFile, onClear }: FileUploadProps) {
const [dragOver, setDragOver] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
const file = e.dataTransfer.files[0];
if (file) onFileSelect(file);
},
[onFileSelect],
);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) onFileSelect(file);
},
[onFileSelect],
);
if (selectedFile) {
return (
<div className="flex items-center gap-3 p-4 rounded-lg border bg-blue-50/50 dark:bg-blue-500/5 border-blue-200 dark:border-blue-500/20">
<FileKey className="w-8 h-8 text-blue-500 shrink-0" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-slate-900 dark:text-white truncate">
{selectedFile.name}
</p>
<p className="text-xs text-slate-500 dark:text-slate-400">
{(selectedFile.size / 1024).toFixed(1)} KB
</p>
</div>
<button
onClick={onClear}
className="p-1.5 rounded-md hover:bg-slate-200 dark:hover:bg-slate-700 transition-colors"
>
<X className="w-4 h-4 text-slate-400" />
</button>
</div>
);
}
return (
<div
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
onDragLeave={() => setDragOver(false)}
onDrop={handleDrop}
onClick={() => inputRef.current?.click()}
className={`relative flex flex-col items-center justify-center p-8 rounded-xl border-2 border-dashed
cursor-pointer transition-all duration-200
${dragOver
? 'border-blue-500 bg-blue-50/50 dark:bg-blue-500/5'
: 'border-slate-300 dark:border-slate-600 hover:border-blue-400 dark:hover:border-blue-500 hover:bg-slate-50 dark:hover:bg-slate-800/50'
}`}
>
<Upload className={`w-8 h-8 mb-3 ${dragOver ? 'text-blue-500' : 'text-slate-400'}`} />
<p className="text-sm font-medium text-slate-700 dark:text-slate-300">
Drop your file here or <span className="text-blue-600 dark:text-blue-400">browse</span>
</p>
<p className="text-xs text-slate-400 mt-1">
Supports {accept} files up to 10 MB
</p>
<input
ref={inputRef}
type="file"
accept={accept}
onChange={handleChange}
className="hidden"
/>
</div>
);
}

View File

@@ -0,0 +1,39 @@
import { Shield, Sun, Moon } from 'lucide-react';
interface HeaderProps {
darkMode: boolean;
onToggleDark: () => void;
}
export function Header({ darkMode, onToggleDark }: HeaderProps) {
return (
<header className="border-b bg-white/80 dark:bg-slate-900/80 backdrop-blur-md sticky top-0 z-50">
<div className="max-w-6xl mx-auto px-4 h-16 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-lg bg-blue-600 flex items-center justify-center">
<Shield className="w-5 h-5 text-white" />
</div>
<div>
<h1 className="text-lg font-bold text-slate-900 dark:text-white leading-tight">
CertTools
</h1>
<p className="text-xs text-slate-500 dark:text-slate-400 leading-tight">
SSL Certificate Toolkit
</p>
</div>
</div>
<button
onClick={onToggleDark}
className="p-2.5 rounded-lg bg-slate-100 dark:bg-slate-800 hover:bg-slate-200 dark:hover:bg-slate-700 transition-colors"
title={darkMode ? 'Switch to light mode' : 'Switch to dark mode'}
>
{darkMode ? (
<Sun className="w-4.5 h-4.5 text-slate-600 dark:text-slate-300" />
) : (
<Moon className="w-4.5 h-4.5 text-slate-600 dark:text-slate-300" />
)}
</button>
</div>
</header>
);
}

View File

@@ -0,0 +1,149 @@
import { useState } from 'react';
import { KeyRound, Loader2, CheckCircle2, XCircle, Trash2 } from 'lucide-react';
import { matchKeyToCert } from '../api';
import type { MatchResult } from '../types';
export function KeyMatcher() {
const [certPem, setCertPem] = useState('');
const [keyPem, setKeyPem] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [result, setResult] = useState<MatchResult | null>(null);
const handleMatch = async () => {
if (!certPem.trim() || !keyPem.trim()) return;
setLoading(true);
setError('');
setResult(null);
try {
const data = await matchKeyToCert(certPem, keyPem);
setResult(data);
} catch (e: any) {
setError(e.message);
} finally {
setLoading(false);
}
};
const handleClear = () => {
setCertPem('');
setKeyPem('');
setError('');
setResult(null);
};
return (
<div className="space-y-6">
<div className="card p-6">
<div className="flex items-center gap-2 mb-4">
<KeyRound className="w-5 h-5 text-blue-500" />
<h2 className="text-lg font-semibold text-slate-900 dark:text-white">
Certificate & Key Matcher
</h2>
</div>
<p className="text-sm text-slate-500 dark:text-slate-400 mb-5">
Check whether a private key matches a certificate by comparing their public key modulus.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="label">Certificate (PEM)</label>
<textarea
value={certPem}
onChange={(e) => setCertPem(e.target.value)}
placeholder="-----BEGIN CERTIFICATE-----&#10;...&#10;-----END CERTIFICATE-----"
rows={10}
className="textarea-field"
/>
</div>
<div>
<label className="label">Private Key (PEM)</label>
<textarea
value={keyPem}
onChange={(e) => setKeyPem(e.target.value)}
placeholder="-----BEGIN RSA PRIVATE KEY-----&#10;...&#10;-----END RSA PRIVATE KEY-----"
rows={10}
className="textarea-field"
/>
</div>
</div>
<div className="flex gap-3 mt-4">
<button
onClick={handleMatch}
disabled={!certPem.trim() || !keyPem.trim() || loading}
className="btn-primary"
>
{loading && <Loader2 className="w-4 h-4 animate-spin inline mr-2" />}
Check Match
</button>
{(certPem || keyPem || result) && (
<button onClick={handleClear} className="btn-secondary flex items-center gap-1.5">
<Trash2 className="w-3.5 h-3.5" />
Clear
</button>
)}
</div>
</div>
{error && (
<div className="p-4 rounded-lg bg-red-50 dark:bg-red-500/10 border border-red-200 dark:border-red-500/20">
<p className="text-sm text-red-700 dark:text-red-400">{error}</p>
</div>
)}
{result && (
<div
className={`card p-6 border-2 ${
result.match
? 'border-emerald-300 dark:border-emerald-500/30 bg-emerald-50/50 dark:bg-emerald-500/5'
: 'border-red-300 dark:border-red-500/30 bg-red-50/50 dark:bg-red-500/5'
}`}
>
<div className="flex items-center gap-3 mb-4">
{result.match ? (
<>
<CheckCircle2 className="w-8 h-8 text-emerald-500" />
<div>
<h3 className="text-lg font-bold text-emerald-700 dark:text-emerald-400">Match!</h3>
<p className="text-sm text-emerald-600 dark:text-emerald-500">
The private key matches this certificate.
</p>
</div>
</>
) : (
<>
<XCircle className="w-8 h-8 text-red-500" />
<div>
<h3 className="text-lg font-bold text-red-700 dark:text-red-400">No Match</h3>
<p className="text-sm text-red-600 dark:text-red-500">
The private key does NOT match this certificate.
</p>
</div>
</>
)}
</div>
<div className="bg-white/60 dark:bg-slate-800/40 rounded-lg p-4 space-y-2">
<div className="flex flex-col gap-1">
<span className="text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wide">
Certificate Modulus
</span>
<code className="text-xs font-mono text-slate-700 dark:text-slate-300 break-all">
{result.certModulus}
</code>
</div>
<div className="flex flex-col gap-1">
<span className="text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wide">
Key Modulus
</span>
<code className="text-xs font-mono text-slate-700 dark:text-slate-300 break-all">
{result.keyModulus}
</code>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,97 @@
import { useState } from 'react';
import { FileText, Loader2, Trash2 } from 'lucide-react';
import { CertificateInfoCard } from './CertificateInfo';
import { decodePem } from '../api';
import type { CertificateInfo } from '../types';
const PLACEHOLDER = `-----BEGIN CERTIFICATE-----
MIIDdzCCAl+gAwIBAgIEAgAAuTANBgkqhk...
Paste your PEM-encoded certificate here
-----END CERTIFICATE-----`;
export function PemDecoder() {
const [pem, setPem] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [certificates, setCertificates] = useState<CertificateInfo[]>([]);
const handleDecode = async () => {
if (!pem.trim()) return;
setLoading(true);
setError('');
setCertificates([]);
try {
const data = await decodePem(pem);
setCertificates(data.certificates);
} catch (e: any) {
setError(e.message);
} finally {
setLoading(false);
}
};
const handleClear = () => {
setPem('');
setError('');
setCertificates([]);
};
return (
<div className="space-y-6">
<div className="card p-6">
<div className="flex items-center gap-2 mb-4">
<FileText className="w-5 h-5 text-blue-500" />
<h2 className="text-lg font-semibold text-slate-900 dark:text-white">
Certificate Decoder
</h2>
</div>
<p className="text-sm text-slate-500 dark:text-slate-400 mb-5">
Paste a PEM-encoded certificate to view its details. Supports multiple certificates in one input.
</p>
<div className="space-y-4">
<div>
<label className="label">PEM Certificate</label>
<textarea
value={pem}
onChange={(e) => setPem(e.target.value)}
placeholder={PLACEHOLDER}
rows={10}
className="textarea-field"
/>
</div>
<div className="flex gap-3">
<button onClick={handleDecode} disabled={!pem.trim() || loading} className="btn-primary">
{loading && <Loader2 className="w-4 h-4 animate-spin inline mr-2" />}
Decode
</button>
{(pem || certificates.length > 0) && (
<button onClick={handleClear} className="btn-secondary flex items-center gap-1.5">
<Trash2 className="w-3.5 h-3.5" />
Clear
</button>
)}
</div>
</div>
</div>
{error && (
<div className="p-4 rounded-lg bg-red-50 dark:bg-red-500/10 border border-red-200 dark:border-red-500/20">
<p className="text-sm text-red-700 dark:text-red-400">{error}</p>
</div>
)}
{certificates.length > 0 && (
<div className="space-y-4">
<h3 className="text-sm font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider">
Decoded Certificates ({certificates.length})
</h3>
{certificates.map((cert, i) => (
<CertificateInfoCard key={i} cert={cert} index={i} defaultExpanded={i === 0} />
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,146 @@
import { useState } from 'react';
import { FileKey, Loader2, Eye, EyeOff } from 'lucide-react';
import { FileUpload } from './FileUpload';
import { CertificateInfoCard } from './CertificateInfo';
import { CopyButton } from './CopyButton';
import { decodePfx } from '../api';
import type { PfxResult } from '../types';
export function PfxDecoder() {
const [file, setFile] = useState<File | null>(null);
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [result, setResult] = useState<PfxResult | null>(null);
const [showKey, setShowKey] = useState(false);
const handleDecode = async () => {
if (!file) return;
setLoading(true);
setError('');
setResult(null);
try {
const data = await decodePfx(file, password);
setResult(data);
} catch (e: any) {
setError(e.message);
} finally {
setLoading(false);
}
};
const handleClear = () => {
setFile(null);
setPassword('');
setError('');
setResult(null);
setShowKey(false);
};
return (
<div className="space-y-6">
<div className="card p-6">
<div className="flex items-center gap-2 mb-4">
<FileKey className="w-5 h-5 text-blue-500" />
<h2 className="text-lg font-semibold text-slate-900 dark:text-white">
PFX / PKCS#12 Decoder
</h2>
</div>
<p className="text-sm text-slate-500 dark:text-slate-400 mb-5">
Upload a PFX/P12 file and enter the password to extract certificates and private key.
</p>
<div className="space-y-4">
<FileUpload
onFileSelect={setFile}
selectedFile={file}
onClear={() => setFile(null)}
accept=".pfx,.p12"
/>
<div>
<label className="label">Password</label>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter PFX password"
className="input-field pr-10"
onKeyDown={(e) => e.key === 'Enter' && handleDecode()}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300"
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
<div className="flex gap-3">
<button onClick={handleDecode} disabled={!file || loading} className="btn-primary">
{loading && <Loader2 className="w-4 h-4 animate-spin inline mr-2" />}
Decode PFX
</button>
{(file || result) && (
<button onClick={handleClear} className="btn-secondary">
Clear
</button>
)}
</div>
</div>
</div>
{error && (
<div className="p-4 rounded-lg bg-red-50 dark:bg-red-500/10 border border-red-200 dark:border-red-500/20">
<p className="text-sm text-red-700 dark:text-red-400">{error}</p>
</div>
)}
{result && (
<div className="space-y-4">
<h3 className="text-sm font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider">
Certificates ({result.certificates.length})
</h3>
{result.certificates.map((cert, i) => (
<CertificateInfoCard key={i} cert={cert} index={i} defaultExpanded={i === 0} />
))}
{result.privateKeyPem && (
<div className="card p-6">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-slate-900 dark:text-white">
Private Key
</h3>
<div className="flex gap-2">
<button
onClick={() => setShowKey(!showKey)}
className="btn-secondary text-xs flex items-center gap-1.5"
>
{showKey ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
{showKey ? 'Hide' : 'Reveal'}
</button>
<CopyButton text={result.privateKeyPem} label="Copy Key" />
</div>
</div>
{showKey ? (
<pre className="bg-slate-900 dark:bg-slate-950 text-amber-400 p-4 rounded-lg text-xs overflow-x-auto whitespace-pre-wrap break-all">
{result.privateKeyPem}
</pre>
) : (
<div className="bg-slate-100 dark:bg-slate-900/50 rounded-lg p-4 text-center">
<p className="text-sm text-slate-500 dark:text-slate-400">
Private key hidden for security. Click "Reveal" to show.
</p>
</div>
)}
</div>
)}
</div>
)}
</div>
);
}

83
client/src/index.css Normal file
View File

@@ -0,0 +1,83 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
@apply antialiased;
font-family: 'Inter', system-ui, -apple-system, sans-serif;
}
* {
@apply border-slate-200 dark:border-slate-700/60;
}
::selection {
@apply bg-blue-500/30;
}
::-webkit-scrollbar {
@apply w-2 h-2;
}
::-webkit-scrollbar-track {
@apply bg-transparent;
}
::-webkit-scrollbar-thumb {
@apply bg-slate-300 dark:bg-slate-700 rounded-full;
}
::-webkit-scrollbar-thumb:hover {
@apply bg-slate-400 dark:bg-slate-600;
}
}
@layer components {
.card {
@apply bg-white dark:bg-slate-800/50 rounded-xl border shadow-sm;
}
.btn-primary {
@apply px-5 py-2.5 bg-blue-600 hover:bg-blue-700 active:bg-blue-800
text-white font-medium rounded-lg transition-colors
disabled:opacity-50 disabled:cursor-not-allowed
focus:outline-none focus:ring-2 focus:ring-blue-500/40;
}
.btn-secondary {
@apply px-4 py-2 bg-slate-100 dark:bg-slate-700 hover:bg-slate-200 dark:hover:bg-slate-600
text-slate-700 dark:text-slate-200 font-medium rounded-lg transition-colors
disabled:opacity-50 disabled:cursor-not-allowed;
}
.input-field {
@apply w-full px-4 py-2.5 rounded-lg border bg-white dark:bg-slate-900/50
text-slate-900 dark:text-slate-100 placeholder:text-slate-400
focus:outline-none focus:ring-2 focus:ring-blue-500/40 focus:border-blue-500
transition-colors;
}
.textarea-field {
@apply w-full px-4 py-3 rounded-lg border bg-white dark:bg-slate-900/50
text-slate-900 dark:text-slate-100 placeholder:text-slate-400
focus:outline-none focus:ring-2 focus:ring-blue-500/40 focus:border-blue-500
font-mono text-sm leading-relaxed resize-y transition-colors;
}
.label {
@apply block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5;
}
.badge-green {
@apply inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium
bg-emerald-50 dark:bg-emerald-500/10 text-emerald-700 dark:text-emerald-400
border border-emerald-200 dark:border-emerald-500/20;
}
.badge-red {
@apply inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium
bg-red-50 dark:bg-red-500/10 text-red-700 dark:text-red-400
border border-red-200 dark:border-red-500/20;
}
.badge-blue {
@apply inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium
bg-blue-50 dark:bg-blue-500/10 text-blue-700 dark:text-blue-400
border border-blue-200 dark:border-blue-500/20;
}
.badge-amber {
@apply inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium
bg-amber-50 dark:bg-amber-500/10 text-amber-700 dark:text-amber-400
border border-amber-200 dark:border-amber-500/20;
}
}

10
client/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);

58
client/src/types.ts Normal file
View File

@@ -0,0 +1,58 @@
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 PemDecodeResult {
certificates: CertificateInfo[];
}
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[];
}

1
client/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

14
client/tailwind.config.js Normal file
View File

@@ -0,0 +1,14 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
darkMode: 'class',
theme: {
extend: {
fontFamily: {
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
mono: ['"JetBrains Mono"', '"Fira Code"', 'monospace'],
},
},
},
plugins: [],
};

21
client/tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src"]
}

15
client/vite.config.ts Normal file
View File

@@ -0,0 +1,15 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': 'http://localhost:3001',
},
},
build: {
outDir: 'dist',
},
});