Skip to content

Commit 8f13c9c

Browse files
luizomfclaude
andcommitted
feat(keygen): add SSH key generator page
New /keygen page for generating SSH key pairs in the browser: - Ed25519 (recommended), RSA-2048, RSA-4096 - Generates private key in OpenSSH format (Ed25519) or PKCS8 PEM (RSA) - Public key in SSH one-liner format (ssh-ed25519/ssh-rsa) - SHA256 fingerprint - Equivalent ssh-keygen command - ssh-copy-id install instruction - Download private/public key files - Browser support detection warning - No passphrase support in V1 (warned in UI) New lib: crypto.ts with Web Crypto API Ed25519/RSA generation, OpenSSH binary format serialization, ASN.1 DER parser for RSA SPKI. 7 new tests (114 total). All 4 tools now complete. All nav links and home cards active. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent ad4c31f commit 8f13c9c

5 files changed

Lines changed: 635 additions & 3 deletions

File tree

src/layouts/Base.astro

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ const navItems = [
1212
{ href: '/tunnels/', label: 'Tunnels', disabled: false },
1313
{ href: '/hardening/', label: 'Hardening', disabled: false },
1414
{ href: '/config/', label: 'Config', disabled: false },
15-
{ href: '/keygen/', label: 'KeyGen', disabled: true },
15+
{ href: '/keygen/', label: 'KeyGen', disabled: false },
1616
];
1717
---
1818

src/lib/__tests__/crypto.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { generateSshKey, isEd25519Supported } from '../crypto';
3+
4+
// Web Crypto is available in Node 22+ (which this project requires)
5+
6+
describe('isEd25519Supported', () => {
7+
it('returns true in Node 22+ environment', () => {
8+
expect(isEd25519Supported()).toBe(true);
9+
});
10+
});
11+
12+
describe('generateSshKey - Ed25519', () => {
13+
it('generates valid ed25519 key pair', async () => {
14+
const key = await generateSshKey('ed25519', 'test@host');
15+
expect(key.keyType).toBe('ed25519');
16+
expect(key.privateKey).toContain('-----BEGIN OPENSSH PRIVATE KEY-----');
17+
expect(key.privateKey).toContain('-----END OPENSSH PRIVATE KEY-----');
18+
expect(key.publicKey).toMatch(/^ssh-ed25519 [A-Za-z0-9+/]+ test@host$/);
19+
expect(key.fingerprint).toMatch(/^SHA256:[A-Za-z0-9+/]+$/);
20+
expect(key.command).toBe('ssh-keygen -t ed25519 -C "test@host"');
21+
});
22+
23+
it('generates key without comment', async () => {
24+
const key = await generateSshKey('ed25519', '');
25+
expect(key.publicKey).toMatch(/^ssh-ed25519 [A-Za-z0-9+/]+$/);
26+
expect(key.command).toBe('ssh-keygen -t ed25519');
27+
});
28+
29+
it('generates unique keys each time', async () => {
30+
const key1 = await generateSshKey('ed25519', '');
31+
const key2 = await generateSshKey('ed25519', '');
32+
expect(key1.publicKey).not.toBe(key2.publicKey);
33+
expect(key1.privateKey).not.toBe(key2.privateKey);
34+
expect(key1.fingerprint).not.toBe(key2.fingerprint);
35+
});
36+
37+
it('public key blob is 51 bytes (base64 of 51 = 68 chars)', async () => {
38+
const key = await generateSshKey('ed25519', '');
39+
// "ssh-ed25519 <68 chars base64>"
40+
const base64Part = key.publicKey.split(' ')[1];
41+
expect(base64Part).toHaveLength(68);
42+
});
43+
});
44+
45+
describe('generateSshKey - RSA', () => {
46+
it('generates valid RSA-2048 key pair', async () => {
47+
const key = await generateSshKey('rsa-2048', 'test@host');
48+
expect(key.keyType).toBe('rsa-2048');
49+
expect(key.privateKey).toContain('-----BEGIN PRIVATE KEY-----');
50+
expect(key.privateKey).toContain('-----END PRIVATE KEY-----');
51+
expect(key.publicKey).toMatch(/^ssh-rsa [A-Za-z0-9+/=]+ test@host$/);
52+
expect(key.fingerprint).toMatch(/^SHA256:/);
53+
expect(key.command).toBe('ssh-keygen -t rsa -b 2048 -C "test@host"');
54+
});
55+
56+
it('generates valid RSA-4096 key pair', async () => {
57+
const key = await generateSshKey('rsa-4096', '');
58+
expect(key.keyType).toBe('rsa-4096');
59+
expect(key.publicKey).toMatch(/^ssh-rsa /);
60+
expect(key.command).toContain('-b 4096');
61+
});
62+
}, 15000);

