mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-14 07:30:20 +00:00
feat(fips): fips validated JWT's
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
import crypto from "crypto";
|
||||
|
||||
import { SecretEncryptionAlgo } from "@app/db/schemas";
|
||||
import { CryptographyError } from "@app/lib/errors";
|
||||
|
||||
export const asymmetricFipsValidated = () => {
|
||||
const generateKeyPair = () => {
|
||||
const { publicKey, privateKey } = crypto.generateKeyPairSync("x25519");
|
||||
|
||||
return {
|
||||
publicKey: publicKey.export({ type: "spki", format: "der" }).toString("base64"),
|
||||
privateKey: privateKey.export({ type: "pkcs8", format: "der" }).toString("base64")
|
||||
};
|
||||
};
|
||||
|
||||
const encryptAsymmetric = (data: string, publicKey: string, privateKey: string) => {
|
||||
const pubKeyObj = crypto.createPublicKey({
|
||||
key: Buffer.from(publicKey, "base64"),
|
||||
type: "spki",
|
||||
format: "der"
|
||||
});
|
||||
|
||||
const privKeyObj = crypto.createPrivateKey({
|
||||
key: Buffer.from(privateKey, "base64"),
|
||||
type: "pkcs8",
|
||||
format: "der"
|
||||
});
|
||||
|
||||
// Generate shared secret using x25519 curve
|
||||
const sharedSecret = crypto.diffieHellman({
|
||||
privateKey: privKeyObj,
|
||||
publicKey: pubKeyObj
|
||||
});
|
||||
|
||||
const nonce = crypto.randomBytes(24);
|
||||
|
||||
// Derive 32-byte key from shared secret
|
||||
const key = crypto.createHash("sha256").update(sharedSecret).digest();
|
||||
|
||||
// Use first 12 bytes of nonce as IV for AES-GCM
|
||||
const iv = nonce.subarray(0, 12);
|
||||
|
||||
// Encrypt with AES-256-GCM
|
||||
const cipher = crypto.createCipheriv(SecretEncryptionAlgo.AES_256_GCM, key, iv);
|
||||
|
||||
const ciphertext = cipher.update(data, "utf8");
|
||||
cipher.final();
|
||||
|
||||
const authTag = cipher.getAuthTag();
|
||||
|
||||
// Combine ciphertext and auth tag
|
||||
const combined = Buffer.concat([ciphertext, authTag]);
|
||||
|
||||
return {
|
||||
ciphertext: combined.toString("base64"),
|
||||
nonce: nonce.toString("base64")
|
||||
};
|
||||
};
|
||||
|
||||
const decryptAsymmetric = ({
|
||||
ciphertext,
|
||||
nonce,
|
||||
publicKey,
|
||||
privateKey
|
||||
}: {
|
||||
ciphertext: string;
|
||||
nonce: string;
|
||||
publicKey: string;
|
||||
privateKey: string;
|
||||
}) => {
|
||||
// Convert base64 keys back to key objects
|
||||
const pubKeyObj = crypto.createPublicKey({
|
||||
key: Buffer.from(publicKey, "base64"),
|
||||
type: "spki",
|
||||
format: "der"
|
||||
});
|
||||
|
||||
const privKeyObj = crypto.createPrivateKey({
|
||||
key: Buffer.from(privateKey, "base64"),
|
||||
type: "pkcs8",
|
||||
format: "der"
|
||||
});
|
||||
|
||||
// Generate same shared secret
|
||||
const sharedSecret = crypto.diffieHellman({
|
||||
privateKey: privKeyObj,
|
||||
publicKey: pubKeyObj
|
||||
});
|
||||
|
||||
const nonceBuffer = Buffer.from(nonce, "base64");
|
||||
const combinedBuffer = Buffer.from(ciphertext, "base64");
|
||||
|
||||
// Split ciphertext and auth tag (last 16 bytes for GCM)
|
||||
const actualCiphertext = combinedBuffer.subarray(0, -16);
|
||||
const authTag = combinedBuffer.subarray(-16);
|
||||
|
||||
// Derive same 32-byte key
|
||||
const key = crypto.createHash("sha256").update(sharedSecret).digest();
|
||||
|
||||
// Use first 12 bytes of nonce as IV
|
||||
const iv = nonceBuffer.subarray(0, 12);
|
||||
|
||||
// Decrypt
|
||||
const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv);
|
||||
decipher.setAuthTag(authTag);
|
||||
|
||||
const plaintext = decipher.update(actualCiphertext);
|
||||
|
||||
try {
|
||||
const final = decipher.final();
|
||||
return Buffer.concat([plaintext, final]).toString("utf8");
|
||||
} catch (error) {
|
||||
throw new CryptographyError({
|
||||
message: "Invalid ciphertext or keys"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
generateKeyPair,
|
||||
encryptAsymmetric,
|
||||
decryptAsymmetric
|
||||
};
|
||||
};
|
||||
+54
-271
@@ -4,6 +4,7 @@ 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";
|
||||
|
||||
@@ -12,277 +13,30 @@ 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";
|
||||
|
||||
enum DigestType {
|
||||
Hex = "hex",
|
||||
Base64 = "base64"
|
||||
}
|
||||
|
||||
export enum SymmetricKeySize {
|
||||
Bits128 = "128-bits",
|
||||
Bits256 = "256-bits"
|
||||
}
|
||||
|
||||
type TDecryptSymmetricInput =
|
||||
| {
|
||||
ciphertext: string;
|
||||
iv: string;
|
||||
tag: string;
|
||||
key: string | Buffer; // can be hex encoded or buffer
|
||||
keySize: SymmetricKeySize.Bits128;
|
||||
}
|
||||
| {
|
||||
ciphertext: string;
|
||||
iv: string;
|
||||
tag: string;
|
||||
key: string; // must be base64 encoded
|
||||
keySize: SymmetricKeySize.Bits256;
|
||||
};
|
||||
|
||||
type TEncryptSymmetricInput =
|
||||
| {
|
||||
plaintext: string;
|
||||
key: string;
|
||||
keySize: SymmetricKeySize.Bits256;
|
||||
}
|
||||
| {
|
||||
plaintext: string;
|
||||
key: string | Buffer;
|
||||
keySize: SymmetricKeySize.Bits128;
|
||||
};
|
||||
|
||||
type TDecryptAsymmetricInput = {
|
||||
ciphertext: string;
|
||||
nonce: string;
|
||||
publicKey: string;
|
||||
privateKey: string;
|
||||
};
|
||||
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 hasherFipsValidated = () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const $validatePassword = async (
|
||||
inputPassword: Buffer,
|
||||
storedHash: Buffer,
|
||||
salt: Buffer,
|
||||
iterations: number,
|
||||
keyLength: number
|
||||
) => {
|
||||
const computedHash = await $hashPassword(inputPassword, salt, iterations, keyLength);
|
||||
|
||||
return crypto.timingSafeEqual(computedHash, storedHash);
|
||||
};
|
||||
|
||||
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) => {
|
||||
try {
|
||||
if (!hashedPassword?.startsWith("$v1$")) return false;
|
||||
|
||||
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 {
|
||||
hash,
|
||||
compare
|
||||
};
|
||||
};
|
||||
|
||||
const generateAsymmetricKeyPairFipsValidated = () => {
|
||||
const { publicKey, privateKey } = crypto.generateKeyPairSync("x25519");
|
||||
|
||||
return {
|
||||
publicKey: publicKey.export({ type: "spki", format: "der" }).toString("base64"),
|
||||
privateKey: privateKey.export({ type: "pkcs8", format: "der" }).toString("base64")
|
||||
};
|
||||
};
|
||||
|
||||
const encryptAsymmetricFipsValidated = (data: string, publicKey: string, privateKey: string) => {
|
||||
const pubKeyObj = crypto.createPublicKey({
|
||||
key: Buffer.from(publicKey, "base64"),
|
||||
type: "spki",
|
||||
format: "der"
|
||||
});
|
||||
|
||||
const privKeyObj = crypto.createPrivateKey({
|
||||
key: Buffer.from(privateKey, "base64"),
|
||||
type: "pkcs8",
|
||||
format: "der"
|
||||
});
|
||||
|
||||
// Generate shared secret using x25519 curve
|
||||
const sharedSecret = crypto.diffieHellman({
|
||||
privateKey: privKeyObj,
|
||||
publicKey: pubKeyObj
|
||||
});
|
||||
|
||||
const nonce = crypto.randomBytes(24);
|
||||
|
||||
// Derive 32-byte key from shared secret
|
||||
const key = crypto.createHash("sha256").update(sharedSecret).digest();
|
||||
|
||||
// Use first 12 bytes of nonce as IV for AES-GCM
|
||||
const iv = nonce.subarray(0, 12);
|
||||
|
||||
// Encrypt with AES-256-GCM
|
||||
const cipher = crypto.createCipheriv(SecretEncryptionAlgo.AES_256_GCM, key, iv);
|
||||
|
||||
const ciphertext = cipher.update(data, "utf8");
|
||||
cipher.final();
|
||||
|
||||
const authTag = cipher.getAuthTag();
|
||||
|
||||
// Combine ciphertext and auth tag
|
||||
const combined = Buffer.concat([ciphertext, authTag]);
|
||||
|
||||
return {
|
||||
ciphertext: combined.toString("base64"),
|
||||
nonce: nonce.toString("base64")
|
||||
};
|
||||
};
|
||||
|
||||
const decryptAsymmetricFipsValidated = ({
|
||||
ciphertext,
|
||||
nonce,
|
||||
publicKey,
|
||||
privateKey
|
||||
}: {
|
||||
ciphertext: string;
|
||||
nonce: string;
|
||||
publicKey: string;
|
||||
privateKey: string;
|
||||
}) => {
|
||||
// Convert base64 keys back to key objects
|
||||
const pubKeyObj = crypto.createPublicKey({
|
||||
key: Buffer.from(publicKey, "base64"),
|
||||
type: "spki",
|
||||
format: "der"
|
||||
});
|
||||
|
||||
const privKeyObj = crypto.createPrivateKey({
|
||||
key: Buffer.from(privateKey, "base64"),
|
||||
type: "pkcs8",
|
||||
format: "der"
|
||||
});
|
||||
|
||||
// Generate same shared secret
|
||||
const sharedSecret = crypto.diffieHellman({
|
||||
privateKey: privKeyObj,
|
||||
publicKey: pubKeyObj
|
||||
});
|
||||
|
||||
const nonceBuffer = Buffer.from(nonce, "base64");
|
||||
const combinedBuffer = Buffer.from(ciphertext, "base64");
|
||||
|
||||
// Split ciphertext and auth tag (last 16 bytes for GCM)
|
||||
const actualCiphertext = combinedBuffer.subarray(0, -16);
|
||||
const authTag = combinedBuffer.subarray(-16);
|
||||
|
||||
// Derive same 32-byte key
|
||||
const key = crypto.createHash("sha256").update(sharedSecret).digest();
|
||||
|
||||
// Use first 12 bytes of nonce as IV
|
||||
const iv = nonceBuffer.subarray(0, 12);
|
||||
|
||||
// Decrypt
|
||||
const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv);
|
||||
decipher.setAuthTag(authTag);
|
||||
|
||||
const plaintext = decipher.update(actualCiphertext);
|
||||
|
||||
try {
|
||||
const final = decipher.final();
|
||||
return Buffer.concat([plaintext, final]).toString("utf8");
|
||||
} catch (error) {
|
||||
throw new CryptographyError({
|
||||
message: "Invalid ciphertext or keys"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const generateAsymmetricKeyPairNoFipsValidation = () => {
|
||||
const pair = nacl.box.keyPair();
|
||||
|
||||
@@ -447,21 +201,21 @@ const cryptographyFactory = () => {
|
||||
const asymmetric = () => {
|
||||
const generateKeyPair = () => {
|
||||
if (isFipsModeEnabled()) {
|
||||
return generateAsymmetricKeyPairFipsValidated();
|
||||
return asymmetricFipsValidated().generateKeyPair();
|
||||
}
|
||||
return generateAsymmetricKeyPairNoFipsValidation();
|
||||
};
|
||||
|
||||
const encrypt = (data: string, publicKey: string, privateKey: string) => {
|
||||
if (isFipsModeEnabled()) {
|
||||
return encryptAsymmetricFipsValidated(data, publicKey, privateKey);
|
||||
return asymmetricFipsValidated().encryptAsymmetric(data, publicKey, privateKey);
|
||||
}
|
||||
return encryptAsymmetricNoFipsValidation(data, publicKey, privateKey);
|
||||
};
|
||||
|
||||
const decrypt = ({ ciphertext, nonce, publicKey, privateKey }: TDecryptAsymmetricInput) => {
|
||||
if (isFipsModeEnabled()) {
|
||||
return decryptAsymmetricFipsValidated({ ciphertext, nonce, publicKey, privateKey });
|
||||
return asymmetricFipsValidated().decryptAsymmetric({ ciphertext, nonce, publicKey, privateKey });
|
||||
}
|
||||
return decryptAsymmetricNoFipsValidation({ ciphertext, nonce, publicKey, privateKey });
|
||||
};
|
||||
@@ -633,13 +387,44 @@ const cryptographyFactory = () => {
|
||||
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,
|
||||
hashing,
|
||||
verifyFipsLicense,
|
||||
hashing,
|
||||
encryption,
|
||||
jwt,
|
||||
randomBytes: crypto.randomBytes,
|
||||
randomInt: crypto.randomInt,
|
||||
rawCrypto: {
|
||||
@@ -674,6 +459,4 @@ const cryptographyFactory = () => {
|
||||
|
||||
const factoryInstance = cryptographyFactory();
|
||||
|
||||
export type TCryptographyFactory = ReturnType<typeof cryptographyFactory>;
|
||||
|
||||
export { factoryInstance as crypto, DigestType };
|
||||
@@ -0,0 +1,107 @@
|
||||
import crypto from "crypto";
|
||||
|
||||
import { CryptographyError } from "@app/lib/errors";
|
||||
|
||||
export const hasherFipsValidated = () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const $validatePassword = async (
|
||||
inputPassword: Buffer,
|
||||
storedHash: Buffer,
|
||||
salt: Buffer,
|
||||
iterations: number,
|
||||
keyLength: number
|
||||
) => {
|
||||
const computedHash = await $hashPassword(inputPassword, salt, iterations, keyLength);
|
||||
|
||||
return crypto.timingSafeEqual(computedHash, storedHash);
|
||||
};
|
||||
|
||||
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) => {
|
||||
try {
|
||||
if (!hashedPassword?.startsWith("$v1$")) return false;
|
||||
|
||||
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 {
|
||||
hash,
|
||||
compare
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
export { crypto } from "./crypto";
|
||||
export {
|
||||
DigestType,
|
||||
SymmetricKeySize,
|
||||
TDecryptAsymmetricInput,
|
||||
TDecryptSymmetricInput,
|
||||
TEncryptedWithRootEncryptionKey,
|
||||
TEncryptSymmetricInput
|
||||
} from "./types";
|
||||
@@ -0,0 +1,289 @@
|
||||
import crypto from "crypto";
|
||||
import RE2 from "re2";
|
||||
|
||||
import { Algorithm, CompleteJWTPayload, JWTPayload, JWTSecretOrKey, JWTSignOptions, JWTVerifyOptions } from "./types";
|
||||
|
||||
export const jwtFipsValidated = () => {
|
||||
const $base64urlEncode = (str: string) => Buffer.from(str).toString("base64url");
|
||||
const $isRSAAlgorithm = (algorithm: string) => algorithm.startsWith("RS");
|
||||
|
||||
const $parseTimeToSeconds = (timeStr: string | number) => {
|
||||
if (typeof timeStr === "number") {
|
||||
return timeStr;
|
||||
}
|
||||
|
||||
const match = new RE2(/^(\d+)([smhd])$/).exec(timeStr);
|
||||
if (!match) {
|
||||
throw new Error(`Invalid time format: ${timeStr}`);
|
||||
}
|
||||
|
||||
const value = parseInt(match[1], 10);
|
||||
const unit = match[2];
|
||||
|
||||
switch (unit) {
|
||||
case "s":
|
||||
return value;
|
||||
case "m":
|
||||
return value * 60;
|
||||
case "h":
|
||||
return value * 60 * 60;
|
||||
case "d":
|
||||
return value * 60 * 60 * 24;
|
||||
default:
|
||||
throw new Error(`Unknown time unit: ${unit}`);
|
||||
}
|
||||
};
|
||||
|
||||
const $getHashAlgorithm = (algorithm: string) => {
|
||||
switch (algorithm) {
|
||||
case "HS256":
|
||||
case "RS256":
|
||||
return "sha256";
|
||||
case "HS384":
|
||||
case "RS384":
|
||||
return "sha384";
|
||||
case "HS512":
|
||||
case "RS512":
|
||||
return "sha512";
|
||||
default:
|
||||
throw new Error(`Unsupported algorithm: ${algorithm}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Sign a JWT token
|
||||
*/
|
||||
const sign = (payload: JWTPayload, secretOrPrivateKey: JWTSecretOrKey, options: JWTSignOptions = {}) => {
|
||||
const algorithm = options.algorithm || "HS256";
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
// Create header
|
||||
const header = {
|
||||
alg: algorithm,
|
||||
typ: "JWT",
|
||||
...(options.keyid && { kid: options.keyid })
|
||||
};
|
||||
|
||||
// Create payload with timestamps
|
||||
const finalPayload = {
|
||||
...payload,
|
||||
iat: now,
|
||||
...(options.expiresIn !== undefined && { exp: now + $parseTimeToSeconds(options.expiresIn) })
|
||||
};
|
||||
|
||||
// Encode header and payload
|
||||
const encodedHeader = $base64urlEncode(JSON.stringify(header));
|
||||
const encodedPayload = $base64urlEncode(JSON.stringify(finalPayload));
|
||||
|
||||
// Create signature
|
||||
const data = `${encodedHeader}.${encodedPayload}`;
|
||||
const hashAlgorithm = $getHashAlgorithm(algorithm);
|
||||
let signature: string;
|
||||
|
||||
if ($isRSAAlgorithm(algorithm)) {
|
||||
let privateKey: crypto.KeyLike | { key: string | Buffer };
|
||||
|
||||
if (typeof secretOrPrivateKey === "string") {
|
||||
try {
|
||||
// Try to create a proper private key object
|
||||
privateKey = crypto.createPrivateKey(secretOrPrivateKey);
|
||||
} catch (error) {
|
||||
throw new Error("Invalid JWT private key");
|
||||
}
|
||||
} else {
|
||||
privateKey = secretOrPrivateKey;
|
||||
}
|
||||
|
||||
const signatureBuffer = crypto.sign(hashAlgorithm, Buffer.from(data), privateKey);
|
||||
signature = signatureBuffer.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
||||
} else {
|
||||
// HMAC signing
|
||||
if (typeof secretOrPrivateKey !== "string") {
|
||||
throw new Error("HMAC algorithms require a string secret");
|
||||
}
|
||||
signature = crypto
|
||||
.createHmac(hashAlgorithm, secretOrPrivateKey)
|
||||
.update(data)
|
||||
.digest("base64")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=/g, "");
|
||||
}
|
||||
|
||||
return `${data}.${signature}`;
|
||||
};
|
||||
|
||||
const verify = (token: string, secretOrKey: JWTSecretOrKey, options: JWTVerifyOptions = {}) => {
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) {
|
||||
throw new Error("Invalid JWT format");
|
||||
}
|
||||
|
||||
const [encodedHeader, encodedPayload, signature] = parts;
|
||||
|
||||
// Decode header
|
||||
const headerJson = Buffer.from(encodedHeader, "base64").toString();
|
||||
const header = JSON.parse(headerJson) as { alg: string; typ: string; kid?: string };
|
||||
|
||||
if (!header.alg || header.typ !== "JWT") {
|
||||
throw new Error("Invalid JWT header");
|
||||
}
|
||||
|
||||
// Extract the actual key from different input types
|
||||
let keyString: string;
|
||||
if (Buffer.isBuffer(secretOrKey)) {
|
||||
keyString = secretOrKey.toString();
|
||||
} else if (typeof secretOrKey === "object" && "key" in secretOrKey) {
|
||||
keyString = Buffer.isBuffer(secretOrKey.key) ? secretOrKey.key.toString() : secretOrKey.key;
|
||||
} else {
|
||||
keyString = secretOrKey as string;
|
||||
}
|
||||
|
||||
// Verify signature
|
||||
const data = `${encodedHeader}.${encodedPayload}`;
|
||||
const hashAlgorithm = $getHashAlgorithm(header.alg);
|
||||
let isValidSignature: boolean;
|
||||
|
||||
if ($isRSAAlgorithm(header.alg)) {
|
||||
// For RSA, handle both private and public keys
|
||||
let verificationKey: crypto.KeyLike;
|
||||
|
||||
// Clean up the key format
|
||||
const cleanKey = keyString.replace(/\\n/g, "\n");
|
||||
|
||||
try {
|
||||
// If it's a private key, extract the public key for verification
|
||||
if (cleanKey.includes("PRIVATE KEY")) {
|
||||
const privateKeyObj = crypto.createPrivateKey(cleanKey);
|
||||
verificationKey = crypto.createPublicKey(privateKeyObj);
|
||||
} else {
|
||||
// It's already a public key
|
||||
verificationKey = crypto.createPublicKey(cleanKey);
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error("Invalid JWT signature");
|
||||
}
|
||||
|
||||
// Convert base64url signature back to buffer
|
||||
const signatureBuffer = Buffer.from(
|
||||
signature.replace(/-/g, "+").replace(/_/g, "/") + "==".slice(0, (4 - (signature.length % 4)) % 4),
|
||||
"base64"
|
||||
);
|
||||
isValidSignature = crypto.verify(hashAlgorithm, Buffer.from(data), verificationKey, signatureBuffer);
|
||||
} else {
|
||||
// HMAC verification
|
||||
const expectedSignature = crypto
|
||||
.createHmac(hashAlgorithm, keyString)
|
||||
.update(data)
|
||||
.digest("base64")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=/g, "");
|
||||
isValidSignature = signature === expectedSignature;
|
||||
}
|
||||
|
||||
if (!isValidSignature) {
|
||||
throw new Error("Invalid JWT signature");
|
||||
}
|
||||
|
||||
// Decode payload
|
||||
const payloadJson = Buffer.from(encodedPayload, "base64").toString();
|
||||
const payload = JSON.parse(payloadJson) as JWTPayload & {
|
||||
aud?: string | string[];
|
||||
iss?: string;
|
||||
sub?: string;
|
||||
nbf?: number;
|
||||
jti?: string;
|
||||
};
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const clockTolerance = options.clockTolerance || 0;
|
||||
|
||||
// Check expiration
|
||||
if (!options.ignoreExpiration && payload.exp && now - clockTolerance > payload.exp) {
|
||||
throw new Error("JWT token has expired");
|
||||
}
|
||||
|
||||
// Check not before
|
||||
if (!options.ignoreNotBefore && payload.nbf && now + clockTolerance < payload.nbf) {
|
||||
throw new Error("JWT not active");
|
||||
}
|
||||
|
||||
// Check audience
|
||||
if (options.audience) {
|
||||
const audiences = Array.isArray(options.audience) ? options.audience : [options.audience];
|
||||
const tokenAudiences = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
|
||||
const hasValidAudience = audiences.some((aud) => tokenAudiences.includes(aud));
|
||||
if (!hasValidAudience) {
|
||||
throw new Error("JWT audience invalid");
|
||||
}
|
||||
}
|
||||
|
||||
// Check issuer
|
||||
if (options.issuer) {
|
||||
const issuers = Array.isArray(options.issuer) ? options.issuer : [options.issuer];
|
||||
if (!payload.iss || !issuers.includes(payload.iss)) {
|
||||
throw new Error("JWT issuer invalid");
|
||||
}
|
||||
}
|
||||
|
||||
// Check subject
|
||||
if (options.subject && payload.sub !== options.subject) {
|
||||
throw new Error("JWT subject invalid");
|
||||
}
|
||||
|
||||
// Check JWT ID
|
||||
if (options.jwtid && payload.jti !== options.jwtid) {
|
||||
throw new Error("JWT ID invalid");
|
||||
}
|
||||
|
||||
// Check max age
|
||||
if (options.maxAge && payload.iat) {
|
||||
const maxAgeSeconds = typeof options.maxAge === "string" ? $parseTimeToSeconds(options.maxAge) : options.maxAge;
|
||||
if (now - payload.iat > maxAgeSeconds) {
|
||||
throw new Error("JWT max age exceeded");
|
||||
}
|
||||
}
|
||||
|
||||
// Check algorithms
|
||||
if (options.algorithms && !options.algorithms.includes(header.alg as Algorithm)) {
|
||||
throw new Error(`Algorithm not allowed: ${header.alg}`);
|
||||
}
|
||||
|
||||
return payload;
|
||||
};
|
||||
|
||||
const decode = (token: string, options: { complete?: boolean } = {}): JWTPayload | CompleteJWTPayload => {
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) {
|
||||
throw new Error("Invalid JWT format");
|
||||
}
|
||||
|
||||
const [encodedHeader, encodedPayload] = parts;
|
||||
|
||||
// Decode header
|
||||
const headerJson = Buffer.from(encodedHeader, "base64").toString();
|
||||
const header = JSON.parse(headerJson) as Record<string, unknown>;
|
||||
|
||||
// Decode payload
|
||||
const payloadJson = Buffer.from(encodedPayload, "base64").toString();
|
||||
const payload = JSON.parse(payloadJson) as Record<string, unknown>;
|
||||
|
||||
// Return complete token info or just payload
|
||||
if (options.complete) {
|
||||
return {
|
||||
header,
|
||||
payload,
|
||||
signature: parts[2]
|
||||
};
|
||||
}
|
||||
|
||||
return payload as JWTPayload;
|
||||
};
|
||||
|
||||
return {
|
||||
sign,
|
||||
verify,
|
||||
decode
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,92 @@
|
||||
import { KeyObject } from "crypto";
|
||||
|
||||
import { SecretEncryptionAlgo, SecretKeyEncoding } from "@app/db/schemas";
|
||||
|
||||
export enum DigestType {
|
||||
Hex = "hex",
|
||||
Base64 = "base64"
|
||||
}
|
||||
|
||||
export enum SymmetricKeySize {
|
||||
Bits128 = "128-bits",
|
||||
Bits256 = "256-bits"
|
||||
}
|
||||
|
||||
export type TDecryptSymmetricInput =
|
||||
| {
|
||||
ciphertext: string;
|
||||
iv: string;
|
||||
tag: string;
|
||||
key: string | Buffer; // can be hex encoded or buffer
|
||||
keySize: SymmetricKeySize.Bits128;
|
||||
}
|
||||
| {
|
||||
ciphertext: string;
|
||||
iv: string;
|
||||
tag: string;
|
||||
key: string; // must be base64 encoded
|
||||
keySize: SymmetricKeySize.Bits256;
|
||||
};
|
||||
|
||||
export type TEncryptSymmetricInput =
|
||||
| {
|
||||
plaintext: string;
|
||||
key: string;
|
||||
keySize: SymmetricKeySize.Bits256;
|
||||
}
|
||||
| {
|
||||
plaintext: string;
|
||||
key: string | Buffer;
|
||||
keySize: SymmetricKeySize.Bits128;
|
||||
};
|
||||
|
||||
export type TDecryptAsymmetricInput = {
|
||||
ciphertext: string;
|
||||
nonce: string;
|
||||
publicKey: string;
|
||||
privateKey: string;
|
||||
};
|
||||
|
||||
export type TEncryptedWithRootEncryptionKey = {
|
||||
iv: string;
|
||||
tag: string;
|
||||
ciphertext: string;
|
||||
algorithm: SecretEncryptionAlgo;
|
||||
encoding: SecretKeyEncoding;
|
||||
};
|
||||
|
||||
export interface JWTPayload {
|
||||
iat?: number;
|
||||
exp?: number;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface CompleteJWTPayload {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
header: any;
|
||||
payload: JWTPayload;
|
||||
signature: string;
|
||||
}
|
||||
|
||||
export type Algorithm = "HS256" | "HS384" | "HS512" | "RS256" | "RS384" | "RS512";
|
||||
|
||||
export interface JWTSignOptions {
|
||||
algorithm?: Algorithm | undefined;
|
||||
keyid?: string | undefined;
|
||||
expiresIn?: string | number;
|
||||
}
|
||||
|
||||
export type JWTSecretOrKey = string | Buffer | KeyObject | { key: string | Buffer; passphrase: string };
|
||||
|
||||
export interface JWTVerifyOptions {
|
||||
algorithms?: Algorithm[] | undefined;
|
||||
audience?: string | string[];
|
||||
issuer?: string | string[];
|
||||
subject?: string;
|
||||
ignoreExpiration?: boolean;
|
||||
ignoreNotBefore?: boolean;
|
||||
clockTolerance?: number;
|
||||
maxAge?: string | number;
|
||||
jwtid?: string;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
export { crypto, SymmetricKeySize } from "./cryptography";
|
||||
export { crypto, SymmetricKeySize, TEncryptedWithRootEncryptionKey } from "./cryptography";
|
||||
export { buildSecretBlindIndexFromName } from "./encryption";
|
||||
export {
|
||||
decryptIntegrationAuths,
|
||||
|
||||
Reference in New Issue
Block a user