mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-16 15:31:19 +00:00
feat(fips): fips validated JWT's
This commit is contained in:
@@ -0,0 +1,462 @@
|
||||
// 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 };
|
||||
Reference in New Issue
Block a user