This commit is contained in:
michael committed 2026-09-16 05:30:23 +00:00
1 parent 312dd3b3ad
commit 4f4fb0baf2
11 files changed
+181 -38

No files matched your search

+16 -4
View File
@@ -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) {
+26
View File
@@ -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,
+72
View File
@@ -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");
}
+26
View File
@@ -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);
});