forked from EduCraft/curriculum-project-hub
161 lines
6.8 KiB
TypeScript
161 lines
6.8 KiB
TypeScript
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { afterAll, beforeEach, describe, expect, it } from "vitest";
|
|
import { ProviderConnectionService, verifyStoredProviderEnvelopes } from "../../src/connections/providerConnections.js";
|
|
import {
|
|
FeishuApplicationConnectionService,
|
|
verifyStoredFeishuApplicationEnvelopes,
|
|
} from "../../src/connections/feishuApplicationConnections.js";
|
|
import { LocalSecretEnvelope, loadLocalSecretKeyringFile, type SecretEnvelopeV1 } from "../../src/security/secretEnvelope.js";
|
|
import { LOCAL_KEK_ROTATION_ADVISORY_LOCK, rotateLocalKek } from "../../src/security/localKekRotation.js";
|
|
import { DEFAULT_ORG_ID, prisma, resetDb } from "./helpers.js";
|
|
|
|
const temporaryDirectories: string[] = [];
|
|
|
|
describe("local KEK rotation", () => {
|
|
beforeEach(async () => {
|
|
await resetDb();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })));
|
|
await prisma.$disconnect();
|
|
});
|
|
|
|
it("atomically retains the old KEK, rewraps DEKs, and authenticates every envelope", async () => {
|
|
const oldKey = Buffer.alloc(32, "o");
|
|
const oldSecrets = new LocalSecretEnvelope({
|
|
activeKeyId: "local-old",
|
|
keys: new Map([["local-old", oldKey]]),
|
|
});
|
|
await prisma.user.create({ data: { id: "rotation-admin", feishuOpenId: "ou_rotation", displayName: "Rotation" } });
|
|
await prisma.organizationMembership.create({
|
|
data: { organizationId: DEFAULT_ORG_ID, userId: "rotation-admin", role: "OWNER" },
|
|
});
|
|
await new ProviderConnectionService(prisma, oldSecrets, async () => {}).rotateByok({
|
|
organizationId: DEFAULT_ORG_ID,
|
|
providerId: "openrouter",
|
|
actorUserId: "rotation-admin",
|
|
baseUrl: "https://provider.example/api",
|
|
authToken: "credential-stays-encrypted",
|
|
});
|
|
await new FeishuApplicationConnectionService(prisma, oldSecrets, async () => {}).rotateCustomerApplication({
|
|
organizationId: DEFAULT_ORG_ID,
|
|
actorUserId: "rotation-admin",
|
|
appId: "cli_rotation",
|
|
appSecret: "feishu-credential-stays-encrypted",
|
|
botOpenId: "ou_rotation_bot",
|
|
});
|
|
const before = await prisma.providerCredentialVersion.findFirstOrThrow({
|
|
include: { connection: true },
|
|
});
|
|
const beforeEnvelope = before.envelope as unknown as SecretEnvelopeV1;
|
|
const beforeFeishu = await prisma.feishuApplicationCredentialVersion.findFirstOrThrow({
|
|
include: { connection: true },
|
|
});
|
|
const beforeFeishuEnvelope = beforeFeishu.envelope as unknown as SecretEnvelopeV1;
|
|
const directory = await mkdtemp(join(tmpdir(), "cph-kek-rotation-"));
|
|
temporaryDirectories.push(directory);
|
|
const keyringFile = join(directory, "keyring.json");
|
|
await writeFile(keyringFile, JSON.stringify({
|
|
version: 1,
|
|
activeKeyId: "local-old",
|
|
keys: { "local-old": oldKey.toString("base64") },
|
|
}), { mode: 0o600 });
|
|
const expectedOwner = {
|
|
uid: process.getuid?.() ?? 0,
|
|
gid: process.getgid?.() ?? 0,
|
|
};
|
|
if (expectedOwner.uid !== 0 || expectedOwner.gid !== 0) {
|
|
await expect(rotateLocalKek({
|
|
keyringFile,
|
|
prisma,
|
|
generatedKey: Buffer.alloc(32, "x"),
|
|
})).rejects.toThrow("must be owned by 0:0");
|
|
}
|
|
|
|
let releaseLock!: () => void;
|
|
const releaseLockPromise = new Promise<void>((resolve) => {
|
|
releaseLock = resolve;
|
|
});
|
|
let lockAcquired!: () => void;
|
|
const lockAcquiredPromise = new Promise<void>((resolve) => {
|
|
lockAcquired = resolve;
|
|
});
|
|
const lockHolder = prisma.$transaction(async (tx) => {
|
|
await tx.$queryRaw<Array<{ readonly locked: string }>>`
|
|
SELECT pg_advisory_xact_lock(${LOCAL_KEK_ROTATION_ADVISORY_LOCK})::text AS "locked"
|
|
`;
|
|
lockAcquired();
|
|
await releaseLockPromise;
|
|
}, { timeout: 30_000 });
|
|
await lockAcquiredPromise;
|
|
try {
|
|
await expect(rotateLocalKek({
|
|
keyringFile,
|
|
prisma,
|
|
generatedKey: Buffer.alloc(32, "x"),
|
|
expectedOwner,
|
|
})).rejects.toThrow("rotation lock is already held");
|
|
} finally {
|
|
releaseLock();
|
|
await lockHolder;
|
|
}
|
|
|
|
const result = await rotateLocalKek({
|
|
keyringFile,
|
|
prisma,
|
|
now: new Date("2026-07-10T12:34:56.000Z"),
|
|
generatedKey: Buffer.alloc(32, "n"),
|
|
expectedOwner,
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
previousActiveKeyId: "local-old",
|
|
activeKeyId: expect.stringMatching(/^local-20260710T123456Z-/),
|
|
rewrapped: 2,
|
|
verified: 2,
|
|
});
|
|
const after = await prisma.providerCredentialVersion.findUniqueOrThrow({ where: { id: before.id } });
|
|
const afterEnvelope = after.envelope as unknown as SecretEnvelopeV1;
|
|
expect(after.keyId).toBe(result.activeKeyId);
|
|
expect(afterEnvelope.payload).toEqual(beforeEnvelope.payload);
|
|
expect(afterEnvelope.wrappedDek).not.toEqual(beforeEnvelope.wrappedDek);
|
|
const afterFeishu = await prisma.feishuApplicationCredentialVersion.findUniqueOrThrow({
|
|
where: { id: beforeFeishu.id },
|
|
});
|
|
const afterFeishuEnvelope = afterFeishu.envelope as unknown as SecretEnvelopeV1;
|
|
expect(afterFeishu.keyId).toBe(result.activeKeyId);
|
|
expect(afterFeishuEnvelope.payload).toEqual(beforeFeishuEnvelope.payload);
|
|
expect(afterFeishuEnvelope.wrappedDek).not.toEqual(beforeFeishuEnvelope.wrappedDek);
|
|
const keyringSource = await readFile(keyringFile, "utf8");
|
|
expect(keyringSource).toContain("local-old");
|
|
expect(keyringSource).toContain(result.activeKeyId);
|
|
expect(keyringSource).not.toContain("credential-stays-encrypted");
|
|
expect(keyringSource).not.toContain("feishu-credential-stays-encrypted");
|
|
|
|
const rotatedSecrets = new LocalSecretEnvelope(await loadLocalSecretKeyringFile(keyringFile));
|
|
await expect(verifyStoredProviderEnvelopes(prisma, rotatedSecrets)).resolves.toBe(1);
|
|
await expect(verifyStoredFeishuApplicationEnvelopes(prisma, rotatedSecrets)).resolves.toBe(1);
|
|
expect(() => oldSecrets.decryptJson({
|
|
purpose: "provider-connection",
|
|
organizationId: before.connection.organizationId,
|
|
connectionId: before.connection.id,
|
|
secretVersionId: before.id,
|
|
}, afterEnvelope)).toThrow("secret envelope key is unavailable");
|
|
expect(() => oldSecrets.decryptJson({
|
|
purpose: "feishu-application",
|
|
organizationId: beforeFeishu.connection.organizationId,
|
|
connectionId: beforeFeishu.connection.id,
|
|
secretVersionId: beforeFeishu.id,
|
|
}, afterFeishuEnvelope)).toThrow("secret envelope key is unavailable");
|
|
await expect(prisma.auditEntry.count({
|
|
where: { action: "provider_secret.kek_rewrapped", organizationId: DEFAULT_ORG_ID },
|
|
})).resolves.toBe(1);
|
|
await expect(prisma.auditEntry.count({
|
|
where: { action: "feishu_application_secret.kek_rewrapped", organizationId: DEFAULT_ORG_ID },
|
|
})).resolves.toBe(1);
|
|
});
|
|
});
|