Source code for edri.protocol.handshake

"""Mutual nonce handshake over the frame codec.

::

    Switch    -> Connector:  HELLO_SERVER  server_nonce(32)
    Connector -> Switch:     HELLO_CLIENT  router_id(16) + client_nonce(32), mac_c
    Switch    -> Connector:  HELLO_ACCEPT  mac_s

Both sides prove key possession, so a rogue Switch cannot harvest anything from
legitimate nodes, and neither side controls the transcript alone. ``router_id``
sits inside the MAC, which is what stops an outsider claiming another router's
identity.

With a cluster-wide PSK this closes impersonation against anyone outside the
cluster - the reported hole - but not between members, who all hold the same
key. Per-router keys become version 2 without reframing.
"""
from hmac import compare_digest, new as hmac_new
from secrets import token_bytes
from uuid import UUID

from edri.protocol.channel import SecureChannel
from edri.protocol.errors import ErrorReason, ProtocolError
from edri.protocol.frame import (
    MAC_LENGTH,
    NONCE_LENGTH,
    PROTOCOL_VERSION,
    ZERO_TARGET,
    FrameType,
)
from edri.protocol.keys import KeySet

__all__ = [
    "DEVELOPMENT_KEY", "HANDSHAKE_TIMEOUT", "ZERO_FINGERPRINT", "client_handshake",
    "client_handshake_unauthenticated", "derive_session_keys",
    "server_handshake", "server_handshake_unauthenticated", "transcript_mac",
]

# Wall-clock budget for the whole handshake. A peer that opens a socket and then
# says nothing must not hold a slot. A security parameter: one shared definition
# so the Switch and the Connector cannot drift apart.
HANDSHAKE_TIMEOUT = 10

ZERO_FINGERPRINT = bytes(32)
CLIENT_LABEL = b"edri-c"
SERVER_LABEL = b"edri-s"
ROUTER_ID_LENGTH = 16


