Skip to content

Commit 0b3baf8

Browse files
committed
feat(web): in-browser Ed25519 key generation
Generate a key pair with Web Crypto entirely client-side. The private key is encoded into the OpenSSH openssh-key-v1 format, revealed once with download/copy, and never sent to the server; the public key is added to the user's handle automatically.
1 parent dadee08 commit 0b3baf8

4 files changed

Lines changed: 328 additions & 0 deletions

File tree

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ This is a clean-room, open-source SSH identity service that you can self-host.
3838
- 🧭 **Content negotiation.** Browsers get a polished profile page; `curl`/`wget`
3939
get plain text. Append `.keys` or `?format=txt` to force plain text.
4040
- 🛡️ **Passkey auth.** Phishing-resistant WebAuthn for register and login.
41+
-**In-browser key generation.** Mint an Ed25519 key with Web Crypto; the
42+
private key is shown once and never touches the server, the public key is added
43+
automatically.
4144
- 🧩 **Modern key types.** Ed25519, ECDSA, RSA and FIDO `sk-*` keys, validated on
4245
the wire and fingerprinted (SHA256).
4346
- 🪶 **Single container.** Node + SQLite. No external services required.
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import { AlertTriangle, Download, KeySquare, Loader2, Sparkles, X } from 'lucide-react';
2+
import { useEffect, useState } from 'react';
3+
import { api, type SshKey } from '../lib/api';
4+
import { ed25519Supported, generateEd25519KeyPair, type GeneratedKeyPair } from '../lib/sshkeygen';
5+
import { Alert } from './Alert';
6+
import { CodeBlock } from './CodeBlock';
7+
8+
function download(filename: string, contents: string) {
9+
const blob = new Blob([contents], { type: 'application/octet-stream' });
10+
const url = URL.createObjectURL(blob);
11+
const a = document.createElement('a');
12+
a.href = url;
13+
a.download = filename;
14+
document.body.appendChild(a);
15+
a.click();
16+
a.remove();
17+
URL.revokeObjectURL(url);
18+
}
19+
20+
export function GenerateKey({ onAdded }: { onAdded: (key: SshKey) => void }) {
21+
const [label, setLabel] = useState('');
22+
const [busy, setBusy] = useState(false);
23+
const [error, setError] = useState<string | null>(null);
24+
const [supported, setSupported] = useState<boolean | null>(null);
25+
const [result, setResult] = useState<{ pair: GeneratedKeyPair; stored: SshKey } | null>(null);
26+
27+
useEffect(() => {
28+
let active = true;
29+
void ed25519Supported().then((ok) => active && setSupported(ok));
30+
return () => {
31+
active = false;
32+
};
33+
}, []);
34+
35+
const generate = async () => {
36+
setError(null);
37+
setBusy(true);
38+
try {
39+
const pair = await generateEd25519KeyPair(label.trim());
40+
// Only the public half is uploaded; the private key stays in this tab.
41+
const stored = await api.addKey(label.trim(), pair.publicKey);
42+
onAdded(stored);
43+
setResult({ pair, stored });
44+
setLabel('');
45+
} catch (err) {
46+
setError(err instanceof Error ? err.message : 'Key generation failed.');
47+
} finally {
48+
setBusy(false);
49+
}
50+
};
51+
52+
if (supported === false) {
53+
return (
54+
<div className="card p-6">
55+
<h2 className="font-semibold text-white">Generate a key pair</h2>
56+
<div className="mt-3">
57+
<Alert
58+
kind="error"
59+
message="This browser can't generate Ed25519 keys. Use ssh-keygen locally and paste the public key above."
60+
/>
61+
</div>
62+
</div>
63+
);
64+
}
65+
66+
return (
67+
<>
68+
<div className="card p-6">
69+
<div className="flex items-center gap-2">
70+
<Sparkles className="h-4 w-4 text-accent-400" />
71+
<h2 className="font-semibold text-white">Generate a key pair in your browser</h2>
72+
</div>
73+
<p className="mt-1.5 text-sm text-slate-400">
74+
Creates an Ed25519 key with Web Crypto. The private key is shown only once and never sent to
75+
the server — the public key is added to your handle automatically.
76+
</p>
77+
<div className="mt-4 flex flex-col gap-3 sm:flex-row">
78+
<input
79+
value={label}
80+
onChange={(e) => setLabel(e.target.value)}
81+
placeholder="Label (optional), e.g. Work laptop"
82+
className="input sm:flex-1"
83+
/>
84+
<button onClick={generate} disabled={busy || supported === null} className="btn-primary shrink-0">
85+
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <KeySquare className="h-4 w-4" />}
86+
Generate
87+
</button>
88+
</div>
89+
{error && (
90+
<div className="mt-3">
91+
<Alert kind="error" message={error} />
92+
</div>
93+
)}
94+
</div>
95+
96+
{result && (
97+
<PrivateKeyModal
98+
pair={result.pair}
99+
fingerprint={result.stored.fingerprint}
100+
onClose={() => setResult(null)}
101+
onDownload={download}
102+
/>
103+
)}
104+
</>
105+
);
106+
}
107+
108+
function PrivateKeyModal({
109+
pair,
110+
fingerprint,
111+
onClose,
112+
onDownload,
113+
}: {
114+
pair: GeneratedKeyPair;
115+
fingerprint: string;
116+
onClose: () => void;
117+
onDownload: (filename: string, contents: string) => void;
118+
}) {
119+
useEffect(() => {
120+
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose();
121+
window.addEventListener('keydown', onKey);
122+
document.body.style.overflow = 'hidden';
123+
return () => {
124+
window.removeEventListener('keydown', onKey);
125+
document.body.style.overflow = '';
126+
};
127+
}, [onClose]);
128+
129+
return (
130+
<div
131+
className="fixed inset-0 z-50 grid place-items-center bg-ink-950/80 p-4 backdrop-blur-sm"
132+
role="dialog"
133+
aria-modal="true"
134+
onClick={onClose}
135+
>
136+
<div
137+
className="card w-full max-w-2xl animate-fade-up p-6 shadow-glow"
138+
onClick={(e) => e.stopPropagation()}
139+
>
140+
<div className="flex items-start justify-between">
141+
<div>
142+
<h2 className="text-lg font-bold text-white">Save your private key</h2>
143+
<p className="mt-1 font-mono text-xs text-slate-500">{fingerprint}</p>
144+
</div>
145+
<button onClick={onClose} aria-label="Close" className="rounded-lg p-1.5 text-slate-400 hover:bg-ink-800 hover:text-white">
146+
<X className="h-5 w-5" />
147+
</button>
148+
</div>
149+
150+
<div className="mt-4 flex items-start gap-2.5 rounded-xl border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm text-amber-200">
151+
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
152+
<span>
153+
This private key is shown <strong>once</strong> and is never stored by SSHID. Download or copy
154+
it now and keep it secret. The public key has already been added to your handle.
155+
</span>
156+
</div>
157+
158+
<div className="mt-4 space-y-3">
159+
<CodeBlock label="id_ed25519 (private key)" code={pair.privateKey}>
160+
{pair.privateKey}
161+
</CodeBlock>
162+
163+
<div className="flex flex-wrap gap-3">
164+
<button onClick={() => onDownload(pair.filename, pair.privateKey)} className="btn-primary">
165+
<Download className="h-4 w-4" />
166+
Download private key
167+
</button>
168+
<button
169+
onClick={() => onDownload(`${pair.filename}.pub`, pair.publicKey + '\n')}
170+
className="btn-ghost"
171+
>
172+
<Download className="h-4 w-4" />
173+
Download public key
174+
</button>
175+
</div>
176+
177+
<p className="text-xs text-slate-500">
178+
Install it with{' '}
179+
<code className="font-mono text-slate-400">
180+
mv {pair.filename} ~/.ssh/ &amp;&amp; chmod 600 ~/.ssh/{pair.filename}
181+
</code>
182+
</p>
183+
</div>
184+
</div>
185+
</div>
186+
);
187+
}

