diff --git a/frontend/src/components/utilities/checks/checkIsPasswordBreached.ts b/frontend/src/components/utilities/checks/checkIsPasswordBreached.ts index 75caf9dcf..abfeac508 100644 --- a/frontend/src/components/utilities/checks/checkIsPasswordBreached.ts +++ b/frontend/src/components/utilities/checks/checkIsPasswordBreached.ts @@ -1,47 +1,83 @@ import axios from "axios"; import { createHash } from "crypto"; // added types from @types/node -export const checkIsPasswordBreached = async (password: string) => { // 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 - // if there is a match, that password has been involved in a password breach and should NOT be accepted - + // 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) => { const dataBreachCheckAPIBaseURL = "https://api.pwnedpasswords.com/range/"; // added to CSP + const maxRetryAttempts = 3; try { const textEncoder = new TextEncoder(); const encodedPwd = textEncoder.encode(password); const hash = createHash("sha1").update(encodedPwd).digest(); + const hashedPwd = hash.toString("hex").toUpperCase(); + const hashedPwdToSend = hashedPwd.slice(0, 5); // ONLY the first five hash chars are sent + const rangedHashTableUri = `${dataBreachCheckAPIBaseURL}${hashedPwdToSend}`; + + let response; + let retryAttempt = 0; - const hashedPwd = Array.from(new Uint8Array(hash)) - .map((byte) => byte.toString(16).padStart(2, "0")) - .join("") - .toUpperCase(); + 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", + }, + }); - // ONLY the first five SHA-1 hash chars need to be sent (must be over HTTPS) - const hashedPwdToSend = hashedPwd.slice(0, 5); - - const response = await axios.get(`${dataBreachCheckAPIBaseURL}${hashedPwdToSend}`); - const responseData = response.data.toUpperCase(); - - // compare against the API's ranged db's hash table - const isBreachedPassword = responseData.includes(hashedPwd.slice(5, 40)); - - // Clear the hashed password from memory as a precaution - const zeroBuffer = new Uint8Array(encodedPwd.length); - encodedPwd.set(zeroBuffer); - - return isBreachedPassword; // boolean: true indicates the password has been involved in a data breach - } catch (err: any) { - if (axios.isAxiosError(err) && err.response && err.response.status === 429) { - console.error("Received a 429 response from the Pwnd Passwords API"); - // Handle the 429 error here (not 100% sure what the rate limits are for the password API but looks like <10 calls/min) - // an error here should probably not cause the setting/resetting/changing password to fail (unless desired) - } else { - console.error(err); + if (response.status === 200) { + break; + } else { + retryAttempt++; + } + } catch (err) { + if (!axios.isAxiosError(err)) { + throw err; + } + retryAttempt++; + } } + + if (response && response.status === 200) { + const responseData = response.data.toUpperCase(); + // compare last 35 hash chars to the returned ranged hash table + // returns a boolean: true indicates the password has been involved in a data breach (ie. pwnd) + const isBreachedPassword = responseData.includes(hashedPwd.slice(5, 40)); + + // Clear the hashed password from memory as a precaution + const zeroBuffer = new Uint8Array(encodedPwd.length); + encodedPwd.set(zeroBuffer); + + return isBreachedPassword; + } + + console.error( + `Received a non-200 response (${response ? response.status : "unknown"}) from the Pwnd Passwords API` + ); + return false; // better to return a safe response if no breach can be determined + } catch (err: any) { + console.error("An unexpected error has occurred:", err.message); + return false; // Return a safe response in case of unexpected errors + // the HIBP API could return 400 (empty string supplied), 429 or 503 if Cloudflare edge node is down) } };