[docs] def transcript_mac(psk: bytes, label: bytes, server_nonce: bytes, client_nonce: bytes, router_id: bytes, tls_fingerprint: bytes) -> bytes: message = (label + bytes([PROTOCOL_VERSION]) + server_nonce + client_nonce + router_id + tls_fingerprint) return hmac_new(psk, message, "sha256").digest()[:MAC_LENGTH]
[docs] def derive_session_keys(psk: bytes, server_nonce: bytes, client_nonce: bytes) -> tuple[bytes, bytes]: """Return ``(k_c2s, k_s2c)``. Bulk frames never use the PSK directly. That is also what makes rotation seamless: the PSK is consulted only here, so replacing it cannot disturb an established connection. """ base = server_nonce + client_nonce k_c2s = hmac_new(psk, base + b"c2s", "sha256").digest() k_s2c = hmac_new(psk, base + b"s2c", "sha256").digest() return k_c2s, k_s2c
[docs] def client_handshake(channel: SecureChannel, router_id: UUID, keyset: KeySet, tls_fingerprint: bytes = ZERO_FINGERPRINT) -> None: """Authenticate to a Switch and activate ``channel``. Raises on failure.""" psk = keyset.primary try: hello_server = channel.receive_unkeyed() server_nonce = hello_server.payload client_nonce = token_bytes(NONCE_LENGTH) mac_c = transcript_mac(psk, CLIENT_LABEL, server_nonce, client_nonce, router_id.bytes, tls_fingerprint) # HELLO_CLIENT authenticates itself: its payload is the nonce and identity, # and mac_c over the transcript is the proof. It is sent with the PSK as the # frame key so the codec needs no special case. channel.activate(send_key=psk, receive_key=psk) channel.send(FrameType.HELLO_CLIENT, ZERO_TARGET, router_id.bytes + client_nonce + mac_c) accept = channel.receive() except ConnectionError as e: # A Switch that rejects the handshake drops the connection rather than # answering (see server_handshake), but so does a Switch that restarts # or a network that resets mid-handshake. The EOF cannot tell those # apart; mark it transport-level so the caller does not accuse the # secret for what is most often a transient drop. raise ProtocolError( f"connection closed during handshake: {e}", ErrorReason.BAD_MAC, transport=True ) from e if accept.type != FrameType.HELLO_ACCEPT: raise ProtocolError(f"expected HELLO_ACCEPT, got {accept.type.name}", ErrorReason.BAD_MAC) expected_s = transcript_mac(psk, SERVER_LABEL, server_nonce, client_nonce, router_id.bytes, tls_fingerprint) if not compare_digest(expected_s, accept.payload): raise ProtocolError("switch failed to prove key possession", ErrorReason.BAD_MAC) k_c2s, k_s2c = derive_session_keys(psk, server_nonce, client_nonce) channel.rekey(send_key=k_c2s, receive_key=k_s2c)
[docs] def server_handshake(channel: SecureChannel, keyset: KeySet, tls_fingerprint: bytes = ZERO_FINGERPRINT) -> tuple[UUID, int]: """Authenticate a connecting router. Returns ``(router_id, key_index)``.""" server_nonce = token_bytes(NONCE_LENGTH) channel.send_unkeyed(FrameType.HELLO_SERVER, ZERO_TARGET, server_nonce) # The server cannot know which key of a rotating set the client used until # it tries them, so the frame key is resolved by trial below. channel.activate(send_key=keyset.primary, receive_key=keyset.primary) try: hello = channel.receive_any_key(list(keyset)) if hello.frame.type != FrameType.HELLO_CLIENT: raise ProtocolError(f"expected HELLO_CLIENT, got {hello.frame.type.name}", ErrorReason.BAD_MAC) payload = hello.frame.payload if len(payload) != ROUTER_ID_LENGTH + NONCE_LENGTH + MAC_LENGTH: raise ProtocolError(f"malformed HELLO_CLIENT of {len(payload)} bytes", ErrorReason.BAD_MAC) router_id_bytes = payload[:ROUTER_ID_LENGTH] client_nonce = payload[ROUTER_ID_LENGTH:ROUTER_ID_LENGTH + NONCE_LENGTH] mac_c = payload[ROUTER_ID_LENGTH + NONCE_LENGTH:] psk = hello.key key_index = hello.key_index expected_c = transcript_mac(psk, CLIENT_LABEL, server_nonce, client_nonce, router_id_bytes, tls_fingerprint) if not compare_digest(expected_c, mac_c): raise ProtocolError("client failed to prove key possession", ErrorReason.BAD_MAC) mac_s = transcript_mac(psk, SERVER_LABEL, server_nonce, client_nonce, router_id_bytes, tls_fingerprint) # The frame-level key was set to keyset.primary as a placeholder above, # since which key would authenticate was unknown until receive_any_key # resolved it; HELLO_ACCEPT must go out under that resolved key, not # the placeholder, or a client that used a non-primary key rejects it. channel.send_key = psk channel.send(FrameType.HELLO_ACCEPT, ZERO_TARGET, mac_s) except ProtocolError: # An unauthenticated peer gets no second try: drop the connection # immediately so it doesn't sit as a foothold, and so the other side's # blocked read fails fast instead of idling out its own timeout. channel.close() raise except ConnectionError as e: channel.close() raise ProtocolError(f"connection closed during handshake: {e}", ErrorReason.BAD_MAC) from e k_c2s, k_s2c = derive_session_keys(psk, server_nonce, client_nonce) channel.rekey(send_key=k_s2c, receive_key=k_c2s, key_index=key_index) return UUID(bytes=router_id_bytes), key_index
[docs] def server_handshake_unauthenticated(channel: SecureChannel) -> tuple[UUID, int]: """Development-only handshake with no key. Same frame exchange, all MACs keyed with a fixed public constant, so the Switch has exactly one code path. This provides no security whatsoever and is reachable only on a loopback bind (see edri.switch.switch). """ return server_handshake(channel, KeySet([DEVELOPMENT_KEY]))
[docs] def client_handshake_unauthenticated(channel: SecureChannel, router_id: UUID) -> None: client_handshake(channel, router_id, KeySet([DEVELOPMENT_KEY]))
# Public by design - it is in the source. Its length is not incidental: KeySet # rejects anything shorter than MINIMUM_SECRET_LENGTH, so do not "tidy" this # string down to something shorter or the loopback development path stops # starting at all. DEVELOPMENT_KEY = b"edri-development-no-authentication"