apps/web/src/lib/sshkeygen.ts

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
/**
2+
* Client-side SSH key generation.
3+
*
4+
* An Ed25519 key pair is generated with the Web Crypto API and encoded into the
5+
* exact byte formats OpenSSH uses:
6+
* - the public key as a single `ssh-ed25519 AAAA… comment` line, and
7+
* - the private key as an unencrypted `openssh-key-v1` PEM blob.
8+
*
9+
* The private key is produced and handled entirely in the browser — it is never
10+
* transmitted to the server. Only the public half is uploaded.
11+
*/
12+
13+
export interface GeneratedKeyPair {
14+
/** OpenSSH single-line public key (`ssh-ed25519 <base64> <comment>`). */
15+
publicKey: string;
16+
/** Unencrypted OpenSSH private key (PEM). */
17+
privateKey: string;
18+
/** Suggested filename stem, e.g. `id_ed25519`. */
19+
filename: string;
20+
}
21+
22+
/** Whether this browser can generate Ed25519 keys via Web Crypto. */
23+
export async function ed25519Supported(): Promise<boolean> {
24+
try {
25+
await crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']);
26+
return true;
27+
} catch {
28+
return false;
29+
}
30+
}
31+
32+
class ByteWriter {
33+
private chunks: Uint8Array[] = [];
34+
35+
bytes(data: Uint8Array): this {
36+
this.chunks.push(data);
37+
return this;
38+
}
39+
40+
uint32(value: number): this {
41+
const b = new Uint8Array(4);
42+
new DataView(b.buffer).setUint32(0, value, false);
43+
return this.bytes(b);
44+
}
45+
46+
/** SSH wire-format string: 4-byte big-endian length prefix + payload. */
47+
sshString(data: Uint8Array | string): this {
48+
const payload = typeof data === 'string' ? new TextEncoder().encode(data) : data;
49+
return this.uint32(payload.length).bytes(payload);
50+
}
51+
52+
concat(): Uint8Array {
53+
const total = this.chunks.reduce((n, c) => n + c.length, 0);
54+
const out = new Uint8Array(total);
55+
let offset = 0;
56+
for (const c of this.chunks) {
57+
out.set(c, offset);
58+
offset += c.length;
59+
}
60+
return out;
61+
}
62+
}
63+
64+
function toBase64(bytes: Uint8Array): string {
65+
let binary = '';
66+
for (const byte of bytes) binary += String.fromCharCode(byte);
67+
return btoa(binary);
68+
}
69+
70+
function wrap(base64: string, width = 70): string {
71+
const lines: string[] = [];
72+
for (let i = 0; i < base64.length; i += width) lines.push(base64.slice(i, i + width));
73+
return lines.join('\n');
74+
}
75+
76+
function publicKeyBlob(pub: Uint8Array): Uint8Array {
77+
return new ByteWriter().sshString('ssh-ed25519').sshString(pub).concat();
78+
}
79+
80+
function encodePrivateKey(seed: Uint8Array, pub: Uint8Array, comment: string): string {
81+
const checkInt = crypto.getRandomValues(new Uint32Array(1))[0]!;
82+
83+
// priv field is seed(32) || public(32), as OpenSSH stores it.
84+
const priv = new Uint8Array(64);
85+
priv.set(seed, 0);
86+
priv.set(pub, 32);
87+
88+
let privateSection = new ByteWriter()
89+
.uint32(checkInt)
90+
.uint32(checkInt)
91+
.sshString('ssh-ed25519')
92+
.sshString(pub)
93+
.sshString(priv)
94+
.sshString(comment)
95+
.concat();
96+
97+
// Pad to a multiple of the cipher block size (8 for "none") with 1,2,3,…
98+
const padLen = (8 - (privateSection.length % 8)) % 8;
99+
if (padLen > 0) {
100+
const padded = new Uint8Array(privateSection.length + padLen);
101+
padded.set(privateSection, 0);
102+
for (let i = 0; i < padLen; i++) padded[privateSection.length + i] = i + 1;
103+
privateSection = padded;
104+
}
105+
106+
const magic = new TextEncoder().encode('openssh-key-v1\0');
107+
const body = new ByteWriter()
108+
.bytes(magic)
109+
.sshString('none') // ciphername
110+
.sshString('none') // kdfname
111+
.sshString('') // kdfoptions
112+
.uint32(1) // number of keys
113+
.sshString(publicKeyBlob(pub))
114+
.sshString(privateSection)
115+
.concat();
116+
117+
return `-----BEGIN OPENSSH PRIVATE KEY-----\n${wrap(toBase64(body))}\n-----END OPENSSH PRIVATE KEY-----\n`;
118+
}
119+
120+
export async function generateEd25519KeyPair(comment: string): Promise<GeneratedKeyPair> {
121+
const label = comment.trim() || 'sshid';
122+
const keyPair = (await crypto.subtle.generateKey({ name: 'Ed25519' }, true, [
123+
'sign',
124+
'verify',
125+
])) as CryptoKeyPair;
126+
127+
const pub = new Uint8Array(await crypto.subtle.exportKey('raw', keyPair.publicKey));
128+
// PKCS#8 for Ed25519 is a fixed prefix followed by the 32-byte seed.
129+
const pkcs8 = new Uint8Array(await crypto.subtle.exportKey('pkcs8', keyPair.privateKey));
130+
const seed = pkcs8.slice(pkcs8.length - 32);
131+
132+
const publicKey = `ssh-ed25519 ${toBase64(publicKeyBlob(pub))} ${label}`;
133+
const privateKey = encodePrivateKey(seed, pub, label);
134+
135+
return { publicKey, privateKey, filename: 'id_ed25519' };
136+
}

apps/web/src/pages/Dashboard.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { useEffect, useState, type FormEvent } from 'react';
33
import { Navigate } from 'react-router-dom';
44
import { Alert } from '../components/Alert';
55
import { CodeBlock } from '../components/CodeBlock';
6+
import { GenerateKey } from '../components/GenerateKey';
67
import { api, type SshKey } from '../lib/api';
78
import { useAuth } from '../lib/auth';
89
import { PUBLIC_HOST, PUBLIC_ORIGIN } from '../lib/constants';
@@ -41,6 +42,7 @@ export function Dashboard() {
4142

4243
<section className="mt-8 grid gap-6 lg:grid-cols-[1fr_360px]">
4344
<div className="space-y-6">
45+
<GenerateKey onAdded={(k) => setKeys((prev) => [...prev, k])} />
4446
<AddKeyForm onAdded={(k) => setKeys((prev) => [...prev, k])} />
4547

4648
<div className="card">

0 commit comments

Comments
 (0)