|
| 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). |
0 commit comments