Files
florian.wildeandClaude Opus 5.5 6b20566676 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>
2026-10-12 09:16:42 +02:00

228 lines
9.0 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createHash } from "node:crypto";
import { beforeAll, describe, expect, it } from "vitest";
import {
bitsVonRsa,
type ErzeugtesSchluesselpaar,
fingerprintSha256,
privateKeyLesen,
publicKeyZeileLesen,
SchluesselFormatFehler,
schluesselpaarErzeugen,
} from "@/lib/ssh/openssh";
import {
ecdsaBlob,
ed25519Blob,
enthaeltMaterial,
pemRumpf,
rsaBlob,
skEcdsaBlob,
skEd25519Blob,
sshString,
zeile,
} from "./hilfen";
const PASSPHRASE = "korrekt Pferd Batterie Heftklammer";
function fehlerVon(aufruf: () => unknown): SchluesselFormatFehler {
try {
aufruf();
} catch (fehler) {
expect(fehler).toBeInstanceOf(SchluesselFormatFehler);
return fehler as SchluesselFormatFehler;
}
throw new Error("Es wurde ein Fehler erwartet.");
}
// RSA-4096 kostet je Erzeugung bis zu einige Sekunden; einmal je Variante reicht.
const paare: Record<string, ErzeugtesSchluesselpaar> = {};
beforeAll(async () => {
const [ed, edPass, rsa, rsaPass] = await Promise.all([
schluesselpaarErzeugen("ED25519", { kommentar: "sshkv:k_1 backup" }),
schluesselpaarErzeugen("ED25519", { kommentar: "mit passphrase", passphrase: PASSPHRASE }),
schluesselpaarErzeugen("RSA4096", { kommentar: "rsa ohne" }),
schluesselpaarErzeugen("RSA4096", { kommentar: "rsa mit", passphrase: PASSPHRASE }),
]);
Object.assign(paare, { ed, edPass, rsa, rsaPass });
}, 60_000);
describe("schluesselpaarErzeugen und privateKeyLesen", () => {
it.each([
["ed", "ED25519", 256, false],
["edPass", "ED25519", 256, true],
["rsa", "RSA", 4096, false],
["rsaPass", "RSA", 4096, true],
] as const)("Rundreise %s", (name, typ, bits, mitPassphrase) => {
const paar = paare[name]!;
expect(paar.privateKeyOpenssh.startsWith("-----BEGIN OPENSSH PRIVATE KEY-----\n")).toBe(true);
expect(paar.privateKeyOpenssh.endsWith("-----END OPENSSH PRIVATE KEY-----\n")).toBe(true);
expect(paar.fingerprint).toMatch(/^SHA256:[A-Za-z0-9+/]{43}$/);
const gelesen = privateKeyLesen(paar.privateKeyOpenssh, mitPassphrase ? PASSPHRASE : undefined);
expect(gelesen).toMatchObject({
typ,
bits,
publicKeyZeile: paar.publicKeyZeile,
fingerprint: paar.fingerprint,
verschluesselt: mitPassphrase,
});
const oeffentlich = publicKeyZeileLesen(paar.publicKeyZeile);
expect(oeffentlich.fingerprint).toBe(paar.fingerprint);
expect(oeffentlich.kommentar).toBe(gelesen.kommentar);
});
it("bricht die Base64-Zeilen wie OpenSSH nach 70 Zeichen um", () => {
const zeilen = paare["rsa"]!.privateKeyOpenssh.trim().split("\n").slice(1, -1);
expect(zeilen.slice(0, -1).every((z) => z.length === 70)).toBe(true);
});
it("verschluesselt mit aes256-ctr und bcrypt", () => {
const roh = Buffer.from(pemRumpf(paare["edPass"]!.privateKeyOpenssh), "base64").toString("latin1");
expect(roh).toContain("aes256-ctr");
expect(roh).toContain("bcrypt");
});
it("erzeugt jedes Mal einen anderen Schluessel", async () => {
const a = await schluesselpaarErzeugen("ED25519", { kommentar: "a" });
expect(a.fingerprint).not.toBe(paare["ed"]!.fingerprint);
});
it("verträgt CRLF und Leerraum um den Private Key", () => {
const text = "\n " + paare["ed"]!.privateKeyOpenssh.replace(/\n/g, "\r\n") + " \n";
expect(privateKeyLesen(text).fingerprint).toBe(paare["ed"]!.fingerprint);
});
it("lehnt Steuerzeichen im Kommentar ab", async () => {
await expect(schluesselpaarErzeugen("ED25519", { kommentar: "a\nssh-rsa AAAA" })).rejects.toBeInstanceOf(
SchluesselFormatFehler,
);
});
});
describe("privateKeyLesen – Fehlerfälle", () => {
it("meldet eine fehlende Passphrase", () => {
expect(fehlerVon(() => privateKeyLesen(paare["edPass"]!.privateKeyOpenssh)).code).toBe(
"passphrase_fehlt",
);
});
it.each(["edPass", "rsaPass"])("meldet eine falsche Passphrase (%s)", (name) => {
const fehler = fehlerVon(() => privateKeyLesen(paare[name]!.privateKeyOpenssh, "falsch"));
expect(fehler.code).toBe("passphrase_falsch");
expect(fehler.message).toBe("Die Passphrase ist falsch.");
});
it("erkennt einen abgeschnittenen Key", () => {
const pem = paare["rsa"]!.privateKeyOpenssh;
const zeilen = pem.trim().split("\n");
const halb = [...zeilen.slice(0, zeilen.length / 2), zeilen.at(-1)!].join("\n");
expect(fehlerVon(() => privateKeyLesen(halb)).code).toBe("ungueltig");
expect(fehlerVon(() => privateKeyLesen(pem.slice(0, pem.length / 2))).code).toBe("ungueltig");
});
it("erkennt ein vertauschtes Byte im unverschlüsselten Teil", () => {
const roh = Buffer.from(pemRumpf(paare["ed"]!.privateKeyOpenssh), "base64");
roh[roh.length - 60]! ^= 0x01;
const pem = `-----BEGIN OPENSSH PRIVATE KEY-----\n${roh.toString("base64")}\n-----END OPENSSH PRIVATE KEY-----\n`;
expect(fehlerVon(() => privateKeyLesen(pem)).code).toBe("ungueltig");
});
it("weist PEM-Formate außer OpenSSH mit eigener Meldung ab", () => {
const fehler = fehlerVon(() =>
privateKeyLesen("-----BEGIN RSA PRIVATE KEY-----\nAAAA\n-----END RSA PRIVATE KEY-----"),
);
expect(fehler.code).toBe("nicht_unterstuetzt");
});
it("weist Unsinn ab", () => {
expect(fehlerVon(() => privateKeyLesen("hallo")).code).toBe("ungueltig");
});
it("enthält in keiner Fehlermeldung Schlüsselmaterial oder die Passphrase", () => {
const pem = paare["rsaPass"]!.privateKeyOpenssh;
const rumpf = pemRumpf(pem);
const meldungen = [
fehlerVon(() => privateKeyLesen(pem, "falsch")).message,
fehlerVon(() => privateKeyLesen(pem)).message,
fehlerVon(() => privateKeyLesen(pem.slice(0, 400))).message,
fehlerVon(() => privateKeyLesen(pem.replace("A", "!"))).message,
fehlerVon(() => privateKeyLesen(pem.replace("OPENSSH", "RSA"))).message,
];
for (const meldung of meldungen) {
expect(enthaeltMaterial(meldung, rumpf)).toBe(false);
expect(meldung).not.toContain(PASSPHRASE);
expect(meldung).not.toContain("-----BEGIN");
}
});
});
describe("publicKeyZeileLesen", () => {
it.each([
["ssh-ed25519", ed25519Blob, 256],
["ssh-rsa", () => rsaBlob(2048), 2048],
["ecdsa-sha2-nistp256", () => ecdsaBlob("nistp256"), 256],
["ecdsa-sha2-nistp384", () => ecdsaBlob("nistp384"), 384],
["ecdsa-sha2-nistp521", () => ecdsaBlob("nistp521"), 521],
["sk-ssh-ed25519@openssh.com", skEd25519Blob, 256],
["sk-ecdsa-sha2-nistp256@openssh.com", skEcdsaBlob, 256],
] as const)("liest %s", (typ, blobBauen, bits) => {
const blob = blobBauen();
const info = publicKeyZeileLesen(zeile(typ, blob, "jemand@rechner mit leerzeichen"));
expect(info).toMatchObject({ typ, kommentar: "jemand@rechner mit leerzeichen", bits });
expect(info.blob.equals(blob)).toBe(true);
});
it("akzeptiert Zeilen ohne Kommentar", () => {
expect(publicKeyZeileLesen(`ssh-ed25519 ${ed25519Blob().toString("base64")}`).kommentar).toBe("");
});
it("lehnt einen Blob ab, dessen Typ nicht zur Zeile passt", () => {
expect(fehlerVon(() => publicKeyZeileLesen(zeile("ssh-rsa", ed25519Blob()))).code).toBe("ungueltig");
expect(
fehlerVon(() => publicKeyZeileLesen(zeile("ecdsa-sha2-nistp384", ecdsaBlob("nistp256")))).code,
).toBe("ungueltig");
});
it("lehnt RSA unter 2048 Bit ab, außer der Aufrufer erlaubt es", () => {
const kurz = zeile("ssh-rsa", rsaBlob(1024));
const fehler = fehlerVon(() => publicKeyZeileLesen(kurz));
expect(fehler.code).toBe("zu_schwach");
expect(fehler.message).toContain("2048");
expect(publicKeyZeileLesen(kurz, { rsaMindestBits: 0 }).bits).toBe(1024);
});
it("lehnt unbekannte Typen, kaputtes Base64 und angehängte Bytes ab", () => {
expect(fehlerVon(() => publicKeyZeileLesen("ssh-dss AAAAB3NzaC1kc3M= x")).code).toBe(
"nicht_unterstuetzt",
);
expect(fehlerVon(() => publicKeyZeileLesen("ssh-ed25519 AAA*AAA x")).code).toBe("ungueltig");
expect(fehlerVon(() => publicKeyZeileLesen("ssh-ed25519")).code).toBe("ungueltig");
const mitRest = Buffer.concat([ed25519Blob(), sshString("x")]);
expect(fehlerVon(() => publicKeyZeileLesen(zeile("ssh-ed25519", mitRest))).code).toBe("ungueltig");
const abgeschnitten = ed25519Blob().subarray(0, 30);
expect(fehlerVon(() => publicKeyZeileLesen(zeile("ssh-ed25519", abgeschnitten))).code).toBe("ungueltig");
});
it("lehnt Steuerzeichen im Kommentar ab", () => {
expect(fehlerVon(() => publicKeyZeileLesen(zeile("ssh-ed25519", ed25519Blob(), "a\u0000b"))).code).toBe(
"ungueltig",
);
});
});
describe("fingerprintSha256 und bitsVonRsa", () => {
it("ist SHA-256 über den Blob, Base64 ohne Padding", () => {
const blob = ed25519Blob();
const erwartet = createHash("sha256").update(blob).digest("base64").replace(/=+$/, "");
expect(fingerprintSha256(blob)).toBe(`SHA256:${erwartet}`);
expect(fingerprintSha256(blob)).not.toContain("=");
});
it("zählt die Bits des RSA-Moduls", () => {
expect(bitsVonRsa(rsaBlob(2048))).toBe(2048);
expect(bitsVonRsa(rsaBlob(3072))).toBe(3072);
expect(() => bitsVonRsa(ed25519Blob())).toThrow(SchluesselFormatFehler);
});
});