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:
@@ -47,6 +47,7 @@
|
|||||||
"@node-rs/argon2": "^2.2.2",
|
"@node-rs/argon2": "^2.2.2",
|
||||||
"@prisma/adapter-pg": "7.10.0",
|
"@prisma/adapter-pg": "7.10.0",
|
||||||
"@prisma/client": "7.10.0",
|
"@prisma/client": "7.10.0",
|
||||||
|
"bcrypt-pbkdf": "^1.0.2",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"jose": "^6.2.12",
|
"jose": "^6.2.12",
|
||||||
"lucide-react": "1.47.0",
|
"lucide-react": "1.47.0",
|
||||||
|
|||||||
Generated
+3
@@ -184,6 +184,9 @@ importers:
|
|||||||
'@prisma/client':
|
'@prisma/client':
|
||||||
specifier: 7.10.0
|
specifier: 7.10.0
|
||||||
version: 7.10.0(prisma@7.10.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@6.0.3))(typescript@6.0.3)
|
version: 7.10.0(prisma@7.10.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@6.0.3))(typescript@6.0.3)
|
||||||
|
bcrypt-pbkdf:
|
||||||
|
specifier: ^1.0.2
|
||||||
|
version: 1.0.2
|
||||||
clsx:
|
clsx:
|
||||||
specifier: ^2.1.1
|
specifier: ^2.1.1
|
||||||
version: 2.1.1
|
version: 2.1.1
|
||||||
|
|||||||
@@ -0,0 +1,682 @@
|
|||||||
|
import {
|
||||||
|
createCipheriv,
|
||||||
|
createDecipheriv,
|
||||||
|
createHash,
|
||||||
|
createPrivateKey,
|
||||||
|
createPublicKey,
|
||||||
|
generateKeyPair,
|
||||||
|
randomBytes,
|
||||||
|
timingSafeEqual,
|
||||||
|
} from "node:crypto";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import bcryptPbkdf from "bcrypt-pbkdf";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenSSH-Schluesselformate (#006): Erzeugen, Schreiben und Lesen von Private Keys im
|
||||||
|
* Format `openssh-key-v1` sowie Lesen von Public-Key-Zeilen.
|
||||||
|
*
|
||||||
|
* Bewusst ohne `ssh-keygen`: Der Aufruf braeuchte temporaere Dateien mit dem Private Key
|
||||||
|
* auf der Platte des Servers, und genau die wollen wir nicht (siehe docs/PLAN.md). Node
|
||||||
|
* `crypto` erzeugt die Schluessel, die Serialisierung ist hier von Hand nachgebaut
|
||||||
|
* (Referenz: PROTOCOL.key und sshkey.c in OpenSSH). Die Tests pruefen das Ergebnis gegen
|
||||||
|
* das echte `ssh-keygen`.
|
||||||
|
*
|
||||||
|
* Fehlermeldungen sind feste Saetze. Nichts aus der Eingabe wird in eine Meldung
|
||||||
|
* eingesetzt, weil ein Private Key sonst ueber Log oder Oberflaeche nach aussen geraten
|
||||||
|
* koennte.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const erzeugeSchluesselpaar = promisify(generateKeyPair);
|
||||||
|
|
||||||
|
export type ErzeugbarerSchluesseltyp = "ED25519" | "RSA4096";
|
||||||
|
export type PrivateSchluesseltyp = "ED25519" | "RSA";
|
||||||
|
export type PublicSchluesseltyp =
|
||||||
|
| "ssh-ed25519"
|
||||||
|
| "ssh-rsa"
|
||||||
|
| "ecdsa-sha2-nistp256"
|
||||||
|
| "ecdsa-sha2-nistp384"
|
||||||
|
| "ecdsa-sha2-nistp521"
|
||||||
|
| "sk-ssh-ed25519@openssh.com"
|
||||||
|
| "sk-ecdsa-sha2-nistp256@openssh.com";
|
||||||
|
|
||||||
|
export const PUBLIC_SCHLUESSELTYPEN: readonly PublicSchluesseltyp[] = [
|
||||||
|
"ssh-ed25519",
|
||||||
|
"ssh-rsa",
|
||||||
|
"ecdsa-sha2-nistp256",
|
||||||
|
"ecdsa-sha2-nistp384",
|
||||||
|
"ecdsa-sha2-nistp521",
|
||||||
|
"sk-ssh-ed25519@openssh.com",
|
||||||
|
"sk-ecdsa-sha2-nistp256@openssh.com",
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Unterhalb davon gilt RSA als gebrochen bzw. wird von aktuellem OpenSSH abgelehnt. */
|
||||||
|
export const RSA_MINDEST_BITS = 2048;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runden fuer bcrypt_pbkdf beim Schreiben. `ssh-keygen` nimmt inzwischen 24; 16 war der
|
||||||
|
* fruehere Standard und haelt das Verschluesseln im Worker unter einer Sekunde, obwohl
|
||||||
|
* bcrypt-pbkdf reines JavaScript ist. Beim Lesen gilt, was in der Datei steht.
|
||||||
|
*/
|
||||||
|
const BCRYPT_RUNDEN = 16;
|
||||||
|
const BCRYPT_SALT_LAENGE = 16;
|
||||||
|
|
||||||
|
/** Grenze gegen absichtlich teure Dateien beim Import (ssh-keygen -a erlaubt beliebig viel). */
|
||||||
|
const BCRYPT_RUNDEN_HOECHSTENS = 1000;
|
||||||
|
|
||||||
|
const MAGIC = Buffer.from("openssh-key-v1\0", "latin1");
|
||||||
|
const PEM_ANFANG = "-----BEGIN OPENSSH PRIVATE KEY-----";
|
||||||
|
const PEM_ENDE = "-----END OPENSSH PRIVATE KEY-----";
|
||||||
|
|
||||||
|
export type SchluesselFormatFehlerCode =
|
||||||
|
"ungueltig" | "nicht_unterstuetzt" | "passphrase_fehlt" | "passphrase_falsch" | "zu_schwach";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fehler beim Lesen oder Erzeugen eines Schluessels. `code` erlaubt der Oberflaeche,
|
||||||
|
* gezielt zu reagieren (etwa nach der Passphrase zu fragen), ohne Meldungstexte zu
|
||||||
|
* vergleichen. Die Meldung enthaelt nie Schluesselmaterial.
|
||||||
|
*/
|
||||||
|
export class SchluesselFormatFehler extends Error {
|
||||||
|
override readonly name = "SchluesselFormatFehler";
|
||||||
|
constructor(
|
||||||
|
readonly code: SchluesselFormatFehlerCode,
|
||||||
|
meldung: string,
|
||||||
|
) {
|
||||||
|
super(meldung);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ungueltig(meldung = "Der Schlüssel ist beschädigt oder unvollständig."): SchluesselFormatFehler {
|
||||||
|
return new SchluesselFormatFehler("ungueltig", meldung);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SSH-Wire-Format (RFC 4251, Abschnitt 5)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function uint32(wert: number): Buffer {
|
||||||
|
const puffer = Buffer.alloc(4);
|
||||||
|
puffer.writeUInt32BE(wert >>> 0);
|
||||||
|
return puffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sshString(inhalt: Buffer | string): Buffer {
|
||||||
|
const daten = typeof inhalt === "string" ? Buffer.from(inhalt, "utf8") : inhalt;
|
||||||
|
return Buffer.concat([uint32(daten.length), daten]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vorzeichenbehaftete Big-Endian-Zahl ohne fuehrende Nullen, bei gesetztem Hochbit mit 0x00 davor. */
|
||||||
|
function mpint(betrag: Buffer): Buffer {
|
||||||
|
let start = 0;
|
||||||
|
while (start < betrag.length && betrag[start] === 0) start++;
|
||||||
|
const ohneNullen = betrag.subarray(start);
|
||||||
|
if (ohneNullen.length > 0 && (ohneNullen[0]! & 0x80) !== 0) {
|
||||||
|
return sshString(Buffer.concat([Buffer.from([0]), ohneNullen]));
|
||||||
|
}
|
||||||
|
return sshString(ohneNullen);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liest SSH-Wire-Daten. Jeder Zugriff hinter das Ende wirft einen SchluesselFormatFehler,
|
||||||
|
* damit abgeschnittene Eingaben nie zu RangeErrors mit Pufferausschnitten werden.
|
||||||
|
*/
|
||||||
|
class Leser {
|
||||||
|
private position = 0;
|
||||||
|
constructor(private readonly puffer: Buffer) {}
|
||||||
|
|
||||||
|
uint32(): number {
|
||||||
|
if (this.position + 4 > this.puffer.length) throw ungueltig();
|
||||||
|
const wert = this.puffer.readUInt32BE(this.position);
|
||||||
|
this.position += 4;
|
||||||
|
return wert;
|
||||||
|
}
|
||||||
|
|
||||||
|
bytes(laenge: number): Buffer {
|
||||||
|
if (laenge < 0 || this.position + laenge > this.puffer.length) throw ungueltig();
|
||||||
|
const teil = this.puffer.subarray(this.position, this.position + laenge);
|
||||||
|
this.position += laenge;
|
||||||
|
return teil;
|
||||||
|
}
|
||||||
|
|
||||||
|
string(): Buffer {
|
||||||
|
return this.bytes(this.uint32());
|
||||||
|
}
|
||||||
|
|
||||||
|
text(): string {
|
||||||
|
return this.string().toString("utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Liefert den Betrag einer nicht-negativen mpint ohne fuehrende Nullen. */
|
||||||
|
mpint(): Buffer {
|
||||||
|
const roh = this.string();
|
||||||
|
if (roh.length > 0 && (roh[0]! & 0x80) !== 0) throw ungueltig();
|
||||||
|
let start = 0;
|
||||||
|
while (start < roh.length && roh[start] === 0) start++;
|
||||||
|
return roh.subarray(start);
|
||||||
|
}
|
||||||
|
|
||||||
|
rest(): Buffer {
|
||||||
|
return this.puffer.subarray(this.position);
|
||||||
|
}
|
||||||
|
|
||||||
|
amEnde(): boolean {
|
||||||
|
return this.position === this.puffer.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function bitsVonBetrag(betrag: Buffer): number {
|
||||||
|
if (betrag.length === 0) return 0;
|
||||||
|
return (betrag.length - 1) * 8 + (32 - Math.clz32(betrag[0]!));
|
||||||
|
}
|
||||||
|
|
||||||
|
function alsBigInt(betrag: Buffer): bigint {
|
||||||
|
return betrag.length === 0 ? 0n : BigInt("0x" + betrag.toString("hex"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Fingerprint und Public Keys
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** SHA-256-Fingerprint im Format von `ssh-keygen -l`: Base64 ohne Padding. */
|
||||||
|
export function fingerprintSha256(blob: Buffer): string {
|
||||||
|
return "SHA256:" + createHash("sha256").update(blob).digest("base64").replace(/=+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schluessellaenge eines `ssh-rsa`-Blobs in Bit (Laenge des Moduls n). */
|
||||||
|
export function bitsVonRsa(blob: Buffer): number {
|
||||||
|
const leser = new Leser(blob);
|
||||||
|
if (leser.text() !== "ssh-rsa") {
|
||||||
|
throw new SchluesselFormatFehler("ungueltig", "Der Schlüssel ist kein RSA-Schlüssel.");
|
||||||
|
}
|
||||||
|
leser.mpint(); // e
|
||||||
|
return bitsVonBetrag(leser.mpint());
|
||||||
|
}
|
||||||
|
|
||||||
|
const ECDSA_PUNKTLAENGE: Record<string, number> = {
|
||||||
|
nistp256: 65,
|
||||||
|
nistp384: 97,
|
||||||
|
nistp521: 133,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prueft den Aufbau eines Public-Key-Blobs. Erwartet wird genau der Typ `typ`; zusaetzliche
|
||||||
|
* Bytes am Ende gelten als Fehler, weil OpenSSH sie ebenfalls ablehnt.
|
||||||
|
*/
|
||||||
|
function publicBlobPruefen(typ: PublicSchluesseltyp, blob: Buffer, rsaMindestBits: number): number | null {
|
||||||
|
const leser = new Leser(blob);
|
||||||
|
if (leser.text() !== typ) {
|
||||||
|
throw new SchluesselFormatFehler(
|
||||||
|
"ungueltig",
|
||||||
|
"Der Schlüsseltyp in der Zeile passt nicht zum Inhalt des Schlüssels.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let bits: number | null = null;
|
||||||
|
switch (typ) {
|
||||||
|
case "ssh-ed25519":
|
||||||
|
if (leser.string().length !== 32) throw ungueltig();
|
||||||
|
bits = 256;
|
||||||
|
break;
|
||||||
|
case "sk-ssh-ed25519@openssh.com":
|
||||||
|
if (leser.string().length !== 32) throw ungueltig();
|
||||||
|
leser.string(); // application, z. B. "ssh:"
|
||||||
|
bits = 256;
|
||||||
|
break;
|
||||||
|
case "ssh-rsa": {
|
||||||
|
const e = leser.mpint();
|
||||||
|
const n = leser.mpint();
|
||||||
|
if (e.length === 0 || n.length === 0 || (e[e.length - 1]! & 1) === 0) throw ungueltig();
|
||||||
|
bits = bitsVonBetrag(n);
|
||||||
|
if (bits < rsaMindestBits) {
|
||||||
|
throw new SchluesselFormatFehler(
|
||||||
|
"zu_schwach",
|
||||||
|
`RSA-Schlüssel müssen mindestens ${rsaMindestBits} Bit lang sein.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "ecdsa-sha2-nistp256":
|
||||||
|
case "ecdsa-sha2-nistp384":
|
||||||
|
case "ecdsa-sha2-nistp521":
|
||||||
|
case "sk-ecdsa-sha2-nistp256@openssh.com": {
|
||||||
|
const erwarteteKurve = typ.startsWith("sk-") ? "nistp256" : typ.slice("ecdsa-sha2-".length);
|
||||||
|
if (leser.text() !== erwarteteKurve) throw ungueltig();
|
||||||
|
const punkt = leser.string();
|
||||||
|
if (punkt.length !== ECDSA_PUNKTLAENGE[erwarteteKurve] || punkt[0] !== 0x04) throw ungueltig();
|
||||||
|
if (typ.startsWith("sk-")) leser.string(); // application
|
||||||
|
bits = Number(erwarteteKurve.slice("nistp".length));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!leser.amEnde()) throw ungueltig();
|
||||||
|
return bits;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PublicKeyInfo {
|
||||||
|
typ: PublicSchluesseltyp;
|
||||||
|
blob: Buffer;
|
||||||
|
kommentar: string;
|
||||||
|
fingerprint: string;
|
||||||
|
/** Schluessellaenge in Bit (RSA: Modul, ECDSA: Kurve, ed25519: 256). */
|
||||||
|
bits: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PublicKeyLeseOptionen {
|
||||||
|
/**
|
||||||
|
* Mindestlaenge fuer RSA. Standard ist RSA_MINDEST_BITS (Import). Wer vorhandene
|
||||||
|
* authorized_keys nur beobachtet, setzt 0, damit alte Schluessel sichtbar bleiben.
|
||||||
|
*/
|
||||||
|
rsaMindestBits?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE64 = /^[A-Za-z0-9+/]+={0,2}$/;
|
||||||
|
|
||||||
|
function istPublicTyp(wert: string): wert is PublicSchluesseltyp {
|
||||||
|
return (PUBLIC_SCHLUESSELTYPEN as readonly string[]).includes(wert);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Steuerzeichen im Kommentar wuerden authorized_keys-Zeilen aufbrechen. */
|
||||||
|
function kommentarPruefen(kommentar: string): void {
|
||||||
|
if (/[\x00-\x1f\x7f]/.test(kommentar)) {
|
||||||
|
throw new SchluesselFormatFehler("ungueltig", "Der Kommentar enthält unzulässige Steuerzeichen.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liest eine Public-Key-Zeile `typ base64 [kommentar]` (ohne authorized_keys-Optionen).
|
||||||
|
*/
|
||||||
|
export function publicKeyZeileLesen(zeile: string, optionen: PublicKeyLeseOptionen = {}): PublicKeyInfo {
|
||||||
|
const treffer = /^(\S+)[ \t]+(\S+)(?:[ \t]+(.*))?$/.exec(zeile.trim());
|
||||||
|
if (!treffer) {
|
||||||
|
throw new SchluesselFormatFehler("ungueltig", "Die Zeile ist kein Public Key im OpenSSH-Format.");
|
||||||
|
}
|
||||||
|
const typText = treffer[1]!;
|
||||||
|
const base64 = treffer[2]!;
|
||||||
|
const kommentar = (treffer[3] ?? "").trim();
|
||||||
|
if (!istPublicTyp(typText)) {
|
||||||
|
throw new SchluesselFormatFehler("nicht_unterstuetzt", "Dieser Schlüsseltyp wird nicht unterstützt.");
|
||||||
|
}
|
||||||
|
if (!BASE64.test(base64) || base64.length % 4 !== 0) {
|
||||||
|
throw new SchluesselFormatFehler("ungueltig", "Der Public Key ist nicht korrekt Base64-kodiert.");
|
||||||
|
}
|
||||||
|
const blob = Buffer.from(base64, "base64");
|
||||||
|
kommentarPruefen(kommentar);
|
||||||
|
const bits = publicBlobPruefen(typText, blob, optionen.rsaMindestBits ?? RSA_MINDEST_BITS);
|
||||||
|
return { typ: typText, blob, kommentar, fingerprint: fingerprintSha256(blob), bits };
|
||||||
|
}
|
||||||
|
|
||||||
|
function publicKeyZeileBauen(blob: Buffer, typ: string, kommentar: string): string {
|
||||||
|
const basis = `${typ} ${blob.toString("base64")}`;
|
||||||
|
return kommentar ? `${basis} ${kommentar}` : basis;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Private Keys: Hilfen
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** 48 Byte aus bcrypt_pbkdf: 32 Byte AES-Schluessel, 16 Byte Zaehler-Startwert. */
|
||||||
|
function bcryptAbleiten(passphrase: string, salt: Buffer, runden: number): Buffer {
|
||||||
|
const passwortBytes = Buffer.from(passphrase, "utf8");
|
||||||
|
const ergebnis = Buffer.alloc(48);
|
||||||
|
try {
|
||||||
|
const rueckgabe = bcryptPbkdf.pbkdf(
|
||||||
|
passwortBytes,
|
||||||
|
passwortBytes.length,
|
||||||
|
salt,
|
||||||
|
salt.length,
|
||||||
|
ergebnis,
|
||||||
|
ergebnis.length,
|
||||||
|
runden,
|
||||||
|
);
|
||||||
|
if (rueckgabe !== 0) throw ungueltig();
|
||||||
|
return ergebnis;
|
||||||
|
} finally {
|
||||||
|
passwortBytes.fill(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** OpenSSH bricht die Base64-Zeilen nach 70 Zeichen um. */
|
||||||
|
function pemKodieren(datei: Buffer): string {
|
||||||
|
const base64 = datei.toString("base64");
|
||||||
|
const zeilen: string[] = [];
|
||||||
|
for (let i = 0; i < base64.length; i += 70) zeilen.push(base64.slice(i, i + 70));
|
||||||
|
return `${PEM_ANFANG}\n${zeilen.join("\n")}\n${PEM_ENDE}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pemDekodieren(text: string): Buffer {
|
||||||
|
const normalisiert = text.replace(/\r\n?/g, "\n").trim();
|
||||||
|
if (!normalisiert.startsWith(PEM_ANFANG)) {
|
||||||
|
if (/-----BEGIN [A-Z ]*PRIVATE KEY-----/.test(normalisiert)) {
|
||||||
|
throw new SchluesselFormatFehler(
|
||||||
|
"nicht_unterstuetzt",
|
||||||
|
"Nur Private Keys im OpenSSH-Format werden unterstützt („BEGIN OPENSSH PRIVATE KEY“).",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new SchluesselFormatFehler("ungueltig", "Der Text ist kein Private Key im OpenSSH-Format.");
|
||||||
|
}
|
||||||
|
if (!normalisiert.endsWith(PEM_ENDE)) {
|
||||||
|
throw ungueltig("Der Private Key ist unvollständig (Endzeile fehlt).");
|
||||||
|
}
|
||||||
|
const rumpf = normalisiert
|
||||||
|
.slice(PEM_ANFANG.length, normalisiert.length - PEM_ENDE.length)
|
||||||
|
.replace(/\s+/g, "");
|
||||||
|
if (!BASE64.test(rumpf) || rumpf.length % 4 !== 0) {
|
||||||
|
throw ungueltig("Der Private Key ist nicht korrekt Base64-kodiert.");
|
||||||
|
}
|
||||||
|
return Buffer.from(rumpf, "base64");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PKCS#8-Huelle fuer einen ed25519-Seed (RFC 8410), damit Node daraus den Public Key ableitet. */
|
||||||
|
const ED25519_PKCS8_PRAEFIX = Buffer.from("302e020100300506032b657004220420", "hex");
|
||||||
|
|
||||||
|
function ed25519PublicAusSeed(seed: Buffer): Buffer {
|
||||||
|
const der = Buffer.concat([ED25519_PKCS8_PRAEFIX, seed]);
|
||||||
|
try {
|
||||||
|
const jwk = createPublicKey(createPrivateKey({ key: der, format: "der", type: "pkcs8" })).export({
|
||||||
|
format: "jwk",
|
||||||
|
});
|
||||||
|
return Buffer.from(jwk.x ?? "", "base64url");
|
||||||
|
} catch {
|
||||||
|
throw ungueltig();
|
||||||
|
} finally {
|
||||||
|
der.fill(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function gleich(a: Buffer, b: Buffer): boolean {
|
||||||
|
return a.length === b.length && timingSafeEqual(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Private Keys: Erzeugen und Schreiben
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface SchluesselpaarOptionen {
|
||||||
|
kommentar: string;
|
||||||
|
/** Leer oder nicht gesetzt: unverschluesselt, wie `ssh-keygen -N ''`. */
|
||||||
|
passphrase?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ErzeugtesSchluesselpaar {
|
||||||
|
/** z. B. "ssh-ed25519 AAAA… kommentar" */
|
||||||
|
publicKeyZeile: string;
|
||||||
|
/** "-----BEGIN OPENSSH PRIVATE KEY-----…" */
|
||||||
|
privateKeyOpenssh: string;
|
||||||
|
/** "SHA256:…" ohne Padding */
|
||||||
|
fingerprint: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Baut die Datei `openssh-key-v1` (PROTOCOL.key). `privatTeil` sind die typabhaengigen
|
||||||
|
* Felder ohne Kommentar.
|
||||||
|
*/
|
||||||
|
function privateKeySchreiben(
|
||||||
|
publicBlob: Buffer,
|
||||||
|
privatTeil: Buffer,
|
||||||
|
kommentar: string,
|
||||||
|
passphrase: string | undefined,
|
||||||
|
): string {
|
||||||
|
const verschluesseln = passphrase !== undefined && passphrase !== "";
|
||||||
|
const blockgroesse = verschluesseln ? 16 : 8;
|
||||||
|
// Zweimal derselbe Zufallswert: Beim Lesen verraet ein Unterschied die falsche Passphrase.
|
||||||
|
const checkint = randomBytes(4);
|
||||||
|
const ohnePadding = Buffer.concat([checkint, checkint, privatTeil, sshString(kommentar)]);
|
||||||
|
const paddingLaenge = (blockgroesse - (ohnePadding.length % blockgroesse)) % blockgroesse;
|
||||||
|
const padding = Buffer.from(Array.from({ length: paddingLaenge }, (_, i) => i + 1));
|
||||||
|
const abschnitt = Buffer.concat([ohnePadding, padding]);
|
||||||
|
ohnePadding.fill(0);
|
||||||
|
|
||||||
|
let cipherName = "none";
|
||||||
|
let kdfName = "none";
|
||||||
|
let kdfOptionen = Buffer.alloc(0);
|
||||||
|
let inhalt = abschnitt;
|
||||||
|
if (verschluesseln) {
|
||||||
|
const salt = randomBytes(BCRYPT_SALT_LAENGE);
|
||||||
|
const abgeleitet = bcryptAbleiten(passphrase, salt, BCRYPT_RUNDEN);
|
||||||
|
try {
|
||||||
|
const cipher = createCipheriv("aes-256-ctr", abgeleitet.subarray(0, 32), abgeleitet.subarray(32, 48));
|
||||||
|
inhalt = Buffer.concat([cipher.update(abschnitt), cipher.final()]);
|
||||||
|
} finally {
|
||||||
|
abgeleitet.fill(0);
|
||||||
|
}
|
||||||
|
cipherName = "aes256-ctr";
|
||||||
|
kdfName = "bcrypt";
|
||||||
|
kdfOptionen = Buffer.concat([sshString(salt), uint32(BCRYPT_RUNDEN)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const datei = Buffer.concat([
|
||||||
|
MAGIC,
|
||||||
|
sshString(cipherName),
|
||||||
|
sshString(kdfName),
|
||||||
|
sshString(kdfOptionen),
|
||||||
|
uint32(1),
|
||||||
|
sshString(publicBlob),
|
||||||
|
sshString(inhalt),
|
||||||
|
]);
|
||||||
|
const pem = pemKodieren(datei);
|
||||||
|
datei.fill(0);
|
||||||
|
abschnitt.fill(0);
|
||||||
|
return pem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzeugt ein neues Schluesselpaar. Asynchron, weil RSA-4096 sonst den Worker fuer
|
||||||
|
* spuerbare Zeit blockiert. Der Private Key liegt danach als String vor; den kann
|
||||||
|
* JavaScript nicht ueberschreiben – Aufrufer sollen ihn sofort versiegeln.
|
||||||
|
*/
|
||||||
|
export async function schluesselpaarErzeugen(
|
||||||
|
typ: ErzeugbarerSchluesseltyp,
|
||||||
|
{ kommentar, passphrase }: SchluesselpaarOptionen,
|
||||||
|
): Promise<ErzeugtesSchluesselpaar> {
|
||||||
|
kommentarPruefen(kommentar);
|
||||||
|
let typName: string;
|
||||||
|
let publicBlob: Buffer;
|
||||||
|
let privatTeil: Buffer;
|
||||||
|
|
||||||
|
if (typ === "ED25519") {
|
||||||
|
typName = "ssh-ed25519";
|
||||||
|
const { privateKey } = await erzeugeSchluesselpaar("ed25519");
|
||||||
|
const jwk = privateKey.export({ format: "jwk" });
|
||||||
|
const oeffentlich = Buffer.from(jwk.x ?? "", "base64url");
|
||||||
|
const seed = Buffer.from(jwk.d ?? "", "base64url");
|
||||||
|
// OpenSSH speichert 64 Byte: Seed gefolgt vom Public Key (Format von ref10/NaCl).
|
||||||
|
const geheim = Buffer.concat([seed, oeffentlich]);
|
||||||
|
publicBlob = Buffer.concat([sshString(typName), sshString(oeffentlich)]);
|
||||||
|
privatTeil = Buffer.concat([sshString(typName), sshString(oeffentlich), sshString(geheim)]);
|
||||||
|
seed.fill(0);
|
||||||
|
geheim.fill(0);
|
||||||
|
} else {
|
||||||
|
typName = "ssh-rsa";
|
||||||
|
const { privateKey } = await erzeugeSchluesselpaar("rsa", {
|
||||||
|
modulusLength: 4096,
|
||||||
|
publicExponent: 0x10001,
|
||||||
|
});
|
||||||
|
const jwk = privateKey.export({ format: "jwk" });
|
||||||
|
const feld = (wert: string | undefined) => Buffer.from(wert ?? "", "base64url");
|
||||||
|
const n = feld(jwk.n);
|
||||||
|
const e = feld(jwk.e);
|
||||||
|
const d = feld(jwk.d);
|
||||||
|
const p = feld(jwk.p);
|
||||||
|
const q = feld(jwk.q);
|
||||||
|
const iqmp = feld(jwk.qi);
|
||||||
|
// Public-Teil: e vor n (RFC 4253). Private-Teil: n e d iqmp p q (sshkey.c). JWK `qi`
|
||||||
|
// ist q^-1 mod p und damit genau das `iqmp` von OpenSSH.
|
||||||
|
publicBlob = Buffer.concat([sshString(typName), mpint(e), mpint(n)]);
|
||||||
|
privatTeil = Buffer.concat([
|
||||||
|
sshString(typName),
|
||||||
|
mpint(n),
|
||||||
|
mpint(e),
|
||||||
|
mpint(d),
|
||||||
|
mpint(iqmp),
|
||||||
|
mpint(p),
|
||||||
|
mpint(q),
|
||||||
|
]);
|
||||||
|
for (const geheim of [d, p, q, iqmp]) geheim.fill(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const privateKeyOpenssh = privateKeySchreiben(publicBlob, privatTeil, kommentar, passphrase);
|
||||||
|
return {
|
||||||
|
publicKeyZeile: publicKeyZeileBauen(publicBlob, typName, kommentar),
|
||||||
|
privateKeyOpenssh,
|
||||||
|
fingerprint: fingerprintSha256(publicBlob),
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
privatTeil.fill(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Private Keys: Lesen
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface PrivateKeyInfo {
|
||||||
|
typ: PrivateSchluesseltyp;
|
||||||
|
bits: number;
|
||||||
|
publicKeyZeile: string;
|
||||||
|
kommentar: string;
|
||||||
|
fingerprint: string;
|
||||||
|
/** War die Datei mit einer Passphrase geschuetzt? */
|
||||||
|
verschluesselt: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PrivateKeyLeseOptionen {
|
||||||
|
rsaMindestBits?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liest einen Private Key im OpenSSH-Format, entschluesselt ihn bei Bedarf und prueft,
|
||||||
|
* dass Private und Public Key zusammengehoeren. Gibt nur oeffentliche Angaben zurueck.
|
||||||
|
*/
|
||||||
|
export function privateKeyLesen(
|
||||||
|
openssh: string,
|
||||||
|
passphrase?: string,
|
||||||
|
optionen: PrivateKeyLeseOptionen = {},
|
||||||
|
): PrivateKeyInfo {
|
||||||
|
const datei = pemDekodieren(openssh);
|
||||||
|
const zuLoeschen: Buffer[] = [datei];
|
||||||
|
try {
|
||||||
|
const leser = new Leser(datei);
|
||||||
|
if (!gleich(leser.bytes(MAGIC.length), MAGIC)) {
|
||||||
|
throw new SchluesselFormatFehler("ungueltig", "Der Text ist kein Private Key im OpenSSH-Format.");
|
||||||
|
}
|
||||||
|
const cipherName = leser.text();
|
||||||
|
const kdfName = leser.text();
|
||||||
|
const kdfOptionen = leser.string();
|
||||||
|
if (leser.uint32() !== 1) {
|
||||||
|
throw new SchluesselFormatFehler(
|
||||||
|
"nicht_unterstuetzt",
|
||||||
|
"Dateien mit mehr als einem Schlüssel werden nicht unterstützt.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const publicBlob = Buffer.from(leser.string());
|
||||||
|
const inhalt = leser.string();
|
||||||
|
if (!leser.amEnde()) throw ungueltig();
|
||||||
|
|
||||||
|
const publicTyp = new Leser(publicBlob).text();
|
||||||
|
if (publicTyp !== "ssh-ed25519" && publicTyp !== "ssh-rsa") {
|
||||||
|
throw new SchluesselFormatFehler(
|
||||||
|
"nicht_unterstuetzt",
|
||||||
|
"Es werden nur Private Keys vom Typ ed25519 und RSA unterstützt.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Der Public Key liegt unverschluesselt vor; eine zu kurze RSA-Laenge faellt so auf,
|
||||||
|
// bevor jemand nach der Passphrase gefragt wird.
|
||||||
|
const bits = publicBlobPruefen(publicTyp, publicBlob, optionen.rsaMindestBits ?? RSA_MINDEST_BITS) ?? 0;
|
||||||
|
|
||||||
|
let abschnitt: Buffer;
|
||||||
|
let blockgroesse: number;
|
||||||
|
const verschluesselt = cipherName !== "none";
|
||||||
|
if (cipherName === "none") {
|
||||||
|
if (kdfName !== "none" || kdfOptionen.length !== 0) throw ungueltig();
|
||||||
|
abschnitt = inhalt;
|
||||||
|
blockgroesse = 8;
|
||||||
|
} else if (cipherName === "aes256-ctr") {
|
||||||
|
if (kdfName !== "bcrypt") {
|
||||||
|
throw new SchluesselFormatFehler(
|
||||||
|
"nicht_unterstuetzt",
|
||||||
|
"Das Verfahren zur Ableitung des Schlüssels wird nicht unterstützt.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (passphrase === undefined || passphrase === "") {
|
||||||
|
throw new SchluesselFormatFehler(
|
||||||
|
"passphrase_fehlt",
|
||||||
|
"Der Private Key ist mit einer Passphrase geschützt.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const kdfLeser = new Leser(kdfOptionen);
|
||||||
|
const salt = kdfLeser.string();
|
||||||
|
const runden = kdfLeser.uint32();
|
||||||
|
if (!kdfLeser.amEnde() || salt.length === 0 || runden < 1 || runden > BCRYPT_RUNDEN_HOECHSTENS) {
|
||||||
|
throw ungueltig();
|
||||||
|
}
|
||||||
|
blockgroesse = 16;
|
||||||
|
if (inhalt.length % blockgroesse !== 0) throw ungueltig();
|
||||||
|
const abgeleitet = bcryptAbleiten(passphrase, salt, runden);
|
||||||
|
try {
|
||||||
|
const decipher = createDecipheriv(
|
||||||
|
"aes-256-ctr",
|
||||||
|
abgeleitet.subarray(0, 32),
|
||||||
|
abgeleitet.subarray(32, 48),
|
||||||
|
);
|
||||||
|
abschnitt = Buffer.concat([decipher.update(inhalt), decipher.final()]);
|
||||||
|
zuLoeschen.push(abschnitt);
|
||||||
|
} finally {
|
||||||
|
abgeleitet.fill(0);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new SchluesselFormatFehler(
|
||||||
|
"nicht_unterstuetzt",
|
||||||
|
"Die Verschlüsselung dieses Private Keys wird nicht unterstützt (erwartet: aes256-ctr).",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (abschnitt.length % blockgroesse !== 0) throw ungueltig();
|
||||||
|
|
||||||
|
const privat = new Leser(abschnitt);
|
||||||
|
if (privat.uint32() !== privat.uint32()) {
|
||||||
|
throw verschluesselt
|
||||||
|
? new SchluesselFormatFehler("passphrase_falsch", "Die Passphrase ist falsch.")
|
||||||
|
: ungueltig();
|
||||||
|
}
|
||||||
|
if (privat.text() !== publicTyp) throw ungueltig();
|
||||||
|
|
||||||
|
let typ: PrivateSchluesseltyp;
|
||||||
|
const erwartet = new Leser(publicBlob);
|
||||||
|
erwartet.text();
|
||||||
|
if (publicTyp === "ssh-ed25519") {
|
||||||
|
typ = "ED25519";
|
||||||
|
const oeffentlich = privat.string();
|
||||||
|
const geheim = privat.string();
|
||||||
|
if (geheim.length !== 64 || !gleich(oeffentlich, erwartet.string())) throw ungueltig();
|
||||||
|
if (!gleich(geheim.subarray(32), oeffentlich)) throw ungueltig();
|
||||||
|
if (!gleich(ed25519PublicAusSeed(geheim.subarray(0, 32)), oeffentlich)) throw ungueltig();
|
||||||
|
} else {
|
||||||
|
typ = "RSA";
|
||||||
|
const n = privat.mpint();
|
||||||
|
const e = privat.mpint();
|
||||||
|
const d = privat.mpint();
|
||||||
|
privat.mpint(); // iqmp
|
||||||
|
const p = privat.mpint();
|
||||||
|
const q = privat.mpint();
|
||||||
|
const erwartetesE = erwartet.mpint();
|
||||||
|
const erwartetesN = erwartet.mpint();
|
||||||
|
if (!gleich(n, erwartetesN) || !gleich(e, erwartetesE) || d.length === 0) throw ungueltig();
|
||||||
|
// Billige Plausibilitaet: Ein Private Key, dessen Primfaktoren nicht n ergeben, ist kaputt.
|
||||||
|
if (alsBigInt(p) * alsBigInt(q) !== alsBigInt(n)) throw ungueltig();
|
||||||
|
}
|
||||||
|
|
||||||
|
const kommentar = privat.text();
|
||||||
|
kommentarPruefen(kommentar);
|
||||||
|
const padding = privat.rest();
|
||||||
|
if (padding.length >= blockgroesse || padding.some((wert, i) => wert !== i + 1)) throw ungueltig();
|
||||||
|
|
||||||
|
return {
|
||||||
|
typ,
|
||||||
|
bits,
|
||||||
|
publicKeyZeile: publicKeyZeileBauen(publicBlob, publicTyp, kommentar),
|
||||||
|
kommentar,
|
||||||
|
fingerprint: fingerprintSha256(publicBlob),
|
||||||
|
verschluesselt,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
for (const puffer of zuLoeschen) puffer.fill(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+20
@@ -0,0 +1,20 @@
|
|||||||
|
// Das Paket bringt keine Typen mit, und @types/bcrypt-pbkdf gibt es nicht (#006).
|
||||||
|
// Deklariert ist nur, was src/lib/ssh/openssh.ts tatsaechlich benutzt.
|
||||||
|
declare module "bcrypt-pbkdf" {
|
||||||
|
/**
|
||||||
|
* bcrypt_pbkdf wie in OpenSSH. Schreibt `keylen` Byte in `key` und gibt 0 zurueck,
|
||||||
|
* bei ungueltigen Argumenten (leere Passphrase, leeres Salt, rounds < 1) -1.
|
||||||
|
*/
|
||||||
|
export function pbkdf(
|
||||||
|
pass: Uint8Array,
|
||||||
|
passlen: number,
|
||||||
|
salt: Uint8Array,
|
||||||
|
saltlen: number,
|
||||||
|
key: Uint8Array,
|
||||||
|
keylen: number,
|
||||||
|
rounds: number,
|
||||||
|
): number;
|
||||||
|
|
||||||
|
const bcryptPbkdf: { pbkdf: typeof pbkdf };
|
||||||
|
export default bcryptPbkdf;
|
||||||
|
}
|
||||||
@@ -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