Add encryption service and tests. Update README
This commit is contained in:
1 parent
ef7ac8bef8
commit
f009aa17d9
10 files changed
+545
-3
No files matched your search
@@ -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`) |
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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=<output of openssl rand -base64 32>
|
||||
|
||||
# Feature Flags
|
||||
# -------------
|
||||
# Set to "false" to disable the selectable theme feature on status pages.
|
||||
|
||||
@@ -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<string>();
|
||||
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<typeof envSchema>;
|
||||
|
||||
@@ -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<Mongoose>;
|
||||
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<SharedServices> => {
|
||||
// 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<Mongoose> | null = null;
|
||||
@@ -213,6 +220,7 @@ export const buildShared = async ({
|
||||
db,
|
||||
settingsService,
|
||||
emailService,
|
||||
encryptionService,
|
||||
notificationMessageBuilder,
|
||||
incidentService,
|
||||
checkService,
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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<typeof nodeCrypto, "createCipheriv" | "createDecipheriv" | "createHash" | "randomBytes">;
|
||||
|
||||
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<string, unknown>): 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:
|
||||
// <version>.<keyId>.<iv>.<tag>.<encryptedData>
|
||||
// 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");
|
||||
};
|
||||
}
|
||||
@@ -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<string, unknown>) => {
|
||||
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());
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,7 @@ const makeEnv = (overrides?: Partial<ValidatedEnv>): ValidatedEnv =>
|
||||
DB_CONNECTION_STRING: "mongodb://localhost:27017/test_db",
|
||||
DB_TYPE: "mongodb",
|
||||
STATUS_PAGE_THEMES_ENABLED: false,
|
||||
ENCRYPTION_KEY: [],
|
||||
...overrides,
|
||||
}) as ValidatedEnv;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user