Initial commit: SSH Key Generator

React+Vite app that generates Ed25519 and RSA SSH keys
entirely in the browser using Web Crypto API and micro-key-producer.
Styled with Tailwind CSS v4 dark theme.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-17 16:40:40 +02:00
commit c609be0213
20 changed files with 4067 additions and 0 deletions

24
.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

12
index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SSH Key Generator</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

3515
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

23
package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "ssh-generator",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@noble/hashes": "^2.0.1",
"@tailwindcss/vite": "^4.1.18",
"micro-key-producer": "^0.8.2",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"tailwindcss": "^4.1.18"
},
"devDependencies": {
"@vitejs/plugin-react": "^5.1.1",
"vite": "^7.3.1"
}
}

20
src/App.jsx Normal file
View File

@@ -0,0 +1,20 @@
import KeyGenerator from './components/KeyGenerator.jsx'
export default function App() {
return (
<div className="min-h-screen bg-zinc-950 text-white flex flex-col">
<header className="pt-12 pb-8 text-center">
<h1 className="text-3xl font-bold tracking-tight">SSH Key Generator</h1>
<p className="mt-2 text-zinc-400">Generate SSH keys directly in your browser</p>
</header>
<main className="flex-1 px-4 pb-16">
<KeyGenerator />
</main>
<footer className="py-6 text-center text-xs text-zinc-600">
Keys are generated client-side using Web Crypto API. No data leaves your browser.
</footer>
</div>
)
}

View File

@@ -0,0 +1,14 @@
export default function CommentField({ value, onChange }) {
return (
<div>
<label className="block text-sm font-medium text-zinc-300 mb-2">Comment</label>
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="user@hostname"
className="w-full px-4 py-3 rounded-lg border border-zinc-700 bg-zinc-800/50 text-white placeholder-zinc-500 focus:outline-none focus:border-blue-500 transition-colors"
/>
</div>
)
}

View File

@@ -0,0 +1,26 @@
export default function GenerateButton({ onClick, loading }) {
return (
<button
onClick={onClick}
disabled={loading}
className="w-full px-6 py-3 rounded-lg bg-blue-600 hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed text-white font-medium transition-colors cursor-pointer flex items-center justify-center gap-2"
>
{loading ? (
<>
<svg className="animate-spin h-5 w-5" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Generating...
</>
) : (
<>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z" />
</svg>
Generate SSH Key
</>
)}
</button>
)
}

View File

