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.