add secrets rotation service and tests
This commit is contained in:
1 parent
c64c4cc5ff
commit
b14b2142e3
8 files changed
+241
-5
No files matched your search
@@ -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";
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
File renamed without changes.
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user