Files
quantumtunnel/quantumpipe/sansio.py
T
2026-10-07 03:07:34 +00:00

434 lines
17 KiB
Python

"""The quantumpipe protocol state machine, sans I/O. session()
initiates ("alice"), session(msg1) responds ("bob"); results are bytes
to send or signal objects; once established, session(Message) encrypts a
frame and session(frame) decrypts one back. See docs/design.md."""
from __future__ import annotations
import logging
import struct
from collections.abc import MutableMapping
from dataclasses import dataclass
from .chab3 import Receiver, Sender, random
from .exceptions import ProtocolError, _Burn
from .handshake import Registration, Resumption, open_hello
from .kdf import kdf
_log = logging.getLogger(__name__)
# one-line, secret-free rendering of call inputs/results for the log
def _describe(obj) -> str:
if obj is None:
return "-"
if isinstance(obj, (bytes, bytearray)):
return f"frame[{len(obj)}]"
if isinstance(obj, Message):
return f"Message(kind={obj.kind}, {len(obj.data)}B)"
if isinstance(obj, Verification):
return f"Verification({obj}, answer={obj.answer})"
return type(obj).__name__
# --------------------------------------------------------------------------
# credentials
#
# generate_registration_secret() creates the one-time registration secret,
# given to both parties out of band and stored as a bare 32-byte store
# entry. The first successful registration replaces it with the composite.
_CREDENTIAL = struct.Struct("32s32s1216s4s?") # idroot | shared | peer pub | sas | vf
def generate_registration_secret() -> bytes:
"""Fresh 256-bit registration secret, given to both parties once."""
return random(32)
def credential_id(cred: bytes) -> bytes:
"""A stable 16-byte identifier for a stored credential (recomputable
from the record); rotates when the credential does."""
if len(cred) == _CREDENTIAL.size:
cred = cred[:-1] # exclude the verified flag
return kdf("cred/id", cred, length=16)
def _credential_shared(cred: bytes) -> bytes:
# the bare secret itself, or the composite's rotated field
return cred if len(cred) == 32 else _CREDENTIAL.unpack(cred)[1]
def _same_credential(a: bytes, b: bytes) -> bool:
# same credential, ignoring the verified flag byte on composites
if len(a) != len(b):
return False
if len(a) == _CREDENTIAL.size:
return a[:-1] == b[:-1]
return a == b
# --------------------------------------------------------------------------
# signals and traffic
@dataclass
class Verification:
"""Out-of-band comparison request (registration only): fill .answer
and pass back -- True = verified, None = proceed unverified, False =
reject (the credential is deleted)."""
sas: bytes
answer: bool | None = None
def __str__(self) -> str:
return self.sas.hex()
@dataclass(frozen=True)
class Established:
"""Handshake complete: sas = the credential's comparison digest,
verified = its flag at establishment."""
sas: bytes
verified: bool
@dataclass(frozen=True)
class Message:
"""One traffic frame: an opaque user kind plus bytes. Kind 0 is
reserved for in-band protocol messages."""
data: bytes
kind: int
# dispatch in/out types
_In = bytes | Message | Verification | None
_Out = bytes | Message | Verification | Established
# --------------------------------------------------------------------------
# the state machine
class Session:
"""One end of the protocol state machine; call it to advance.
store maps peer ids to credentials; key_id selects the credential to
initiate with (none -> respond); ad is application session context,
bound into msg1 (both sides must pass the same value).
"""
def __init__(self, store: MutableMapping, key_id=None, /, ad: bytes = b""):
self._store = store
self._key_id = None
self._ad = ad
self._registering: bool | None = None # unknown until a credential loads
self._exchange: Registration | Resumption | None = None
self._initiator: bool | None = None # unknown until the first call
self._pending: bytes | None = None # a tail message held for the gate
self._verified_answer = False
self._phase = "hello"
if key_id is not None:
self._load_credential(key_id)
def __call__(self, msg: _In = None, /) -> list[_Out]:
_log.debug("call state=%s input=%s", self.state, _describe(msg))
try:
if isinstance(msg, Message): # outbound user traffic
out = self._st_data(msg)
else:
match self._phase:
case "hello":
out = self._st_hello(msg)
case "msg2":
out = self._st_msg2(msg)
case "msg3":
out = self._st_msg3(msg)
case "msg4":
out = self._st_msg4(msg)
case "msg5":
out = self._st_msg5(msg)
case "verify":
out = self._st_verify(msg)
case "established": # inbound user traffic
out = self._st_data(msg)
case _:
raise ProtocolError(f"unexpected input in state {self.state}")
except _Burn as b:
# invalid data inside a validly opened box: the sender knew
# the keys and misbehaved -- retire the credential
self._delete_credential()
_log.debug("fail state=%s: credential burned: %s", self.state, b)
raise ProtocolError(f"credential burned: {b}") from None
except (ValueError, struct.error) as e:
_log.debug("fail state=%s: %r", self.state, e)
raise ProtocolError(str(e)) from None
_log.debug(
"emit state=%s results=[%s]",
self.state,
", ".join(_describe(r) for r in out),
)
return out
# -- outcome ------------------------------------------------------------
@property
def state(self) -> str:
"""mode:phase, e.g. "reg:msg2"; "established"/"failed" at the end."""
if self._phase in ("established", "failed") or self._registering is None:
return self._phase
mode = "reg" if self._registering else "auth"
return f"{mode}:{self._phase}"
@property
def verification(self) -> Verification:
"""The out-of-band comparison value (*:verify, or established
over a still-unverified credential)."""
if self._phase == "verify" or (
self._phase == "established" and not self._credential_verified
):
return Verification(self._sas)
raise ProtocolError(f"nothing to verify in state {self.state}")
@property
def verified(self) -> bool:
"""Whether the session was verified (established only)."""
if self._phase != "established":
raise ProtocolError("handshake not complete")
return self._verified
@property
def credential(self) -> bytes:
"""The stored credential for this session's peer."""
if self._phase != "established":
raise ProtocolError("handshake not complete")
return self._store[self._key_id]
def confirm(self, verified: bool) -> None:
"""Post-establishment decision on a still-unverified credential:
True flags it verified, False deletes it. The session itself is
unaffected."""
if self._phase != "established" or self._credential_verified:
raise ProtocolError(f"nothing to confirm in state {self.state}")
if verified:
current = self._store.get(self._key_id)
if current is None or not _same_credential(current, self._stored):
raise ProtocolError("credential changed underneath us")
self._swap_credential(verified=True)
self._verified = True
else:
# rejected: the credential is deleted outright -- a genuine
# mismatch means a MITM; re-provision a fresh PSK
self._delete_credential()
# -- credential loading --------------------------------------------------
def _load_credential(self, key_id) -> Registration | Resumption:
# bad local credential -> caller's problem (KeyError/struct.error)
cred = self._store[key_id]
self._key_id = key_id
self._stored = cred # CAS reference for every later store write
if len(cred) == 32: # bare registration secret
self._idroot = random(32) # fresh identity root
self._shared = cred
self._registering = True
self._credential_verified = False
hs: Registration | Resumption = Registration(cred, self._idroot, self._ad)
else:
(
self._idroot,
self._shared,
self._peer_pub,
self._sas,
self._credential_verified,
) = _CREDENTIAL.unpack(cred)
self._registering = False
hs = Resumption(self._shared, self._idroot, self._peer_pub, self._ad)
self._exchange = hs
return hs
# -- store mutations -------------------------------------------------------
#
# Every write is a compare-and-swap against the credential this session
# loaded (self._stored): 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 store is synchronously accessed (a
# plain dict), so these checks are atomic by construction.
def _swap_credential(self, verified: bool) -> None:
# atomic credential replacement in the store; the application
# persists the store itself if it wants durability
self._credential_verified = verified
self._stored = _CREDENTIAL.pack(
self._idroot, self._shared, self._peer_pub, self._sas, verified
)
self._store[self._key_id] = self._stored
def _delete_credential(self) -> None:
# delete only if the slot still holds OUR credential: a parallel
# connection may have won the slot in the meantime
current = self._store.get(self._key_id)
if current is not None and _same_credential(current, self._stored):
del self._store[self._key_id]
# -- state handlers ------------------------------------------------------
def _st_hello(self, msg: _In) -> list[_Out]:
if msg is None: # initiate ("alice")
hs = self._exchange
if hs is None:
raise ProtocolError("initiation requires a key_id")
self._initiator = True
self._phase = "msg2"
return [hs.startA()]
if not isinstance(msg, bytes):
raise ProtocolError(f"expecting msg1 in state {self.state}")
# respond ("bob"): identify the caller by trying msg1 against
# every credential in the store -- an AEAD open per candidate is
# cheap, and a box that opens pins the credential (and its mode).
# No candidate opening proves no key knowledge: nothing burns.
for key_id, cred in self._store.items():
try:
opened = open_hello(
msg,
shared=_credential_shared(cred),
ad=self._ad,
flow="reg" if len(cred) == 32 else "res",
)
except (struct.error, ValueError):
continue # not this credential (or a malformed store entry)
break
else:
raise ProtocolError("invalid authentication tag")
self._initiator = False
self._phase = "msg3"
return [self._load_credential(key_id).respondB(msg, opened)]
def _st_msg2(self, msg: _In) -> list[_Out]:
if not isinstance(msg, bytes):
raise ProtocolError(f"expecting msg2 in state {self.state}")
hs = self._exchange
assert hs is not None # a credential is loaded by now
msg3 = hs.respondA(msg)
if self._registering: # registration continues to msg4
self._phase = "msg4"
return [msg3]
return [msg3, *self._keyed(hs, initiator=True)]
def _st_msg3(self, msg: _In) -> list[_Out]:
if not isinstance(msg, bytes):
raise ProtocolError(f"expecting msg3 in state {self.state}")
hs = self._exchange
assert hs is not None # a credential is loaded by now
if self._registering:
assert isinstance(hs, Registration)
# msg4 is held back until the comparison answer
self._pending, self._sas = hs.confirmB(msg)
self._phase = "verify"
return [Verification(self._sas)]
assert isinstance(hs, Resumption)
hs.completeB(msg)
return self._keyed(hs, initiator=False)
def _st_msg4(self, msg: _In) -> list[_Out]:
# registration only: bob's challenge and confirmation arrive;
# msg5 is held back until the comparison answer
if not isinstance(msg, bytes):
raise ProtocolError(f"expecting msg4 in state {self.state}")
hs = self._exchange
assert isinstance(hs, Registration)
self._pending, self._sas = hs.confirmA(msg)
self._phase = "verify"
return [Verification(self._sas)]
def _st_msg5(self, msg: _In) -> list[_Out]:
# registration only: the initiator's confirmation finishes the
# exchange -- the key change happens here
if not isinstance(msg, bytes):
raise ProtocolError(f"expecting msg5 in state {self.state}")
hs = self._exchange
assert isinstance(hs, Registration)
hs.finishB(msg)
self._install_keys(hs, initiator=False)
self._swap_at_gate(self._verified_answer)
return [self._establish(verified=self._verified_answer)]
def _st_verify(self, msg: _In) -> list[_Out]:
# registration only: the comparison answer gates the tail of the
# exchange. False deletes the credential and fails; True and
# None both proceed (verified / unverified).
if not isinstance(msg, Verification):
raise ProtocolError(f"expecting the comparison answer in {self.state}")
if msg.answer is False:
self._delete_credential()
self._phase = "failed"
raise ProtocolError("verification rejected")
hs = self._exchange
assert isinstance(hs, Registration) # the resumption flow never gates
pending = self._pending
assert pending is not None # set by the gate that led here
if self._initiator:
# everything is in: key change, traffic channels, established
self._install_keys(hs, initiator=True)
self._swap_at_gate(msg.answer is True)
return [pending, self._establish(verified=msg.answer is True)]
# responder: release the held msg4; the key change waits for msg5
self._verified_answer = msg.answer is True
self._phase = "msg5"
return [pending]
def _swap_at_gate(self, verified: bool) -> None:
# the composite atomically replaces the registration secret,
# flagged by the answer; the swap is the arbitration point for
# parallel registrations -- the first swap wins, a later one
# finds the registration secret gone and fails
if self._store.get(self._key_id) != self._stored:
self._phase = "failed"
raise ProtocolError("registration key already spent")
self._swap_credential(verified=verified)
def _keyed(self, hs: Registration | Resumption, initiator: bool) -> list[_Out]:
# resumption only: install the traffic channels, no comparison
self._install_keys(hs, initiator)
return [self._establish(verified=self._credential_verified)]
def _st_data(self, msg: _In) -> list[_Out]:
# traffic: Messages encrypt into wire frames, frames decrypt
# back to Messages; the kind byte is opaque here (0 = protocol)
if self._phase != "established":
raise ProtocolError(f"no traffic in state {self.state}")
if isinstance(msg, Message):
if msg.kind == 0:
raise ProtocolError("frame kind 0 is reserved for protocol use")
return [bytes(self._tx.encrypt(bytes([msg.kind]) + msg.data))]
if not isinstance(msg, bytes):
raise ProtocolError(f"expecting traffic in state {self.state}")
plain = self._rx.decrypt(msg)
if not plain:
raise ProtocolError("empty traffic frame")
kind, data = plain[0], bytes(plain[1:])
if kind == 0:
# in-band protocol messages will be handled here
raise ProtocolError("frame kind 0 is reserved for protocol use")
return [Message(data, kind)]
# -- transitions shared by both roles ------------------------------------
def _install_keys(self, hs: Registration | Resumption, initiator: bool) -> None:
# pull the results out of the exchange and dispose of its state
self._exchange = None
if isinstance(hs, Registration):
send, recv, self._shared, self._sas, self._peer_pub = hs.finish(initiator)
# rotated shared in memory only: the store swap waits for the answer
else:
send, recv = hs.finish(initiator)
self._tx, self._rx = Sender(send), Receiver(recv)
def _establish(self, verified: bool) -> Established:
self._verified = verified
self._phase = "established"
return Established(self._sas, verified)