From f246410f2498aeaef99c282966e11b63da8c5f62 Mon Sep 17 00:00:00 2001 From: Bobban Rydh Date: Mon, 5 Oct 2026 00:53:07 +0200 Subject: [PATCH] Encrypt the notification channels' credentials at rest The Gotify and ntfy tokens, the SMTP password and the webhook secret were stored as plain text in the settings table. They are now encrypted with the same key as integration credentials (CREDENTIALS_ENCRYPTION_KEY), marked with an "enc:v1:" prefix. Settings are decrypted when read and encrypted when written, so nothing else changes; values saved before this are converted at startup. An edit that doesn't touch a credential keeps its stored ciphertext, so a wrong or missing key (which reads as empty) can't be made permanent by an unrelated edit. Without a key new credentials fall back to plain storage, and the startup warning, .env.example and the Privacy page say so. Co-Authored-By: Claude Sonnet 5.5 --- .env.example | 7 +- server/src/env.ts | 3 +- server/src/index.ts | 5 ++ server/src/services/settingsStore.ts | 108 ++++++++++++++++++++++++++- web/src/pages/Privacy.tsx | 7 +- 5 files changed, 121 insertions(+), 9 deletions(-) diff --git a/.env.example b/.env.example index b42f546..3dbcb6e 100644 --- a/.env.example +++ b/.env.example @@ -5,9 +5,10 @@ APP_BASE_URL=https://homelab.example.lan # node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" SESSION_SECRET=change-me-to-a-random-64-char-hex-string -# 32-byte (64 hex char) key used to encrypt stored integration API tokens at -# rest (AES-256-GCM). Generate the same way as SESSION_SECRET. Losing/changing -# this key makes previously-stored integration credentials unreadable. +# 32-byte (64 hex char) key used to encrypt stored integration API tokens, and the +# notification channels' credentials (Gotify/ntfy tokens, SMTP password, webhook +# secret), at rest (AES-256-GCM). Generate the same way as SESSION_SECRET. +# Losing/changing this key makes those stored credentials unreadable. CREDENTIALS_ENCRYPTION_KEY=change-me-to-a-random-64-char-hex-string # Port docker-compose publishes on the host (container always listens on 3000). diff --git a/server/src/env.ts b/server/src/env.ts index 4f7bdc4..36e263c 100644 --- a/server/src/env.ts +++ b/server/src/env.ts @@ -29,7 +29,8 @@ export function warnIfAuthNotConfigured() { if (!env.credentialsEncryptionEnabled) { console.warn( "CREDENTIALS_ENCRYPTION_KEY is not set to a 64-character hex string. " + - "Saving integration credentials (Proxmox/Synology/etc API tokens) will fail until it is configured.", + "Saving integration credentials (Proxmox/Synology/etc API tokens) will fail until it is configured, and the " + + "notification channels' credentials (Gotify/ntfy tokens, SMTP password, webhook secret) are stored unencrypted.", ); } } diff --git a/server/src/index.ts b/server/src/index.ts index 000c40a..d4fd531 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -8,6 +8,7 @@ import { mkdirSync, existsSync } from "node:fs"; import { env, warnIfAuthNotConfigured } from "./env.js"; import { resolveDataPath } from "./paths.js"; import { runMigrations } from "./db/migrate.js"; +import { encryptStoredSettingsSecrets } from "./services/settingsStore.js"; import { authRouter } from "./auth/router.js"; import { meRouter } from "./routes/me.js"; import { usersRouter } from "./routes/users.js"; @@ -42,6 +43,10 @@ import { initHealthScheduler } from "./services/healthScheduler.js"; warnIfAuthNotConfigured(); await runMigrations(); +{ + const converted = await encryptStoredSettingsSecrets(); + if (converted > 0) console.log(`Encrypted the stored credentials of ${converted} notification channel${converted === 1 ? "" : "s"}.`); +} await initSecretExpiryScheduler(); await initTailscaleKeyExpiryScheduler(); await initLogRetentionScheduler(); diff --git a/server/src/services/settingsStore.ts b/server/src/services/settingsStore.ts index 8cb5abb..6f55a81 100644 --- a/server/src/services/settingsStore.ts +++ b/server/src/services/settingsStore.ts @@ -2,6 +2,8 @@ import { sql } from "drizzle-orm"; import { db } from "../db/client.js"; import { settings } from "../db/schema.js"; import { defaultThemes, type NameTheme } from "./nameThemes.js"; +import { env } from "../env.js"; +import { decryptSecret, encryptSecret } from "../crypto.js"; export interface GotifySettings { enabled: boolean; @@ -157,6 +159,102 @@ const DEFAULTS: AppSettings = { const KEYS = Object.keys(DEFAULTS) as (keyof AppSettings)[]; +// ─── Encrypting the notification channels' credentials ───────────────────────────────────── +// +// A Gotify/ntfy token, the SMTP password and the webhook secret are stored encrypted with the same key as integration +// credentials (CREDENTIALS_ENCRYPTION_KEY). Everything above this file sees them as plain text — they're opened when +// settings are read and sealed when they're written — so nothing else needs to know. An encrypted value is marked +// with a prefix, which is how a value saved before this existed (plain text) is told apart and picked up later. +const SECRET_FIELDS: Partial> = { + gotify: ["token"], + ntfy: ["token"], + smtp: ["password"], + webhook: ["secret"], +}; +const ENCRYPTED_PREFIX = "enc:v1:"; +const warnedUnreadable = new Set(); + +/** Plain text in, the stored form out. Without a key configured the value is stored as it always was. */ +function sealValue(plain: unknown): unknown { + if (typeof plain !== "string" || plain === "" || !env.credentialsEncryptionEnabled) return plain; + return ENCRYPTED_PREFIX + encryptSecret(plain); +} + +/** The stored form in, plain text out. Anything not marked as encrypted is a legacy plain value and passes through. */ +function openValue(section: string, field: string, stored: unknown): unknown { + if (typeof stored !== "string" || !stored.startsWith(ENCRYPTED_PREFIX)) return stored; + try { + return decryptSecret(stored.slice(ENCRYPTED_PREFIX.length)); + } catch { + // The key was changed or removed. An empty credential is the honest result; shout once so it isn't a silent mystery. + const name = `${section}.${field}`; + if (!warnedUnreadable.has(name)) { + warnedUnreadable.add(name); + console.warn(`Can't decrypt the stored ${name} — CREDENTIALS_ENCRYPTION_KEY has changed or is missing. Enter it again under Settings → Notifications.`); + } + return ""; + } +} + +function transformSecrets(section: string, value: Record, fn: (field: string, v: unknown) => unknown): Record { + const fields = SECRET_FIELDS[section as keyof AppSettings]; + if (!fields) return value; + const out = { ...value }; + for (const field of fields) if (field in out) out[field] = fn(field, out[field]); + return out; +} + +/** + * The stored form of a section about to be written. A credential this edit sets is sealed. One it doesn't touch keeps + * its stored value as it is when that's already encrypted — even if it can't be read right now (wrong or missing key), so + * an unrelated edit, like a new Gotify address, can't overwrite it with the empty value it reads back as. + */ +async function sealSection(section: string, merged: Record, patch: Record): Promise> { + const fields = SECRET_FIELDS[section as keyof AppSettings]; + if (!fields) return merged; + const [row] = await db.select().from(settings).where(sql`${settings.key} = ${section}`).limit(1); + let stored: Record = {}; + try { + stored = row ? JSON.parse(row.value) : {}; + } catch { + stored = {}; + } + const out = { ...merged }; + for (const field of fields) { + const previous = stored[field]; + if (field in patch) out[field] = sealValue(patch[field]); + else if (typeof previous === "string" && previous.startsWith(ENCRYPTED_PREFIX)) out[field] = previous; + else out[field] = sealValue(merged[field]); + } + return out; +} + +/** + * Encrypts any credential still stored as plain text. Run once at startup, so an existing install is converted by + * upgrading and restarting — no one has to re-save anything. Safe to run every time; it only touches what isn't encrypted. + */ +export async function encryptStoredSettingsSecrets(): Promise { + if (!env.credentialsEncryptionEnabled) return 0; + let converted = 0; + const rows = await db.select().from(settings); + for (const row of rows) { + const fields = SECRET_FIELDS[row.key as keyof AppSettings]; + if (!fields) continue; + let parsed: Record; + try { + parsed = JSON.parse(row.value); + } catch { + continue; + } + const needs = fields.some((f) => typeof parsed[f] === "string" && parsed[f] !== "" && !(parsed[f] as string).startsWith(ENCRYPTED_PREFIX)); + if (!needs) continue; + const sealed = transformSecrets(row.key, parsed, (_f, v) => (typeof v === "string" && v !== "" && !v.startsWith(ENCRYPTED_PREFIX) ? sealValue(v) : v)); + await db.update(settings).set({ value: JSON.stringify(sealed) }).where(sql`${settings.key} = ${row.key}`); + converted++; + } + return converted; +} + export async function getSettings(): Promise { const rows = await db.select().from(settings); const byKey = new Map(rows.map((r) => [r.key, r.value])); @@ -172,7 +270,10 @@ export async function getSettings(): Promise { parsed = {}; } } - (result[key] as Record) = { ...(DEFAULTS[key] as object), ...parsed }; + (result[key] as Record) = { + ...(DEFAULTS[key] as object), + ...transformSecrets(key, parsed, (field, v) => openValue(key, field, v)), + }; } return result; } @@ -184,8 +285,9 @@ export async function updateSettings(partial: AppSettingsPatch): Promise; + const merged = { ...(current[key] as object), ...patch } as Record; + const value = JSON.stringify(await sealSection(key, merged, patch)); await db .insert(settings) .values({ key, value }) diff --git a/web/src/pages/Privacy.tsx b/web/src/pages/Privacy.tsx index 73ee8d0..95ef325 100644 --- a/web/src/pages/Privacy.tsx +++ b/web/src/pages/Privacy.tsx @@ -195,8 +195,11 @@ export default function Privacy() { Until deleted. - Integration and DNS credentials - API tokens and passwords, encrypted (AES-256-GCM) with a key held in the server's environment, not in the database. + Integration, DNS and notification credentials + + API tokens and passwords — including the Gotify/ntfy tokens, SMTP password and webhook secret — encrypted (AES-256-GCM) with a key held in + the server's environment, not in the database. (If that key isn't set, notification credentials are kept unencrypted and the server says so at startup.) + Until deleted. Settings → Backup exports include them, encrypted with a passphrase you choose.