The packed 1216-byte hybrid public had no consumer that wanted it whole -- everything unpacked immediately. Message layouts and the credential record now carry peer_m | peer_x as explicit fields; _PUB and the unpack-at-use idiom are gone. credential_id values change with the record layout. Step comments in the exchange methods explain what each solve/challenge proves.
7.0 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; the burn policy is already decided at the internal
raise sites (handshake.py raises _Burn for invalid data inside a valid
box; chab3's ValueError means an unauthentic box) before anything
escapes. Genuine bugs (TypeError, AttributeError) propagate untouched;
chab3 raises 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