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 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
+ [![npm version](https://img.shields.io/npm/v/haveibeenfiltered.svg)](https://www.npmjs.com/package/haveibeenfiltered)
4
+ [![license](https://img.shields.io/npm/l/haveibeenfiltered.svg)](https://github.com/kolobus/haveibeenfiltered/blob/main/LICENSE)
5
+ [![node](https://img.shields.io/node/v/haveibeenfiltered.svg)](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
+ });
@@ -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 };
@@ -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
+ }