edri.protocol package

Submodules

edri.protocol.channel module

One connection's protocol state: keys, sequence counters and the send lock.

class edri.protocol.channel.KeyedFrame(frame: Frame, key: bytes, key_index: int)[source]

Bases: object

A received frame plus which key of a set authenticated it.

class edri.protocol.channel.SecureChannel(sock: socket, max_message: int)[source]

Bases: object

Frames in and out of one socket, authenticated per direction.

A channel starts pre-key, where it will accept exactly one unverified HELLO_SERVER — no key is agreed yet, and that frame is authenticated retroactively because server_nonce sits inside the handshake transcript. activate() moves it to established, after which every frame is verified and there is no path back short of a new connection.

activate(send_key: bytes, receive_key: bytes, key_index: int = 0) → None[source]
close() → None[source]
receive() → Frame[source]
receive_any_key(keys: list[bytes]) → KeyedFrame[source]

Read one frame, trying each key in turn.

Only the server's HELLO_CLIENT needs this: during a rotation the Switch holds several keys and cannot know which one the client used until the MAC verifies.

receive_unkeyed() → Frame[source]

Read the single permitted pre-key frame, with every field pinned.

The slot is consumed as soon as this call is allowed to proceed, not only once the frame turns out to be a valid HELLO_SERVER - a malformed or forged first attempt must burn the one chance the same as a good one, so "at most one attempt" holds regardless of what a caller does after a failure.

rekey(send_key: bytes, receive_key: bytes, key_index: int = 0) → None[source]

Swap in the derived session keys once the handshake agrees on them.

Sequence counters deliberately survive: the handshake frames already consumed 0 (and 1 downstream), and restarting the count would let a captured handshake frame be replayed as traffic.

send(frame_type: FrameType, target: bytes, payload: bytes) → None[source]
send_unkeyed(frame_type: FrameType, target: bytes, payload: bytes) → None[source]

Write the one frame that predates key agreement (HELLO_SERVER).

edri.protocol.delivery module

Application-level message identifiers used by Connector <-> Switch.

IDs live outside pickled Event objects and remain stable across retransmissions. An ACK confirms acceptance into the next process's in-memory queue, not execution by a manager, durability, or exactly-once effects.

edri.protocol.errors module

class edri.protocol.errors.ErrorReason(*values)[source]

Bases: IntEnum

Why a frame or handshake was rejected.

The value travels in the payload of an ERROR frame, so a peer can tell "wrong secret" from "unreachable" — something closing the socket cannot express. Values are part of the wire format: append, never renumber.

BAD_MAC = 2
BAD_SEQ = 3
BAD_VERSION = 1
TOO_LARGE = 4
UNKNOWN_ROUTER = 5
exception edri.protocol.errors.ProtocolError(message: str, reason: ErrorReason, *, transport: bool = False)[source]

Bases: Exception

A frame could not be accepted. Always fatal for its connection.

edri.protocol.frame module

Protocol v2 frame: one 48-byte header, identical in both directions.

offset  size  field
0       1     version
1       1     type
2       2     flags
4       8     seq
12      4     length
16      16    target
32      16    mac
48      ...   payload

The MAC covers header[0:32] ‖ payload — every header byte except the MAC field itself — so type, sequence and target are all authenticated, not just the body.

class edri.protocol.frame.Frame(type: edri.protocol.frame.FrameType, seq: int, target: bytes, payload: bytes, flags: int = 0)[source]

Bases: object

flags: int = 0
class edri.protocol.frame.FrameType(*values)[source]

Bases: IntEnum

Frame types occupy their own space, no longer shared with router UUIDs.

ACK = 19
DEMANDS = 17
ERROR = 32
EVENT = 16
HELLO_ACCEPT = 3
HELLO_CLIENT = 2
HELLO_SERVER = 1
LAST_EVENTS = 18
REKEY = 4
edri.protocol.frame.compute_mac(key: bytes, header_without_mac: bytes, payload: bytes) → bytes[source]
edri.protocol.frame.decode_header(header: bytes) → tuple[int, FrameType, int, int, int, bytes, bytes][source]

Split a 48-byte header. Does not verify the MAC — see verify_frame().

edri.protocol.frame.encode_frame(frame: Frame, key: bytes | None) → bytes[source]

Serialize frame; key of None zeroes the MAC (HELLO_SERVER only).

edri.protocol.frame.read_exactly(sock: socket, amount: int) → bytes[source]

Read exactly amount bytes or raise.

recv may return fewer bytes than asked for at any time; the pre-v2 code assumed otherwise in three places, which is how a frame could be parsed from a partial read.

edri.protocol.frame.read_frame(sock: socket, max_message: int) → tuple[bytes, bytes][source]

Read one frame. The length is validated before the payload is read.

edri.protocol.frame.verify_frame(header: bytes, payload: bytes, key: bytes) → None[source]

Raise ProtocolError unless header + payload authenticate.

edri.protocol.handshake module

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.

edri.protocol.handshake.client_handshake(channel: SecureChannel, router_id: UUID, keyset: KeySet, tls_fingerprint: bytes = b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00') → None[source]

Authenticate to a Switch and activate channel. Raises on failure.

edri.protocol.handshake.client_handshake_unauthenticated(channel: SecureChannel, router_id: UUID) → None[source]
edri.protocol.handshake.derive_session_keys(psk: bytes, server_nonce: bytes, client_nonce: bytes) → tuple[bytes, bytes][source]

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.

edri.protocol.handshake.server_handshake(channel: SecureChannel, keyset: KeySet, tls_fingerprint: bytes = b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00') → tuple[UUID, int][source]

Authenticate a connecting router. Returns (router_id, key_index).

edri.protocol.handshake.server_handshake_unauthenticated(channel: SecureChannel) → tuple[UUID, int][source]

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).

edri.protocol.handshake.transcript_mac(psk: bytes, label: bytes, server_nonce: bytes, client_nonce: bytes, router_id: bytes, tls_fingerprint: bytes) → bytes[source]

edri.protocol.keys module

Pre-shared keys, from the environment or a file.

A file is supported because environment variables are readable through /proc/<pid>/environ, docker inspect and pod specs, and because a file is what Kubernetes and Docker secrets mount. It holds one key per line so a rotation can have both the old and the new key present at once.

class edri.protocol.keys.KeySet(keys: list[bytes])[source]

Bases: object

One or more accepted keys. primary is what this process sends with.

classmethod from_file(path: str) → KeySet[source]
classmethod from_settings() → KeySet | None[source]

Build from configuration, or None when no secret is configured.

property primary: bytes

Module contents