PinVault/tests/test_security.py
Jarian Cottingham a69c40bc84 Fix startup crashes and harden crypto
- Define missing dict_factory (orphaned fragment left app unable to
  return DB rows; every endpoint crashed on first query)
- Skip backup when PINVAULT_NAS_BACKUP_DIR is empty (os.makedirs('')
  raised in bootstrap and killed gunicorn at import; compose default)
- Generate PINs with secrets.randbelow instead of random.randint
- Per-row random HMAC salts for PINs and recovery codes (single shared
  hardcoded salt defeated the precomputation protection); constant-time
  comparisons; legacy rows keep the historic salt
- Atomic backup restore (temp file + os.replace) to avoid torn DB
- Validate lock_days type, guard backup loop, move re import to module
  level
- Replace weak unittest stubs with 34-test pytest suite (sqlcipher
  shimmed with sqlite3 so tests run without the native lib)
- Add README, LICENSE, pyproject, .env.example; ruff clean
2026-08-20 22:52:28 +00:00

102 lines
3.4 KiB
Python

import inspect
import os
import secrets
import app as pinvault
from conftest import AUTH, create_pin
def test_api_key_length():
assert len(pinvault.API_KEY) >= 32
def test_bootstrap_returns_bool():
assert isinstance(pinvault._bootstrap_ok, bool)
def test_no_sys_exit_in_bootstrap():
src = inspect.getsource(pinvault.bootstrap)
assert "sys.exit" not in src, "bootstrap must not call sys.exit"
def test_nas_backup_configurable():
assert pinvault.NAS_BACKUP_DIR == os.environ.get("PINVAULT_NAS_BACKUP_DIR", "")
def test_hash_val_per_row_salt():
assert pinvault.hash_val("1234", "salt-a") != pinvault.hash_val("1234", "salt-b")
# Legacy rows (salt NULL) keep working with the historic salt.
assert pinvault.hash_val("1234") == pinvault.hash_val("1234", pinvault.LEGACY_SALT)
def test_recover_pin_roundtrip():
for pin in ("0000", "0042", "1000", "9999"):
salt = secrets.token_hex(16)
assert pinvault.recover_pin(pinvault.hash_val(pin, salt), salt) == pin
def test_new_pins_have_per_row_salt(client):
data = create_pin(client, label="salted")
with pinvault.get_conn() as conn:
row = conn.execute("SELECT salt FROM pins WHERE id = ?", (data["id"],)).fetchone()
assert row["salt"], "pin row must carry a salt"
assert row["salt"] != pinvault.LEGACY_SALT
codes = conn.execute(
"SELECT salt FROM recovery_codes WHERE pin_id = ?", (data["id"],)
).fetchall()
assert len(codes) == 4
assert all(c["salt"] for c in codes)
def test_pin_generation_zero_padded(client):
for _ in range(10):
data = create_pin(client)
assert len(data["pin"]) == 4
assert data["pin"] == f"{int(data['pin']):04d}"
def test_rate_limit_blocks_after_max(client):
data = create_pin(client, lock_days=30)
url = f"/api/pins/{data['id']}/access"
for i in range(pinvault._PIN_MAX_ATTEMPTS):
resp = client.post(url, json={}, headers=AUTH)
assert resp.status_code == 423, f"attempt {i + 1} should be allowed"
resp = client.post(url, json={}, headers=AUTH)
assert resp.status_code == 429
def test_rate_limit_resets_on_success(client):
data = create_pin(client, lock_days=0)
url = f"/api/pins/{data['id']}/access"
for _ in range(pinvault._PIN_MAX_ATTEMPTS + 3):
assert client.post(url, json={}, headers=AUTH).status_code == 200
def test_rate_limit_independent_per_pin(client):
a = create_pin(client, label="a", lock_days=30)
b = create_pin(client, label="b", lock_days=30)
for _ in range(pinvault._PIN_MAX_ATTEMPTS + 1):
client.post(f"/api/pins/{a['id']}/access", json={}, headers=AUTH)
resp = client.post(f"/api/pins/{b['id']}/access", json={}, headers=AUTH)
assert resp.status_code == 423
def test_check_pin_rate_limit_unit():
pinvault._pin_attempt_locks.clear()
for i in range(pinvault._PIN_MAX_ATTEMPTS):
allowed, _ = pinvault._check_pin_rate_limit(999)
assert allowed, f"Attempt {i + 1} should be allowed"
allowed, remaining = pinvault._check_pin_rate_limit(999)
assert not allowed
assert remaining > 0
pinvault._reset_pin_rate_limit(999)
allowed, _ = pinvault._check_pin_rate_limit(999)
assert allowed
pinvault._pin_attempt_locks.clear()
def test_backup_dirs_excludes_empty_nas():
if not pinvault.NAS_BACKUP_DIR:
assert pinvault._backup_dirs() == [pinvault.LOCAL_BACKUP_DIR]