diff --git a/apps/dokploy/__test__/env/encryption.test.ts b/apps/dokploy/__test__/env/encryption.test.ts new file mode 100644 index 000000000..233782b39 --- /dev/null +++ b/apps/dokploy/__test__/env/encryption.test.ts @@ -0,0 +1,88 @@ +import { + decryptValue, + encryptValue, + isEncrypted, +} from "@dokploy/server/lib/encryption"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +describe("encryptValue / decryptValue", () => { + it("round-trips a value", () => { + const value = + "DATABASE_URL=postgres://user:secret@host:5432/db\nAPI_KEY=123"; + const encrypted = encryptValue(value); + + expect(encrypted).not.toBe(value); + expect(isEncrypted(encrypted)).toBe(true); + expect(encrypted).not.toContain("secret"); + expect(decryptValue(encrypted)).toBe(value); + }); + + it("uses a random IV so equal inputs produce different ciphertexts", () => { + const value = "KEY=value"; + expect(encryptValue(value)).not.toBe(encryptValue(value)); + }); + + it("passes legacy plaintext through on decrypt", () => { + const plaintext = "KEY=legacy-plaintext-value"; + expect(decryptValue(plaintext)).toBe(plaintext); + }); + + it("passes empty values through unchanged", () => { + expect(encryptValue("")).toBe(""); + expect(decryptValue("")).toBe(""); + }); + + it("does not double-encrypt an already encrypted value", () => { + const encrypted = encryptValue("KEY=value"); + expect(encryptValue(encrypted)).toBe(encrypted); + }); + + it("throws a descriptive error on tampered ciphertext", () => { + const encrypted = encryptValue("KEY=value"); + const tampered = `${encrypted.slice(0, -4)}AAAA`; + expect(() => decryptValue(tampered)).toThrow(/BETTER_AUTH_SECRET/); + }); +}); + +describe("dedicated ENCRYPTION_KEY", () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); + }); + + const loadWithEncryptionKey = async (key: string) => { + vi.stubEnv("ENCRYPTION_KEY", key); + vi.resetModules(); + return await import("@dokploy/server/lib/encryption"); + }; + + it("encrypts with the dedicated key when set", async () => { + const withKey = await loadWithEncryptionKey("my-dedicated-key"); + const encrypted = withKey.encryptValue("KEY=value"); + + expect(withKey.decryptValue(encrypted)).toBe("KEY=value"); + // The default (auth-secret derived) module cannot read it + expect(() => decryptValue(encrypted)).toThrow(/ENCRYPTION_KEY/); + }); + + it("still decrypts legacy values via the auth-secret fallback", async () => { + // Encrypted before the install adopted a dedicated key + const legacyEncrypted = encryptValue("KEY=legacy-value"); + + const withKey = await loadWithEncryptionKey("my-dedicated-key"); + expect(withKey.decryptValue(legacyEncrypted)).toBe("KEY=legacy-value"); + }); + + it("re-encrypts with the dedicated key on write", async () => { + const withKey = await loadWithEncryptionKey("my-dedicated-key"); + const reEncrypted = withKey.encryptValue( + withKey.decryptValue(encryptValue("KEY=migrated")), + ); + + const other = await loadWithEncryptionKey("another-key"); + // Readable only by the dedicated key (or its own fallback), proving + // the write used the primary key, not the legacy one + expect(withKey.decryptValue(reEncrypted)).toBe("KEY=migrated"); + expect(() => other.decryptValue(reEncrypted)).toThrow(); + }); +}); diff --git a/packages/server/src/db/schema/application.ts b/packages/server/src/db/schema/application.ts index 59dfd3716..c2ad44126 100644 --- a/packages/server/src/db/schema/application.ts +++ b/packages/server/src/db/schema/application.ts @@ -51,7 +51,12 @@ import { UpdateConfigSwarmSchema, } from "./shared"; import { sshKeys } from "./ssh-key"; -import { APP_NAME_MESSAGE, APP_NAME_REGEX, generateAppName } from "./utils"; +import { + APP_NAME_MESSAGE, + APP_NAME_REGEX, + encryptedText, + generateAppName, +} from "./utils"; export const sourceType = pgEnum("sourceType", [ "docker", "git", @@ -82,11 +87,11 @@ export const applications = pgTable("application", { .$defaultFn(() => generateAppName("app")) .unique(), description: text("description"), - env: text("env"), - previewEnv: text("previewEnv"), + env: encryptedText("env"), + previewEnv: encryptedText("previewEnv"), watchPaths: text("watchPaths").array(), - previewBuildArgs: text("previewBuildArgs"), - previewBuildSecrets: text("previewBuildSecrets"), + previewBuildArgs: encryptedText("previewBuildArgs"), + previewBuildSecrets: encryptedText("previewBuildSecrets"), previewLabels: text("previewLabels").array(), previewWildcard: text("previewWildcard"), previewPort: integer("previewPort").default(3000), @@ -105,8 +110,8 @@ export const applications = pgTable("application", { "previewRequireCollaboratorPermissions", ).default(true), rollbackActive: boolean("rollbackActive").default(false), - buildArgs: text("buildArgs"), - buildSecrets: text("buildSecrets"), + buildArgs: encryptedText("buildArgs"), + buildSecrets: encryptedText("buildSecrets"), memoryReservation: text("memoryReservation"), memoryLimit: text("memoryLimit"), cpuReservation: text("cpuReservation"), diff --git a/packages/server/src/db/schema/compose.ts b/packages/server/src/db/schema/compose.ts index 7803cb0a7..958da428c 100644 --- a/packages/server/src/db/schema/compose.ts +++ b/packages/server/src/db/schema/compose.ts @@ -17,7 +17,12 @@ import { schedules } from "./schedule"; import { server } from "./server"; import { applicationStatus, triggerType } from "./shared"; import { sshKeys } from "./ssh-key"; -import { APP_NAME_MESSAGE, APP_NAME_REGEX, generateAppName } from "./utils"; +import { + APP_NAME_MESSAGE, + APP_NAME_REGEX, + encryptedText, + generateAppName, +} from "./utils"; export const sourceTypeCompose = pgEnum("sourceTypeCompose", [ "git", "github", @@ -39,7 +44,7 @@ export const compose = pgTable("compose", { .notNull() .$defaultFn(() => generateAppName("compose")), description: text("description"), - env: text("env"), + env: encryptedText("env"), composeFile: text("composeFile").notNull().default(""), refreshToken: text("refreshToken").$defaultFn(() => nanoid()), sourceType: sourceTypeCompose("sourceType").notNull().default("github"), diff --git a/packages/server/src/db/schema/environment.ts b/packages/server/src/db/schema/environment.ts index a2c76d701..f8b38c087 100644 --- a/packages/server/src/db/schema/environment.ts +++ b/packages/server/src/db/schema/environment.ts @@ -11,6 +11,7 @@ import { mysql } from "./mysql"; import { postgres } from "./postgres"; import { projects } from "./project"; import { redis } from "./redis"; +import { encryptedText } from "./utils"; export const environments = pgTable("environment", { environmentId: text("environmentId") @@ -22,7 +23,7 @@ export const environments = pgTable("environment", { createdAt: text("createdAt") .notNull() .$defaultFn(() => new Date().toISOString()), - env: text("env").notNull().default(""), + env: encryptedText("env").notNull().default(""), projectId: text("projectId") .notNull() .references(() => projects.projectId, { onDelete: "cascade" }), diff --git a/packages/server/src/db/schema/libsql.ts b/packages/server/src/db/schema/libsql.ts index 046f985f7..e1158fbf7 100644 --- a/packages/server/src/db/schema/libsql.ts +++ b/packages/server/src/db/schema/libsql.ts @@ -37,6 +37,7 @@ import { import { DATABASE_PASSWORD_MESSAGE, DATABASE_PASSWORD_REGEX, + encryptedText, generateAppName, } from "./utils"; @@ -58,7 +59,7 @@ export const libsql = pgTable("libsql", { enableNamespaces: boolean("enableNamespaces").notNull().default(false), dockerImage: text("dockerImage").notNull(), command: text("command"), - env: text("env"), + env: encryptedText("env"), // RESOURCES memoryReservation: text("memoryReservation"), memoryLimit: text("memoryLimit"), diff --git a/packages/server/src/db/schema/mariadb.ts b/packages/server/src/db/schema/mariadb.ts index ce3f3e2d2..8220f2d70 100644 --- a/packages/server/src/db/schema/mariadb.ts +++ b/packages/server/src/db/schema/mariadb.ts @@ -33,6 +33,7 @@ import { APP_NAME_REGEX, DATABASE_PASSWORD_MESSAGE, DATABASE_PASSWORD_REGEX, + encryptedText, generateAppName, } from "./utils"; @@ -54,7 +55,7 @@ export const mariadb = pgTable("mariadb", { dockerImage: text("dockerImage").notNull(), command: text("command"), args: text("args").array(), - env: text("env"), + env: encryptedText("env"), // RESOURCES memoryReservation: text("memoryReservation"), memoryLimit: text("memoryLimit"), diff --git a/packages/server/src/db/schema/mongo.ts b/packages/server/src/db/schema/mongo.ts index a5910f197..ef3413ba5 100644 --- a/packages/server/src/db/schema/mongo.ts +++ b/packages/server/src/db/schema/mongo.ts @@ -40,6 +40,7 @@ import { APP_NAME_REGEX, DATABASE_PASSWORD_MESSAGE, DATABASE_PASSWORD_REGEX, + encryptedText, generateAppName, } from "./utils"; @@ -59,7 +60,7 @@ export const mongo = pgTable("mongo", { dockerImage: text("dockerImage").notNull().default("mongo:8"), command: text("command"), args: text("args").array(), - env: text("env"), + env: encryptedText("env"), memoryReservation: text("memoryReservation"), memoryLimit: text("memoryLimit"), cpuReservation: text("cpuReservation"), diff --git a/packages/server/src/db/schema/mysql.ts b/packages/server/src/db/schema/mysql.ts index a9a5d072f..da0764d57 100644 --- a/packages/server/src/db/schema/mysql.ts +++ b/packages/server/src/db/schema/mysql.ts @@ -33,6 +33,7 @@ import { APP_NAME_REGEX, DATABASE_PASSWORD_MESSAGE, DATABASE_PASSWORD_REGEX, + encryptedText, generateAppName, } from "./utils"; @@ -54,7 +55,7 @@ export const mysql = pgTable("mysql", { dockerImage: text("dockerImage").notNull(), command: text("command"), args: text("args").array(), - env: text("env"), + env: encryptedText("env"), memoryReservation: text("memoryReservation"), memoryLimit: text("memoryLimit"), cpuReservation: text("cpuReservation"), diff --git a/packages/server/src/db/schema/postgres.ts b/packages/server/src/db/schema/postgres.ts index 954b15d4a..1ad7a6674 100644 --- a/packages/server/src/db/schema/postgres.ts +++ b/packages/server/src/db/schema/postgres.ts @@ -33,6 +33,7 @@ import { APP_NAME_REGEX, DATABASE_PASSWORD_MESSAGE, DATABASE_PASSWORD_REGEX, + encryptedText, generateAppName, } from "./utils"; @@ -53,7 +54,7 @@ export const postgres = pgTable("postgres", { dockerImage: text("dockerImage").notNull(), command: text("command"), args: text("args").array(), - env: text("env"), + env: encryptedText("env"), memoryReservation: text("memoryReservation"), externalPort: integer("externalPort"), memoryLimit: text("memoryLimit"), diff --git a/packages/server/src/db/schema/project.ts b/packages/server/src/db/schema/project.ts index 75faece69..907bf70dd 100644 --- a/packages/server/src/db/schema/project.ts +++ b/packages/server/src/db/schema/project.ts @@ -6,6 +6,7 @@ import { z } from "zod"; import { organization } from "./account"; import { environments } from "./environment"; import { projectTags } from "./tag"; +import { encryptedText } from "./utils"; export const projects = pgTable("project", { projectId: text("projectId") @@ -21,7 +22,7 @@ export const projects = pgTable("project", { organizationId: text("organizationId") .notNull() .references(() => organization.id, { onDelete: "cascade" }), - env: text("env").notNull().default(""), + env: encryptedText("env").notNull().default(""), }); export const projectRelations = relations(projects, ({ many, one }) => ({ @@ -37,6 +38,7 @@ const createSchema = createInsertSchema(projects, { projectId: z.string().min(1), name: z.string().min(1), description: z.string().optional(), + env: z.string().optional(), }); export const apiCreateProject = createSchema.pick({ diff --git a/packages/server/src/db/schema/redis.ts b/packages/server/src/db/schema/redis.ts index c07074d0c..0a63973b8 100644 --- a/packages/server/src/db/schema/redis.ts +++ b/packages/server/src/db/schema/redis.ts @@ -27,7 +27,12 @@ import { type UpdateConfigSwarm, UpdateConfigSwarmSchema, } from "./shared"; -import { APP_NAME_MESSAGE, APP_NAME_REGEX, generateAppName } from "./utils"; +import { + APP_NAME_MESSAGE, + APP_NAME_REGEX, + encryptedText, + generateAppName, +} from "./utils"; export const redis = pgTable("redis", { redisId: text("redisId") @@ -44,7 +49,7 @@ export const redis = pgTable("redis", { dockerImage: text("dockerImage").notNull(), command: text("command"), args: text("args").array(), - env: text("env"), + env: encryptedText("env"), memoryReservation: text("memoryReservation"), memoryLimit: text("memoryLimit"), cpuReservation: text("cpuReservation"), diff --git a/packages/server/src/db/schema/utils.ts b/packages/server/src/db/schema/utils.ts index 30babea6d..4ba9007e1 100644 --- a/packages/server/src/db/schema/utils.ts +++ b/packages/server/src/db/schema/utils.ts @@ -1,7 +1,36 @@ +import { decryptValue, encryptValue } from "@dokploy/server/lib/encryption"; import { generatePassword } from "@dokploy/server/templates"; import { faker } from "@faker-js/faker"; +import { customType } from "drizzle-orm/pg-core"; import { customAlphabet } from "nanoid"; +/** + * Text column encrypted at rest (AES-256-GCM, key derived from + * BETTER_AUTH_SECRET). Legacy plaintext values are passed through on read + * and get encrypted the next time they are written. + */ +export const encryptedText = customType<{ data: string; driverData: string }>({ + dataType() { + return "text"; + }, + toDriver(value) { + return encryptValue(value); + }, + fromDriver(value) { + try { + return decryptValue(value); + } catch { + // Fail open so a key mismatch (e.g. restoring a backup under a + // different BETTER_AUTH_SECRET) degrades to showing ciphertext + // instead of breaking every query that touches the row. + console.error( + "Failed to decrypt an encrypted column; returning the raw value. This usually means BETTER_AUTH_SECRET changed after the value was encrypted.", + ); + return value; + } + }, +}); + const alphabet = "abcdefghijklmnopqrstuvwxyz123456789"; const customNanoid = customAlphabet(alphabet, 6); diff --git a/packages/server/src/lib/encryption-secret.ts b/packages/server/src/lib/encryption-secret.ts new file mode 100644 index 000000000..70b64cdd9 --- /dev/null +++ b/packages/server/src/lib/encryption-secret.ts @@ -0,0 +1,15 @@ +import { readSecret } from "../db/constants"; + +const { ENCRYPTION_KEY, ENCRYPTION_KEY_FILE } = process.env; + +function resolveEncryptionSecret(): string | undefined { + if (ENCRYPTION_KEY) { + return ENCRYPTION_KEY; + } + if (ENCRYPTION_KEY_FILE) { + return readSecret(ENCRYPTION_KEY_FILE); + } + return undefined; +} + +export const encryptionSecret = resolveEncryptionSecret(); diff --git a/packages/server/src/lib/encryption.ts b/packages/server/src/lib/encryption.ts new file mode 100644 index 000000000..658cb9771 --- /dev/null +++ b/packages/server/src/lib/encryption.ts @@ -0,0 +1,65 @@ +import { + createCipheriv, + createDecipheriv, + createHmac, + randomBytes, +} from "node:crypto"; +import { betterAuthSecret } from "./auth-secret"; +import { encryptionSecret } from "./encryption-secret"; + +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]; + +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); + for (const key of decryptionKeys) { + 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.", + ); +};