mirror of
https://github.com/CorentinTh/it-tools.git
synced 2026-10-21 21:31:21 +00:00
feat(new tool): SSH key pair generator (ed25519, RSA)
This commit is contained in:
parent
9a1d8b10c3
commit
1620656e79
6 changed files with 347 additions and 1 deletions
|
|
@ -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',
|
||||
|
|
|
|||
12
src/tools/ssh-key-pair-generator/index.ts
Normal file
12
src/tools/ssh-key-pair-generator/index.ts
Normal file
|
|
@ -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'),
|
||||
});
|
||||
102
src/tools/ssh-key-pair-generator/ssh-encoding.service.ts
Normal file
102
src/tools/ssh-key-pair-generator/ssh-encoding.service.ts
Normal file
|
|
@ -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: `<keytype> <base64 blob> [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));
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<SshKeyPair> {
|
||||
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<SshKeyPair> {
|
||||
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;
|
||||
}
|
||||
85
src/tools/ssh-key-pair-generator/ssh-key-pair-generator.vue
Normal file
85
src/tools/ssh-key-pair-generator/ssh-key-pair-generator.vue
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
<script setup lang="ts">
|
||||
import { generateEd25519SshPair, generateRsaSshPair } from './ssh-key-pair-generator.service';
|
||||
import TextareaCopyable from '@/components/TextareaCopyable.vue';
|
||||
import { computedRefreshableAsync } from '@/composable/computedRefreshable';
|
||||
import { withDefaultOnErrorAsync } from '@/utils/defaults';
|
||||
|
||||
const keyType = ref<'ed25519' | 'rsa'>('ed25519');
|
||||
const rsaBits = ref<2048 | 3072 | 4096>(3072);
|
||||
const comment = ref('');
|
||||
|
||||
const emptyPair = { publicKey: '', privateKey: '' };
|
||||
|
||||
const [pair, refreshPair] = computedRefreshableAsync(
|
||||
() => withDefaultOnErrorAsync(
|
||||
async () => (keyType.value === 'ed25519'
|
||||
? generateEd25519SshPair(comment.value)
|
||||
: generateRsaSshPair(comment.value, rsaBits.value)),
|
||||
emptyPair,
|
||||
),
|
||||
emptyPair,
|
||||
);
|
||||
|
||||
function downloadPrivateKey() {
|
||||
const extension = keyType.value === 'ed25519' ? 'ed25519' : `rsa_${rsaBits.value}`;
|
||||
const blob = new Blob([pair.value.privateKey], { type: 'text/plain' });
|
||||
const link = document.createElement('a');
|
||||
link.href = URL.createObjectURL(blob);
|
||||
link.download = `id_${extension}`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(link.href);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div style="flex: 0 0 100%">
|
||||
<div item-style="flex: 1 1 0" style="max-width: 640px" mx-auto flex flex-col gap-3>
|
||||
<c-buttons-select v-model:value="keyType" :options="['ed25519', 'rsa']" label="Key type" label-width="120px" />
|
||||
|
||||
<n-form-item v-if="keyType === 'rsa'" label="Bits :" label-placement="left" label-width="120">
|
||||
<n-radio-group v-model:value="rsaBits">
|
||||
<n-radio-button :value="2048">
|
||||
2048
|
||||
</n-radio-button>
|
||||
<n-radio-button :value="3072">
|
||||
3072
|
||||
</n-radio-button>
|
||||
<n-radio-button :value="4096">
|
||||
4096
|
||||
</n-radio-button>
|
||||
</n-radio-group>
|
||||
</n-form-item>
|
||||
|
||||
<c-input-text
|
||||
v-model:value="comment"
|
||||
label="Comment (optional, usually an email)"
|
||||
placeholder="user@host"
|
||||
clearable
|
||||
/>
|
||||
|
||||
<c-button @click="refreshPair">
|
||||
Refresh key-pair
|
||||
</c-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div mt-4>
|
||||
<h3>Public key (authorized_keys format)</h3>
|
||||
<TextareaCopyable :value="pair.publicKey" />
|
||||
</div>
|
||||
|
||||
<div mt-3>
|
||||
<div flex items-center justify-between>
|
||||
<h3>Private key (OpenSSH format, no passphrase)</h3>
|
||||
<c-button variant="text" @click="downloadPrivateKey()">
|
||||
Download private key
|
||||
</c-button>
|
||||
</div>
|
||||
<TextareaCopyable :value="pair.privateKey" />
|
||||
</div>
|
||||
|
||||
<div mt-2 text-sm op-60>
|
||||
The private key is generated in your browser and never leaves it. Keep it safe — it is displayed
|
||||
only here.
|
||||
</div>
|
||||
</template>
|
||||
Loading…
Add table
Add a link
Reference in a new issue