blackhole-sec 0.1.0__tar.gz

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.
Files changed (29) hide show
  1. blackhole_sec-0.1.0/LICENSE +21 -0
  2. blackhole_sec-0.1.0/PKG-INFO +173 -0
  3. blackhole_sec-0.1.0/README.md +137 -0
  4. blackhole_sec-0.1.0/pyproject.toml +58 -0
  5. blackhole_sec-0.1.0/setup.cfg +4 -0
  6. blackhole_sec-0.1.0/src/blackhole_sec/__init__.py +3 -0
  7. blackhole_sec-0.1.0/src/blackhole_sec/bundle/__init__.py +154 -0
  8. blackhole_sec-0.1.0/src/blackhole_sec/check/__init__.py +6 -0
  9. blackhole_sec-0.1.0/src/blackhole_sec/check/features.py +272 -0
  10. blackhole_sec-0.1.0/src/blackhole_sec/check/mitre.py +31 -0
  11. blackhole_sec-0.1.0/src/blackhole_sec/check/report.py +42 -0
  12. blackhole_sec-0.1.0/src/blackhole_sec/check/scorer.py +31 -0
  13. blackhole_sec-0.1.0/src/blackhole_sec/cli.py +299 -0
  14. blackhole_sec-0.1.0/src/blackhole_sec/shred/__init__.py +5 -0
  15. blackhole_sec-0.1.0/src/blackhole_sec/shred/cleaner.py +70 -0
  16. blackhole_sec-0.1.0/src/blackhole_sec/shred/metadata.py +49 -0
  17. blackhole_sec-0.1.0/src/blackhole_sec/vault/__init__.py +26 -0
  18. blackhole_sec-0.1.0/src/blackhole_sec/vault/crypto.py +44 -0
  19. blackhole_sec-0.1.0/src/blackhole_sec/vault/generator.py +24 -0
  20. blackhole_sec-0.1.0/src/blackhole_sec/vault/store.py +170 -0
  21. blackhole_sec-0.1.0/src/blackhole_sec.egg-info/PKG-INFO +173 -0
  22. blackhole_sec-0.1.0/src/blackhole_sec.egg-info/SOURCES.txt +27 -0
  23. blackhole_sec-0.1.0/src/blackhole_sec.egg-info/dependency_links.txt +1 -0
  24. blackhole_sec-0.1.0/src/blackhole_sec.egg-info/entry_points.txt +3 -0
  25. blackhole_sec-0.1.0/src/blackhole_sec.egg-info/requires.txt +13 -0
  26. blackhole_sec-0.1.0/src/blackhole_sec.egg-info/top_level.txt +1 -0
  27. blackhole_sec-0.1.0/tests/test_check.py +52 -0
  28. blackhole_sec-0.1.0/tests/test_shred_bundle.py +89 -0
  29. blackhole_sec-0.1.0/tests/test_vault.py +43 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Satin Networks
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.
@@ -0,0 +1,173 @@
1
+ Metadata-Version: 2.4
2
+ Name: blackhole-sec
3
+ Version: 0.1.0
4
+ Summary: Offline phishing checks, encrypted vault, metadata shredder and secure .bhb archives.
5
+ Author: Satin Networks
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/satin-networks/blackhole-sec
8
+ Project-URL: Repository, https://github.com/satin-networks/blackhole-sec
9
+ Project-URL: Issues, https://github.com/satin-networks/blackhole-sec/issues
10
+ Keywords: security,opsec,phishing,vault,shredder,privacy,offline
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: End Users/Desktop
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Topic :: Security
19
+ Classifier: Topic :: Security :: Cryptography
20
+ Requires-Python: >=3.11
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: click>=8.1
24
+ Requires-Dist: rich>=13.0
25
+ Requires-Dist: cryptography>=42.0
26
+ Requires-Dist: argon2-cffi>=23.0
27
+ Requires-Dist: Pillow>=10.0
28
+ Requires-Dist: pyyaml>=6.0
29
+ Requires-Dist: pydantic>=2.0
30
+ Provides-Extra: dev
31
+ Requires-Dist: pytest>=8.0; extra == "dev"
32
+ Requires-Dist: ruff>=0.4; extra == "dev"
33
+ Requires-Dist: build; extra == "dev"
34
+ Requires-Dist: twine; extra == "dev"
35
+ Dynamic: license-file
36
+
37
+ # blackhole-sec
38
+
39
+ A small offline toolkit I actually use: check sketchy links, keep passwords
40
+ in an encrypted vault, strip photo metadata before posting, and pack
41
+ directories into encrypted `.bhb` files instead of zip.
42
+
43
+ No accounts. No network calls. Nothing leaves your machine.
44
+
45
+ Full write-ups for each tool live here:
46
+ **https://satin-networks.github.io/blackhole-sec/**
47
+
48
+ ```bash
49
+ pip install blackhole-sec
50
+ ```
51
+
52
+ ```bash
53
+ blackhole check "http://secure-paypal-login.tk/free-nitro"
54
+ blackhole vault init
55
+ blackhole shred analyze photo.jpg
56
+ blackhole bundle create ./mydir ./backup.bhb
57
+ ```
58
+
59
+ ## The four tools
60
+
61
+ ### 1. check - is this link a trap?
62
+
63
+ Paste a Discord / email lure and get a straight answer. Everything runs
64
+ locally, the URL is never fetched.
65
+
66
+ ```bash
67
+ blackhole check "http://secure.paypal.com.evil.tk/login?redirect=http://evil.com"
68
+ # MALICIOUS 68/100 (HIGH) hxxp[:]//secure[.]paypal[.]com[.]evil[.]tk/login...
69
+ # +18 brand_impersonation: brand=paypal host=secure.paypal.com.evil.tk
70
+ # +10 suspicious_tld: .tk
71
+ # ...
72
+
73
+ blackhole check -f urls.txt --json > report.json
74
+ echo "http://paypal-secure.tk/login" | blackhole check --file -
75
+ ```
76
+
77
+ Scoring is plain and explainable: each signal adds points, 0-20 is BENIGN,
78
+ 21-49 SUSPICIOUS, 50+ MALICIOUS. Output is defanged (`hxxp[://]`) so you
79
+ can't click it by accident. Batch mode and `--threshold` fit nicely in scripts.
80
+
81
+ ### 2. vault - passwords on your own disk
82
+
83
+ One encrypted file, one master password. Argon2id turns your master into a
84
+ key, AES-256-GCM does the rest. I never store the master anywhere.
85
+
86
+ ```bash
87
+ blackhole vault init --vault ~/.blackhole/vault.db
88
+ blackhole vault set github --username alice --generate 24
89
+ blackhole vault get github --show
90
+ blackhole vault list
91
+ blackhole vault audit
92
+ blackhole vault gen --length 24
93
+ ```
94
+
95
+ The file is chmod 0600. Every command locks when it finishes. `audit`
96
+ calls out reused, short, and stale passwords with a 0-100 score.
97
+
98
+ Lose the master and the vault is gone - there is no reset, by design.
99
+
100
+ ### 3. shred - clean it before you post it
101
+
102
+ Photos carry GPS, camera model, and timestamps. `analyze` shows what's in
103
+ there, `clean` writes a `*.cleaned` copy with it stripped (your original
104
+ stays untouched), `verify` passes or fails for scripts, and `shred`
105
+ overwrites and deletes for real.
106
+
107
+ ```bash
108
+ blackhole shred analyze photo.jpg
109
+ blackhole shred clean photo.jpg
110
+ blackhole shred verify photo.cleaned.jpg; echo $?
111
+ blackhole shred shred secret.txt --passes 7 --yes
112
+ ```
113
+
114
+ ### 4. bundle - encrypted archives that aren't zip
115
+
116
+ I got tired of zip passwords cracking in minutes and filenames leaking even
117
+ with AES. `.bhb` packs a directory to tar.gz and encrypts the whole blob.
118
+ Filenames, sizes, everything is hidden. Only blackhole opens it.
119
+
120
+ ```bash
121
+ blackhole bundle create ./photos ./photos.bhb
122
+ blackhole bundle create ./photos ./photos.bhb --no-password # writes photos.bhb.key
123
+ blackhole bundle extract ./photos.bhb ./restored --password
124
+ ```
125
+
126
+ Wrong password or a tampered file just refuses to open. Extraction rejects
127
+ absolute paths, `..`, and symlinks.
128
+
129
+ ```bash
130
+ blackhole intake "http://evil.tk/login" ./photo.jpg # link + file in one go
131
+ ```
132
+
133
+ ## Install
134
+
135
+ Python 3.11 or newer.
136
+
137
+ ```bash
138
+ pip install blackhole-sec
139
+ pip install -e ".[dev]" # hacking on it
140
+ ```
141
+
142
+ Depends on click, rich, cryptography, argon2-cffi, Pillow, pyyaml and
143
+ pydantic. That's it.
144
+
145
+ ## Layout
146
+
147
+ ```
148
+ src/blackhole_sec/
149
+ check/ link scoring, no network
150
+ vault/ encrypted store + generator
151
+ shred/ metadata + secure delete
152
+ bundle/ .bhb create / extract
153
+ cli.py everything wired together
154
+ tests/ offline fixtures, no network
155
+ docs/ longer write-ups for each tool
156
+ ```
157
+
158
+ ## Security notes
159
+
160
+ Short version is in `SECURITY.md`. The honest version:
161
+
162
+ - Standard primitives only (Argon2id, AES-GCM, `secrets`). Nothing home-rolled.
163
+ - `check` never touches the network. Input capped at 2048 chars.
164
+ - Secrets live in 0600 files. Keys are wiped best-effort after use.
165
+ - Not audited. Don't bet your life on any single tool, including this one.
166
+
167
+ Found something? Open a private advisory with version, OS, and steps.
168
+ Please don't post vault files or bundles anywhere public.
169
+
170
+ ## License
171
+
172
+ MIT - see LICENSE. Built by Satin Networks alongside
173
+ [wisp](https://github.com/satin-networks/wisp), our local WireGuard manager.
@@ -0,0 +1,137 @@
1
+ # blackhole-sec
2
+
3
+ A small offline toolkit I actually use: check sketchy links, keep passwords
4
+ in an encrypted vault, strip photo metadata before posting, and pack
5
+ directories into encrypted `.bhb` files instead of zip.
6
+
7
+ No accounts. No network calls. Nothing leaves your machine.
8
+
9
+ Full write-ups for each tool live here:
10
+ **https://satin-networks.github.io/blackhole-sec/**
11
+
12
+ ```bash
13
+ pip install blackhole-sec
14
+ ```
15
+
16
+ ```bash
17
+ blackhole check "http://secure-paypal-login.tk/free-nitro"
18
+ blackhole vault init
19
+ blackhole shred analyze photo.jpg
20
+ blackhole bundle create ./mydir ./backup.bhb
21
+ ```
22
+
23
+ ## The four tools
24
+
25
+ ### 1. check - is this link a trap?
26
+
27
+ Paste a Discord / email lure and get a straight answer. Everything runs
28
+ locally, the URL is never fetched.
29
+
30
+ ```bash
31
+ blackhole check "http://secure.paypal.com.evil.tk/login?redirect=http://evil.com"
32
+ # MALICIOUS 68/100 (HIGH) hxxp[:]//secure[.]paypal[.]com[.]evil[.]tk/login...
33
+ # +18 brand_impersonation: brand=paypal host=secure.paypal.com.evil.tk
34
+ # +10 suspicious_tld: .tk
35
+ # ...
36
+
37
+ blackhole check -f urls.txt --json > report.json
38
+ echo "http://paypal-secure.tk/login" | blackhole check --file -
39
+ ```
40
+
41
+ Scoring is plain and explainable: each signal adds points, 0-20 is BENIGN,
42
+ 21-49 SUSPICIOUS, 50+ MALICIOUS. Output is defanged (`hxxp[://]`) so you
43
+ can't click it by accident. Batch mode and `--threshold` fit nicely in scripts.
44
+
45
+ ### 2. vault - passwords on your own disk
46
+
47
+ One encrypted file, one master password. Argon2id turns your master into a
48
+ key, AES-256-GCM does the rest. I never store the master anywhere.
49
+
50
+ ```bash
51
+ blackhole vault init --vault ~/.blackhole/vault.db
52
+ blackhole vault set github --username alice --generate 24
53
+ blackhole vault get github --show
54
+ blackhole vault list
55
+ blackhole vault audit
56
+ blackhole vault gen --length 24
57
+ ```
58
+
59
+ The file is chmod 0600. Every command locks when it finishes. `audit`
60
+ calls out reused, short, and stale passwords with a 0-100 score.
61
+
62
+ Lose the master and the vault is gone - there is no reset, by design.
63
+
64
+ ### 3. shred - clean it before you post it
65
+
66
+ Photos carry GPS, camera model, and timestamps. `analyze` shows what's in
67
+ there, `clean` writes a `*.cleaned` copy with it stripped (your original
68
+ stays untouched), `verify` passes or fails for scripts, and `shred`
69
+ overwrites and deletes for real.
70
+
71
+ ```bash
72
+ blackhole shred analyze photo.jpg
73
+ blackhole shred clean photo.jpg
74
+ blackhole shred verify photo.cleaned.jpg; echo $?
75
+ blackhole shred shred secret.txt --passes 7 --yes
76
+ ```
77
+
78
+ ### 4. bundle - encrypted archives that aren't zip
79
+
80
+ I got tired of zip passwords cracking in minutes and filenames leaking even
81
+ with AES. `.bhb` packs a directory to tar.gz and encrypts the whole blob.
82
+ Filenames, sizes, everything is hidden. Only blackhole opens it.
83
+
84
+ ```bash
85
+ blackhole bundle create ./photos ./photos.bhb
86
+ blackhole bundle create ./photos ./photos.bhb --no-password # writes photos.bhb.key
87
+ blackhole bundle extract ./photos.bhb ./restored --password
88
+ ```
89
+
90
+ Wrong password or a tampered file just refuses to open. Extraction rejects
91
+ absolute paths, `..`, and symlinks.
92
+
93
+ ```bash
94
+ blackhole intake "http://evil.tk/login" ./photo.jpg # link + file in one go
95
+ ```
96
+
97
+ ## Install
98
+
99
+ Python 3.11 or newer.
100
+
101
+ ```bash
102
+ pip install blackhole-sec
103
+ pip install -e ".[dev]" # hacking on it
104
+ ```
105
+
106
+ Depends on click, rich, cryptography, argon2-cffi, Pillow, pyyaml and
107
+ pydantic. That's it.
108
+
109
+ ## Layout
110
+
111
+ ```
112
+ src/blackhole_sec/
113
+ check/ link scoring, no network
114
+ vault/ encrypted store + generator
115
+ shred/ metadata + secure delete
116
+ bundle/ .bhb create / extract
117
+ cli.py everything wired together
118
+ tests/ offline fixtures, no network
119
+ docs/ longer write-ups for each tool
120
+ ```
121
+
122
+ ## Security notes
123
+
124
+ Short version is in `SECURITY.md`. The honest version:
125
+
126
+ - Standard primitives only (Argon2id, AES-GCM, `secrets`). Nothing home-rolled.
127
+ - `check` never touches the network. Input capped at 2048 chars.
128
+ - Secrets live in 0600 files. Keys are wiped best-effort after use.
129
+ - Not audited. Don't bet your life on any single tool, including this one.
130
+
131
+ Found something? Open a private advisory with version, OS, and steps.
132
+ Please don't post vault files or bundles anywhere public.
133
+
134
+ ## License
135
+
136
+ MIT - see LICENSE. Built by Satin Networks alongside
137
+ [wisp](https://github.com/satin-networks/wisp), our local WireGuard manager.
@@ -0,0 +1,58 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "blackhole-sec"
7
+ version = "0.1.0"
8
+ description = "Offline phishing checks, encrypted vault, metadata shredder and secure .bhb archives."
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.11"
12
+ authors = [{ name = "Satin Networks" }]
13
+ keywords = ["security", "opsec", "phishing", "vault", "shredder", "privacy", "offline"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Environment :: Console",
17
+ "Intended Audience :: End Users/Desktop",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Operating System :: OS Independent",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Topic :: Security",
23
+ "Topic :: Security :: Cryptography",
24
+ ]
25
+ dependencies = [
26
+ "click>=8.1",
27
+ "rich>=13.0",
28
+ "cryptography>=42.0",
29
+ "argon2-cffi>=23.0",
30
+ "Pillow>=10.0",
31
+ "pyyaml>=6.0",
32
+ "pydantic>=2.0",
33
+ ]
34
+
35
+ [project.optional-dependencies]
36
+ dev = ["pytest>=8.0", "ruff>=0.4", "build", "twine"]
37
+
38
+ [project.scripts]
39
+ blackhole = "blackhole_sec.cli:main"
40
+ blackhole-sec = "blackhole_sec.cli:main"
41
+
42
+ [project.urls]
43
+ Homepage = "https://github.com/satin-networks/blackhole-sec"
44
+ Repository = "https://github.com/satin-networks/blackhole-sec"
45
+ Issues = "https://github.com/satin-networks/blackhole-sec/issues"
46
+
47
+ [tool.setuptools.packages.find]
48
+ where = ["src"]
49
+
50
+ [tool.ruff]
51
+ target-version = "py311"
52
+ line-length = 100
53
+
54
+ [tool.ruff.lint]
55
+ ignore = ["BLE001", "S110", "S101", "PLR2004", "EM101", "EM102", "TRY003"]
56
+
57
+ [tool.pytest.ini_options]
58
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """Blackhole-sec: offline personal opsec toolkit."""
2
+ __version__ = "0.1.0"
3
+ __all__ = ["__version__"]
@@ -0,0 +1,154 @@
1
+ """Secure directory archives (.bhb).
2
+
3
+ Plain zip has two problems I kept running into: weak passwords crack fast,
4
+ and filenames sit in the clear even with AES. .bhb packs a directory to
5
+ tar.gz, then encrypts the whole thing with Argon2id -> AES-256-GCM.
6
+ Only blackhole can open it, which is the point.
7
+
8
+ Layout v1, big-endian:
9
+ BHB1 | kdf_id | t,m,p | salt | nonce | ct_len | ct
10
+ ct holds the tar.gz. The header is used as AAD.
11
+
12
+ Two modes: password, or no password (writes a .key file you need to keep).
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import io
17
+ import os
18
+ import struct
19
+ import tarfile
20
+ from pathlib import Path
21
+
22
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
23
+
24
+ from ..vault.crypto import DEFAULT_M, DEFAULT_P, DEFAULT_T, b64d, b64e, derive_key, generate_salt
25
+
26
+ MAGIC = b"BHB1"
27
+ VERSION_KDF_ARGON = 1
28
+ VERSION_KDF_NONE = 0
29
+ HEADER_FMT = ">4sBIIIH" # magic, kdf_id, t, m, p, salt_len
30
+ NONCE_LEN = 12
31
+
32
+
33
+ def _build_tar(src_dir: Path) -> bytes:
34
+ buf = io.BytesIO()
35
+ with tarfile.open(fileobj=buf, mode="w:gz") as tf:
36
+ for root, _, files in os.walk(src_dir):
37
+ for fn in sorted(files):
38
+ full = Path(root) / fn
39
+ arc = full.relative_to(src_dir).as_posix()
40
+ # ZipSlip-class defense at build time
41
+ if arc.startswith("/") or ".." in Path(arc).parts:
42
+ raise ValueError(f"unsafe path: {arc}")
43
+ ti = tf.gettarinfo(str(full), arcname=arc)
44
+ ti.uid = ti.gid = 0
45
+ ti.uname = ti.gname = ""
46
+ ti.mtime = int(ti.mtime)
47
+ with open(full, "rb") as f:
48
+ tf.addfile(ti, f)
49
+ return buf.getvalue()
50
+
51
+
52
+ def _safe_extract(tar_bytes: bytes, dest: Path) -> list[str]:
53
+ dest.mkdir(parents=True, exist_ok=True)
54
+ out: list[str] = []
55
+ buf = io.BytesIO(tar_bytes)
56
+ with tarfile.open(fileobj=buf, mode="r:gz") as tf:
57
+ for m in tf.getmembers():
58
+ # Reject everything but regular files/dirs (no symlinks, fifos, devices)
59
+ if not (m.isfile() or m.isdir()):
60
+ continue
61
+ p = Path(m.name)
62
+ if m.name.startswith("/") or ".." in p.parts or m.name.startswith("~"):
63
+ continue
64
+ target = dest / p
65
+ if m.isdir():
66
+ target.mkdir(parents=True, exist_ok=True)
67
+ else:
68
+ target.parent.mkdir(parents=True, exist_ok=True)
69
+ f = tf.extractfile(m)
70
+ if f is None:
71
+ continue
72
+ target.write_bytes(f.read())
73
+ try:
74
+ os.chmod(target, m.mode & 0o777)
75
+ except Exception:
76
+ pass
77
+ out.append(m.name)
78
+ return out
79
+
80
+
81
+ def create_bundle(src_dir: str | Path, out: str | Path, password: str | None,
82
+ t: int = DEFAULT_T, m: int = DEFAULT_M, p: int = DEFAULT_P) -> dict:
83
+ src = Path(src_dir)
84
+ dst = Path(out)
85
+ if not src.is_dir():
86
+ raise ValueError("src must be a directory")
87
+ tar_bytes = _build_tar(src)
88
+ if password:
89
+ salt = generate_salt()
90
+ key = derive_key(password, salt, t, m, p)
91
+ kdf_id = VERSION_KDF_ARGON
92
+ keyfile = None
93
+ else:
94
+ salt = generate_salt(8) # header salt marker only
95
+ key = os.urandom(32)
96
+ kdf_id = VERSION_KDF_NONE
97
+ keyfile = str(dst) + ".key"
98
+ Path(keyfile).write_text(b64e(key))
99
+ os.chmod(keyfile, 0o600)
100
+ nonce = os.urandom(NONCE_LEN)
101
+ header = struct.pack(HEADER_FMT, MAGIC, kdf_id, t, m, p, len(salt)) + salt + nonce
102
+ aad = header # authenticate everything before ct
103
+ ct = AESGCM(key).encrypt(nonce, tar_bytes, aad)
104
+ # wipe key material best-effort
105
+ try:
106
+ for i in range(len(key)):
107
+ key[i] = 0 # type: ignore
108
+ except Exception:
109
+ pass
110
+ with open(dst, "wb") as f:
111
+ f.write(header + struct.pack(">Q", len(ct)) + ct)
112
+ os.chmod(dst, 0o600)
113
+ return {"bundle": str(dst), "keyfile": keyfile, "bytes_in": len(tar_bytes),
114
+ "bytes_out": dst.stat().st_size, "kdf": "argon2id" if password else "keyfile"}
115
+
116
+
117
+ def extract_bundle(bundle: str | Path, dest: str | Path, password: str | None = None,
118
+ keyfile: str | Path | None = None) -> list[str]:
119
+ data = Path(bundle).read_bytes()
120
+ hlen = struct.calcsize(HEADER_FMT)
121
+ magic, kdf_id, t, m, p, slen = struct.unpack(HEADER_FMT, data[:hlen])
122
+ if magic != MAGIC:
123
+ raise ValueError("not a .bhb bundle (bad magic)")
124
+ off = hlen
125
+ salt = data[off:off + slen]
126
+ off += slen
127
+ nonce = data[off:off + NONCE_LEN]
128
+ off += NONCE_LEN
129
+ (ct_len,) = struct.unpack(">Q", data[off:off + 8])
130
+ off += 8
131
+ ct = data[off:off + ct_len]
132
+ if len(ct) != ct_len:
133
+ raise ValueError("truncated bundle")
134
+ header = data[:off - 8] # up to nonce inclusive == AAD
135
+ if kdf_id == VERSION_KDF_ARGON:
136
+ if not password:
137
+ raise ValueError("bundle needs a password (--password)")
138
+ key = derive_key(password, salt, t, m, p)
139
+ elif kdf_id == VERSION_KDF_NONE:
140
+ kf = keyfile or (str(bundle) + ".key")
141
+ key = b64d(Path(kf).read_text().strip())
142
+ else:
143
+ raise ValueError(f"unsupported kdf_id {kdf_id}")
144
+ try:
145
+ tar_bytes = AESGCM(key).decrypt(nonce, ct, header)
146
+ except Exception as e:
147
+ raise ValueError("decryption failed: wrong password/keyfile or tampered bundle") from e
148
+ finally:
149
+ try:
150
+ for i in range(len(key)):
151
+ key[i] = 0 # type: ignore
152
+ except Exception:
153
+ pass
154
+ return _safe_extract(tar_bytes, Path(dest))
@@ -0,0 +1,6 @@
1
+ """Check subpackage: offline phishing URL analysis."""
2
+ from .features import FeatureResult, analyze_url
3
+ from .report import defang, format_text, to_dict
4
+ from .scorer import score_features, verdict_for_score
5
+
6
+ __all__ = ["FeatureResult", "analyze_url", "defang", "format_text", "score_features", "to_dict", "verdict_for_score"]