@@ -0,0 +1,41 @@
import { useState } from 'react'
import { copyToClipboard, downloadFile } from '../lib/utils.js'
export default function KeyDisplay({ label, value, filename }) {
const [copied, setCopied] = useState(false)
const handleCopy = async () => {
await copyToClipboard(value)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
const handleDownload = () => {
downloadFile(filename, value)
}
return (
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-sm font-medium text-zinc-300">{label}</label>
<div className="flex gap-2">
<button
onClick={handleCopy}
className="px-3 py-1 text-xs rounded border border-zinc-700 text-zinc-400 hover:text-white hover:border-zinc-500 transition-colors cursor-pointer"
>
{copied ? 'Copied!' : 'Copy'}
</button>
<button
onClick={handleDownload}
className="px-3 py-1 text-xs rounded border border-zinc-700 text-zinc-400 hover:text-white hover:border-zinc-500 transition-colors cursor-pointer"
>
Download
</button>
</div>
</div>
<pre className="w-full p-4 rounded-lg border border-zinc-700 bg-zinc-900/50 text-zinc-300 text-xs font-mono overflow-x-auto whitespace-pre-wrap break-all max-h-48">
{value}
</pre>
</div>
)
}

View File

@@ -0,0 +1,59 @@
import { useState } from 'react'
import KeyTypeSelector from './KeyTypeSelector.jsx'
import KeySizeSelector from './KeySizeSelector.jsx'
import CommentField from './CommentField.jsx'
import GenerateButton from './GenerateButton.jsx'
import KeyOutput from './KeyOutput.jsx'
import { generateKeyPair } from '../lib/keygen.js'
export default function KeyGenerator() {
const [keyType, setKeyType] = useState('ed25519')
const [keySize, setKeySize] = useState(4096)
const [comment, setComment] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const [keys, setKeys] = useState(null)
const handleGenerate = async () => {
setLoading(true)
setError(null)
try {
const result = await generateKeyPair({ keyType, keySize, comment })
setKeys({ ...result, keyType })
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
return (
<div className="w-full max-w-2xl mx-auto">
<div className="rounded-xl border border-zinc-800 bg-zinc-900/80 backdrop-blur p-6 sm:p-8 space-y-6">
<KeyTypeSelector value={keyType} onChange={setKeyType} />
{keyType === 'rsa' && (
<KeySizeSelector value={keySize} onChange={setKeySize} />
)}
<CommentField value={comment} onChange={setComment} />
<GenerateButton onClick={handleGenerate} loading={loading} />
{error && (
<div className="p-4 rounded-lg border border-red-800 bg-red-900/20 text-red-400 text-sm">
{error}
</div>
)}
{keys && (
<KeyOutput
publicKey={keys.publicKey}
privateKey={keys.privateKey}
keyType={keys.keyType}
/>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,32 @@
import KeyDisplay from './KeyDisplay.jsx'
export default function KeyOutput({ publicKey, privateKey, keyType }) {
const pubFilename = keyType === 'ed25519' ? 'id_ed25519.pub' : 'id_rsa.pub'
const privFilename = keyType === 'ed25519' ? 'id_ed25519' : 'id_rsa'
return (
<div className="space-y-6 mt-8">
<div className="flex items-center gap-3">
<div className="h-px flex-1 bg-zinc-800" />
<span className="text-xs text-zinc-500 uppercase tracking-wider">Generated Keys</span>
<div className="h-px flex-1 bg-zinc-800" />
</div>
<KeyDisplay
label="Public Key"
value={publicKey}
filename={pubFilename}
/>
<KeyDisplay
label="Private Key"
value={privateKey}
filename={privFilename}
/>
<p className="text-xs text-zinc-500 text-center">
Keys are generated entirely in your browser. Nothing is sent to any server.
</p>
</div>
)
}

View File

@@ -0,0 +1,25 @@
const SIZES = [2048, 4096]
export default function KeySizeSelector({ value, onChange }) {
return (
<div>
<label className="block text-sm font-medium text-zinc-300 mb-2">Key Size (bits)</label>
<div className="flex gap-3">
{SIZES.map((size) => (
<button
key={size}
onClick={() => onChange(size)}
className={`flex-1 px-4 py-3 rounded-lg border text-center transition-all cursor-pointer ${
value === size
? 'border-blue-500 bg-blue-500/10 text-white'
: 'border-zinc-700 bg-zinc-800/50 text-zinc-400 hover:border-zinc-600'
}`}
>
<div className="font-medium">{size}</div>
<div className="text-xs mt-1 opacity-70">{size === 4096 ? 'More secure' : 'Standard'}</div>
</button>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,28 @@
const KEY_TYPES = [
{ value: 'ed25519', label: 'Ed25519', desc: 'Modern, fast, recommended' },
{ value: 'rsa', label: 'RSA', desc: 'Wide compatibility' },
]
export default function KeyTypeSelector({ value, onChange }) {
return (
<div>
<label className="block text-sm font-medium text-zinc-300 mb-2">Key Type</label>
<div className="flex gap-3">
{KEY_TYPES.map((type) => (
<button
key={type.value}
onClick={() => onChange(type.value)}
className={`flex-1 px-4 py-3 rounded-lg border text-left transition-all cursor-pointer ${
value === type.value
? 'border-blue-500 bg-blue-500/10 text-white'
: 'border-zinc-700 bg-zinc-800/50 text-zinc-400 hover:border-zinc-600'
}`}
>
<div className="font-medium">{type.label}</div>
<div className="text-xs mt-1 opacity-70">{type.desc}</div>
</button>
))}
</div>
</div>
)
}

1
src/index.css Normal file
View File

@@ -0,0 +1 @@
@import "tailwindcss";

11
src/lib/ed25519.js Normal file
View File

@@ -0,0 +1,11 @@
import getKeys from 'micro-key-producer/ssh.js'
export async function generateEd25519(comment) {
const privKeyRaw = crypto.getRandomValues(new Uint8Array(32))
const keys = getKeys(privKeyRaw, comment)
return {
publicKey: keys.publicKey,
privateKey: keys.privateKey,
}
}

16
src/lib/keygen.js Normal file
View File

@@ -0,0 +1,16 @@
import { generateRSA } from './rsa.js'
import { generateEd25519 } from './ed25519.js'
export async function generateKeyPair({ keyType, keySize, comment }) {
const commentStr = comment || 'user@browser'
if (keyType === 'ed25519') {
return generateEd25519(commentStr)
}
if (keyType === 'rsa') {
return generateRSA(keySize, commentStr)
}
throw new Error(`Unsupported key type: ${keyType}`)
}

100
src/lib/openssh-encoder.js Normal file
View File

@@ -0,0 +1,100 @@
// OpenSSH binary format encoding helpers
export function encodeUint32(value) {
const buf = new Uint8Array(4)
buf[0] = (value >>> 24) & 0xff
buf[1] = (value >>> 16) & 0xff
buf[2] = (value >>> 8) & 0xff
buf[3] = value & 0xff
return buf
}
export function encodeString(str) {
const encoded = new TextEncoder().encode(str)
return encodeBytes(encoded)
}
export function encodeBytes(data) {
const buf = new Uint8Array(4 + data.length)
const len = encodeUint32(data.length)
buf.set(len, 0)
buf.set(data, 4)
return buf
}
export function encodeMpint(bytes) {
// If high bit is set, prepend a zero byte
if (bytes[0] & 0x80) {
const padded = new Uint8Array(bytes.length + 1)
padded[0] = 0
padded.set(bytes, 1)
return encodeBytes(padded)
}
return encodeBytes(bytes)
}
export function concat(...arrays) {
const totalLen = arrays.reduce((sum, a) => sum + a.length, 0)
const result = new Uint8Array(totalLen)
let offset = 0
for (const arr of arrays) {
result.set(arr, offset)
offset += arr.length
}
return result
}
export function wrapPrivateKey(keyType, pubkeyBlob, privatePart, comment) {
// OpenSSH private key format (openssh-key-v1)
const AUTH_MAGIC = new TextEncoder().encode('openssh-key-v1\0')
const ciphername = encodeString('none')
const kdfname = encodeString('none')
const kdfoptions = encodeString('')
const nkeys = encodeUint32(1)
const pubkeyWrapped = encodeBytes(pubkeyBlob)
// checkint - random matching uint32 pair
const checkBytes = crypto.getRandomValues(new Uint8Array(4))
const checkInt = concat(checkBytes, checkBytes)
const commentBytes = encodeString(comment)
const inner = concat(checkInt, privatePart, commentBytes)
// Pad to block size of 8 (for 'none' cipher)
const blockSize = 8
const padLen = blockSize - (inner.length % blockSize)
const padding = new Uint8Array(padLen)
for (let i = 0; i < padLen; i++) padding[i] = i + 1
const paddedInner = concat(inner, padding)
const privateWrapped = encodeBytes(paddedInner)
const blob = concat(
AUTH_MAGIC,
ciphername,
kdfname,
kdfoptions,
nkeys,
pubkeyWrapped,
privateWrapped
)
return blob
}
export function formatPEM(label, data) {
const b64 = uint8ToBase64(data)
const lines = []
for (let i = 0; i < b64.length; i += 70) {
lines.push(b64.slice(i, i + 70))
}
return `-----BEGIN ${label}-----\n${lines.join('\n')}\n-----END ${label}-----\n`
}
export function uint8ToBase64(bytes) {
let binary = ''
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i])
}
return btoa(binary)
}

81
src/lib/rsa.js Normal file
View File

@@ -0,0 +1,81 @@
import {
encodeString,
encodeBytes,
encodeMpint,
concat,
wrapPrivateKey,
formatPEM,
uint8ToBase64,
} from './openssh-encoder.js'
export async function generateRSA(bits, comment) {
const keyPair = await crypto.subtle.generateKey(
{
name: 'RSASSA-PKCS1-v1_5',
modulusLength: bits,
publicExponent: new Uint8Array([0x01, 0x00, 0x01]),
hash: 'SHA-256',
},
true,
['sign', 'verify']
)
const jwkPriv = await crypto.subtle.exportKey('jwk', keyPair.privateKey)
const n = b64urlToBytes(jwkPriv.n)
const e = b64urlToBytes(jwkPriv.e)
const d = b64urlToBytes(jwkPriv.d)
const p = b64urlToBytes(jwkPriv.p)
const q = b64urlToBytes(jwkPriv.q)
const dp = b64urlToBytes(jwkPriv.dp)
const dq = b64urlToBytes(jwkPriv.dq)
const qi = b64urlToBytes(jwkPriv.qi)
const publicKey = formatRSAPublicKey(e, n, comment)
const privateKey = formatRSAPrivateKey(n, e, d, p, q, dp, dq, qi, comment)
return { publicKey, privateKey }
}
function b64urlToBytes(b64url) {
const b64 = b64url.replace(/-/g, '+').replace(/_/g, '/')
const pad = (4 - (b64.length % 4)) % 4
const padded = b64 + '='.repeat(pad)
const binary = atob(padded)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i)
}
return bytes
}
function formatRSAPublicKey(e, n, comment) {
const keyType = encodeString('ssh-rsa')
const eEnc = encodeMpint(e)
const nEnc = encodeMpint(n)
const blob = concat(keyType, eEnc, nEnc)
const b64 = uint8ToBase64(blob)
return `ssh-rsa ${b64} ${comment}`
}
function formatRSAPrivateKey(n, e, d, p, q, dp, dq, qi, comment) {
const keyTypeStr = 'ssh-rsa'
const keyType = encodeString(keyTypeStr)
const eEnc = encodeMpint(e)
const nEnc = encodeMpint(n)
const pubkeyBlob = concat(keyType, eEnc, nEnc)
// OpenSSH RSA private key order: keytype, n, e, d, iqmp (qi), p, q
const privatePart = concat(
encodeString(keyTypeStr),
encodeMpint(n),
encodeMpint(e),
encodeMpint(d),
encodeMpint(qi),
encodeMpint(p),
encodeMpint(q)
)
const blob = wrapPrivateKey(keyTypeStr, pubkeyBlob, privatePart, comment)
return formatPEM('OPENSSH PRIVATE KEY', blob)
}

21
src/lib/utils.js Normal file
View File

@@ -0,0 +1,21 @@
export function toBase64(uint8arr) {
let binary = ''
for (let i = 0; i < uint8arr.length; i++) {
binary += String.fromCharCode(uint8arr[i])
}
return btoa(binary)
}
export async function copyToClipboard(text) {
await navigator.clipboard.writeText(text)
}
export function downloadFile(filename, content) {
const blob = new Blob([content], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
}

10
src/main.jsx Normal file
View File

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

8
vite.config.js Normal file
View File

@@ -0,0 +1,8 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [react(), tailwindcss()],
base: '/ssh/',
})