Files
Spoolman/spoolman/exceptions.py
T
Donkie e4563e58c7 auth: core primitives
Levels, principals, password hashing, cookies and login throttling. Pure
library code -- nothing imports it into a request path yet.

levels.py
  An ordered read < edit < manage scale. Ranks live in a separate mapping
  rather than as comparison operators on the enum: StrEnum inherits str's
  operators, so defining __lt__/__ge__ would shadow string ordering with a
  second alphabetical meaning that happens to agree today and would stop
  agreeing the moment a level is renamed. Callers use covers().

principal.py
  A frozen dataclass describing who is asking and what they may do. The
  absence of a credential is None rather than a Principal, so "nobody" can
  never be mistaken for a permission holder. Carries the tenant_id seam from
  the design doc: always None, present so auth functions already thread a
  tenant and adding tenancy later is a filter inside them rather than a
  change to forty routers. UNRESTRICTED is what gates return when auth is
  off, before any database access.

hashing.py
  scrypt at n=2**14, r=8, p=1 -- argon2-cffi and bcrypt ship compiled wheels
  that are painful on the armv7 image, and scrypt is memory-hard and in the
  standard library. OWASP's headline n=2**17 would be multi-second on the
  ARM boards Spoolman runs on; their memory-constrained variant raises p,
  which costs the same time without the memory hardness.

  Hashes embed their own parameters, so the cost can be raised later without
  a migration: verification re-derives with the stored parameters and
  needs_rehash() reports staleness for opportunistic upgrade at login.

  Deriving blocks for ~50 ms here and several times that on a Pi. Spoolman
  is a single process, so a synchronous call in a handler would stall every
  other request. Measured 4 hashes serially at 171 ms against 63 ms across
  four threads, confirming scrypt releases the GIL and the _async wrappers
  are a real offload. Handlers must use those.

  Session tokens are hashed with plain unsalted SHA-256, deliberately: they
  are 256 bits of cryptographic randomness, so there is no dictionary to
  attack and no work factor worth paying. The digest exists so a database
  leak does not hand over usable cookies.

cookies.py
  Session cookie is HttpOnly, so CSRF is handled by double submit against a
  second script-readable cookie. Secure is only ever concluded from positive
  evidence -- real TLS, or a proxy listed in SPOOLMAN_TRUSTED_PROXIES saying
  so -- because setting it on a plain-HTTP LAN deployment makes the browser
  discard the cookie and presents as a login that succeeds and does nothing.

ratelimit.py
  In-process sliding windows keyed by username and by client address. Sound
  because the deployment contract is one process, the same assumption the
  websocket manager and scheduler already make; counters reset on restart,
  which is why lockout is also persisted on auth_user. X-Forwarded-For and
  X-Forwarded-Proto are honoured only from a configured trusted proxy,
  otherwise any client could spoof them to evade the limiter.

Also adds AuthenticationRequiredError and PermissionDeniedError to
exceptions.py, matching the existing bare-Exception style.

Verified: level ordering and rejection of unknown names; principal frozen
and anonymous capped at read; hash round-trip, distinct salts, corrupt
hashes never validating, needs_rehash on stale parameters, and dummy_verify
costing 41 ms against a real verify's 42 ms so unknown usernames are
timing-indistinguishable; window limit/expiry/reset; untrusted
X-Forwarded-For ignored and untrusted proxies unable to force Secure; and
all nine CSRF cases including a lowercase-method bypass attempt.
2026-07-28 08:50:24 +02:00

26 lines
463 B
Python

"""Various exceptions used."""
class ItemNotFoundError(Exception):
pass
class ItemDeleteError(Exception):
pass
class ItemCreateError(Exception):
pass
class SpoolMeasureError(Exception):
pass
class AuthenticationRequiredError(Exception):
"""No usable credential was presented. Mapped to HTTP 401."""
class PermissionDeniedError(Exception):
"""A credential was presented but does not permit the operation. Mapped to HTTP 403."""