Merge branch 'phase/006-geheimnisse-schluesselformat' (Kern)

This commit is contained in:
2026-10-12 09:21:41 +02:00
12 changed files with 1742 additions and 8 deletions
+198
View File
@@ -0,0 +1,198 @@
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
import { open } from "node:fs/promises";
/**
* Envelope Encryption fuer Geheimnisse at rest (#006, docs/PLAN.md Abschnitt 3).
*
* Jedes Geheimnis bekommt einen eigenen zufaelligen Data Encryption Key (DEK). Der DEK
* verschluesselt den Klartext, der Master-Key verschluesselt nur den DEK. Dadurch
* - genuegt fuer eine Master-Key-Rotation das Umschluesseln der DEKs (`sshkv rewrap`),
* die eigentlichen Chiffrate bleiben unberuehrt;
* - bindet das AAD (`zusatz`, z. B. `schluessel:<id>:<fingerprint>`) jedes Chiffrat an
* seinen Datensatz: Wer in der Datenbank Chiffrate zwischen Zeilen vertauscht, erhaelt
* beim Oeffnen einen Fehler statt eines fremden Private Keys.
*
* Aufbau beider Puffer (`chiffrat` und `dekVerschluesselt`):
*
* | Version (1 Byte, derzeit 1) | IV (12 Byte) | GCM-Tag (16 Byte) | Daten (n Byte) |
*
* AES-256-GCM in beiden Stufen. AAD des Klartexts ist `zusatz` (UTF-8), AAD des DEK ist
* `"sshkv-dek:v" + masterKeyVersion`, damit ein DEK nicht mit dem Master-Key einer
* anderen Version geoeffnet werden kann.
*
* Framework-frei; der Master-Key selbst darf nur im Worker geladen werden (CLAUDE.md).
*/
const FORMAT_VERSION = 1;
const IV_LAENGE = 12;
const TAG_LAENGE = 16;
const KOPF_LAENGE = 1 + IV_LAENGE + TAG_LAENGE;
const SCHLUESSEL_LAENGE = 32;
export interface Umschlag {
chiffrat: Buffer;
dekVerschluesselt: Buffer;
masterKeyVersion: number;
}
/**
* Oeffnen ist gescheitert. Bewusst ohne Angabe, welcher Teil nicht passte (Master-Key,
* AAD oder Chiffrat): GCM kann das nicht unterscheiden, und Raten hilft niemandem.
*/
export class EntschluesselungsFehler extends Error {
override readonly name = "EntschluesselungsFehler";
constructor(
meldung = "Entschlüsselung fehlgeschlagen: Master-Key, Zuordnung oder Chiffrat passen nicht zusammen.",
) {
super(meldung);
}
}
/** Fehler beim Laden des Master-Keys. Die Meldung nennt den Pfad, nie den Inhalt. */
export class MasterKeyFehler extends Error {
override readonly name = "MasterKeyFehler";
}
function masterKeyPruefen(masterKey: Buffer): void {
if (masterKey.length !== SCHLUESSEL_LAENGE) {
throw new MasterKeyFehler("Der Master-Key muss genau 32 Byte lang sein.");
}
}
function dekAad(masterKeyVersion: number): Buffer {
return Buffer.from(`sshkv-dek:v${masterKeyVersion}`, "utf8");
}
function gcmVerschluesseln(klartext: Buffer, schluessel: Buffer, aad: Buffer): Buffer {
const iv = randomBytes(IV_LAENGE);
const cipher = createCipheriv("aes-256-gcm", schluessel, iv, { authTagLength: TAG_LAENGE });
cipher.setAAD(aad);
const daten = Buffer.concat([cipher.update(klartext), cipher.final()]);
return Buffer.concat([Buffer.from([FORMAT_VERSION]), iv, cipher.getAuthTag(), daten]);
}
function gcmEntschluesseln(puffer: Buffer, schluessel: Buffer, aad: Buffer): Buffer {
if (puffer.length < KOPF_LAENGE || puffer[0] !== FORMAT_VERSION) {
throw new EntschluesselungsFehler();
}
const iv = puffer.subarray(1, 1 + IV_LAENGE);
const tag = puffer.subarray(1 + IV_LAENGE, KOPF_LAENGE);
const daten = puffer.subarray(KOPF_LAENGE);
try {
const decipher = createDecipheriv("aes-256-gcm", schluessel, iv, { authTagLength: TAG_LAENGE });
decipher.setAAD(aad);
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(daten), decipher.final()]);
} catch {
// Die Original-Ausnahme verraet nichts Geheimes, ist aber englisch und ohne Nutzen.
throw new EntschluesselungsFehler();
}
}
/**
* Verschluesselt `klartext` mit einem frischen DEK. Der Klartext gehoert dem Aufrufer und
* wird hier nicht ueberschrieben; den DEK loescht die Funktion selbst.
*/
export function versiegeln(
klartext: Buffer,
masterKey: Buffer,
zusatz: string,
masterKeyVersion = 1,
): Umschlag {
masterKeyPruefen(masterKey);
const dek = randomBytes(SCHLUESSEL_LAENGE);
try {
const chiffrat = gcmVerschluesseln(klartext, dek, Buffer.from(zusatz, "utf8"));
const dekVerschluesselt = gcmVerschluesseln(dek, masterKey, dekAad(masterKeyVersion));
return { chiffrat, dekVerschluesselt, masterKeyVersion };
} finally {
dek.fill(0);
}
}
/**
* Oeffnet einen Umschlag. `masterKey` muss zur `masterKeyVersion` des Umschlags gehoeren.
* Der zurueckgegebene Klartext gehoert dem Aufrufer, der ihn nach Gebrauch mit `fill(0)`
* ueberschreiben soll.
*/
export function oeffnen(umschlag: Umschlag, masterKey: Buffer, zusatz: string): Buffer {
masterKeyPruefen(masterKey);
const dek = gcmEntschluesseln(umschlag.dekVerschluesselt, masterKey, dekAad(umschlag.masterKeyVersion));
try {
if (dek.length !== SCHLUESSEL_LAENGE) throw new EntschluesselungsFehler();
return gcmEntschluesseln(umschlag.chiffrat, dek, Buffer.from(zusatz, "utf8"));
} finally {
dek.fill(0);
}
}
/** Mit dieser Variable darf ein Master-Key mit zu offenen Dateirechten geladen werden. */
export const UNSICHER_ERLAUBT_VARIABLE = "SSHKV_MASTER_KEY_UNSICHER_ERLAUBT";
/**
* Laedt den Master-Key aus einer Datei (`SSHKV_MASTER_KEY_FILE`).
*
* Akzeptiert 32 rohe Bytes, 44 Zeichen Base64 oder 64 Zeichen Hex (jeweils mit optionalem
* Zeilenende), weil `openssl rand -base64 32 > datei` der naheliegende Weg ist, einen
* Schluessel anzulegen. Darf Gruppe oder Welt die Datei lesen, wird abgebrochen: Ein
* versehentlich lesbarer Master-Key oeffnet jeden gespeicherten Private Key.
*/
export async function masterKeyLaden(pfad: string): Promise<Buffer> {
let inhalt: Buffer;
const datei = await open(pfad, "r").catch(() => {
throw new MasterKeyFehler(`Die Master-Key-Datei ${pfad} kann nicht geöffnet werden.`);
});
try {
// Rechte am geoeffneten Handle pruefen, nicht am Pfad: sonst koennte die Datei
// zwischen Pruefung und Lesen ausgetauscht werden.
const status = await datei.stat();
if (!status.isFile()) {
throw new MasterKeyFehler(`Die Master-Key-Datei ${pfad} ist keine reguläre Datei.`);
}
if ((status.mode & 0o044) !== 0) {
const meldung =
`Die Master-Key-Datei ${pfad} ist für Gruppe oder andere lesbar ` +
`(Rechte ${(status.mode & 0o777).toString(8)}). Bitte mit „chmod 600“ einschränken.`;
if (process.env[UNSICHER_ERLAUBT_VARIABLE] !== "1") throw new MasterKeyFehler(meldung);
console.warn(`${meldung} Wird wegen ${UNSICHER_ERLAUBT_VARIABLE}=1 trotzdem geladen.`);
}
// Groessere Dateien gar nicht erst ganz lesen; 70 Byte reichen fuer Hex plus CRLF.
if (status.size > 70) {
throw new MasterKeyFehler(`Die Master-Key-Datei ${pfad} ist zu groß für einen 32-Byte-Schlüssel.`);
}
inhalt = await datei.readFile();
} finally {
await datei.close();
}
try {
const schluessel = masterKeyDekodieren(inhalt);
if (!schluessel) {
throw new MasterKeyFehler(
`Die Master-Key-Datei ${pfad} enthält keinen 32-Byte-Schlüssel ` +
"(erwartet: 32 Byte roh, 44 Zeichen Base64 oder 64 Zeichen Hex).",
);
}
if (schluessel.every((byte) => byte === 0)) {
schluessel.fill(0);
throw new MasterKeyFehler(`Die Master-Key-Datei ${pfad} enthält nur Nullbytes.`);
}
return schluessel;
} finally {
inhalt.fill(0);
}
}
function masterKeyDekodieren(inhalt: Buffer): Buffer | null {
if (inhalt.length === SCHLUESSEL_LAENGE) return Buffer.from(inhalt);
let ende = inhalt.length;
if (inhalt[ende - 1] === 0x0a) ende--;
if (inhalt[ende - 1] === 0x0d) ende--;
const text = inhalt.subarray(0, ende).toString("latin1");
if (/^[0-9a-fA-F]{64}$/.test(text)) return Buffer.from(text, "hex");
if (/^[A-Za-z0-9+/]{43}=$/.test(text)) {
const schluessel = Buffer.from(text, "base64");
return schluessel.length === SCHLUESSEL_LAENGE ? schluessel : null;
}
return null;
}
+95
View File
@@ -0,0 +1,95 @@
/**
* Schwaerzt Geheimnisse in beliebigen Werten, bevor sie in Log oder Protokoll landen (#006).
*
* Zwei Netze, weil eines allein nicht reicht:
* - Feldnamen: Alles, was nach Private Key, Passwort, Passphrase, Secret oder Token heisst,
* wird unabhaengig vom Inhalt ersetzt – auch Objekte und Puffer darunter.
* - Inhalte: Ein Private Key im PEM-Format wird auch dort erkannt, wo niemand ihn erwartet,
* etwa eingebettet in eine Fehlermeldung oder einen Stacktrace.
*
* Gibt immer eine Kopie zurueck; der Eingabewert bleibt unveraendert.
*/
export const GESCHWAERZT = "[geschwärzt]";
/**
* Wortstaemme, die einen Feldnamen als geheim markieren. Verglichen wird ohne Gross-/
* Kleinschreibung und ohne `_`/`-`, und zwar irgendwo im Namen: `clientSecret` und
* `x-api-token` sollen genauso greifen wie `privateKeyOpenssh` oder `privatVerschluesselt`.
* Lieber einmal zu viel schwaerzen als einen Key ins Log schreiben.
*/
const GEHEIME_WORTSTAEMME = [
"privat",
"passwort",
"password",
"passphrase",
"secret",
"geheimnis",
"token",
"masterkey",
"authorization",
"cookie",
];
const HOECHSTE_TIEFE = 12;
/** Von BEGIN bis END eines beliebigen PEM-Private-Keys, oder bis zum Textende, falls abgeschnitten. */
const PEM_PRIVATE_KEY = /-----BEGIN[^-\n]*PRIVATE KEY-----[\s\S]*?(?:-----END[^-\n]*PRIVATE KEY-----|$)/g;
function istGeheimerName(name: string): boolean {
const normalisiert = name.toLowerCase().replace(/[_\-\s]/g, "");
return GEHEIME_WORTSTAEMME.some((stamm) => normalisiert.includes(stamm));
}
function textSchwaerzen(text: string): string {
if (!text.includes("-----BEGIN")) return text;
const ersetzt = text.replace(PEM_PRIVATE_KEY, GESCHWAERZT);
// Faengt seltsame Varianten ab, die der Ausdruck oben nicht trifft (z. B. Zeilenumbruch
// mitten im Kopf). Dann geht die ganze Zeichenkette.
return ersetzt.includes("-----BEGIN") && ersetzt.includes("PRIVATE KEY") ? GESCHWAERZT : ersetzt;
}
export function schwaerzen(wert: unknown): unknown {
return schwaerzenRekursiv(wert, new WeakSet(), 0);
}
function schwaerzenRekursiv(wert: unknown, gesehen: WeakSet<object>, tiefe: number): unknown {
if (typeof wert === "string") return textSchwaerzen(wert);
if (wert === null || typeof wert !== "object") return wert;
// Rohe Bytes koennen Schluesselmaterial sein und sind im Log ohnehin nicht lesbar.
if (wert instanceof Uint8Array || wert instanceof ArrayBuffer) return "[Binärdaten]";
if (wert instanceof Date) return new Date(wert.getTime());
if (gesehen.has(wert)) return "[zirkulär]";
if (tiefe >= HOECHSTE_TIEFE) return "[zu tief]";
gesehen.add(wert);
try {
if (Array.isArray(wert)) return wert.map((eintrag) => schwaerzenRekursiv(eintrag, gesehen, tiefe + 1));
if (wert instanceof Map) {
return Object.fromEntries(
[...wert.entries()].map(([schluessel, eintrag]) => [
String(schluessel),
istGeheimerName(String(schluessel)) ? GESCHWAERZT : schwaerzenRekursiv(eintrag, gesehen, tiefe + 1),
]),
);
}
if (wert instanceof Set)
return [...wert].map((eintrag) => schwaerzenRekursiv(eintrag, gesehen, tiefe + 1));
const ergebnis: Record<string, unknown> = {};
// Bei Fehlern sind name, message und stack nicht aufzaehlbar, tragen aber gerade den
// Text, in dem ein Key stecken kann.
if (wert instanceof Error) {
ergebnis["name"] = wert.name;
ergebnis["message"] = textSchwaerzen(wert.message);
if (wert.stack) ergebnis["stack"] = textSchwaerzen(wert.stack);
if (wert.cause !== undefined) ergebnis["cause"] = schwaerzenRekursiv(wert.cause, gesehen, tiefe + 1);
}
for (const [name, eintrag] of Object.entries(wert)) {
ergebnis[name] = istGeheimerName(name) ? GESCHWAERZT : schwaerzenRekursiv(eintrag, gesehen, tiefe + 1);
}
return ergebnis;
} finally {
// Nur Zyklen erkennen, nicht mehrfach referenzierte Teilbaeume verstecken.
gesehen.delete(wert);
}
}
+682
View File
@@ -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);
}
}
+20
View File
@@ -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;
}