Files
infisical/backend/src/lib/crypto/cryptography/crypto.ts
2025-07-08 18:28:43 +04:00

463 lines
15 KiB
TypeScript

// NOTE: DO NOT USE crypto-js ANYWHERE EXCEPT THIS FILE.
// We use crypto-js purely to get around our native node crypto FIPS restrictions in FIPS mode.
import crypto, { subtle } from "node:crypto";
import bcrypt from "bcrypt";
import cryptoJs from "crypto-js";
import jwtDep from "jsonwebtoken";
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";
import { isBase64 } from "../../base64";
import { getConfig } from "../../config/env";
import { CryptographyError } from "../../errors";
import { logger } from "../../logger";
import { asymmetricFipsValidated } from "./asymmetric-fips";
import { hasherFipsValidated } from "./hash-fips";
import { jwtFipsValidated } from "./jwt-fips";
import {
DigestType,
JWTPayload,
JWTSecretOrKey,
JWTSignOptions,
JWTVerifyOptions,
SymmetricKeySize,
TDecryptAsymmetricInput,
TDecryptSymmetricInput,
TEncryptSymmetricInput
} from "./types";
const bytesToBits = (bytes: number) => bytes * 8;
const IV_BYTES_SIZE = 12;
const BLOCK_SIZE_BYTES_16 = 16;
const generateAsymmetricKeyPairNoFipsValidation = () => {
const pair = nacl.box.keyPair();
return {
publicKey: naclUtils.encodeBase64(pair.publicKey),
privateKey: naclUtils.encodeBase64(pair.secretKey)
};
};
export const encryptAsymmetricNoFipsValidation = (plaintext: string, publicKey: string, privateKey: string) => {
const nonce = nacl.randomBytes(24);
const ciphertext = nacl.box(
naclUtils.decodeUTF8(plaintext),
nonce,
naclUtils.decodeBase64(publicKey),
naclUtils.decodeBase64(privateKey)
);
return {
ciphertext: naclUtils.encodeBase64(ciphertext),
nonce: naclUtils.encodeBase64(nonce)
};
};
const decryptAsymmetricNoFipsValidation = ({ ciphertext, nonce, publicKey, privateKey }: TDecryptAsymmetricInput) => {
const plaintext: Uint8Array | null = nacl.box.open(
naclUtils.decodeBase64(ciphertext),
naclUtils.decodeBase64(nonce),
naclUtils.decodeBase64(publicKey),
naclUtils.decodeBase64(privateKey)
);
if (plaintext == null) throw Error("Invalid ciphertext or keys");
return naclUtils.encodeUTF8(plaintext);
};
export const generateAsymmetricKeyPair = () => {
const pair = nacl.box.keyPair();
return {
publicKey: naclUtils.encodeBase64(pair.publicKey),
privateKey: naclUtils.encodeBase64(pair.secretKey)
};
};
export const computeMd5 = (message: string, digest: DigestType = DigestType.Hex) => {
let encoder;
switch (digest) {
case DigestType.Hex:
encoder = cryptoJs.enc.Hex;
break;
case DigestType.Base64:
encoder = cryptoJs.enc.Base64;
break;
default:
throw new CryptographyError({
message: `Invalid digest type: ${digest as string}`
});
}
return cryptoJs.MD5(message).toString(encoder);
};
const cryptographyFactory = () => {
let $fipsEnabled = false;
let $isInitialized = false;
const $checkIsInitialized = () => {
if (!$isInitialized) {
throw new CryptographyError({
message: "Internal cryptography module is not initialized"
});
}
};
const isFipsModeEnabled = (options: { skipInitializationCheck?: boolean } = {}) => {
if (!options?.skipInitializationCheck) {
$checkIsInitialized();
}
return $fipsEnabled;
};
const verifyFipsLicense = (licenseService: Pick<TLicenseServiceFactory, "onPremFeatures">) => {
if (isFipsModeEnabled({ skipInitializationCheck: true }) && !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) {
const appCfg = getConfig();
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:
"FIPS mode is enabled, but the ENCRYPTION_KEY environment variable is not a base64 encoded 256-bit key.\nYou can generate a 256-bit key using the following command: `openssl rand -base64 32`"
});
}
if (bytesToBits(Buffer.from(appCfg.ENCRYPTION_KEY, "base64").length) !== 256) {
throw new CryptographyError({
message:
"FIPS mode is enabled, but the ENCRYPTION_KEY environment variable is not a 256-bit key.\nYou can generate a 256-bit key using the following command: `openssl rand -base64 32`"
});
}
} else {
throw new CryptographyError({
message:
"FIPS mode is enabled, but the ENCRYPTION_KEY environment variable is not set.\nYou can generate a 256-bit key using the following command: `openssl rand -base64 32`"
});
}
}
$fipsEnabled = enabled;
$isInitialized = true;
};
const initialize = async (superAdminDAL: TSuperAdminDALFactory) => {
if ($isInitialized) {
return isFipsModeEnabled();
}
if (process.env.FIPS_ENABLED !== "true") {
logger.info("[FIPS]: Instance is running in non-FIPS mode.");
$setFipsModeEnabled(false);
return false;
}
const serverCfg = await superAdminDAL.findById(ADMIN_CONFIG_DB_UUID).catch(() => null);
// if fips mode is enabled, we need to check if the deployment is a new deployment or an old one.
if (serverCfg) {
if (serverCfg.fipsEnabled) {
logger.info("[FIPS]: Instance is configured for FIPS mode of operation. Continuing startup with FIPS enabled.");
$setFipsModeEnabled(true);
return true;
}
logger.info("[FIPS]: Instance age predates FIPS mode inception date. Continuing without FIPS.");
$setFipsModeEnabled(false);
return false;
}
logger.info("[FIPS]: First time initializing cryptography module on a new deployment. FIPS mode is enabled.");
// TODO(daniel): check if it's an enterprise deployment
// if there is no server cfg, and FIPS_MODE is `true`, its a fresh FIPS deployment. We need to set the fipsEnabled to true.
$setFipsModeEnabled(true);
return true;
};
const encryption = () => {
$checkIsInitialized();
const asymmetric = () => {
const generateKeyPair = () => {
if (isFipsModeEnabled()) {
return asymmetricFipsValidated().generateKeyPair();
}
return generateAsymmetricKeyPairNoFipsValidation();
};
const encrypt = (data: string, publicKey: string, privateKey: string) => {
if (isFipsModeEnabled()) {
return asymmetricFipsValidated().encryptAsymmetric(data, publicKey, privateKey);
}
return encryptAsymmetricNoFipsValidation(data, publicKey, privateKey);
};
const decrypt = ({ ciphertext, nonce, publicKey, privateKey }: TDecryptAsymmetricInput) => {
if (isFipsModeEnabled()) {
return asymmetricFipsValidated().decryptAsymmetric({ ciphertext, nonce, publicKey, privateKey });
}
return decryptAsymmetricNoFipsValidation({ ciphertext, nonce, publicKey, privateKey });
};
return {
generateKeyPair,
encrypt,
decrypt
};
};
const decryptSymmetric = ({ ciphertext, iv, tag, key, keySize }: TDecryptSymmetricInput): string => {
let decipher;
if (keySize === SymmetricKeySize.Bits128) {
// 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
decipher = crypto.createDecipheriv(SecretEncryptionAlgo.AES_256_GCM, key, Buffer.from(iv, "base64"));
} else {
const secretKey = crypto.createSecretKey(key, "base64");
decipher = crypto.createDecipheriv(SecretEncryptionAlgo.AES_256_GCM, secretKey, Buffer.from(iv, "base64"));
}
decipher.setAuthTag(Buffer.from(tag, "base64"));
let cleartext = decipher.update(ciphertext, "base64", "utf8");
cleartext += decipher.final("utf8");
return cleartext;
};
const encryptSymmetric = ({ plaintext, key, keySize }: TEncryptSymmetricInput) => {
let iv;
let cipher;
if (keySize === SymmetricKeySize.Bits128) {
iv = crypto.randomBytes(BLOCK_SIZE_BYTES_16);
cipher = crypto.createCipheriv(SecretEncryptionAlgo.AES_256_GCM, key, iv);
} else {
iv = crypto.randomBytes(IV_BYTES_SIZE);
cipher = crypto.createCipheriv(SecretEncryptionAlgo.AES_256_GCM, crypto.createSecretKey(key, "base64"), iv);
}
let ciphertext = cipher.update(plaintext, "utf8", "base64");
ciphertext += cipher.final("base64");
return {
ciphertext,
iv: iv.toString("base64"),
tag: cipher.getAuthTag().toString("base64")
};
};
const encryptWithRootEncryptionKey = (data: string) => {
const appCfg = getConfig();
const rootEncryptionKey = appCfg.ROOT_ENCRYPTION_KEY;
const encryptionKey = appCfg.ENCRYPTION_KEY;
if (rootEncryptionKey) {
const { iv, tag, ciphertext } = encryptSymmetric({
plaintext: data,
key: rootEncryptionKey,
keySize: SymmetricKeySize.Bits256
});
return {
iv,
tag,
ciphertext,
algorithm: SecretEncryptionAlgo.AES_256_GCM,
encoding: SecretKeyEncoding.BASE64
};
}
if (encryptionKey) {
const { iv, tag, ciphertext } = encryptSymmetric({
plaintext: data,
key: encryptionKey,
keySize: SymmetricKeySize.Bits128
});
return {
iv,
tag,
ciphertext,
algorithm: SecretEncryptionAlgo.AES_256_GCM,
encoding: SecretKeyEncoding.UTF8
};
}
throw new CryptographyError({
message: "Missing both encryption keys"
});
};
const decryptWithRootEncryptionKey = <T = string>({
keyEncoding,
ciphertext,
tag,
iv
}: Omit<TDecryptSymmetricInput, "key" | "keySize"> & {
keyEncoding: SecretKeyEncoding;
}) => {
const appCfg = getConfig();
// the or gate is used used in migration
const rootEncryptionKey = appCfg?.ROOT_ENCRYPTION_KEY || process.env.ROOT_ENCRYPTION_KEY;
const encryptionKey = appCfg?.ENCRYPTION_KEY || process.env.ENCRYPTION_KEY;
if (rootEncryptionKey && keyEncoding === SecretKeyEncoding.BASE64) {
const data = decryptSymmetric({
key: rootEncryptionKey,
iv,
tag,
ciphertext,
keySize: SymmetricKeySize.Bits256
});
return data as T;
}
if (encryptionKey && keyEncoding === SecretKeyEncoding.UTF8) {
const data = decryptSymmetric({ key: encryptionKey, iv, tag, ciphertext, keySize: SymmetricKeySize.Bits128 });
return data as T;
}
throw new CryptographyError({
message: "Missing both encryption keys"
});
};
return {
asymmetric,
encryptWithRootEncryptionKey,
decryptWithRootEncryptionKey,
encryptSymmetric,
decryptSymmetric
};
};
const hashing = () => {
$checkIsInitialized();
// mark this function as deprecated
/**
* @deprecated Do not use MD5 unless you absolutely have to. It is considered an unsafe hashing algorithm, and should only be used if absolutely necessary.
*/
const md5 = (message: string, digest: DigestType = DigestType.Hex) => {
// If FIPS is enabled and we need MD5, we use the crypto-js implementation.
// Avoid this at all costs unless strictly necessary, like for mongo atlas digest auth.
if (isFipsModeEnabled()) {
return computeMd5(message, digest);
}
return crypto.createHash("md5").update(message).digest(digest);
};
const createHash = async (password: string, saltRounds: number) => {
if (isFipsModeEnabled()) {
const hasher = hasherFipsValidated();
const hash = await hasher.hash(password, saltRounds);
return hash;
}
const hash = await bcrypt.hash(password, saltRounds);
return hash;
};
const compareHash = async (password: string, hash: string) => {
if (isFipsModeEnabled()) {
const isValid = await hasherFipsValidated().compare(password, hash);
return isValid;
}
const isValid = await bcrypt.compare(password, hash);
return isValid;
};
return {
md5,
createHash,
compareHash
};
};
const jwt = () => {
$checkIsInitialized();
const sign = (payload: JWTPayload, secretOrKey: JWTSecretOrKey, options: JWTSignOptions = {}) => {
if (isFipsModeEnabled()) {
return jwtFipsValidated().sign(payload, secretOrKey, options);
}
return jwtDep.sign(payload, secretOrKey, options);
};
const verify = (token: string, secretOrKey: JWTSecretOrKey, options: JWTVerifyOptions = {}) => {
if (isFipsModeEnabled()) {
return jwtFipsValidated().verify(token, secretOrKey, options);
}
return jwtDep.verify(token, secretOrKey, options);
};
const decode = (token: string, options: { complete?: boolean } = {}) => {
if (isFipsModeEnabled()) {
return jwtFipsValidated().decode(token, options);
}
return jwtDep.decode(token, options);
};
return {
sign,
verify,
decode
};
};
return {
initialize,
isFipsModeEnabled,
verifyFipsLicense,
hashing,
encryption,
jwt,
randomBytes: crypto.randomBytes,
randomInt: crypto.randomInt,
rawCrypto: {
createHash: crypto.createHash,
createHmac: crypto.createHmac,
sign: crypto.sign,
verify: crypto.verify,
createSign: crypto.createSign,
createVerify: crypto.createVerify,
generateKeyPair: crypto.generateKeyPair,
createCipheriv: crypto.createCipheriv,
createDecipheriv: crypto.createDecipheriv,
createPublicKey: crypto.createPublicKey,
createPrivateKey: crypto.createPrivateKey,
getRandomValues: crypto.getRandomValues,
randomUUID: crypto.randomUUID,
subtle: {
// eslint-disable-next-line @typescript-eslint/unbound-method
generateKey: subtle.generateKey,
// eslint-disable-next-line @typescript-eslint/unbound-method
importKey: subtle.importKey,
// eslint-disable-next-line @typescript-eslint/unbound-method
exportKey: subtle.exportKey
},
constants: crypto.constants,
X509Certificate: crypto.X509Certificate,
KeyObject: crypto.KeyObject,
Hash: crypto.Hash
}
};
};
const factoryInstance = cryptographyFactory();
export { factoryInstance as crypto, DigestType };