Merge branch 'phase/006-geheimnisse-schluesselformat' (Kern)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user