# Design notes ## Architecture - `chab3.py` -- ChaCha20-Blake3 AEAD boxes, counter-nonce channels, CSPRNG. - `handshake.py` -- the key-exchange cryptography. A `Registration` or `Resumption` object (two fully separate classes; the shared parts are free functions) holds one exchange's per-connection state -- ephemeral root, commitment, exchange secrets, the ad chain (a tiny `_Chain` class) -- and is disposed by the caller once the exchange completes; its secret fields are a candidate for SecureBuffer backing. - `sansio.py` -- the protocol state machine (`Session`), the key store and its policies, traffic framing. - `ws.py` -- `SecSocket`, a thin asyncio WebSocket pump over a Session. - `exceptions.py` -- `ProtocolError`, in its own module so wrappers and applications can import it without the sans-io machinery. ## Driving a session `session()` with no message initiates ("alice"), receiving msg1 first responds ("bob"); results are bytes (wire messages to send) or signal objects. A `Verification` carries the out-of-band comparison digest -- fill `.answer`, pass it back. Registration gates its tail on the answers: the responder is asked after msg3 (his answer releases msg4), the initiator after msg4 (her answer releases msg5 and finishes her side); a False answer deletes the credential and the held message is never sent. Resumptions never prompt: the stored sas rides on `Established` and the parties may compare it whenever they wish, then `Session.confirm()` flags the credential verified or deletes it. Once established, traffic goes through the same dispatch: `session(Message)` encrypts a frame, `session(frame)` decrypts one back. Keeping everything on the dispatch -- rather than separate encrypt/decrypt calls -- is what allows in-band protocol messages and their own result patterns later (re-keying, post-close semantics). Which handshake a session runs is determined by its credential: 32 bytes (bare shared) -> registration, credential-sized -> resumption. The initiator is constructed with its key id; the responder without one, identifying the caller by trying msg1 against every credential in the store -- an AEAD open per candidate, cheap even for many. ## The key store A mutable mapping (a plain dict works) of one credential per peer under an application-chosen id: either the bare 32-byte registration secret (given to both parties out of band; it IS the initial `shared`, unhashed; the kdf info strings provide all domain separation needed) or the composite written by the first successful registration: idroot(32) | shared'(32) | peer_m(1184) | peer_x(32) | sas(4) | verified(1) The rotation to shared' is derived when the exchange completes but only held in memory: the store swap waits for the key change -- the initiator swaps at her comparison answer, the responder when msg5 completes. Until then the registration secret keeps serving new connections, and a drop before the swap loses nothing but the session. Each swap is atomic: True and None swap the composite in (flagged verified / unverified), False deletes the credential instead. An unverified credential is never prompted again. The swap is also the arbitration point for parallel registrations on the same registration key: only the first swap wins; a later one finds the key gone and fails with "registration key already spent" instead of overwriting. Re-provisioning after a deletion needs a fresh registration secret. Every store write is a compare-and-swap against the credential the session loaded: with several connections in flight on one store, only the first to reach the atomic key change wins, and a loser's later burn/delete/flag-flip never touches a slot that changed underneath it; the verified-flag flip tolerates a parallel confirmation of the same credential (the flag byte is excluded from the comparison). The store is synchronously accessed, so these checks are atomic by construction, and it is only ever mutated in place; persisting it (after results or errors) is the application's business. ### Burns Invalid data inside an otherwise authentic box (bad kdf tags, a bad commitment reveal, low-order points, malformed bodies) burns the credential on the spot: the sender demonstrably knew the keys and misbehaved. AEAD failures and early disconnects instead change nothing -- they prove no key knowledge -- and the same registration secret can simply be tried again. A burn only touches the slot if it still holds the session's own credential: a parallel connection may have won the slot in the meantime. ## Frame kinds One kind byte heads every traffic box's plaintext. The kind is opaque to the state machine, with a single rule: kind 0 is reserved for in-band protocol messages (none implemented yet -- key rotation and the like will ride there, produced and consumed inside the dispatch); all other values are user types, delivered untouched as `Message.kind`. The str/bytes typing is the WebSocket wrapper's business (`ws.py`): `send()` picks the kind from the object (str -> TEXT, bytes -> BINARY, a Message passes through), `receive()` maps it back. Applications wanting their own kinds (protobuf etc.) send/receive `Message` directly; a future third layer may take this mapping over from the wrapper, so other transports don't reimplement it. ## Errors Everything attributable to the peer, the wire, or protocol-level misuse surfaces as `ProtocolError`; the application action is always the same -- log and disconnect. The conversion happens at one point, the Session dispatch, which also owns the burn policy: chab3's WireError (unauthentic or truncated box) proves no key knowledge and burns nothing; any other ValueError/struct.error means invalid data inside a validly opened box -- the peer knew the keys and misbehaved, so the credential burns. handshake.py therefore raises no special types; its one exception is the inner boxes, whose WireError it reclassifies to a plain ValueError because an inner box sits inside an already valid outer one. Genuine bugs (TypeError, AttributeError) propagate untouched; chab3 raises WireError (a ValueError) for bad wire data and TypeError only for program bugs (too-small output buffers). A corrupt LOCAL store entry raises KeyError/struct.error from the constructor -- the caller's own data problem. ## The WebSocket wrapper `SecSocket` duck-types the `websockets` library connection (send/recv) and FastAPI/Starlette's WebSocket (accept/send_bytes/receive_bytes). The socket knows no protocol state: bytes results are sent, other results go to a handler registered for their type, and a Verification result is passed back to the session once its handler ran (the handler sets .answer, or returns the value). The Established result ends accept()/connect(). The out-of-band verification is just one registered handler: async def verify(v: Verification) -> bool | None True confirms, False rejects (the credential is deleted, the handshake fails), None skips (the default when no handler is registered). accept()/connect() return only after the answer: no data flows before it. Resumptions never prompt; the stored digest stays exposed as sock.session.verification for an optional later comparison (sock.session.confirm). sock = SecSocket(ws, store, "peer-1", ad=b"myapp", handlers={Verification: verify}) await sock.connect() # client side; accept() on the server side await sock.send(b"data") msg = await sock.receive() # str | bytes | Message