Files

189 lines
7.1 KiB
TypeScript

import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
LocalSecretEnvelope,
loadLocalSecretKeyring,
type SecretBinding,
} from "../../src/security/secretEnvelope.js";
const createdDirectories: string[] = [];
afterEach(async () => {
await Promise.all(createdDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })));
});
describe("local secret envelope", () => {
it("encrypts with a per-secret DEK and authenticates the complete binding", async () => {
const keyring = await keyringFile({ active: key("a"), previous: key("b") }, "active");
const secrets = new LocalSecretEnvelope(await loadLocalSecretKeyring({
NODE_ENV: "test",
HUB_SECRET_KEYRING_FILE: keyring,
}));
const binding = secretBinding();
const plaintext = {
schemaVersion: 1,
baseUrl: "https://provider.example/api",
authToken: "org-a-secret-token",
anthropicApiKey: "org-a-api-key",
};
const envelope = secrets.encryptJson(binding, plaintext);
expect(envelope).toMatchObject({
version: 1,
algorithm: "AES-256-GCM",
keyId: "active",
});
expect(JSON.stringify(envelope)).not.toContain(plaintext.authToken);
expect(envelope.wrappedDek.ciphertext).not.toBe(envelope.payload.ciphertext);
expect(secrets.decryptJson(binding, envelope)).toEqual(plaintext);
});
it.each([
["organization", { organizationId: "org-b" }],
["connection", { connectionId: "connection-b" }],
["version", { secretVersionId: "secret-version-b" }],
["purpose", { purpose: "feishu-application" }],
])("rejects ciphertext substituted across %s scope", async (_label, changed) => {
const keyring = await keyringFile({ active: key("a") }, "active");
const secrets = new LocalSecretEnvelope(await loadLocalSecretKeyring({
NODE_ENV: "test",
HUB_SECRET_KEYRING_FILE: keyring,
}));
const binding = secretBinding();
const envelope = secrets.encryptJson(binding, { token: "never-log-this-token" });
expect(() => secrets.decryptJson({ ...binding, ...changed }, envelope)).toThrow(
"secret envelope authentication failed",
);
});
it("fails closed for a missing KEK or corrupted ciphertext without including plaintext", async () => {
const encryptingKeyring = await keyringFile({ encrypting: key("a") }, "encrypting");
const decryptingKeyring = await keyringFile({ other: key("b") }, "other");
const encrypting = new LocalSecretEnvelope(await loadLocalSecretKeyring({
NODE_ENV: "test",
HUB_SECRET_KEYRING_FILE: encryptingKeyring,
}));
const decrypting = new LocalSecretEnvelope(await loadLocalSecretKeyring({
NODE_ENV: "test",
HUB_SECRET_KEYRING_FILE: decryptingKeyring,
}));
const binding = secretBinding();
const envelope = encrypting.encryptJson(binding, { token: "never-log-this-token" });
expect(() => decrypting.decryptJson(binding, envelope)).toThrow("secret envelope key is unavailable");
const corrupted = structuredClone(envelope);
corrupted.payload.ciphertext = flipFirstByte(corrupted.payload.ciphertext);
let message = "";
try {
encrypting.decryptJson(binding, corrupted);
} catch (error) {
message = error instanceof Error ? error.message : String(error);
}
expect(message).toBe("secret envelope authentication failed");
expect(message).not.toContain("never-log-this-token");
});
it("rewraps only the DEK when the active KEK rotates", async () => {
const firstFile = await keyringFile({ old: key("a") }, "old");
const rotatedFile = await keyringFile({ old: key("a"), current: key("c") }, "current");
const first = new LocalSecretEnvelope(await loadLocalSecretKeyring({
NODE_ENV: "test",
HUB_SECRET_KEYRING_FILE: firstFile,
}));
const rotated = new LocalSecretEnvelope(await loadLocalSecretKeyring({
NODE_ENV: "test",
HUB_SECRET_KEYRING_FILE: rotatedFile,
}));
const binding = secretBinding();
const envelope = first.encryptJson(binding, { token: "rotatable" });
const rewrapped = rotated.rewrap(binding, envelope);
expect(rewrapped.keyId).toBe("current");
expect(rewrapped.payload).toEqual(envelope.payload);
expect(rewrapped.wrappedDek).not.toEqual(envelope.wrappedDek);
expect(rotated.decryptJson(binding, rewrapped)).toEqual({ token: "rotatable" });
});
it("loads production keys only from the fixed systemd credential", async () => {
const credentialsDirectory = await temporaryDirectory();
await writeKeyring(path.join(credentialsDirectory, "cph-secret-keyring"), { active: key("a") }, "active");
await expect(loadLocalSecretKeyring({
NODE_ENV: "production",
CREDENTIALS_DIRECTORY: credentialsDirectory,
})).resolves.toMatchObject({ activeKeyId: "active" });
await expect(loadLocalSecretKeyring({
NODE_ENV: "production",
CREDENTIALS_DIRECTORY: credentialsDirectory,
HUB_SECRET_KEYRING_FILE: "/tmp/untrusted-keyring.json",
})).rejects.toThrow("production secret keyring must use the systemd credential");
});
it("rejects absent, malformed, or incomplete keyrings", async () => {
await expect(loadLocalSecretKeyring({ NODE_ENV: "test" })).rejects.toThrow(
"missing required secret keyring setting",
);
const directory = await temporaryDirectory();
const malformed = path.join(directory, "malformed.json");
await writeFile(malformed, "not json", "utf8");
await expect(loadLocalSecretKeyring({
NODE_ENV: "test",
HUB_SECRET_KEYRING_FILE: malformed,
})).rejects.toThrow("invalid local secret keyring");
const missingActive = await keyringFile({ old: key("a") }, "current");
await expect(loadLocalSecretKeyring({
NODE_ENV: "test",
HUB_SECRET_KEYRING_FILE: missingActive,
})).rejects.toThrow("active key is unavailable");
});
});
function secretBinding(): SecretBinding {
return {
purpose: "provider-connection",
organizationId: "org-a",
connectionId: "connection-a",
secretVersionId: "secret-version-a",
};
}
function key(character: string): string {
return Buffer.alloc(32, character).toString("base64");
}
async function keyringFile(keys: Readonly<Record<string, string>>, activeKeyId: string): Promise<string> {
const directory = await temporaryDirectory();
const file = path.join(directory, "keyring.json");
await writeKeyring(file, keys, activeKeyId);
return file;
}
async function temporaryDirectory(): Promise<string> {
const directory = await mkdtemp(path.join(tmpdir(), "cph-secret-envelope-"));
createdDirectories.push(directory);
return directory;
}
async function writeKeyring(
file: string,
keys: Readonly<Record<string, string>>,
activeKeyId: string,
): Promise<void> {
await writeFile(file, JSON.stringify({ version: 1, activeKeyId, keys }), { encoding: "utf8", mode: 0o600 });
}
function flipFirstByte(value: string): string {
const bytes = Buffer.from(value, "base64");
bytes[0] = (bytes[0] ?? 0) ^ 1;
return bytes.toString("base64");
}