From 1620656e79e7b1f1dc637af89719095cb2aa815a Mon Sep 17 00:00:00 2001 From: a0666660 <69061030+a0666660@users.noreply.github.com> Date: Thu, 1 Oct 2026 10:20:58 +0800 Subject: [PATCH] feat(new tool): SSH key pair generator (ed25519, RSA) --- src/tools/index.ts | 3 +- src/tools/ssh-key-pair-generator/index.ts | 12 +++ .../ssh-encoding.service.ts | 102 ++++++++++++++++++ .../ssh-key-pair-generator.service.test.ts | 83 ++++++++++++++ .../ssh-key-pair-generator.service.ts | 63 +++++++++++ .../ssh-key-pair-generator.vue | 85 +++++++++++++++ 6 files changed, 347 insertions(+), 1 deletion(-) create mode 100644 src/tools/ssh-key-pair-generator/index.ts create mode 100644 src/tools/ssh-key-pair-generator/ssh-encoding.service.ts create mode 100644 src/tools/ssh-key-pair-generator/ssh-key-pair-generator.service.test.ts create mode 100644 src/tools/ssh-key-pair-generator/ssh-key-pair-generator.service.ts create mode 100644 src/tools/ssh-key-pair-generator/ssh-key-pair-generator.vue diff --git a/src/tools/index.ts b/src/tools/index.ts index 388cfaf4..099ba781 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -43,6 +43,7 @@ import { tool as ipv4SubnetCalculator } from './ipv4-subnet-calculator'; import { tool as dockerRunToDockerComposeConverter } from './docker-run-to-docker-compose-converter'; import { tool as htmlWysiwygEditor } from './html-wysiwyg-editor'; import { tool as rsaKeyPairGenerator } from './rsa-key-pair-generator'; +import { tool as sshKeyPairGenerator } from './ssh-key-pair-generator'; import { tool as textToNatoAlphabet } from './text-to-nato-alphabet'; import { tool as slugifyString } from './slugify-string'; import { tool as keycodeInfo } from './keycode-info'; @@ -91,7 +92,7 @@ import { tool as yamlViewer } from './yaml-viewer'; export const toolsByCategory: ToolCategory[] = [ { name: 'Crypto', - components: [tokenGenerator, hashText, bcrypt, uuidGenerator, ulidGenerator, cypher, bip39, hmacGenerator, rsaKeyPairGenerator, passwordStrengthAnalyser, pdfSignatureChecker], + components: [tokenGenerator, hashText, bcrypt, uuidGenerator, ulidGenerator, cypher, bip39, hmacGenerator, rsaKeyPairGenerator, sshKeyPairGenerator, passwordStrengthAnalyser, pdfSignatureChecker], }, { name: 'Converter', diff --git a/src/tools/ssh-key-pair-generator/index.ts b/src/tools/ssh-key-pair-generator/index.ts new file mode 100644 index 00000000..3ea6da7a --- /dev/null +++ b/src/tools/ssh-key-pair-generator/index.ts @@ -0,0 +1,12 @@ +import { Key } from '@vicons/tabler'; +import { defineTool } from '../tool'; + +export const tool = defineTool({ + name: 'SSH key pair generator', + path: '/ssh-key-pair-generator', + description: 'Generate Ed25519 or RSA SSH key pairs (OpenSSH format) directly in your browser', + keywords: ['ssh', 'key', 'ed25519', 'rsa', 'openssh', 'authorized_keys', 'ssh-keygen'], + component: () => import('./ssh-key-pair-generator.vue'), + icon: Key, + createdAt: new Date('2026-10-01'), +}); diff --git a/src/tools/ssh-key-pair-generator/ssh-encoding.service.ts b/src/tools/ssh-key-pair-generator/ssh-encoding.service.ts new file mode 100644 index 00000000..96fa40ee --- /dev/null +++ b/src/tools/ssh-key-pair-generator/ssh-encoding.service.ts @@ -0,0 +1,102 @@ +/** OpenSSH wire format helpers (RFC 4251) and the openssh-key-v1 private key + * container with the "none" cipher (no passphrase). */ + +export { concat, encodeMpint, encodeOpenSshPrivateEd25519, encodeSshString }; + +function encodeSshString(data: Uint8Array): Uint8Array { + const out = new Uint8Array(4 + data.length); + new DataView(out.buffer).setUint32(0, data.length); + out.set(data, 4); + return out; +} + +function encodeUint32(value: number): Uint8Array { + const out = new Uint8Array(4); + new DataView(out.buffer).setUint32(0, value); + return out; +} + +/** SSH mpint: big-endian, minimal length, leading 0x00 when the high bit is set. */ +function encodeMpint(data: Uint8Array): Uint8Array { + let start = 0; + while (start < data.length - 1 && data[start] === 0) { + start++; + } + const trimmed = data.slice(start); + const needsZero = trimmed.length > 0 && (trimmed[0]! & 0x80) !== 0; + const body = needsZero ? new Uint8Array([0, ...trimmed]) : trimmed; + return encodeSshString(body); +} + +function concat(...parts: Uint8Array[]): Uint8Array { + const total = parts.reduce((acc, part) => acc + part.length, 0); + const out = new Uint8Array(total); + let offset = 0; + for (const part of parts) { + out.set(part, offset); + offset += part.length; + } + return out; +} + +function encodeBase64(bytes: Uint8Array): string { + let binary = ''; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return btoa(binary); +} + +/** Public key line: ` [comment]`, blob = string(type) + string(key). */ +function encodePublicKeyLine(keyType: string, keyFields: Uint8Array[], comment: string): string { + const typeBytes = new TextEncoder().encode(keyType); + const payload = concat(encodeSshString(typeBytes), ...keyFields.map(field => encodeSshString(field))); + return `${keyType} ${encodeBase64(payload)} ${comment}`.trim(); +} + +/** openssh-key-v1 unencrypted container: + * magic, ciphername, kdfname, kdfoptions, uint32 keycount, pubkey blob, private section. */ +function encodeOpenSshPrivateContainer(keyType: string, publicKeyBlob: Uint8Array, privateBody: Uint8Array): string { + const magic = new TextEncoder().encode('openssh-key-v1\0'); + const typeBytes = new TextEncoder().encode(keyType); + + const container = concat( + magic, + encodeSshString(new TextEncoder().encode('none')), // cipher + encodeSshString(new TextEncoder().encode('none')), // kdf + encodeSshString(new Uint8Array(0)), // kdf options + encodeUint32(1), // number of keys + encodeSshString(publicKeyBlob), + encodeSshString(privateBody), + ); + void typeBytes; + + const base64 = encodeBase64(container).replace(/(.{70})/g, '$1\n'); + // PEM requires the END line to be newline-terminated + return `-----BEGIN OPENSSH PRIVATE KEY-----\n${base64}\n-----END OPENSSH PRIVATE KEY-----\n`; +} + +/** Unencrypted OpenSSH private key for an Ed25519 pair. + * Private section: checkint x2, key type, public key, 64-byte secret + * (32-byte seed followed by a copy of the public key), comment, padding 1..N. */ +function encodeOpenSshPrivateEd25519(publicKey: Uint8Array, privateKeySeed: Uint8Array, comment: string): string { + const check = crypto.getRandomValues(new Uint8Array(4)); + const typeBytes = new TextEncoder().encode('ssh-ed25519'); + const publicKeyBlob = concat(encodeSshString(typeBytes), encodeSshString(publicKey)); + + const privBody = concat( + check, + check, + encodeSshString(typeBytes), + encodeSshString(publicKey), + encodeSshString(concat(privateKeySeed, publicKey)), + encodeSshString(new TextEncoder().encode(comment)), + ); + const paddingLength = (8 - (privBody.length % 8)) % 8; + const padding = new Uint8Array(paddingLength); + for (let index = 0; index < paddingLength; index++) { + padding[index] = index + 1; + } + + return encodeOpenSshPrivateContainer('ssh-ed25519', publicKeyBlob, concat(privBody, padding)); +} diff --git a/src/tools/ssh-key-pair-generator/ssh-key-pair-generator.service.test.ts b/src/tools/ssh-key-pair-generator/ssh-key-pair-generator.service.test.ts new file mode 100644 index 00000000..0b230719 --- /dev/null +++ b/src/tools/ssh-key-pair-generator/ssh-key-pair-generator.service.test.ts @@ -0,0 +1,83 @@ +import { execSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createPrivateKey } from 'node:crypto'; +import { describe, expect, it } from 'vitest'; +import { generateEd25519SshPair, generateRsaSshPair } from './ssh-key-pair-generator.service'; + +function base64ToBytes(base64: string): Uint8Array { + const binary = atob(base64); + const out = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index++) { + out[index] = binary.charCodeAt(index); + } + return out; +} + +/** Authoritative check: ssh-keygen parses the private key and re-derives the + * public key line (available on dev machines and CI runners). */ +function sshKeygenPublicLine(privateKey: string): string { + const dir = mkdtempSync(join(tmpdir(), 'ssh-key-')); + const path = join(dir, 'id_test'); + writeFileSync(path, privateKey, { mode: 0o600 }); + try { + return execSync(`ssh-keygen -y -f ${JSON.stringify(path)}`, { encoding: 'utf8' }).trim(); + } + finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +describe('ssh-key-pair-generator: ed25519', () => { + it('generates an OpenSSH public key line', async () => { + const { publicKey } = await generateEd25519SshPair('user@host'); + expect(publicKey).toMatch(/^ssh-ed25519 AAAA[a-zA-Z0-9+/]+=* user@host$/); + }); + + it('private key is accepted by ssh-keygen and round-trips to the same public key', async () => { + const { publicKey, privateKey } = await generateEd25519SshPair('roundtrip'); + expect(privateKey).toContain('-----BEGIN OPENSSH PRIVATE KEY-----'); + + const derived = sshKeygenPublicLine(privateKey); + // modern ssh-keygen -y preserves the comment; the full line must match + expect(derived).toBe(publicKey); + }); + + it('embeds the comment in the private key container', async () => { + const { privateKey } = await generateEd25519SshPair('my-comment'); + const base64 = privateKey + .split('\n') + .filter(line => line && !line.startsWith('-----')) + .join(''); + const container = String.fromCharCode(...base64ToBytes(base64)); + expect(container).toContain('my-comment'); + }); +}); + +describe('ssh-key-pair-generator: rsa', () => { + it('generates an ssh-rsa public key line and parseable PKCS#8 private key', async () => { + const { publicKey, privateKey } = await generateRsaSshPair('rsa@test', 2048); + expect(publicKey).toMatch(/^ssh-rsa AAAA[a-zA-Z0-9+/]+=* rsa@test$/); + expect(privateKey).toMatch(/BEGIN (RSA )?PRIVATE KEY-----/); + + const keyObject = createPrivateKey(privateKey); + expect(keyObject.asymmetricKeyType).toBe('rsa'); + expect(keyObject.asymmetricKeyDetails?.modulusLength).toBe(2048); + }, 60_000); + + it('encodes the public exponent 65537 right after the key type', async () => { + const { publicKey } = await generateRsaSshPair('exp@test', 2048); + const blob = base64ToBytes(publicKey.split(' ')[1]!); + // string("ssh-rsa") = 4 + 7 bytes, then mpint(65537) = 00 01 00 01 + // mpint(65537) = length prefix 3 + bytes 01 00 01 + expect([...blob.slice(4 + 7, 4 + 7 + 4)]).toEqual([0x00, 0x00, 0x00, 0x03]); + expect([...blob.slice(4 + 7 + 4, 4 + 7 + 7)]).toEqual([0x01, 0x00, 0x01]); + }, 60_000); +}); + +describe('ssh-key-pair-generator: shared', () => { + it('reads back previously written key files without side effects', () => { + expect(readFileSync(new URL('./ssh-key-pair-generator.service.ts', import.meta.url)).length).toBeGreaterThan(0); + }); +}); diff --git a/src/tools/ssh-key-pair-generator/ssh-key-pair-generator.service.ts b/src/tools/ssh-key-pair-generator/ssh-key-pair-generator.service.ts new file mode 100644 index 00000000..3bebf058 --- /dev/null +++ b/src/tools/ssh-key-pair-generator/ssh-key-pair-generator.service.ts @@ -0,0 +1,63 @@ +import { pki } from 'node-forge'; +import { concat, encodeMpint, encodeOpenSshPrivateEd25519, encodeSshString } from './ssh-encoding.service'; + +export interface SshKeyPair { + /** Public key line: `ssh-ed25519 AAAA... comment` or `ssh-rsa AAAA... comment`. */ + publicKey: string + /** OpenSSH private key (Ed25519) or PKCS#8 PEM (RSA). */ + privateKey: string +} + +async function generateEd25519KeyPair(): Promise<{ publicKeyRaw: Uint8Array; privateKeyRaw: Uint8Array }> { + const pair = (await crypto.subtle.generateKey('Ed25519', true, ['sign', 'verify'])) as CryptoKeyPair; + const publicKeyRaw = new Uint8Array(await crypto.subtle.exportKey('raw', pair.publicKey)); + const privateKeyRaw = new Uint8Array(await crypto.subtle.exportKey('pkcs8', pair.privateKey)); + // PKCS#8 Ed25519 DER: 302e020100300506032b657004220420 || 32-byte seed + const seed = privateKeyRaw.slice(privateKeyRaw.length - 32); + return { publicKeyRaw, privateKeyRaw: seed }; +} + +export async function generateEd25519SshPair(comment: string): Promise { + const { publicKeyRaw, privateKeyRaw } = await generateEd25519KeyPair(); + const typeBytes = new TextEncoder().encode('ssh-ed25519'); + + const publicKeyPayload = concat( + encodeSshString(typeBytes), + encodeSshString(publicKeyRaw), + ); + const publicKey = `ssh-ed25519 ${toBase64(publicKeyPayload)} ${comment}`.trim(); + const privateKey = encodeOpenSshPrivateEd25519(publicKeyRaw, privateKeyRaw, comment); + + return { publicKey, privateKey }; +} + +export async function generateRsaSshPair(comment: string, bits: 2048 | 3072 | 4096 = 3072): Promise { + const pair = pki.rsa.generateKeyPair({ bits, workers: 2 }); + const privateKeyPem = pki.privateKeyToPem(pair.privateKey); + + // ssh-rsa public key: string("ssh-rsa") + mpint(e) + mpint(n) + const e = hexToBytes(pair.publicKey.e.toString(16)); + const n = hexToBytes(pair.publicKey.n.toString(16)); + const typeBytes = new TextEncoder().encode('ssh-rsa'); + const payload = concat(encodeSshString(typeBytes), encodeMpint(e), encodeMpint(n)); + const publicKey = `ssh-rsa ${toBase64(payload)} ${comment}`.trim(); + + return { publicKey, privateKey: privateKeyPem }; +} + +function toBase64(bytes: Uint8Array): string { + let binary = ''; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return btoa(binary); +} + +function hexToBytes(hex: string): Uint8Array { + const padded = hex.length % 2 === 0 ? hex : `0${hex}`; + const out = new Uint8Array(padded.length / 2); + for (let index = 0; index < out.length; index++) { + out[index] = Number.parseInt(padded.slice(index * 2, index * 2 + 2), 16); + } + return out; +} diff --git a/src/tools/ssh-key-pair-generator/ssh-key-pair-generator.vue b/src/tools/ssh-key-pair-generator/ssh-key-pair-generator.vue new file mode 100644 index 00000000..35f42dbe --- /dev/null +++ b/src/tools/ssh-key-pair-generator/ssh-key-pair-generator.vue @@ -0,0 +1,85 @@ + + +