diff --git a/README.md b/README.md index de6068fb9..19205f853 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,7 @@ The image is configured entirely through environment variables on the server con |---|---|---| | `DB_CONNECTION_STRING` | Yes | MongoDB connection string, e.g. `mongodb://mongodb:27017/uptime_db` | | `JWT_SECRET` | Yes | Secret used to sign auth tokens; generate one with `openssl rand -hex 32` | +| `ENCRYPTION_KEY` | No | Encrypts stored Docker TLS client keys at rest; generate one with `openssl rand -base64 32`. Comma-separated list: the first key encrypts, every key decrypts. Must be identical on the API and every worker. To rotate without downtime, deploy `OLD_KEY,NEW_KEY` everywhere, then `NEW_KEY,OLD_KEY` everywhere, wait for the worker to re-encrypt every row, then drop `OLD_KEY`. | | `CLIENT_HOST` | Yes | The URL users reach the app at, e.g. `https://checkmate.example.com`; used for CORS and for links in notifications and emails | | `LOG_LEVEL` | No | Server log level: `error`, `warn`, `info`, or `debug` (default `debug`) | diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index e1a2d0e5a..a66bf0bb0 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -9,6 +9,7 @@ services: - DB_CONNECTION_STRING=mongodb://mongodb:27017/uptime_db - CLIENT_HOST=${CLIENT_HOST:-http://localhost:52345} - JWT_SECRET=${JWT_SECRET:?set JWT_SECRET in your environment} + - ENCRYPTION_KEY=${ENCRYPTION_KEY:-} stop_grace_period: 60s healthcheck: test: diff --git a/server/.env.example b/server/.env.example index 5a44935b8..b228110e6 100644 --- a/server/.env.example +++ b/server/.env.example @@ -27,6 +27,17 @@ CLIENT_HOST=http://localhost:5173 # ---------------------- ORIGIN=localhost +# Encryption +# ------------------ +# Encrypts stored Docker TLS client keys. Not needed for socket-only Docker monitors. +# Generate a key: openssl rand -base64 32 +# Key list: comma-separated; the first key encrypts, every key decrypts. +# Split deployments: set the same value on the API and every worker. +# Rotate a key: 1. deploy OLD_KEY,NEW_KEY everywhere so every process can read both +# 2. deploy NEW_KEY,OLD_KEY everywhere so new writes use the new key +# 3. wait for the worker to re-encrypt every row, then drop OLD_KEY +# ENCRYPTION_KEY= + # Feature Flags # ------------- # Set to "false" to disable the selectable theme feature on status pages. diff --git a/server/src/config/envValidation.ts b/server/src/config/envValidation.ts index a769a28c7..3c1b85164 100644 --- a/server/src/config/envValidation.ts +++ b/server/src/config/envValidation.ts @@ -3,6 +3,39 @@ import { DbTypes, LogLevels, QueueModes } from "@/domain/app-settings/app-settin import { booleanCoercion } from "@/api/validation/shared.js"; import { ILogger } from "@/utils/logger.js"; +// Padded standard base64 of exactly 32 bytes, as produced by `openssl rand -base64 32` +const ENCRYPTION_KEY_ENTRY = /^[A-Za-z0-9+/]{43}=$/; + +export const encryptionKeyList = z + .string() + .optional() + .transform((raw, ctx) => { + if (raw === undefined) return []; + const entries = raw + .split(",") + .map((entry, index) => ({ entry: entry.trim(), slot: index + 1 })) + .filter(({ entry }) => entry.length > 0); + const seen = new Set(); + const keys: Buffer[] = []; + entries.forEach(({ entry, slot }) => { + if (!ENCRYPTION_KEY_ENTRY.test(entry)) { + ctx.addIssue({ + code: "custom", + message: `ENCRYPTION_KEY entry ${slot} must be 32 bytes as padded standard base64 (openssl rand -base64 32)`, + }); + return; + } + const decoded = Buffer.from(entry, "base64"); + const fingerprint = decoded.toString("hex"); + if (seen.has(fingerprint)) { + ctx.addIssue({ code: "custom", message: `ENCRYPTION_KEY entry ${slot} duplicates an earlier entry` }); + } + seen.add(fingerprint); + keys.push(decoded); + }); + return keys; + }); + const envSchema = z.object({ // Server Configuration PORT: z.string().default("52345"), @@ -36,6 +69,9 @@ const envSchema = z.object({ // Feature flags STATUS_PAGE_THEMES_ENABLED: booleanCoercion.default(true), + + // Encryption + ENCRYPTION_KEY: encryptionKeyList, }); export type ValidatedEnv = z.infer; diff --git a/server/src/config/services.shared.ts b/server/src/config/services.shared.ts index 6fb5d9e8e..4f8f63412 100644 --- a/server/src/config/services.shared.ts +++ b/server/src/config/services.shared.ts @@ -1,6 +1,6 @@ import { Mongoose } from "mongoose"; import { hostname } from "node:os"; -import { randomUUID } from "node:crypto"; +import * as nodeCrypto from "node:crypto"; import fs from "fs"; import path from "path"; import nodemailer from "nodemailer"; @@ -20,6 +20,7 @@ import { INotificationsService, NotificationsService } from "@/domain/notificati import { IEmailService, EmailService } from "@/service/emailService.js"; import { GlobalPingService } from "@/service/globalPingService.js"; import { ILogger } from "@/utils/logger.js"; +import { IEncryptionService, EncryptionService } from "@/service/encryptionService.js"; // Notification providers import type { NotificationProviderRegistry } from "@/domain/notifications/notification.service.js"; @@ -83,6 +84,7 @@ export interface SharedServices { db: IDb; settingsService: ISettingsService; emailService: IEmailService; + encryptionService: IEncryptionService; notificationMessageBuilder: INotificationMessageBuilder; incidentService: IIncidentService; checkService: ICheckService; @@ -118,13 +120,18 @@ export const buildShared = async ({ logger, envSettings, settingsService, + encryptionKeys, }: { logger: ILogger; envSettings: EnvConfig; settingsService: ISettingsService; + encryptionKeys: Buffer[]; }): Promise => { // Shared worker ID - const workerId = `${hostname()}:${process.pid}:${randomUUID()}`; + const workerId = `${hostname()}:${process.pid}:${nodeCrypto.randomUUID()}`; + + // Create the encryption service + const encryptionService = new EncryptionService(encryptionKeys, logger, nodeCrypto); // Create DB let db: IDb | null = null; @@ -213,6 +220,7 @@ export const buildShared = async ({ db, settingsService, emailService, + encryptionService, notificationMessageBuilder, incidentService, checkService, diff --git a/server/src/index.ts b/server/src/index.ts index 68ef00a1f..e522fa07e 100755 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -45,7 +45,7 @@ const startApp = async () => { // *********************** // Build shared services // *********************** - const shared = await buildShared({ logger, envSettings, settingsService }); + const shared = await buildShared({ logger, envSettings, settingsService, encryptionKeys: env.ENCRYPTION_KEY }); // *********************** // Worker node path, don't need API diff --git a/server/src/service/encryptionService.ts b/server/src/service/encryptionService.ts new file mode 100644 index 000000000..f5b52620d --- /dev/null +++ b/server/src/service/encryptionService.ts @@ -0,0 +1,176 @@ +import { ILogger } from "@/utils/logger.js"; +import type * as nodeCrypto from "node:crypto"; +import { AppError } from "@/utils/AppError.js"; + +const SERVICE_NAME = "EncryptionService"; + +const ENCRYPTION_KEY_BYTES = 32; + +// Encryption consts +const FORMAT_VERSION = "v1"; +const ALGORITHM = "aes-256-gcm"; +const IV_BYTES = 12; +const TAG_BYTES = 16; +const KEY_ID_BYTES = 4; +const SEGMENT_COUNT = 5; +type V1Segments = [string, string, string, string, string]; +const isV1Segments = (segments: string[]): segments is V1Segments => segments.length === SEGMENT_COUNT; +const BASE64URL = /^[A-Za-z0-9_-]*$/; + +type KeyEntry = { id: string; key: Buffer }; + +export type EncryptionCryptoLib = Pick; + +export interface IEncryptionService { + isConfigured(): boolean; + currentKeyId(): string; + keyIdOf(ciphertext: string): string; + needsReencryption(ciphertext: string): boolean; + encrypt(plaintext: string): string; + decrypt(ciphertext: string): string; +} + +export class EncryptionService implements IEncryptionService { + static SERVICE_NAME = SERVICE_NAME; + + private readonly entries: KeyEntry[]; + + constructor( + keys: Buffer[], + private logger: ILogger, + private cryptoLib: EncryptionCryptoLib + ) { + this.entries = keys.map((key, index) => this.toEntry(key, index)); + this.entries.forEach((entry, index) => { + if (this.entries.findIndex((other) => other.id === entry.id) !== index) { + throw this.fail("constructor", "ENCRYPTION_KEY key ids collide; regenerate one key", { index }); + } + }); + + const current = this.entries[0]; + + this.logger.info({ + message: current + ? `Encryption configured with ${this.entries.length} key(s); current key id ${current.id}` + : "ENCRYPTION_KEY not set; encrypted fields are unavailable", + service: SERVICE_NAME, + method: "constructor", + }); + } + + isConfigured = () => { + return this.entries.length > 0; + }; + + currentKeyId = () => { + return this.requireCurrentKey("currentKeyId").id; + }; + + keyIdOf = (ciphertext: string) => { + return this.parse(ciphertext, "keyIdOf").keyId; + }; + + // Check if the ciphertext was encrypted with the current key + needsReencryption = (ciphertext: string) => { + const currentKey = this.requireCurrentKey("needsReencryption"); + return this.parse(ciphertext, "needsReencryption").keyId !== currentKey.id; + }; + + encrypt = (plaintext: string) => { + const currentKey = this.requireCurrentKey("encrypt"); // Get the current key for encryption + const iv = this.cryptoLib.randomBytes(IV_BYTES); // Get IV_BYTES worth of random bytes for nonce + const cipher = this.cryptoLib.createCipheriv(ALGORITHM, currentKey.key, iv, { authTagLength: TAG_BYTES }); // Create the cipher + cipher.setAAD(this.aad(currentKey.id)); // set signing key as additional data so it is protected by the verification tag + const data = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); // Encode plaintext as utf8 and run through cipher + const tag = cipher.getAuthTag(); // Get verification tag + return [FORMAT_VERSION, currentKey.id, iv.toString("base64url"), tag.toString("base64url"), data.toString("base64url")].join("."); + }; + + decrypt = (ciphertext: string) => { + this.requireCurrentKey("decrypt"); // Config check + const { keyId, iv, tag, data } = this.parse(ciphertext, "decrypt"); + const key = this.entries.find((entry) => entry.id === keyId)?.key; + if (!key) { + throw this.fail("decrypt", "No encryption key matches ciphertext", { keyId }); + } + const decipher = this.cryptoLib.createDecipheriv(ALGORITHM, key, iv, { authTagLength: TAG_BYTES }); + decipher.setAAD(this.aad(keyId)); // reconstruct the additional data, must be done BEFORE auth tag + decipher.setAuthTag(tag); // Give the decipher the tag to verify + try { + return Buffer.concat([decipher.update(data), decipher.final()]).toString("utf8"); + } catch { + throw this.fail("decrypt", "Ciphertext failed authentication"); + } + }; + + private fail = (method: string, message: string, details?: Record): AppError => { + return new AppError({ + message, + status: 500, + service: SERVICE_NAME, + method, + details, + }); + }; + + // Create an ID:Key pair for each key. + private toEntry = (key: Buffer, keyIdx: number): KeyEntry => { + if (key.length !== ENCRYPTION_KEY_BYTES) throw this.fail("constructor", "ENCRYPTION_KEY is not 32 bytes", { keyIdx }); + + const id = this.cryptoLib + .createHash("sha256") // Create a sha256 hasher + .update(key) // Feed it the key + .digest() // Digest a hash + .subarray(0, KEY_ID_BYTES) // Keep first KEY_ID_BYTES, enough to avoid collision + .toString("base64url"); + return { id, key }; + }; + + private requireCurrentKey = (method: string): KeyEntry => { + const current = this.entries[0]; + if (!current) { + throw this.fail(method, "ENCRYPTION_KEY is not configured"); + } + return current; + }; + + // v1 Ciphertext stored as a string with five segments: + // .... + // iv and tag belong to aes-256-gcm standard + + private parse = (ciphertext: string, method: string) => { + const segments = ciphertext.split("."); + const version = segments[0]; + if (version !== FORMAT_VERSION) { + throw this.fail(method, "Unsupported ciphertext version", { version }); + } + + // ************************** + // v1 parser + // ************************** + if (!isV1Segments(segments)) { + throw this.fail(method, "Ciphertext is malformed"); + } + + const [, keyId, ivText, tagText, dataText] = segments; // Skip first segment, we already have version + + if (![keyId, ivText, tagText, dataText].every((segment) => BASE64URL.test(segment))) { + throw this.fail(method, "Ciphertext is malformed"); + } + + const iv = Buffer.from(ivText, "base64url"); + const tag = Buffer.from(tagText, "base64url"); + + if (keyId.length === 0 || iv.length !== IV_BYTES || tag.length !== TAG_BYTES) { + throw this.fail(method, "Ciphertext is malformed"); + } + return { keyId, iv, tag, data: Buffer.from(dataText, "base64url") }; + // ************************** + // End v1 parser + // ************************** + }; + + private aad = (keyId: string) => { + return Buffer.from(`${FORMAT_VERSION}.${keyId}`, "utf8"); + }; +} diff --git a/server/test/unit/services/encryptionService.test.ts b/server/test/unit/services/encryptionService.test.ts new file mode 100644 index 000000000..c700343a3 --- /dev/null +++ b/server/test/unit/services/encryptionService.test.ts @@ -0,0 +1,239 @@ +import { describe, expect, it } from "@jest/globals"; +import * as nodeCrypto from "node:crypto"; +import { EncryptionService } from "../../../src/service/encryptionService.ts"; +import type { EncryptionCryptoLib } from "../../../src/service/encryptionService.ts"; +import { AppError } from "../../../src/utils/AppError.ts"; +import { createMockLogger } from "../../helpers/createMockLogger.ts"; + +const newKey = () => nodeCrypto.randomBytes(32); + +const setup = (keys: Buffer[], cryptoLib: EncryptionCryptoLib = nodeCrypto) => { + const logger = createMockLogger(); + const service = new EncryptionService(keys, logger, cryptoLib); + return { service, logger }; +}; + +const expectAppError = (fn: () => unknown, message: string, details?: Record) => { + let caught: unknown; + try { + fn(); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(AppError); + const appError = caught as AppError; + expect(appError.message).toBe(message); + expect(appError.status).toBe(500); + expect(appError.service).toBe("EncryptionService"); + if (details) expect(appError.details).toEqual(details); +}; + +const tamper = (ciphertext: string, segment: number, replacer: (value: string) => string) => { + const parts = ciphertext.split("."); + parts[segment] = replacer(parts[segment] ?? ""); + return parts.join("."); +}; + +const flipFirstChar = (value: string) => (value[0] === "A" ? "B" : "A") + value.slice(1); + +describe("EncryptionService", () => { + describe("construction", () => { + it("is unconfigured with no keys and says so", () => { + const { service, logger } = setup([]); + expect(service.isConfigured()).toBe(false); + expectAppError(() => service.currentKeyId(), "ENCRYPTION_KEY is not configured"); + expect(logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: "ENCRYPTION_KEY not set; encrypted fields are unavailable" })); + }); + + it("is configured with one key and exposes a 6-character base64url key id", () => { + const { service, logger } = setup([newKey()]); + expect(service.isConfigured()).toBe(true); + expect(service.currentKeyId()).toMatch(/^[A-Za-z0-9_-]{6}$/); + expect(logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("Encryption configured with 1 key(s)") })); + }); + + it("rejects an entry that is not 32 bytes, naming its index", () => { + const short = nodeCrypto.randomBytes(31); + const long = nodeCrypto.randomBytes(33); + expectAppError(() => setup([newKey(), short]), "ENCRYPTION_KEY is not 32 bytes", { keyIdx: 1 }); + expectAppError(() => setup([long]), "ENCRYPTION_KEY is not 32 bytes", { keyIdx: 0 }); + }); + + it("rejects the same key listed twice", () => { + const key = newKey(); + expectAppError(() => setup([key, key]), "ENCRYPTION_KEY key ids collide; regenerate one key", { index: 1 }); + }); + + it("treats the first key as current", () => { + const a = newKey(); + const b = newKey(); + const { service: onlyA } = setup([a]); + const { service: onlyB } = setup([b]); + const { service: bThenA } = setup([b, a]); + expect(bThenA.currentKeyId()).toBe(onlyB.currentKeyId()); + expect(bThenA.currentKeyId()).not.toBe(onlyA.currentKeyId()); + }); + }); + + describe("round trip", () => { + it.each([ + ["ascii", "hello"], + ["pem-shaped", "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC7\n-----END PRIVATE KEY-----\n"], + ["multi-byte utf-8", "clé privée — 秘密鍵 🔑"], + ["empty", ""], + ])("decrypts what it encrypted (%s)", (_label, plaintext) => { + const { service } = setup([newKey()]); + expect(service.decrypt(service.encrypt(plaintext))).toBe(plaintext); + }); + + it("uses a fresh IV per call so equal plaintexts encrypt differently", () => { + const { service } = setup([newKey()]); + const first = service.encrypt("same"); + const second = service.encrypt("same"); + expect(first).not.toBe(second); + expect(service.decrypt(first)).toBe("same"); + expect(service.decrypt(second)).toBe("same"); + }); + + it("produces the documented five-segment base64url form", () => { + const { service } = setup([newKey()]); + expect(service.encrypt("x")).toMatch(/^v1\.[A-Za-z0-9_-]{6}\.[A-Za-z0-9_-]{16}\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]*$/); + }); + + it("does not leak the plaintext into the stored form", () => { + const { service } = setup([newKey()]); + const ciphertext = service.encrypt("-----BEGIN PRIVATE KEY-----"); + expect(ciphertext).not.toContain("PRIVATE"); + expect(Buffer.from(ciphertext.split(".")[4] ?? "", "base64url").toString("utf8")).not.toContain("PRIVATE"); + }); + + it("is deterministic for a fixed IV, pinning the format", () => { + const fixedIv = Buffer.alloc(12, 7); + const cryptoLib: EncryptionCryptoLib = { + createCipheriv: nodeCrypto.createCipheriv, + createDecipheriv: nodeCrypto.createDecipheriv, + createHash: nodeCrypto.createHash, + randomBytes: () => fixedIv, + }; + const key = newKey(); + const { service } = setup([key], cryptoLib); + const first = service.encrypt("pinned"); + const second = service.encrypt("pinned"); + expect(first).toBe(second); + expect(first.split(".")[2]).toBe(fixedIv.toString("base64url")); + expect(setup([key]).service.decrypt(first)).toBe("pinned"); + }); + }); + + describe("key selection and rotation", () => { + it("decrypts with a key that is not first in the list and reports it needs re-encryption", () => { + const a = newKey(); + const b = newKey(); + const { service: withA } = setup([a]); + const ciphertext = withA.encrypt("secret"); + + const { service: withBThenA } = setup([b, a]); + expect(withBThenA.decrypt(ciphertext)).toBe("secret"); + expect(withBThenA.keyIdOf(ciphertext)).toBe(withA.currentKeyId()); + expect(withBThenA.needsReencryption(ciphertext)).toBe(true); + }); + + it("encrypts under the first key and does not flag it for re-encryption", () => { + const { service } = setup([newKey(), newKey()]); + const ciphertext = service.encrypt("secret"); + expect(service.keyIdOf(ciphertext)).toBe(service.currentKeyId()); + expect(service.needsReencryption(ciphertext)).toBe(false); + }); + + it("fails with the key id when no listed key matches, but keyIdOf still answers", () => { + const a = newKey(); + const { service: withA } = setup([a]); + const ciphertext = withA.encrypt("secret"); + const idOfA = withA.currentKeyId(); + + const { service: withB } = setup([newKey()]); + expectAppError(() => withB.decrypt(ciphertext), "No encryption key matches ciphertext", { keyId: idOfA }); + expect(withB.keyIdOf(ciphertext)).toBe(idOfA); + }); + }); + + describe("tampering and malformed input", () => { + it("rejects a modified data segment", () => { + const { service } = setup([newKey()]); + const ciphertext = service.encrypt("payload"); + expectAppError(() => service.decrypt(tamper(ciphertext, 4, flipFirstChar)), "Ciphertext failed authentication"); + }); + + it("rejects a modified tag segment", () => { + const { service } = setup([newKey()]); + const ciphertext = service.encrypt("payload"); + expectAppError(() => service.decrypt(tamper(ciphertext, 3, flipFirstChar)), "Ciphertext failed authentication"); + }); + + it("rejects a modified iv segment", () => { + const { service } = setup([newKey()]); + const ciphertext = service.encrypt("payload"); + expectAppError(() => service.decrypt(tamper(ciphertext, 2, flipFirstChar)), "Ciphertext failed authentication"); + }); + + it("rejects a ciphertext relabelled with another listed key's id", () => { + const a = newKey(); + const b = newKey(); + const { service } = setup([a, b]); + const ciphertext = service.encrypt("payload"); + const idOfB = setup([b]).service.currentKeyId(); + expectAppError(() => service.decrypt(tamper(ciphertext, 1, () => idOfB)), "Ciphertext failed authentication"); + }); + + it("rejects a ciphertext whose key id matches a listed key made from different bytes", () => { + const a = newKey(); + const b = newKey(); + const ciphertext = setup([a]).service.encrypt("payload"); + const { service: withB } = setup([b]); + const forged = tamper(ciphertext, 1, () => withB.currentKeyId()); + expectAppError(() => withB.decrypt(forged), "Ciphertext failed authentication"); + }); + + it.each([ + ["too few segments", "v1.abc.def"], + ["too many segments", "v1.a.b.c.d.e"], + ["empty key id", "v1..AAAAAAAAAAAAAAAA.AAAAAAAAAAAAAAAAAAAAAA.AA"], + ["standard base64 padding", "v1.abcdef.AAAAAAAAAAAAAAA=.AAAAAAAAAAAAAAAAAAAAAA.AA"], + ["plus sign in a segment", "v1.abcdef.AAAAAAAAAAAAAA+A.AAAAAAAAAAAAAAAAAAAAAA.AA"], + ["iv of the wrong length", "v1.abcdef.AAAA.AAAAAAAAAAAAAAAAAAAAAA.AA"], + ["tag of the wrong length", "v1.abcdef.AAAAAAAAAAAAAAAA.AAAA.AA"], + ])("rejects malformed v1 input (%s)", (_label, input) => { + const { service } = setup([newKey()]); + expectAppError(() => service.decrypt(input), "Ciphertext is malformed"); + expectAppError(() => service.keyIdOf(input), "Ciphertext is malformed"); + }); + + it.each([ + ["empty string", "", ""], + ["no version segment", "abcdef.AAAA.AAAA.AA", "abcdef"], + ["future version with a different layout", "v2.x.y.z.w.v.u", "v2"], + ])("reports the version before any layout rule (%s)", (_label, input, version) => { + const { service } = setup([newKey()]); + expectAppError(() => service.decrypt(input), "Unsupported ciphertext version", { version }); + expectAppError(() => service.keyIdOf(input), "Unsupported ciphertext version", { version }); + }); + }); + + describe("unconfigured service", () => { + it("refuses to encrypt, decrypt, or evaluate rotation", () => { + const { service: configured } = setup([newKey()]); + const ciphertext = configured.encrypt("x"); + const { service } = setup([]); + expectAppError(() => service.encrypt("x"), "ENCRYPTION_KEY is not configured"); + expectAppError(() => service.decrypt(ciphertext), "ENCRYPTION_KEY is not configured"); + expectAppError(() => service.needsReencryption(ciphertext), "ENCRYPTION_KEY is not configured"); + }); + + it("still parses a key id out of a well-formed ciphertext", () => { + const { service: configured } = setup([newKey()]); + const ciphertext = configured.encrypt("x"); + const { service } = setup([]); + expect(service.keyIdOf(ciphertext)).toBe(configured.currentKeyId()); + }); + }); +}); diff --git a/server/test/unit/services/settingsService.test.ts b/server/test/unit/services/settingsService.test.ts index 063a64f6d..92a31a89b 100644 --- a/server/test/unit/services/settingsService.test.ts +++ b/server/test/unit/services/settingsService.test.ts @@ -16,6 +16,7 @@ const makeEnv = (overrides?: Partial): ValidatedEnv => DB_CONNECTION_STRING: "mongodb://localhost:27017/test_db", DB_TYPE: "mongodb", STATUS_PAGE_THEMES_ENABLED: false, + ENCRYPTION_KEY: [], ...overrides, }) as ValidatedEnv; diff --git a/server/test/unit/validation/envValidation.test.ts b/server/test/unit/validation/envValidation.test.ts new file mode 100644 index 000000000..e34610400 --- /dev/null +++ b/server/test/unit/validation/envValidation.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "@jest/globals"; +import { randomBytes } from "node:crypto"; +import { encryptionKeyList } from "../../../src/config/envValidation.ts"; + +const key = () => randomBytes(32).toString("base64"); +const decoded = (k: string) => Buffer.from(k, "base64"); + +const issueMessages = (input: string) => { + const result = encryptionKeyList.safeParse(input); + expect(result.success).toBe(false); + return result.success ? [] : result.error.issues.map((issue) => issue.message); +}; + +describe("encryptionKeyList", () => { + it("is an empty list when unset or blank", () => { + expect(encryptionKeyList.parse(undefined)).toEqual([]); + expect(encryptionKeyList.parse("")).toEqual([]); + expect(encryptionKeyList.parse(" , ")).toEqual([]); + }); + + it("accepts one key, trimming whitespace and a trailing comma", () => { + const k = key(); + expect(encryptionKeyList.parse(k)).toEqual([decoded(k)]); + expect(encryptionKeyList.parse(` ${k} ,`)).toEqual([decoded(k)]); + }); + + it("accepts several keys in the given order", () => { + const a = key(); + const b = key(); + expect(encryptionKeyList.parse(`${a},${b}`)).toEqual([decoded(a), decoded(b)]); + }); + + it.each([ + ["43 characters", key().slice(0, 43)], + ["no trailing =", key().slice(0, 43) + "A"], + ["a space inside", key().replace(/^(.{10})/, "$1 ")], + ["base64url alphabet", key().replace(/[+/]/g, "-").replace(/=$/, "")], + ["31 bytes", randomBytes(31).toString("base64")], + ["33 bytes", randomBytes(33).toString("base64")], + ])("rejects an entry that is not 32 standard-base64 bytes (%s)", (_label, bad) => { + expect(issueMessages(`${key()},${bad}`)).toContain("ENCRYPTION_KEY entry 2 must be 32 bytes as padded standard base64 (openssl rand -base64 32)"); + }); + + it("reports every bad entry, not just the first", () => { + const messages = issueMessages(`${key().slice(0, 43)},${key()},${randomBytes(31).toString("base64")}`); + expect(messages).toContain("ENCRYPTION_KEY entry 1 must be 32 bytes as padded standard base64 (openssl rand -base64 32)"); + expect(messages).toContain("ENCRYPTION_KEY entry 3 must be 32 bytes as padded standard base64 (openssl rand -base64 32)"); + expect(messages).toHaveLength(2); + }); + + it("numbers entries by comma position, counting blank slots", () => { + const bad = randomBytes(31).toString("base64"); + expect(issueMessages(`${key()},,${bad}`)).toContain( + "ENCRYPTION_KEY entry 3 must be 32 bytes as padded standard base64 (openssl rand -base64 32)" + ); + }); + + it("rejects the same key listed twice", () => { + const k = key(); + expect(issueMessages(`${k},${k}`)).toContain("ENCRYPTION_KEY entry 2 duplicates an earlier entry"); + }); + + it("never echoes the entry in an issue message", () => { + const bad = randomBytes(31).toString("base64"); + for (const message of issueMessages(bad)) { + expect(message).not.toContain(bad); + } + }); +});