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:
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal 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
12
index.html
Normal 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
3515
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
package.json
Normal file
23
package.json
Normal 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
20
src/App.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
14
src/components/CommentField.jsx
Normal file
14
src/components/CommentField.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
26
src/components/GenerateButton.jsx
Normal file
26
src/components/GenerateButton.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
41
src/components/KeyDisplay.jsx
Normal file
41
src/components/KeyDisplay.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
59
src/components/KeyGenerator.jsx
Normal file
59
src/components/KeyGenerator.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
32
src/components/KeyOutput.jsx
Normal file
32
src/components/KeyOutput.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
25
src/components/KeySizeSelector.jsx
Normal file
25
src/components/KeySizeSelector.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
28
src/components/KeyTypeSelector.jsx
Normal file
28
src/components/KeyTypeSelector.jsx
Normal 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
1
src/index.css
Normal file
@@ -0,0 +1 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
11
src/lib/ed25519.js
Normal file
11
src/lib/ed25519.js
Normal 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
16
src/lib/keygen.js
Normal 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
100
src/lib/openssh-encoder.js
Normal 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
81
src/lib/rsa.js
Normal 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
21
src/lib/utils.js
Normal 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
10
src/main.jsx
Normal 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
8
vite.config.js
Normal 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/',
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user