Files
siftlode/backend/app/security.py
T
peter 90567718b2 feat(files): share-link tokens are kept only hashed and encrypted, and every link change is audited
A /watch link's token is the credential, and it sat in the database as written. Migration 0107
replaces the column with the token's SHA-256 (the lookup) and a Fernet-encrypted copy (so the owner
can copy the same link again); every existing address keeps working, and the downgrade decrypts
them back. Without a copy this key can read, a link still opens but its address cannot be shown:
"Copy link" then takes the newest link that has one or makes a new one, and the Share dialog says
so in that row instead of a broken URL. Making, changing and revoking a link are audited, never
with its token or password.
2026-10-15 01:02:29 +02:00

91 lines
3.3 KiB
Python

import hashlib
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
from cryptography.fernet import Fernet
from app import logs
from app.config import settings
log = logs.get_logger(__name__)
# argon2id with the library defaults (sensible memory/time cost). One shared hasher instance.
_ph = PasswordHasher()
def hash_token(raw: str) -> str:
"""SHA-256 hex digest for storing single-use tokens (email-link tokens, the setup token)
by hash rather than in cleartext. Not a password hash — these are high-entropy randoms."""
return hashlib.sha256(raw.encode()).hexdigest()
def hash_password(password: str) -> str:
return _ph.hash(password)
def verify_password(password: str, hashed: str | None) -> bool:
if not hashed:
return False
try:
return _ph.verify(hashed, password)
except VerifyMismatchError:
return False # silent-ok: a wrong password. The commonest path through this function.
except Exception as exc: # noqa: BLE001 — never raise to the caller, whatever went wrong
# Everything else is a hash this process cannot USE — truncated in the column, written by
# a different argon2 build. It is answered as "wrong password", so the owner of that
# account is locked out by a bug and told they mistyped.
log.warning(
"a stored password hash could not be verified",
extra=logs.event("auth.password.hash_unusable", reason=type(exc).__name__),
)
return False
_fernet: Fernet | None = (
Fernet(settings.token_encryption_key.encode()) if settings.token_encryption_key else None
)
def can_encrypt() -> bool:
"""Whether a value can be stored encrypted here: TOKEN_ENCRYPTION_KEY is set. Production
refuses to start without it (`config`); a local box may run without one."""
return _fernet is not None
def _require_fernet() -> Fernet:
if _fernet is None:
raise RuntimeError("TOKEN_ENCRYPTION_KEY is not configured")
return _fernet
def encrypt(value: str | None) -> str | None:
if value is None:
return None
return _require_fernet().encrypt(value.encode()).decode()
def decrypt(value: str | None) -> str | None:
if value is None:
return None
return _require_fernet().decrypt(value.encode()).decode()
def decrypt_optional(value: str | None) -> str | None:
"""Like decrypt() but returns None instead of raising when the value is absent or not a valid
ciphertext (e.g. a legacy plaintext token stored before encryption) — so callers fall back to
a refresh rather than erroring."""
if not value:
return None
try:
return _require_fernet().decrypt(value.encode()).decode()
except Exception as exc: # noqa: BLE001 — callers fall back to a refresh, never to an error
# Info rather than warning because the docstring's legacy-plaintext case comes through
# here too and is harmless. What is NOT harmless looks identical: a rotated
# TOKEN_ENCRYPTION_KEY turns every stored token unreadable at once, and every account
# then reads as "not connected" with nothing saying the key is the reason.
log.info(
"a stored ciphertext could not be decrypted",
extra=logs.event("security.decrypt.failed", reason=type(exc).__name__),
)
return None