Update
This commit is contained in:
1 parent
312dd3b3ad
commit
4f4fb0baf2
11 files changed
+181
-38
No files matched your search
+16
-4
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user