feat: export full keyring in backup encryption key file

This commit is contained in:
Mauricio Siu
2026-07-10 03:16:31 -06:00
parent 2e867c5be1
commit c04d56bf2c
3 changed files with 28 additions and 19 deletions
+3 -3
View File
@@ -1,7 +1,7 @@
import { import {
decryptValue, decryptValue,
encryptValue, encryptValue,
exportEncryptionKey, exportEncryptionKeys,
isEncrypted, isEncrypted,
} from "@dokploy/server/lib/encryption"; } from "@dokploy/server/lib/encryption";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
@@ -44,8 +44,8 @@ describe("encryptValue / decryptValue", () => {
expect(() => decryptValue(tampered)).toThrow(/BETTER_AUTH_SECRET/); expect(() => decryptValue(tampered)).toThrow(/BETTER_AUTH_SECRET/);
}); });
it("exports the primary key as 32-byte hex for backups", () => { it("exports the derived keys as 32-byte hex lines for backups", () => {
expect(exportEncryptionKey()).toMatch(/^[0-9a-f]{64}$/); expect(exportEncryptionKeys()).toMatch(/^[0-9a-f]{64}(\n[0-9a-f]{64})*$/);
}); });
}); });
+23 -14
View File
@@ -28,31 +28,41 @@ const decryptionKeys = encryptionSecret
? [primaryKey, deriveKey(betterAuthSecret)] ? [primaryKey, deriveKey(betterAuthSecret)]
: [primaryKey]; : [primaryKey];
export const exportEncryptionKey = () => primaryKey.toString("hex"); // Derived keys only — never the raw secrets. A leaked key can decrypt
// stored values, but the raw BETTER_AUTH_SECRET could also forge sessions.
export const exportEncryptionKeys = () =>
decryptionKeys.map((key) => key.toString("hex")).join("\n");
let restoredKey: Buffer | undefined; let restoredKeys: Buffer[] | undefined;
// A backup created with "include encryption key" places the original // A backup created with "include encryption key" places the original
// server's key at BASE_PATH when restored; use it as a last-resort // server's keys at BASE_PATH when restored; use them as a last-resort
// decryption fallback so restored values keep working on the new server. // decryption fallback so restored values keep working on the new server.
const loadRestoredKey = (): Buffer | undefined => { const loadRestoredKeys = (): Buffer[] => {
if (restoredKey) { if (restoredKeys?.length) {
return restoredKey; return restoredKeys;
} }
try { try {
const { BASE_PATH } = paths(); const { BASE_PATH } = paths();
const hex = readFileSync( const keys = readFileSync(
join(BASE_PATH, ENCRYPTION_KEY_BACKUP_FILE), join(BASE_PATH, ENCRYPTION_KEY_BACKUP_FILE),
"utf8", "utf8",
).trim(); )
const key = Buffer.from(hex, "hex"); .split("\n")
if (key.length === 32 && !key.equals(primaryKey)) { .map((line) => line.trim())
restoredKey = key; .filter(Boolean)
.map((hex) => Buffer.from(hex, "hex"))
.filter(
(key) =>
key.length === 32 && !decryptionKeys.some((own) => own.equals(key)),
);
if (keys.length) {
restoredKeys = keys;
} }
} catch { } catch {
// No restored key file present. // No restored key file present.
} }
return restoredKey; return restoredKeys ?? [];
}; };
export const isEncrypted = (value: string) => export const isEncrypted = (value: string) =>
@@ -79,8 +89,7 @@ export const decryptValue = (value: string): string => {
const iv = payload.subarray(0, IV_LENGTH); const iv = payload.subarray(0, IV_LENGTH);
const authTag = payload.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH); const authTag = payload.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH);
const encrypted = payload.subarray(IV_LENGTH + AUTH_TAG_LENGTH); const encrypted = payload.subarray(IV_LENGTH + AUTH_TAG_LENGTH);
const restored = loadRestoredKey(); const keys = [...decryptionKeys, ...loadRestoredKeys()];
const keys = restored ? [...decryptionKeys, restored] : decryptionKeys;
for (const key of keys) { for (const key of keys) {
try { try {
const decipher = createDecipheriv("aes-256-gcm", key, iv); const decipher = createDecipheriv("aes-256-gcm", key, iv);
@@ -5,7 +5,7 @@ import { join } from "node:path";
import { IS_CLOUD, paths } from "@dokploy/server/constants"; import { IS_CLOUD, paths } from "@dokploy/server/constants";
import { import {
ENCRYPTION_KEY_BACKUP_FILE, ENCRYPTION_KEY_BACKUP_FILE,
exportEncryptionKey, exportEncryptionKeys,
} from "@dokploy/server/lib/encryption"; } from "@dokploy/server/lib/encryption";
import type { BackupSchedule } from "@dokploy/server/services/backup"; import type { BackupSchedule } from "@dokploy/server/services/backup";
import { import {
@@ -92,7 +92,7 @@ export const runWebServerBackup = async (backup: BackupSchedule) => {
// the encryption keyring picks it up as a decryption fallback. // the encryption keyring picks it up as a decryption fallback.
await writeFile( await writeFile(
join(tempDir, "filesystem", ENCRYPTION_KEY_BACKUP_FILE), join(tempDir, "filesystem", ENCRYPTION_KEY_BACKUP_FILE),
exportEncryptionKey(), exportEncryptionKeys(),
{ mode: 0o600 }, { mode: 0o600 },
); );
writeStream.write("Included encryption key in backup\n"); writeStream.write("Included encryption key in backup\n");