The burn decision now lives solely in the Session dispatch: chab3's WireError (unauthentic or truncated box) proves no key knowledge and burns nothing; any other ValueError/struct.error means a peer who opened the box misbehaved, so the credential burns. handshake.py drops its try/except wrappers and the _check helper; inner-box WireErrors are reclassified to plain ValueError since they occur inside an already valid outer box.
7.3 KiB
Design notes
Architecture
chab3.py-- ChaCha20-Blake3 AEAD boxes, counter-nonce channels, CSPRNG.handshake.py-- the key-exchange cryptography. ARegistrationorResumptionobject (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_Chainclass) -- 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