Merge branch 'phase/006-geheimnisse-worker'
This commit is contained in:
@@ -1,25 +1,25 @@
|
|||||||
# #006 Verschlüsselte Ablage und OpenSSH-Schlüsselformat
|
# #006 Verschlüsselte Ablage und OpenSSH-Schlüsselformat
|
||||||
**Status:** in Arbeit
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.1.0
|
**Meilenstein:** v0.1.0
|
||||||
**Labels:** sicherheit
|
**Labels:** sicherheit
|
||||||
**Geschätzt:** 4 h **Tatsächlich:** –
|
**Geschätzt:** 4 h **Tatsächlich:** 0 h 25 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
Envelope Encryption für Private Keys mit Master-Key aus Datei; eigene Erzeugung und Serialisierung von OpenSSH-Schlüsseln ohne temporäre Dateien; Deployment-Key der Instanz.
|
Envelope Encryption für Private Keys mit Master-Key aus Datei; eigene Erzeugung und Serialisierung von OpenSSH-Schlüsseln ohne temporäre Dateien; Deployment-Key der Instanz.
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
- [x] Master-Key aus Datei laden (32 Byte roh, Base64 oder Hex; Rechteprüfung) – `masterKeyLaden`
|
- [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] 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] 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] 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`
|
- [x] Fingerprint SHA256 wie `ssh-keygen -l`
|
||||||
- [ ] Deployment-Key beim ersten Start erzeugen
|
- [x] Deployment-Key beim ersten Start erzeugen
|
||||||
- [x] Log-Redaction (`schwaerzen`; Einbau in Logger/Protokoll folgt)
|
- [x] Log-Redaction (`schwaerzen`; Einbau in Logger/Protokoll folgt)
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
- [x] `ssh-keygen -y -f` liest erzeugte Schlüssel (mit und ohne Passphrase)
|
- [x] `ssh-keygen -y -f` liest erzeugte Schlüssel (mit und ohne Passphrase)
|
||||||
- [x] Fingerprints identisch zu `ssh-keygen -lf`
|
- [x] Fingerprints identisch zu `ssh-keygen -lf`
|
||||||
- [ ] Datenbank-Dump enthält keinen Klartext-Schlüssel
|
- [x] Datenbank-Dump enthält keinen Klartext-Schlüssel
|
||||||
|
|
||||||
## Abhängigkeiten
|
## Abhängigkeiten
|
||||||
#003
|
#003
|
||||||
@@ -55,3 +55,7 @@ Envelope Encryption für Private Keys mit Master-Key aus Datei; eigene Erzeugung
|
|||||||
`passphrase`, `secret`, `geheimnis`, `token`, `masterkey`, `authorization`, `cookie` enthalten
|
`passphrase`, `secret`, `geheimnis`, `token`, `masterkey`, `authorization`, `cookie` enthalten
|
||||||
(nicht nur als Präfix), sowie auf PEM-Private-Keys in jeder Zeichenkette (auch abgeschnitten,
|
(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]`.
|
auch in `message`/`stack` von Fehlern). Rohe Bytes werden zu `[Binärdaten]`.
|
||||||
|
- **Anbindung im Worker (Teil 2):** `src/worker/geheimnisse.ts` ist die einzige Stelle, die `privatVerschluesselt`/`dekVerschluesselt` liest; `tests/geheimnisse-grenze.test.ts` hält das strukturell fest, ebenso dass nichts außerhalb von `src/worker` von dort importiert.
|
||||||
|
- **Zusatzdaten** `schluessel:<id>:<fingerprint>`: Die ID wird deshalb vor dem Versiegeln vergeben (`randomUUID`), nicht von Prisma.
|
||||||
|
- **Deployment-Key** entsteht beim Start des Workers (ed25519, Kommentar `sshkv-deployment@<rechner>`). Gleichzeitige Starts: der partielle Index lässt nur einen zu, der zweite liest den ersten.
|
||||||
|
- **Dump-Test:** `pg_dump --data-only` der Testdatenbank enthält weder „PRIVATE KEY“ noch den Schlüsselkern.
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { hostname } from "node:os";
|
||||||
|
import type { Schluessel } from "@/generated/prisma/client";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { masterKeyLaden, oeffnen, versiegeln, type Umschlag } from "@/lib/krypto/umschlag";
|
||||||
|
import { schluesselpaarErzeugen, type ErzeugbarerSchluesseltyp } from "@/lib/ssh/openssh";
|
||||||
|
import { protokollieren } from "@/server/services/protokoll";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die einzige Stelle, die Private Keys ver- und entschluesselt (#006). Laeuft nur im Worker:
|
||||||
|
* nur dort liegt der Master-Key (CLAUDE.md, „Feste Regeln“). `tests/geheimnisse-grenze.test.ts`
|
||||||
|
* prueft, dass niemand sonst `privatVerschluesselt` liest.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Aktuelle Master-Key-Version. Steigt mit einer Master-Key-Rotation (spaeteres Issue). */
|
||||||
|
export const MASTER_KEY_VERSION = 1;
|
||||||
|
|
||||||
|
let masterKey: Promise<Buffer> | undefined;
|
||||||
|
|
||||||
|
function masterKeyHolen(): Promise<Buffer> {
|
||||||
|
const pfad = process.env["SSHKV_MASTER_KEY_FILE"];
|
||||||
|
if (!pfad) {
|
||||||
|
return Promise.reject(new Error("SSHKV_MASTER_KEY_FILE ist nicht gesetzt. Siehe .env.example."));
|
||||||
|
}
|
||||||
|
masterKey ??= masterKeyLaden(pfad).catch((fehler: unknown) => {
|
||||||
|
// Beim naechsten Versuch neu lesen, statt den Fehler ewig zu behalten.
|
||||||
|
masterKey = undefined;
|
||||||
|
throw fehler;
|
||||||
|
});
|
||||||
|
return masterKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nur fuer Tests: einen anderen Master-Key-Pfad wirksam machen. */
|
||||||
|
export function masterKeyVergessen(): void {
|
||||||
|
masterKey = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zusatzdaten (AAD) des Umschlags. Bindet das Chiffrat an genau diesen Schluessel: ein in
|
||||||
|
* der Datenbank auf eine andere Zeile kopiertes Chiffrat laesst sich dort nicht oeffnen.
|
||||||
|
*/
|
||||||
|
export function zusatzFuer(schluesselId: string, fingerprint: string): string {
|
||||||
|
return `schluessel:${schluesselId}:${fingerprint}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VersiegelterKey = {
|
||||||
|
readonly privatVerschluesselt: Uint8Array<ArrayBuffer>;
|
||||||
|
readonly dekVerschluesselt: Uint8Array<ArrayBuffer>;
|
||||||
|
readonly masterKeyVersion: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function privateKeyVersiegeln(
|
||||||
|
schluesselId: string,
|
||||||
|
fingerprint: string,
|
||||||
|
privateKeyOpenssh: string,
|
||||||
|
): Promise<VersiegelterKey> {
|
||||||
|
const klartext = Buffer.from(privateKeyOpenssh, "utf8");
|
||||||
|
try {
|
||||||
|
const umschlag = versiegeln(
|
||||||
|
klartext,
|
||||||
|
await masterKeyHolen(),
|
||||||
|
zusatzFuer(schluesselId, fingerprint),
|
||||||
|
MASTER_KEY_VERSION,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
privatVerschluesselt: new Uint8Array(umschlag.chiffrat),
|
||||||
|
dekVerschluesselt: new Uint8Array(umschlag.dekVerschluesselt),
|
||||||
|
masterKeyVersion: umschlag.masterKeyVersion,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
klartext.fill(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Oeffnet den Private Key eines Schluessels. Der Aufrufer bekommt einen Buffer und muss ihn
|
||||||
|
* nach Gebrauch mit `fill(0)` ueberschreiben.
|
||||||
|
*/
|
||||||
|
export async function privateKeyOeffnen(
|
||||||
|
schluessel: Pick<
|
||||||
|
Schluessel,
|
||||||
|
"id" | "fingerprint" | "privatVerschluesselt" | "dekVerschluesselt" | "masterKeyVersion"
|
||||||
|
>,
|
||||||
|
): Promise<Buffer> {
|
||||||
|
if (
|
||||||
|
!schluessel.privatVerschluesselt ||
|
||||||
|
!schluessel.dekVerschluesselt ||
|
||||||
|
schluessel.masterKeyVersion === null
|
||||||
|
) {
|
||||||
|
throw new Error("Zu diesem Schlüssel ist kein Private Key gespeichert.");
|
||||||
|
}
|
||||||
|
if (schluessel.masterKeyVersion !== MASTER_KEY_VERSION) {
|
||||||
|
throw new Error(
|
||||||
|
`Der Private Key ist mit Master-Key-Version ${schluessel.masterKeyVersion} verschlüsselt.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const umschlag: Umschlag = {
|
||||||
|
chiffrat: Buffer.from(schluessel.privatVerschluesselt),
|
||||||
|
dekVerschluesselt: Buffer.from(schluessel.dekVerschluesselt),
|
||||||
|
masterKeyVersion: schluessel.masterKeyVersion,
|
||||||
|
};
|
||||||
|
return oeffnen(umschlag, await masterKeyHolen(), zusatzFuer(schluessel.id, schluessel.fingerprint));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Liest den Private Key eines Schluessels aus der Datenbank – nur hier selektiert. */
|
||||||
|
export async function privateKeyLaden(schluesselId: string): Promise<Buffer> {
|
||||||
|
const schluessel = await prisma.schluessel.findUniqueOrThrow({
|
||||||
|
where: { id: schluesselId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
fingerprint: true,
|
||||||
|
privatVerschluesselt: true,
|
||||||
|
dekVerschluesselt: true,
|
||||||
|
masterKeyVersion: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return privateKeyOeffnen(schluessel);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NeuerSchluessel = {
|
||||||
|
readonly id: string;
|
||||||
|
readonly publicKeyZeile: string;
|
||||||
|
readonly fingerprint: string;
|
||||||
|
readonly versiegelt: VersiegelterKey;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzeugt ein Schluesselpaar und versiegelt den Private Key sofort. Die ID wird hier
|
||||||
|
* vergeben, weil sie in die Zusatzdaten des Umschlags eingeht.
|
||||||
|
*/
|
||||||
|
export async function schluesselpaarVersiegeltErzeugen(
|
||||||
|
typ: ErzeugbarerSchluesseltyp,
|
||||||
|
kommentar: string,
|
||||||
|
): Promise<NeuerSchluessel> {
|
||||||
|
const paar = await schluesselpaarErzeugen(typ, { kommentar });
|
||||||
|
const id = randomUUID();
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
publicKeyZeile: paar.publicKeyZeile,
|
||||||
|
fingerprint: paar.fingerprint,
|
||||||
|
versiegelt: await privateKeyVersiegeln(id, paar.fingerprint, paar.privateKeyOpenssh),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stellt beim Start des Workers sicher, dass es einen Deployment-Key der Instanz gibt
|
||||||
|
* (#006). Gibt die ID zurueck. Gleichzeitige Starts scheitern am partiellen Index
|
||||||
|
* `schluessel_ein_instanzstandard` und lesen dann den Key des anderen.
|
||||||
|
*/
|
||||||
|
export async function deploymentKeySicherstellen(): Promise<string> {
|
||||||
|
const vorhanden = await prisma.schluessel.findFirst({
|
||||||
|
where: { kategorie: "DEPLOYMENT", instanzStandard: true, status: "AKTIV" },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (vorhanden) return vorhanden.id;
|
||||||
|
|
||||||
|
const kommentar = `sshkv-deployment@${hostname()}`;
|
||||||
|
const neu = await schluesselpaarVersiegeltErzeugen("ED25519", kommentar);
|
||||||
|
try {
|
||||||
|
await prisma.schluessel.create({
|
||||||
|
data: {
|
||||||
|
id: neu.id,
|
||||||
|
name: "Deployment-Key der Instanz",
|
||||||
|
slug: "sshkv-deployment",
|
||||||
|
typ: "ED25519",
|
||||||
|
kategorie: "DEPLOYMENT",
|
||||||
|
publicKey: neu.publicKeyZeile,
|
||||||
|
fingerprint: neu.fingerprint,
|
||||||
|
kommentar,
|
||||||
|
instanzStandard: true,
|
||||||
|
...neu.versiegelt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (fehler) {
|
||||||
|
const anderer = await prisma.schluessel.findFirst({
|
||||||
|
where: { kategorie: "DEPLOYMENT", instanzStandard: true, status: "AKTIV" },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (anderer) return anderer.id;
|
||||||
|
throw fehler;
|
||||||
|
}
|
||||||
|
await protokollieren({
|
||||||
|
akteur: "System",
|
||||||
|
aktion: "deploymentkey.erzeugt",
|
||||||
|
ergebnis: "ERFOLG",
|
||||||
|
fingerprint: neu.fingerprint,
|
||||||
|
});
|
||||||
|
return neu.id;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import type { Job } from "pg-boss";
|
|||||||
import { VERSION } from "@/lib/version";
|
import { VERSION } from "@/lib/version";
|
||||||
import { WARTESCHLANGE, warteschlangeHolen, type VorgangsJob } from "@/server/vorgaenge/warteschlange";
|
import { WARTESCHLANGE, warteschlangeHolen, type VorgangsJob } from "@/server/vorgaenge/warteschlange";
|
||||||
import { vorgangAusfuehren } from "@/worker/ausfuehren";
|
import { vorgangAusfuehren } from "@/worker/ausfuehren";
|
||||||
|
import { deploymentKeySicherstellen } from "@/worker/geheimnisse";
|
||||||
import { HANDLER } from "@/worker/handler";
|
import { HANDLER } from "@/worker/handler";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -10,6 +11,9 @@ import { HANDLER } from "@/worker/handler";
|
|||||||
* und nur von hier aus spricht sshkv SSH mit den Hosts.
|
* und nur von hier aus spricht sshkv SSH mit den Hosts.
|
||||||
*/
|
*/
|
||||||
async function starten(): Promise<void> {
|
async function starten(): Promise<void> {
|
||||||
|
// Zuerst: ohne lesbaren Master-Key und Deployment-Key kann der Worker nichts tun, und das
|
||||||
|
// soll beim Start auffallen, nicht beim ersten Vorgang.
|
||||||
|
await deploymentKeySicherstellen();
|
||||||
const boss = await warteschlangeHolen();
|
const boss = await warteschlangeHolen();
|
||||||
|
|
||||||
await boss.work<VorgangsJob>(
|
await boss.work<VorgangsJob>(
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { readFileSync, readdirSync, statSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strukturell (#006): Nur der Worker liest Chiffrat und Data-Key, und der Webprozess
|
||||||
|
* importiert nichts aus src/worker – sonst laege der Master-Key-Zugriff im Web.
|
||||||
|
*/
|
||||||
|
function dateien(verzeichnis: string): string[] {
|
||||||
|
return readdirSync(verzeichnis).flatMap((name) => {
|
||||||
|
const pfad = path.join(verzeichnis, name);
|
||||||
|
if (statSync(pfad).isDirectory()) return name === "generated" ? [] : dateien(pfad);
|
||||||
|
return /\.(ts|tsx)$/.test(name) ? [pfad.split(path.sep).join("/")] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALLE = dateien("src");
|
||||||
|
|
||||||
|
/** Dateien, die die Namen tragen, ohne die Datenbank zu lesen – mit Begruendung. */
|
||||||
|
const NAMEN_ERLAUBT: Record<string, string> = {
|
||||||
|
"src/worker/geheimnisse.ts": "die eine Stelle, die liest",
|
||||||
|
"src/lib/krypto/umschlag.ts": "Feldname des Umschlags, rein rechnerisch",
|
||||||
|
"src/lib/protokoll-schwaerzen.ts": "Feldname in der Schwaerz-Liste",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("Grenze um die Private Keys", () => {
|
||||||
|
it("nur src/worker/geheimnisse.ts liest privatVerschluesselt und dekVerschluesselt", () => {
|
||||||
|
const verstoesse = ALLE.filter(
|
||||||
|
(datei) =>
|
||||||
|
!NAMEN_ERLAUBT[datei] && /privatVerschluesselt|dekVerschluesselt/.test(readFileSync(datei, "utf8")),
|
||||||
|
);
|
||||||
|
expect(verstoesse).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nichts ausserhalb von src/worker importiert aus src/worker", () => {
|
||||||
|
const verstoesse = ALLE.filter(
|
||||||
|
(datei) => !datei.startsWith("src/worker/") && /from ["']@\/worker\//.test(readFileSync(datei, "utf8")),
|
||||||
|
);
|
||||||
|
expect(verstoesse).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { privateKeyLesen } from "@/lib/ssh/openssh";
|
||||||
|
import {
|
||||||
|
deploymentKeySicherstellen,
|
||||||
|
masterKeyVergessen,
|
||||||
|
privateKeyLaden,
|
||||||
|
privateKeyOeffnen,
|
||||||
|
} from "@/worker/geheimnisse";
|
||||||
|
import { datenbankLeeren } from "./hilfen/db";
|
||||||
|
|
||||||
|
/** Verschluesselte Ablage im Worker (#006). */
|
||||||
|
|
||||||
|
let verzeichnis: string;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
verzeichnis = mkdtempSync(path.join(tmpdir(), "sshkv-master-"));
|
||||||
|
const datei = path.join(verzeichnis, "master.key");
|
||||||
|
writeFileSync(datei, randomBytes(32), { mode: 0o600 });
|
||||||
|
process.env["SSHKV_MASTER_KEY_FILE"] = datei;
|
||||||
|
masterKeyVergessen();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
rmSync(verzeichnis, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await datenbankLeeren();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Deployment-Key", () => {
|
||||||
|
it("wird einmal erzeugt und danach wiederverwendet", async () => {
|
||||||
|
const [a, b] = await Promise.all([deploymentKeySicherstellen(), deploymentKeySicherstellen()]);
|
||||||
|
expect(a).toBe(b);
|
||||||
|
expect(await prisma.schluessel.count({ where: { kategorie: "DEPLOYMENT" } })).toBe(1);
|
||||||
|
expect(await deploymentKeySicherstellen()).toBe(a);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("laesst sich oeffnen und passt zum Public Key", async () => {
|
||||||
|
const id = await deploymentKeySicherstellen();
|
||||||
|
const schluessel = await prisma.schluessel.findUniqueOrThrow({ where: { id } });
|
||||||
|
const klartext = await privateKeyLaden(id);
|
||||||
|
const info = privateKeyLesen(klartext.toString("utf8"));
|
||||||
|
klartext.fill(0);
|
||||||
|
expect(info.fingerprint).toBe(schluessel.fingerprint);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("laesst sich nicht unter fremder ID oeffnen (Zusatzdaten)", async () => {
|
||||||
|
const id = await deploymentKeySicherstellen();
|
||||||
|
const schluessel = await prisma.schluessel.findUniqueOrThrow({ where: { id } });
|
||||||
|
await expect(privateKeyOeffnen({ ...schluessel, id: "andere-id" })).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("steht in keinem Datenbank-Dump im Klartext", async () => {
|
||||||
|
const id = await deploymentKeySicherstellen();
|
||||||
|
const klartext = (await privateKeyLaden(id)).toString("utf8");
|
||||||
|
// Der Teil zwischen Kopf und Fuss ist das eigentliche Geheimnis.
|
||||||
|
const kern = klartext.split("\n").slice(1, -2).join("");
|
||||||
|
const url = new URL(process.env["DATABASE_URL"] ?? "");
|
||||||
|
const dump = execFileSync(
|
||||||
|
"docker",
|
||||||
|
["exec", "sshkv-dev-db", "pg_dump", "-U", url.username, "--data-only", url.pathname.slice(1)],
|
||||||
|
{ encoding: "utf8", maxBuffer: 64 * 1024 * 1024 },
|
||||||
|
);
|
||||||
|
expect(dump).toContain("Deployment-Key der Instanz");
|
||||||
|
expect(dump).not.toContain("PRIVATE KEY");
|
||||||
|
expect(dump).not.toContain(kern.slice(0, 40));
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user