"""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"