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
@@ -1,5 +1,5 @@
# #006 Verschlüsselte Ablage und OpenSSH-Schlüsselformat # #006 Verschlüsselte Ablage und OpenSSH-Schlüsselformat
**Status:** offen **Status:** in Arbeit
**Meilenstein:** v0.1.0 **Meilenstein:** v0.1.0
**Labels:** sicherheit **Labels:** sicherheit
**Geschätzt:** 4 h **Tatsächlich:** – **Geschätzt:** 4 h **Tatsächlich:** –
@@ -8,19 +8,50 @@
Envelope Encryption für Private Keys mit Master-Key aus Datei; eigene Erzeugung und Serialisierung von OpenSSH-Schlüsseln ohne temporäre Dateien; Deployment-Key der Instanz. Envelope Encryption für Private Keys mit Master-Key aus Datei; eigene Erzeugung und Serialisierung von OpenSSH-Schlüsseln ohne temporäre Dateien; Deployment-Key der Instanz.
## Aufgaben ## Aufgaben
- [ ] Master-Key aus `SSHKV_MASTER_KEY_FILE` (32 Byte), nur im Worker - [x] Master-Key aus Datei laden (32 Byte roh, Base64 oder Hex; Rechteprüfung) – `masterKeyLaden`
- [ ] AES-256-GCM, DEK je Schlüssel, AAD = Schlüssel-ID + Fingerprint, Master-Key-Version - [ ] Master-Key aus `SSHKV_MASTER_KEY_FILE` nur im Worker anbinden
- [ ] ed25519 und RSA-4096 erzeugen (Node `crypto`), OpenSSH-Format schreiben und lesen (optional Passphrase: bcrypt-pbkdf + aes256-ctr) - [x] AES-256-GCM, DEK je Schlüssel, AAD = Schlüssel-ID + Fingerprint, Master-Key-Version
- [ ] Fingerprint SHA256 wie `ssh-keygen -l` - [x] ed25519 und RSA-4096 erzeugen (Node `crypto`), OpenSSH-Format schreiben und lesen (optional Passphrase: bcrypt-pbkdf + aes256-ctr)
- [x] Fingerprint SHA256 wie `ssh-keygen -l`
- [ ] Deployment-Key beim ersten Start erzeugen - [ ] Deployment-Key beim ersten Start erzeugen
- [ ] Log-Redaction - [x] Log-Redaction (`schwaerzen`; Einbau in Logger/Protokoll folgt)
## Akzeptanzkriterien ## Akzeptanzkriterien
- [ ] `ssh-keygen -y -f` liest erzeugte Schlüssel (mit und ohne Passphrase) - [x] `ssh-keygen -y -f` liest erzeugte Schlüssel (mit und ohne Passphrase)
- [ ] Fingerprints identisch zu `ssh-keygen -lf` - [x] Fingerprints identisch zu `ssh-keygen -lf`
- [ ] Datenbank-Dump enthält keinen Klartext-Schlüssel - [ ] Datenbank-Dump enthält keinen Klartext-Schlüssel
## Abhängigkeiten ## Abhängigkeiten
#003 #003
## Notizen / Entscheidungen ## Notizen / Entscheidungen
- **Kern fertig; Anbindung an Datenbank/Worker folgt mit #007.** Offen sind Deployment-Key beim
ersten Start, Master-Key nur im Worker laden und der Nachweis „Datenbank-Dump enthält keinen
Klartext“.
- Module: `src/lib/ssh/openssh.ts` (Erzeugen/Schreiben/Lesen, Fingerprint, Public-Key-Zeilen),
`src/lib/krypto/umschlag.ts` (Envelope Encryption, Master-Key laden),
`src/lib/protokoll-schwaerzen.ts` (Redaction). Alle framework-frei.
- Eigener Serializer für `openssh-key-v1` statt `ssh-keygen`: keine Private Keys als Temp-Dateien.
Die Tests prüfen gegen das echte `ssh-keygen` (beide Richtungen, mit/ohne Passphrase).
- Passphrase: `aes256-ctr` + `bcrypt` mit **16 Runden** (ssh-keygen nimmt heute 24). `bcrypt-pbkdf`
ist reines JavaScript; 16 Runden halten das im Worker unter einer Sekunde. Beim Lesen gilt die
Rundenzahl aus der Datei (Obergrenze 1000 gegen absichtlich teure Importe).
Andere Cipher (z. B. `aes256-gcm@openssh.com` via `ssh-keygen -Z`) werden mit klarer Meldung
abgelehnt. Private-Key-Import nur für ed25519 und RSA.
- `SchluesselFormatFehler` trägt einen `code` (`ungueltig`, `nicht_unterstuetzt`,
`passphrase_fehlt`, `passphrase_falsch`, `zu_schwach`); Meldungen sind feste Sätze ohne Eingabe.
- RSA-Mindestlänge 2048 Bit standardmäßig in `publicKeyZeileLesen` und `privateKeyLesen`;
`{ rsaMindestBits: 0 }` für das reine Beobachten vorhandener `authorized_keys` (Drift).
- Kommentare mit Steuerzeichen werden abgelehnt, sonst ließe sich über einen Kommentar eine zweite
Zeile in `authorized_keys` einschleusen.
- Umschlag-Format je Puffer: `Version (1) | IV (12) | Tag (16) | Daten`. AAD Klartext = `zusatz`
(vorgesehen: `schluessel:<id>:<fingerprint>`), AAD DEK = `sshkv-dek:v<masterKeyVersion>`.
`versiegeln(..., masterKeyVersion = 1)`; beim Öffnen muss der Aufrufer den Master-Key passend zur
Version wählen.
- Master-Key-Datei mit Gruppe/Welt-Leserecht → Abbruch, außer `SSHKV_MASTER_KEY_UNSICHER_ERLAUBT=1`
(dann Warnung). Achtung Docker: Secrets ohne Swarm sind Bind-Mounts mit den Rechten der
Host-Datei – dort `chmod 400` und passenden Eigentümer setzen.
- Schwärzen greift auf Feldnamen, die einen der Wortstämme `privat`, `passwort`, `password`,
`passphrase`, `secret`, `geheimnis`, `token`, `masterkey`, `authorization`, `cookie` enthalten
(nicht nur als Präfix), sowie auf PEM-Private-Keys in jeder Zeichenkette (auch abgeschnitten,
auch in `message`/`stack` von Fehlern). Rohe Bytes werden zu `[Binärdaten]`.
+1
View File
@@ -49,6 +49,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",
+3
View File
@@ -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
+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;
}
+98
View File
@@ -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 } });
});
});
+167
View File
@@ -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"));
});
});
+81
View File
@@ -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;
}
+131
View File
@@ -0,0 +1,131 @@
import { execFileSync } from "node:child_process";
import { existsSync } from "node:fs";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
type ErzeugbarerSchluesseltyp,
privateKeyLesen,
publicKeyZeileLesen,
schluesselpaarErzeugen,
} from "@/lib/ssh/openssh";
import { ecdsaBlob, ed25519Blob, rsaBlob, skEcdsaBlob, skEd25519Blob, zeile } from "./hilfen";
/**
* Gegenprobe mit dem echten OpenSSH (#006). `ssh-keygen` kommt nur hier vor, nie im
* Produktivcode – dort waeren dafuer Private Keys als Dateien noetig.
*/
const SSH_KEYGEN = "/usr/bin/ssh-keygen";
const PASSPHRASE = "geheim und lang genug";
let verzeichnis: string;
let zaehler = 0;
function sshKeygen(...argumente: string[]): string {
// stdin geschlossen: Fragt ssh-keygen doch einmal nach etwas, scheitert der Test, statt zu haengen.
return execFileSync(SSH_KEYGEN, argumente, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
}
async function dateiSchreiben(inhalt: string): Promise<string> {
const pfad = join(verzeichnis, `schluessel-${zaehler++}`);
await writeFile(pfad, inhalt, { mode: 0o600 });
return pfad;
}
/** "256 SHA256:… kommentar (ED25519)" → "SHA256:…" */
function fingerprintAusAusgabe(ausgabe: string): string {
return ausgabe.trim().split(/\s+/)[1]!;
}
beforeAll(async () => {
verzeichnis = await mkdtemp(join(tmpdir(), "sshkv-test-"));
});
afterAll(async () => {
await rm(verzeichnis, { recursive: true, force: true });
});
describe.skipIf(!existsSync(SSH_KEYGEN))("Kompatibilität mit ssh-keygen", () => {
describe.each([
["ED25519", undefined],
["ED25519", PASSPHRASE],
["RSA4096", undefined],
["RSA4096", PASSPHRASE],
] as [ErzeugbarerSchluesseltyp, string | undefined][])(
"selbst erzeugt: %s, Passphrase %s",
(typ, passphrase) => {
it("ssh-keygen -y liest den Private Key, -lf liefert denselben Fingerprint", async () => {
const paar = await schluesselpaarErzeugen(typ, { kommentar: "sshkv:k_test pruefung", passphrase });
const pfad = await dateiSchreiben(paar.privateKeyOpenssh);
const oeffentlich = sshKeygen("-y", "-P", passphrase ?? "", "-f", pfad).trim();
expect(oeffentlich).toBe(paar.publicKeyZeile);
const pfadPublic = await dateiSchreiben(paar.publicKeyZeile + "\n");
expect(fingerprintAusAusgabe(sshKeygen("-lf", pfadPublic))).toBe(paar.fingerprint);
// -lf auf den Private Key liest bei verschluesselten Dateien nur den Public-Teil.
expect(fingerprintAusAusgabe(sshKeygen("-lf", pfad))).toBe(paar.fingerprint);
}, 30_000);
if (passphrase) {
it("ssh-keygen lehnt eine falsche Passphrase ab", async () => {
const paar = await schluesselpaarErzeugen(typ, { kommentar: "x", passphrase });
const pfad = await dateiSchreiben(paar.privateKeyOpenssh);
expect(() => sshKeygen("-y", "-P", "falsch", "-f", pfad)).toThrow();
}, 30_000);
}
},
);
describe.each([
["ed25519", [], ""],
["ed25519", [], PASSPHRASE],
["rsa", ["-b", "2048"], ""],
["rsa", ["-b", "2048"], PASSPHRASE],
] as [string, string[], string][])(
"von ssh-keygen erzeugt: %s, Passphrase „%s“",
(typ, extra, passphrase) => {
it("wird gelesen, Public Key und Fingerprint stimmen", async () => {
const pfad = join(verzeichnis, `fremd-${zaehler++}`);
sshKeygen("-q", "-t", typ, ...extra, "-N", passphrase, "-C", "fremd@rechner", "-f", pfad);
const privat = await readFile(pfad, "utf8");
const publicZeile = (await readFile(`${pfad}.pub`, "utf8")).trim();
const gelesen = privateKeyLesen(privat, passphrase || undefined);
expect(gelesen.publicKeyZeile).toBe(publicZeile);
expect(gelesen.kommentar).toBe("fremd@rechner");
expect(gelesen.verschluesselt).toBe(passphrase !== "");
expect(gelesen.fingerprint).toBe(fingerprintAusAusgabe(sshKeygen("-lf", `${pfad}.pub`)));
expect(publicKeyZeileLesen(publicZeile).fingerprint).toBe(gelesen.fingerprint);
if (passphrase) {
expect(() => privateKeyLesen(privat, "falsch")).toThrow("Die Passphrase ist falsch.");
}
}, 30_000);
},
);
it("liest von ssh-keygen erzeugte ECDSA-Public-Keys", async () => {
for (const bits of ["256", "384", "521"]) {
const pfad = join(verzeichnis, `ecdsa-${bits}`);
sshKeygen("-q", "-t", "ecdsa", "-b", bits, "-N", "", "-C", "e", "-f", pfad);
const publicZeile = await readFile(`${pfad}.pub`, "utf8");
const info = publicKeyZeileLesen(publicZeile);
expect(info.typ).toBe(`ecdsa-sha2-nistp${bits}`);
expect(info.fingerprint).toBe(fingerprintAusAusgabe(sshKeygen("-lf", `${pfad}.pub`)));
}
});
it.each([
["ssh-ed25519", ed25519Blob],
["ssh-rsa", () => rsaBlob(2048)],
["ecdsa-sha2-nistp256", () => ecdsaBlob("nistp256")],
["sk-ssh-ed25519@openssh.com", skEd25519Blob],
["sk-ecdsa-sha2-nistp256@openssh.com", skEcdsaBlob],
] as const)("Fingerprint von %s stimmt mit ssh-keygen -lf überein", async (typ, blobBauen) => {
const text = zeile(typ, blobBauen());
const pfad = await dateiSchreiben(text + "\n");
expect(publicKeyZeileLesen(text).fingerprint).toBe(fingerprintAusAusgabe(sshKeygen("-lf", pfad)));
});
});
+227
View File
@@ -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);
});
});