"""User entity + PIN hashing. Sprint 2.5: minimal model to back the Studio login. PINs are 4-8 digit numeric strings hashed with PBKDF2-HMAC-SHA256 (stdlib, no extra dependency) using a per-user 16-byte salt and a 200k-iteration work factor. Hash format follows a self-describing string so future migrations (argon2, scrypt) can co-exist. Format:: pbkdf2_sha256$$$ """ from __future__ import annotations import base64 import hashlib import hmac import os from datetime import UTC, datetime from pydantic import BaseModel, ConfigDict, Field, field_validator from arautopilot.core.ids import new_vessel_id from arautopilot.core.rbac import Role _PBKDF2_ITERATIONS = 200_000 _PBKDF2_SALT_LEN = 16 _PBKDF2_HASH_LEN = 32 _PBKDF2_ALGO = "sha256" def _hash_pin(pin: str, *, iterations: int = _PBKDF2_ITERATIONS) -> str: """Hash a PIN with PBKDF2-HMAC-SHA256. Returns the self-describing string.""" if not _looks_like_pin(pin): raise ValueError( "PIN must be 4-8 digits (numeric). Use ASCII digits only." ) salt = os.urandom(_PBKDF2_SALT_LEN) digest = hashlib.pbkdf2_hmac( _PBKDF2_ALGO, pin.encode("utf-8"), salt, iterations, _PBKDF2_HASH_LEN ) return ( f"pbkdf2_{_PBKDF2_ALGO}${iterations}" f"${base64.b64encode(salt).decode('ascii')}" f"${base64.b64encode(digest).decode('ascii')}" ) def _verify_pin(pin: str, hashed: str) -> bool: """Constant-time verification of a PIN against a stored hash.""" if not _looks_like_pin(pin): return False try: scheme, iters_s, salt_b64, hash_b64 = hashed.split("$", 3) except ValueError: return False if scheme != f"pbkdf2_{_PBKDF2_ALGO}": return False try: iterations = int(iters_s) salt = base64.b64decode(salt_b64) expected = base64.b64decode(hash_b64) except (ValueError, TypeError): return False candidate = hashlib.pbkdf2_hmac( _PBKDF2_ALGO, pin.encode("utf-8"), salt, iterations, len(expected) ) return hmac.compare_digest(candidate, expected) def _looks_like_pin(pin: str) -> bool: return bool(pin) and 4 <= len(pin) <= 8 and pin.isdigit() class User(BaseModel): """A user of the Studio or the bridge display. Field ``pin_hash`` is the only sensitive value persisted -- the plain PIN never lives in memory longer than the duration of a verify call. """ model_config = ConfigDict(extra="forbid", validate_assignment=True) user_id: str = Field(default_factory=lambda: new_vessel_id()) display_name: str = Field(min_length=1, max_length=80) role: Role pin_hash: str = Field(min_length=8, max_length=300) vessel_id: str | None = Field( default=None, description="If set, this user belongs to one specific vessel " "(Owners + their crew). None means cross-vessel scope " "(Super Admin + Engineer).", ) active: bool = True created_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) last_login_at: datetime | None = None @field_validator("pin_hash") @classmethod def _looks_like_hash(cls, v: str) -> str: # Cheap structural validation -- does not verify the hash itself. parts = v.split("$") if len(parts) != 4 or not parts[0].startswith("pbkdf2_"): raise ValueError( "pin_hash must be in the form pbkdf2_$$$" ) return v # ----- Construction helpers ------------------------------------------- @classmethod def create( cls, *, display_name: str, role: Role, pin: str, vessel_id: str | None = None, ) -> "User": """Construct a new user from a plaintext PIN. The PIN is hashed before the model is built; the plaintext does not survive this call. """ return cls( display_name=display_name, role=role, pin_hash=_hash_pin(pin), vessel_id=vessel_id, ) # ----- Authentication -------------------------------------------------- def verify_pin(self, pin: str) -> bool: """Return True iff ``pin`` matches this user's stored hash.""" return _verify_pin(pin, self.pin_hash) def set_pin(self, pin: str) -> "User": """Return a copy with a freshly-hashed new PIN.""" return self.model_copy(update={"pin_hash": _hash_pin(pin)}) def touch_login(self) -> "User": """Return a copy with ``last_login_at`` refreshed to now (UTC).""" return self.model_copy(update={"last_login_at": datetime.now(UTC)})