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() { - TicketTracker + TicketTracker v1.0.0-rc1 diff --git a/frontend/src/views/MonthlyClosePage.tsx b/frontend/src/views/MonthlyClosePage.tsx index fbe0e81..2a2ecd8 100644 --- a/frontend/src/views/MonthlyClosePage.tsx +++ b/frontend/src/views/MonthlyClosePage.tsx @@ -92,18 +92,18 @@ function groupOpenSessions(sessions: StatisticsOpenSession[]) { function OpenTicketItem({ group, month, onNavigate }: { group: OpenTicketGroup; month: string; onNavigate: (to: string) => void }) { return ( -
-
-
- {group.ticketNumber} - - {group.openCount} offen +
+
+
+
+ {group.ticketNumber} + + {group.openCount} offen +
+

{group.organizationName}

+

{formatMinutes(group.totalMinutes)} offen zu bewerten

-

{group.organizationName}

-

{formatMinutes(group.totalMinutes)} offen zu bewerten

-
-
- @@ -126,29 +126,31 @@ function CrmDayItem({ const isAcknowledged = Boolean(item.acknowledged); return ( -
-
-
- {item.ticket_number} - - {formatDate(`${item.day}T00:00:00`)} - {isAcknowledged ? "zur Kenntnis" : "offen"} +
+
+
+
+ {item.ticket_number} + + {formatDate(`${item.day}T00:00:00`)} + {isAcknowledged ? "zur Kenntnis" : "offen"} +
+

{item.organization_name}

+

+ Abgerechnet {formatMinutes(item.tracked_minutes)} + Teamspace {formatMinutes(item.crm_billed_minutes)} + {typeof item.delta_minutes === "number" ? Differenz {formatTeamspaceDelta(item.delta_minutes)} : null} +

+
+
+ +
-

{item.organization_name}

-

- Abgerechnet {formatMinutes(item.tracked_minutes)} - Teamspace {formatMinutes(item.crm_billed_minutes)} - {typeof item.delta_minutes === "number" ? Differenz {formatTeamspaceDelta(item.delta_minutes)} : null} -

-
-
- -
);