Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions apps/dokploy/__test__/env/encryption.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
19 changes: 12 additions & 7 deletions packages/server/src/db/schema/application.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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),
Expand All @@ -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"),
Expand Down
9 changes: 7 additions & 2 deletions packages/server/src/db/schema/compose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"),
Expand Down
3 changes: 2 additions & 1 deletion packages/server/src/db/schema/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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" }),
Expand Down
3 changes: 2 additions & 1 deletion packages/server/src/db/schema/libsql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
import {
DATABASE_PASSWORD_MESSAGE,
DATABASE_PASSWORD_REGEX,
encryptedText,
generateAppName,
} from "./utils";

Expand All @@ -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"),
Expand Down
3 changes: 2 additions & 1 deletion packages/server/src/db/schema/mariadb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
APP_NAME_REGEX,
DATABASE_PASSWORD_MESSAGE,
DATABASE_PASSWORD_REGEX,
encryptedText,
generateAppName,
} from "./utils";

Expand All @@ -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"),
Expand Down
3 changes: 2 additions & 1 deletion packages/server/src/db/schema/mongo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
APP_NAME_REGEX,
DATABASE_PASSWORD_MESSAGE,
DATABASE_PASSWORD_REGEX,
encryptedText,
generateAppName,
} from "./utils";

Expand All @@ -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"),
Expand Down
3 changes: 2 additions & 1 deletion packages/server/src/db/schema/mysql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
APP_NAME_REGEX,
DATABASE_PASSWORD_MESSAGE,
DATABASE_PASSWORD_REGEX,
encryptedText,
generateAppName,
} from "./utils";

Expand All @@ -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"),
Expand Down
3 changes: 2 additions & 1 deletion packages/server/src/db/schema/postgres.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
APP_NAME_REGEX,
DATABASE_PASSWORD_MESSAGE,
DATABASE_PASSWORD_REGEX,
encryptedText,
generateAppName,
} from "./utils";

Expand All @@ -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"),
Expand Down
4 changes: 3 additions & 1 deletion packages/server/src/db/schema/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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 }) => ({
Expand All @@ -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({
Expand Down
9 changes: 7 additions & 2 deletions packages/server/src/db/schema/redis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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"),
Expand Down
29 changes: 29 additions & 0 deletions packages/server/src/db/schema/utils.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand Down
15 changes: 15 additions & 0 deletions packages/server/src/lib/encryption-secret.ts
Original file line number Diff line number Diff line change
@@ -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();
Loading
Loading