feat: OpenSSH-Schluessel erzeugen, schreiben und lesen (#006)

Private Keys sollen nie als Datei auf dem sshkv-Server liegen, auch nicht
kurz. ssh-keygen braucht aber genau das. Deshalb erzeugt Node crypto die
Schluessel (ed25519, RSA-4096), und ein eigener Serializer schreibt das
Format openssh-key-v1 – unverschluesselt oder mit aes256-ctr und
bcrypt_pbkdf (16 Runden, 16 Byte Salt), Checkint und Padding wie OpenSSH.

Der Parser liest auch von ssh-keygen erzeugte Dateien, prueft, dass
Private und Public Key zusammengehoeren, und meldet fehlende oder falsche
Passphrasen ueber eigene Codes. Fehlermeldungen sind feste Saetze ohne
Eingabe darin, damit kein Schluesselmaterial in Logs geraet. RSA unter
2048 Bit wird beim Import abgelehnt; wer nur beobachtet, kann die Grenze
ausschalten.

Die Tests pruefen beide Richtungen gegen das echte ssh-keygen (-y, -lf).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 09:16:42 +02:00
co-authored by Claude Opus 5.5
parent 224386106b
commit 6b20566676
7 changed files with 1145 additions and 0 deletions
+131
View File
@@ -0,0 +1,131 @@
import { execFileSync } from "node:child_process";
import { existsSync } from "node:fs";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
type ErzeugbarerSchluesseltyp,
privateKeyLesen,
publicKeyZeileLesen,
schluesselpaarErzeugen,
} from "@/lib/ssh/openssh";
import { ecdsaBlob, ed25519Blob, rsaBlob, skEcdsaBlob, skEd25519Blob, zeile } from "./hilfen";
/**
* Gegenprobe mit dem echten OpenSSH (#006). `ssh-keygen` kommt nur hier vor, nie im
* Produktivcode – dort waeren dafuer Private Keys als Dateien noetig.
*/
const SSH_KEYGEN = "/usr/bin/ssh-keygen";
const PASSPHRASE = "geheim und lang genug";
let verzeichnis: string;
let zaehler = 0;
function sshKeygen(...argumente: string[]): string {
// stdin geschlossen: Fragt ssh-keygen doch einmal nach etwas, scheitert der Test, statt zu haengen.
return execFileSync(SSH_KEYGEN, argumente, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
}
async function dateiSchreiben(inhalt: string): Promise<string> {
const pfad = join(verzeichnis, `schluessel-${zaehler++}`);
await writeFile(pfad, inhalt, { mode: 0o600 });
return pfad;
}
/** "256 SHA256:… kommentar (ED25519)" → "SHA256:…" */
function fingerprintAusAusgabe(ausgabe: string): string {
return ausgabe.trim().split(/\s+/)[1]!;
}
beforeAll(async () => {
verzeichnis = await mkdtemp(join(tmpdir(), "sshkv-test-"));
});
afterAll(async () => {
await rm(verzeichnis, { recursive: true, force: true });
});
describe.skipIf(!existsSync(SSH_KEYGEN))("Kompatibilität mit ssh-keygen", () => {
describe.each([
["ED25519", undefined],
["ED25519", PASSPHRASE],
["RSA4096", undefined],
["RSA4096", PASSPHRASE],
] as [ErzeugbarerSchluesseltyp, string | undefined][])(
"selbst erzeugt: %s, Passphrase %s",
(typ, passphrase) => {
it("ssh-keygen -y liest den Private Key, -lf liefert denselben Fingerprint", async () => {
const paar = await schluesselpaarErzeugen(typ, { kommentar: "sshkv:k_test pruefung", passphrase });
const pfad = await dateiSchreiben(paar.privateKeyOpenssh);
const oeffentlich = sshKeygen("-y", "-P", passphrase ?? "", "-f", pfad).trim();
expect(oeffentlich).toBe(paar.publicKeyZeile);
const pfadPublic = await dateiSchreiben(paar.publicKeyZeile + "\n");
expect(fingerprintAusAusgabe(sshKeygen("-lf", pfadPublic))).toBe(paar.fingerprint);
// -lf auf den Private Key liest bei verschluesselten Dateien nur den Public-Teil.
expect(fingerprintAusAusgabe(sshKeygen("-lf", pfad))).toBe(paar.fingerprint);
}, 30_000);
if (passphrase) {
it("ssh-keygen lehnt eine falsche Passphrase ab", async () => {
const paar = await schluesselpaarErzeugen(typ, { kommentar: "x", passphrase });
const pfad = await dateiSchreiben(paar.privateKeyOpenssh);
expect(() => sshKeygen("-y", "-P", "falsch", "-f", pfad)).toThrow();
}, 30_000);
}
},
);
describe.each([
["ed25519", [], ""],
["ed25519", [], PASSPHRASE],
["rsa", ["-b", "2048"], ""],
["rsa", ["-b", "2048"], PASSPHRASE],
] as [string, string[], string][])(
"von ssh-keygen erzeugt: %s, Passphrase „%s“",
(typ, extra, passphrase) => {
it("wird gelesen, Public Key und Fingerprint stimmen", async () => {
const pfad = join(verzeichnis, `fremd-${zaehler++}`);
sshKeygen("-q", "-t", typ, ...extra, "-N", passphrase, "-C", "fremd@rechner", "-f", pfad);
const privat = await readFile(pfad, "utf8");
const publicZeile = (await readFile(`${pfad}.pub`, "utf8")).trim();
const gelesen = privateKeyLesen(privat, passphrase || undefined);
expect(gelesen.publicKeyZeile).toBe(publicZeile);
expect(gelesen.kommentar).toBe("fremd@rechner");
expect(gelesen.verschluesselt).toBe(passphrase !== "");
expect(gelesen.fingerprint).toBe(fingerprintAusAusgabe(sshKeygen("-lf", `${pfad}.pub`)));
expect(publicKeyZeileLesen(publicZeile).fingerprint).toBe(gelesen.fingerprint);
if (passphrase) {
expect(() => privateKeyLesen(privat, "falsch")).toThrow("Die Passphrase ist falsch.");
}
}, 30_000);
},
);
it("liest von ssh-keygen erzeugte ECDSA-Public-Keys", async () => {
for (const bits of ["256", "384", "521"]) {
const pfad = join(verzeichnis, `ecdsa-${bits}`);
sshKeygen("-q", "-t", "ecdsa", "-b", bits, "-N", "", "-C", "e", "-f", pfad);
const publicZeile = await readFile(`${pfad}.pub`, "utf8");
const info = publicKeyZeileLesen(publicZeile);
expect(info.typ).toBe(`ecdsa-sha2-nistp${bits}`);
expect(info.fingerprint).toBe(fingerprintAusAusgabe(sshKeygen("-lf", `${pfad}.pub`)));
}
});
it.each([
["ssh-ed25519", ed25519Blob],
["ssh-rsa", () => rsaBlob(2048)],
["ecdsa-sha2-nistp256", () => ecdsaBlob("nistp256")],
["sk-ssh-ed25519@openssh.com", skEd25519Blob],
["sk-ecdsa-sha2-nistp256@openssh.com", skEcdsaBlob],
] as const)("Fingerprint von %s stimmt mit ssh-keygen -lf überein", async (typ, blobBauen) => {
const text = zeile(typ, blobBauen());
const pfad = await dateiSchreiben(text + "\n");
expect(publicKeyZeileLesen(text).fingerprint).toBe(fingerprintAusAusgabe(sshKeygen("-lf", pfad)));
});
});