Skip to content

Commit f4a6b65

Browse files
fsecada01claude
andauthored
feat(core): HMAC-sign client-carried state (A1, #21) (#35)
Component state round-trips to the client unsigned — the #1 security gap (Epic A). This adds HMAC-SHA256 state signing at the StateSerializer choke point so every adapter is covered. - New stdlib-only core/signing.py: StateSigner + CorruptStateError. Versioned token format cfs1.<b64url(payload)>.<b64url(mac)>; the MAC binds the version prefix to prevent cross-format confusion; verify uses hmac.compare_digest. - Key config: StateSigner.configure(secret) or STATE_SIGNING_KEY env var. Sequence / comma-separated values enable rotation: first key signs, all keys verify (A2 groundwork). Unconfigured = disabled (legacy pass-through) with a one-time prominent warning. - Enabled mode: all outbound state is signed; inbound MUST be a valid token — plain JSON strings and raw dicts raise CorruptStateError (no unsigned fallback). - Closed the dict bypass: FastAPI, Flask, Litestar, Django FBV/CBV, and the WebSocket manager now route inbound state through the new StateSerializer.load_untrusted() single entry point (a client could previously submit state as a JSON object and skip deserialization). - Django ComponentView.handle_error() now maps client input errors (ValueError, incl. corrupt state) to 400 instead of 500, matching the other adapters. - Docs: docs/STATE_SIGNING.md (per-adapter setup + rotation procedure, covers A2 doc scope); README + CHANGELOG updated. - Tests: 36 new tests in tests/test_signing.py (round-trip, tamper / truncation / garbage rejection, rotation, env pickup, disabled-mode parity, FastAPI integration incl. tampered-state 400). Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 99d8a00 commit f4a6b65

13 files changed

Lines changed: 859 additions & 54 deletions

File tree

CHANGELOG.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,29 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [Unreleased]
9+
10+
### Added
11+
12+
- **HMAC-signed client state (Epic A, A1 — #21)** — new stdlib-only
13+
`core/signing.py` with `StateSigner` (HMAC-SHA256, versioned
14+
`cfs1.<payload>.<mac>` token format) and `CorruptStateError`. Enable via
15+
`StateSigner.configure(secret)` or the `STATE_SIGNING_KEY` environment
16+
variable; comma-separated / sequence values enable key rotation (first key
17+
signs, all keys verify). When enabled, all outbound state is signed and
18+
inbound state must be a valid token — tampered, unsigned, or raw-dict state
19+
is rejected with HTTP 400. When disabled, legacy plain-JSON behavior is
20+
preserved and a one-time warning is logged. See `docs/STATE_SIGNING.md`.
21+
22+
### Security
23+
24+
- Closed the **dict bypass**: all adapters (FastAPI, Flask, Litestar, Django
25+
FBV/CBV, WebSocket) now route inbound state through
26+
`StateSerializer.load_untrusted()`, so a client can no longer submit state
27+
as a raw JSON object to skip deserialization/verification.
28+
- Django `ComponentView.handle_error()` now maps client input errors
29+
(`ValueError`, including corrupt state) to `400` instead of `500`.
30+
831
## [0.5.0b0] - 2026-06-24
932

1033
### Added

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -238,8 +238,9 @@ Generated from docstrings by pdoc and deployed to GitHub Pages on every push to
238238
| [Architecture Overview](docs/server_component_spec.md) | Core design and component lifecycle |
239239
| [Django Implementation](docs/DJANGO_IMPLEMENTATION.md) | Django adapter setup and patterns |
240240
| [Class-Based Views](docs/CBV_GUIDE.md) | CBV auth/permission patterns |
241+
| [State Signing](docs/STATE_SIGNING.md) | HMAC-signed client state: setup per adapter + key rotation |
241242
| [E-Commerce Example](docs/examples/ecommerce.md) | Real-time cart + product demo |
242-
| [Multi-Step Wizard](docs/examples/wizard.md) | FastAPI wizard recipe (unsigned state — pending Epic A1) |
243+
| [Multi-Step Wizard](docs/examples/wizard.md) | FastAPI wizard recipe (enable [state signing](docs/STATE_SIGNING.md) in production) |
243244
| [CSRF & CSWSH Guide](docs/SECURITY_CSRF.md) | Per-adapter CSRF coverage audit + WebSocket hijacking guidance |
244245

245246
### AI / LLM Context
@@ -593,7 +594,7 @@ and [epic issues](https://github.com/fsecada01/component-framework/issues?q=is%3
593594
> optimistic UI once it lands).
594595
595596
**0.6.0b — Hardening Foundation** *(Tier 0: security & rendering fidelity)*
596-
- [ ] Signed / tamper-proof state (HMAC) — *security gate*
597+
- [x] Signed / tamper-proof state (HMAC) — *security gate* — see [State Signing](docs/STATE_SIGNING.md)
597598
- [ ] DOM morphing — preserve focus / scroll / in-flight input; stable list keys
598599
- [ ] CSRF coverage for FastAPI / Litestar / Flask HTTP paths (today Django-only)
599600
- [ ] 422 re-render on form-validation failure (adapters currently return 200)

docs/STATE_SIGNING.md

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
# State Signing (HMAC)
2+
3+
Component state round-trips through the client on every dispatch: the server
4+
returns a serialized `state` field, and the client echoes it back with the
5+
next event. Without a signature, a client can tamper with any state field
6+
before echoing it back.
7+
8+
State signing closes this gap. When enabled, every outbound state string is
9+
an HMAC-SHA256 signed token, and every inbound state **must** be a valid
10+
token — plain JSON strings and raw JSON objects are rejected with
11+
`CorruptStateError` (HTTP 400 on all adapters).
12+
13+
## Token format
14+
15+
```
16+
cfs1.<base64url(json_payload)>.<base64url(hmac_sha256_mac)>
17+
```
18+
19+
The MAC covers `cfs1.<payload>` — the version prefix is bound into the
20+
signature, so tokens cannot be replayed across future format versions.
21+
22+
## Enabling signing
23+
24+
Signing is configured once per process, at application startup. There are two
25+
equivalent mechanisms:
26+
27+
### 1. Environment variable (no code changes)
28+
29+
```bash
30+
export STATE_SIGNING_KEY="$(openssl rand -hex 32)"
31+
```
32+
33+
Comma-separated values enable key rotation (first key signs, all verify):
34+
35+
```bash
36+
export STATE_SIGNING_KEY="new-key,old-key"
37+
```
38+
39+
### 2. Explicit configuration
40+
41+
```python
42+
from component_framework import StateSigner
43+
44+
StateSigner.configure("your-secret-key") # single key
45+
StateSigner.configure(["new-key", "old-key"]) # rotation
46+
StateSigner.configure(None) # explicitly disable (overrides env)
47+
```
48+
49+
`StateSigner.configure()` takes precedence over the environment variable.
50+
51+
If neither is set, signing is **disabled** and the framework falls back to
52+
legacy plain-JSON state — a one-time warning is logged. Do not run production
53+
deployments unsigned.
54+
55+
## Per-adapter setup
56+
57+
The signer is process-global and lives below the adapter layer, so setup is
58+
the same everywhere: configure it before the app starts serving.
59+
60+
### FastAPI
61+
62+
```python
63+
from fastapi import FastAPI
64+
from component_framework import StateSigner
65+
from component_framework.adapters.fastapi import create_component_routes
66+
67+
StateSigner.configure(os.environ["MY_SECRET"]) # or just set STATE_SIGNING_KEY
68+
69+
app = FastAPI()
70+
create_component_routes(app)
71+
```
72+
73+
### Litestar
74+
75+
```python
76+
from litestar import Litestar
77+
from component_framework import StateSigner
78+
from component_framework.adapters.litestar import component_endpoint
79+
80+
StateSigner.configure(os.environ["MY_SECRET"])
81+
82+
app = Litestar(route_handlers=[component_endpoint])
83+
```
84+
85+
### Flask
86+
87+
```python
88+
from flask import Flask
89+
from component_framework import StateSigner
90+
from component_framework.adapters.flask import register_component_routes
91+
92+
app = Flask(__name__)
93+
StateSigner.configure(app.config["SECRET_KEY"]) # reusing SECRET_KEY is fine
94+
register_component_routes(app)
95+
```
96+
97+
### Django
98+
99+
Configure in `settings.py` (or an `AppConfig.ready()` hook):
100+
101+
```python
102+
# settings.py
103+
from component_framework import StateSigner
104+
105+
StateSigner.configure(SECRET_KEY) # or a dedicated key
106+
```
107+
108+
## Key rotation procedure
109+
110+
1. **Prepend** the new key: `STATE_SIGNING_KEY="new-key,old-key"` (or
111+
`StateSigner.configure(["new-key", "old-key"])`). New responses are signed
112+
with `new-key`; states still in flight (signed with `old-key`) continue to
113+
verify.
114+
2. **Wait** until in-flight client states have expired — i.e. long enough that
115+
no open browser tab still holds a state signed with the old key. For most
116+
apps a deploy cycle or a day is plenty.
117+
3. **Drop** the old key: `STATE_SIGNING_KEY="new-key"`. Old tokens now fail
118+
verification (clients simply re-mount their components).
119+
120+
## WebSocket pushes
121+
122+
WebSocket traffic goes through the same `StateSerializer` choke point:
123+
inbound `component_event` messages are verified, and both dispatch responses
124+
and server-initiated `push_update()` broadcasts carry signed state.
125+
126+
## Error handling
127+
128+
Tampered, truncated, unsigned, or foreign-key state raises
129+
`component_framework.CorruptStateError` (a `ComponentError` subclass). The
130+
bundled adapters map it to:
131+
132+
| Adapter | Response |
133+
|---------|----------|
134+
| FastAPI | `400` (`HTTPException`) |
135+
| Litestar | `400` (`HTTPException`) |
136+
| Flask | `400` JSON error |
137+
| Django (FBV + CBV) | `400` `JsonResponse` |
138+
| WebSocket | `{"type": "error", ...}` frame |
139+
140+
## What signing does and does not protect
141+
142+
- **Protects:** integrity — the client cannot alter state fields, forge
143+
state, or splice state across format versions.
144+
- **Does not protect:** confidentiality — the payload is base64-encoded JSON,
145+
readable by the client. Never put secrets in component state.
146+
- **Does not prevent replay of a stale-but-valid token** for the same
147+
component. Treat state as untrusted input in handlers and enforce
148+
authorization server-side (see Epic A3, locked fields, for the follow-up).
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
"""Component Framework — server-driven components for Python web frameworks."""
2+
3+
from .core.signing import CorruptStateError, StateSigner
4+
5+
__all__ = [
6+
"CorruptStateError",
7+
"StateSigner",
8+
]

src/component_framework/adapters/django_views.py

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -75,13 +75,11 @@ def component_view(request: HttpRequest, name: str) -> JsonResponse:
7575
except json.JSONDecodeError as e:
7676
return JsonResponse({"error": f"Invalid JSON in payload/params: {e}"}, status=400)
7777

78-
# Deserialize state
79-
state = None
80-
if state_str:
81-
try:
82-
state = StateSerializer.deserialize(state_str)
83-
except Exception as e:
84-
return JsonResponse({"error": f"Invalid state: {e}"}, status=400)
78+
# Deserialize state (verified against the signing key when enabled)
79+
try:
80+
state = StateSerializer.load_untrusted(state_str)
81+
except Exception as e:
82+
return JsonResponse({"error": f"Invalid state: {e}"}, status=400)
8583

8684
# Compute optimistic patch BEFORE dispatch (uses pre-dispatch state).
8785
# Hydrate state so get_optimistic_patch can access current state values.
@@ -254,12 +252,14 @@ def parse_payload(self, payload_str: str | dict) -> dict:
254252
except json.JSONDecodeError as e:
255253
raise ValueError(f"Invalid payload JSON: {e}")
256254

257-
def parse_state(self, state_str: str | None) -> dict | None:
258-
"""Parse state JSON string."""
259-
if not state_str:
260-
return None
255+
def parse_state(self, state_raw: str | dict | None) -> dict | None:
256+
"""Parse client-supplied state (signed token, JSON string, or dict).
257+
258+
Routes through :meth:`StateSerializer.load_untrusted` so raw dicts
259+
cannot bypass signature verification when signing is enabled.
260+
"""
261261
try:
262-
return StateSerializer.deserialize(state_str)
262+
return StateSerializer.load_untrusted(state_raw)
263263
except Exception as e:
264264
raise ValueError(f"Invalid state: {e}")
265265

@@ -304,7 +304,14 @@ def component_not_found(self, name: str) -> JsonResponse:
304304
return JsonResponse({"error": f"Component '{name}' not found"}, status=404)
305305

306306
def handle_error(self, error: Exception, name: str) -> JsonResponse:
307-
"""Handle errors."""
307+
"""Handle errors.
308+
309+
Client input errors (bad JSON, tampered/corrupt state) map to 400;
310+
everything else maps to 500.
311+
"""
312+
if isinstance(error, ValueError):
313+
logger.warning("Bad request for component '%s': %s", name, error)
314+
return JsonResponse({"error": str(error)}, status=400)
308315
logger.exception(f"Error processing component '{name}'")
309316
return JsonResponse({"error": "Internal server error"}, status=500)
310317

src/component_framework/adapters/fastapi.py

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -62,16 +62,10 @@ def _extract_params(data: dict) -> tuple[dict, str | None, dict, dict | None]:
6262
params = _parse_json_str(data.get("params"), default={}) or {}
6363
event = data.get("event")
6464
payload = _parse_json_str(data.get("payload"), default={}) or {}
65-
state_raw = data.get("state")
66-
67-
state = None
68-
if state_raw:
69-
try:
70-
state = (
71-
StateSerializer.deserialize(state_raw) if isinstance(state_raw, str) else state_raw
72-
)
73-
except Exception as e:
74-
raise HTTPException(status_code=400, detail=f"Invalid state: {e}")
65+
try:
66+
state = StateSerializer.load_untrusted(data.get("state"))
67+
except Exception as e:
68+
raise HTTPException(status_code=400, detail=f"Invalid state: {e}")
7569

7670
return params, event, payload, state
7771

src/component_framework/adapters/flask.py

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -98,16 +98,10 @@ def _extract_params(data: dict) -> tuple[dict, str | None, dict, dict | None]:
9898
params = _parse_json_str(data.get("params"), default={}) or {}
9999
event = data.get("event")
100100
payload = _parse_json_str(data.get("payload"), default={}) or {}
101-
state_raw = data.get("state")
102-
103-
state = None
104-
if state_raw:
105-
try:
106-
state = (
107-
StateSerializer.deserialize(state_raw) if isinstance(state_raw, str) else state_raw
108-
)
109-
except Exception as e:
110-
raise ValueError(f"Invalid state: {e}")
101+
try:
102+
state = StateSerializer.load_untrusted(data.get("state"))
103+
except Exception as e:
104+
raise ValueError(f"Invalid state: {e}")
111105

112106
return params, event, payload, state
113107

src/component_framework/adapters/litestar.py

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -62,16 +62,10 @@ def _extract_params(data: dict) -> tuple[dict, str | None, dict, dict | None]:
6262
params = _parse_json_str(data.get("params"), default={}) or {}
6363
event = data.get("event")
6464
payload = _parse_json_str(data.get("payload"), default={}) or {}
65-
state_raw = data.get("state")
66-
67-
state = None
68-
if state_raw:
69-
try:
70-
state = (
71-
StateSerializer.deserialize(state_raw) if isinstance(state_raw, str) else state_raw
72-
)
73-
except Exception as e:
74-
raise HTTPException(status_code=400, detail=f"Invalid state: {e}")
65+
try:
66+
state = StateSerializer.load_untrusted(data.get("state"))
67+
except Exception as e:
68+
raise HTTPException(status_code=400, detail=f"Invalid state: {e}")
7569

7670
return params, event, payload, state
7771

src/component_framework/core/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,18 @@
1313
)
1414
from .registry import ComponentRegistry, registry
1515
from .renderer import Renderer
16+
from .signing import CorruptStateError, StateSigner
1617
from .state import InMemoryStateStore, StateStore
1718
from .streaming import StreamingComponent, format_sse_frame
1819
from .websocket import ComponentWebSocketManager, WebSocketConnection, ws_manager
1920

2021
__all__ = [
2122
"Component",
2223
"ComponentError",
24+
"CorruptStateError",
2325
"EventNotFoundError",
2426
"StateSerializer",
27+
"StateSigner",
2528
"compose",
2629
"SlotRenderer",
2730
"FormComponent",

0 commit comments

Comments
 (0)