feat: secure organization provider credentials

This commit is contained in:
2026-07-10 22:04:43 +08:00
parent e049cb6880
commit 848f913597
51 changed files with 3280 additions and 230 deletions
+204
View File
@@ -0,0 +1,204 @@
import { randomBytes } from "node:crypto";
import { lstat, open, rename, unlink, type FileHandle } from "node:fs/promises";
import { basename, dirname, isAbsolute, join } from "node:path";
import type { Prisma, PrismaClient } from "@prisma/client";
import { rewrapStoredProviderEnvelopes } from "../connections/providerConnections.js";
import {
LocalSecretEnvelope,
loadLocalSecretKeyringFile,
type LocalSecretKeyring,
} from "./secretEnvelope.js";
export interface RotateLocalKekInput {
readonly keyringFile: string;
readonly prisma: PrismaClient;
readonly now?: Date;
readonly generatedKey?: Buffer;
readonly expectedOwner?: { readonly uid: number; readonly gid: number };
}
export interface RotateLocalKekResult {
readonly previousActiveKeyId: string;
readonly activeKeyId: string;
readonly verified: number;
readonly rewrapped: number;
}
export const LOCAL_KEK_ROTATION_ADVISORY_LOCK = 7_310_579_862_737_525_041n;
/**
* Atomically installs a new active KEK while retaining all previous keys, then
* rewraps database DEKs. A crash after the file rename is recoverable because
* both old and new KEKs remain in the keyring and the operation is rerunnable.
*/
export async function rotateLocalKek(input: RotateLocalKekInput): Promise<RotateLocalKekResult> {
if (!isAbsolute(input.keyringFile)) {
throw new Error("local secret keyring path must be absolute");
}
const expectedOwner = input.expectedOwner ?? { uid: 0, gid: 0 };
const initialMetadata = await lstat(input.keyringFile);
assertKeyringMetadata(initialMetadata, expectedOwner);
return input.prisma.$transaction(async (tx) => {
const rows = await tx.$queryRaw<Array<{ readonly locked: boolean }>>`
SELECT pg_try_advisory_xact_lock(${LOCAL_KEK_ROTATION_ADVISORY_LOCK}) AS "locked"
`;
if (rows[0]?.locked !== true) {
throw new Error("local KEK rotation lock is already held");
}
return rotateUnderLock(input, expectedOwner, tx);
}, { maxWait: 5_000, timeout: 300_000 });
}
async function rotateUnderLock(
input: RotateLocalKekInput,
expectedOwner: { readonly uid: number; readonly gid: number },
tx: Prisma.TransactionClient,
): Promise<RotateLocalKekResult> {
const metadata = await lstat(input.keyringFile);
assertKeyringMetadata(metadata, expectedOwner);
const previous = await loadLocalSecretKeyringFile(input.keyringFile);
const generatedKey = input.generatedKey === undefined
? randomBytes(32)
: Buffer.from(input.generatedKey);
if (generatedKey.byteLength !== 32) {
generatedKey.fill(0);
throw new Error("generated local KEK must contain exactly 32 bytes");
}
const keyId = nextKeyId(input.now ?? new Date());
if (previous.keys.has(keyId)) {
generatedKey.fill(0);
throw new Error(`generated local KEK identifier already exists: ${keyId}`);
}
const keys = new Map(previous.keys);
keys.set(keyId, generatedKey);
const keyring: LocalSecretKeyring = { activeKeyId: keyId, keys };
try {
await replaceKeyringAtomically(input.keyringFile, keyring, metadata.uid, metadata.gid);
const result = await rewrapStoredProviderEnvelopes(
tx,
new LocalSecretEnvelope(keyring),
);
return {
previousActiveKeyId: previous.activeKeyId,
activeKeyId: keyId,
...result,
};
} finally {
generatedKey.fill(0);
}
}
function assertKeyringMetadata(
metadata: {
readonly mode: number;
readonly uid: number;
readonly gid: number;
isFile(): boolean;
isSymbolicLink(): boolean;
},
expectedOwner: { readonly uid: number; readonly gid: number },
): void {
if (metadata.isSymbolicLink() || !metadata.isFile()) {
throw new Error("local secret keyring must be a regular non-symlink file");
}
if (metadata.uid !== expectedOwner.uid || metadata.gid !== expectedOwner.gid) {
throw new Error(
`local secret keyring must be owned by ${expectedOwner.uid}:${expectedOwner.gid}`,
);
}
if ((metadata.mode & 0o777) !== 0o600) {
throw new Error("local secret keyring must have mode 0600");
}
}
async function replaceKeyringAtomically(
file: string,
keyring: LocalSecretKeyring,
uid: number,
gid: number,
): Promise<void> {
const directory = dirname(file);
const temporary = join(directory, `.${basename(file)}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`);
const encodedKeys = Object.fromEntries(
[...keyring.keys].map(([keyId, key]) => [keyId, key.toString("base64")]),
);
const contents = `${JSON.stringify({ version: 1, activeKeyId: keyring.activeKeyId, keys: encodedKeys })}\n`;
let handle: FileHandle | undefined;
try {
handle = await open(temporary, "wx", 0o600);
await handle.chown(uid, gid);
await handle.writeFile(contents, "utf8");
await handle.sync();
await handle.close();
handle = undefined;
await rename(temporary, file);
await syncDirectory(directory);
} catch (error) {
const cleanupFailures: unknown[] = [];
if (handle !== undefined) {
try {
await handle.close();
} catch (cleanupError) {
cleanupFailures.push(cleanupError);
}
}
try {
await removeIfExists(temporary);
} catch (cleanupError) {
cleanupFailures.push(cleanupError);
}
if (cleanupFailures.length > 0) {
throw new AggregateError(
[error, ...cleanupFailures],
"local keyring replacement and cleanup both failed",
{ cause: error },
);
}
throw error;
}
}
async function syncDirectory(directory: string): Promise<void> {
const handle = await open(directory, "r");
let syncFailure: unknown;
try {
await handle.sync();
} catch (error) {
syncFailure = error;
}
let closeFailure: unknown;
try {
await handle.close();
} catch (error) {
closeFailure = error;
}
if (syncFailure !== undefined && closeFailure !== undefined) {
throw new AggregateError(
[syncFailure, closeFailure],
"keyring directory sync and handle close both failed",
{ cause: syncFailure },
);
}
if (syncFailure !== undefined) throw syncFailure;
if (closeFailure !== undefined) throw closeFailure;
}
async function removeIfExists(file: string): Promise<void> {
try {
await unlink(file);
} catch (error) {
if (isErrno(error, "ENOENT")) return;
throw error;
}
}
function isErrno(error: unknown, code: string): error is NodeJS.ErrnoException {
return error instanceof Error && "code" in error && error.code === code;
}
function nextKeyId(now: Date): string {
const timestamp = now.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
return `local-${timestamp}-${randomBytes(4).toString("hex")}`;
}
+268
View File
@@ -0,0 +1,268 @@
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
import { readFile } from "node:fs/promises";
import path from "node:path";
const KEY_BYTES = 32;
const NONCE_BYTES = 12;
const AUTH_TAG_BYTES = 16;
const PRODUCTION_CREDENTIAL_NAME = "cph-secret-keyring";
export interface SecretBinding {
readonly purpose: string;
readonly organizationId: string;
readonly connectionId: string;
readonly secretVersionId: string;
}
export interface EncryptedPart {
readonly nonce: string;
readonly ciphertext: string;
readonly authTag: string;
}
export interface SecretEnvelopeV1 {
readonly version: 1;
readonly algorithm: "AES-256-GCM";
readonly keyId: string;
readonly wrappedDek: EncryptedPart;
readonly payload: EncryptedPart;
}
export interface LocalSecretKeyring {
readonly activeKeyId: string;
readonly keys: ReadonlyMap<string, Buffer>;
}
export type SecretKeyringEnv = Readonly<Record<string, string | undefined>>;
/**
* Loads the one local KEK source accepted by the current runtime. Production
* deliberately has no file-path fallback: systemd must materialize the fixed
* credential below CREDENTIALS_DIRECTORY.
*/
export async function loadLocalSecretKeyring(
env: SecretKeyringEnv = process.env,
): Promise<LocalSecretKeyring> {
const nodeEnv = env["NODE_ENV"]?.trim();
const configuredFile = env["HUB_SECRET_KEYRING_FILE"]?.trim();
let file: string;
if (nodeEnv === "production") {
if (configuredFile !== undefined && configuredFile !== "") {
throw new Error("production secret keyring must use the systemd credential");
}
const credentialsDirectory = env["CREDENTIALS_DIRECTORY"]?.trim();
if (credentialsDirectory === undefined || credentialsDirectory === "") {
throw new Error("missing required secret keyring setting: CREDENTIALS_DIRECTORY");
}
file = path.join(credentialsDirectory, PRODUCTION_CREDENTIAL_NAME);
} else {
if (configuredFile === undefined || configuredFile === "") {
throw new Error("missing required secret keyring setting: HUB_SECRET_KEYRING_FILE");
}
file = configuredFile;
}
return loadLocalSecretKeyringFile(file);
}
export async function loadLocalSecretKeyringFile(file: string): Promise<LocalSecretKeyring> {
let source: string;
try {
source = await readFile(file, "utf8");
} catch (error) {
throw new Error("local secret keyring is unavailable", { cause: error });
}
return parseKeyring(source);
}
export class LocalSecretEnvelope {
constructor(private readonly keyring: LocalSecretKeyring) {}
get activeKeyId(): string {
return this.keyring.activeKeyId;
}
encryptJson(binding: SecretBinding, value: unknown): SecretEnvelopeV1 {
validateBinding(binding);
const kek = requireKey(this.keyring, this.keyring.activeKeyId);
const dek = randomBytes(KEY_BYTES);
const payloadBytes = Buffer.from(JSON.stringify(value), "utf8");
try {
return {
version: 1,
algorithm: "AES-256-GCM",
keyId: this.keyring.activeKeyId,
wrappedDek: encryptPart(kek, dek, additionalData(binding, "dek")),
payload: encryptPart(dek, payloadBytes, additionalData(binding, "payload")),
};
} finally {
dek.fill(0);
payloadBytes.fill(0);
}
}
decryptJson<T = unknown>(binding: SecretBinding, envelope: SecretEnvelopeV1): T {
validateBinding(binding);
validateEnvelope(envelope);
const kek = requireKey(this.keyring, envelope.keyId);
const dek = decryptPart(kek, envelope.wrappedDek, additionalData(binding, "dek"));
try {
const plaintext = decryptPart(dek, envelope.payload, additionalData(binding, "payload"));
try {
return JSON.parse(plaintext.toString("utf8")) as T;
} catch (error) {
throw new Error("secret envelope payload is invalid", { cause: error });
} finally {
plaintext.fill(0);
}
} finally {
dek.fill(0);
}
}
rewrap(binding: SecretBinding, envelope: SecretEnvelopeV1): SecretEnvelopeV1 {
validateBinding(binding);
validateEnvelope(envelope);
const previousKek = requireKey(this.keyring, envelope.keyId);
const activeKek = requireKey(this.keyring, this.keyring.activeKeyId);
const dek = decryptPart(previousKek, envelope.wrappedDek, additionalData(binding, "dek"));
try {
return {
...envelope,
keyId: this.keyring.activeKeyId,
wrappedDek: encryptPart(activeKek, dek, additionalData(binding, "dek")),
};
} finally {
dek.fill(0);
}
}
}
function parseKeyring(source: string): LocalSecretKeyring {
let value: unknown;
try {
value = JSON.parse(source) as unknown;
} catch (error) {
throw new Error("invalid local secret keyring", { cause: error });
}
if (!isRecord(value) || value["version"] !== 1 || !isNonEmptyString(value["activeKeyId"]) ||
!isRecord(value["keys"])) {
throw new Error("invalid local secret keyring");
}
const keys = new Map<string, Buffer>();
for (const [keyId, encodedKey] of Object.entries(value["keys"])) {
if (!isNonEmptyString(keyId) || !isNonEmptyString(encodedKey)) {
throw new Error("invalid local secret keyring");
}
const key = decodeBase64(encodedKey, "invalid local secret keyring");
if (key.byteLength !== KEY_BYTES) {
throw new Error("invalid local secret keyring");
}
keys.set(keyId, key);
}
const activeKeyId = value["activeKeyId"];
if (!keys.has(activeKeyId)) {
throw new Error("local secret keyring active key is unavailable");
}
return { activeKeyId, keys };
}
function encryptPart(key: Buffer, plaintext: Buffer, aad: Buffer): EncryptedPart {
const nonce = randomBytes(NONCE_BYTES);
const cipher = createCipheriv("aes-256-gcm", key, nonce, { authTagLength: AUTH_TAG_BYTES });
cipher.setAAD(aad);
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
const authTag = cipher.getAuthTag();
return {
nonce: nonce.toString("base64"),
ciphertext: ciphertext.toString("base64"),
authTag: authTag.toString("base64"),
};
}
function decryptPart(key: Buffer, encrypted: EncryptedPart, aad: Buffer): Buffer {
try {
const nonce = decodeBase64(encrypted.nonce, "secret envelope is malformed");
const ciphertext = decodeBase64(encrypted.ciphertext, "secret envelope is malformed");
const authTag = decodeBase64(encrypted.authTag, "secret envelope is malformed");
if (nonce.byteLength !== NONCE_BYTES || authTag.byteLength !== AUTH_TAG_BYTES) {
throw new Error("secret envelope is malformed");
}
const decipher = createDecipheriv("aes-256-gcm", key, nonce, { authTagLength: AUTH_TAG_BYTES });
decipher.setAAD(aad);
decipher.setAuthTag(authTag);
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
} catch (error) {
if (error instanceof Error && error.message === "secret envelope is malformed") {
throw error;
}
throw new Error("secret envelope authentication failed", { cause: error });
}
}
function additionalData(binding: SecretBinding, layer: "dek" | "payload"): Buffer {
return Buffer.from(JSON.stringify([
"cph-secret-envelope",
1,
layer,
binding.purpose,
binding.organizationId,
binding.connectionId,
binding.secretVersionId,
]), "utf8");
}
function requireKey(keyring: LocalSecretKeyring, keyId: string): Buffer {
const key = keyring.keys.get(keyId);
if (key === undefined) {
throw new Error("secret envelope key is unavailable");
}
return key;
}
function validateBinding(binding: SecretBinding): void {
if (!isNonEmptyString(binding.purpose) || !isNonEmptyString(binding.organizationId) ||
!isNonEmptyString(binding.connectionId) || !isNonEmptyString(binding.secretVersionId)) {
throw new Error("secret envelope binding is invalid");
}
}
function validateEnvelope(envelope: SecretEnvelopeV1): void {
if (!isRecord(envelope) || envelope["version"] !== 1 || envelope["algorithm"] !== "AES-256-GCM" ||
!isNonEmptyString(envelope["keyId"]) || !isEncryptedPart(envelope["wrappedDek"]) ||
!isEncryptedPart(envelope["payload"])) {
throw new Error("secret envelope is malformed");
}
}
function isEncryptedPart(value: unknown): value is EncryptedPart {
return isRecord(value) && isNonEmptyString(value["nonce"]) &&
isNonEmptyString(value["ciphertext"]) && isNonEmptyString(value["authTag"]);
}
function decodeBase64(value: string, errorMessage: string): Buffer {
if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) {
throw new Error(errorMessage);
}
const decoded = Buffer.from(value, "base64");
if (decoded.toString("base64") !== value) {
throw new Error(errorMessage);
}
return decoded;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim() !== "";
}