haveibeenfiltered 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +254 -0
- package/bin/cli.js +214 -0
- package/lib/datasets.js +22 -0
- package/lib/download.js +126 -0
- package/lib/filter.js +175 -0
- package/lib/index.js +64 -0
- package/lib/murmurhash3.js +69 -0
- package/package.json +45 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mihail Fedorov
|
|
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 all
|
|
13
|
+
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 THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
# haveibeenfiltered
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/haveibeenfiltered)
|
|
4
|
+
[](https://github.com/kolobus/haveibeenfiltered/blob/main/LICENSE)
|
|
5
|
+
[](https://nodejs.org)
|
|
6
|
+
|
|
7
|
+
Offline password breach checking using [ribbon filters](https://engineering.fb.com/2021/07/09/core-infra/ribbon-filter/). Check passwords against the [Have I Been Pwned](https://haveibeenpwned.com/) dataset (2B+ passwords) locally, with no API calls.
|
|
8
|
+
|
|
9
|
+
See [haveibeenfiltered.com](https://haveibeenfiltered.com) for more information about the project.
|
|
10
|
+
|
|
11
|
+
## Why?
|
|
12
|
+
|
|
13
|
+
| Approach | Speed | Privacy | Offline | Size |
|
|
14
|
+
|----------|-------|---------|---------|------|
|
|
15
|
+
| HIBP API | ~200ms/req | Partial (k-anonymity) | No | 0 |
|
|
16
|
+
| Full hash DB | Fast | Full | Yes | 30+ GB |
|
|
17
|
+
| **haveibeenfiltered** | **~14 microseconds** | **Full** | **Yes** | **1.8 GB** |
|
|
18
|
+
|
|
19
|
+
A ribbon filter compresses 2 billion SHA-1 hashes into 1.8 GB with a ~0.78% false positive rate and zero false negatives.
|
|
20
|
+
|
|
21
|
+
## Quick Start
|
|
22
|
+
|
|
23
|
+
### Install
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npm install haveibeenfiltered
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
### Download the filter data
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
npx haveibeenfiltered download
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
This downloads the HIBP dataset (~1.8 GB) to `~/.haveibeenfiltered/` with SHA-256 integrity verification.
|
|
36
|
+
|
|
37
|
+
For a smaller dataset to try first:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
npx haveibeenfiltered download --dataset rockyou
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### Use in your app
|
|
44
|
+
|
|
45
|
+
```js
|
|
46
|
+
const hbf = require('haveibeenfiltered')
|
|
47
|
+
|
|
48
|
+
const filter = await hbf.load({ dataset: 'rockyou' })
|
|
49
|
+
|
|
50
|
+
filter.check('password123') // true — breached!
|
|
51
|
+
filter.check('Tr0ub4dor&3') // false — not found
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## API
|
|
55
|
+
|
|
56
|
+
### `hbf.load(options?)`
|
|
57
|
+
|
|
58
|
+
Loads a ribbon filter and returns a `RibbonFilter` instance.
|
|
59
|
+
|
|
60
|
+
```js
|
|
61
|
+
// Default dataset (HIBP)
|
|
62
|
+
const filter = await hbf.load()
|
|
63
|
+
|
|
64
|
+
// Specific dataset
|
|
65
|
+
const filter = await hbf.load({ dataset: 'rockyou' })
|
|
66
|
+
|
|
67
|
+
// Custom file path
|
|
68
|
+
const filter = await hbf.load({ path: '/opt/data/ribbon-hibp-v1.bin' })
|
|
69
|
+
|
|
70
|
+
// Auto-download if missing (opt-in)
|
|
71
|
+
const filter = await hbf.load({ autoDownload: true })
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
**Options:**
|
|
75
|
+
|
|
76
|
+
| Option | Type | Default | Description |
|
|
77
|
+
|--------|------|---------|-------------|
|
|
78
|
+
| `dataset` | `string` | `'hibp'` | Dataset name (`hibp`, `rockyou`) |
|
|
79
|
+
| `path` | `string` | — | Explicit path to `.bin` file |
|
|
80
|
+
| `autoDownload` | `boolean` | `false` | Download from CDN if file is missing |
|
|
81
|
+
|
|
82
|
+
**Path resolution order:**
|
|
83
|
+
|
|
84
|
+
1. `opts.path` — explicit path
|
|
85
|
+
2. `$HAVEIBEENFILTERED_PATH` — environment variable
|
|
86
|
+
3. `~/.haveibeenfiltered/<filename>` — default directory
|
|
87
|
+
|
|
88
|
+
### `filter.check(password)`
|
|
89
|
+
|
|
90
|
+
Check a plaintext password. Returns `true` if found in the breach dataset.
|
|
91
|
+
|
|
92
|
+
```js
|
|
93
|
+
filter.check('password123') // true
|
|
94
|
+
filter.check('correcthorsebatterystaple') // depends on dataset
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### `filter.checkHash(sha1hex)`
|
|
98
|
+
|
|
99
|
+
Check a pre-computed SHA-1 hash (hex string, case-insensitive).
|
|
100
|
+
|
|
101
|
+
```js
|
|
102
|
+
filter.checkHash('5BAA61E4C9B93F3F0682250B6CF8331B7EE68FD8') // true ("password")
|
|
103
|
+
filter.checkHash('5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8') // also works
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### `filter.meta`
|
|
107
|
+
|
|
108
|
+
Returns filter metadata.
|
|
109
|
+
|
|
110
|
+
```js
|
|
111
|
+
filter.meta
|
|
112
|
+
// {
|
|
113
|
+
// version: 1,
|
|
114
|
+
// totalKeys: 2048908128,
|
|
115
|
+
// fpBits: 7,
|
|
116
|
+
// numShards: 256,
|
|
117
|
+
// ...
|
|
118
|
+
// }
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### `filter.close()`
|
|
122
|
+
|
|
123
|
+
Releases the in-memory buffer. Call when done.
|
|
124
|
+
|
|
125
|
+
## CLI
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
npx haveibeenfiltered <command> [options]
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### Download filter data
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
# Download HIBP dataset (~1.8 GB)
|
|
135
|
+
npx haveibeenfiltered download
|
|
136
|
+
|
|
137
|
+
# Download RockYou dataset (~13 MB)
|
|
138
|
+
npx haveibeenfiltered download --dataset rockyou
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### Check passwords
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
# Check a single password
|
|
145
|
+
npx haveibeenfiltered check password123
|
|
146
|
+
|
|
147
|
+
# Check multiple passwords
|
|
148
|
+
npx haveibeenfiltered check password 123456 iloveyou
|
|
149
|
+
|
|
150
|
+
# Check from stdin (batch mode)
|
|
151
|
+
cat passwords.txt | npx haveibeenfiltered check --stdin
|
|
152
|
+
|
|
153
|
+
# Check pre-computed SHA-1 hashes
|
|
154
|
+
npx haveibeenfiltered check --hash 5BAA61E4C9B93F3F0682250B6CF8331B7EE68FD8
|
|
155
|
+
|
|
156
|
+
# Batch hash checking
|
|
157
|
+
cat sha1-hashes.txt | npx haveibeenfiltered check --stdin --hash
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### Output formats
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
# Default: human-readable
|
|
164
|
+
npx haveibeenfiltered check password123
|
|
165
|
+
# password123: FOUND
|
|
166
|
+
|
|
167
|
+
# JSON output
|
|
168
|
+
npx haveibeenfiltered check password123 --json
|
|
169
|
+
# {"input":"password123","found":true}
|
|
170
|
+
|
|
171
|
+
# Quiet mode (exit code only — 1 if found, 0 if not)
|
|
172
|
+
npx haveibeenfiltered check password123 --quiet && echo "safe" || echo "breached"
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
### Check status
|
|
176
|
+
|
|
177
|
+
```bash
|
|
178
|
+
npx haveibeenfiltered status
|
|
179
|
+
# hibp: READY
|
|
180
|
+
# Path: /home/user/.haveibeenfiltered/ribbon-hibp-v1.bin
|
|
181
|
+
# Size: 1830.9 MB (1,918,974,105 bytes)
|
|
182
|
+
# Keys: 2,048,908,128
|
|
183
|
+
# Version: 1
|
|
184
|
+
# FP bits: 7
|
|
185
|
+
# Shards: 256
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
## Datasets
|
|
189
|
+
|
|
190
|
+
| Dataset | Passwords | Filter Size | FP Rate | Description |
|
|
191
|
+
|---------|-----------|-------------|---------|-------------|
|
|
192
|
+
| `hibp` | 2,048,908,128 | 1.8 GB | ~0.78% | [Have I Been Pwned](https://haveibeenpwned.com/) full password list |
|
|
193
|
+
| `rockyou` | 14,344,391 | 12.8 MB | ~0.78% | [RockYou](https://en.wikipedia.org/wiki/RockYou#Data_breach) breach (2009) |
|
|
194
|
+
|
|
195
|
+
### CDN
|
|
196
|
+
|
|
197
|
+
Filter binaries are hosted at `https://download.haveibeenfiltered.com/`:
|
|
198
|
+
|
|
199
|
+
| File | Size | SHA-256 |
|
|
200
|
+
|------|------|---------|
|
|
201
|
+
| [`ribbon-hibp-v1.bin`](https://download.haveibeenfiltered.com/ribbon-hibp-v1.bin) | 1.8 GB | `4eeb8608fa8541a51a952ecda91ad2f86e6f7457b0dbe34b88ba8a7ed33750ce` |
|
|
202
|
+
| [`ribbon-rockyou-v1.bin`](https://download.haveibeenfiltered.com/ribbon-rockyou-v1.bin) | 12.8 MB | `777d3c1640e7067bc7fb222488199c3371de5360639561f1f082db6b7c16a447` |
|
|
203
|
+
|
|
204
|
+
The CLI downloads to `~/.haveibeenfiltered/` by default. Integrity is verified via SHA-256 after each download.
|
|
205
|
+
|
|
206
|
+
### False Positive Rate
|
|
207
|
+
|
|
208
|
+
The filter uses 7-bit fingerprints, giving a theoretical false positive rate of 1/128 (~0.78%). This means:
|
|
209
|
+
|
|
210
|
+
- Zero false negatives — every breached password is detected
|
|
211
|
+
- ~0.78% of safe passwords will incorrectly report as breached
|
|
212
|
+
|
|
213
|
+
## How It Works
|
|
214
|
+
|
|
215
|
+
haveibeenfiltered uses [ribbon filters](https://engineering.fb.com/2021/07/09/core-infra/ribbon-filter/), a space-efficient probabilistic data structure (similar to Bloom filters but ~20% smaller).
|
|
216
|
+
|
|
217
|
+
1. **Build** — All 2B+ password SHA-1 hashes are inserted into a ribbon filter, sharded by the first byte (256 shards)
|
|
218
|
+
2. **Query** — Your password is SHA-1 hashed, then checked against the filter using MurmurHash3 for the internal lookup
|
|
219
|
+
3. **Result** — `true` means definitely breached (or ~0.78% chance of false positive). `false` means definitely not in the dataset.
|
|
220
|
+
|
|
221
|
+
The filter data is stored as a single binary file with a custom format (magic: `RBBN`), containing bit-packed solution vectors and overflow tables per shard.
|
|
222
|
+
|
|
223
|
+
## Security
|
|
224
|
+
|
|
225
|
+
- All checking is local — passwords never leave your machine
|
|
226
|
+
- Downloads are SHA-256 verified
|
|
227
|
+
- HTTPS only — redirects are refused
|
|
228
|
+
- Zero npm dependencies — only Node.js builtins
|
|
229
|
+
|
|
230
|
+
## Performance
|
|
231
|
+
|
|
232
|
+
| Operation | Time | Throughput |
|
|
233
|
+
|-----------|------|------------|
|
|
234
|
+
| `check(password)` | ~14 us | ~72,000/sec |
|
|
235
|
+
| `checkHash(sha1hex)` | ~8 us | ~121,000/sec |
|
|
236
|
+
|
|
237
|
+
Benchmarked on a single core. The filter loads into memory once (~1.8 GB RAM for HIBP) and all subsequent lookups are in-memory.
|
|
238
|
+
|
|
239
|
+
## Requirements
|
|
240
|
+
|
|
241
|
+
- **Node.js** >= 16.0.0
|
|
242
|
+
- **Disk space** — 1.8 GB for HIBP, 13 MB for RockYou
|
|
243
|
+
- **RAM** — same as disk (filter is loaded into memory)
|
|
244
|
+
|
|
245
|
+
## Links
|
|
246
|
+
|
|
247
|
+
- [haveibeenfiltered.com](https://haveibeenfiltered.com) — Project homepage
|
|
248
|
+
- [GitHub](https://github.com/kolobus/haveibeenfiltered) — Source code
|
|
249
|
+
- [npm](https://www.npmjs.com/package/haveibeenfiltered) — Package registry
|
|
250
|
+
- [Have I Been Pwned](https://haveibeenpwned.com/) — Password breach data source
|
|
251
|
+
|
|
252
|
+
## License
|
|
253
|
+
|
|
254
|
+
[MIT](LICENSE)
|
package/bin/cli.js
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
const readline = require('readline');
|
|
8
|
+
const { RibbonFilter } = require('../lib/filter');
|
|
9
|
+
const { DATASETS } = require('../lib/datasets');
|
|
10
|
+
const { download } = require('../lib/download');
|
|
11
|
+
|
|
12
|
+
const BOOLEAN_FLAGS = new Set(['stdin', 'hash', 'json', 'quiet']);
|
|
13
|
+
|
|
14
|
+
function parseArgs(argv) {
|
|
15
|
+
const args = Object.create(null);
|
|
16
|
+
args._ = [];
|
|
17
|
+
for (let i = 0; i < argv.length; i++) {
|
|
18
|
+
const arg = argv[i];
|
|
19
|
+
if (arg.startsWith('--')) {
|
|
20
|
+
const key = arg.slice(2);
|
|
21
|
+
if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue;
|
|
22
|
+
if (BOOLEAN_FLAGS.has(key)) {
|
|
23
|
+
args[key] = true;
|
|
24
|
+
} else {
|
|
25
|
+
const next = argv[i + 1];
|
|
26
|
+
if (next && !next.startsWith('--')) {
|
|
27
|
+
args[key] = next;
|
|
28
|
+
i++;
|
|
29
|
+
} else {
|
|
30
|
+
args[key] = true;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
} else {
|
|
34
|
+
args._.push(arg);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return args;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function resolveDataset(name) {
|
|
41
|
+
const ds = DATASETS[name];
|
|
42
|
+
if (!ds) {
|
|
43
|
+
console.error('Unknown dataset: ' + name + '. Available: ' + Object.keys(DATASETS).join(', '));
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
return ds;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function resolveFilterPath(args) {
|
|
50
|
+
if (args.path) return path.resolve(args.path);
|
|
51
|
+
const dsName = args.dataset || 'hibp';
|
|
52
|
+
const ds = resolveDataset(dsName);
|
|
53
|
+
if (process.env.HAVEIBEENFILTERED_PATH) return path.resolve(process.env.HAVEIBEENFILTERED_PATH);
|
|
54
|
+
return path.join(os.homedir(), '.haveibeenfiltered', ds.filename);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function cmdDownload(args) {
|
|
58
|
+
const dsName = args.dataset || 'hibp';
|
|
59
|
+
const ds = resolveDataset(dsName);
|
|
60
|
+
const filterPath = resolveFilterPath(args);
|
|
61
|
+
|
|
62
|
+
if (fs.existsSync(filterPath)) {
|
|
63
|
+
console.log(ds.name + ' filter already exists at ' + filterPath);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
console.log('Downloading ' + ds.name + ' filter...');
|
|
68
|
+
console.log(' URL: ' + ds.url);
|
|
69
|
+
console.log(' Destination: ' + filterPath);
|
|
70
|
+
console.log(' Expected size: ' + (ds.expectedBytes / 1048576).toFixed(1) + ' MB');
|
|
71
|
+
console.log(' SHA-256: ' + ds.sha256);
|
|
72
|
+
|
|
73
|
+
await download(ds.url, filterPath, {
|
|
74
|
+
expectedBytes: ds.expectedBytes,
|
|
75
|
+
sha256: ds.sha256,
|
|
76
|
+
onProgress: ({ downloaded, total, percent }) => {
|
|
77
|
+
const mb = (downloaded / 1048576).toFixed(1);
|
|
78
|
+
const totalMb = (total / 1048576).toFixed(1);
|
|
79
|
+
process.stdout.write('\r ' + mb + ' / ' + totalMb + ' MB (' + percent + '%)');
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
console.log('\nDone. Integrity verified.');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function cmdStatus(args) {
|
|
86
|
+
const dsName = args.dataset || 'hibp';
|
|
87
|
+
const ds = resolveDataset(dsName);
|
|
88
|
+
const filterPath = resolveFilterPath(args);
|
|
89
|
+
|
|
90
|
+
if (!fs.existsSync(filterPath)) {
|
|
91
|
+
console.log(ds.name + ': NOT DOWNLOADED');
|
|
92
|
+
console.log(' Expected path: ' + filterPath);
|
|
93
|
+
console.log(' Run: npx haveibeenfiltered download --dataset ' + dsName);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const stat = fs.statSync(filterPath);
|
|
98
|
+
const buffer = fs.readFileSync(filterPath);
|
|
99
|
+
const filter = new RibbonFilter(buffer);
|
|
100
|
+
const meta = filter.meta;
|
|
101
|
+
filter.close();
|
|
102
|
+
|
|
103
|
+
console.log(ds.name + ': READY');
|
|
104
|
+
console.log(' Path: ' + filterPath);
|
|
105
|
+
console.log(' Size: ' + (stat.size / 1048576).toFixed(1) + ' MB (' + stat.size.toLocaleString() + ' bytes)');
|
|
106
|
+
console.log(' Keys: ' + meta.totalKeys.toLocaleString());
|
|
107
|
+
console.log(' Version: ' + meta.version);
|
|
108
|
+
console.log(' FP bits: ' + meta.fpBits);
|
|
109
|
+
console.log(' Shards: ' + meta.numShards);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function cmdCheck(args) {
|
|
113
|
+
const filterPath = resolveFilterPath(args);
|
|
114
|
+
|
|
115
|
+
if (!fs.existsSync(filterPath)) {
|
|
116
|
+
console.error('Filter not found: ' + filterPath);
|
|
117
|
+
console.error('Run: npx haveibeenfiltered download --dataset ' + (args.dataset || 'hibp'));
|
|
118
|
+
process.exit(1);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const buffer = fs.readFileSync(filterPath);
|
|
122
|
+
const filter = new RibbonFilter(buffer);
|
|
123
|
+
const useHash = !!args.hash;
|
|
124
|
+
const useJson = !!args.json;
|
|
125
|
+
const useQuiet = !!args.quiet;
|
|
126
|
+
|
|
127
|
+
const lookup = useHash
|
|
128
|
+
? (v) => filter.checkHash(v)
|
|
129
|
+
: (v) => filter.check(v);
|
|
130
|
+
|
|
131
|
+
let anyFound = false;
|
|
132
|
+
const inputs = [];
|
|
133
|
+
|
|
134
|
+
if (args.stdin) {
|
|
135
|
+
const rl = readline.createInterface({ input: process.stdin, terminal: false });
|
|
136
|
+
for await (const line of rl) {
|
|
137
|
+
const v = line.trim();
|
|
138
|
+
if (v) inputs.push(v);
|
|
139
|
+
}
|
|
140
|
+
} else {
|
|
141
|
+
if (args._.length === 0) {
|
|
142
|
+
console.error('Usage: haveibeenfiltered check <password> [--hash] [--json] [--quiet]');
|
|
143
|
+
process.exit(1);
|
|
144
|
+
}
|
|
145
|
+
inputs.push(...args._);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const results = [];
|
|
149
|
+
for (const v of inputs) {
|
|
150
|
+
const found = lookup(v);
|
|
151
|
+
if (found) anyFound = true;
|
|
152
|
+
results.push({ input: v, found });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (useJson) {
|
|
156
|
+
console.log(JSON.stringify(results.length === 1 ? results[0] : results));
|
|
157
|
+
} else if (!useQuiet) {
|
|
158
|
+
for (const r of results) {
|
|
159
|
+
console.log(r.input + ': ' + (r.found ? 'FOUND' : 'NOT FOUND'));
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
filter.close();
|
|
164
|
+
if (useQuiet) process.exit(anyFound ? 1 : 0);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function usage() {
|
|
168
|
+
console.log('Usage: haveibeenfiltered <command> [options]');
|
|
169
|
+
console.log('');
|
|
170
|
+
console.log('Commands:');
|
|
171
|
+
console.log(' download Download filter data for a dataset');
|
|
172
|
+
console.log(' status Check if filter data is available');
|
|
173
|
+
console.log(' check Check password(s) against the filter');
|
|
174
|
+
console.log('');
|
|
175
|
+
console.log('Options:');
|
|
176
|
+
console.log(' --dataset <name> Dataset to use (hibp, rockyou). Default: hibp');
|
|
177
|
+
console.log(' --path <path> Custom path to filter file');
|
|
178
|
+
console.log(' --stdin Read input from stdin (check command)');
|
|
179
|
+
console.log(' --hash Input is SHA-1 hex, not plaintext (check command)');
|
|
180
|
+
console.log(' --json Output results as JSON (check command)');
|
|
181
|
+
console.log(' --quiet No output, exit code 1 if any found (check command)');
|
|
182
|
+
console.log('');
|
|
183
|
+
console.log('Examples:');
|
|
184
|
+
console.log(' npx haveibeenfiltered download --dataset rockyou');
|
|
185
|
+
console.log(' npx haveibeenfiltered status');
|
|
186
|
+
console.log(' npx haveibeenfiltered check password123');
|
|
187
|
+
console.log(' npx haveibeenfiltered check --hash 5BAA61E4C9B93F3F0682250B6CF8331B7EE68FD8');
|
|
188
|
+
console.log(' npx haveibeenfiltered check --stdin < passwords.txt');
|
|
189
|
+
console.log(' npx haveibeenfiltered check --stdin --hash < hashes.txt');
|
|
190
|
+
console.log(' npx haveibeenfiltered check password123 --json');
|
|
191
|
+
console.log(' npx haveibeenfiltered check password123 --quiet && echo "safe" || echo "breached"');
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function main() {
|
|
195
|
+
const args = parseArgs(process.argv.slice(2));
|
|
196
|
+
const command = args._.shift();
|
|
197
|
+
|
|
198
|
+
switch (command) {
|
|
199
|
+
case 'download': return cmdDownload(args);
|
|
200
|
+
case 'status': return cmdStatus(args);
|
|
201
|
+
case 'check': return cmdCheck(args);
|
|
202
|
+
default:
|
|
203
|
+
usage();
|
|
204
|
+
if (command) {
|
|
205
|
+
console.error('\nUnknown command: ' + command);
|
|
206
|
+
process.exit(1);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
main().catch((err) => {
|
|
212
|
+
console.error('Error: ' + err.message);
|
|
213
|
+
process.exit(1);
|
|
214
|
+
});
|
package/lib/datasets.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const DATASETS = {
|
|
4
|
+
hibp: {
|
|
5
|
+
name: 'hibp',
|
|
6
|
+
version: 1,
|
|
7
|
+
filename: 'ribbon-hibp-v1.bin',
|
|
8
|
+
url: 'https://download.haveibeenfiltered.com/ribbon-hibp-v1.bin',
|
|
9
|
+
expectedBytes: 1918974105,
|
|
10
|
+
sha256: '4eeb8608fa8541a51a952ecda91ad2f86e6f7457b0dbe34b88ba8a7ed33750ce',
|
|
11
|
+
},
|
|
12
|
+
rockyou: {
|
|
13
|
+
name: 'rockyou',
|
|
14
|
+
version: 1,
|
|
15
|
+
filename: 'ribbon-rockyou-v1.bin',
|
|
16
|
+
url: 'https://download.haveibeenfiltered.com/ribbon-rockyou-v1.bin',
|
|
17
|
+
expectedBytes: 13456384,
|
|
18
|
+
sha256: '777d3c1640e7067bc7fb222488199c3371de5360639561f1f082db6b7c16a447',
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
module.exports = { DATASETS };
|
package/lib/download.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const https = require('https');
|
|
4
|
+
const crypto = require('crypto');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
|
|
8
|
+
const MAX_DOWNLOAD_BYTES = 4 * 1024 * 1024 * 1024; // 4 GB absolute cap
|
|
9
|
+
|
|
10
|
+
function download(url, destPath, opts) {
|
|
11
|
+
opts = opts || {};
|
|
12
|
+
const onProgress = opts.onProgress || null;
|
|
13
|
+
const expectedBytes = opts.expectedBytes || 0;
|
|
14
|
+
const expectedSha256 = opts.sha256 || null;
|
|
15
|
+
|
|
16
|
+
if (!url.startsWith('https://')) {
|
|
17
|
+
return Promise.reject(new Error('Refusing non-HTTPS download URL: ' + url));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const sizeLimit = expectedBytes > 0 ? expectedBytes + 1024 : MAX_DOWNLOAD_BYTES;
|
|
21
|
+
|
|
22
|
+
return new Promise((resolve, reject) => {
|
|
23
|
+
const dir = path.dirname(destPath);
|
|
24
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
25
|
+
|
|
26
|
+
const tmpPath = destPath + '.tmp';
|
|
27
|
+
const file = fs.createWriteStream(tmpPath);
|
|
28
|
+
|
|
29
|
+
function cleanup(err) {
|
|
30
|
+
try { fs.unlinkSync(tmpPath); } catch (_) {}
|
|
31
|
+
reject(err);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
https.get(url, (res) => {
|
|
35
|
+
/* Refuse redirects — the CDN URL must resolve directly */
|
|
36
|
+
if (res.statusCode >= 300 && res.statusCode < 400) {
|
|
37
|
+
res.resume();
|
|
38
|
+
cleanup(new Error('Download refused redirect (HTTP ' + res.statusCode + ' → ' + (res.headers.location || '?') + '). Update the dataset URL.'));
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (res.statusCode !== 200) {
|
|
43
|
+
res.resume();
|
|
44
|
+
cleanup(new Error('Download failed: HTTP ' + res.statusCode));
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/* Check Content-Length against expected size if available */
|
|
49
|
+
const contentLength = parseInt(res.headers['content-length'], 10) || 0;
|
|
50
|
+
if (expectedBytes > 0 && contentLength > 0 && contentLength !== expectedBytes) {
|
|
51
|
+
res.resume();
|
|
52
|
+
cleanup(new Error('Content-Length mismatch: expected ' + expectedBytes + ' bytes, server reports ' + contentLength));
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let downloaded = 0;
|
|
57
|
+
|
|
58
|
+
res.on('data', (chunk) => {
|
|
59
|
+
downloaded += chunk.length;
|
|
60
|
+
if (downloaded > sizeLimit) {
|
|
61
|
+
res.destroy();
|
|
62
|
+
cleanup(new Error('Download exceeded size limit (' + sizeLimit + ' bytes). Aborting.'));
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (onProgress && contentLength > 0) {
|
|
66
|
+
onProgress({
|
|
67
|
+
downloaded,
|
|
68
|
+
total: contentLength,
|
|
69
|
+
percent: Math.round(downloaded / contentLength * 100),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
res.pipe(file);
|
|
75
|
+
|
|
76
|
+
file.on('finish', () => {
|
|
77
|
+
file.close(() => {
|
|
78
|
+
/* Validate file size */
|
|
79
|
+
const stat = fs.statSync(tmpPath);
|
|
80
|
+
if (expectedBytes > 0 && stat.size !== expectedBytes) {
|
|
81
|
+
cleanup(new Error('Downloaded file size mismatch: expected ' + expectedBytes + ' bytes, got ' + stat.size));
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/* Validate RBBN magic */
|
|
86
|
+
const fd = fs.openSync(tmpPath, 'r');
|
|
87
|
+
const magic = Buffer.alloc(4);
|
|
88
|
+
fs.readSync(fd, magic, 0, 4, 0);
|
|
89
|
+
fs.closeSync(fd);
|
|
90
|
+
|
|
91
|
+
if (magic.toString('ascii') !== 'RBBN') {
|
|
92
|
+
cleanup(new Error('Downloaded file is not a valid ribbon filter (bad magic)'));
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/* Validate SHA-256 */
|
|
97
|
+
if (expectedSha256) {
|
|
98
|
+
const hash = crypto.createHash('sha256');
|
|
99
|
+
const stream = fs.createReadStream(tmpPath);
|
|
100
|
+
stream.on('data', (chunk) => hash.update(chunk));
|
|
101
|
+
stream.on('end', () => {
|
|
102
|
+
const actual = hash.digest('hex');
|
|
103
|
+
if (actual !== expectedSha256) {
|
|
104
|
+
cleanup(new Error(
|
|
105
|
+
'SHA-256 mismatch!\n' +
|
|
106
|
+
' Expected: ' + expectedSha256 + '\n' +
|
|
107
|
+
' Got: ' + actual + '\n' +
|
|
108
|
+
'The downloaded file may have been tampered with.'
|
|
109
|
+
));
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
fs.renameSync(tmpPath, destPath);
|
|
113
|
+
resolve(destPath);
|
|
114
|
+
});
|
|
115
|
+
stream.on('error', cleanup);
|
|
116
|
+
} else {
|
|
117
|
+
fs.renameSync(tmpPath, destPath);
|
|
118
|
+
resolve(destPath);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
}).on('error', cleanup);
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
module.exports = { download };
|
package/lib/filter.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const { murmurhash3_x64_128 } = require('./murmurhash3');
|
|
5
|
+
|
|
6
|
+
const HEADER_SIZE = 5168;
|
|
7
|
+
const RIBBON_W = 64;
|
|
8
|
+
const HEX_CHARS = [48,49,50,51,52,53,54,55,56,57,65,66,67,68,69,70]; // '0'-'9','A'-'F'
|
|
9
|
+
|
|
10
|
+
class RibbonFilter {
|
|
11
|
+
constructor(buffer) {
|
|
12
|
+
this._buf = buffer;
|
|
13
|
+
this.shardM = new Array(256);
|
|
14
|
+
this.shardStartRange = new Array(256);
|
|
15
|
+
this.shardOverflowCount = new Array(256);
|
|
16
|
+
this.shardSolOffset = new Array(256);
|
|
17
|
+
this.shardOvfOffset = new Array(256);
|
|
18
|
+
this._keyBuf = Buffer.alloc(40);
|
|
19
|
+
this._solBuf = Buffer.alloc(66); /* max readLen 64 + 2 sentinel bytes */
|
|
20
|
+
this._readHeader();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
_readHeader() {
|
|
24
|
+
const buf = this._buf;
|
|
25
|
+
|
|
26
|
+
const magic = buf.toString('ascii', 0, 4);
|
|
27
|
+
if (magic !== 'RBBN') throw new Error('Not a ribbon filter file (magic: ' + magic + ')');
|
|
28
|
+
|
|
29
|
+
this.version = buf.readUInt32LE(4);
|
|
30
|
+
this.numShards = buf.readUInt32LE(8);
|
|
31
|
+
this.ribbonW = buf.readUInt32LE(12);
|
|
32
|
+
this.hashSeed = buf.readUInt32LE(16);
|
|
33
|
+
this.fpBits = buf.readUInt32LE(20);
|
|
34
|
+
this.fpMask = (1 << this.fpBits) - 1;
|
|
35
|
+
this.totalKeys = Number(buf.readBigUInt64LE(24));
|
|
36
|
+
this.totalSolution = Number(buf.readBigUInt64LE(32));
|
|
37
|
+
this.totalOverflow = Number(buf.readBigUInt64LE(40));
|
|
38
|
+
|
|
39
|
+
/* Pre-compute BigInt constants */
|
|
40
|
+
this._fpShift = BigInt(64 - this.fpBits);
|
|
41
|
+
this._fpMaskBig = BigInt(this.fpMask);
|
|
42
|
+
this._shardStartRangeBig = new Array(256);
|
|
43
|
+
|
|
44
|
+
for (let i = 0; i < 256; i++) {
|
|
45
|
+
this.shardM[i] = Number(buf.readBigUInt64LE(48 + i * 8));
|
|
46
|
+
this.shardStartRange[i] = Number(buf.readBigUInt64LE(2096 + i * 8));
|
|
47
|
+
this.shardOverflowCount[i] = buf.readUInt32LE(4144 + i * 4);
|
|
48
|
+
this._shardStartRangeBig[i] = BigInt(this.shardStartRange[i]);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let offset = HEADER_SIZE;
|
|
52
|
+
for (let i = 0; i < 256; i++) {
|
|
53
|
+
this.shardSolOffset[i] = offset;
|
|
54
|
+
const packedBytes = Math.ceil(this.shardM[i] * this.fpBits / 8);
|
|
55
|
+
offset += packedBytes;
|
|
56
|
+
this.shardOvfOffset[i] = offset;
|
|
57
|
+
offset += this.shardOverflowCount[i] * 16;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
check(password) {
|
|
62
|
+
const digest = crypto.createHash('sha1').update(password).digest();
|
|
63
|
+
const shard = digest[0];
|
|
64
|
+
const keyBuf = this._keyBuf;
|
|
65
|
+
for (let i = 0; i < 20; i++) {
|
|
66
|
+
keyBuf[i * 2] = HEX_CHARS[digest[i] >> 4];
|
|
67
|
+
keyBuf[i * 2 + 1] = HEX_CHARS[digest[i] & 0xf];
|
|
68
|
+
}
|
|
69
|
+
return this._query(shard, keyBuf);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
checkHash(sha1hex) {
|
|
73
|
+
const shard = parseInt(sha1hex.substring(0, 2), 16);
|
|
74
|
+
const keyBuf = this._keyBuf;
|
|
75
|
+
for (let i = 0; i < 40; i++) {
|
|
76
|
+
let c = sha1hex.charCodeAt(i);
|
|
77
|
+
if (c >= 97) c -= 32; /* a-f → A-F */
|
|
78
|
+
keyBuf[i] = c;
|
|
79
|
+
}
|
|
80
|
+
return this._query(shard, keyBuf);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
_query(shard, keyBuf) {
|
|
84
|
+
const buf = this._buf;
|
|
85
|
+
const m = this.shardM[shard];
|
|
86
|
+
if (m === 0 || this.shardStartRange[shard] === 0) return false;
|
|
87
|
+
|
|
88
|
+
const fpBits = this.fpBits;
|
|
89
|
+
const fpMask = this.fpMask;
|
|
90
|
+
|
|
91
|
+
const [h1, h2] = murmurhash3_x64_128(keyBuf, this.hashSeed);
|
|
92
|
+
|
|
93
|
+
const start = Number(h1 % this._shardStartRangeBig[shard]);
|
|
94
|
+
const coeff = h2 | 1n;
|
|
95
|
+
const fp = Number((h1 >> this._fpShift) & this._fpMaskBig);
|
|
96
|
+
|
|
97
|
+
/* Split coeff into two 32-bit halves for fast inner loop */
|
|
98
|
+
const coeffLo = Number(coeff & 0xFFFFFFFFn);
|
|
99
|
+
const coeffHi = Number((coeff >> 32n) & 0xFFFFFFFFn);
|
|
100
|
+
|
|
101
|
+
/* Copy packed solution bytes into reusable buffer */
|
|
102
|
+
const firstBit = start * fpBits;
|
|
103
|
+
const firstByte = Math.floor(firstBit / 8);
|
|
104
|
+
const packedSize = Math.ceil(m * fpBits / 8);
|
|
105
|
+
const readLen = Math.min(64, packedSize - firstByte);
|
|
106
|
+
const solOff = this.shardSolOffset[shard] + firstByte;
|
|
107
|
+
const solBuf = this._solBuf;
|
|
108
|
+
buf.copy(solBuf, 0, solOff, solOff + readLen);
|
|
109
|
+
solBuf[readLen] = 0; /* sentinel for 2-byte unpack at boundary */
|
|
110
|
+
solBuf[readLen + 1] = 0;
|
|
111
|
+
|
|
112
|
+
/* XOR unpacked values — two 32-bit loops instead of one BigInt loop */
|
|
113
|
+
const startBitRem = firstBit & 7;
|
|
114
|
+
let result = 0;
|
|
115
|
+
|
|
116
|
+
let cLo = coeffLo;
|
|
117
|
+
for (let pos = 0; pos < 32 && cLo !== 0; pos++) {
|
|
118
|
+
if (cLo & 1) {
|
|
119
|
+
const relBitOff = startBitRem + pos * fpBits;
|
|
120
|
+
const byteIdx = relBitOff >> 3;
|
|
121
|
+
const shift = relBitOff & 7;
|
|
122
|
+
result ^= ((solBuf[byteIdx] | (solBuf[byteIdx + 1] << 8)) >> shift) & fpMask;
|
|
123
|
+
}
|
|
124
|
+
cLo >>>= 1;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
let cHi = coeffHi;
|
|
128
|
+
for (let pos = 32; pos < RIBBON_W && cHi !== 0; pos++) {
|
|
129
|
+
if (cHi & 1) {
|
|
130
|
+
const relBitOff = startBitRem + pos * fpBits;
|
|
131
|
+
const byteIdx = relBitOff >> 3;
|
|
132
|
+
const shift = relBitOff & 7;
|
|
133
|
+
result ^= ((solBuf[byteIdx] | (solBuf[byteIdx + 1] << 8)) >> shift) & fpMask;
|
|
134
|
+
}
|
|
135
|
+
cHi >>>= 1;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (result === fp) return true;
|
|
139
|
+
|
|
140
|
+
/* Check overflow (bumped entries) */
|
|
141
|
+
const nOvf = this.shardOverflowCount[shard];
|
|
142
|
+
if (nOvf > 0) {
|
|
143
|
+
const ovfOff = this.shardOvfOffset[shard];
|
|
144
|
+
for (let i = 0; i < nOvf; i++) {
|
|
145
|
+
const off = ovfOff + i * 16;
|
|
146
|
+
const oCoeff = buf.readBigUInt64LE(off);
|
|
147
|
+
const oStart = buf.readUInt32LE(off + 8);
|
|
148
|
+
const oFp = buf[off + 12];
|
|
149
|
+
if (oStart === start && oCoeff === coeff && oFp === fp)
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
get meta() {
|
|
158
|
+
return {
|
|
159
|
+
version: this.version,
|
|
160
|
+
numShards: this.numShards,
|
|
161
|
+
ribbonW: this.ribbonW,
|
|
162
|
+
hashSeed: this.hashSeed,
|
|
163
|
+
fpBits: this.fpBits,
|
|
164
|
+
totalKeys: this.totalKeys,
|
|
165
|
+
totalSolution: this.totalSolution,
|
|
166
|
+
totalOverflow: this.totalOverflow,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
close() {
|
|
171
|
+
this._buf = null;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
module.exports = { RibbonFilter };
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const { RibbonFilter } = require('./filter');
|
|
7
|
+
const { DATASETS } = require('./datasets');
|
|
8
|
+
const { download } = require('./download');
|
|
9
|
+
|
|
10
|
+
const _cache = Object.create(null);
|
|
11
|
+
|
|
12
|
+
function resolveDataset(name) {
|
|
13
|
+
const ds = DATASETS[name];
|
|
14
|
+
if (!ds) throw new Error('Unknown dataset: ' + name + '. Available: ' + Object.keys(DATASETS).join(', '));
|
|
15
|
+
return ds;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function defaultDir() {
|
|
19
|
+
return path.join(os.homedir(), '.haveibeenfiltered');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function load(opts) {
|
|
23
|
+
opts = opts || {};
|
|
24
|
+
const datasetName = opts.dataset || 'hibp';
|
|
25
|
+
const ds = resolveDataset(datasetName);
|
|
26
|
+
const autoDownload = opts.autoDownload === true;
|
|
27
|
+
|
|
28
|
+
// Resolve filter path
|
|
29
|
+
let filterPath;
|
|
30
|
+
if (opts.path) {
|
|
31
|
+
filterPath = path.resolve(opts.path);
|
|
32
|
+
} else if (process.env.HAVEIBEENFILTERED_PATH) {
|
|
33
|
+
filterPath = path.resolve(process.env.HAVEIBEENFILTERED_PATH);
|
|
34
|
+
} else {
|
|
35
|
+
filterPath = path.join(defaultDir(), ds.filename);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Return cached filter if already loaded
|
|
39
|
+
if (_cache[filterPath]) return _cache[filterPath];
|
|
40
|
+
|
|
41
|
+
// Check if file exists
|
|
42
|
+
if (!fs.existsSync(filterPath)) {
|
|
43
|
+
if (!autoDownload) {
|
|
44
|
+
throw new Error('Filter not found: ' + filterPath + '. Download it first:\n npx haveibeenfiltered download --dataset ' + datasetName);
|
|
45
|
+
}
|
|
46
|
+
// Download from CDN
|
|
47
|
+
process.stderr.write('Downloading ' + ds.name + ' filter to ' + filterPath + '...\n');
|
|
48
|
+
await download(ds.url, filterPath, {
|
|
49
|
+
expectedBytes: ds.expectedBytes,
|
|
50
|
+
sha256: ds.sha256,
|
|
51
|
+
onProgress: ({ percent }) => {
|
|
52
|
+
process.stderr.write('\r ' + percent + '%');
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
process.stderr.write('\n');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const buffer = fs.readFileSync(filterPath);
|
|
59
|
+
const filter = new RibbonFilter(buffer);
|
|
60
|
+
_cache[filterPath] = filter;
|
|
61
|
+
return filter;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
module.exports = { load };
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const MASK64 = (1n << 64n) - 1n;
|
|
4
|
+
|
|
5
|
+
function rotl64(x, r) {
|
|
6
|
+
const br = BigInt(r);
|
|
7
|
+
return ((x << br) | (x >> (64n - br))) & MASK64;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function fmix64(k) {
|
|
11
|
+
k ^= k >> 33n;
|
|
12
|
+
k = (k * 0xff51afd7ed558ccdn) & MASK64;
|
|
13
|
+
k ^= k >> 33n;
|
|
14
|
+
k = (k * 0xc4ceb9fe1a85ec53n) & MASK64;
|
|
15
|
+
k ^= k >> 33n;
|
|
16
|
+
return k;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function murmurhash3_x64_128(buf, seed) {
|
|
20
|
+
const len = buf.length;
|
|
21
|
+
const nblocks = Math.floor(len / 16);
|
|
22
|
+
|
|
23
|
+
let h1 = BigInt(seed);
|
|
24
|
+
let h2 = BigInt(seed);
|
|
25
|
+
|
|
26
|
+
const c1 = 0x87c37b91114253d5n;
|
|
27
|
+
const c2 = 0x4cf5ad432745937fn;
|
|
28
|
+
|
|
29
|
+
for (let i = 0; i < nblocks; i++) {
|
|
30
|
+
let k1 = buf.readBigUInt64LE(i * 16);
|
|
31
|
+
let k2 = buf.readBigUInt64LE(i * 16 + 8);
|
|
32
|
+
|
|
33
|
+
k1 = (k1 * c1) & MASK64; k1 = rotl64(k1, 31); k1 = (k1 * c2) & MASK64;
|
|
34
|
+
h1 ^= k1;
|
|
35
|
+
h1 = rotl64(h1, 27); h1 = (h1 + h2) & MASK64; h1 = (h1 * 5n + 0x52dce729n) & MASK64;
|
|
36
|
+
|
|
37
|
+
k2 = (k2 * c2) & MASK64; k2 = rotl64(k2, 33); k2 = (k2 * c1) & MASK64;
|
|
38
|
+
h2 ^= k2;
|
|
39
|
+
h2 = rotl64(h2, 31); h2 = (h2 + h1) & MASK64; h2 = (h2 * 5n + 0x38495ab5n) & MASK64;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const tailOff = nblocks * 16;
|
|
43
|
+
const tlen = len & 15;
|
|
44
|
+
let k1 = 0n, k2 = 0n;
|
|
45
|
+
|
|
46
|
+
if (tlen > 8) {
|
|
47
|
+
for (let i = tlen - 1; i >= 8; i--)
|
|
48
|
+
k2 ^= BigInt(buf[tailOff + i]) << (BigInt(i - 8) * 8n);
|
|
49
|
+
k2 = (k2 * c2) & MASK64; k2 = rotl64(k2, 33); k2 = (k2 * c1) & MASK64;
|
|
50
|
+
h2 ^= k2;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (tlen > 0) {
|
|
54
|
+
const end = Math.min(tlen - 1, 7);
|
|
55
|
+
for (let i = end; i >= 0; i--)
|
|
56
|
+
k1 ^= BigInt(buf[tailOff + i]) << (BigInt(i) * 8n);
|
|
57
|
+
k1 = (k1 * c1) & MASK64; k1 = rotl64(k1, 31); k1 = (k1 * c2) & MASK64;
|
|
58
|
+
h1 ^= k1;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
h1 ^= BigInt(len); h2 ^= BigInt(len);
|
|
62
|
+
h1 = (h1 + h2) & MASK64; h2 = (h2 + h1) & MASK64;
|
|
63
|
+
h1 = fmix64(h1); h2 = fmix64(h2);
|
|
64
|
+
h1 = (h1 + h2) & MASK64; h2 = (h2 + h1) & MASK64;
|
|
65
|
+
|
|
66
|
+
return [h1, h2];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
module.exports = { murmurhash3_x64_128 };
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "haveibeenfiltered",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Offline password breach checking using ribbon filters. Check passwords against HIBP (2B+ passwords) and other breach datasets locally, with zero API calls.",
|
|
5
|
+
"main": "lib/index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"haveibeenfiltered": "bin/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"lib/",
|
|
11
|
+
"bin/",
|
|
12
|
+
"LICENSE",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"test": "node test.js"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"hibp",
|
|
20
|
+
"haveibeenpwned",
|
|
21
|
+
"password",
|
|
22
|
+
"breach",
|
|
23
|
+
"security",
|
|
24
|
+
"ribbon-filter",
|
|
25
|
+
"offline",
|
|
26
|
+
"password-check",
|
|
27
|
+
"pwned",
|
|
28
|
+
"data-breach",
|
|
29
|
+
"sha1",
|
|
30
|
+
"filter"
|
|
31
|
+
],
|
|
32
|
+
"author": "Mihail Fedorov <mihail@fedorov.net>",
|
|
33
|
+
"license": "MIT",
|
|
34
|
+
"repository": {
|
|
35
|
+
"type": "git",
|
|
36
|
+
"url": "git+https://github.com/kolobus/haveibeenfiltered.git"
|
|
37
|
+
},
|
|
38
|
+
"bugs": {
|
|
39
|
+
"url": "https://github.com/kolobus/haveibeenfiltered/issues"
|
|
40
|
+
},
|
|
41
|
+
"homepage": "https://haveibeenfiltered.com",
|
|
42
|
+
"engines": {
|
|
43
|
+
"node": ">=16.0.0"
|
|
44
|
+
}
|
|
45
|
+
}
|