Skip to content

Commit 0917fcf

Browse files
Den-Secclaude
andcommitted
F7: online HIBP k-anonymity checker + capability map
- src/tools/pwhibp_check.py: standalone admin/dev tool (stdlib only) that checks a password against HIBP's online Pwned Passwords API using the k-anonymity range model (only the 5-char SHA-1 prefix leaves the host, with response padding). Hidden prompt by default; --bloom cross-checks a local breach.bloom against online truth. Explicitly NOT used by the in-LSASS filter, which stays strictly offline. - README: "Capability -> code" table mapping every feature to its implementation, plus a status bump to v0.1.0. Verified locally: the tool's offline Bloom reader agrees with the core (password/letmein present, strong password absent). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 59d3c25 commit 0917fcf

2 files changed

Lines changed: 155 additions & 4 deletions

File tree

‎README.md‎

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,9 @@ passwords that fail policy:
1818
- **GPO-friendly deployment** - registry-driven configuration (ADMX template), x64,
1919
with install/rollback scripts.
2020

21-
> ⚠️ **Project status: under active development.** The build, test and CI scaffolding is in
22-
> place; the validation engine, LSASS shim and deployment tooling are being implemented in
23-
> phases (see [Roadmap](#roadmap)). Do not deploy to a production Domain Controller.
21+
> **Status: v0.1.0.** The validation core (unit-tested in CI), the LSASS shim, the offline
22+
> Bloom pipeline and the deployment tooling are all in place. Treat it like any new LSASS
23+
> component: validate on a **lab** Domain Controller before production.
2424
2525
## Why this design
2626

@@ -95,9 +95,23 @@ ADMX/ADML template ships in [`deploy/`](deploy/).
9595
| `src/dll/` | The `LSASS`-resident shim (the three LSA exports) |
9696
| `tests/` | GoogleTest suite over `pwfilter_core` (run via CTest) |
9797
| `scripts/build_bloom.py` | Offline builder: HIBP dump → Bloom artifact |
98+
| `src/tools/pwhibp_check.py` | Admin/dev tool: online HIBP k-anonymity check (out of LSASS) |
9899
| `deploy/` | Install/uninstall scripts + ADMX template (GPO) |
99100
| `docs/` | Architecture, deployment and configuration guides |
100101

102+
## Capability → code
103+
104+
| Capability | Where |
105+
|------------|-------|
106+
| LSA password filter for Active Directory, in C++ | `src/dll/dllmain.cpp` (3 LSA exports) + `src/core/` (C++17) |
107+
| Blocks compromised passwords, offline breach-list (HIBP) | `src/core/breach_bloom.cpp`, `src/core/bloom.cpp`, `scripts/build_bloom.py` |
108+
| Have I Been Pwned k-anonymity model | `src/tools/pwhibp_check.py` (online range query); offline corpus is the same dataset |
109+
| Custom complexity rules | `src/core/complexity.cpp` (length, classes, keyboard walks, sequences, repeats, identity) |
110+
| Company blacklist | `src/core/blacklist.cpp` + `blacklist.txt` / `company_terms.txt` |
111+
| Event logging (metadata only) | `src/dll/eventlog.cpp` + `src/dll/messages.mc` |
112+
| GPO-friendly deployment | `deploy/*.ps1` + `deploy/PasswordFilter.admx` / `.adml` |
113+
| Open-source | this repository, [MIT](LICENSE) |
114+
101115
## Roadmap
102116

103117
- [x] **F0** - CMake + CI scaffolding, three targets, smoke test
@@ -107,7 +121,7 @@ ADMX/ADML template ships in [`deploy/`](deploy/).
107121
- [x] **F4** - Python Bloom builder + sample data + cross-language format test
108122
- [x] **F5** - LSASS shim: fail-safe `dllmain`, registry/file config, Event Log
109123
- [x] **F6** - Deployment scripts, ADMX, full documentation
110-
- [ ] **F7** - Optional online HIBP k-anonymity checker (admin tool, out of LSASS)
124+
- [x] **F7** - Optional online HIBP k-anonymity checker (admin tool, out of LSASS)
111125

112126
## Security
113127

‎src/tools/pwhibp_check.py‎

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
#!/usr/bin/env python3
2+
"""Online HIBP k-anonymity password checker - an ADMIN/DEV tool, NOT part of the filter.
3+
4+
The in-LSASS DLL is strictly OFFLINE: it never makes a network call. This standalone
5+
utility queries Have I Been Pwned's online Pwned Passwords API using the **k-anonymity**
6+
range model - it sends only the first 5 hex characters of the password's SHA-1 hash and
7+
checks the returned suffixes locally, so the full hash (and the password) never leave the
8+
host. Use it to:
9+
10+
* spot-check individual passwords against the live corpus, and
11+
* validate a locally-built breach.bloom against online truth (--bloom).
12+
13+
Standard library only.
14+
15+
Examples
16+
--------
17+
python pwhibp_check.py # prompt (hidden input), check one password
18+
python pwhibp_check.py --stdin # one password per line on stdin
19+
python pwhibp_check.py --bloom breach.bloom # also query the local Bloom and compare
20+
"""
21+
22+
import argparse
23+
import getpass
24+
import hashlib
25+
import struct
26+
import sys
27+
import urllib.error
28+
import urllib.request
29+
30+
API = "https://api.pwnedpasswords.com/range/"
31+
USER_AGENT = "PasswordFilterDLL-pwhibp-check"
32+
MASK64 = (1 << 64) - 1
33+
BLOOM_MAGIC = b"PWBLOOM1"
34+
BLOOM_HEADER = 64
35+
36+
37+
def hibp_count(password, padding=True):
38+
"""Return how many times the password appears in HIBP (0 = not found), via k-anonymity."""
39+
digest = hashlib.sha1(password.encode("utf-8")).hexdigest().upper()
40+
prefix, suffix = digest[:5], digest[5:]
41+
headers = {"User-Agent": USER_AGENT}
42+
if padding:
43+
headers["Add-Padding"] = "true" # pad the response for extra privacy
44+
req = urllib.request.Request(API + prefix, headers=headers)
45+
with urllib.request.urlopen(req, timeout=20) as resp:
46+
body = resp.read().decode("utf-8")
47+
for line in body.splitlines():
48+
sfx, _, count = line.partition(":")
49+
if sfx == suffix:
50+
try:
51+
n = int(count)
52+
except ValueError:
53+
n = 1
54+
return n # padded entries have count 0 and never match a real suffix
55+
return 0
56+
57+
58+
class LocalBloom:
59+
"""Minimal reader for the breach.bloom format (see scripts/build_bloom.py)."""
60+
61+
def __init__(self, path):
62+
with open(path, "rb") as f:
63+
self.data = f.read()
64+
if len(self.data) < BLOOM_HEADER or self.data[:8] != BLOOM_MAGIC:
65+
raise ValueError("not a PWBLOOM1 file")
66+
_, ver, self.k, self.m, _, scheme = struct.unpack("<8sIIQQI", self.data[:36])
67+
if ver != 1 or scheme != 1 or self.m % 8 != 0:
68+
raise ValueError("unsupported bloom header")
69+
if len(self.data) != BLOOM_HEADER + self.m // 8:
70+
raise ValueError("bloom size mismatch")
71+
self.bits = self.data[BLOOM_HEADER:]
72+
73+
def maybe_contains(self, password):
74+
d = hashlib.sha1(password.encode("utf-8")).digest()
75+
h1 = int.from_bytes(d[0:8], "little")
76+
h2 = int.from_bytes(d[8:16], "little")
77+
for i in range(self.k):
78+
idx = ((h1 + i * h2) & MASK64) % self.m
79+
if not (self.bits[idx >> 3] & (1 << (idx & 7))):
80+
return False
81+
return True
82+
83+
84+
def passwords_from(args):
85+
if args.stdin:
86+
for line in sys.stdin:
87+
line = line.rstrip("\r\n")
88+
if line:
89+
yield line
90+
elif args.password:
91+
for p in args.password:
92+
yield p
93+
else:
94+
yield getpass.getpass("Password (hidden): ")
95+
96+
97+
def main(argv=None):
98+
ap = argparse.ArgumentParser(description="Online HIBP k-anonymity checker (admin/dev tool).")
99+
ap.add_argument("password", nargs="*", help="password(s); omit to be prompted (hidden)")
100+
ap.add_argument("--stdin", action="store_true", help="read one password per line from stdin")
101+
ap.add_argument("--bloom", metavar="FILE", help="also query a local breach.bloom and compare")
102+
ap.add_argument("--no-padding", action="store_true", help="disable HIBP response padding")
103+
args = ap.parse_args(argv)
104+
105+
if args.password:
106+
print("warning: passwords on the command line may be saved in shell history.",
107+
file=sys.stderr)
108+
109+
bloom = None
110+
if args.bloom:
111+
try:
112+
bloom = LocalBloom(args.bloom)
113+
except (OSError, ValueError) as e:
114+
print(f"error: cannot load bloom: {e}", file=sys.stderr)
115+
return 2
116+
117+
rc = 0
118+
for pw in passwords_from(args):
119+
try:
120+
n = hibp_count(pw, padding=not args.no_padding)
121+
except urllib.error.URLError as e:
122+
print(f"error: HIBP request failed: {e}", file=sys.stderr)
123+
return 3
124+
online = f"BREACHED ({n} times)" if n else "not found online"
125+
if bloom is not None:
126+
local = "present" if bloom.maybe_contains(pw) else "absent"
127+
agree = "" if ((n > 0) == bloom.maybe_contains(pw)) else " [MISMATCH]"
128+
print(f"online: {online:24} | local bloom: {local}{agree}")
129+
else:
130+
print(online)
131+
if n:
132+
rc = 1 # at least one breached
133+
return rc
134+
135+
136+
if __name__ == "__main__":
137+
sys.exit(main())

0 commit comments

Comments
 (0)