forked from bai/curriculum-project-hub
189 lines
7.1 KiB
TypeScript
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");
|
|
}
|