forked from EduCraft/curriculum-project-hub
feat: secure organization provider credentials
This commit is contained in:
@@ -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")}`;
|
||||
}
|
||||
@@ -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() !== "";
|
||||
}
|
||||
Reference in New Issue
Block a user