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.
142 lines
7.0 KiB
Markdown
142 lines
7.0 KiB
Markdown
# 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; 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
|