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.
436 lines
17 KiB
Python
436 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("32s32s1184s32s4s?") # idroot|shared|peer_m|peer_x|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,
|
|
peer_m,
|
|
peer_x,
|
|
self._sas,
|
|
self._credential_verified,
|
|
) = _CREDENTIAL.unpack(cred)
|
|
self._peer_pub = (peer_m, peer_x)
|
|
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)
|