Initial commit: CertTools SSL certificate toolkit
Made-with: Cursor
This commit is contained in:
5
.dockerignore
Normal file
5
.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
.git
|
||||
*.md
|
||||
client/dist
|
||||
server/dist
|
||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
.env
|
||||
.DS_Store
|
||||
27
Dockerfile
Normal file
27
Dockerfile
Normal 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
115
README.md
Normal 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
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',
|
||||
},
|
||||
});
|
||||
329
package-lock.json
generated
Normal file
329
package-lock.json
generated
Normal 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
16
package.json
Normal 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
1725
server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
server/package.json
Normal file
23
server/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "cert-tools-server",
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.21.0",
|
||||
"cors": "^2.8.5",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"node-forge": "^1.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/node-forge": "^1.3.11",
|
||||
"typescript": "^5.6.0",
|
||||
"tsx": "^4.19.0"
|
||||
}
|
||||
}
|
||||
26
server/src/index.ts
Normal file
26
server/src/index.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import path from 'path';
|
||||
import certRoutes from './routes/certificates';
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
|
||||
|
||||
app.use('/api', certRoutes);
|
||||
|
||||
// Serve frontend in production
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
const clientDist = path.join(__dirname, '../../client/dist');
|
||||
app.use(express.static(clientDist));
|
||||
app.get('*', (_req, res) => {
|
||||
res.sendFile(path.join(clientDist, 'index.html'));
|
||||
});
|
||||
}
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`CertTools server running on http://localhost:${PORT}`);
|
||||
});
|
||||
99
server/src/routes/certificates.ts
Normal file
99
server/src/routes/certificates.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import multer from 'multer';
|
||||
import {
|
||||
parseCertificate,
|
||||
decodePfx,
|
||||
matchKeyToCert,
|
||||
parseCsr,
|
||||
verifyChain,
|
||||
} from '../services/certService';
|
||||
|
||||
const router = Router();
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: 10 * 1024 * 1024 },
|
||||
});
|
||||
|
||||
router.post('/decode/pfx', upload.single('file'), (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
res.status(400).json({ error: 'No file uploaded' });
|
||||
return;
|
||||
}
|
||||
const password = req.body.password || '';
|
||||
const result = decodePfx(req.file.buffer, password);
|
||||
res.json(result);
|
||||
} catch (e: any) {
|
||||
const message = e.message?.includes('Invalid password')
|
||||
? 'Invalid password or corrupted PFX file'
|
||||
: e.message || 'Failed to decode PFX file';
|
||||
res.status(400).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/decode/pem', (req: Request, res: Response) => {
|
||||
try {
|
||||
const { pem } = req.body;
|
||||
if (!pem) {
|
||||
res.status(400).json({ error: 'No PEM data provided' });
|
||||
return;
|
||||
}
|
||||
|
||||
const pemRegex = /-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g;
|
||||
const pems = pem.match(pemRegex) || [];
|
||||
|
||||
if (pems.length === 0) {
|
||||
res.status(400).json({ error: 'No valid PEM certificates found in the input' });
|
||||
return;
|
||||
}
|
||||
|
||||
const certificates = pems.map((p: string) => parseCertificate(p));
|
||||
res.json({ certificates });
|
||||
} catch (e: any) {
|
||||
res.status(400).json({ error: e.message || 'Failed to decode PEM' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/match', (req: Request, res: Response) => {
|
||||
try {
|
||||
const { certificate, privateKey } = req.body;
|
||||
if (!certificate || !privateKey) {
|
||||
res.status(400).json({ error: 'Both certificate and private key are required' });
|
||||
return;
|
||||
}
|
||||
const result = matchKeyToCert(certificate, privateKey);
|
||||
res.json(result);
|
||||
} catch (e: any) {
|
||||
res.status(400).json({ error: e.message || 'Failed to compare key and certificate' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/decode/csr', (req: Request, res: Response) => {
|
||||
try {
|
||||
const { pem } = req.body;
|
||||
if (!pem) {
|
||||
res.status(400).json({ error: 'No CSR data provided' });
|
||||
return;
|
||||
}
|
||||
const result = parseCsr(pem);
|
||||
res.json(result);
|
||||
} catch (e: any) {
|
||||
res.status(400).json({ error: e.message || 'Failed to decode CSR' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/chain/verify', (req: Request, res: Response) => {
|
||||
try {
|
||||
const { pem } = req.body;
|
||||
if (!pem) {
|
||||
res.status(400).json({ error: 'No certificate chain provided' });
|
||||
return;
|
||||
}
|
||||
const result = verifyChain(pem);
|
||||
res.json(result);
|
||||
} catch (e: any) {
|
||||
res.status(400).json({ error: e.message || 'Failed to verify chain' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
324
server/src/services/certService.ts
Normal file
324
server/src/services/certService.ts
Normal file
@@ -0,0 +1,324 @@
|
||||
import forge from 'node-forge';
|
||||
|
||||
export interface CertificateInfo {
|
||||
subject: Record<string, string>;
|
||||
issuer: Record<string, string>;
|
||||
serialNumber: string;
|
||||
validFrom: string;
|
||||
validTo: string;
|
||||
daysRemaining: number;
|
||||
isExpired: boolean;
|
||||
sans: string[];
|
||||
fingerprints: {
|
||||
sha1: string;
|
||||
sha256: string;
|
||||
};
|
||||
publicKey: {
|
||||
algorithm: string;
|
||||
bits: number;
|
||||
};
|
||||
signatureAlgorithm: string;
|
||||
isSelfSigned: boolean;
|
||||
version: number;
|
||||
keyUsage: string[];
|
||||
extKeyUsage: string[];
|
||||
pem: string;
|
||||
isCA: boolean;
|
||||
}
|
||||
|
||||
export interface PfxResult {
|
||||
certificates: CertificateInfo[];
|
||||
privateKeyPem: string | null;
|
||||
}
|
||||
|
||||
export interface MatchResult {
|
||||
match: boolean;
|
||||
certModulus: string;
|
||||
keyModulus: string;
|
||||
}
|
||||
|
||||
export interface CsrInfo {
|
||||
subject: Record<string, string>;
|
||||
publicKey: {
|
||||
algorithm: string;
|
||||
bits: number;
|
||||
};
|
||||
sans: string[];
|
||||
isSignatureValid: boolean;
|
||||
pem: string;
|
||||
}
|
||||
|
||||
export interface ChainVerifyResult {
|
||||
certificates: CertificateInfo[];
|
||||
isValid: boolean;
|
||||
errors: string[];
|
||||
chainOrder: string[];
|
||||
}
|
||||
|
||||
function extractAttributes(attrs: forge.pki.CertificateField[]): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
for (const attr of attrs) {
|
||||
const name = attr.shortName || attr.name || attr.type;
|
||||
if (name && attr.value) {
|
||||
result[name] = attr.value as string;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function getFingerprint(cert: forge.pki.Certificate, algorithm: 'sha1' | 'sha256'): string {
|
||||
const der = forge.asn1.toDer(forge.pki.certificateToAsn1(cert)).getBytes();
|
||||
const md = algorithm === 'sha256' ? forge.md.sha256.create() : forge.md.sha1.create();
|
||||
md.update(der);
|
||||
return md.digest().toHex().match(/.{2}/g)!.join(':').toUpperCase();
|
||||
}
|
||||
|
||||
function getKeyUsage(cert: forge.pki.Certificate): string[] {
|
||||
const ext = cert.getExtension('keyUsage') as any;
|
||||
if (!ext) return [];
|
||||
const usages: string[] = [];
|
||||
if (ext.digitalSignature) usages.push('Digital Signature');
|
||||
if (ext.nonRepudiation) usages.push('Non Repudiation');
|
||||
if (ext.keyEncipherment) usages.push('Key Encipherment');
|
||||
if (ext.dataEncipherment) usages.push('Data Encipherment');
|
||||
if (ext.keyAgreement) usages.push('Key Agreement');
|
||||
if (ext.keyCertSign) usages.push('Certificate Sign');
|
||||
if (ext.cRLSign) usages.push('CRL Sign');
|
||||
return usages;
|
||||
}
|
||||
|
||||
function getExtKeyUsage(cert: forge.pki.Certificate): string[] {
|
||||
const ext = cert.getExtension('extKeyUsage') as any;
|
||||
if (!ext) return [];
|
||||
const usages: string[] = [];
|
||||
if (ext.serverAuth) usages.push('Server Authentication');
|
||||
if (ext.clientAuth) usages.push('Client Authentication');
|
||||
if (ext.codeSigning) usages.push('Code Signing');
|
||||
if (ext.emailProtection) usages.push('Email Protection');
|
||||
if (ext.timeStamping) usages.push('Time Stamping');
|
||||
return usages;
|
||||
}
|
||||
|
||||
function getSANs(cert: forge.pki.Certificate): string[] {
|
||||
const ext = cert.getExtension('subjectAltName') as any;
|
||||
if (!ext || !ext.altNames) return [];
|
||||
return ext.altNames.map((an: any) => {
|
||||
if (an.type === 2) return `DNS: ${an.value}`;
|
||||
if (an.type === 7) return `IP: ${an.ip || an.value}`;
|
||||
if (an.type === 1) return `Email: ${an.value}`;
|
||||
if (an.type === 6) return `URI: ${an.value}`;
|
||||
return an.value;
|
||||
});
|
||||
}
|
||||
|
||||
function checkIsCA(cert: forge.pki.Certificate): boolean {
|
||||
const ext = cert.getExtension('basicConstraints') as any;
|
||||
return ext ? !!ext.cA : false;
|
||||
}
|
||||
|
||||
const SIG_OID_MAP: Record<string, string> = {
|
||||
'1.2.840.113549.1.1.4': 'MD5 with RSA',
|
||||
'1.2.840.113549.1.1.5': 'SHA-1 with RSA',
|
||||
'1.2.840.113549.1.1.11': 'SHA-256 with RSA',
|
||||
'1.2.840.113549.1.1.12': 'SHA-384 with RSA',
|
||||
'1.2.840.113549.1.1.13': 'SHA-512 with RSA',
|
||||
'1.2.840.10045.4.3.2': 'ECDSA with SHA-256',
|
||||
'1.2.840.10045.4.3.3': 'ECDSA with SHA-384',
|
||||
'1.2.840.10045.4.3.4': 'ECDSA with SHA-512',
|
||||
};
|
||||
|
||||
function getSignatureAlgorithm(cert: forge.pki.Certificate): string {
|
||||
const oid = (cert as any).signatureOid || (cert as any).siginfo?.algorithmOid || '';
|
||||
return SIG_OID_MAP[oid] || oid || 'Unknown';
|
||||
}
|
||||
|
||||
function getPublicKeyInfo(publicKey: forge.pki.PublicKey): { algorithm: string; bits: number } {
|
||||
const rsa = publicKey as forge.pki.rsa.PublicKey;
|
||||
if ((rsa as any).n) {
|
||||
return { algorithm: 'RSA', bits: (rsa as any).n.bitLength() };
|
||||
}
|
||||
return { algorithm: 'Unknown', bits: 0 };
|
||||
}
|
||||
|
||||
export function parseCertificate(pem: string): CertificateInfo {
|
||||
const cert = forge.pki.certificateFromPem(pem);
|
||||
const now = new Date();
|
||||
const validTo = cert.validity.notAfter;
|
||||
const daysRemaining = Math.floor((validTo.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
return {
|
||||
subject: extractAttributes(cert.subject.attributes),
|
||||
issuer: extractAttributes(cert.issuer.attributes),
|
||||
serialNumber: cert.serialNumber.toUpperCase(),
|
||||
validFrom: cert.validity.notBefore.toISOString(),
|
||||
validTo: cert.validity.notAfter.toISOString(),
|
||||
daysRemaining,
|
||||
isExpired: daysRemaining < 0,
|
||||
sans: getSANs(cert),
|
||||
fingerprints: {
|
||||
sha1: getFingerprint(cert, 'sha1'),
|
||||
sha256: getFingerprint(cert, 'sha256'),
|
||||
},
|
||||
publicKey: getPublicKeyInfo(cert.publicKey),
|
||||
signatureAlgorithm: getSignatureAlgorithm(cert),
|
||||
isSelfSigned: cert.isIssuer(cert),
|
||||
version: cert.version + 1,
|
||||
keyUsage: getKeyUsage(cert),
|
||||
extKeyUsage: getExtKeyUsage(cert),
|
||||
pem: forge.pki.certificateToPem(cert).trim(),
|
||||
isCA: checkIsCA(cert),
|
||||
};
|
||||
}
|
||||
|
||||
export function decodePfx(buffer: Buffer, password: string): PfxResult {
|
||||
const derBytes = forge.util.decode64(buffer.toString('base64'));
|
||||
const asn1 = forge.asn1.fromDer(derBytes);
|
||||
const p12 = forge.pkcs12.pkcs12FromAsn1(asn1, false, password);
|
||||
|
||||
const certificates: CertificateInfo[] = [];
|
||||
let privateKeyPem: string | null = null;
|
||||
|
||||
const certBags = p12.getBags({ bagType: forge.pki.oids.certBag });
|
||||
const certs = certBags[forge.pki.oids.certBag] || [];
|
||||
|
||||
for (const bag of certs) {
|
||||
if (bag.cert) {
|
||||
const pem = forge.pki.certificateToPem(bag.cert);
|
||||
certificates.push(parseCertificate(pem));
|
||||
}
|
||||
}
|
||||
|
||||
const shroudedKeyBags = p12.getBags({ bagType: forge.pki.oids.pkcs8ShroudedKeyBag });
|
||||
const shroudedKeys = shroudedKeyBags[forge.pki.oids.pkcs8ShroudedKeyBag] || [];
|
||||
if (shroudedKeys.length > 0 && shroudedKeys[0].key) {
|
||||
privateKeyPem = forge.pki.privateKeyToPem(shroudedKeys[0].key).trim();
|
||||
}
|
||||
|
||||
if (!privateKeyPem) {
|
||||
const keyBags = p12.getBags({ bagType: forge.pki.oids.keyBag });
|
||||
const keys = keyBags[forge.pki.oids.keyBag] || [];
|
||||
if (keys.length > 0 && keys[0].key) {
|
||||
privateKeyPem = forge.pki.privateKeyToPem(keys[0].key).trim();
|
||||
}
|
||||
}
|
||||
|
||||
// Sort: end-entity first, then intermediates, then root CA
|
||||
certificates.sort((a, b) => {
|
||||
if (!a.isCA && b.isCA) return -1;
|
||||
if (a.isCA && !b.isCA) return 1;
|
||||
if (a.isSelfSigned && !b.isSelfSigned) return 1;
|
||||
if (!a.isSelfSigned && b.isSelfSigned) return -1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
return { certificates, privateKeyPem };
|
||||
}
|
||||
|
||||
export function matchKeyToCert(certPem: string, keyPem: string): MatchResult {
|
||||
const cert = forge.pki.certificateFromPem(certPem);
|
||||
const privateKey = forge.pki.privateKeyFromPem(keyPem);
|
||||
|
||||
const pubKey = cert.publicKey as forge.pki.rsa.PublicKey;
|
||||
const certModulus = (pubKey as any).n.toString(16);
|
||||
const keyModulus = (privateKey as any).n.toString(16);
|
||||
|
||||
return {
|
||||
match: certModulus === keyModulus,
|
||||
certModulus: certModulus.substring(0, 48) + '...',
|
||||
keyModulus: keyModulus.substring(0, 48) + '...',
|
||||
};
|
||||
}
|
||||
|
||||
export function parseCsr(pem: string): CsrInfo {
|
||||
const csr = forge.pki.certificationRequestFromPem(pem);
|
||||
|
||||
const sans: string[] = [];
|
||||
const extensionRequest = (csr as any).getAttribute?.({ name: 'extensionRequest' });
|
||||
if (extensionRequest && extensionRequest.extensions) {
|
||||
for (const ext of extensionRequest.extensions) {
|
||||
if (ext.name === 'subjectAltName' && ext.altNames) {
|
||||
for (const an of ext.altNames) {
|
||||
if (an.type === 2) sans.push(`DNS: ${an.value}`);
|
||||
else if (an.type === 7) sans.push(`IP: ${an.ip || an.value}`);
|
||||
else if (an.type === 1) sans.push(`Email: ${an.value}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let isSignatureValid = false;
|
||||
try {
|
||||
isSignatureValid = csr.verify();
|
||||
} catch {
|
||||
isSignatureValid = false;
|
||||
}
|
||||
|
||||
return {
|
||||
subject: extractAttributes(csr.subject.attributes),
|
||||
publicKey: csr.publicKey ? getPublicKeyInfo(csr.publicKey) : { algorithm: 'Unknown', bits: 0 },
|
||||
sans,
|
||||
isSignatureValid,
|
||||
pem: forge.pki.certificationRequestToPem(csr).trim(),
|
||||
};
|
||||
}
|
||||
|
||||
export function verifyChain(pemChain: string): ChainVerifyResult {
|
||||
const pemRegex = /-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g;
|
||||
const pems = pemChain.match(pemRegex) || [];
|
||||
|
||||
if (pems.length === 0) {
|
||||
return { certificates: [], isValid: false, errors: ['No certificates found in input'], chainOrder: [] };
|
||||
}
|
||||
|
||||
const certificates = pems.map(p => parseCertificate(p));
|
||||
const forgeCerts = pems.map(p => forge.pki.certificateFromPem(p));
|
||||
const errors: string[] = [];
|
||||
|
||||
// Sort: end-entity → intermediates → root
|
||||
const indices = certificates.map((_, i) => i);
|
||||
indices.sort((a, b) => {
|
||||
const ca = certificates[a], cb = certificates[b];
|
||||
if (!ca.isCA && cb.isCA) return -1;
|
||||
if (ca.isCA && !cb.isCA) return 1;
|
||||
if (ca.isSelfSigned && !cb.isSelfSigned) return 1;
|
||||
if (!ca.isSelfSigned && cb.isSelfSigned) return -1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
const sortedCerts = indices.map(i => certificates[i]);
|
||||
const sortedForge = indices.map(i => forgeCerts[i]);
|
||||
|
||||
// Verify chain links
|
||||
for (let i = 0; i < sortedForge.length - 1; i++) {
|
||||
try {
|
||||
const issuerFound = sortedForge.find((c, j) => {
|
||||
if (j === i) return false;
|
||||
try { return c.verify(sortedForge[i]); } catch { return false; }
|
||||
});
|
||||
if (!issuerFound) {
|
||||
errors.push(`Cannot find issuer for "${sortedCerts[i].subject.CN || 'Unknown'}"`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
errors.push(`Verification error for "${sortedCerts[i].subject.CN}": ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const cert of sortedCerts) {
|
||||
if (cert.isExpired) {
|
||||
errors.push(`Certificate "${cert.subject.CN || 'Unknown'}" has expired`);
|
||||
}
|
||||
}
|
||||
|
||||
const chainOrder = sortedCerts.map(c => {
|
||||
if (!c.isCA) return `End Entity: ${c.subject.CN || 'Unknown'}`;
|
||||
if (c.isSelfSigned) return `Root CA: ${c.subject.CN || 'Unknown'}`;
|
||||
return `Intermediate CA: ${c.subject.CN || 'Unknown'}`;
|
||||
});
|
||||
|
||||
return {
|
||||
certificates: sortedCerts,
|
||||
isValid: errors.length === 0,
|
||||
errors,
|
||||
chainOrder,
|
||||
};
|
||||
}
|
||||
17
server/tsconfig.json
Normal file
17
server/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2020"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user