feat: auth by default, theme toggle, CoderPad startup, review fixes
- Require a session on every route; public routes opt out with @allow_anonymous - Split password hashing and pepper loading into passwords.py - Add a system/light/dark theme toggle backed by light-dark() colors - Ignore stale 401s from an earlier session, PATCH only changed book fields, and block overlapping journal-entry saves - Add bin/start and CoderPad Vite server settings for the pad's start/restart - Rewrite README as a mise onboarding guide; expand .gitignore - Include review-round fixes and tests
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
"""Password hashing (ADR-0001): PBKDF2-SHA256 over an HMAC pepper, plus loading that pepper."""
|
||||
|
||||
import base64
|
||||
import functools
|
||||
import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
ITERATIONS = 600_000
|
||||
SALT_BYTES = 16
|
||||
MIN_PEPPER_CHARS = 32
|
||||
|
||||
|
||||
def hash_password(
|
||||
password: str,
|
||||
pepper: bytes,
|
||||
*,
|
||||
salt: bytes | None = None,
|
||||
iterations: int = ITERATIONS,
|
||||
) -> str:
|
||||
salt = secrets.token_bytes(SALT_BYTES) if salt is None else salt
|
||||
derived = _derive(password, pepper, salt, iterations)
|
||||
return f"pbkdf2_sha256${iterations}${_b64(salt)}${_b64(derived)}"
|
||||
|
||||
|
||||
def verify_password(password: str, stored: str, pepper: bytes) -> bool:
|
||||
_, iterations, salt, expected = stored.split("$")
|
||||
derived = _derive(password, pepper, base64.b64decode(salt), int(iterations))
|
||||
return hmac.compare_digest(derived, base64.b64decode(expected))
|
||||
|
||||
|
||||
def needs_rehash(stored: str) -> bool:
|
||||
return int(stored.split("$")[1]) < ITERATIONS
|
||||
|
||||
|
||||
def _derive(password: str, pepper: bytes, salt: bytes, iterations: int) -> bytes:
|
||||
peppered = hmac.new(pepper, password.encode("utf-8"), hashlib.sha256).digest()
|
||||
return hashlib.pbkdf2_hmac("sha256", peppered, salt, iterations)
|
||||
|
||||
|
||||
def _b64(raw: bytes) -> str:
|
||||
return base64.b64encode(raw).decode()
|
||||
|
||||
|
||||
@functools.cache
|
||||
def dummy_hash() -> str:
|
||||
# Verified against for unknown usernames so their response time matches a real account.
|
||||
return hash_password(secrets.token_urlsafe(16), b"\0" * MIN_PEPPER_CHARS)
|
||||
|
||||
|
||||
def load_pepper(pepper_file: Path) -> bytes:
|
||||
from_env = os.environ.get("PASSWORD_PEPPER")
|
||||
if from_env is not None:
|
||||
if len(from_env) < MIN_PEPPER_CHARS:
|
||||
raise RuntimeError(
|
||||
f"PASSWORD_PEPPER must be at least {MIN_PEPPER_CHARS} characters. "
|
||||
'Generate one with: python -c "import secrets; print(secrets.token_hex(32))"'
|
||||
)
|
||||
return from_env.encode()
|
||||
try:
|
||||
fd = os.open(pepper_file, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||
except FileExistsError:
|
||||
pass
|
||||
else:
|
||||
with os.fdopen(fd, "w") as f:
|
||||
f.write(secrets.token_hex(32))
|
||||
log.warning(
|
||||
"PASSWORD_PEPPER is not set; using %s. Development only: production must supply the pepper "
|
||||
"from a secrets manager.",
|
||||
pepper_file,
|
||||
)
|
||||
pepper = pepper_file.read_text().strip()
|
||||
if len(pepper) < MIN_PEPPER_CHARS:
|
||||
raise RuntimeError(
|
||||
f"{pepper_file} holds fewer than {MIN_PEPPER_CHARS} characters. Set PASSWORD_PEPPER, or delete "
|
||||
"the file to regenerate it (existing passwords will stop verifying)."
|
||||
)
|
||||
return pepper.encode()
|
||||
Reference in New Issue
Block a user