mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-15 06:35:21 +00:00
feat: Hardware security modules
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
import * as grapheneLib from "graphene-pk11";
|
||||
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { logger } from "@app/lib/logger";
|
||||
|
||||
export type HsmModule = {
|
||||
module: grapheneLib.Module | null;
|
||||
graphene: typeof grapheneLib;
|
||||
};
|
||||
|
||||
export const initializePkcs11Module = () => {
|
||||
const appCfg = getConfig();
|
||||
|
||||
let module: grapheneLib.Module | null = null;
|
||||
|
||||
const initialize = () => {
|
||||
if (!appCfg.isHsmConfigured) {
|
||||
return;
|
||||
}
|
||||
|
||||
module = grapheneLib.Module.load(appCfg.HSM_LIB_PATH!, "SoftHSM");
|
||||
module.initialize();
|
||||
logger.info("PKCS#11 module initialized");
|
||||
};
|
||||
|
||||
const finalize = () => {
|
||||
if (module) {
|
||||
module.finalize();
|
||||
logger.info("PKCS#11 module finalized");
|
||||
}
|
||||
};
|
||||
|
||||
const getModule = (): HsmModule => ({ module, graphene: grapheneLib });
|
||||
|
||||
return {
|
||||
initialize,
|
||||
finalize,
|
||||
getModule
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,276 @@
|
||||
import grapheneLib from "graphene-pk11";
|
||||
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { logger } from "@app/lib/logger";
|
||||
|
||||
import { HsmModule } from "./hsm-fns";
|
||||
|
||||
type THsmServiceFactoryDep = {
|
||||
pkcs11Module: HsmModule;
|
||||
};
|
||||
const SESSION_TIMEOUT = 5 * 60 * 1000; // 5 minutes
|
||||
const USER_ALREADY_LOGGED_IN_ERROR = "CKR_USER_ALREADY_LOGGED_IN";
|
||||
|
||||
export type THsmServiceFactory = ReturnType<typeof hsmServiceFactory>;
|
||||
|
||||
class HsmSessionManager {
|
||||
private session: grapheneLib.Session | null = null;
|
||||
|
||||
private lastUsed: number = 0;
|
||||
|
||||
private module: grapheneLib.Module;
|
||||
|
||||
private graphene: typeof grapheneLib;
|
||||
|
||||
private sessionCheckInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
private startSessionMonitoring() {
|
||||
// Check session health every minute
|
||||
this.sessionCheckInterval = setInterval(() => {
|
||||
this.checkAndRefreshSession();
|
||||
}, 60 * 1000); // 1 minute
|
||||
}
|
||||
|
||||
private checkAndRefreshSession() {
|
||||
if (!this.session) return;
|
||||
|
||||
const now = Date.now();
|
||||
if (now - this.lastUsed > SESSION_TIMEOUT) {
|
||||
logger.info("Session expired, cleaning up...");
|
||||
this.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
private cleanup() {
|
||||
if (this.session) {
|
||||
try {
|
||||
this.session.logout();
|
||||
this.session.close();
|
||||
} catch (error) {
|
||||
logger.error("Error during session cleanup:", error);
|
||||
}
|
||||
this.session = null;
|
||||
}
|
||||
|
||||
if (this.sessionCheckInterval) {
|
||||
clearInterval(this.sessionCheckInterval);
|
||||
this.sessionCheckInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
getSession(): grapheneLib.Session {
|
||||
const appCfg = getConfig();
|
||||
|
||||
// If we have a valid session, update its last used time and return it
|
||||
if (this.session) {
|
||||
try {
|
||||
// Try a simple operation to verify session is still valid
|
||||
this.session.generateRandom(16);
|
||||
this.lastUsed = Date.now();
|
||||
return this.session;
|
||||
} catch (error) {
|
||||
logger.info("HSM Session validation failed, creating new session...");
|
||||
this.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
// Create new session
|
||||
const slot = this.module.getSlots(appCfg.HSM_SLOT);
|
||||
// eslint-disable-next-line no-bitwise
|
||||
if (!(slot.flags & this.graphene.SlotFlag.TOKEN_PRESENT)) {
|
||||
throw new Error("Slot is not initialized");
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-bitwise
|
||||
const session = slot.open(this.graphene.SessionFlag.RW_SESSION | this.graphene.SessionFlag.SERIAL_SESSION);
|
||||
|
||||
try {
|
||||
session.login(appCfg.HSM_PIN!);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (error: any) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -- The error is of type `Pkcs11Error`, but this error is not exported by graphene. And we don't want to install another library just for an error assertion.
|
||||
if (error.message !== USER_ALREADY_LOGGED_IN_ERROR) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
this.session = session;
|
||||
this.lastUsed = Date.now();
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
constructor(module: grapheneLib.Module, graphene: typeof grapheneLib) {
|
||||
this.module = module;
|
||||
this.graphene = graphene;
|
||||
|
||||
this.startSessionMonitoring();
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-empty-pattern
|
||||
export const hsmServiceFactory = ({ pkcs11Module: { module, graphene } }: THsmServiceFactoryDep) => {
|
||||
const appCfg = getConfig();
|
||||
|
||||
// Constants for buffer structure
|
||||
const IV_LENGTH = 16;
|
||||
const TAG_LENGTH = 16;
|
||||
|
||||
let sessionManager: HsmSessionManager | null = null;
|
||||
|
||||
const $findKey = (session: grapheneLib.Session) => {
|
||||
// Find the existing AES key
|
||||
const template = {
|
||||
class: graphene.ObjectClass.SECRET_KEY,
|
||||
keyType: graphene.KeyType.AES,
|
||||
label: appCfg.HSM_KEY_LABEL
|
||||
} as grapheneLib.ITemplate;
|
||||
|
||||
const key = session.find(template).items(0);
|
||||
|
||||
if (!key) {
|
||||
throw new Error("Failed to encrypt data, AES key not found");
|
||||
}
|
||||
|
||||
return key;
|
||||
};
|
||||
|
||||
const $keyExists = (session: grapheneLib.Session): boolean => {
|
||||
try {
|
||||
const key = $findKey(session);
|
||||
// items(0) will throw an error if no items are found
|
||||
// Return true only if we got a valid object with handle
|
||||
return key && typeof key.handle !== "undefined";
|
||||
} catch (error) {
|
||||
// If items(0) throws, it means no key was found
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-call
|
||||
if ((error as any).message?.includes("CKR_OBJECT_HANDLE_INVALID")) {
|
||||
return false;
|
||||
}
|
||||
logger.error(error, "Error checking for HSM key presence");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const isActive = async () => {
|
||||
if (!module || !appCfg.isHsmConfigured || !sessionManager) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return appCfg.isHsmConfigured && module !== null;
|
||||
};
|
||||
|
||||
const startService = () => {
|
||||
if (!appCfg.isHsmConfigured || !module) return;
|
||||
|
||||
sessionManager = new HsmSessionManager(module, graphene);
|
||||
const session = sessionManager.getSession();
|
||||
|
||||
try {
|
||||
// Check if key already exists
|
||||
if ($keyExists(session)) {
|
||||
logger.info("Key already exists, skipping creation");
|
||||
} else {
|
||||
// Generate 256-bit AES key with persistent storage
|
||||
session.generateKey(graphene.KeyGenMechanism.AES, {
|
||||
class: graphene.ObjectClass.SECRET_KEY,
|
||||
token: true, // This ensures the key is stored persistently
|
||||
valueLen: 256 / 8,
|
||||
keyType: graphene.KeyType.AES,
|
||||
label: appCfg.HSM_KEY_LABEL,
|
||||
encrypt: true,
|
||||
decrypt: true,
|
||||
extractable: false, // Prevent key export
|
||||
sensitive: true, // Mark as sensitive data
|
||||
private: true // Require login to access
|
||||
});
|
||||
logger.info(`Key created successfully with label: ${appCfg.HSM_KEY_LABEL}`);
|
||||
}
|
||||
|
||||
const mechs = session.slot.getMechanisms();
|
||||
let gotAesGcmMechanism = false;
|
||||
|
||||
// eslint-disable-next-line no-plusplus
|
||||
for (let i = 0; i < mechs.length; i++) {
|
||||
const mech = mechs.items(i);
|
||||
if (mech.name === "AES_GCM") {
|
||||
gotAesGcmMechanism = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!gotAesGcmMechanism) {
|
||||
throw new Error("Failed to initialize HSM. AES GCM encryption mechanism not supported by the HSM");
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(error, "Error creating HSM key");
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
function encrypt(data: Buffer): Buffer {
|
||||
if (!module) {
|
||||
throw new Error("PKCS#11 module is not initialized");
|
||||
}
|
||||
|
||||
if (!sessionManager) {
|
||||
throw new Error("HSM Session manager is not initialized");
|
||||
}
|
||||
const session = sessionManager.getSession();
|
||||
const key = $findKey(session);
|
||||
|
||||
// Generate IV
|
||||
const iv = session.generateRandom(IV_LENGTH);
|
||||
const alg = {
|
||||
name: appCfg.HSM_MECHANISM,
|
||||
params: new graphene.AesGcm240Params(iv)
|
||||
} as grapheneLib.IAlgorithm;
|
||||
|
||||
const cipher = session.createCipher(alg, new graphene.Key(key).toType());
|
||||
|
||||
// Calculate the output buffer size based on input length
|
||||
// GCM adds a 16-byte auth tag, so we need input length + 16
|
||||
const outputBuffer = Buffer.alloc(data.length + TAG_LENGTH);
|
||||
const encryptedData = cipher.once(data, outputBuffer);
|
||||
|
||||
// Combine IV + encrypted data into a single buffer
|
||||
// Format: [IV (16 bytes)][Encrypted Data][Auth Tag (16 bytes)]
|
||||
return Buffer.concat([iv, encryptedData]);
|
||||
}
|
||||
|
||||
function decrypt(encryptedBlob: Buffer): Buffer {
|
||||
if (!module) {
|
||||
throw new Error("PKCS#11 module is not initialized");
|
||||
}
|
||||
|
||||
if (!sessionManager) {
|
||||
throw new Error("HSM Session manager is not initialized");
|
||||
}
|
||||
|
||||
const session = sessionManager.getSession();
|
||||
const key = $findKey(session);
|
||||
|
||||
// Extract IV, ciphertext, and tag from the blob
|
||||
const iv = encryptedBlob.subarray(0, IV_LENGTH);
|
||||
const ciphertext = encryptedBlob.subarray(IV_LENGTH, encryptedBlob.length);
|
||||
|
||||
const algo = {
|
||||
name: appCfg.HSM_MECHANISM,
|
||||
params: new graphene.AesGcm240Params(iv) // Pass both IV and tag
|
||||
};
|
||||
|
||||
const decipher = session.createDecipher(algo, new graphene.Key(key).toType());
|
||||
|
||||
// Allocate buffer for decrypted data
|
||||
const outputBuffer = Buffer.alloc(ciphertext.length);
|
||||
|
||||
const decrypted = decipher.once(ciphertext, outputBuffer);
|
||||
return decrypted;
|
||||
}
|
||||
return {
|
||||
encrypt,
|
||||
startService,
|
||||
isActive,
|
||||
decrypt
|
||||
};
|
||||
};
|
||||
@@ -19,6 +19,7 @@ import { logger } from "@app/lib/logger";
|
||||
import { alphaNumericNanoId } from "@app/lib/nanoid";
|
||||
import { getByteLengthForAlgorithm } from "@app/services/kms/kms-fns";
|
||||
|
||||
import { THsmServiceFactory } from "../hsm/hsm-service";
|
||||
import { TOrgDALFactory } from "../org/org-dal";
|
||||
import { TProjectDALFactory } from "../project/project-dal";
|
||||
import { TInternalKmsDALFactory } from "./internal-kms-dal";
|
||||
@@ -40,9 +41,10 @@ type TKmsServiceFactoryDep = {
|
||||
kmsDAL: TKmsKeyDALFactory;
|
||||
projectDAL: Pick<TProjectDALFactory, "findById" | "updateById" | "transaction">;
|
||||
orgDAL: Pick<TOrgDALFactory, "findById" | "updateById" | "transaction">;
|
||||
kmsRootConfigDAL: Pick<TKmsRootConfigDALFactory, "findById" | "create">;
|
||||
kmsRootConfigDAL: Pick<TKmsRootConfigDALFactory, "findById" | "create" | "updateById">;
|
||||
keyStore: Pick<TKeyStoreFactory, "acquireLock" | "waitTillReady" | "setItemWithExpiry">;
|
||||
internalKmsDAL: Pick<TInternalKmsDALFactory, "create">;
|
||||
hsmService: THsmServiceFactory;
|
||||
};
|
||||
|
||||
export type TKmsServiceFactory = ReturnType<typeof kmsServiceFactory>;
|
||||
@@ -63,7 +65,8 @@ export const kmsServiceFactory = ({
|
||||
keyStore,
|
||||
internalKmsDAL,
|
||||
orgDAL,
|
||||
projectDAL
|
||||
projectDAL,
|
||||
hsmService
|
||||
}: TKmsServiceFactoryDep) => {
|
||||
let ROOT_ENCRYPTION_KEY = Buffer.alloc(0);
|
||||
|
||||
@@ -801,6 +804,7 @@ export const kmsServiceFactory = ({
|
||||
const isBase64 = !appCfg.ENCRYPTION_KEY;
|
||||
if (!encryptionKey) throw new Error("Root encryption key not found for KMS service.");
|
||||
const encryptionKeyBuffer = Buffer.from(encryptionKey, isBase64 ? "base64" : "utf8");
|
||||
const hsmEnabled = await hsmService.isActive();
|
||||
|
||||
const lock = await keyStore.acquireLock([`KMS_ROOT_CFG_LOCK`], 3000, { retryCount: 3 }).catch(() => null);
|
||||
if (!lock) {
|
||||
@@ -817,8 +821,43 @@ export const kmsServiceFactory = ({
|
||||
if (kmsRootConfig) {
|
||||
if (lock) await lock.release();
|
||||
logger.info("KMS: Encrypted ROOT Key found from DB. Decrypting.");
|
||||
const decryptedRootKey = cipher.decrypt(kmsRootConfig.encryptedRootKey, encryptionKeyBuffer);
|
||||
// set the flag so that other instancen nodes can start
|
||||
|
||||
let decryptedRootKey: Buffer | null = null;
|
||||
try {
|
||||
decryptedRootKey = cipher.decrypt(kmsRootConfig.encryptedRootKey, encryptionKeyBuffer);
|
||||
logger.info("KMS: Decrypted ROOT Key with platform key.");
|
||||
} catch (err) {
|
||||
// First we attempt to decrypt with regular root key. If it fails, we check if HSM is enabled, and if it is we attempt to decrypt with HSM.
|
||||
if (hsmEnabled && kmsRootConfig.isEncryptedByHsm) {
|
||||
decryptedRootKey = hsmService.decrypt(kmsRootConfig.encryptedRootKey);
|
||||
logger.info("KMS: Decrypted ROOT Key with HSM.");
|
||||
} else {
|
||||
// If HSM is not enabled we assume it's a general error, and throw.
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
if (!decryptedRootKey) {
|
||||
logger.error(
|
||||
{ hsmEnabled, isEncryptedByHsm: kmsRootConfig.isEncryptedByHsm },
|
||||
"KMS: Failed to decrypt ROOT Key"
|
||||
);
|
||||
throw new Error("Failed to decrypt ROOT Key");
|
||||
}
|
||||
|
||||
// If the key is not encrypted with HSM, we re-encrypt it with HSM and update the key in the DB.
|
||||
if (!kmsRootConfig.isEncryptedByHsm && hsmEnabled) {
|
||||
const encryptedRootKey = hsmService.encrypt(decryptedRootKey);
|
||||
|
||||
if (!encryptedRootKey) {
|
||||
logger.error("KMS: Failed to encrypt ROOT Key with HSM");
|
||||
throw new Error("Failed to encrypt ROOT Key with HSM");
|
||||
}
|
||||
|
||||
await kmsRootConfigDAL.updateById(KMS_ROOT_CONFIG_UUID, { encryptedRootKey, isEncryptedByHsm: true });
|
||||
}
|
||||
|
||||
// set the flag so that other instance nodes can start
|
||||
await keyStore.setItemWithExpiry(KMS_ROOT_CREATION_WAIT_KEY, KMS_ROOT_CREATION_WAIT_TIME, "true");
|
||||
logger.info("KMS: Loading ROOT Key into Memory.");
|
||||
ROOT_ENCRYPTION_KEY = decryptedRootKey;
|
||||
@@ -827,9 +866,23 @@ export const kmsServiceFactory = ({
|
||||
|
||||
logger.info("KMS: Generating ROOT Key");
|
||||
const newRootKey = randomSecureBytes(32);
|
||||
const encryptedRootKey = cipher.encrypt(newRootKey, encryptionKeyBuffer);
|
||||
|
||||
let encryptedRootKey: Buffer | null = null;
|
||||
let isEncryptedByHsm = false;
|
||||
|
||||
if (hsmEnabled) {
|
||||
encryptedRootKey = hsmService.encrypt(newRootKey);
|
||||
isEncryptedByHsm = true;
|
||||
} else {
|
||||
encryptedRootKey = cipher.encrypt(newRootKey, encryptionKeyBuffer);
|
||||
}
|
||||
|
||||
if (!encryptedRootKey) {
|
||||
logger.error({ hsmEnabled }, "KMS: Failed to encrypt ROOT Key");
|
||||
}
|
||||
|
||||
// @ts-expect-error id is kept as fixed for idempotence and to avoid race condition
|
||||
await kmsRootConfigDAL.create({ encryptedRootKey, id: KMS_ROOT_CONFIG_UUID });
|
||||
await kmsRootConfigDAL.create({ encryptedRootKey, id: KMS_ROOT_CONFIG_UUID, isEncryptedByHsm });
|
||||
|
||||
// set the flag so that other instancen nodes can start
|
||||
await keyStore.setItemWithExpiry(KMS_ROOT_CREATION_WAIT_KEY, KMS_ROOT_CREATION_WAIT_TIME, "true");
|
||||
|
||||
Reference in New Issue
Block a user