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>, activeKeyId: string): Promise { const directory = await temporaryDirectory(); const file = path.join(directory, "keyring.json"); await writeKeyring(file, keys, activeKeyId); return file; } async function temporaryDirectory(): Promise { const directory = await mkdtemp(path.join(tmpdir(), "cph-secret-envelope-")); createdDirectories.push(directory); return directory; } async function writeKeyring( file: string, keys: Readonly>, activeKeyId: string, ): Promise { 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"); }