Files
quantumtunnel/docs/design.md
T
LeoVasanko f035859fd2 Split hybrid public keys into separate wire fields
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.
2026-10-07 17:59:13 +00:00

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