Session keys hardened (namespaced hashes of tokens). Various cleanup.

This commit is contained in:
Leo Vasanko
2026-02-15 20:14:48 +00:00
parent 18722f0e01
commit e59852b44c
21 changed files with 495 additions and 565 deletions
+23 -29
View File
@@ -11,6 +11,7 @@ import uuid7
from paskia import db
from paskia.util import hostutil
from paskia.util import passphrase as passphrase_util
from paskia.util.crypto import hash_secret
# Sentinel for uuid fields before they are set by create() or DB post init
_UUID_UNSET = UUID(int=0)
@@ -360,9 +361,12 @@ class Session(msgspec.Struct, dict=True, omit_defaults=True):
Mutable fields: expiry (updated on session refresh)
Immutable fields: user_uuid, credential_uuid, host, ip, user_agent, client_uuid
key is stored in the dict key, not in the struct.
key is the hashed db_key, stored in the dict key, not in the struct.
If client_uuid is set, this is an OIDC session (key is the sid claim).
If client_uuid is set, this is an OIDC session.
Security: The database stores only derived keys, never the raw secret.
A database leak does not expose working session credentials.
"""
user_uuid: UUID = msgspec.field(name="user")
@@ -375,7 +379,7 @@ class Session(msgspec.Struct, dict=True, omit_defaults=True):
def __post_init__(self):
if not hasattr(self, "key"):
self.key: str = ""
self.key: bytes = b""
@property
def user(self) -> User:
@@ -415,20 +419,27 @@ class Session(msgspec.Struct, dict=True, omit_defaults=True):
cls,
user: UUID | User,
credential: UUID | Credential,
key: bytes,
host: str,
ip: str,
user_agent: str,
expiry: datetime,
client: UUID | None = None,
) -> Session:
"""Create a new Session with auto-generated key.
"""Create a new Session with the provided key.
If client is provided, creates an OIDC session (key becomes sid claim).
Args:
key: The hashed session key (derived from secret via hash_secret)
Returns:
Session object with key set
"""
user_uuid = user if isinstance(user, UUID) else user.uuid
credential_uuid = (
credential if isinstance(credential, UUID) else credential.uuid
)
session = cls(
user_uuid=user_uuid,
credential_uuid=credential_uuid,
@@ -438,7 +449,7 @@ class Session(msgspec.Struct, dict=True, omit_defaults=True):
expiry=expiry,
client_uuid=client,
)
session.key = secrets.token_urlsafe(12)
session.key = key
return session
@@ -600,7 +611,7 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False):
roles: dict[UUID, Role] = {}
users: dict[UUID, User] = {}
credentials: dict[UUID, Credential] = {}
sessions: dict[str, Session] = {}
sessions: dict[bytes, Session] = {}
reset_tokens: dict[bytes, ResetToken] = {}
# OIDC provider data
oid_clients: dict[UUID, OIDClient] = {}
@@ -632,19 +643,21 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False):
return self._store.transaction(action, ctx, user=user)
def session_ctx(
self, session_key: str, host: str | None = None
self, session_secret: str, host: str | None = None
) -> SessionContext | None:
"""Get full session context with effective permissions.
Args:
session_key: The session key string
session_secret: The session secret (cookie value) - will be hashed for lookup
host: Optional host for binding/validation and domain-scoped permissions
Returns:
SessionContext if valid, None if session not found, expired, or host mismatch
"""
key = hash_secret("cookie", session_secret)
try:
s = self.sessions[session_key]
s = self.sessions[key]
except KeyError:
return None
@@ -693,22 +706,3 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False):
credential=credential,
permissions=effective_perms,
)
def oidc_session_by_sid(
self, sid: str, client_uuid: UUID | None = None
) -> Session | None:
"""Look up an OIDC session by sid (session key).
Args:
sid: The session ID (same as session key)
client_uuid: If provided, verify the session belongs to this client
Returns:
Session if found and valid OIDC session, None otherwise
"""
s = self.sessions.get(sid)
if not s or s.client_uuid is None:
return None
if client_uuid is not None and s.client_uuid != client_uuid:
return None
return s