mirror of
https://github.com/Infisical/infisical.git
synced 2026-01-09 15:38:03 -05:00
463 lines
15 KiB
TypeScript
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 };
|