Initial commit: CertTools SSL certificate toolkit
Made-with: Cursor
This commit is contained in:
16
client/index.html
Normal file
16
client/index.html
Normal 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
2786
client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
client/package.json
Normal file
26
client/package.json
Normal 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
6
client/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
93
client/src/App.tsx
Normal file
93
client/src/App.tsx
Normal 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
64
client/src/api.ts
Normal 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);
|
||||
}
|
||||
191
client/src/components/CertificateInfo.tsx
Normal file
191
client/src/components/CertificateInfo.tsx
Normal 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'} · 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>
|
||||
);
|
||||
}
|
||||
151
client/src/components/ChainVerifier.tsx
Normal file
151
client/src/components/ChainVerifier.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
44
client/src/components/CopyButton.tsx
Normal file
44
client/src/components/CopyButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
170
client/src/components/CsrDecoder.tsx
Normal file
170
client/src/components/CsrDecoder.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
84
client/src/components/FileUpload.tsx
Normal file
84
client/src/components/FileUpload.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
client/src/components/Header.tsx
Normal file
39
client/src/components/Header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
149
client/src/components/KeyMatcher.tsx
Normal file
149
client/src/components/KeyMatcher.tsx
Normal 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----- ... -----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----- ... -----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>
|
||||
);
|
||||
}
|
||||
97
client/src/components/PemDecoder.tsx
Normal file
97
client/src/components/PemDecoder.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
146
client/src/components/PfxDecoder.tsx
Normal file
146
client/src/components/PfxDecoder.tsx
Normal 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
83
client/src/index.css
Normal 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
10
client/src/main.tsx
Normal 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
58
client/src/types.ts
Normal 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
1
client/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
14
client/tailwind.config.js
Normal file
14
client/tailwind.config.js
Normal 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
21
client/tsconfig.json
Normal 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
15
client/vite.config.ts
Normal 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',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user