mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-09-14 02:56:13 +05:00
109 lines
3.4 KiB
TypeScript
109 lines
3.4 KiB
TypeScript
import {
|
|
createCipheriv,
|
|
createDecipheriv,
|
|
createHmac,
|
|
randomBytes,
|
|
} from "node:crypto";
|
|
import { readFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { paths } from "@dokploy/server/constants";
|
|
import { betterAuthSecret } from "./auth-secret";
|
|
import { encryptionSecret } from "./encryption-secret";
|
|
|
|
export const ENCRYPTION_KEY_BACKUP_FILE = "encryption.key";
|
|
|
|
const ENCRYPTION_PREFIX = "enc:v1:";
|
|
const IV_LENGTH = 12;
|
|
const AUTH_TAG_LENGTH = 16;
|
|
|
|
const deriveKey = (secret: string) =>
|
|
createHmac("sha256", secret).update("dokploy:db-encryption:v1").digest();
|
|
|
|
const primaryKey = deriveKey(encryptionSecret ?? betterAuthSecret);
|
|
|
|
// Installs that adopt a dedicated ENCRYPTION_KEY still hold values encrypted
|
|
// with the auth-secret-derived key; keep it as a decrypt fallback so they
|
|
// lazily re-encrypt on the next write instead of becoming unreadable.
|
|
const decryptionKeys = encryptionSecret
|
|
? [primaryKey, deriveKey(betterAuthSecret)]
|
|
: [primaryKey];
|
|
|
|
// 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 restoredKeys: Buffer[] | undefined;
|
|
|
|
// A backup created with "include encryption key" places the original
|
|
// 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.
|
|
const loadRestoredKeys = (): Buffer[] => {
|
|
if (restoredKeys?.length) {
|
|
return restoredKeys;
|
|
}
|
|
try {
|
|
const { BASE_PATH } = paths();
|
|
const keys = readFileSync(
|
|
join(BASE_PATH, ENCRYPTION_KEY_BACKUP_FILE),
|
|
"utf8",
|
|
)
|
|
.split("\n")
|
|
.map((line) => line.trim())
|
|
.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 {
|
|
// No restored key file present.
|
|
}
|
|
return restoredKeys ?? [];
|
|
};
|
|
|
|
export const isEncrypted = (value: string) =>
|
|
value.startsWith(ENCRYPTION_PREFIX);
|
|
|
|
export const encryptValue = (value: string): string => {
|
|
if (!value || isEncrypted(value)) {
|
|
return value;
|
|
}
|
|
const iv = randomBytes(IV_LENGTH);
|
|
const cipher = createCipheriv("aes-256-gcm", primaryKey, iv);
|
|
const encrypted = Buffer.concat([
|
|
cipher.update(value, "utf8"),
|
|
cipher.final(),
|
|
]);
|
|
return `${ENCRYPTION_PREFIX}${Buffer.concat([iv, cipher.getAuthTag(), encrypted]).toString("base64")}`;
|
|
};
|
|
|
|
export const decryptValue = (value: string): string => {
|
|
if (!value || !isEncrypted(value)) {
|
|
return value;
|
|
}
|
|
const payload = Buffer.from(value.slice(ENCRYPTION_PREFIX.length), "base64");
|
|
const iv = payload.subarray(0, IV_LENGTH);
|
|
const authTag = payload.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH);
|
|
const encrypted = payload.subarray(IV_LENGTH + AUTH_TAG_LENGTH);
|
|
const keys = [...decryptionKeys, ...loadRestoredKeys()];
|
|
for (const key of keys) {
|
|
try {
|
|
const decipher = createDecipheriv("aes-256-gcm", key, iv);
|
|
decipher.setAuthTag(authTag);
|
|
return Buffer.concat([
|
|
decipher.update(encrypted),
|
|
decipher.final(),
|
|
]).toString("utf8");
|
|
} catch {
|
|
// GCM auth failed for this key; try the next one.
|
|
}
|
|
}
|
|
throw new Error(
|
|
"Failed to decrypt a stored secret. This usually means ENCRYPTION_KEY or BETTER_AUTH_SECRET changed after the value was encrypted.",
|
|
);
|
|
};
|