feat(fips): fips inside

This commit is contained in:
Daniel Hougaard
2025-07-07 18:16:53 +04:00
parent 70071015d2
commit ce88b0cbb1
19 changed files with 244 additions and 100 deletions
+46 -6
View File
@@ -1,6 +1,8 @@
import { z } from "zod";
import { crypto } from "@app/lib/crypto/cryptography";
import { QueueWorkerProfile } from "@app/lib/types";
import { TSuperAdminDALFactory } from "@app/services/super-admin/super-admin-dal";
import { BadRequestError } from "../errors";
import { removeTrailingSlash } from "../fn";
@@ -349,7 +351,7 @@ export const getConfig = () => envCfg;
export const getOriginalConfig = () => originalEnvConfig;
// cannot import singleton logger directly as it needs config to load various transport
export const initEnvConfig = (logger?: CustomLogger) => {
export const initEnvConfig = async (superAdminDAL?: TSuperAdminDALFactory, logger?: CustomLogger) => {
const parsedEnv = envSchema.safeParse(process.env);
if (!parsedEnv.success) {
(logger ?? console).error("Invalid environment variables. Check the error below");
@@ -357,16 +359,50 @@ export const initEnvConfig = (logger?: CustomLogger) => {
process.exit(-1);
}
const config = Object.freeze(parsedEnv.data);
envCfg = config;
if (superAdminDAL) {
const fipsEnabled = await crypto.initialize(superAdminDAL);
if (!originalEnvConfig) {
originalEnvConfig = config;
if (fipsEnabled) {
const newEnvCfg = {
...envCfg,
ROOT_ENCRYPTION_KEY: envCfg.ENCRYPTION_KEY
};
delete newEnvCfg.ENCRYPTION_KEY;
envCfg = Object.freeze(newEnvCfg);
}
}
if (!envCfg) {
const config = Object.freeze(parsedEnv.data);
envCfg = config;
if (!originalEnvConfig) {
originalEnvConfig = config;
}
}
return envCfg;
};
export const getDatabaseCredentials = (logger?: CustomLogger) => {
const parsedEnv = envSchema.safeParse(process.env);
if (!parsedEnv.success) {
(logger ?? console).error("Invalid environment variables. Check the error below");
(logger ?? console).error(parsedEnv.error.issues);
process.exit(-1);
}
return {
dbConnectionUri: envCfg.DB_CONNECTION_URI,
dbRootCert: envCfg.DB_ROOT_CERT,
readReplicas: envCfg.DB_READ_REPLICAS?.map((el) => ({
dbRootCert: el.DB_ROOT_CERT,
dbConnectionUri: el.DB_CONNECTION_URI
}))
};
};
// A list of environment variables that can be overwritten
export const overwriteSchema: {
[key: string]: {
@@ -462,7 +498,11 @@ export const overrideEnvConfig = (config: Record<string, string>) => {
const parsedResult = envSchema.safeParse(tempEnv);
if (parsedResult.success) {
envCfg = Object.freeze(parsedResult.data);
envCfg = Object.freeze({
...parsedResult.data,
ENCRYPTION_KEY: envCfg.ENCRYPTION_KEY,
ROOT_ENCRYPTION_KEY: envCfg.ROOT_ENCRYPTION_KEY
});
}
};
+107 -41
View File
@@ -8,6 +8,7 @@ import nacl from "tweetnacl";
import naclUtils from "tweetnacl-util";
import { SecretEncryptionAlgo, SecretKeyEncoding } from "@app/db/schemas";
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
import { TSuperAdminDALFactory } from "@app/services/super-admin/super-admin-dal";
import { ADMIN_CONFIG_DB_UUID } from "@app/services/super-admin/super-admin-service";
@@ -67,40 +68,101 @@ const IV_BYTES_SIZE = 12;
const BLOCK_SIZE_BYTES_16 = 16;
const hasherFipsValidated = () => {
const $hashPassword = (password: string, salt: string, iterations: number, keyLength: number) => {
return new Promise<string>((resolve, reject) => {
const keySize = 32;
// For the salt when using pkdf2, we do salt rounds^6. If the salt rounds are 10, this will result in 10^6 = 1.000.000 iterations.
// The reason for this is because pbkdf2 is not as compute intense as bcrypt, making it faster to brute-force.
// From my testing, doing salt rounds^6 brings the computational power required to a little more than bcrypt.
// OWASP recommends a minimum of 600.000 iterations for pbkdf2, so 1.000.000 is more than enough.
// Ref: https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#pbkdf2
const MIN_COST_FACTOR = 10;
const MAX_COST_FACTOR = 20; // Iterations scales polynomial (costFactor^6), so we need an upper bound
const $calculateIterations = (costFactor: number) => {
return Math.round(costFactor ** 6);
};
const $hashPassword = (password: Buffer, salt: Buffer, iterations: number, keyLength: number) => {
return new Promise<Buffer>((resolve, reject) => {
crypto.pbkdf2(password, salt, iterations, keyLength, "sha256", (err, derivedKey) => {
if (err) {
return reject(err);
}
resolve(derivedKey.toString("hex"));
resolve(derivedKey);
});
});
};
const $validatePassword = (
inputPassword: string,
storedHash: string,
salt: string,
const $validatePassword = async (
inputPassword: Buffer,
storedHash: Buffer,
salt: Buffer,
iterations: number,
keyLength: number
) => {
return $hashPassword(inputPassword, salt, iterations, keyLength).then((hash) => hash === storedHash);
const computedHash = await $hashPassword(inputPassword, salt, iterations, keyLength);
return crypto.timingSafeEqual(computedHash, storedHash);
};
const hash = async (password: string, saltRounds: number) => {
const salt = crypto.randomBytes(16).toString("hex");
const derivedKey = await $hashPassword(password, salt, saltRounds, 32);
return `$infisical$${saltRounds}$${salt}$${derivedKey}`;
const hash = async (password: string, costFactor: number) => {
// Strict input validation
if (typeof password !== "string" || password.length === 0) {
throw new CryptographyError({
message: "Invalid input, password must be a non-empty string"
});
}
if (!Number.isInteger(costFactor)) {
throw new CryptographyError({
message: "Invalid cost factor, must be an integer"
});
}
if (costFactor < MIN_COST_FACTOR || costFactor > MAX_COST_FACTOR) {
throw new CryptographyError({
message: `Invalid cost factor, must be between ${MIN_COST_FACTOR} and ${MAX_COST_FACTOR}`
});
}
const iterations = $calculateIterations(costFactor);
const salt = crypto.randomBytes(16);
const derivedKey = await $hashPassword(Buffer.from(password), salt, iterations, keySize);
const combined = Buffer.concat([salt, derivedKey]);
return `$v1$${costFactor}$${combined.toString("base64")}`; // Store original costFactor!
};
const compare = async (password: string, hashedPassword: string) => {
if (!hashedPassword.startsWith("$infisical$")) {
throw new Error("Invalid hash format");
}
try {
if (!hashedPassword?.startsWith("$v1$")) return false;
const [, , iterations, salt, storedHash] = hashedPassword.split("$");
return $validatePassword(password, storedHash, salt, Number(iterations), 32);
const parts = hashedPassword.split("$");
if (parts.length !== 4) return false;
const [, , storedCostFactor, combined] = parts;
if (
!Number.isInteger(Number(storedCostFactor)) ||
Number(storedCostFactor) < MIN_COST_FACTOR ||
Number(storedCostFactor) > MAX_COST_FACTOR
) {
return false;
}
const combinedBuffer = Buffer.from(combined, "base64");
const salt = combinedBuffer.subarray(0, 16);
const storedHash = combinedBuffer.subarray(16);
const iterations = $calculateIterations(Number(storedCostFactor));
const isMatch = await $validatePassword(Buffer.from(password), storedHash, salt, iterations, keySize);
return isMatch;
} catch {
return false;
}
};
return {
@@ -216,7 +278,9 @@ const decryptAsymmetricFipsValidated = ({
const final = decipher.final();
return Buffer.concat([plaintext, final]).toString("utf8");
} catch (error) {
throw new Error("Invalid ciphertext or keys");
throw new CryptographyError({
message: "Invalid ciphertext or keys"
});
}
};
@@ -276,7 +340,9 @@ export const computeMd5 = (message: string, digest: DigestType = DigestType.Hex)
encoder = cryptoJs.enc.Base64;
break;
default:
throw new Error(`Invalid digest type: ${digest as string}`);
throw new CryptographyError({
message: `Invalid digest type: ${digest as string}`
});
}
return cryptoJs.MD5(message).toString(encoder);
@@ -289,7 +355,9 @@ const cryptographyFactory = () => {
const $checkIsInitialized = () => {
if (!$isInitialized) {
throw new Error("Internal cryptography module is not initialized");
throw new CryptographyError({
message: "Internal cryptography module is not initialized"
});
}
};
@@ -297,6 +365,15 @@ const cryptographyFactory = () => {
$checkIsInitialized();
return $fipsEnabled;
};
const verifyFipsLicense = (licenseService: Pick<TLicenseServiceFactory, "onPremFeatures">) => {
if (isFipsModeEnabled() && !licenseService.onPremFeatures?.fips) {
throw new CryptographyError({
message: "FIPS mode is enabled but your license does not include FIPS support. Please contact support."
});
}
};
const $setFipsModeEnabled = (enabled: boolean) => {
// If FIPS is enabled, we need to validate that the ENCRYPTION_KEY is in a base64 format, and is a 256-bit key.
if (enabled) {
@@ -305,6 +382,7 @@ const cryptographyFactory = () => {
if (appCfg.ENCRYPTION_KEY) {
// we need to validate that the ENCRYPTION_KEY is a base64 encoded 256-bit key
// note(daniel): for some reason this resolves as true for some hex-encoded strings.
if (!isBase64(appCfg.ENCRYPTION_KEY)) {
throw new CryptographyError({
message:
@@ -398,15 +476,8 @@ const cryptographyFactory = () => {
let decipher;
if (keySize === SymmetricKeySize.Bits128) {
if (isFipsModeEnabled()) {
throw new CryptographyError({
message: "128-bit symmetric key is not supported in FIPS mode of operation."
});
}
// Not ideal: 128-bit hex key (32 chars) gets interpreted as 32 UTF-8 bytes (256 bits)
// This works but reduces effective key entropy from 256 to 128 bits
// Note: Never use this for FIPS mode of operation.
decipher = crypto.createDecipheriv(SecretEncryptionAlgo.AES_256_GCM, key, Buffer.from(iv, "base64"));
} else {
const secretKey = crypto.createSecretKey(key, "base64");
@@ -425,12 +496,6 @@ const cryptographyFactory = () => {
let cipher;
if (keySize === SymmetricKeySize.Bits128) {
if (isFipsModeEnabled()) {
throw new CryptographyError({
message: "128-bit symmetric key is not supported in FIPS mode of operation."
});
}
iv = crypto.randomBytes(BLOCK_SIZE_BYTES_16);
cipher = crypto.createCipheriv(SecretEncryptionAlgo.AES_256_GCM, key, iv);
} else {
@@ -452,6 +517,7 @@ const cryptographyFactory = () => {
const appCfg = getConfig();
const rootEncryptionKey = appCfg.ROOT_ENCRYPTION_KEY;
const encryptionKey = appCfg.ENCRYPTION_KEY;
if (rootEncryptionKey) {
const { iv, tag, ciphertext } = encryptSymmetric({
plaintext: data,
@@ -480,7 +546,9 @@ const cryptographyFactory = () => {
encoding: SecretKeyEncoding.UTF8
};
}
throw new Error("Missing both encryption keys");
throw new CryptographyError({
message: "Missing both encryption keys"
});
};
const decryptWithRootEncryptionKey = <T = string>({
@@ -509,7 +577,9 @@ const cryptographyFactory = () => {
const data = decryptSymmetric({ key: encryptionKey, iv, tag, ciphertext, keySize: SymmetricKeySize.Bits128 });
return data as T;
}
throw new Error("Missing both encryption keys");
throw new CryptographyError({
message: "Missing both encryption keys"
});
};
return {
@@ -540,12 +610,7 @@ const cryptographyFactory = () => {
if (isFipsModeEnabled()) {
const hasher = hasherFipsValidated();
// For the salt when using pkdf2, we do salt rounds * 100.000.
// The reason for this is because pbkdf2 is not as compute intense as bcrypt, making it faster to brute-force.
// From my testing, doing salt rounds * 100.000 brings the computational power required to roughly the same as bcrypt.
// OWASP recommends a minimum of 600.000 iterations for pbkdf2.
// Ref: https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#pbkdf2
const hash = await hasher.hash(password, saltRounds * 100_000);
const hash = await hasher.hash(password, saltRounds);
return hash;
}
const hash = await bcrypt.hash(password, saltRounds);
@@ -572,6 +637,7 @@ const cryptographyFactory = () => {
initialize,
isFipsModeEnabled,
hashing,
verifyFipsLicense,
encryption,
randomBytes: crypto.randomBytes,
randomInt: crypto.randomInt,
+2 -4
View File
@@ -74,10 +74,8 @@ const initTelemetryInstrumentation = ({
});
};
const setupTelemetry = () => {
const { envCfg } = initEnvConfig();
console.log("envCfg", envCfg);
const setupTelemetry = async () => {
const envCfg = await initEnvConfig();
if (envCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
console.log("Initializing telemetry instrumentation");