diff --git a/.env.docker-external.example b/.env.docker-external.example
index 2c2c3dd..cc371ec 100644
--- a/.env.docker-external.example
+++ b/.env.docker-external.example
@@ -2,6 +2,7 @@ TICKETTRACKER_IMAGE=git.d-razz.de/michael/tickettracker:latest
APP_PORT=3910
DATABASE_URL=postgresql://tickettracker:change-me@10.10.10.23:5432/tickettracker
+SECRET_ENCRYPTION_KEY=change-me-secret-encryption-key
DB_POOL_SIZE=10
COOKIE_SECURE=false
diff --git a/.env.docker-postgres.example b/.env.docker-postgres.example
index 7709e9e..64165be 100644
--- a/.env.docker-postgres.example
+++ b/.env.docker-postgres.example
@@ -4,6 +4,7 @@ APP_PORT=3910
POSTGRES_DB=tickettracker
POSTGRES_USER=tickettracker
POSTGRES_PASSWORD=change-me-db-password
+SECRET_ENCRYPTION_KEY=change-me-secret-encryption-key
DB_POOL_SIZE=10
COOKIE_SECURE=false
diff --git a/.env.example b/.env.example
index c99ad6b..54ae97d 100644
--- a/.env.example
+++ b/.env.example
@@ -1,4 +1,5 @@
DATABASE_URL=postgresql://tickettracker:change-me@10.10.10.23:5432/tickettracker
+SECRET_ENCRYPTION_KEY=change-me-secret-encryption-key
APP_PORT=3910
TICKETTRACKER_IMAGE=git.d-razz.de/michael/tickettracker:latest
COOKIE_SECURE=false
diff --git a/backend/src/index.ts b/backend/src/index.ts
index cc31a8f..3767554 100644
--- a/backend/src/index.ts
+++ b/backend/src/index.ts
@@ -8,6 +8,7 @@ import type pg from "pg";
import { migrate } from "./migrations.js";
import { pool, query, withTransaction } from "./db.js";
import { clearAuthSession, createAuthSession, currentUser, hashPassword, requireAdmin, requireAuth, requireUser, verifyPassword } from "./auth.js";
+import { decryptSecret, encryptSecret } from "./secrets.js";
import {
databaseExportTables as backupTables,
exportDatabaseSql,
@@ -399,10 +400,13 @@ type ZammadSyncResult = {
async function getAppSetting(key: string) {
const result = await query<{ value: string }>("SELECT value FROM app_settings WHERE key = $1;", [key]);
- return result.rows[0]?.value ?? null;
+ const value = result.rows[0]?.value ?? null;
+ return key === zammadApiKeySettingKey ? decryptSecret(value) : value;
}
async function setAppSetting(key: string, value: string) {
+ const storedValue = key === zammadApiKeySettingKey ? encryptSecret(value) : value;
+
await query(
`
INSERT INTO app_settings (key, value, updated_at)
@@ -410,7 +414,7 @@ async function setAppSetting(key: string, value: string) {
ON CONFLICT (key)
DO UPDATE SET value = EXCLUDED.value, updated_at = now();
`,
- [key, value]
+ [key, storedValue]
);
}
@@ -520,7 +524,7 @@ async function getZammadCredentialsForUser(userId: string) {
throw badRequest("Zammad URL muss zuerst im Adminbereich gespeichert werden");
}
- const apiToken = userResult.rows[0]?.zammad_api_token ?? null;
+ const apiToken = decryptSecret(userResult.rows[0]?.zammad_api_token);
if (!apiToken) {
throw badRequest("Zammad-Token muss zuerst im Profil hinterlegt werden");
@@ -4074,7 +4078,15 @@ app.patch("/api/auth/me", async (req, res) => {
WHERE id = $4
RETURNING id, username, display_name, role, active, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token;
`,
- [username, displayName, newPassword ? await hashPassword(newPassword) : null, user.id, updatesZammadToken, zammadApiToken, zammadUserId]
+ [
+ username,
+ displayName,
+ newPassword ? await hashPassword(newPassword) : null,
+ user.id,
+ updatesZammadToken,
+ zammadApiToken ? encryptSecret(zammadApiToken) : null,
+ zammadUserId
+ ]
);
if (result.rowCount === 0) {
diff --git a/backend/src/migrations.ts b/backend/src/migrations.ts
index f862eb7..2a9be63 100644
--- a/backend/src/migrations.ts
+++ b/backend/src/migrations.ts
@@ -1,4 +1,7 @@
import { query } from "./db.js";
+import { encryptSecret, isEncryptedSecret } from "./secrets.js";
+
+const zammadApiKeySettingKey = "zammad.api_key";
async function findMigrationAdmin() {
const adminResult = await query<{ id: string }>("SELECT id FROM users WHERE role = 'admin' ORDER BY id ASC LIMIT 1;");
@@ -39,6 +42,27 @@ async function ensureClosureOwnership(table: string, column: string, primaryKeyC
await query(`CREATE INDEX IF NOT EXISTS idx_${table}_${column}_user ON ${table}(${column}, user_id);`);
}
+async function encryptStoredApiSecrets() {
+ const userTokenResult = await query<{ id: string; zammad_api_token: string | null }>(`
+ SELECT id::text AS id, zammad_api_token
+ FROM users
+ WHERE zammad_api_token IS NOT NULL;
+ `);
+
+ for (const row of userTokenResult.rows) {
+ if (row.zammad_api_token && !isEncryptedSecret(row.zammad_api_token)) {
+ await query("UPDATE users SET zammad_api_token = $1, updated_at = now() WHERE id = $2;", [encryptSecret(row.zammad_api_token), row.id]);
+ }
+ }
+
+ const zammadApiKeyResult = await query<{ value: string }>("SELECT value FROM app_settings WHERE key = $1;", [zammadApiKeySettingKey]);
+ const zammadApiKey = zammadApiKeyResult.rows[0]?.value ?? null;
+
+ if (zammadApiKey && !isEncryptedSecret(zammadApiKey)) {
+ await query("UPDATE app_settings SET value = $1, updated_at = now() WHERE key = $2;", [encryptSecret(zammadApiKey), zammadApiKeySettingKey]);
+ }
+}
+
export async function migrate() {
await query(`
CREATE TABLE IF NOT EXISTS users (
@@ -91,6 +115,8 @@ export async function migrate() {
);
`);
+ await encryptStoredApiSecrets();
+
await query(`
CREATE TABLE IF NOT EXISTS user_preferences (
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
diff --git a/backend/src/secrets.ts b/backend/src/secrets.ts
new file mode 100644
index 0000000..f6f30a3
--- /dev/null
+++ b/backend/src/secrets.ts
@@ -0,0 +1,72 @@
+import crypto from "node:crypto";
+
+const encryptedSecretPrefix = "ttenc:v1:";
+
+let warnedAboutFallbackKey = false;
+let cachedKey: Buffer | null = null;
+
+function getEncryptionMaterial() {
+ const configuredKey = process.env.SECRET_ENCRYPTION_KEY ?? process.env.TICKETTRACKER_SECRET_KEY;
+
+ if (configuredKey) {
+ return configuredKey;
+ }
+
+ if (!process.env.DATABASE_URL) {
+ throw new Error("SECRET_ENCRYPTION_KEY is required to encrypt database secrets");
+ }
+
+ if (!warnedAboutFallbackKey) {
+ console.warn("SECRET_ENCRYPTION_KEY is not set; deriving database secret encryption from DATABASE_URL.");
+ warnedAboutFallbackKey = true;
+ }
+
+ return process.env.DATABASE_URL;
+}
+
+function getEncryptionKey() {
+ if (!cachedKey) {
+ cachedKey = crypto.createHash("sha256").update(getEncryptionMaterial()).digest();
+ }
+
+ return cachedKey;
+}
+
+export function isEncryptedSecret(value: string | null | undefined) {
+ return typeof value === "string" && value.startsWith(encryptedSecretPrefix);
+}
+
+export function encryptSecret(value: string) {
+ if (isEncryptedSecret(value)) {
+ return value;
+ }
+
+ const iv = crypto.randomBytes(12);
+ const cipher = crypto.createCipheriv("aes-256-gcm", getEncryptionKey(), iv);
+ const encrypted = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
+ const tag = cipher.getAuthTag();
+
+ return `${encryptedSecretPrefix}${iv.toString("base64url")}.${tag.toString("base64url")}.${encrypted.toString("base64url")}`;
+}
+
+export function decryptSecret(value: string | null | undefined) {
+ if (!value) {
+ return null;
+ }
+
+ if (!isEncryptedSecret(value)) {
+ return value;
+ }
+
+ const parts = value.slice(encryptedSecretPrefix.length).split(".");
+
+ if (parts.length !== 3) {
+ throw new Error("Encrypted database secret has an invalid format");
+ }
+
+ const [ivText, tagText, encryptedText] = parts;
+ const decipher = crypto.createDecipheriv("aes-256-gcm", getEncryptionKey(), Buffer.from(ivText, "base64url"));
+ decipher.setAuthTag(Buffer.from(tagText, "base64url"));
+
+ return Buffer.concat([decipher.update(Buffer.from(encryptedText, "base64url")), decipher.final()]).toString("utf8");
+}
diff --git a/backend/test/secrets.test.ts b/backend/test/secrets.test.ts
new file mode 100644
index 0000000..3f737d5
--- /dev/null
+++ b/backend/test/secrets.test.ts
@@ -0,0 +1,26 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { decryptSecret, encryptSecret, isEncryptedSecret } from "../src/secrets.js";
+
+test("database secrets are encrypted and can be decrypted", () => {
+ process.env.SECRET_ENCRYPTION_KEY = "test-secret-key";
+
+ const encrypted = encryptSecret("zammad-token-with-specials-!$%&/()=?");
+
+ assert.equal(isEncryptedSecret(encrypted), true);
+ assert.notEqual(encrypted, "zammad-token-with-specials-!$%&/()=?");
+ assert.equal(decryptSecret(encrypted), "zammad-token-with-specials-!$%&/()=?");
+});
+
+test("plain legacy secrets are read as-is until migration encrypts them", () => {
+ assert.equal(decryptSecret("legacy-token"), "legacy-token");
+ assert.equal(decryptSecret(null), null);
+});
+
+test("already encrypted secrets are not encrypted twice", () => {
+ process.env.SECRET_ENCRYPTION_KEY = "test-secret-key";
+
+ const encrypted = encryptSecret("zammad-token");
+
+ assert.equal(encryptSecret(encrypted), encrypted);
+});
diff --git a/compose.postgres.yml b/compose.postgres.yml
index 2d295f6..5276bbd 100644
--- a/compose.postgres.yml
+++ b/compose.postgres.yml
@@ -26,6 +26,7 @@ services:
condition: service_healthy
environment:
DATABASE_URL: postgresql://${POSTGRES_USER:-tickettracker}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-tickettracker}
+ SECRET_ENCRYPTION_KEY: ${SECRET_ENCRYPTION_KEY:-}
PORT: 3000
BACKEND_PORT: 3001
API_PROXY_TARGET: http://127.0.0.1:3001
diff --git a/compose.yml b/compose.yml
index 2a06427..7921b0a 100644
--- a/compose.yml
+++ b/compose.yml
@@ -7,6 +7,7 @@ services:
restart: unless-stopped
environment:
DATABASE_URL: ${DATABASE_URL}
+ SECRET_ENCRYPTION_KEY: ${SECRET_ENCRYPTION_KEY:-}
PORT: 3000
BACKEND_PORT: 3001
API_PROXY_TARGET: http://127.0.0.1:3001
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index b0947d6..73cc3b0 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1245,7 +1245,7 @@ export function App() {
{group.organizationName}
+{formatMinutes(group.totalMinutes)} offen zu bewerten
{group.organizationName}
-{formatMinutes(group.totalMinutes)} offen zu bewerten
-