From b14b2142e362933ca73fbc774f8b563cfc9a9c60 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 11 Sep 2026 10:09:30 -0700 Subject: [PATCH] add secrets rotation service and tests --- server/src/config/services.shared.ts | 2 +- server/src/config/services.worker.ts | 20 +++ .../{ => encryption}/encryptionService.ts | 0 .../encryption/secretsRotationService.ts | 60 +++++++ server/src/service/network/DockerProvider.ts | 2 +- .../providers/network/dockerProvider.test.ts | 2 +- .../unit/services/encryptionService.test.ts | 4 +- .../services/secretsRotationService.test.ts | 156 ++++++++++++++++++ 8 files changed, 241 insertions(+), 5 deletions(-) rename server/src/service/{ => encryption}/encryptionService.ts (100%) create mode 100644 server/src/service/encryption/secretsRotationService.ts create mode 100644 server/test/unit/services/secretsRotationService.test.ts diff --git a/server/src/config/services.shared.ts b/server/src/config/services.shared.ts index 4f8f63412..3b4fdb147 100644 --- a/server/src/config/services.shared.ts +++ b/server/src/config/services.shared.ts @@ -20,7 +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"; +import { IEncryptionService, EncryptionService } from "@/service/encryption/encryptionService.js"; // Notification providers import type { NotificationProviderRegistry } from "@/domain/notifications/notification.service.js"; diff --git a/server/src/config/services.worker.ts b/server/src/config/services.worker.ts index 0598a5466..8b2f4de07 100644 --- a/server/src/config/services.worker.ts +++ b/server/src/config/services.worker.ts @@ -16,6 +16,7 @@ import { SharedServices } from "@/config/services.shared.js"; import { INetworkService, NetworkService } from "@/service/networkService.js"; import { IBufferService, BufferService } from "@/service/bufferService.js"; import { IStatusService, StatusService } from "@/service/statusService.js"; +import { ISecretsRotationService, SecretsRotationService } from "@/service/encryption/secretsRotationService.js"; import { IQueueWorker } from "@/worker/worker.interface.js"; import { MonitorStatusPolicy } from "@/worker/worker.monitor-status-policy.js"; import { WorkerHelper } from "@/worker/worker.helper.js"; @@ -39,6 +40,7 @@ import { GameProvider } from "@/service/network/GameProvider.js"; import { GrpcProvider } from "@/service/network/GrpcProvider.js"; import { WebSocketProvider } from "@/service/network/WebSocketProvider.js"; import { DNSProvider } from "@/service/network/DNSProvider.js"; +import { AppError } from "@/utils/AppError.js"; export interface WorkerServices { worker: IQueueWorker; networkService: INetworkService; @@ -165,5 +167,23 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig workerId, }); + const secretsRotationService = new SecretsRotationService(monitorsRepository, encryptionService, logger); + try { + const result = await secretsRotationService.run(); + logger.debug({ + message: "Secrets rotation run successfully", + service: "WorkerServices", + method: "buildWorker", + details: { ...result }, + }); + } catch (error: unknown) { + logger.warn({ + message: `Could not rotate Docker TLS secrets: ${error instanceof Error ? error.message : String(error)}`, + service: "WorkerServices", + method: "buildWorker", + details: { ...(error instanceof AppError ? error.details : {}) }, + }); + } + return { worker, networkService, bufferService, statusService }; }; diff --git a/server/src/service/encryptionService.ts b/server/src/service/encryption/encryptionService.ts similarity index 100% rename from server/src/service/encryptionService.ts rename to server/src/service/encryption/encryptionService.ts diff --git a/server/src/service/encryption/secretsRotationService.ts b/server/src/service/encryption/secretsRotationService.ts new file mode 100644 index 000000000..c4e05881b --- /dev/null +++ b/server/src/service/encryption/secretsRotationService.ts @@ -0,0 +1,60 @@ +import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js"; +import { IEncryptionService } from "@/service/encryption/encryptionService.js"; +import { AppError } from "@/utils/AppError.js"; +import { ILogger } from "@/utils/logger.js"; + +const SERVICE_NAME = "SecretsRotationService"; + +export interface ISecretsRotationService { + run(): Promise<{ scanned: number; reencrypted: number; skipped: number }>; +} + +export class SecretsRotationService implements ISecretsRotationService { + static SERVICE_NAME = SERVICE_NAME; + + constructor( + private monitorsRepository: IMonitorsRepository, + private encryptionService: IEncryptionService, + private logger: ILogger + ) {} + + run = async () => { + const rows = await this.monitorsRepository.findAllDockerTlsKeys(); + const result = { scanned: rows.length, reencrypted: 0, skipped: 0 }; + if (rows.length === 0) return result; + + if (!this.encryptionService.isConfigured()) { + this.logger.warn({ + message: `${rows.length} monitor(s) have a stored Docker TLS key but ENCRYPTION_KEY is not set; their checks will fail until it is`, + service: SERVICE_NAME, + method: "run", + }); + result.skipped = rows.length; + return result; + } + + for (const monitor of rows) { + const { id, dockerTlsKey } = monitor; + try { + if (!this.encryptionService.needsReencryption(dockerTlsKey)) continue; + const plainText = this.encryptionService.decrypt(dockerTlsKey); + await this.monitorsRepository.updateDockerTlsKey(id, this.encryptionService.encrypt(plainText)); + result.reencrypted += 1; + } catch (error: unknown) { + result.skipped += 1; + this.logger.warn({ + message: `Could not reencrypt Docker TLS key for monitor ${id}: ${error instanceof Error ? error.message : String(error)}`, + service: SERVICE_NAME, + method: "run", + details: { monitorId: id, ...(error instanceof AppError ? error.details : {}) }, + }); + } + } + this.logger.info({ + message: `Docker TLS key rotation: ${result.reencrypted} re-encrypted, ${result.skipped} skipped, ${result.scanned} scanned`, + service: SERVICE_NAME, + method: "run", + }); + return result; + }; +} diff --git a/server/src/service/network/DockerProvider.ts b/server/src/service/network/DockerProvider.ts index c5eeda860..d68eebd38 100644 --- a/server/src/service/network/DockerProvider.ts +++ b/server/src/service/network/DockerProvider.ts @@ -25,7 +25,7 @@ import { AppError } from "@/utils/AppError.js"; import Dockerode from "dockerode"; import { timeRequest } from "@/service/network/utils.js"; import { NETWORK_ERROR } from "@/types/network.js"; -import { IEncryptionService } from "@/service/encryptionService.js"; +import { IEncryptionService } from "@/service/encryption/encryptionService.js"; import { DOCKER_TLS_URL, isDockerTlsUrl } from "@/utils/dockerHost.js"; import { splitCertificateBundle } from "@/utils/pem.js"; diff --git a/server/test/unit/providers/network/dockerProvider.test.ts b/server/test/unit/providers/network/dockerProvider.test.ts index 4562db8de..f49ecef9e 100644 --- a/server/test/unit/providers/network/dockerProvider.test.ts +++ b/server/test/unit/providers/network/dockerProvider.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, jest } from "@jest/globals"; import https from "node:https"; import { DockerProvider } from "../../../../src/service/network/DockerProvider.ts"; -import type { IEncryptionService } from "../../../../src/service/encryptionService.ts"; +import type { IEncryptionService } from "../../../../src/service/encryption/encryptionService.ts"; import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts"; import { createMockLogger } from "../../../helpers/createMockLogger.ts"; import { NETWORK_ERROR } from "../../../../src/types/network.ts"; diff --git a/server/test/unit/services/encryptionService.test.ts b/server/test/unit/services/encryptionService.test.ts index c700343a3..9797f4939 100644 --- a/server/test/unit/services/encryptionService.test.ts +++ b/server/test/unit/services/encryptionService.test.ts @@ -1,7 +1,7 @@ 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 { EncryptionService } from "../../../src/service/encryption/encryptionService.ts"; +import type { EncryptionCryptoLib } from "../../../src/service/encryption/encryptionService.ts"; import { AppError } from "../../../src/utils/AppError.ts"; import { createMockLogger } from "../../helpers/createMockLogger.ts"; diff --git a/server/test/unit/services/secretsRotationService.test.ts b/server/test/unit/services/secretsRotationService.test.ts new file mode 100644 index 000000000..c0caf9c34 --- /dev/null +++ b/server/test/unit/services/secretsRotationService.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import * as nodeCrypto from "node:crypto"; +import { EncryptionService } from "../../../src/service/encryption/encryptionService.ts"; +import { SecretsRotationService } from "../../../src/service/encryption/secretsRotationService.ts"; +import type { IMonitorsRepository } from "../../../src/domain/monitors/monitor.repository.interface.ts"; +import { createMockLogger } from "../../helpers/createMockLogger.ts"; + +type Row = { id: string; dockerTlsKey: string }; + +const PLAINTEXT = "-----BEGIN PRIVATE KEY-----\nstub\n-----END PRIVATE KEY-----"; + +const newKey = () => nodeCrypto.randomBytes(32); + +const encryptionServiceFor = (keys: Buffer[]) => new EncryptionService(keys, createMockLogger(), nodeCrypto); + +const tamperData = (ciphertext: string) => { + const parts = ciphertext.split("."); + const data = parts[4] ?? ""; + parts[4] = (data[0] === "A" ? "B" : "A") + data.slice(1); + return parts.join("."); +}; + +const setup = (keys: Buffer[], rows: Row[]) => { + const encryptionService = encryptionServiceFor(keys); + const repository = { + findAllDockerTlsKeys: jest.fn(async () => rows), + updateDockerTlsKey: jest.fn(async () => undefined), + }; + const logger = createMockLogger(); + const service = new SecretsRotationService(repository as unknown as IMonitorsRepository, encryptionService, logger); + return { service, encryptionService, repository, logger }; +}; + +const updatedKeyFor = (repository: { updateDockerTlsKey: jest.Mock }, id: string): string => { + const call = repository.updateDockerTlsKey.mock.calls.find(([calledId]) => calledId === id); + expect(call).toBeDefined(); + return (call as [string, string])[1]; +}; + +describe("SecretsRotationService", () => { + it("re-encrypts stale rows under the current key and leaves current rows untouched", async () => { + const current = newKey(); + const previous = newKey(); + const stale = encryptionServiceFor([previous]); + const rows = [ + { id: "m-current", dockerTlsKey: encryptionServiceFor([current]).encrypt(PLAINTEXT) }, + { id: "m-stale-1", dockerTlsKey: stale.encrypt(PLAINTEXT) }, + { id: "m-stale-2", dockerTlsKey: stale.encrypt(PLAINTEXT) }, + ]; + const { service, encryptionService, repository, logger } = setup([current, previous], rows); + + const result = await service.run(); + + expect(result).toEqual({ scanned: 3, reencrypted: 2, skipped: 0 }); + expect(repository.updateDockerTlsKey).toHaveBeenCalledTimes(2); + for (const id of ["m-stale-1", "m-stale-2"]) { + const rotated = updatedKeyFor(repository, id); + expect(encryptionService.keyIdOf(rotated)).toBe(encryptionService.currentKeyId()); + expect(encryptionService.decrypt(rotated)).toBe(PLAINTEXT); + } + expect(repository.updateDockerTlsKey).not.toHaveBeenCalledWith("m-current", expect.anything()); + expect(logger.warn).not.toHaveBeenCalled(); + expect(logger.info).toHaveBeenCalledWith( + expect.objectContaining({ message: "Docker TLS key rotation: 2 re-encrypted, 0 skipped, 3 scanned", service: "SecretsRotationService" }) + ); + }); + + it("returns zero counts when every row is already under the current key", async () => { + const current = newKey(); + const rows = [ + { id: "m-1", dockerTlsKey: encryptionServiceFor([current]).encrypt(PLAINTEXT) }, + { id: "m-2", dockerTlsKey: encryptionServiceFor([current]).encrypt(PLAINTEXT) }, + ]; + const { service, repository, logger } = setup([current], rows); + + const result = await service.run(); + + expect(result).toEqual({ scanned: 2, reencrypted: 0, skipped: 0 }); + expect(repository.updateDockerTlsKey).not.toHaveBeenCalled(); + expect(logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: "Docker TLS key rotation: 0 re-encrypted, 0 skipped, 2 scanned" })); + }); + + it("skips a row encrypted under an unknown key and logs its key id", async () => { + const unknown = encryptionServiceFor([newKey()]); + const rows = [{ id: "m-unknown", dockerTlsKey: unknown.encrypt(PLAINTEXT) }]; + const { service, repository, logger } = setup([newKey()], rows); + + const result = await service.run(); + + expect(result).toEqual({ scanned: 1, reencrypted: 0, skipped: 1 }); + expect(repository.updateDockerTlsKey).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("Could not reencrypt Docker TLS key for monitor m-unknown"), + service: "SecretsRotationService", + details: { monitorId: "m-unknown", keyId: unknown.currentKeyId() }, + }) + ); + }); + + it("continues past a row that fails to decrypt", async () => { + const current = newKey(); + const previous = newKey(); + const stale = encryptionServiceFor([previous]); + const rows = [ + { id: "m-broken", dockerTlsKey: tamperData(stale.encrypt(PLAINTEXT)) }, + { id: "m-stale", dockerTlsKey: stale.encrypt(PLAINTEXT) }, + ]; + const { service, encryptionService, repository, logger } = setup([current, previous], rows); + + const result = await service.run(); + + expect(result).toEqual({ scanned: 2, reencrypted: 1, skipped: 1 }); + expect(repository.updateDockerTlsKey).toHaveBeenCalledTimes(1); + expect(encryptionService.decrypt(updatedKeyFor(repository, "m-stale"))).toBe(PLAINTEXT); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("Could not reencrypt Docker TLS key for monitor m-broken: Ciphertext failed authentication"), + details: expect.objectContaining({ monitorId: "m-broken" }), + }) + ); + expect(logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: "Docker TLS key rotation: 1 re-encrypted, 1 skipped, 2 scanned" })); + }); + + it("warns and skips every row when ENCRYPTION_KEY is not configured", async () => { + const rows = [ + { id: "m-1", dockerTlsKey: encryptionServiceFor([newKey()]).encrypt(PLAINTEXT) }, + { id: "m-2", dockerTlsKey: encryptionServiceFor([newKey()]).encrypt(PLAINTEXT) }, + ]; + const { service, repository, logger } = setup([], rows); + + const result = await service.run(); + + expect(result).toEqual({ scanned: 2, reencrypted: 0, skipped: 2 }); + expect(repository.updateDockerTlsKey).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalledTimes(1); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + message: "2 monitor(s) have a stored Docker TLS key but ENCRYPTION_KEY is not set; their checks will fail until it is", + service: "SecretsRotationService", + }) + ); + expect(logger.info).not.toHaveBeenCalled(); + }); + + it("logs nothing when ENCRYPTION_KEY is not configured and there are no rows", async () => { + const { service, repository, logger } = setup([], []); + + const result = await service.run(); + + expect(result).toEqual({ scanned: 0, reencrypted: 0, skipped: 0 }); + expect(repository.updateDockerTlsKey).not.toHaveBeenCalled(); + expect(logger.warn).not.toHaveBeenCalled(); + expect(logger.info).not.toHaveBeenCalled(); + }); +});