Files
Nighthawk42andClaude Opus 4.8 023ccd9f74 Rewrite as a packaged tool with an encrypted vault (v2.0.0)
Replace the single-file v1.3.1 script with a proper src/bnet_auth_tool/
package: config (YAML settings + platformdirs), crypto (scrypt + AES-256-GCM
with a versioned header; legacy PBKDF2 100k/600k still decrypt), storage
(single encrypted vault), fileio (atomic 0600 writes), api, totp, migrate, and
a cli with an interactive menu plus argparse subcommands.

Security/audit fixes: drop catch-all excepts, stop leaking server bodies/tokens
in errors, atomic permission-hardened writes, explicit Ctrl-C handling,
versioned format header, best-effort passphrase scrubbing.

Add packaging (pyproject for uv, organized requirements.txt fallback, uv.lock),
.gitignore, settings.yaml, CLAUDE.md/AGENTS.md, 22 pytest tests, and ruff
config. Online attach/retrieve is preserved but kept labelled unverified.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 19:56:35 -04:00

90 lines
3.2 KiB
Python

"""Crypto round-trips and legacy-format back-compat."""
from __future__ import annotations
import base64
import json
import os
import pytest
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from bnet_auth_tool.crypto import EncryptionManager, looks_encrypted
from bnet_auth_tool.errors import DecryptionError
SAMPLE = {"serial": "US-1234", "deviceSecret": "deadbeef", "nested": {"a": [1, 2, 3]}}
def _legacy_pbkdf2_package(data: dict, passphrase: str, iterations: int, *, include_iters: bool):
"""Reproduce the v1.x encryption package format for back-compat tests."""
salt = os.urandom(16)
nonce = os.urandom(12)
kdf = PBKDF2HMAC(algorithm=hashes.SHA256(), length=32, salt=salt, iterations=iterations)
key = kdf.derive(passphrase.encode("utf-8"))
ciphertext = AESGCM(key).encrypt(nonce, json.dumps(data).encode("utf-8"), None)
package = {
"salt": base64.b64encode(salt).decode(),
"nonce": base64.b64encode(nonce).decode(),
"ciphertext": base64.b64encode(ciphertext).decode(),
}
if include_iters:
package["kdf_iterations"] = iterations
return json.dumps(package).encode("utf-8")
def test_scrypt_round_trip(settings):
mgr = EncryptionManager("correct horse", settings.crypto)
blob = mgr.encrypt(SAMPLE)
assert looks_encrypted(blob)
assert json.loads(blob)["kdf"] == "scrypt"
assert mgr.decrypt(blob) == SAMPLE
def test_pbkdf2_round_trip(settings):
cfg = settings.crypto
object.__setattr__(cfg, "kdf", "pbkdf2")
mgr = EncryptionManager("pw", cfg)
blob = mgr.encrypt(SAMPLE)
assert json.loads(blob)["kdf"] == "pbkdf2"
assert mgr.decrypt(blob) == SAMPLE
def test_wrong_passphrase_raises(settings):
blob = EncryptionManager("right", settings.crypto).encrypt(SAMPLE)
with pytest.raises(DecryptionError):
EncryptionManager("wrong", settings.crypto).decrypt(blob)
def test_legacy_with_explicit_iterations(settings):
# A modern PBKDF2 file embeds its own count; decrypt must honour it
# regardless of the configured default. Small count keeps the test fast.
blob = _legacy_pbkdf2_package(SAMPLE, "pw", 7777, include_iters=True)
mgr = EncryptionManager("pw", settings.crypto)
assert mgr.decrypt(blob) == SAMPLE
def test_legacy_missing_iterations_uses_legacy_count(settings):
# Simulate a pre-v1.3 file: no kdf_iterations field. The manager must assume
# the configured legacy iteration count to decrypt it.
iters = settings.crypto.pbkdf2_legacy_iterations
blob = _legacy_pbkdf2_package(SAMPLE, "pw", iters, include_iters=False)
mgr = EncryptionManager("pw", settings.crypto)
assert mgr.decrypt(blob) == SAMPLE
def test_corrupt_data_raises(settings):
with pytest.raises(DecryptionError):
EncryptionManager("pw", settings.crypto).decrypt(b"not json")
def test_passphrase_scrub(settings):
mgr = EncryptionManager("secret", settings.crypto)
mgr.close()
assert all(b == 0 for b in mgr._passphrase)
def test_looks_encrypted_false_for_plain():
assert not looks_encrypted(json.dumps(SAMPLE).encode())