Merge branch 'phase/006-geheimnisse-schluesselformat' (Kern)
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { GESCHWAERZT, schwaerzen } from "@/lib/protokoll-schwaerzen";
|
||||
|
||||
const KEY =
|
||||
"-----BEGIN OPENSSH PRIVATE KEY-----\n" +
|
||||
"b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\n" +
|
||||
"-----END OPENSSH PRIVATE KEY-----";
|
||||
|
||||
describe("schwaerzen", () => {
|
||||
it("ersetzt geheime Felder in verschachtelten Objekten und Arrays", () => {
|
||||
const eingabe = {
|
||||
name: "backup",
|
||||
privateKeyOpenssh: "irgendwas",
|
||||
privatVerschluesselt: Buffer.from("x"),
|
||||
einstellungen: {
|
||||
passwort: "hunter2",
|
||||
Password: "hunter2",
|
||||
sshPassphrase: "abc",
|
||||
client_secret: "s",
|
||||
geheimnisWert: { tief: 1 },
|
||||
accessToken: "t",
|
||||
},
|
||||
liste: [{ token: "t", erlaubt: "ja" }, "frei", 3],
|
||||
};
|
||||
expect(schwaerzen(eingabe)).toEqual({
|
||||
name: "backup",
|
||||
privateKeyOpenssh: GESCHWAERZT,
|
||||
privatVerschluesselt: GESCHWAERZT,
|
||||
einstellungen: {
|
||||
passwort: GESCHWAERZT,
|
||||
Password: GESCHWAERZT,
|
||||
sshPassphrase: GESCHWAERZT,
|
||||
client_secret: GESCHWAERZT,
|
||||
geheimnisWert: GESCHWAERZT,
|
||||
accessToken: GESCHWAERZT,
|
||||
},
|
||||
liste: [{ token: GESCHWAERZT, erlaubt: "ja" }, "frei", 3],
|
||||
});
|
||||
// Eingabe bleibt unberuehrt.
|
||||
expect(eingabe.einstellungen.passwort).toBe("hunter2");
|
||||
});
|
||||
|
||||
it("findet Private Keys in beliebigen Zeichenketten", () => {
|
||||
const ergebnis = schwaerzen({ notiz: `vorher ${KEY} nachher`, tief: [[KEY]] }) as {
|
||||
notiz: string;
|
||||
tief: string[][];
|
||||
};
|
||||
expect(ergebnis.notiz).toBe(`vorher ${GESCHWAERZT} nachher`);
|
||||
expect(ergebnis.tief[0]![0]).toBe(GESCHWAERZT);
|
||||
expect(JSON.stringify(ergebnis)).not.toContain("b3BlbnNzaC1rZXkt");
|
||||
});
|
||||
|
||||
it("schwärzt Keys in Fehlermeldungen und Stacktraces", () => {
|
||||
const fehler = new Error(`Parser scheiterte an ${KEY}`, { cause: { passphrase: "x", text: KEY } });
|
||||
const ergebnis = schwaerzen({ fehler }) as { fehler: Record<string, unknown> };
|
||||
expect(ergebnis.fehler["name"]).toBe("Error");
|
||||
expect(ergebnis.fehler["message"]).toBe(`Parser scheiterte an ${GESCHWAERZT}`);
|
||||
expect(JSON.stringify(ergebnis)).not.toContain("b3BlbnNzaC1rZXkt");
|
||||
expect(JSON.stringify(ergebnis)).not.toContain("PRIVATE KEY");
|
||||
expect(ergebnis.fehler["cause"]).toEqual({ passphrase: GESCHWAERZT, text: GESCHWAERZT });
|
||||
});
|
||||
|
||||
it("schwärzt auch abgeschnittene Keys ohne END-Zeile", () => {
|
||||
const abgeschnitten = KEY.slice(0, 60);
|
||||
expect(schwaerzen(`log: ${abgeschnitten}`)).toBe(`log: ${GESCHWAERZT}`);
|
||||
});
|
||||
|
||||
it("schwärzt andere PEM-Private-Keys (PKCS#1, PKCS#8)", () => {
|
||||
for (const art of ["RSA PRIVATE KEY", "PRIVATE KEY", "ENCRYPTED PRIVATE KEY"]) {
|
||||
const pem = `-----BEGIN ${art}-----\nMIIEow\n-----END ${art}-----`;
|
||||
expect(schwaerzen(pem)).toBe(GESCHWAERZT);
|
||||
}
|
||||
});
|
||||
|
||||
it("lässt Public Keys und harmlose Werte stehen", () => {
|
||||
const wert = {
|
||||
publicKeyZeile: "ssh-ed25519 AAAAC3Nza x",
|
||||
fingerprint: "SHA256:abc",
|
||||
anzahl: 3,
|
||||
aktiv: true,
|
||||
leer: null,
|
||||
zeit: new Date(0),
|
||||
};
|
||||
expect(schwaerzen(wert)).toEqual(wert);
|
||||
expect(schwaerzen("-----BEGIN CERTIFICATE-----")).toBe("-----BEGIN CERTIFICATE-----");
|
||||
});
|
||||
|
||||
it("ersetzt rohe Bytes und übersteht Zyklen", () => {
|
||||
const zyklisch: Record<string, unknown> = { daten: Buffer.from("geheim") };
|
||||
zyklisch["selbst"] = zyklisch;
|
||||
expect(schwaerzen(zyklisch)).toEqual({ daten: "[Binärdaten]", selbst: "[zirkulär]" });
|
||||
});
|
||||
|
||||
it("zeigt mehrfach referenzierte Teilbäume vollständig", () => {
|
||||
const geteilt = { a: 1 };
|
||||
expect(schwaerzen({ x: geteilt, y: geteilt })).toEqual({ x: { a: 1 }, y: { a: 1 } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
EntschluesselungsFehler,
|
||||
MasterKeyFehler,
|
||||
masterKeyLaden,
|
||||
oeffnen,
|
||||
UNSICHER_ERLAUBT_VARIABLE,
|
||||
versiegeln,
|
||||
} from "@/lib/krypto/umschlag";
|
||||
|
||||
const ZUSATZ = "schluessel:k_8f3a:SHA256:abc";
|
||||
const KLARTEXT =
|
||||
"-----BEGIN OPENSSH PRIVATE KEY-----\nnur ein Platzhalter\n-----END OPENSSH PRIVATE KEY-----\n";
|
||||
|
||||
describe("versiegeln und oeffnen", () => {
|
||||
const masterKey = randomBytes(32);
|
||||
|
||||
it("Rundreise liefert den Klartext", () => {
|
||||
const umschlag = versiegeln(Buffer.from(KLARTEXT), masterKey, ZUSATZ);
|
||||
expect(umschlag.masterKeyVersion).toBe(1);
|
||||
expect(oeffnen(umschlag, masterKey, ZUSATZ).toString()).toBe(KLARTEXT);
|
||||
});
|
||||
|
||||
it("hält das dokumentierte Format ein", () => {
|
||||
const umschlag = versiegeln(Buffer.from(KLARTEXT), masterKey, ZUSATZ, 3);
|
||||
expect(umschlag.chiffrat[0]).toBe(1);
|
||||
expect(umschlag.chiffrat.length).toBe(1 + 12 + 16 + Buffer.byteLength(KLARTEXT));
|
||||
expect(umschlag.dekVerschluesselt.length).toBe(1 + 12 + 16 + 32);
|
||||
expect(umschlag.masterKeyVersion).toBe(3);
|
||||
expect(umschlag.chiffrat.toString("latin1")).not.toContain("Platzhalter");
|
||||
});
|
||||
|
||||
it("liefert bei jedem Versiegeln ein anderes Chiffrat", () => {
|
||||
const a = versiegeln(Buffer.from(KLARTEXT), masterKey, ZUSATZ);
|
||||
const b = versiegeln(Buffer.from(KLARTEXT), masterKey, ZUSATZ);
|
||||
expect(a.chiffrat.equals(b.chiffrat)).toBe(false);
|
||||
expect(a.dekVerschluesselt.equals(b.dekVerschluesselt)).toBe(false);
|
||||
});
|
||||
|
||||
it("lehnt einen falschen Master-Key ab", () => {
|
||||
const umschlag = versiegeln(Buffer.from(KLARTEXT), masterKey, ZUSATZ);
|
||||
expect(() => oeffnen(umschlag, randomBytes(32), ZUSATZ)).toThrow(EntschluesselungsFehler);
|
||||
});
|
||||
|
||||
it("lehnt ein falsches AAD ab (vertauschte Datensätze)", () => {
|
||||
const umschlag = versiegeln(Buffer.from(KLARTEXT), masterKey, ZUSATZ);
|
||||
expect(() => oeffnen(umschlag, masterKey, "schluessel:k_anders:SHA256:abc")).toThrow(
|
||||
EntschluesselungsFehler,
|
||||
);
|
||||
});
|
||||
|
||||
it("lehnt eine falsche Master-Key-Version ab", () => {
|
||||
const umschlag = versiegeln(Buffer.from(KLARTEXT), masterKey, ZUSATZ, 1);
|
||||
expect(() => oeffnen({ ...umschlag, masterKeyVersion: 2 }, masterKey, ZUSATZ)).toThrow(
|
||||
EntschluesselungsFehler,
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["chiffrat", "dekVerschluesselt"] as const)("erkennt jeden Bit-Flip in %s", (feld) => {
|
||||
const umschlag = versiegeln(Buffer.from(KLARTEXT), masterKey, ZUSATZ);
|
||||
// Version, IV, Tag und Daten: jede Stelle muss auffallen.
|
||||
for (const stelle of [0, 1, 13, 29, umschlag[feld].length - 1]) {
|
||||
const kaputt = Buffer.from(umschlag[feld]);
|
||||
kaputt[stelle]! ^= 0x01;
|
||||
expect(() => oeffnen({ ...umschlag, [feld]: kaputt }, masterKey, ZUSATZ)).toThrow(
|
||||
EntschluesselungsFehler,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("lehnt abgeschnittene Puffer ab", () => {
|
||||
const umschlag = versiegeln(Buffer.from(KLARTEXT), masterKey, ZUSATZ);
|
||||
expect(() =>
|
||||
oeffnen({ ...umschlag, chiffrat: umschlag.chiffrat.subarray(0, 20) }, masterKey, ZUSATZ),
|
||||
).toThrow(EntschluesselungsFehler);
|
||||
});
|
||||
|
||||
it("verlangt einen 32-Byte-Master-Key", () => {
|
||||
expect(() => versiegeln(Buffer.from("x"), randomBytes(16), ZUSATZ)).toThrow(MasterKeyFehler);
|
||||
});
|
||||
|
||||
it("nennt in Fehlermeldungen weder Klartext noch Schlüssel", () => {
|
||||
const umschlag = versiegeln(Buffer.from(KLARTEXT), masterKey, ZUSATZ);
|
||||
try {
|
||||
oeffnen(umschlag, randomBytes(32), ZUSATZ);
|
||||
expect.unreachable();
|
||||
} catch (fehler) {
|
||||
const text = String((fehler as Error).message) + String((fehler as Error).stack);
|
||||
expect(text).not.toContain("Platzhalter");
|
||||
expect(text).not.toContain(masterKey.toString("hex"));
|
||||
expect(text).not.toContain(masterKey.toString("base64"));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("masterKeyLaden", () => {
|
||||
let verzeichnis: string;
|
||||
const schluessel = randomBytes(32);
|
||||
|
||||
beforeAll(async () => {
|
||||
verzeichnis = await mkdtemp(join(tmpdir(), "sshkv-masterkey-"));
|
||||
});
|
||||
afterAll(async () => {
|
||||
await rm(verzeichnis, { recursive: true, force: true });
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function datei(name: string, inhalt: Buffer | string, modus = 0o600): Promise<string> {
|
||||
const pfad = join(verzeichnis, name);
|
||||
await writeFile(pfad, inhalt);
|
||||
await chmod(pfad, modus);
|
||||
return pfad;
|
||||
}
|
||||
|
||||
it.each([
|
||||
["roh", schluessel],
|
||||
["base64", schluessel.toString("base64")],
|
||||
["base64 mit Zeilenende", schluessel.toString("base64") + "\n"],
|
||||
["hex", schluessel.toString("hex")],
|
||||
["hex mit CRLF", schluessel.toString("hex") + "\r\n"],
|
||||
])("liest %s", async (name, inhalt) => {
|
||||
const geladen = await masterKeyLaden(await datei(name, inhalt));
|
||||
expect(geladen.equals(schluessel)).toBe(true);
|
||||
});
|
||||
|
||||
it("lehnt falsche Längen ab", async () => {
|
||||
await expect(masterKeyLaden(await datei("kurz", randomBytes(31)))).rejects.toThrow(MasterKeyFehler);
|
||||
await expect(masterKeyLaden(await datei("lang", randomBytes(33)))).rejects.toThrow(MasterKeyFehler);
|
||||
await expect(masterKeyLaden(await datei("hex-kurz", randomBytes(31).toString("hex")))).rejects.toThrow(
|
||||
"32-Byte-Schlüssel",
|
||||
);
|
||||
});
|
||||
|
||||
it("lehnt einen Schlüssel aus lauter Nullbytes ab", async () => {
|
||||
await expect(masterKeyLaden(await datei("null", Buffer.alloc(32)))).rejects.toThrow("Nullbytes");
|
||||
});
|
||||
|
||||
it("lehnt eine fehlende Datei ab", async () => {
|
||||
await expect(masterKeyLaden(join(verzeichnis, "gibt-es-nicht"))).rejects.toThrow(MasterKeyFehler);
|
||||
});
|
||||
|
||||
it("verweigert Dateien, die Gruppe oder Welt lesen dürfen", async () => {
|
||||
for (const modus of [0o640, 0o604, 0o644]) {
|
||||
const pfad = await datei(`offen-${modus.toString(8)}`, schluessel, modus);
|
||||
await expect(masterKeyLaden(pfad)).rejects.toThrow("chmod 600");
|
||||
}
|
||||
expect((await masterKeyLaden(await datei("nur-lesen", schluessel, 0o400))).equals(schluessel)).toBe(true);
|
||||
});
|
||||
|
||||
it("lädt offene Dateien nur mit ausdrücklicher Erlaubnis und warnt dann", async () => {
|
||||
vi.stubEnv(UNSICHER_ERLAUBT_VARIABLE, "1");
|
||||
const warnung = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const geladen = await masterKeyLaden(await datei("offen-erlaubt", schluessel.toString("hex"), 0o644));
|
||||
expect(geladen.equals(schluessel)).toBe(true);
|
||||
expect(warnung).toHaveBeenCalledOnce();
|
||||
const ausgabe = String(warnung.mock.calls[0]?.[0]);
|
||||
expect(ausgabe).not.toContain(schluessel.toString("hex"));
|
||||
expect(ausgabe).not.toContain(schluessel.toString("base64"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { generateKeyPairSync } from "node:crypto";
|
||||
|
||||
/** Kleine Wire-Format-Helfer, um gezielt kaputte oder seltene Blobs zu bauen (#006). */
|
||||
export function sshString(inhalt: Buffer | string): Buffer {
|
||||
const daten = typeof inhalt === "string" ? Buffer.from(inhalt) : inhalt;
|
||||
const laenge = Buffer.alloc(4);
|
||||
laenge.writeUInt32BE(daten.length);
|
||||
return Buffer.concat([laenge, daten]);
|
||||
}
|
||||
|
||||
export function mpint(betrag: Buffer): Buffer {
|
||||
return sshString((betrag[0]! & 0x80) !== 0 ? Buffer.concat([Buffer.from([0]), betrag]) : betrag);
|
||||
}
|
||||
|
||||
export function zeile(typ: string, blob: Buffer, kommentar = "test"): string {
|
||||
return `${typ} ${blob.toString("base64")} ${kommentar}`;
|
||||
}
|
||||
|
||||
export function rsaBlob(bits: number): Buffer {
|
||||
const { publicKey } = generateKeyPairSync("rsa", { modulusLength: bits });
|
||||
const jwk = publicKey.export({ format: "jwk" });
|
||||
return Buffer.concat([
|
||||
sshString("ssh-rsa"),
|
||||
mpint(Buffer.from(jwk.e!, "base64url")),
|
||||
mpint(Buffer.from(jwk.n!, "base64url")),
|
||||
]);
|
||||
}
|
||||
|
||||
const KURVEN = { nistp256: "P-256", nistp384: "P-384", nistp521: "P-521" } as const;
|
||||
|
||||
export function ecdsaPunkt(kurve: keyof typeof KURVEN): Buffer {
|
||||
const { publicKey } = generateKeyPairSync("ec", { namedCurve: KURVEN[kurve] });
|
||||
const jwk = publicKey.export({ format: "jwk" });
|
||||
return Buffer.concat([
|
||||
Buffer.from([4]),
|
||||
Buffer.from(jwk.x!, "base64url"),
|
||||
Buffer.from(jwk.y!, "base64url"),
|
||||
]);
|
||||
}
|
||||
|
||||
export function ecdsaBlob(kurve: keyof typeof KURVEN): Buffer {
|
||||
return Buffer.concat([sshString(`ecdsa-sha2-${kurve}`), sshString(kurve), sshString(ecdsaPunkt(kurve))]);
|
||||
}
|
||||
|
||||
export function ed25519Roh(): Buffer {
|
||||
const { publicKey } = generateKeyPairSync("ed25519");
|
||||
return Buffer.from(publicKey.export({ format: "jwk" }).x!, "base64url");
|
||||
}
|
||||
|
||||
export function ed25519Blob(): Buffer {
|
||||
return Buffer.concat([sshString("ssh-ed25519"), sshString(ed25519Roh())]);
|
||||
}
|
||||
|
||||
export function skEd25519Blob(): Buffer {
|
||||
return Buffer.concat([sshString("sk-ssh-ed25519@openssh.com"), sshString(ed25519Roh()), sshString("ssh:")]);
|
||||
}
|
||||
|
||||
export function skEcdsaBlob(): Buffer {
|
||||
return Buffer.concat([
|
||||
sshString("sk-ecdsa-sha2-nistp256@openssh.com"),
|
||||
sshString("nistp256"),
|
||||
sshString(ecdsaPunkt("nistp256")),
|
||||
sshString("ssh:"),
|
||||
]);
|
||||
}
|
||||
|
||||
/** Base64-Rumpf eines PEM-Textes, um Fehlermeldungen auf Schluesselmaterial zu pruefen. */
|
||||
export function pemRumpf(pem: string): string {
|
||||
return pem
|
||||
.split("\n")
|
||||
.filter((z) => z && !z.startsWith("-----"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
/** Ob `text` irgendein 16-Zeichen-Stueck aus `material` enthaelt. */
|
||||
export function enthaeltMaterial(text: string, material: string): boolean {
|
||||
for (let i = 0; i + 16 <= material.length; i += 4) {
|
||||
if (text.includes(material.slice(i, i + 16))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -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)));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,227 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user