Add encryption service and tests. Update README

This commit is contained in:
Alex Holliday committed 2026-09-10 10:31:41 -07:00
1 parent ef7ac8bef8
commit f009aa17d9
10 files changed
+545 -3

No files matched your search

+1
View File
@@ -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`) |
+1
View File
@@ -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:
+11
View File
@@ -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.
+36
View File
@@ -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>;
+10 -2
View File
@@ -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
View File
@@ -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
+176
View File
@@ -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);
}
});
});