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

5
.dockerignore Normal file
View File

@@ -0,0 +1,5 @@
node_modules
.git
*.md
client/dist
server/dist

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules/
dist/
*.log
.env
.DS_Store

27
Dockerfile Normal file
View File

@@ -0,0 +1,27 @@
# Stage 1: Build React client
FROM node:22-alpine AS client-build
WORKDIR /app/client
COPY client/package.json client/package-lock.json ./
RUN npm ci
COPY client/ ./
RUN npm run build
# Stage 2: Compile TypeScript server
FROM node:22-alpine AS server-build
WORKDIR /app/server
COPY server/package.json server/package-lock.json ./
RUN npm ci
COPY server/ ./
RUN npx tsc
# Stage 3: Production image
FROM node:22-alpine
WORKDIR /app/server
COPY server/package.json server/package-lock.json ./
RUN npm ci --omit=dev
COPY --from=server-build /app/server/dist ./dist
COPY --from=client-build /app/client/dist /app/client/dist
ENV NODE_ENV=production
ENV PORT=80
EXPOSE 80
CMD ["node", "dist/index.js"]

115
README.md Normal file
View File

@@ -0,0 +1,115 @@
# CertTools — SSL Certificate Toolkit
Web service for SSL/TLS certificate processing: decode PFX, analyze PEM certificates, verify certificate chains, match keys to certificates, and decode CSRs.
## Features
- **PFX/PKCS#12 Decoder** — Upload a `.pfx` / `.p12` file with password to extract the full certificate chain and private key
- **Certificate Decoder** — Paste PEM to view subject, issuer, validity, SANs, fingerprints, key usage, and more
- **Key Matcher** — Verify that a private key matches a certificate (RSA modulus comparison)
- **CSR Decoder** — Decode Certificate Signing Requests with signature verification
- **Chain Verifier** — Validate certificate chain order and trust links
## Tech Stack
- **Backend:** Node.js, Express, TypeScript, node-forge
- **Frontend:** React, TypeScript, Vite, Tailwind CSS
- **Icons:** Lucide React
## Quick Start (Development)
```bash
# Install all dependencies
npm run install:all
npm install
# Start both server and client in dev mode
npm run dev
```
Server runs on `http://localhost:3001`, client on `http://localhost:5173` (with API proxy).
## Production Build & Deployment
```bash
# 1. Install dependencies
cd server && npm install --production
cd ../client && npm install && npm run build
cd ..
# 2. Start production server
cd server
NODE_ENV=production PORT=3001 node -e "require('tsx/cjs'); require('./src/index.ts')"
# Or build server first:
cd server && npx tsc && NODE_ENV=production PORT=3001 node dist/index.js
```
In production the Express server serves the built frontend from `client/dist/`.
### Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `PORT` | `3001` | Server port |
| `NODE_ENV` | — | Set to `production` to serve static frontend |
### Reverse Proxy (nginx)
```nginx
server {
listen 443 ssl;
server_name certs.example.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://127.0.0.1:3001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 10m;
}
}
```
## Project Structure
```
cert-tools/
├── server/ # Express API
│ └── src/
│ ├── index.ts # Server entry point
│ ├── routes/
│ │ └── certificates.ts # API endpoints
│ └── services/
│ └── certService.ts # Certificate processing logic
├── client/ # React frontend
│ └── src/
│ ├── App.tsx # Main app with tool navigation
│ ├── api.ts # API client
│ ├── types.ts # Shared TypeScript types
│ └── components/
│ ├── Header.tsx
│ ├── FileUpload.tsx
│ ├── CopyButton.tsx
│ ├── CertificateInfo.tsx
│ ├── PfxDecoder.tsx
│ ├── PemDecoder.tsx
│ ├── KeyMatcher.tsx
│ ├── CsrDecoder.tsx
│ └── ChainVerifier.tsx
└── package.json # Root scripts
```
## API Endpoints
| Method | Path | Description |
|--------|------|-------------|
| POST | `/api/decode/pfx` | Decode PFX file (multipart: `file` + `password`) |
| POST | `/api/decode/pem` | Decode PEM certificate(s) (JSON: `{ pem }`) |
| POST | `/api/decode/csr` | Decode CSR (JSON: `{ pem }`) |
| POST | `/api/match` | Match cert & key (JSON: `{ certificate, privateKey }`) |
| POST | `/api/chain/verify` | Verify cert chain (JSON: `{ pem }`) |

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',
},
});

329
package-lock.json generated Normal file
View File

@@ -0,0 +1,329 @@
{
"name": "cert-tools",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cert-tools",
"version": "1.0.0",
"devDependencies": {
"concurrently": "^9.1.0"
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/chalk/node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/cliui": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.1",
"wrap-ansi": "^7.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/concurrently": {
"version": "9.2.1",
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz",
"integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==",
"dev": true,
"license": "MIT",
"dependencies": {
"chalk": "4.1.2",
"rxjs": "7.8.2",
"shell-quote": "1.8.3",
"supports-color": "8.1.1",
"tree-kill": "1.2.2",
"yargs": "17.7.2"
},
"bin": {
"conc": "dist/bin/concurrently.js",
"concurrently": "dist/bin/concurrently.js"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
}
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/rxjs": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.1.0"
}
},
"node_modules/shell-quote": {
"version": "1.8.3",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/supports-color": {
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
"node_modules/tree-kill": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
"dev": true,
"license": "MIT",
"bin": {
"tree-kill": "cli.js"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD"
},
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=10"
}
},
"node_modules/yargs": {
"version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"cliui": "^8.0.1",
"escalade": "^3.1.1",
"get-caller-file": "^2.0.5",
"require-directory": "^2.1.1",
"string-width": "^4.2.3",
"y18n": "^5.0.5",
"yargs-parser": "^21.1.1"
},
"engines": {
"node": ">=12"
}
},
"node_modules/yargs-parser": {
"version": "21.1.1",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=12"
}
}
}
}

16
package.json Normal file
View File

@@ -0,0 +1,16 @@
{
"name": "cert-tools",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "concurrently \"npm run dev:server\" \"npm run dev:client\"",
"dev:server": "cd server && npm run dev",
"dev:client": "cd client && npm run dev",
"build": "cd client && npm run build",
"start": "cd server && npm start",
"install:all": "cd server && npm install && cd ../client && npm install"
},
"devDependencies": {
"concurrently": "^9.1.0"
}
}

1725
server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

23
server/package.json Normal file
View File

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

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

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

View File

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

View File

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

17
server/tsconfig.json Normal file
View File

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