mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-15 20:25:43 +00:00
Merge pull request #885 from ragnarbull/ragnarbull-auth-pwd-fixes
Password fixes - enforce max length, add checks (pwd breach, PII, low entropy), improved UX, deprecate common-passwords api
This commit is contained in:
@@ -1,69 +0,0 @@
|
||||
/* eslint-disable no-param-reassign */
|
||||
interface PasswordCheckProps {
|
||||
password: string;
|
||||
errorCheck: boolean;
|
||||
setPasswordErrorLength: (value: boolean) => void;
|
||||
setPasswordErrorNumber: (value: boolean) => void;
|
||||
setPasswordErrorLowerCase: (value: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* This function checks a user password with respect to some criteria.
|
||||
*/
|
||||
const passwordCheck = ({
|
||||
password,
|
||||
setPasswordErrorLength,
|
||||
setPasswordErrorNumber,
|
||||
setPasswordErrorLowerCase,
|
||||
errorCheck
|
||||
}: PasswordCheckProps) => {
|
||||
|
||||
if (!password || password.length < 14) {
|
||||
setPasswordErrorLength(true);
|
||||
errorCheck = true;
|
||||
} else {
|
||||
setPasswordErrorLength(false);
|
||||
}
|
||||
|
||||
if (!/\d/.test(password)) {
|
||||
setPasswordErrorNumber(true);
|
||||
errorCheck = true;
|
||||
} else {
|
||||
setPasswordErrorNumber(false);
|
||||
}
|
||||
|
||||
if (!/[a-z]/.test(password)) {
|
||||
setPasswordErrorLowerCase(true);
|
||||
errorCheck = true;
|
||||
// } else if (/(.)(?:(?!\1).){1,2}/.test(password)) {
|
||||
// console.log(111)
|
||||
// setPasswordError(true);
|
||||
// setPasswordErrorMessage("Password should not contain repeating characters.");
|
||||
// errorCheck = true;
|
||||
// } else if (RegExp(`[${email}]`).test(password)) {
|
||||
// console.log(222)
|
||||
// setPasswordError(true);
|
||||
// setPasswordErrorMessage("Password should not contain your email.");
|
||||
// errorCheck = true;
|
||||
} else {
|
||||
setPasswordErrorLowerCase(false);
|
||||
}
|
||||
|
||||
// if (!/[A-Z]/.test(password)) {
|
||||
// setPasswordErrorUpperCase(true);
|
||||
// errorCheck = true;
|
||||
// } else {
|
||||
// setPasswordErrorUpperCase(false);
|
||||
// }
|
||||
|
||||
// if (!/(?=.*[!@#$%^&*])/.test(password)) {
|
||||
// setPasswordErrorSpecialChar(true);
|
||||
// // "Please add at least 1 special character (*, !, #, %)."
|
||||
// errorCheck = true;
|
||||
// } else {
|
||||
// setPasswordErrorSpecialChar(false);
|
||||
// }
|
||||
return errorCheck;
|
||||
};
|
||||
|
||||
export default passwordCheck;
|
||||
@@ -1,72 +0,0 @@
|
||||
type Errors = {
|
||||
length?: string,
|
||||
upperCase?: string,
|
||||
lowerCase?: string,
|
||||
number?: string,
|
||||
specialChar?: string,
|
||||
repeatedChar?: string,
|
||||
commonPassword?: string
|
||||
};
|
||||
|
||||
interface CheckPasswordParams {
|
||||
password: string;
|
||||
commonPasswords: string[];
|
||||
setErrors: (value: Errors) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that the password [password] is at least:
|
||||
* - 8 characters long
|
||||
* - Contains 1 uppercase character (A-Z)
|
||||
* - Contains 1 lowercase character (a-z)
|
||||
* - Contains 1 number (0-9)
|
||||
* - Does not contain 3 repeat, consecutive characters
|
||||
*
|
||||
* The function returns whether or not the password [password]
|
||||
* passes the minimum requirements above. It sets errors on
|
||||
* an erorr object via [setErrors].
|
||||
*
|
||||
* @param {Object} obj
|
||||
* @param {String} obj.password - the password to check
|
||||
* @param {Function} obj.setErrors - set state function to set error object
|
||||
*/
|
||||
const checkPassword = ({
|
||||
password,
|
||||
commonPasswords,
|
||||
setErrors
|
||||
}: CheckPasswordParams): boolean => {
|
||||
const errors: Errors = {};
|
||||
|
||||
if (password.length < 8) {
|
||||
errors.length = "8 characters";
|
||||
}
|
||||
|
||||
if (!/[A-Z]/.test(password)) {
|
||||
errors.upperCase = "1 uppercase character (A-Z)";
|
||||
}
|
||||
|
||||
if (!/[a-z]/.test(password)) {
|
||||
errors.lowerCase = "1 lowercase character (a-z)";
|
||||
}
|
||||
|
||||
if (!/[0-9]/.test(password)) {
|
||||
errors.number = "1 number (0-9)";
|
||||
}
|
||||
|
||||
if (!/[!@#$%^&*(),.?":{}|<>]/.test(password)) {
|
||||
errors.specialChar = "1 special character (!@#$%^&*(),.?)";
|
||||
}
|
||||
|
||||
if (/([A-Za-z0-9])\1\1\1/.test(password)) {
|
||||
errors.repeatedChar = "No 3 repeat, consecutive characters";
|
||||
}
|
||||
|
||||
if (commonPasswords.includes(password)) {
|
||||
errors.commonPassword = "No common passwords";
|
||||
}
|
||||
|
||||
setErrors(errors);
|
||||
return Object.keys(errors).length > 0;
|
||||
}
|
||||
|
||||
export default checkPassword;
|
||||
@@ -0,0 +1,89 @@
|
||||
import { checkIsPasswordBreached } from "./checkIsPasswordBreached";
|
||||
import { escapeCharRegex, letterCharRegex, lowEntropyRegexes,numAndSpecialCharRegex, repeatedCharRegex } from "./passwordRegexes";
|
||||
|
||||
interface PasswordCheckProps {
|
||||
password: string;
|
||||
setPasswordErrorTooShort: (value: boolean) => void;
|
||||
setPasswordErrorTooLong: (value: boolean) => void;
|
||||
setPasswordErrorNoLetterChar: (value: boolean) => void;
|
||||
setPasswordErrorNoNumOrSpecialChar: (value: boolean) => void;
|
||||
setPasswordErrorRepeatedChar: (value: boolean) => void;
|
||||
setPasswordErrorEscapeChar: (value: boolean) => void;
|
||||
setPasswordErrorLowEntropy: (value: boolean) => void;
|
||||
setPasswordErrorBreached: (value: boolean) => void;
|
||||
}
|
||||
|
||||
const passwordCheck = async ({
|
||||
password,
|
||||
setPasswordErrorTooShort,
|
||||
setPasswordErrorTooLong,
|
||||
setPasswordErrorNoLetterChar,
|
||||
setPasswordErrorNoNumOrSpecialChar,
|
||||
setPasswordErrorRepeatedChar,
|
||||
setPasswordErrorEscapeChar,
|
||||
setPasswordErrorLowEntropy,
|
||||
setPasswordErrorBreached
|
||||
}: PasswordCheckProps) => {
|
||||
let errorCheck = false;
|
||||
const tests = [
|
||||
{
|
||||
name: "tooShort",
|
||||
validator: (pwd: string) => pwd.length >= 14,
|
||||
setError: setPasswordErrorTooShort,
|
||||
},
|
||||
{
|
||||
name: "tooLong",
|
||||
validator: (pwd: string) => pwd.length < 101,
|
||||
setError: setPasswordErrorTooLong,
|
||||
},
|
||||
{
|
||||
name: "noLetterChar",
|
||||
validator: (pwd: string) => letterCharRegex.test(pwd),
|
||||
setError: setPasswordErrorNoLetterChar,
|
||||
},
|
||||
{
|
||||
name: "noNumOrSpecialChar",
|
||||
validator: (pwd: string) => numAndSpecialCharRegex.test(pwd),
|
||||
setError: setPasswordErrorNoNumOrSpecialChar,
|
||||
},
|
||||
{
|
||||
name: "repeatedChar",
|
||||
validator: (pwd: string) => !repeatedCharRegex.test(pwd),
|
||||
setError: setPasswordErrorRepeatedChar,
|
||||
},
|
||||
{
|
||||
name: "escapeChar",
|
||||
validator: (pwd: string) => !escapeCharRegex.test(pwd),
|
||||
setError: setPasswordErrorEscapeChar,
|
||||
},
|
||||
{
|
||||
name: "lowEntropy",
|
||||
validator: (pwd: string) => (
|
||||
!lowEntropyRegexes.some(regex => regex.test(pwd))
|
||||
),
|
||||
setError: setPasswordErrorLowEntropy,
|
||||
},
|
||||
];
|
||||
|
||||
const isBreached = await checkIsPasswordBreached(password);
|
||||
|
||||
if (isBreached) {
|
||||
errorCheck = true;
|
||||
setPasswordErrorBreached(true);
|
||||
} else {
|
||||
setPasswordErrorBreached(false);
|
||||
}
|
||||
|
||||
tests.forEach((test) => {
|
||||
if (!test.validator(password)) {
|
||||
errorCheck = true;
|
||||
test.setError(true);
|
||||
} else {
|
||||
test.setError(false);
|
||||
}
|
||||
})
|
||||
|
||||
return errorCheck;
|
||||
};
|
||||
|
||||
export default passwordCheck;
|
||||
@@ -0,0 +1,113 @@
|
||||
import axios from "axios";
|
||||
|
||||
// SHA-1 hash the password using the SubtleCrypto API
|
||||
async function hashPassword(passwordBytes: ArrayBuffer): Promise<ArrayBuffer> {
|
||||
const buffer = await window.crypto.subtle.digest("SHA-1", passwordBytes);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
// Convert the hashed password buffer to a hexadecimal string
|
||||
function bufferToHex(buffer: ArrayBuffer): string {
|
||||
const byteArray = new Uint8Array(buffer);
|
||||
const hexParts: string[] = [];
|
||||
byteArray.forEach((byte) => {
|
||||
const hex = byte.toString(16).padStart(2, "0");
|
||||
hexParts.push(hex);
|
||||
});
|
||||
return hexParts.join("");
|
||||
}
|
||||
|
||||
// see API details here: https://haveibeenpwned.com/API/v3#SearchingPwnedPasswordsByRange
|
||||
// in short, the pending password is hashed (SHA-1), the first 5 chars are sliced and compared against a ranged hash table
|
||||
// this hash table is formed from the 5 char hash prefix (ie. 00000-FFFFF) so 16^5 results
|
||||
// returns a hash table of 800-1000 results
|
||||
// padding has been added to prevent MitM attacker determining which hash table was called by the response size
|
||||
// the last 35 chars of the password hash are compared client-side against the table
|
||||
// if there is a match, that password has been involved in a password breach (ie. pwnd) and should NOT be accepted
|
||||
// the database consists of ~700 mln breached passwords and is continuously updated, including with law enforcement ingestion
|
||||
// https://www.troyhunt.com/open-source-pwned-passwords-with-fbi-feed-and-225m-new-nca-passwords-is-now-live/
|
||||
|
||||
// The HIBP API follows NIST guidance (pg.14) https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-63b.pdf
|
||||
// "When processing requests to establish and change memorized secrets, verifiers SHALL compare
|
||||
// the prospective secrets against a list that contains values known to be commonly-used, expected,
|
||||
// or compromised. For example, the list MAY include, but is not limited to:
|
||||
// • Passwords obtained from previous breach corpuses.
|
||||
// • Dictionary words.
|
||||
// • Repetitive or sequential characters (e.g. ‘aaaaaa’, ‘1234abcd’).
|
||||
// • Context-specific words, such as the name of the service, the username, and derivatives
|
||||
// thereof."
|
||||
|
||||
export const checkIsPasswordBreached = async (password: string): Promise<boolean> => {
|
||||
const HAVE_I_BEEN_PWNED_API_URL = "https://api.pwnedpasswords.com";
|
||||
const maxRetryAttempts = 3;
|
||||
|
||||
let encodedPwd: Uint8Array | undefined;
|
||||
let hashedPwdBuffer: ArrayBuffer | undefined;
|
||||
|
||||
try {
|
||||
// Convert the password to a Uint8Array (UTF-8 encoded bytes)
|
||||
const textEncoder = new TextEncoder();
|
||||
encodedPwd = textEncoder.encode(password);
|
||||
|
||||
// Hash the password and convert it to a useful format for the HIBP API
|
||||
hashedPwdBuffer = await hashPassword(encodedPwd!.buffer);
|
||||
const hashedPwd = bufferToHex(hashedPwdBuffer).toUpperCase();
|
||||
// ONLY send the first 5 hash chars (over HTTPS)
|
||||
const hashedPwdToSend = hashedPwd.slice(0, 5);
|
||||
const safeHashedPwdToSend = encodeURIComponent(hashedPwdToSend); // Ensure URL safety
|
||||
const rangedHashTableUri = `${HAVE_I_BEEN_PWNED_API_URL}/range/${safeHashedPwdToSend}`;
|
||||
|
||||
let response;
|
||||
let retryAttempt = 0;
|
||||
|
||||
/* eslint-disable no-await-in-loop */
|
||||
while (retryAttempt < maxRetryAttempts) {
|
||||
try {
|
||||
response = await axios.get(rangedHashTableUri, {
|
||||
headers: {
|
||||
"Add-Padding": "true", // see https://www.troyhunt.com/enhancing-pwned-passwords-privacy-with-padding/
|
||||
"Content-Type": "text/plain",
|
||||
},
|
||||
});
|
||||
|
||||
if (response.status === 200) {
|
||||
// now we get back one of 16^5 hash prefix tables with random padding
|
||||
const responseData = response.data.toUpperCase();
|
||||
// check the last 35 hash chars to see if there's a match
|
||||
const isBreachedPassword: boolean = responseData.includes(hashedPwd.slice(5, 40));
|
||||
return isBreachedPassword;
|
||||
}
|
||||
retryAttempt += 1;
|
||||
|
||||
} catch (err) {
|
||||
if (!axios.isAxiosError(err)) {
|
||||
throw err;
|
||||
}
|
||||
retryAttempt += 1;
|
||||
}
|
||||
}
|
||||
|
||||
console.error(
|
||||
`Received a non-200 response (${response ? response.status : "unknown"}) from the Pwnd Passwords API`
|
||||
);
|
||||
return false;
|
||||
} catch (err: any) {
|
||||
console.error("An unexpected error has occurred:", err.message);
|
||||
return false;
|
||||
} finally {
|
||||
|
||||
// Clear the UTF-8 encoded password from memory
|
||||
|
||||
if (encodedPwd) {
|
||||
const zeroEncodedPwdBuffer = new Uint8Array(encodedPwd.length);
|
||||
encodedPwd.set(zeroEncodedPwdBuffer);
|
||||
}
|
||||
|
||||
// Clear the hashed password buffer from memory
|
||||
|
||||
if (hashedPwdBuffer) {
|
||||
const zeroHashedPwdBuffer = new Uint8Array(hashedPwdBuffer);
|
||||
zeroHashedPwdBuffer.fill(0);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
import { checkIsPasswordBreached } from "./checkIsPasswordBreached";
|
||||
import { escapeCharRegex, letterCharRegex, lowEntropyRegexes,numAndSpecialCharRegex, repeatedCharRegex } from "./passwordRegexes";
|
||||
|
||||
type Errors = {
|
||||
tooShort?: string;
|
||||
tooLong?: string;
|
||||
noLetterChar?: string;
|
||||
noNumOrSpecialChar?: string;
|
||||
repeatedChar?: string;
|
||||
escapeChar?: string;
|
||||
lowEntropy?: string;
|
||||
breached?: string;
|
||||
};
|
||||
|
||||
interface CheckPasswordParams {
|
||||
password: string;
|
||||
setErrors: (value: Errors) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that the password [password]:
|
||||
* - Contains at least 14 characters
|
||||
* - Contains at most 100 characters
|
||||
* - Contains at least 1 letter character (many languages supported) (case insensitive)
|
||||
* - Contains at least 1 number (0-9) or special character (emojis included)
|
||||
* - Does not contain 3 repeat, consecutive characters
|
||||
* - Does not contain any escape characters/sequences
|
||||
* - Does not contain PII and/or low entropy data (eg. email address, URL, phone number, DoB, SSN, driver's license, passport)
|
||||
* - Is not in a database of breached passwords
|
||||
*
|
||||
* The function returns whether or not the password [password]
|
||||
* passes the minimum requirements above. It sets errors on
|
||||
* an erorr object via [setErrors].
|
||||
*
|
||||
* @param {Object} obj
|
||||
* @param {String} obj.password - the password to check
|
||||
* @param {Function} obj.setErrors - set state function to set error object
|
||||
*/
|
||||
|
||||
const checkPassword = async ({ password, setErrors }: CheckPasswordParams): Promise<boolean> => {
|
||||
const errors: Errors = {};
|
||||
|
||||
const tests = [
|
||||
{
|
||||
name: "tooShort",
|
||||
validator: (pwd: string) => pwd.length >= 14,
|
||||
errorText: "at least 14 characters",
|
||||
},
|
||||
{
|
||||
name: "tooLong",
|
||||
validator: (pwd: string) => pwd.length < 101,
|
||||
errorText: "at most 100 characters",
|
||||
},
|
||||
{
|
||||
name: "noLetterChar",
|
||||
validator: (pwd: string) => letterCharRegex.test(pwd),
|
||||
errorText: "at least 1 letter character",
|
||||
},
|
||||
{
|
||||
name: "noNumOrSpecialChar",
|
||||
validator: (pwd: string) => numAndSpecialCharRegex.test(pwd),
|
||||
errorText: "at least 1 number or special character",
|
||||
},
|
||||
{
|
||||
name: "repeatedChar",
|
||||
validator: (pwd: string) => !repeatedCharRegex.test(pwd),
|
||||
errorText: "at most 3 repeated, consecutive characters",
|
||||
},
|
||||
{
|
||||
name: "escapeChar",
|
||||
validator: (pwd: string) => !escapeCharRegex.test(pwd),
|
||||
errorText: "No escape characters allowed.",
|
||||
},
|
||||
{
|
||||
name: "lowEntropy",
|
||||
validator: (pwd: string) => (
|
||||
!lowEntropyRegexes.some(regex => regex.test(pwd))
|
||||
),
|
||||
errorText: "Password contains sensitive data.",
|
||||
},
|
||||
];
|
||||
|
||||
const isBreached = await checkIsPasswordBreached(password);
|
||||
|
||||
if (isBreached) {
|
||||
errors.breached = "Password was found in a data breach.";
|
||||
}
|
||||
|
||||
tests.forEach((test) => {
|
||||
if (test.validator && !test.validator(password)) {
|
||||
errors[test.name as keyof Errors] = test.errorText;
|
||||
}
|
||||
});
|
||||
|
||||
setErrors(errors);
|
||||
return Object.keys(errors).length > 0;
|
||||
};
|
||||
|
||||
export default checkPassword;
|
||||
@@ -0,0 +1,36 @@
|
||||
// This regex covers letters (case insensitive) for the top 50 most spoken languages
|
||||
/* eslint-disable no-misleading-character-class */
|
||||
export const letterCharRegex = /[A-Za-z\u00C0-\u00D6\u00D8-\u00DE\u00DF-\u00F6\u00F8-\u00FF\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FFF\u0600-\u06FF\u0400-\u04FF\u0500-\u052F\u2DE0-\u2DFF\uA640-\uA69F\u05B0-\u05FF\u0980-\u09FF\u1F00-\u1FFF\u0130\u015E\u011E\u00C7\u00FC\u00FB\u00EB\u00E7]/u;
|
||||
|
||||
// This regex covers digits, special characters, symbols, and emojis.
|
||||
export const numAndSpecialCharRegex = /[\d!@#$%^&*(),.?":{}|<>]|[^\p{L}\p{N}\s]/u;
|
||||
|
||||
// This regex covers 3 repeated consecutive chars (incl. spaces)
|
||||
export const repeatedCharRegex = /(.)\1\1\1|\s{4,}/;
|
||||
|
||||
// This regex covers the escape sequences as a precaution
|
||||
export const escapeCharRegex = /[\n\t\r\\]/;
|
||||
|
||||
// This regex covers some PII and/or low entropy data
|
||||
export const lowEntropyRegexes = [
|
||||
// Email address
|
||||
/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/,
|
||||
|
||||
// URL (incl. subdomains, paths, top-level domains & query params)
|
||||
/^(?:(?:https?|ftp):\/\/)?(?:\w+\.)?[a-zA-Z0-9.-]+\.(?:com|org|net|edu)(?:\/\S*)?(?:\?\S*)?$/,
|
||||
|
||||
// Date in various formats
|
||||
/(\b\d{1,4}[-/.]?\d{1,2}[-/.]?\d{1,4}\b)|(\b\d{1,4}[-/.]?\w{3}[-/.]?\d{1,4}\b)/,
|
||||
|
||||
// Phone numbers (generalized)
|
||||
/(?:\+(?:[1-9]\d{0,2})\s?)?(?:\(\d{1,4}\)\s?)?(?:\d[-.\s]?){5,}\d/,
|
||||
|
||||
// Passport numbers (generalized)
|
||||
/\b(?:[A-Z0-9]{6,9}|[A-Z0-9]{8,9}|[A-Z0-9]{9}|[A-Z0-9]{10,11})\b/,
|
||||
|
||||
// Driver's license numbers (generalized)
|
||||
/\b(?:[A-Z0-9]{7,10}|[A-Z0-9]{10,11}|[A-Z0-9]{7,10})\b/,
|
||||
|
||||
// US social security number
|
||||
/\b\d{3}[-\s]?\d{2}[-\s]?\d{4}\b/,
|
||||
];
|
||||
Reference in New Issue
Block a user