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:
objectA received frame plus which key of a set authenticated it.
- class edri.protocol.channel.SecureChannel(sock: socket, max_message: int)[source]¶
Bases:
objectFrames 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 becauseserver_noncesits 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.- 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.
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:
IntEnumWhy a frame or handshake was rejected.
The value travels in the payload of an
ERRORframe, 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:
ExceptionA 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:
IntEnumFrame 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;keyofNonezeroes the MAC (HELLO_SERVER only).
- edri.protocol.frame.read_exactly(sock: socket, amount: int) bytes[source]¶
Read exactly
amountbytes or raise.recvmay 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.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.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.