From 2d15f1a2cb8fb9e0d28d9853e076de33312c7b11 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Wed, 10 Jun 2026 10:27:35 -0700 Subject: [PATCH] =?UTF-8?q?feat(slice-2):=20platform/session=20=E2=80=94?= =?UTF-8?q?=20stdlib=20HMAC=20signed=20cookie=20tokens=20(=C2=A76.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/app/platform/session.py | 49 +++++++++++++++++++++++++++++++++ backend/tests/test_session.py | 26 +++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 backend/app/platform/session.py create mode 100644 backend/tests/test_session.py diff --git a/backend/app/platform/session.py b/backend/app/platform/session.py new file mode 100644 index 0000000..5b24814 --- /dev/null +++ b/backend/app/platform/session.py @@ -0,0 +1,49 @@ +"""platform/session — stateless signed session tokens (SD-0001 §6.2). + +Sessions are signed HTTP-only cookies; there is no session table. A token is +`base64url(payload_json).base64url(hmac_sha256(payload))` keyed by the configured session +secret (INV-8). `verify` checks the MAC in constant time and returns the payload dict, or +None for any malformed/tampered/wrong-key token. No server-side state to revoke — a leaked +secret is rotated, invalidating every session at once (§6.2, accepted at MVP scale). +""" +from __future__ import annotations + +import base64 +import hashlib +import hmac +import json +from typing import Any + + +def _b64e(raw: bytes) -> str: + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") + + +def _b64d(s: str) -> bytes: + pad = "=" * (-len(s) % 4) + return base64.urlsafe_b64decode(s + pad) + + +def _mac(payload_b64: str, secret: str) -> str: + digest = hmac.new(secret.encode("utf-8"), payload_b64.encode("ascii"), hashlib.sha256).digest() + return _b64e(digest) + + +def sign(payload: dict[str, Any], secret: str) -> str: + """Serialize and sign a session payload into a cookie-safe token.""" + payload_b64 = _b64e(json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")) + return f"{payload_b64}.{_mac(payload_b64, secret)}" + + +def verify(token: str, secret: str) -> dict[str, Any] | None: + """Return the payload if the token's signature is valid, else None.""" + if not token or "." not in token: + return None + payload_b64, _, sig = token.partition(".") + expected = _mac(payload_b64, secret) + if not hmac.compare_digest(sig, expected): + return None + try: + return json.loads(_b64d(payload_b64)) + except (ValueError, json.JSONDecodeError): + return None diff --git a/backend/tests/test_session.py b/backend/tests/test_session.py new file mode 100644 index 0000000..c1f322d --- /dev/null +++ b/backend/tests/test_session.py @@ -0,0 +1,26 @@ +from app.platform import session + +SECRET = "test-secret" + + +def test_sign_then_verify_round_trips(): + token = session.sign({"account_id": 7, "email": "m@example.com"}, SECRET) + assert session.verify(token, SECRET) == {"account_id": 7, "email": "m@example.com"} + + +def test_verify_rejects_tampered_payload(): + token = session.sign({"account_id": 7}, SECRET) + payload_b64, _, sig = token.partition(".") + # Flip the payload but keep the old signature -> must be rejected. + forged = session.sign({"account_id": 8}, SECRET).split(".")[0] + "." + sig + assert session.verify(forged, SECRET) is None + + +def test_verify_rejects_wrong_secret(): + token = session.sign({"account_id": 7}, SECRET) + assert session.verify(token, "other-secret") is None + + +def test_verify_rejects_garbage(): + assert session.verify("not-a-token", SECRET) is None + assert session.verify("", SECRET) is None