forked from bai/curriculum-project-hub
feat: secure organization provider credentials
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
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");
|
||||
}
|
||||
Reference in New Issue
Block a user