├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── index.d.ts ├── index.js ├── lib ├── pwned-pw.d.ts └── pwned-pw.js ├── package.json └── test └── test.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.html 3 | *.min.js 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | node_js: 4 | - "lts/*" 5 | - "9" 6 | - "10" 7 | script: npm test 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2018 Jonathan Underwood 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pwned-pw 2 | 3 | ## Installation 4 | 5 | ``` 6 | npm install pwned-pw 7 | ``` 8 | 9 | ## Browser Use 10 | 11 | * `lib/pwned-pw.js` can be loaded directly into the browser. Minified it is less than 1kb. 12 | * However, it uses `TextEncoder` so Edge browser is not supported. If you add the TextEncoder polyfill it works. 13 | 14 | ## How can I trust the API server? (Hint: you don't need to) 15 | 16 | Take two examples: 17 | * `"123456"` (Most leaked password with over 22 million leaks) 18 | * `"U4JeDx!AdY3;Jh8*J93#ZT8%3bSxM5y451aa"` (Not leaked yet... except now it's public so don't use.) 19 | 20 | 21 | 1. SHA1 hash each password once, and take the first 5 characters. 22 | * `"7C4A8D09CA3762AF61E59520943DC26494F8941B"` 23 | * `"7C4A8CF0102FC0FAC9193784678035EEC619262C"` 24 | * Both have the same first 5 characters, which makes sense since there is only around 1 million combinations of 5 characters of a 16 character set. 25 | * `"7C4A8"` 26 | 2. Query (GET) the API with the last section of the path as these 5 characters. 27 | * `"https://api.pwnedpasswords.com/range/7C4A8"` 28 | * Returns over 477 results. 29 | * Since their database has over 500 million hashes, the pidgeon hole principle states that given a set of 500 million and a possibility space of 1 million (the first five letters) each possibility from the space should have around 500 elements from the set that match it. 30 | * So no matter which 5 letters you send, the server will send back around 500 results. 31 | * Results are formatted as follows: 32 | * `D09CA3762AF61E59520943DC26494F8941B:22390492` 33 | * The 6th - 40th character (remainder) of the hash 34 | * A colon followed by the "number of times this password has appeared as plaintext or a simple unsalted hash in a database leak" (Every leak has thousands of people with 123456 as their password, which explains why there are nowhere near 22 million site leaks in the database but it gives us a count of over 22 million) 35 | * As you can see, `"123456"` is found over 22 million times. 36 | * If you check the results for the remainder of our "strong password" hash it is nowhere to be found... 37 | 3. Think from the API perspective. I did the same request twice. How can it know whether I found a match or not. Did the server learn anything about my "strong password" through this GET request. No it did not. It can not possibly learn anything. 38 | 4. This property is called k-anonymity. So use this package (and the underlying API) with confidence and help PROTECT YOUR USERS FROM THEMSELVES! :-D 39 | 5. This package will do all those steps above for you automatically, so just pass in the string, and await the results. If there's a match, tell your user not to use that password. (Some recommend to only reject a password if the count is >=10 etc. otherwise you're blocking one password that one user used on one site that got hacked... however, I believe that you should reject this since it proves the user is reusing passwords... which is a big no-no.) 40 | * However, I do think it might be fine to allow count === 1 passwords... under the condition you warn the user of the match. 41 | 42 | ## Usage 43 | 44 | ```javascript 45 | const pwnedPw = require('pwned-pw') 46 | 47 | // returns a Promise 48 | pwnedPw.check('123456').then(count => { 49 | console.log('Your pwned count is: ' + count) 50 | // Your pwned count is: 22390492 51 | }).catch(error => { 52 | console.error('Your error is: ' + error.message) 53 | }) 54 | ``` 55 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | export { check } from './lib/pwned-pw'; 2 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./lib/pwned-pw') 2 | -------------------------------------------------------------------------------- /lib/pwned-pw.d.ts: -------------------------------------------------------------------------------- 1 | export declare function check(password: string, timeoutMs?: number): Promise; 2 | -------------------------------------------------------------------------------- /lib/pwned-pw.js: -------------------------------------------------------------------------------- 1 | var pwnedPw = {} 2 | pwnedPw.check = (function () { 3 | 4 | const isNode = () => typeof module !== "undefined" && module.exports 5 | 6 | function timeoutPromise(ms, promise) { 7 | return new Promise((resolve, reject) => { 8 | const timeoutId = setTimeout(() => { 9 | reject(new Error("Timeout")) 10 | }, ms); 11 | promise.then( 12 | (res) => { 13 | clearTimeout(timeoutId); 14 | resolve(res); 15 | }, 16 | (err) => { 17 | clearTimeout(timeoutId); 18 | reject(err); 19 | } 20 | ); 21 | }) 22 | } 23 | 24 | if (isNode()) { 25 | const https = require('https') 26 | const crypto = require('crypto') 27 | 28 | function sha1 (data) { 29 | return new Promise(r => 30 | r(crypto.createHash('sha1').update(data).digest('hex').toUpperCase()) 31 | ) 32 | } 33 | 34 | function getHashes (prefix) { 35 | return new Promise((resolve, reject) => { 36 | https.get('https://api.pwnedpasswords.com/range/' + prefix, res => { 37 | let data = '' 38 | let result 39 | res.setEncoding('utf8') 40 | res.on('data', chunk => { 41 | data += chunk 42 | }) 43 | res.on('end', () => { 44 | resolve(data) 45 | }) 46 | }).on('error', err => { 47 | reject(err) 48 | }) 49 | }) 50 | } 51 | 52 | // else if browser 53 | } else { 54 | 55 | function sha1(message) { 56 | const msgBuffer = new TextEncoder('utf-8').encode(message) 57 | return crypto.subtle.digest('SHA-1', msgBuffer).then(hashBuffer => { 58 | const hashArray = Array.from(new Uint8Array(hashBuffer)) 59 | const hashHex = hashArray.map(b => ('00' + b.toString(16)).slice(-2)).join('') 60 | return hashHex.toUpperCase() 61 | }) 62 | } 63 | 64 | function getHashes(prefix) { 65 | return fetch('https://api.pwnedpasswords.com/range/' + prefix) 66 | .then(response => response.text()) 67 | } 68 | } 69 | 70 | function check (password, timeoutMs) { 71 | timeoutMs = timeoutMs === undefined ? 5000 : timeoutMs 72 | return timeoutPromise(timeoutMs, new Promise((resolve, reject) => { 73 | if (typeof password !== 'string' && !(password instanceof String)) { 74 | return reject(new TypeError('password must be a String.')) 75 | } 76 | let suffix 77 | sha1(password.toString()).then(hash => { // in case of String object 78 | password = null 79 | 80 | let prefix = hash.slice(0, 5) 81 | suffix = hash.slice(5) 82 | hash = null 83 | 84 | return getHashes(prefix) 85 | }).then(data => { 86 | prefix = null 87 | let array = data.trim().split('\n') 88 | data = null 89 | array = array.map(item => { 90 | let parts = item.split(':') 91 | return { 92 | suffix: parts[0].toUpperCase(), 93 | count: parseInt(parts[1]) 94 | } 95 | }) 96 | let matches = array.filter(item => item.suffix === suffix) 97 | array = null 98 | suffix = null 99 | let count = matches[0] ? matches[0].count : 0 100 | matches = null 101 | return resolve(count) 102 | }).catch(err => { 103 | return reject(err) 104 | }) 105 | })) 106 | } 107 | 108 | if (isNode()) { 109 | module.exports = { check: check } 110 | } else { 111 | return check 112 | } 113 | })() 114 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pwned-pw", 3 | "version": "1.1.0", 4 | "description": "Check if your password is in the haveibeenpwned password leak database.", 5 | "main": "index.js", 6 | "types": "index.d.ts", 7 | "files": [ 8 | "lib", 9 | "index.d.ts", 10 | "index.js" 11 | ], 12 | "scripts": { 13 | "test": "node test/test.js" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "https://github.com/junderw/pwned-pw.git" 18 | }, 19 | "keywords": [ 20 | "haveibeenpwned", 21 | "password" 22 | ], 23 | "author": "Jonathan Underwood", 24 | "license": "MIT" 25 | } 26 | -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | const pwnedPw = require('..') 2 | let firstCount 3 | 4 | pwnedPw.check('123456').then(count1 => { 5 | if (typeof count1 !== 'number' || count1 < 1e6) { 6 | console.error('Error: weird value returned for normal string.') 7 | process.exit(1) 8 | return 9 | } 10 | console.log('Passed check #1 with count: ' + count1) 11 | firstCount = count1 12 | return pwnedPw.check(new String('123456')) 13 | }).then(count2 => { 14 | if (typeof count2 !== 'number' || count2 < 1e6) { 15 | console.error('Error: weird value returned for String object.') 16 | process.exit(1) 17 | return 18 | } 19 | console.log('Passed check #2 with count: ' + count2) 20 | if (firstCount !== count2) { 21 | console.error('Error: counts for string and string object were different.') 22 | process.exit(1) 23 | return 24 | } 25 | console.log('Check #1 and #2 were equal!') 26 | return pwnedPw.check(123456).then(() => { 27 | console.error('Error: Does not throw Error on non-string') 28 | process.exit(1) 29 | return 30 | }, error => { 31 | if (error.message !== 'password must be a String.') { 32 | console.error('Error: Does not throw Error on non-string') 33 | process.exit(1) 34 | return 35 | } 36 | console.log('Passed check for throwing Error on non-string') 37 | }).then(() => { 38 | return pwnedPw.check('123456', 3).then(() => { 39 | console.error('Error: Does not throw Error on timeout') 40 | process.exit(1) 41 | return 42 | }, error => { 43 | if (error.message !== 'Timeout') { 44 | console.error('Error: Does not throw Error on timeout') 45 | process.exit(1) 46 | return 47 | } 48 | console.log('Passed check for throwing Error on timeout') 49 | console.log('\nALL CHECKS PASSED! :-D') 50 | }) 51 | }) 52 | }).catch(error => { 53 | console.error('Error: unexpected error') 54 | console.error(error) 55 | process.exit(1) 56 | return 57 | }) 58 | --------------------------------------------------------------------------------