Merge branch 'phase/006-geheimnisse-schluesselformat' (Kern)
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
# #006 Verschlüsselte Ablage und OpenSSH-Schlüsselformat
|
||||
**Status:** offen
|
||||
**Status:** in Arbeit
|
||||
**Meilenstein:** v0.1.0
|
||||
**Labels:** sicherheit
|
||||
**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.
|
||||
|
||||
## Aufgaben
|
||||
- [ ] Master-Key aus `SSHKV_MASTER_KEY_FILE` (32 Byte), nur im Worker
|
||||
- [ ] AES-256-GCM, DEK je Schlüssel, AAD = Schlüssel-ID + Fingerprint, Master-Key-Version
|
||||
- [ ] ed25519 und RSA-4096 erzeugen (Node `crypto`), OpenSSH-Format schreiben und lesen (optional Passphrase: bcrypt-pbkdf + aes256-ctr)
|
||||
- [ ] Fingerprint SHA256 wie `ssh-keygen -l`
|
||||
- [x] Master-Key aus Datei laden (32 Byte roh, Base64 oder Hex; Rechteprüfung) – `masterKeyLaden`
|
||||
- [ ] Master-Key aus `SSHKV_MASTER_KEY_FILE` nur im Worker anbinden
|
||||
- [x] AES-256-GCM, DEK je Schlüssel, AAD = Schlüssel-ID + Fingerprint, Master-Key-Version
|
||||
- [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
|
||||
- [ ] Log-Redaction
|
||||
- [x] Log-Redaction (`schwaerzen`; Einbau in Logger/Protokoll folgt)
|
||||
|
||||
## Akzeptanzkriterien
|
||||
- [ ] `ssh-keygen -y -f` liest erzeugte Schlüssel (mit und ohne Passphrase)
|
||||
- [ ] Fingerprints identisch zu `ssh-keygen -lf`
|
||||
- [x] `ssh-keygen -y -f` liest erzeugte Schlüssel (mit und ohne Passphrase)
|
||||
- [x] Fingerprints identisch zu `ssh-keygen -lf`
|
||||
- [ ] Datenbank-Dump enthält keinen Klartext-Schlüssel
|
||||
|
||||
## Abhängigkeiten
|
||||
#003
|
||||
|
||||
## 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]`.
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
"@node-rs/argon2": "^2.2.2",
|
||||
"@prisma/adapter-pg": "7.10.0",
|
||||
"@prisma/client": "7.10.0",
|
||||
"bcrypt-pbkdf": "^1.0.2",
|
||||
"clsx": "^2.1.1",
|
||||
"jose": "^6.2.12",
|
||||
"lucide-react": "1.47.0",
|
||||
|
||||
Generated
+3
@@ -184,6 +184,9 @@ importers:
|
||||
'@prisma/client':
|
||||
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)
|
||||
bcrypt-pbkdf:
|
||||
specifier: ^1.0.2
|
||||
version: 1.0.2
|
||||
clsx:
|
||||
specifier: ^2.1.1
|
||||
version: 2.1.1
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,682 @@
|
||||
import {
|
||||
createCipheriv,
|
||||
createDecipheriv,
|
||||
createHash,
|
||||
createPrivateKey,
|
||||
createPublicKey,
|
||||
generateKeyPair,
|
||||
randomBytes,
|
||||
timingSafeEqual,
|
||||
} from "node:crypto";
|
||||
import { promisify } from "node:util";
|
||||
import bcryptPbkdf from "bcrypt-pbkdf";
|
||||
|
||||
/**
|
||||
* OpenSSH-Schluesselformate (#006): Erzeugen, Schreiben und Lesen von Private Keys im
|
||||
* Format `openssh-key-v1` sowie Lesen von Public-Key-Zeilen.
|
||||
*
|
||||
* Bewusst ohne `ssh-keygen`: Der Aufruf braeuchte temporaere Dateien mit dem Private Key
|
||||
* auf der Platte des Servers, und genau die wollen wir nicht (siehe docs/PLAN.md). Node
|
||||
* `crypto` erzeugt die Schluessel, die Serialisierung ist hier von Hand nachgebaut
|
||||
* (Referenz: PROTOCOL.key und sshkey.c in OpenSSH). Die Tests pruefen das Ergebnis gegen
|
||||
* das echte `ssh-keygen`.
|
||||
*
|
||||
* Fehlermeldungen sind feste Saetze. Nichts aus der Eingabe wird in eine Meldung
|
||||
* eingesetzt, weil ein Private Key sonst ueber Log oder Oberflaeche nach aussen geraten
|
||||
* koennte.
|
||||
*/
|
||||
|
||||
const erzeugeSchluesselpaar = promisify(generateKeyPair);
|
||||
|
||||
export type ErzeugbarerSchluesseltyp = "ED25519" | "RSA4096";
|
||||
export type PrivateSchluesseltyp = "ED25519" | "RSA";
|
||||
export type PublicSchluesseltyp =
|
||||
| "ssh-ed25519"
|
||||
| "ssh-rsa"
|
||||
| "ecdsa-sha2-nistp256"
|
||||
| "ecdsa-sha2-nistp384"
|
||||
| "ecdsa-sha2-nistp521"
|
||||
| "sk-ssh-ed25519@openssh.com"
|
||||
| "sk-ecdsa-sha2-nistp256@openssh.com";
|
||||
|
||||
export const PUBLIC_SCHLUESSELTYPEN: readonly PublicSchluesseltyp[] = [
|
||||
"ssh-ed25519",
|
||||
"ssh-rsa",
|
||||
"ecdsa-sha2-nistp256",
|
||||
"ecdsa-sha2-nistp384",
|
||||
"ecdsa-sha2-nistp521",
|
||||
"sk-ssh-ed25519@openssh.com",
|
||||
"sk-ecdsa-sha2-nistp256@openssh.com",
|
||||
];
|
||||
|
||||
/** Unterhalb davon gilt RSA als gebrochen bzw. wird von aktuellem OpenSSH abgelehnt. */
|
||||
export const RSA_MINDEST_BITS = 2048;
|
||||
|
||||
/**
|
||||
* Runden fuer bcrypt_pbkdf beim Schreiben. `ssh-keygen` nimmt inzwischen 24; 16 war der
|
||||
* fruehere Standard und haelt das Verschluesseln im Worker unter einer Sekunde, obwohl
|
||||
* bcrypt-pbkdf reines JavaScript ist. Beim Lesen gilt, was in der Datei steht.
|
||||
*/
|
||||
const BCRYPT_RUNDEN = 16;
|
||||
const BCRYPT_SALT_LAENGE = 16;
|
||||
|
||||
/** Grenze gegen absichtlich teure Dateien beim Import (ssh-keygen -a erlaubt beliebig viel). */
|
||||
const BCRYPT_RUNDEN_HOECHSTENS = 1000;
|
||||
|
||||
const MAGIC = Buffer.from("openssh-key-v1\0", "latin1");
|
||||
const PEM_ANFANG = "-----BEGIN OPENSSH PRIVATE KEY-----";
|
||||
const PEM_ENDE = "-----END OPENSSH PRIVATE KEY-----";
|
||||
|
||||
export type SchluesselFormatFehlerCode =
|
||||
"ungueltig" | "nicht_unterstuetzt" | "passphrase_fehlt" | "passphrase_falsch" | "zu_schwach";
|
||||
|
||||
/**
|
||||
* Fehler beim Lesen oder Erzeugen eines Schluessels. `code` erlaubt der Oberflaeche,
|
||||
* gezielt zu reagieren (etwa nach der Passphrase zu fragen), ohne Meldungstexte zu
|
||||
* vergleichen. Die Meldung enthaelt nie Schluesselmaterial.
|
||||
*/
|
||||
export class SchluesselFormatFehler extends Error {
|
||||
override readonly name = "SchluesselFormatFehler";
|
||||
constructor(
|
||||
readonly code: SchluesselFormatFehlerCode,
|
||||
meldung: string,
|
||||
) {
|
||||
super(meldung);
|
||||
}
|
||||
}
|
||||
|
||||
function ungueltig(meldung = "Der Schlüssel ist beschädigt oder unvollständig."): SchluesselFormatFehler {
|
||||
return new SchluesselFormatFehler("ungueltig", meldung);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SSH-Wire-Format (RFC 4251, Abschnitt 5)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function uint32(wert: number): Buffer {
|
||||
const puffer = Buffer.alloc(4);
|
||||
puffer.writeUInt32BE(wert >>> 0);
|
||||
return puffer;
|
||||
}
|
||||
|
||||
function sshString(inhalt: Buffer | string): Buffer {
|
||||
const daten = typeof inhalt === "string" ? Buffer.from(inhalt, "utf8") : inhalt;
|
||||
return Buffer.concat([uint32(daten.length), daten]);
|
||||
}
|
||||
|
||||
/** Vorzeichenbehaftete Big-Endian-Zahl ohne fuehrende Nullen, bei gesetztem Hochbit mit 0x00 davor. */
|
||||
function mpint(betrag: Buffer): Buffer {
|
||||
let start = 0;
|
||||
while (start < betrag.length && betrag[start] === 0) start++;
|
||||
const ohneNullen = betrag.subarray(start);
|
||||
if (ohneNullen.length > 0 && (ohneNullen[0]! & 0x80) !== 0) {
|
||||
return sshString(Buffer.concat([Buffer.from([0]), ohneNullen]));
|
||||
}
|
||||
return sshString(ohneNullen);
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest SSH-Wire-Daten. Jeder Zugriff hinter das Ende wirft einen SchluesselFormatFehler,
|
||||
* damit abgeschnittene Eingaben nie zu RangeErrors mit Pufferausschnitten werden.
|
||||
*/
|
||||
class Leser {
|
||||
private position = 0;
|
||||
constructor(private readonly puffer: Buffer) {}
|
||||
|
||||
uint32(): number {
|
||||
if (this.position + 4 > this.puffer.length) throw ungueltig();
|
||||
const wert = this.puffer.readUInt32BE(this.position);
|
||||
this.position += 4;
|
||||
return wert;
|
||||
}
|
||||
|
||||
bytes(laenge: number): Buffer {
|
||||
if (laenge < 0 || this.position + laenge > this.puffer.length) throw ungueltig();
|
||||
const teil = this.puffer.subarray(this.position, this.position + laenge);
|
||||
this.position += laenge;
|
||||
return teil;
|
||||
}
|
||||
|
||||
string(): Buffer {
|
||||
return this.bytes(this.uint32());
|
||||
}
|
||||
|
||||
text(): string {
|
||||
return this.string().toString("utf8");
|
||||
}
|
||||
|
||||
/** Liefert den Betrag einer nicht-negativen mpint ohne fuehrende Nullen. */
|
||||
mpint(): Buffer {
|
||||
const roh = this.string();
|
||||
if (roh.length > 0 && (roh[0]! & 0x80) !== 0) throw ungueltig();
|
||||
let start = 0;
|
||||
while (start < roh.length && roh[start] === 0) start++;
|
||||
return roh.subarray(start);
|
||||
}
|
||||
|
||||
rest(): Buffer {
|
||||
return this.puffer.subarray(this.position);
|
||||
}
|
||||
|
||||
amEnde(): boolean {
|
||||
return this.position === this.puffer.length;
|
||||
}
|
||||
}
|
||||
|
||||
function bitsVonBetrag(betrag: Buffer): number {
|
||||
if (betrag.length === 0) return 0;
|
||||
return (betrag.length - 1) * 8 + (32 - Math.clz32(betrag[0]!));
|
||||
}
|
||||
|
||||
function alsBigInt(betrag: Buffer): bigint {
|
||||
return betrag.length === 0 ? 0n : BigInt("0x" + betrag.toString("hex"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fingerprint und Public Keys
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** SHA-256-Fingerprint im Format von `ssh-keygen -l`: Base64 ohne Padding. */
|
||||
export function fingerprintSha256(blob: Buffer): string {
|
||||
return "SHA256:" + createHash("sha256").update(blob).digest("base64").replace(/=+$/, "");
|
||||
}
|
||||
|
||||
/** Schluessellaenge eines `ssh-rsa`-Blobs in Bit (Laenge des Moduls n). */
|
||||
export function bitsVonRsa(blob: Buffer): number {
|
||||
const leser = new Leser(blob);
|
||||
if (leser.text() !== "ssh-rsa") {
|
||||
throw new SchluesselFormatFehler("ungueltig", "Der Schlüssel ist kein RSA-Schlüssel.");
|
||||
}
|
||||
leser.mpint(); // e
|
||||
return bitsVonBetrag(leser.mpint());
|
||||
}
|
||||
|
||||
const ECDSA_PUNKTLAENGE: Record<string, number> = {
|
||||
nistp256: 65,
|
||||
nistp384: 97,
|
||||
nistp521: 133,
|
||||
};
|
||||
|
||||
/**
|
||||
* Prueft den Aufbau eines Public-Key-Blobs. Erwartet wird genau der Typ `typ`; zusaetzliche
|
||||
* Bytes am Ende gelten als Fehler, weil OpenSSH sie ebenfalls ablehnt.
|
||||
*/
|
||||
function publicBlobPruefen(typ: PublicSchluesseltyp, blob: Buffer, rsaMindestBits: number): number | null {
|
||||
const leser = new Leser(blob);
|
||||
if (leser.text() !== typ) {
|
||||
throw new SchluesselFormatFehler(
|
||||
"ungueltig",
|
||||
"Der Schlüsseltyp in der Zeile passt nicht zum Inhalt des Schlüssels.",
|
||||
);
|
||||
}
|
||||
let bits: number | null = null;
|
||||
switch (typ) {
|
||||
case "ssh-ed25519":
|
||||
if (leser.string().length !== 32) throw ungueltig();
|
||||
bits = 256;
|
||||
break;
|
||||
case "sk-ssh-ed25519@openssh.com":
|
||||
if (leser.string().length !== 32) throw ungueltig();
|
||||
leser.string(); // application, z. B. "ssh:"
|
||||
bits = 256;
|
||||
break;
|
||||
case "ssh-rsa": {
|
||||
const e = leser.mpint();
|
||||
const n = leser.mpint();
|
||||
if (e.length === 0 || n.length === 0 || (e[e.length - 1]! & 1) === 0) throw ungueltig();
|
||||
bits = bitsVonBetrag(n);
|
||||
if (bits < rsaMindestBits) {
|
||||
throw new SchluesselFormatFehler(
|
||||
"zu_schwach",
|
||||
`RSA-Schlüssel müssen mindestens ${rsaMindestBits} Bit lang sein.`,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "ecdsa-sha2-nistp256":
|
||||
case "ecdsa-sha2-nistp384":
|
||||
case "ecdsa-sha2-nistp521":
|
||||
case "sk-ecdsa-sha2-nistp256@openssh.com": {
|
||||
const erwarteteKurve = typ.startsWith("sk-") ? "nistp256" : typ.slice("ecdsa-sha2-".length);
|
||||
if (leser.text() !== erwarteteKurve) throw ungueltig();
|
||||
const punkt = leser.string();
|
||||
if (punkt.length !== ECDSA_PUNKTLAENGE[erwarteteKurve] || punkt[0] !== 0x04) throw ungueltig();
|
||||
if (typ.startsWith("sk-")) leser.string(); // application
|
||||
bits = Number(erwarteteKurve.slice("nistp".length));
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!leser.amEnde()) throw ungueltig();
|
||||
return bits;
|
||||
}
|
||||
|
||||
export interface PublicKeyInfo {
|
||||
typ: PublicSchluesseltyp;
|
||||
blob: Buffer;
|
||||
kommentar: string;
|
||||
fingerprint: string;
|
||||
/** Schluessellaenge in Bit (RSA: Modul, ECDSA: Kurve, ed25519: 256). */
|
||||
bits: number | null;
|
||||
}
|
||||
|
||||
export interface PublicKeyLeseOptionen {
|
||||
/**
|
||||
* Mindestlaenge fuer RSA. Standard ist RSA_MINDEST_BITS (Import). Wer vorhandene
|
||||
* authorized_keys nur beobachtet, setzt 0, damit alte Schluessel sichtbar bleiben.
|
||||
*/
|
||||
rsaMindestBits?: number;
|
||||
}
|
||||
|
||||
const BASE64 = /^[A-Za-z0-9+/]+={0,2}$/;
|
||||
|
||||
function istPublicTyp(wert: string): wert is PublicSchluesseltyp {
|
||||
return (PUBLIC_SCHLUESSELTYPEN as readonly string[]).includes(wert);
|
||||
}
|
||||
|
||||
/** Steuerzeichen im Kommentar wuerden authorized_keys-Zeilen aufbrechen. */
|
||||
function kommentarPruefen(kommentar: string): void {
|
||||
if (/[\x00-\x1f\x7f]/.test(kommentar)) {
|
||||
throw new SchluesselFormatFehler("ungueltig", "Der Kommentar enthält unzulässige Steuerzeichen.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest eine Public-Key-Zeile `typ base64 [kommentar]` (ohne authorized_keys-Optionen).
|
||||
*/
|
||||
export function publicKeyZeileLesen(zeile: string, optionen: PublicKeyLeseOptionen = {}): PublicKeyInfo {
|
||||
const treffer = /^(\S+)[ \t]+(\S+)(?:[ \t]+(.*))?$/.exec(zeile.trim());
|
||||
if (!treffer) {
|
||||
throw new SchluesselFormatFehler("ungueltig", "Die Zeile ist kein Public Key im OpenSSH-Format.");
|
||||
}
|
||||
const typText = treffer[1]!;
|
||||
const base64 = treffer[2]!;
|
||||
const kommentar = (treffer[3] ?? "").trim();
|
||||
if (!istPublicTyp(typText)) {
|
||||
throw new SchluesselFormatFehler("nicht_unterstuetzt", "Dieser Schlüsseltyp wird nicht unterstützt.");
|
||||
}
|
||||
if (!BASE64.test(base64) || base64.length % 4 !== 0) {
|
||||
throw new SchluesselFormatFehler("ungueltig", "Der Public Key ist nicht korrekt Base64-kodiert.");
|
||||
}
|
||||
const blob = Buffer.from(base64, "base64");
|
||||
kommentarPruefen(kommentar);
|
||||
const bits = publicBlobPruefen(typText, blob, optionen.rsaMindestBits ?? RSA_MINDEST_BITS);
|
||||
return { typ: typText, blob, kommentar, fingerprint: fingerprintSha256(blob), bits };
|
||||
}
|
||||
|
||||
function publicKeyZeileBauen(blob: Buffer, typ: string, kommentar: string): string {
|
||||
const basis = `${typ} ${blob.toString("base64")}`;
|
||||
return kommentar ? `${basis} ${kommentar}` : basis;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private Keys: Hilfen
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 48 Byte aus bcrypt_pbkdf: 32 Byte AES-Schluessel, 16 Byte Zaehler-Startwert. */
|
||||
function bcryptAbleiten(passphrase: string, salt: Buffer, runden: number): Buffer {
|
||||
const passwortBytes = Buffer.from(passphrase, "utf8");
|
||||
const ergebnis = Buffer.alloc(48);
|
||||
try {
|
||||
const rueckgabe = bcryptPbkdf.pbkdf(
|
||||
passwortBytes,
|
||||
passwortBytes.length,
|
||||
salt,
|
||||
salt.length,
|
||||
ergebnis,
|
||||
ergebnis.length,
|
||||
runden,
|
||||
);
|
||||
if (rueckgabe !== 0) throw ungueltig();
|
||||
return ergebnis;
|
||||
} finally {
|
||||
passwortBytes.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
/** OpenSSH bricht die Base64-Zeilen nach 70 Zeichen um. */
|
||||
function pemKodieren(datei: Buffer): string {
|
||||
const base64 = datei.toString("base64");
|
||||
const zeilen: string[] = [];
|
||||
for (let i = 0; i < base64.length; i += 70) zeilen.push(base64.slice(i, i + 70));
|
||||
return `${PEM_ANFANG}\n${zeilen.join("\n")}\n${PEM_ENDE}\n`;
|
||||
}
|
||||
|
||||
function pemDekodieren(text: string): Buffer {
|
||||
const normalisiert = text.replace(/\r\n?/g, "\n").trim();
|
||||
if (!normalisiert.startsWith(PEM_ANFANG)) {
|
||||
if (/-----BEGIN [A-Z ]*PRIVATE KEY-----/.test(normalisiert)) {
|
||||
throw new SchluesselFormatFehler(
|
||||
"nicht_unterstuetzt",
|
||||
"Nur Private Keys im OpenSSH-Format werden unterstützt („BEGIN OPENSSH PRIVATE KEY“).",
|
||||
);
|
||||
}
|
||||
throw new SchluesselFormatFehler("ungueltig", "Der Text ist kein Private Key im OpenSSH-Format.");
|
||||
}
|
||||
if (!normalisiert.endsWith(PEM_ENDE)) {
|
||||
throw ungueltig("Der Private Key ist unvollständig (Endzeile fehlt).");
|
||||
}
|
||||
const rumpf = normalisiert
|
||||
.slice(PEM_ANFANG.length, normalisiert.length - PEM_ENDE.length)
|
||||
.replace(/\s+/g, "");
|
||||
if (!BASE64.test(rumpf) || rumpf.length % 4 !== 0) {
|
||||
throw ungueltig("Der Private Key ist nicht korrekt Base64-kodiert.");
|
||||
}
|
||||
return Buffer.from(rumpf, "base64");
|
||||
}
|
||||
|
||||
/** PKCS#8-Huelle fuer einen ed25519-Seed (RFC 8410), damit Node daraus den Public Key ableitet. */
|
||||
const ED25519_PKCS8_PRAEFIX = Buffer.from("302e020100300506032b657004220420", "hex");
|
||||
|
||||
function ed25519PublicAusSeed(seed: Buffer): Buffer {
|
||||
const der = Buffer.concat([ED25519_PKCS8_PRAEFIX, seed]);
|
||||
try {
|
||||
const jwk = createPublicKey(createPrivateKey({ key: der, format: "der", type: "pkcs8" })).export({
|
||||
format: "jwk",
|
||||
});
|
||||
return Buffer.from(jwk.x ?? "", "base64url");
|
||||
} catch {
|
||||
throw ungueltig();
|
||||
} finally {
|
||||
der.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
function gleich(a: Buffer, b: Buffer): boolean {
|
||||
return a.length === b.length && timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private Keys: Erzeugen und Schreiben
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface SchluesselpaarOptionen {
|
||||
kommentar: string;
|
||||
/** Leer oder nicht gesetzt: unverschluesselt, wie `ssh-keygen -N ''`. */
|
||||
passphrase?: string;
|
||||
}
|
||||
|
||||
export interface ErzeugtesSchluesselpaar {
|
||||
/** z. B. "ssh-ed25519 AAAA… kommentar" */
|
||||
publicKeyZeile: string;
|
||||
/** "-----BEGIN OPENSSH PRIVATE KEY-----…" */
|
||||
privateKeyOpenssh: string;
|
||||
/** "SHA256:…" ohne Padding */
|
||||
fingerprint: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut die Datei `openssh-key-v1` (PROTOCOL.key). `privatTeil` sind die typabhaengigen
|
||||
* Felder ohne Kommentar.
|
||||
*/
|
||||
function privateKeySchreiben(
|
||||
publicBlob: Buffer,
|
||||
privatTeil: Buffer,
|
||||
kommentar: string,
|
||||
passphrase: string | undefined,
|
||||
): string {
|
||||
const verschluesseln = passphrase !== undefined && passphrase !== "";
|
||||
const blockgroesse = verschluesseln ? 16 : 8;
|
||||
// Zweimal derselbe Zufallswert: Beim Lesen verraet ein Unterschied die falsche Passphrase.
|
||||
const checkint = randomBytes(4);
|
||||
const ohnePadding = Buffer.concat([checkint, checkint, privatTeil, sshString(kommentar)]);
|
||||
const paddingLaenge = (blockgroesse - (ohnePadding.length % blockgroesse)) % blockgroesse;
|
||||
const padding = Buffer.from(Array.from({ length: paddingLaenge }, (_, i) => i + 1));
|
||||
const abschnitt = Buffer.concat([ohnePadding, padding]);
|
||||
ohnePadding.fill(0);
|
||||
|
||||
let cipherName = "none";
|
||||
let kdfName = "none";
|
||||
let kdfOptionen = Buffer.alloc(0);
|
||||
let inhalt = abschnitt;
|
||||
if (verschluesseln) {
|
||||
const salt = randomBytes(BCRYPT_SALT_LAENGE);
|
||||
const abgeleitet = bcryptAbleiten(passphrase, salt, BCRYPT_RUNDEN);
|
||||
try {
|
||||
const cipher = createCipheriv("aes-256-ctr", abgeleitet.subarray(0, 32), abgeleitet.subarray(32, 48));
|
||||
inhalt = Buffer.concat([cipher.update(abschnitt), cipher.final()]);
|
||||
} finally {
|
||||
abgeleitet.fill(0);
|
||||
}
|
||||
cipherName = "aes256-ctr";
|
||||
kdfName = "bcrypt";
|
||||
kdfOptionen = Buffer.concat([sshString(salt), uint32(BCRYPT_RUNDEN)]);
|
||||
}
|
||||
|
||||
const datei = Buffer.concat([
|
||||
MAGIC,
|
||||
sshString(cipherName),
|
||||
sshString(kdfName),
|
||||
sshString(kdfOptionen),
|
||||
uint32(1),
|
||||
sshString(publicBlob),
|
||||
sshString(inhalt),
|
||||
]);
|
||||
const pem = pemKodieren(datei);
|
||||
datei.fill(0);
|
||||
abschnitt.fill(0);
|
||||
return pem;
|
||||
}
|
||||
|
||||
/**
|
||||
* Erzeugt ein neues Schluesselpaar. Asynchron, weil RSA-4096 sonst den Worker fuer
|
||||
* spuerbare Zeit blockiert. Der Private Key liegt danach als String vor; den kann
|
||||
* JavaScript nicht ueberschreiben – Aufrufer sollen ihn sofort versiegeln.
|
||||
*/
|
||||
export async function schluesselpaarErzeugen(
|
||||
typ: ErzeugbarerSchluesseltyp,
|
||||
{ kommentar, passphrase }: SchluesselpaarOptionen,
|
||||
): Promise<ErzeugtesSchluesselpaar> {
|
||||
kommentarPruefen(kommentar);
|
||||
let typName: string;
|
||||
let publicBlob: Buffer;
|
||||
let privatTeil: Buffer;
|
||||
|
||||
if (typ === "ED25519") {
|
||||
typName = "ssh-ed25519";
|
||||
const { privateKey } = await erzeugeSchluesselpaar("ed25519");
|
||||
const jwk = privateKey.export({ format: "jwk" });
|
||||
const oeffentlich = Buffer.from(jwk.x ?? "", "base64url");
|
||||
const seed = Buffer.from(jwk.d ?? "", "base64url");
|
||||
// OpenSSH speichert 64 Byte: Seed gefolgt vom Public Key (Format von ref10/NaCl).
|
||||
const geheim = Buffer.concat([seed, oeffentlich]);
|
||||
publicBlob = Buffer.concat([sshString(typName), sshString(oeffentlich)]);
|
||||
privatTeil = Buffer.concat([sshString(typName), sshString(oeffentlich), sshString(geheim)]);
|
||||
seed.fill(0);
|
||||
geheim.fill(0);
|
||||
} else {
|
||||
typName = "ssh-rsa";
|
||||
const { privateKey } = await erzeugeSchluesselpaar("rsa", {
|
||||
modulusLength: 4096,
|
||||
publicExponent: 0x10001,
|
||||
});
|
||||
const jwk = privateKey.export({ format: "jwk" });
|
||||
const feld = (wert: string | undefined) => Buffer.from(wert ?? "", "base64url");
|
||||
const n = feld(jwk.n);
|
||||
const e = feld(jwk.e);
|
||||
const d = feld(jwk.d);
|
||||
const p = feld(jwk.p);
|
||||
const q = feld(jwk.q);
|
||||
const iqmp = feld(jwk.qi);
|
||||
// Public-Teil: e vor n (RFC 4253). Private-Teil: n e d iqmp p q (sshkey.c). JWK `qi`
|
||||
// ist q^-1 mod p und damit genau das `iqmp` von OpenSSH.
|
||||
publicBlob = Buffer.concat([sshString(typName), mpint(e), mpint(n)]);
|
||||
privatTeil = Buffer.concat([
|
||||
sshString(typName),
|
||||
mpint(n),
|
||||
mpint(e),
|
||||
mpint(d),
|
||||
mpint(iqmp),
|
||||
mpint(p),
|
||||
mpint(q),
|
||||
]);
|
||||
for (const geheim of [d, p, q, iqmp]) geheim.fill(0);
|
||||
}
|
||||
|
||||
try {
|
||||
const privateKeyOpenssh = privateKeySchreiben(publicBlob, privatTeil, kommentar, passphrase);
|
||||
return {
|
||||
publicKeyZeile: publicKeyZeileBauen(publicBlob, typName, kommentar),
|
||||
privateKeyOpenssh,
|
||||
fingerprint: fingerprintSha256(publicBlob),
|
||||
};
|
||||
} finally {
|
||||
privatTeil.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private Keys: Lesen
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PrivateKeyInfo {
|
||||
typ: PrivateSchluesseltyp;
|
||||
bits: number;
|
||||
publicKeyZeile: string;
|
||||
kommentar: string;
|
||||
fingerprint: string;
|
||||
/** War die Datei mit einer Passphrase geschuetzt? */
|
||||
verschluesselt: boolean;
|
||||
}
|
||||
|
||||
export interface PrivateKeyLeseOptionen {
|
||||
rsaMindestBits?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest einen Private Key im OpenSSH-Format, entschluesselt ihn bei Bedarf und prueft,
|
||||
* dass Private und Public Key zusammengehoeren. Gibt nur oeffentliche Angaben zurueck.
|
||||
*/
|
||||
export function privateKeyLesen(
|
||||
openssh: string,
|
||||
passphrase?: string,
|
||||
optionen: PrivateKeyLeseOptionen = {},
|
||||
): PrivateKeyInfo {
|
||||
const datei = pemDekodieren(openssh);
|
||||
const zuLoeschen: Buffer[] = [datei];
|
||||
try {
|
||||
const leser = new Leser(datei);
|
||||
if (!gleich(leser.bytes(MAGIC.length), MAGIC)) {
|
||||
throw new SchluesselFormatFehler("ungueltig", "Der Text ist kein Private Key im OpenSSH-Format.");
|
||||
}
|
||||
const cipherName = leser.text();
|
||||
const kdfName = leser.text();
|
||||
const kdfOptionen = leser.string();
|
||||
if (leser.uint32() !== 1) {
|
||||
throw new SchluesselFormatFehler(
|
||||
"nicht_unterstuetzt",
|
||||
"Dateien mit mehr als einem Schlüssel werden nicht unterstützt.",
|
||||
);
|
||||
}
|
||||
const publicBlob = Buffer.from(leser.string());
|
||||
const inhalt = leser.string();
|
||||
if (!leser.amEnde()) throw ungueltig();
|
||||
|
||||
const publicTyp = new Leser(publicBlob).text();
|
||||
if (publicTyp !== "ssh-ed25519" && publicTyp !== "ssh-rsa") {
|
||||
throw new SchluesselFormatFehler(
|
||||
"nicht_unterstuetzt",
|
||||
"Es werden nur Private Keys vom Typ ed25519 und RSA unterstützt.",
|
||||
);
|
||||
}
|
||||
// Der Public Key liegt unverschluesselt vor; eine zu kurze RSA-Laenge faellt so auf,
|
||||
// bevor jemand nach der Passphrase gefragt wird.
|
||||
const bits = publicBlobPruefen(publicTyp, publicBlob, optionen.rsaMindestBits ?? RSA_MINDEST_BITS) ?? 0;
|
||||
|
||||
let abschnitt: Buffer;
|
||||
let blockgroesse: number;
|
||||
const verschluesselt = cipherName !== "none";
|
||||
if (cipherName === "none") {
|
||||
if (kdfName !== "none" || kdfOptionen.length !== 0) throw ungueltig();
|
||||
abschnitt = inhalt;
|
||||
blockgroesse = 8;
|
||||
} else if (cipherName === "aes256-ctr") {
|
||||
if (kdfName !== "bcrypt") {
|
||||
throw new SchluesselFormatFehler(
|
||||
"nicht_unterstuetzt",
|
||||
"Das Verfahren zur Ableitung des Schlüssels wird nicht unterstützt.",
|
||||
);
|
||||
}
|
||||
if (passphrase === undefined || passphrase === "") {
|
||||
throw new SchluesselFormatFehler(
|
||||
"passphrase_fehlt",
|
||||
"Der Private Key ist mit einer Passphrase geschützt.",
|
||||
);
|
||||
}
|
||||
const kdfLeser = new Leser(kdfOptionen);
|
||||
const salt = kdfLeser.string();
|
||||
const runden = kdfLeser.uint32();
|
||||
if (!kdfLeser.amEnde() || salt.length === 0 || runden < 1 || runden > BCRYPT_RUNDEN_HOECHSTENS) {
|
||||
throw ungueltig();
|
||||
}
|
||||
blockgroesse = 16;
|
||||
if (inhalt.length % blockgroesse !== 0) throw ungueltig();
|
||||
const abgeleitet = bcryptAbleiten(passphrase, salt, runden);
|
||||
try {
|
||||
const decipher = createDecipheriv(
|
||||
"aes-256-ctr",
|
||||
abgeleitet.subarray(0, 32),
|
||||
abgeleitet.subarray(32, 48),
|
||||
);
|
||||
abschnitt = Buffer.concat([decipher.update(inhalt), decipher.final()]);
|
||||
zuLoeschen.push(abschnitt);
|
||||
} finally {
|
||||
abgeleitet.fill(0);
|
||||
}
|
||||
} else {
|
||||
throw new SchluesselFormatFehler(
|
||||
"nicht_unterstuetzt",
|
||||
"Die Verschlüsselung dieses Private Keys wird nicht unterstützt (erwartet: aes256-ctr).",
|
||||
);
|
||||
}
|
||||
if (abschnitt.length % blockgroesse !== 0) throw ungueltig();
|
||||
|
||||
const privat = new Leser(abschnitt);
|
||||
if (privat.uint32() !== privat.uint32()) {
|
||||
throw verschluesselt
|
||||
? new SchluesselFormatFehler("passphrase_falsch", "Die Passphrase ist falsch.")
|
||||
: ungueltig();
|
||||
}
|
||||
if (privat.text() !== publicTyp) throw ungueltig();
|
||||
|
||||
let typ: PrivateSchluesseltyp;
|
||||
const erwartet = new Leser(publicBlob);
|
||||
erwartet.text();
|
||||
if (publicTyp === "ssh-ed25519") {
|
||||
typ = "ED25519";
|
||||
const oeffentlich = privat.string();
|
||||
const geheim = privat.string();
|
||||
if (geheim.length !== 64 || !gleich(oeffentlich, erwartet.string())) throw ungueltig();
|
||||
if (!gleich(geheim.subarray(32), oeffentlich)) throw ungueltig();
|
||||
if (!gleich(ed25519PublicAusSeed(geheim.subarray(0, 32)), oeffentlich)) throw ungueltig();
|
||||
} else {
|
||||
typ = "RSA";
|
||||
const n = privat.mpint();
|
||||
const e = privat.mpint();
|
||||
const d = privat.mpint();
|
||||
privat.mpint(); // iqmp
|
||||
const p = privat.mpint();
|
||||
const q = privat.mpint();
|
||||
const erwartetesE = erwartet.mpint();
|
||||
const erwartetesN = erwartet.mpint();
|
||||
if (!gleich(n, erwartetesN) || !gleich(e, erwartetesE) || d.length === 0) throw ungueltig();
|
||||
// Billige Plausibilitaet: Ein Private Key, dessen Primfaktoren nicht n ergeben, ist kaputt.
|
||||
if (alsBigInt(p) * alsBigInt(q) !== alsBigInt(n)) throw ungueltig();
|
||||
}
|
||||
|
||||
const kommentar = privat.text();
|
||||
kommentarPruefen(kommentar);
|
||||
const padding = privat.rest();
|
||||
if (padding.length >= blockgroesse || padding.some((wert, i) => wert !== i + 1)) throw ungueltig();
|
||||
|
||||
return {
|
||||
typ,
|
||||
bits,
|
||||
publicKeyZeile: publicKeyZeileBauen(publicBlob, publicTyp, kommentar),
|
||||
kommentar,
|
||||
fingerprint: fingerprintSha256(publicBlob),
|
||||
verschluesselt,
|
||||
};
|
||||
} finally {
|
||||
for (const puffer of zuLoeschen) puffer.fill(0);
|
||||
}
|
||||
}
|
||||
Vendored
+20
@@ -0,0 +1,20 @@
|
||||
// Das Paket bringt keine Typen mit, und @types/bcrypt-pbkdf gibt es nicht (#006).
|
||||
// Deklariert ist nur, was src/lib/ssh/openssh.ts tatsaechlich benutzt.
|
||||
declare module "bcrypt-pbkdf" {
|
||||
/**
|
||||
* bcrypt_pbkdf wie in OpenSSH. Schreibt `keylen` Byte in `key` und gibt 0 zurueck,
|
||||
* bei ungueltigen Argumenten (leere Passphrase, leeres Salt, rounds < 1) -1.
|
||||
*/
|
||||
export function pbkdf(
|
||||
pass: Uint8Array,
|
||||
passlen: number,
|
||||
salt: Uint8Array,
|
||||
saltlen: number,
|
||||
key: Uint8Array,
|
||||
keylen: number,
|
||||
rounds: number,
|
||||
): number;
|
||||
|
||||
const bcryptPbkdf: { pbkdf: typeof pbkdf };
|
||||
export default bcryptPbkdf;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { GESCHWAERZT, schwaerzen } from "@/lib/protokoll-schwaerzen";
|
||||
|
||||
const KEY =
|
||||
"-----BEGIN OPENSSH PRIVATE KEY-----\n" +
|
||||
"b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\n" +
|
||||
"-----END OPENSSH PRIVATE KEY-----";
|
||||
|
||||
describe("schwaerzen", () => {
|
||||
it("ersetzt geheime Felder in verschachtelten Objekten und Arrays", () => {
|
||||
const eingabe = {
|
||||
name: "backup",
|
||||
privateKeyOpenssh: "irgendwas",
|
||||
privatVerschluesselt: Buffer.from("x"),
|
||||
einstellungen: {
|
||||
passwort: "hunter2",
|
||||
Password: "hunter2",
|
||||
sshPassphrase: "abc",
|
||||
client_secret: "s",
|
||||
geheimnisWert: { tief: 1 },
|
||||
accessToken: "t",
|
||||
},
|
||||
liste: [{ token: "t", erlaubt: "ja" }, "frei", 3],
|
||||
};
|
||||
expect(schwaerzen(eingabe)).toEqual({
|
||||
name: "backup",
|
||||
privateKeyOpenssh: GESCHWAERZT,
|
||||
privatVerschluesselt: GESCHWAERZT,
|
||||
einstellungen: {
|
||||
passwort: GESCHWAERZT,
|
||||
Password: GESCHWAERZT,
|
||||
sshPassphrase: GESCHWAERZT,
|
||||
client_secret: GESCHWAERZT,
|
||||
geheimnisWert: GESCHWAERZT,
|
||||
accessToken: GESCHWAERZT,
|
||||
},
|
||||
liste: [{ token: GESCHWAERZT, erlaubt: "ja" }, "frei", 3],
|
||||
});
|
||||
// Eingabe bleibt unberuehrt.
|
||||
expect(eingabe.einstellungen.passwort).toBe("hunter2");
|
||||
});
|
||||
|
||||
it("findet Private Keys in beliebigen Zeichenketten", () => {
|
||||
const ergebnis = schwaerzen({ notiz: `vorher ${KEY} nachher`, tief: [[KEY]] }) as {
|
||||
notiz: string;
|
||||
tief: string[][];
|
||||
};
|
||||
expect(ergebnis.notiz).toBe(`vorher ${GESCHWAERZT} nachher`);
|
||||
expect(ergebnis.tief[0]![0]).toBe(GESCHWAERZT);
|
||||
expect(JSON.stringify(ergebnis)).not.toContain("b3BlbnNzaC1rZXkt");
|
||||
});
|
||||
|
||||
it("schwärzt Keys in Fehlermeldungen und Stacktraces", () => {
|
||||
const fehler = new Error(`Parser scheiterte an ${KEY}`, { cause: { passphrase: "x", text: KEY } });
|
||||
const ergebnis = schwaerzen({ fehler }) as { fehler: Record<string, unknown> };
|
||||
expect(ergebnis.fehler["name"]).toBe("Error");
|
||||
expect(ergebnis.fehler["message"]).toBe(`Parser scheiterte an ${GESCHWAERZT}`);
|
||||
expect(JSON.stringify(ergebnis)).not.toContain("b3BlbnNzaC1rZXkt");
|
||||
expect(JSON.stringify(ergebnis)).not.toContain("PRIVATE KEY");
|
||||
expect(ergebnis.fehler["cause"]).toEqual({ passphrase: GESCHWAERZT, text: GESCHWAERZT });
|
||||
});
|
||||
|
||||
it("schwärzt auch abgeschnittene Keys ohne END-Zeile", () => {
|
||||
const abgeschnitten = KEY.slice(0, 60);
|
||||
expect(schwaerzen(`log: ${abgeschnitten}`)).toBe(`log: ${GESCHWAERZT}`);
|
||||
});
|
||||
|
||||
it("schwärzt andere PEM-Private-Keys (PKCS#1, PKCS#8)", () => {
|
||||
for (const art of ["RSA PRIVATE KEY", "PRIVATE KEY", "ENCRYPTED PRIVATE KEY"]) {
|
||||
const pem = `-----BEGIN ${art}-----\nMIIEow\n-----END ${art}-----`;
|
||||
expect(schwaerzen(pem)).toBe(GESCHWAERZT);
|
||||
}
|
||||
});
|
||||
|
||||
it("lässt Public Keys und harmlose Werte stehen", () => {
|
||||
const wert = {
|
||||
publicKeyZeile: "ssh-ed25519 AAAAC3Nza x",
|
||||
fingerprint: "SHA256:abc",
|
||||
anzahl: 3,
|
||||
aktiv: true,
|
||||
leer: null,
|
||||
zeit: new Date(0),
|
||||
};
|
||||
expect(schwaerzen(wert)).toEqual(wert);
|
||||
expect(schwaerzen("-----BEGIN CERTIFICATE-----")).toBe("-----BEGIN CERTIFICATE-----");
|
||||
});
|
||||
|
||||
it("ersetzt rohe Bytes und übersteht Zyklen", () => {
|
||||
const zyklisch: Record<string, unknown> = { daten: Buffer.from("geheim") };
|
||||
zyklisch["selbst"] = zyklisch;
|
||||
expect(schwaerzen(zyklisch)).toEqual({ daten: "[Binärdaten]", selbst: "[zirkulär]" });
|
||||
});
|
||||
|
||||
it("zeigt mehrfach referenzierte Teilbäume vollständig", () => {
|
||||
const geteilt = { a: 1 };
|
||||
expect(schwaerzen({ x: geteilt, y: geteilt })).toEqual({ x: { a: 1 }, y: { a: 1 } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
EntschluesselungsFehler,
|
||||
MasterKeyFehler,
|
||||
masterKeyLaden,
|
||||
oeffnen,
|
||||
UNSICHER_ERLAUBT_VARIABLE,
|
||||
versiegeln,
|
||||
} from "@/lib/krypto/umschlag";
|
||||
|
||||
const ZUSATZ = "schluessel:k_8f3a:SHA256:abc";
|
||||
const KLARTEXT =
|
||||
"-----BEGIN OPENSSH PRIVATE KEY-----\nnur ein Platzhalter\n-----END OPENSSH PRIVATE KEY-----\n";
|
||||
|
||||
describe("versiegeln und oeffnen", () => {
|
||||
const masterKey = randomBytes(32);
|
||||
|
||||
it("Rundreise liefert den Klartext", () => {
|
||||
const umschlag = versiegeln(Buffer.from(KLARTEXT), masterKey, ZUSATZ);
|
||||
expect(umschlag.masterKeyVersion).toBe(1);
|
||||
expect(oeffnen(umschlag, masterKey, ZUSATZ).toString()).toBe(KLARTEXT);
|
||||
});
|
||||
|
||||
it("hält das dokumentierte Format ein", () => {
|
||||
const umschlag = versiegeln(Buffer.from(KLARTEXT), masterKey, ZUSATZ, 3);
|
||||
expect(umschlag.chiffrat[0]).toBe(1);
|
||||
expect(umschlag.chiffrat.length).toBe(1 + 12 + 16 + Buffer.byteLength(KLARTEXT));
|
||||
expect(umschlag.dekVerschluesselt.length).toBe(1 + 12 + 16 + 32);
|
||||
expect(umschlag.masterKeyVersion).toBe(3);
|
||||
expect(umschlag.chiffrat.toString("latin1")).not.toContain("Platzhalter");
|
||||
});
|
||||
|
||||
it("liefert bei jedem Versiegeln ein anderes Chiffrat", () => {
|
||||
const a = versiegeln(Buffer.from(KLARTEXT), masterKey, ZUSATZ);
|
||||
const b = versiegeln(Buffer.from(KLARTEXT), masterKey, ZUSATZ);
|
||||
expect(a.chiffrat.equals(b.chiffrat)).toBe(false);
|
||||
expect(a.dekVerschluesselt.equals(b.dekVerschluesselt)).toBe(false);
|
||||
});
|
||||
|
||||
it("lehnt einen falschen Master-Key ab", () => {
|
||||
const umschlag = versiegeln(Buffer.from(KLARTEXT), masterKey, ZUSATZ);
|
||||
expect(() => oeffnen(umschlag, randomBytes(32), ZUSATZ)).toThrow(EntschluesselungsFehler);
|
||||
});
|
||||
|
||||
it("lehnt ein falsches AAD ab (vertauschte Datensätze)", () => {
|
||||
const umschlag = versiegeln(Buffer.from(KLARTEXT), masterKey, ZUSATZ);
|
||||
expect(() => oeffnen(umschlag, masterKey, "schluessel:k_anders:SHA256:abc")).toThrow(
|
||||
EntschluesselungsFehler,
|
||||
);
|
||||
});
|
||||
|
||||
it("lehnt eine falsche Master-Key-Version ab", () => {
|
||||
const umschlag = versiegeln(Buffer.from(KLARTEXT), masterKey, ZUSATZ, 1);
|
||||
expect(() => oeffnen({ ...umschlag, masterKeyVersion: 2 }, masterKey, ZUSATZ)).toThrow(
|
||||
EntschluesselungsFehler,
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["chiffrat", "dekVerschluesselt"] as const)("erkennt jeden Bit-Flip in %s", (feld) => {
|
||||
const umschlag = versiegeln(Buffer.from(KLARTEXT), masterKey, ZUSATZ);
|
||||
// Version, IV, Tag und Daten: jede Stelle muss auffallen.
|
||||
for (const stelle of [0, 1, 13, 29, umschlag[feld].length - 1]) {
|
||||
const kaputt = Buffer.from(umschlag[feld]);
|
||||
kaputt[stelle]! ^= 0x01;
|
||||
expect(() => oeffnen({ ...umschlag, [feld]: kaputt }, masterKey, ZUSATZ)).toThrow(
|
||||
EntschluesselungsFehler,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("lehnt abgeschnittene Puffer ab", () => {
|
||||
const umschlag = versiegeln(Buffer.from(KLARTEXT), masterKey, ZUSATZ);
|
||||
expect(() =>
|
||||
oeffnen({ ...umschlag, chiffrat: umschlag.chiffrat.subarray(0, 20) }, masterKey, ZUSATZ),
|
||||
).toThrow(EntschluesselungsFehler);
|
||||
});
|
||||
|
||||
it("verlangt einen 32-Byte-Master-Key", () => {
|
||||
expect(() => versiegeln(Buffer.from("x"), randomBytes(16), ZUSATZ)).toThrow(MasterKeyFehler);
|
||||
});
|
||||
|
||||
it("nennt in Fehlermeldungen weder Klartext noch Schlüssel", () => {
|
||||
const umschlag = versiegeln(Buffer.from(KLARTEXT), masterKey, ZUSATZ);
|
||||
try {
|
||||
oeffnen(umschlag, randomBytes(32), ZUSATZ);
|
||||
expect.unreachable();
|
||||
} catch (fehler) {
|
||||
const text = String((fehler as Error).message) + String((fehler as Error).stack);
|
||||
expect(text).not.toContain("Platzhalter");
|
||||
expect(text).not.toContain(masterKey.toString("hex"));
|
||||
expect(text).not.toContain(masterKey.toString("base64"));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("masterKeyLaden", () => {
|
||||
let verzeichnis: string;
|
||||
const schluessel = randomBytes(32);
|
||||
|
||||
beforeAll(async () => {
|
||||
verzeichnis = await mkdtemp(join(tmpdir(), "sshkv-masterkey-"));
|
||||
});
|
||||
afterAll(async () => {
|
||||
await rm(verzeichnis, { recursive: true, force: true });
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function datei(name: string, inhalt: Buffer | string, modus = 0o600): Promise<string> {
|
||||
const pfad = join(verzeichnis, name);
|
||||
await writeFile(pfad, inhalt);
|
||||
await chmod(pfad, modus);
|
||||
return pfad;
|
||||
}
|
||||
|
||||
it.each([
|
||||
["roh", schluessel],
|
||||
["base64", schluessel.toString("base64")],
|
||||
["base64 mit Zeilenende", schluessel.toString("base64") + "\n"],
|
||||
["hex", schluessel.toString("hex")],
|
||||
["hex mit CRLF", schluessel.toString("hex") + "\r\n"],
|
||||
])("liest %s", async (name, inhalt) => {
|
||||
const geladen = await masterKeyLaden(await datei(name, inhalt));
|
||||
expect(geladen.equals(schluessel)).toBe(true);
|
||||
});
|
||||
|
||||
it("lehnt falsche Längen ab", async () => {
|
||||
await expect(masterKeyLaden(await datei("kurz", randomBytes(31)))).rejects.toThrow(MasterKeyFehler);
|
||||
await expect(masterKeyLaden(await datei("lang", randomBytes(33)))).rejects.toThrow(MasterKeyFehler);
|
||||
await expect(masterKeyLaden(await datei("hex-kurz", randomBytes(31).toString("hex")))).rejects.toThrow(
|
||||
"32-Byte-Schlüssel",
|
||||
);
|
||||
});
|
||||
|
||||
it("lehnt einen Schlüssel aus lauter Nullbytes ab", async () => {
|
||||
await expect(masterKeyLaden(await datei("null", Buffer.alloc(32)))).rejects.toThrow("Nullbytes");
|
||||
});
|
||||
|
||||
it("lehnt eine fehlende Datei ab", async () => {
|
||||
await expect(masterKeyLaden(join(verzeichnis, "gibt-es-nicht"))).rejects.toThrow(MasterKeyFehler);
|
||||
});
|
||||
|
||||
it("verweigert Dateien, die Gruppe oder Welt lesen dürfen", async () => {
|
||||
for (const modus of [0o640, 0o604, 0o644]) {
|
||||
const pfad = await datei(`offen-${modus.toString(8)}`, schluessel, modus);
|
||||
await expect(masterKeyLaden(pfad)).rejects.toThrow("chmod 600");
|
||||
}
|
||||
expect((await masterKeyLaden(await datei("nur-lesen", schluessel, 0o400))).equals(schluessel)).toBe(true);
|
||||
});
|
||||
|
||||
it("lädt offene Dateien nur mit ausdrücklicher Erlaubnis und warnt dann", async () => {
|
||||
vi.stubEnv(UNSICHER_ERLAUBT_VARIABLE, "1");
|
||||
const warnung = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const geladen = await masterKeyLaden(await datei("offen-erlaubt", schluessel.toString("hex"), 0o644));
|
||||
expect(geladen.equals(schluessel)).toBe(true);
|
||||
expect(warnung).toHaveBeenCalledOnce();
|
||||
const ausgabe = String(warnung.mock.calls[0]?.[0]);
|
||||
expect(ausgabe).not.toContain(schluessel.toString("hex"));
|
||||
expect(ausgabe).not.toContain(schluessel.toString("base64"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { generateKeyPairSync } from "node:crypto";
|
||||
|
||||
/** Kleine Wire-Format-Helfer, um gezielt kaputte oder seltene Blobs zu bauen (#006). */
|
||||
export function sshString(inhalt: Buffer | string): Buffer {
|
||||
const daten = typeof inhalt === "string" ? Buffer.from(inhalt) : inhalt;
|
||||
const laenge = Buffer.alloc(4);
|
||||
laenge.writeUInt32BE(daten.length);
|
||||
return Buffer.concat([laenge, daten]);
|
||||
}
|
||||
|
||||
export function mpint(betrag: Buffer): Buffer {
|
||||
return sshString((betrag[0]! & 0x80) !== 0 ? Buffer.concat([Buffer.from([0]), betrag]) : betrag);
|
||||
}
|
||||
|
||||
export function zeile(typ: string, blob: Buffer, kommentar = "test"): string {
|
||||
return `${typ} ${blob.toString("base64")} ${kommentar}`;
|
||||
}
|
||||
|
||||
export function rsaBlob(bits: number): Buffer {
|
||||
const { publicKey } = generateKeyPairSync("rsa", { modulusLength: bits });
|
||||
const jwk = publicKey.export({ format: "jwk" });
|
||||
return Buffer.concat([
|
||||
sshString("ssh-rsa"),
|
||||
mpint(Buffer.from(jwk.e!, "base64url")),
|
||||
mpint(Buffer.from(jwk.n!, "base64url")),
|
||||
]);
|
||||
}
|
||||
|
||||
const KURVEN = { nistp256: "P-256", nistp384: "P-384", nistp521: "P-521" } as const;
|
||||
|
||||
export function ecdsaPunkt(kurve: keyof typeof KURVEN): Buffer {
|
||||
const { publicKey } = generateKeyPairSync("ec", { namedCurve: KURVEN[kurve] });
|
||||
const jwk = publicKey.export({ format: "jwk" });
|
||||
return Buffer.concat([
|
||||
Buffer.from([4]),
|
||||
Buffer.from(jwk.x!, "base64url"),
|
||||
Buffer.from(jwk.y!, "base64url"),
|
||||
]);
|
||||
}
|
||||
|
||||
export function ecdsaBlob(kurve: keyof typeof KURVEN): Buffer {
|
||||
return Buffer.concat([sshString(`ecdsa-sha2-${kurve}`), sshString(kurve), sshString(ecdsaPunkt(kurve))]);
|
||||
}
|
||||
|
||||
export function ed25519Roh(): Buffer {
|
||||
const { publicKey } = generateKeyPairSync("ed25519");
|
||||
return Buffer.from(publicKey.export({ format: "jwk" }).x!, "base64url");
|
||||
}
|
||||
|
||||
export function ed25519Blob(): Buffer {
|
||||
return Buffer.concat([sshString("ssh-ed25519"), sshString(ed25519Roh())]);
|
||||
}
|
||||
|
||||
export function skEd25519Blob(): Buffer {
|
||||
return Buffer.concat([sshString("sk-ssh-ed25519@openssh.com"), sshString(ed25519Roh()), sshString("ssh:")]);
|
||||
}
|
||||
|
||||
export function skEcdsaBlob(): Buffer {
|
||||
return Buffer.concat([
|
||||
sshString("sk-ecdsa-sha2-nistp256@openssh.com"),
|
||||
sshString("nistp256"),
|
||||
sshString(ecdsaPunkt("nistp256")),
|
||||
sshString("ssh:"),
|
||||
]);
|
||||
}
|
||||
|
||||
/** Base64-Rumpf eines PEM-Textes, um Fehlermeldungen auf Schluesselmaterial zu pruefen. */
|
||||
export function pemRumpf(pem: string): string {
|
||||
return pem
|
||||
.split("\n")
|
||||
.filter((z) => z && !z.startsWith("-----"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
/** Ob `text` irgendein 16-Zeichen-Stueck aus `material` enthaelt. */
|
||||
export function enthaeltMaterial(text: string, material: string): boolean {
|
||||
for (let i = 0; i + 16 <= material.length; i += 4) {
|
||||
if (text.includes(material.slice(i, i + 16))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
type ErzeugbarerSchluesseltyp,
|
||||
privateKeyLesen,
|
||||
publicKeyZeileLesen,
|
||||
schluesselpaarErzeugen,
|
||||
} from "@/lib/ssh/openssh";
|
||||
import { ecdsaBlob, ed25519Blob, rsaBlob, skEcdsaBlob, skEd25519Blob, zeile } from "./hilfen";
|
||||
|
||||
/**
|
||||
* Gegenprobe mit dem echten OpenSSH (#006). `ssh-keygen` kommt nur hier vor, nie im
|
||||
* Produktivcode – dort waeren dafuer Private Keys als Dateien noetig.
|
||||
*/
|
||||
const SSH_KEYGEN = "/usr/bin/ssh-keygen";
|
||||
const PASSPHRASE = "geheim und lang genug";
|
||||
|
||||
let verzeichnis: string;
|
||||
let zaehler = 0;
|
||||
|
||||
function sshKeygen(...argumente: string[]): string {
|
||||
// stdin geschlossen: Fragt ssh-keygen doch einmal nach etwas, scheitert der Test, statt zu haengen.
|
||||
return execFileSync(SSH_KEYGEN, argumente, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
||||
}
|
||||
|
||||
async function dateiSchreiben(inhalt: string): Promise<string> {
|
||||
const pfad = join(verzeichnis, `schluessel-${zaehler++}`);
|
||||
await writeFile(pfad, inhalt, { mode: 0o600 });
|
||||
return pfad;
|
||||
}
|
||||
|
||||
/** "256 SHA256:… kommentar (ED25519)" → "SHA256:…" */
|
||||
function fingerprintAusAusgabe(ausgabe: string): string {
|
||||
return ausgabe.trim().split(/\s+/)[1]!;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
verzeichnis = await mkdtemp(join(tmpdir(), "sshkv-test-"));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await rm(verzeichnis, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe.skipIf(!existsSync(SSH_KEYGEN))("Kompatibilität mit ssh-keygen", () => {
|
||||
describe.each([
|
||||
["ED25519", undefined],
|
||||
["ED25519", PASSPHRASE],
|
||||
["RSA4096", undefined],
|
||||
["RSA4096", PASSPHRASE],
|
||||
] as [ErzeugbarerSchluesseltyp, string | undefined][])(
|
||||
"selbst erzeugt: %s, Passphrase %s",
|
||||
(typ, passphrase) => {
|
||||
it("ssh-keygen -y liest den Private Key, -lf liefert denselben Fingerprint", async () => {
|
||||
const paar = await schluesselpaarErzeugen(typ, { kommentar: "sshkv:k_test pruefung", passphrase });
|
||||
const pfad = await dateiSchreiben(paar.privateKeyOpenssh);
|
||||
|
||||
const oeffentlich = sshKeygen("-y", "-P", passphrase ?? "", "-f", pfad).trim();
|
||||
expect(oeffentlich).toBe(paar.publicKeyZeile);
|
||||
|
||||
const pfadPublic = await dateiSchreiben(paar.publicKeyZeile + "\n");
|
||||
expect(fingerprintAusAusgabe(sshKeygen("-lf", pfadPublic))).toBe(paar.fingerprint);
|
||||
// -lf auf den Private Key liest bei verschluesselten Dateien nur den Public-Teil.
|
||||
expect(fingerprintAusAusgabe(sshKeygen("-lf", pfad))).toBe(paar.fingerprint);
|
||||
}, 30_000);
|
||||
|
||||
if (passphrase) {
|
||||
it("ssh-keygen lehnt eine falsche Passphrase ab", async () => {
|
||||
const paar = await schluesselpaarErzeugen(typ, { kommentar: "x", passphrase });
|
||||
const pfad = await dateiSchreiben(paar.privateKeyOpenssh);
|
||||
expect(() => sshKeygen("-y", "-P", "falsch", "-f", pfad)).toThrow();
|
||||
}, 30_000);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
describe.each([
|
||||
["ed25519", [], ""],
|
||||
["ed25519", [], PASSPHRASE],
|
||||
["rsa", ["-b", "2048"], ""],
|
||||
["rsa", ["-b", "2048"], PASSPHRASE],
|
||||
] as [string, string[], string][])(
|
||||
"von ssh-keygen erzeugt: %s, Passphrase „%s“",
|
||||
(typ, extra, passphrase) => {
|
||||
it("wird gelesen, Public Key und Fingerprint stimmen", async () => {
|
||||
const pfad = join(verzeichnis, `fremd-${zaehler++}`);
|
||||
sshKeygen("-q", "-t", typ, ...extra, "-N", passphrase, "-C", "fremd@rechner", "-f", pfad);
|
||||
const privat = await readFile(pfad, "utf8");
|
||||
const publicZeile = (await readFile(`${pfad}.pub`, "utf8")).trim();
|
||||
|
||||
const gelesen = privateKeyLesen(privat, passphrase || undefined);
|
||||
expect(gelesen.publicKeyZeile).toBe(publicZeile);
|
||||
expect(gelesen.kommentar).toBe("fremd@rechner");
|
||||
expect(gelesen.verschluesselt).toBe(passphrase !== "");
|
||||
expect(gelesen.fingerprint).toBe(fingerprintAusAusgabe(sshKeygen("-lf", `${pfad}.pub`)));
|
||||
expect(publicKeyZeileLesen(publicZeile).fingerprint).toBe(gelesen.fingerprint);
|
||||
|
||||
if (passphrase) {
|
||||
expect(() => privateKeyLesen(privat, "falsch")).toThrow("Die Passphrase ist falsch.");
|
||||
}
|
||||
}, 30_000);
|
||||
},
|
||||
);
|
||||
|
||||
it("liest von ssh-keygen erzeugte ECDSA-Public-Keys", async () => {
|
||||
for (const bits of ["256", "384", "521"]) {
|
||||
const pfad = join(verzeichnis, `ecdsa-${bits}`);
|
||||
sshKeygen("-q", "-t", "ecdsa", "-b", bits, "-N", "", "-C", "e", "-f", pfad);
|
||||
const publicZeile = await readFile(`${pfad}.pub`, "utf8");
|
||||
const info = publicKeyZeileLesen(publicZeile);
|
||||
expect(info.typ).toBe(`ecdsa-sha2-nistp${bits}`);
|
||||
expect(info.fingerprint).toBe(fingerprintAusAusgabe(sshKeygen("-lf", `${pfad}.pub`)));
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
["ssh-ed25519", ed25519Blob],
|
||||
["ssh-rsa", () => rsaBlob(2048)],
|
||||
["ecdsa-sha2-nistp256", () => ecdsaBlob("nistp256")],
|
||||
["sk-ssh-ed25519@openssh.com", skEd25519Blob],
|
||||
["sk-ecdsa-sha2-nistp256@openssh.com", skEcdsaBlob],
|
||||
] as const)("Fingerprint von %s stimmt mit ssh-keygen -lf überein", async (typ, blobBauen) => {
|
||||
const text = zeile(typ, blobBauen());
|
||||
const pfad = await dateiSchreiben(text + "\n");
|
||||
expect(publicKeyZeileLesen(text).fingerprint).toBe(fingerprintAusAusgabe(sshKeygen("-lf", pfad)));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,227 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
bitsVonRsa,
|
||||
type ErzeugtesSchluesselpaar,
|
||||
fingerprintSha256,
|
||||
privateKeyLesen,
|
||||
publicKeyZeileLesen,
|
||||
SchluesselFormatFehler,
|
||||
schluesselpaarErzeugen,
|
||||
} from "@/lib/ssh/openssh";
|
||||
import {
|
||||
ecdsaBlob,
|
||||
ed25519Blob,
|
||||
enthaeltMaterial,
|
||||
pemRumpf,
|
||||
rsaBlob,
|
||||
skEcdsaBlob,
|
||||
skEd25519Blob,
|
||||
sshString,
|
||||
zeile,
|
||||
} from "./hilfen";
|
||||
|
||||
const PASSPHRASE = "korrekt Pferd Batterie Heftklammer";
|
||||
|
||||
function fehlerVon(aufruf: () => unknown): SchluesselFormatFehler {
|
||||
try {
|
||||
aufruf();
|
||||
} catch (fehler) {
|
||||
expect(fehler).toBeInstanceOf(SchluesselFormatFehler);
|
||||
return fehler as SchluesselFormatFehler;
|
||||
}
|
||||
throw new Error("Es wurde ein Fehler erwartet.");
|
||||
}
|
||||
|
||||
// RSA-4096 kostet je Erzeugung bis zu einige Sekunden; einmal je Variante reicht.
|
||||
const paare: Record<string, ErzeugtesSchluesselpaar> = {};
|
||||
|
||||
beforeAll(async () => {
|
||||
const [ed, edPass, rsa, rsaPass] = await Promise.all([
|
||||
schluesselpaarErzeugen("ED25519", { kommentar: "sshkv:k_1 backup" }),
|
||||
schluesselpaarErzeugen("ED25519", { kommentar: "mit passphrase", passphrase: PASSPHRASE }),
|
||||
schluesselpaarErzeugen("RSA4096", { kommentar: "rsa ohne" }),
|
||||
schluesselpaarErzeugen("RSA4096", { kommentar: "rsa mit", passphrase: PASSPHRASE }),
|
||||
]);
|
||||
Object.assign(paare, { ed, edPass, rsa, rsaPass });
|
||||
}, 60_000);
|
||||
|
||||
describe("schluesselpaarErzeugen und privateKeyLesen", () => {
|
||||
it.each([
|
||||
["ed", "ED25519", 256, false],
|
||||
["edPass", "ED25519", 256, true],
|
||||
["rsa", "RSA", 4096, false],
|
||||
["rsaPass", "RSA", 4096, true],
|
||||
] as const)("Rundreise %s", (name, typ, bits, mitPassphrase) => {
|
||||
const paar = paare[name]!;
|
||||
expect(paar.privateKeyOpenssh.startsWith("-----BEGIN OPENSSH PRIVATE KEY-----\n")).toBe(true);
|
||||
expect(paar.privateKeyOpenssh.endsWith("-----END OPENSSH PRIVATE KEY-----\n")).toBe(true);
|
||||
expect(paar.fingerprint).toMatch(/^SHA256:[A-Za-z0-9+/]{43}$/);
|
||||
|
||||
const gelesen = privateKeyLesen(paar.privateKeyOpenssh, mitPassphrase ? PASSPHRASE : undefined);
|
||||
expect(gelesen).toMatchObject({
|
||||
typ,
|
||||
bits,
|
||||
publicKeyZeile: paar.publicKeyZeile,
|
||||
fingerprint: paar.fingerprint,
|
||||
verschluesselt: mitPassphrase,
|
||||
});
|
||||
|
||||
const oeffentlich = publicKeyZeileLesen(paar.publicKeyZeile);
|
||||
expect(oeffentlich.fingerprint).toBe(paar.fingerprint);
|
||||
expect(oeffentlich.kommentar).toBe(gelesen.kommentar);
|
||||
});
|
||||
|
||||
it("bricht die Base64-Zeilen wie OpenSSH nach 70 Zeichen um", () => {
|
||||
const zeilen = paare["rsa"]!.privateKeyOpenssh.trim().split("\n").slice(1, -1);
|
||||
expect(zeilen.slice(0, -1).every((z) => z.length === 70)).toBe(true);
|
||||
});
|
||||
|
||||
it("verschluesselt mit aes256-ctr und bcrypt", () => {
|
||||
const roh = Buffer.from(pemRumpf(paare["edPass"]!.privateKeyOpenssh), "base64").toString("latin1");
|
||||
expect(roh).toContain("aes256-ctr");
|
||||
expect(roh).toContain("bcrypt");
|
||||
});
|
||||
|
||||
it("erzeugt jedes Mal einen anderen Schluessel", async () => {
|
||||
const a = await schluesselpaarErzeugen("ED25519", { kommentar: "a" });
|
||||
expect(a.fingerprint).not.toBe(paare["ed"]!.fingerprint);
|
||||
});
|
||||
|
||||
it("verträgt CRLF und Leerraum um den Private Key", () => {
|
||||
const text = "\n " + paare["ed"]!.privateKeyOpenssh.replace(/\n/g, "\r\n") + " \n";
|
||||
expect(privateKeyLesen(text).fingerprint).toBe(paare["ed"]!.fingerprint);
|
||||
});
|
||||
|
||||
it("lehnt Steuerzeichen im Kommentar ab", async () => {
|
||||
await expect(schluesselpaarErzeugen("ED25519", { kommentar: "a\nssh-rsa AAAA" })).rejects.toBeInstanceOf(
|
||||
SchluesselFormatFehler,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("privateKeyLesen – Fehlerfälle", () => {
|
||||
it("meldet eine fehlende Passphrase", () => {
|
||||
expect(fehlerVon(() => privateKeyLesen(paare["edPass"]!.privateKeyOpenssh)).code).toBe(
|
||||
"passphrase_fehlt",
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["edPass", "rsaPass"])("meldet eine falsche Passphrase (%s)", (name) => {
|
||||
const fehler = fehlerVon(() => privateKeyLesen(paare[name]!.privateKeyOpenssh, "falsch"));
|
||||
expect(fehler.code).toBe("passphrase_falsch");
|
||||
expect(fehler.message).toBe("Die Passphrase ist falsch.");
|
||||
});
|
||||
|
||||
it("erkennt einen abgeschnittenen Key", () => {
|
||||
const pem = paare["rsa"]!.privateKeyOpenssh;
|
||||
const zeilen = pem.trim().split("\n");
|
||||
const halb = [...zeilen.slice(0, zeilen.length / 2), zeilen.at(-1)!].join("\n");
|
||||
expect(fehlerVon(() => privateKeyLesen(halb)).code).toBe("ungueltig");
|
||||
expect(fehlerVon(() => privateKeyLesen(pem.slice(0, pem.length / 2))).code).toBe("ungueltig");
|
||||
});
|
||||
|
||||
it("erkennt ein vertauschtes Byte im unverschlüsselten Teil", () => {
|
||||
const roh = Buffer.from(pemRumpf(paare["ed"]!.privateKeyOpenssh), "base64");
|
||||
roh[roh.length - 60]! ^= 0x01;
|
||||
const pem = `-----BEGIN OPENSSH PRIVATE KEY-----\n${roh.toString("base64")}\n-----END OPENSSH PRIVATE KEY-----\n`;
|
||||
expect(fehlerVon(() => privateKeyLesen(pem)).code).toBe("ungueltig");
|
||||
});
|
||||
|
||||
it("weist PEM-Formate außer OpenSSH mit eigener Meldung ab", () => {
|
||||
const fehler = fehlerVon(() =>
|
||||
privateKeyLesen("-----BEGIN RSA PRIVATE KEY-----\nAAAA\n-----END RSA PRIVATE KEY-----"),
|
||||
);
|
||||
expect(fehler.code).toBe("nicht_unterstuetzt");
|
||||
});
|
||||
|
||||
it("weist Unsinn ab", () => {
|
||||
expect(fehlerVon(() => privateKeyLesen("hallo")).code).toBe("ungueltig");
|
||||
});
|
||||
|
||||
it("enthält in keiner Fehlermeldung Schlüsselmaterial oder die Passphrase", () => {
|
||||
const pem = paare["rsaPass"]!.privateKeyOpenssh;
|
||||
const rumpf = pemRumpf(pem);
|
||||
const meldungen = [
|
||||
fehlerVon(() => privateKeyLesen(pem, "falsch")).message,
|
||||
fehlerVon(() => privateKeyLesen(pem)).message,
|
||||
fehlerVon(() => privateKeyLesen(pem.slice(0, 400))).message,
|
||||
fehlerVon(() => privateKeyLesen(pem.replace("A", "!"))).message,
|
||||
fehlerVon(() => privateKeyLesen(pem.replace("OPENSSH", "RSA"))).message,
|
||||
];
|
||||
for (const meldung of meldungen) {
|
||||
expect(enthaeltMaterial(meldung, rumpf)).toBe(false);
|
||||
expect(meldung).not.toContain(PASSPHRASE);
|
||||
expect(meldung).not.toContain("-----BEGIN");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("publicKeyZeileLesen", () => {
|
||||
it.each([
|
||||
["ssh-ed25519", ed25519Blob, 256],
|
||||
["ssh-rsa", () => rsaBlob(2048), 2048],
|
||||
["ecdsa-sha2-nistp256", () => ecdsaBlob("nistp256"), 256],
|
||||
["ecdsa-sha2-nistp384", () => ecdsaBlob("nistp384"), 384],
|
||||
["ecdsa-sha2-nistp521", () => ecdsaBlob("nistp521"), 521],
|
||||
["sk-ssh-ed25519@openssh.com", skEd25519Blob, 256],
|
||||
["sk-ecdsa-sha2-nistp256@openssh.com", skEcdsaBlob, 256],
|
||||
] as const)("liest %s", (typ, blobBauen, bits) => {
|
||||
const blob = blobBauen();
|
||||
const info = publicKeyZeileLesen(zeile(typ, blob, "jemand@rechner mit leerzeichen"));
|
||||
expect(info).toMatchObject({ typ, kommentar: "jemand@rechner mit leerzeichen", bits });
|
||||
expect(info.blob.equals(blob)).toBe(true);
|
||||
});
|
||||
|
||||
it("akzeptiert Zeilen ohne Kommentar", () => {
|
||||
expect(publicKeyZeileLesen(`ssh-ed25519 ${ed25519Blob().toString("base64")}`).kommentar).toBe("");
|
||||
});
|
||||
|
||||
it("lehnt einen Blob ab, dessen Typ nicht zur Zeile passt", () => {
|
||||
expect(fehlerVon(() => publicKeyZeileLesen(zeile("ssh-rsa", ed25519Blob()))).code).toBe("ungueltig");
|
||||
expect(
|
||||
fehlerVon(() => publicKeyZeileLesen(zeile("ecdsa-sha2-nistp384", ecdsaBlob("nistp256")))).code,
|
||||
).toBe("ungueltig");
|
||||
});
|
||||
|
||||
it("lehnt RSA unter 2048 Bit ab, außer der Aufrufer erlaubt es", () => {
|
||||
const kurz = zeile("ssh-rsa", rsaBlob(1024));
|
||||
const fehler = fehlerVon(() => publicKeyZeileLesen(kurz));
|
||||
expect(fehler.code).toBe("zu_schwach");
|
||||
expect(fehler.message).toContain("2048");
|
||||
expect(publicKeyZeileLesen(kurz, { rsaMindestBits: 0 }).bits).toBe(1024);
|
||||
});
|
||||
|
||||
it("lehnt unbekannte Typen, kaputtes Base64 und angehängte Bytes ab", () => {
|
||||
expect(fehlerVon(() => publicKeyZeileLesen("ssh-dss AAAAB3NzaC1kc3M= x")).code).toBe(
|
||||
"nicht_unterstuetzt",
|
||||
);
|
||||
expect(fehlerVon(() => publicKeyZeileLesen("ssh-ed25519 AAA*AAA x")).code).toBe("ungueltig");
|
||||
expect(fehlerVon(() => publicKeyZeileLesen("ssh-ed25519")).code).toBe("ungueltig");
|
||||
const mitRest = Buffer.concat([ed25519Blob(), sshString("x")]);
|
||||
expect(fehlerVon(() => publicKeyZeileLesen(zeile("ssh-ed25519", mitRest))).code).toBe("ungueltig");
|
||||
const abgeschnitten = ed25519Blob().subarray(0, 30);
|
||||
expect(fehlerVon(() => publicKeyZeileLesen(zeile("ssh-ed25519", abgeschnitten))).code).toBe("ungueltig");
|
||||
});
|
||||
|
||||
it("lehnt Steuerzeichen im Kommentar ab", () => {
|
||||
expect(fehlerVon(() => publicKeyZeileLesen(zeile("ssh-ed25519", ed25519Blob(), "a\u0000b"))).code).toBe(
|
||||
"ungueltig",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fingerprintSha256 und bitsVonRsa", () => {
|
||||
it("ist SHA-256 über den Blob, Base64 ohne Padding", () => {
|
||||
const blob = ed25519Blob();
|
||||
const erwartet = createHash("sha256").update(blob).digest("base64").replace(/=+$/, "");
|
||||
expect(fingerprintSha256(blob)).toBe(`SHA256:${erwartet}`);
|
||||
expect(fingerprintSha256(blob)).not.toContain("=");
|
||||
});
|
||||
|
||||
it("zählt die Bits des RSA-Moduls", () => {
|
||||
expect(bitsVonRsa(rsaBlob(2048))).toBe(2048);
|
||||
expect(bitsVonRsa(rsaBlob(3072))).toBe(3072);
|
||||
expect(() => bitsVonRsa(ed25519Blob())).toThrow(SchluesselFormatFehler);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user