src/lib/crypto.ts

Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
// SSH Key Generation via Web Crypto API
2+
// Supports Ed25519 and RSA (2048/4096)
3+
4+
export type KeyType = 'ed25519' | 'rsa-2048' | 'rsa-4096';
5+
6+
export interface GeneratedKey {
7+
privateKey: string;
8+
publicKey: string;
9+
fingerprint: string;
10+
command: string;
11+
keyType: KeyType;
12+
}
13+
14+
// --- Utility helpers ---
15+
16+
function encodeUint32BE(n: number): Uint8Array {
17+
const buf = new Uint8Array(4);
18+
buf[0] = (n >>> 24) & 0xff;
19+
buf[1] = (n >>> 16) & 0xff;
20+
buf[2] = (n >>> 8) & 0xff;
21+
buf[3] = n & 0xff;
22+
return buf;
23+
}
24+
25+
function sshString(data: Uint8Array | string): Uint8Array {
26+
const bytes = typeof data === 'string' ? new TextEncoder().encode(data) : data;
27+
const result = new Uint8Array(4 + bytes.length);
28+
result.set(encodeUint32BE(bytes.length), 0);
29+
result.set(bytes, 4);
30+
return result;
31+
}
32+
33+
function concat(...arrays: Uint8Array[]): Uint8Array {
34+
const total = arrays.reduce((sum, a) => sum + a.length, 0);
35+
const result = new Uint8Array(total);
36+
let offset = 0;
37+
for (const a of arrays) {
38+
result.set(a, offset);
39+
offset += a.length;
40+
}
41+
return result;
42+
}
43+
44+
function toBase64(data: Uint8Array): string {
45+
let binary = '';
46+
for (let i = 0; i < data.length; i++) {
47+
binary += String.fromCharCode(data[i]);
48+
}
49+
return btoa(binary);
50+
}
51+
52+
function wrapPem(tag: string, base64: string): string {
53+
const lines: string[] = [];
54+
for (let i = 0; i < base64.length; i += 70) {
55+
lines.push(base64.slice(i, i + 70));
56+
}
57+
return `-----BEGIN ${tag}-----\n${lines.join('\n')}\n-----END ${tag}-----\n`;
58+
}
59+
60+
// --- Ed25519 ---
61+
62+
async function generateEd25519(comment: string): Promise<GeneratedKey> {
63+
const keyPair = await crypto.subtle.generateKey('Ed25519', true, ['sign', 'verify']);
64+
65+
// Extract seed from PKCS8 (bytes 16..48)
66+
const pkcs8 = new Uint8Array(await crypto.subtle.exportKey('pkcs8', keyPair.privateKey));
67+
const seed = pkcs8.slice(16, 48);
68+
69+
// Extract public key from SPKI (last 32 bytes)
70+
const spki = new Uint8Array(await crypto.subtle.exportKey('spki', keyPair.publicKey));
71+
const pubRaw = spki.slice(spki.length - 32);
72+
73+
// Public key blob (for one-liner and fingerprint)
74+
const pubBlob = concat(sshString('ssh-ed25519'), sshString(pubRaw));
75+
76+
// Public key one-liner
77+
const publicKeyStr = `ssh-ed25519 ${toBase64(pubBlob)}${comment ? ' ' + comment : ''}`;
78+
79+
// Fingerprint: SHA256 of public key blob, base64 no padding
80+
const hashBuf = await crypto.subtle.digest('SHA-256', pubBlob);
81+
const fingerprint = `SHA256:${toBase64(new Uint8Array(hashBuf)).replace(/=+$/, '')}`;
82+
83+
// OpenSSH private key format
84+
const privateKeyStr = buildOpenSshEd25519(seed, pubRaw, comment);
85+
86+
const cmd = `ssh-keygen -t ed25519${comment ? ` -C "${comment}"` : ''}`;
87+
88+
return { privateKey: privateKeyStr, publicKey: publicKeyStr, fingerprint, command: cmd, keyType: 'ed25519' };
89+
}
90+
91+
function buildOpenSshEd25519(seed: Uint8Array, pubKey: Uint8Array, comment: string): string {
92+
const AUTH_MAGIC = new TextEncoder().encode('openssh-key-v1\0');
93+
94+
// Cipher/KDF = none (unencrypted)
95+
const cipherName = sshString('none');
96+
const kdfName = sshString('none');
97+
const kdfOptions = sshString(new Uint8Array(0));
98+
const numKeys = encodeUint32BE(1);
99+
100+
// Public key section
101+
const pubBlob = concat(sshString('ssh-ed25519'), sshString(pubKey));
102+
const pubSection = sshString(pubBlob);
103+
104+
// Private key section
105+
const checkInt = crypto.getRandomValues(new Uint8Array(4));
106+
const privKey64 = concat(seed, pubKey); // 64 bytes: seed || pubkey
107+
const commentBytes = new TextEncoder().encode(comment);
108+
109+
const privPayload = concat(
110+
checkInt,
111+
checkInt, // same value twice
112+
sshString('ssh-ed25519'),
113+
sshString(pubKey),
114+
sshString(privKey64),
115+
sshString(commentBytes),
116+
);
117+
118+
// Pad to multiple of 8 (cipher block size for "none")
119+
const padLen = (8 - (privPayload.length % 8)) % 8;
120+
const padding = new Uint8Array(padLen);
121+
for (let i = 0; i < padLen; i++) {
122+
padding[i] = (i + 1) & 0xff;
123+
}
124+
125+
const privSection = sshString(concat(privPayload, padding));
126+
127+
// Assemble
128+
const binary = concat(AUTH_MAGIC, cipherName, kdfName, kdfOptions, numKeys, pubSection, privSection);
129+
130+
return wrapPem('OPENSSH PRIVATE KEY', toBase64(binary));
131+
}
132+
133+
// --- RSA ---
134+
135+
async function generateRsa(bits: 2048 | 4096, comment: string): Promise<GeneratedKey> {
136+
const keyPair = await crypto.subtle.generateKey(
137+
{
138+
name: 'RSASSA-PKCS1-v1_5',
139+
modulusLength: bits,
140+
publicExponent: new Uint8Array([0x01, 0x00, 0x01]),
141+
hash: 'SHA-256',
142+
},
143+
true,
144+
['sign', 'verify'],
145+
);
146+
147+
// PKCS8 private key (OpenSSH reads this natively since 7.8)
148+
const pkcs8 = new Uint8Array(await crypto.subtle.exportKey('pkcs8', keyPair.privateKey));
149+
const privateKeyStr = wrapPem('PRIVATE KEY', toBase64(pkcs8));
150+
151+
// Public key in SSH format
152+
const spki = new Uint8Array(await crypto.subtle.exportKey('spki', keyPair.publicKey));
153+
const { n, e } = extractRsaComponents(spki);
154+
155+
const pubBlob = concat(sshString('ssh-rsa'), sshString(e), sshString(n));
156+
const publicKeyStr = `ssh-rsa ${toBase64(pubBlob)}${comment ? ' ' + comment : ''}`;
157+
158+
// Fingerprint
159+
const hashBuf = await crypto.subtle.digest('SHA-256', pubBlob);
160+
const fingerprint = `SHA256:${toBase64(new Uint8Array(hashBuf)).replace(/=+$/, '')}`;
161+
162+
const cmd = `ssh-keygen -t rsa -b ${bits}${comment ? ` -C "${comment}"` : ''}`;
163+
164+
return { privateKey: privateKeyStr, publicKey: publicKeyStr, fingerprint, command: cmd, keyType: `rsa-${bits}` as KeyType };
165+
}
166+
167+
// Minimal ASN.1 DER parser for RSA SPKI to extract n and e
168+
function extractRsaComponents(spki: Uint8Array): { n: Uint8Array; e: Uint8Array } {
169+
let offset = 0;
170+
171+
function readLength(): number {
172+
let length = spki[offset++];
173+
if (length & 0x80) {
174+
const numBytes = length & 0x7f;
175+
length = 0;
176+
for (let i = 0; i < numBytes; i++) {
177+
length = (length << 8) | spki[offset++];
178+
}
179+
}
180+
return length;
181+
}
182+
183+
function readTag(): { tag: number; length: number } {
184+
const tag = spki[offset++];
185+
const length = readLength();
186+
return { tag, length };
187+
}
188+
189+
function skipTlv(): void {
190+
offset++; // tag
191+
const length = readLength();
192+
offset += length;
193+
}
194+
195+
function readInteger(): Uint8Array {
196+
const { tag, length } = readTag();
197+
if (tag !== 0x02) throw new Error('Expected INTEGER');
198+
const data = spki.slice(offset, offset + length);
199+
offset += length;
200+
return data;
201+
}
202+
203+
// Outer SEQUENCE
204+
readTag(); // SEQUENCE (outer)
205+
206+
// AlgorithmIdentifier SEQUENCE — skip entirely
207+
const algSeq = readTag(); // SEQUENCE (algorithm)
208+
offset += algSeq.length; // skip OID + optional NULL inside
209+
210+
// BIT STRING containing RSAPublicKey
211+
const bitString = readTag();
212+
if (bitString.tag !== 0x03) throw new Error('Expected BIT STRING');
213+
offset++; // skip unused bits byte (0x00)
214+
215+
// Inner SEQUENCE (RSAPublicKey)
216+
readTag(); // SEQUENCE
217+
218+
const n = readInteger(); // modulus
219+
const e = readInteger(); // exponent
220+
221+
return { n, e };
222+
}
223+
224+
// --- Public API ---
225+
226+
export async function generateSshKey(type: KeyType, comment: string): Promise<GeneratedKey> {
227+
switch (type) {
228+
case 'ed25519':
229+
return generateEd25519(comment);
230+
case 'rsa-2048':
231+
return generateRsa(2048, comment);
232+
case 'rsa-4096':
233+
return generateRsa(4096, comment);
234+
}
235+
}
236+
237+
export function isEd25519Supported(): boolean {
238+
try {
239+
return typeof crypto !== 'undefined' && typeof crypto.subtle !== 'undefined';
240+
} catch {
241+
return false;
242+
}
243+
}

src/pages/index.astro

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,12 @@ import Footer from '../components/Footer.astro';
4242
</p>
4343
</a>
4444

45-
<a href="/keygen/" class="tool-card tool-card--soon">
45+
<a href="/keygen/" class="tool-card">
4646
<span class="tool-card__icon">ed25519</span>
4747
<h2 class="tool-card__title">Key Generator</h2>
4848
<p class="tool-card__desc">
4949
Gere pares de chaves Ed25519 e RSA direto no navegador via Web Crypto API.
5050
</p>
51-
<span class="tool-card__badge">Em breve</span>
5251
</a>
5352
</section>
5453

0 commit comments

Comments
 (0)