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