1"""SaF - Python mirror of the safe-communication Safety Functional layer.
3Byte-identical to ``Platform_Protocol_SafeComm`` (``src/mac/mac.c``, ``src/saf/saf.c``), the same way
4``simcore.rbc_wire`` mirrors the C wire codec. The construction follows Subset-037-2 v4.0.0
5("EuroRadio FIS - Safety Layer") directly:
7* DES, pure Python so a sim needs no crypto package.
8* CBC-MAC (Subset-037-2 SS6.2.2, "single DES with modified MAC algorithm 3"): every block but the last is
9 single-DES under K1; the last block alone gets E(K3, D(K2, E(K1, .))). ISO/IEC 9797-1 Padding Method 1
10 (zero bits, no extra block when the message is already a multiple of 64 bits). Verified against the
11 official worked example in Subset-037-2 Annex B.
12* KSMAC derivation (SS6.2.3.2.3.13-16): RA and RB are each split into 32-bit left/right halves; KS1/KS2 use
13 the normal key order over the left/right halves, KS3 reuses the left half with the key order reversed.
14* Frames AU1 (13) / AU2 (21) / AU3 (9) / AR (9) / DT (1+n+8) / DI (3, unauthenticated) per Subset-037-2
15 SS6.2.5's tables: a packed ETY|MTI|DF header octet and 24-bit ETCS IDs. The MAC input for AU2/AU3/DT/AR
16 follows SS6.2.3.2.1 (message origin authentication: length|DA|body) and SS6.2.3.2.2/Figure 6 (peer entity
17 authentication) - DA is always the *receiver's own* identity.
18* Sa-DISCONNECT carries no MAC or identity field at all (Table 6/13): SS6.2.3.3.3.2 says release-phase
19 authentication is not required, since a forged one can only end the connection early, never inject or
20 alter safety data. Any well-formed DI is honoured.
22One deliberate, non-conformant addition: the official DT SaPDU carries no sequence number (Table 12) - that
23defence is the Subset-098 SAI sub-layer's job (sequence numbering / Triple Time Stamping / EC counter,
24SS5.4), which this module does not implement. In its place a 4-byte sequence number is carried inside the
25MAC-covered user-data area, matching what ``saf.c`` does. This is a project extension, not itself
26conformant to any subset - see ``docs/safety/SUBSET_MAPPING.md`` in the SafeComm library.
28The session is transport-agnostic: the caller supplies ``send(bytes)`` and ``recv() -> bytes`` (one
29frame per call). ``recv`` raises ``TimeoutError`` when nothing arrived in time.
31This is a test tool. The MAC is cryptographically dated (see the C library's SUBSET_MAPPING.md).
38_IP = (58, 50, 42, 34, 26, 18, 10, 2, 60, 52, 44, 36, 28, 20, 12, 4,
39 62, 54, 46, 38, 30, 22, 14, 6, 64, 56, 48, 40, 32, 24, 16, 8,
40 57, 49, 41, 33, 25, 17, 9, 1, 59, 51, 43, 35, 27, 19, 11, 3,
41 61, 53, 45, 37, 29, 21, 13, 5, 63, 55, 47, 39, 31, 23, 15, 7)
42_FP = (40, 8, 48, 16, 56, 24, 64, 32, 39, 7, 47, 15, 55, 23, 63, 31,
43 38, 6, 46, 14, 54, 22, 62, 30, 37, 5, 45, 13, 53, 21, 61, 29,
44 36, 4, 44, 12, 52, 20, 60, 28, 35, 3, 43, 11, 51, 19, 59, 27,
45 34, 2, 42, 10, 50, 18, 58, 26, 33, 1, 41, 9, 49, 17, 57, 25)
46_E = (32, 1, 2, 3, 4, 5, 4, 5, 6, 7, 8, 9, 8, 9, 10, 11, 12, 13,
47 12, 13, 14, 15, 16, 17, 16, 17, 18, 19, 20, 21, 20, 21, 22, 23, 24, 25,
48 24, 25, 26, 27, 28, 29, 28, 29, 30, 31, 32, 1)
49_P = (16, 7, 20, 21, 29, 12, 28, 17, 1, 15, 23, 26, 5, 18, 31, 10,
50 2, 8, 24, 14, 32, 27, 3, 9, 19, 13, 30, 6, 22, 11, 4, 25)
51_PC1 = (57, 49, 41, 33, 25, 17, 9, 1, 58, 50, 42, 34, 26, 18,
52 10, 2, 59, 51, 43, 35, 27, 19, 11, 3, 60, 52, 44, 36,
53 63, 55, 47, 39, 31, 23, 15, 7, 62, 54, 46, 38, 30, 22,
54 14, 6, 61, 53, 45, 37, 29, 21, 13, 5, 28, 20, 12, 4)
55_PC2 = (14, 17, 11, 24, 1, 5, 3, 28, 15, 6, 21, 10, 23, 19, 12, 4,
56 26, 8, 16, 7, 27, 20, 13, 2, 41, 52, 31, 37, 47, 55, 30, 40,
57 51, 45, 33, 48, 44, 49, 39, 56, 34, 53, 46, 42, 50, 36, 29, 32)
58_SHIFTS = (1, 1, 2, 2, 2, 2, 2, 2, 1, 2, 2, 2, 2, 2, 2, 1)
60 (14, 4, 13, 1, 2, 15, 11, 8, 3, 10, 6, 12, 5, 9, 0, 7,
61 0, 15, 7, 4, 14, 2, 13, 1, 10, 6, 12, 11, 9, 5, 3, 8,
62 4, 1, 14, 8, 13, 6, 2, 11, 15, 12, 9, 7, 3, 10, 5, 0,
63 15, 12, 8, 2, 4, 9, 1, 7, 5, 11, 3, 14, 10, 0, 6, 13),
64 (15, 1, 8, 14, 6, 11, 3, 4, 9, 7, 2, 13, 12, 0, 5, 10,
65 3, 13, 4, 7, 15, 2, 8, 14, 12, 0, 1, 10, 6, 9, 11, 5,
66 0, 14, 7, 11, 10, 4, 13, 1, 5, 8, 12, 6, 9, 3, 2, 15,
67 13, 8, 10, 1, 3, 15, 4, 2, 11, 6, 7, 12, 0, 5, 14, 9),
68 (10, 0, 9, 14, 6, 3, 15, 5, 1, 13, 12, 7, 11, 4, 2, 8,
69 13, 7, 0, 9, 3, 4, 6, 10, 2, 8, 5, 14, 12, 11, 15, 1,
70 13, 6, 4, 9, 8, 15, 3, 0, 11, 1, 2, 12, 5, 10, 14, 7,
71 1, 10, 13, 0, 6, 9, 8, 7, 4, 15, 14, 3, 11, 5, 2, 12),
72 (7, 13, 14, 3, 0, 6, 9, 10, 1, 2, 8, 5, 11, 12, 4, 15,
73 13, 8, 11, 5, 6, 15, 0, 3, 4, 7, 2, 12, 1, 10, 14, 9,
74 10, 6, 9, 0, 12, 11, 7, 13, 15, 1, 3, 14, 5, 2, 8, 4,
75 3, 15, 0, 6, 10, 1, 13, 8, 9, 4, 5, 11, 12, 7, 2, 14),
76 (2, 12, 4, 1, 7, 10, 11, 6, 8, 5, 3, 15, 13, 0, 14, 9,
77 14, 11, 2, 12, 4, 7, 13, 1, 5, 0, 15, 10, 3, 9, 8, 6,
78 4, 2, 1, 11, 10, 13, 7, 8, 15, 9, 12, 5, 6, 3, 0, 14,
79 11, 8, 12, 7, 1, 14, 2, 13, 6, 15, 0, 9, 10, 4, 5, 3),
80 (12, 1, 10, 15, 9, 2, 6, 8, 0, 13, 3, 4, 14, 7, 5, 11,
81 10, 15, 4, 2, 7, 12, 9, 5, 6, 1, 13, 14, 0, 11, 3, 8,
82 9, 14, 15, 5, 2, 8, 12, 3, 7, 0, 4, 10, 1, 13, 11, 6,
83 4, 3, 2, 12, 9, 5, 15, 10, 11, 14, 1, 7, 6, 0, 8, 13),
84 (4, 11, 2, 14, 15, 0, 8, 13, 3, 12, 9, 7, 5, 10, 6, 1,
85 13, 0, 11, 7, 4, 9, 1, 10, 14, 3, 5, 12, 2, 15, 8, 6,
86 1, 4, 11, 13, 12, 3, 7, 14, 10, 15, 6, 8, 0, 5, 9, 2,
87 6, 11, 13, 8, 1, 4, 10, 7, 9, 5, 0, 15, 14, 2, 3, 12),
88 (13, 2, 8, 4, 6, 15, 11, 1, 10, 9, 3, 14, 5, 0, 12, 7,
89 1, 15, 13, 8, 10, 3, 7, 4, 12, 5, 6, 11, 0, 14, 9, 2,
90 7, 11, 4, 1, 9, 12, 14, 2, 0, 6, 10, 13, 15, 3, 5, 8,
91 2, 1, 14, 7, 4, 10, 8, 13, 15, 12, 9, 0, 3, 5, 6, 11),
96 """Bit permutation: ``table`` lists 1-based source bit positions, MSB first."""
99 out = (out << 1) | ((value >> (width - pos)) & 1)
104 k =
_permute(int.from_bytes(key8,
"big"), _PC1, 64)
105 c, d = k >> 28, k & 0xFFFFFFF
107 for shift
in _SHIFTS:
108 c = ((c << shift) | (c >> (28 - shift))) & 0xFFFFFFF
109 d = ((d << shift) | (d >> (28 - shift))) & 0xFFFFFFF
110 keys.append(
_permute((c << 28) | d, _PC2, 56))
118 chunk = (x >> (42 - 6 * i)) & 0x3F
119 row = ((chunk & 0x20) >> 4) | (chunk & 1)
120 col = (chunk >> 1) & 0xF
121 out = (out << 4) | _SBOX[i][row * 16 + col]
126 b =
_permute(int.from_bytes(block8,
"big"), _IP, 64)
127 left, right = b >> 32, b & 0xFFFFFFFF
129 left, right = right, left ^
_feistel(right, sk)
130 return _permute((right << 32) | left, _FP, 64).to_bytes(8,
"big")
134 """Single DES, one 8-byte block."""
139 """Single DES decrypt, one 8-byte block."""
144 """Subset-037-2 SS6.2.2.1 "single DES with modified MAC algorithm 3": E(K3, D(K2, E(K1, block)))."""
151 """The same construction with K1 and K3 swapped: E(K1, D(K2, E(K3, block))). Used only for KS3."""
166 """Session key (SS6.2.3.2.3.13-16): RA = RA_L|RA_R, RB = RB_L|RB_R (32-bit halves).
167 KS1 = algo3(RA_L|RB_L), KS2 = algo3(RA_R|RB_R), KS3 = algo3_reversed(RA_L|RB_L)."""
168 if len(kmac) != KEY_LEN
or len(ra) != RAND_LEN
or len(rb) != RAND_LEN:
169 raise ValueError(
"kmac 24 bytes, ra and rb 8 bytes")
170 block_l = ra[0:4] + rb[0:4]
171 block_r = ra[4:8] + rb[4:8]
176 """CBC-MAC (SS6.2.2): every block but the last under K1 only; the last block via _algo3. ISO/IEC 9797-1
177 Padding Method 1 (zero bits), no extra block when len(msg) is already a multiple of 8."""
178 padded_len = len(msg)
if (len(msg) % 8 == 0)
else (len(msg) + (8 - len(msg) % 8))
179 padded_len = padded_len
or 8
180 padded = bytes(msg) + bytes(padded_len - len(msg))
181 nblocks = padded_len // 8
183 for i
in range(nblocks):
184 block = padded[i * 8:(i + 1) * 8]
185 state = bytes(a ^ b
for a, b
in zip(state, block))
189 state =
_algo3(ksmac, state)
197 for x, y
in zip(a, b):
203 """[length(2, BE) | DA(3) | body], MACed (SS6.2.3.2.1.1 "Procedure 1"). compute_mac() zero-pads itself."""
204 length = 3 + len(body)
205 buf = length.to_bytes(2,
"big") +
_pack_id24(da_id) + bytes(body)
212MTI_AU1, MTI_AU2, MTI_AU3, MTI_DT, MTI_DI, MTI_AR = 1, 2, 3, 5, 8, 9
218 return ((id_ & 0xFFFFFF)).to_bytes(3,
"big")
222 return int.from_bytes(buf,
"big")
226 return bytes([((ety & 0x7) << 5) | ((mti & 0xF) << 1) | (df & 1)])
230 return (header_byte >> 1) & 0xF
234 return header_byte & 1
238 """A SaF failure; ``status`` names the matching C ``SAFECOMM_STATUS_*`` value."""
241 super().
__init__(f
"{status}{': ' + detail if detail else ''}")
245IDLE, WAIT_AU2, WAIT_AU3, WAIT_AR, DATA =
"IDLE",
"WAIT_AU2",
"WAIT_AU3",
"WAIT_AR",
"DATA"
249 """One SaF connection. ``role`` is "initiator" or "responder".
251 ``send(frame: bytes)`` transmits one frame; ``recv() -> bytes`` returns one frame or raises
252 ``TimeoutError``. ``clock()`` returns milliseconds (default: monotonic). ``rng(n)`` returns n random
253 bytes (default ``os.urandom``); tests inject a fixed one. ``ety`` is the 3-bit ETCS ID type
254 (Subset-037-2 Table 8: 1=RBC, 2=Engine, ...).
257 def __init__(self, role, id_self, id_peer, kmac, send, recv, *, ety=0, t_connect_ms=0,
258 t_supervision_ms=0, clock=None, rng=None):
259 if role
not in (
"initiator",
"responder"):
260 raise ValueError(
"role must be initiator or responder")
261 if len(kmac) != KEY_LEN:
262 raise ValueError(
"kmac must be 24 bytes")
284 self.
_fail(
"TIMEOUT",
"no frame")
290 if deadline
is not None and (self.
_clock() - deadline[0]) & 0xFFFFFFFF > deadline[1]:
291 self.
_fail(
"TIMEOUT",
"connect timeout")
295 """Run AU1-AU2-AU3-AR. Raises ``SafError`` (state returns to IDLE) on any failure or timeout."""
297 if self.
role ==
"initiator":
318 """Begin a handshake: returns AU1 to send; the session waits for AU2."""
319 if self.
role !=
"initiator":
320 raise SafError(
"STATE",
"not an initiator")
321 self.
state = WAIT_AU2
322 self.
ra = self.
_rng(RAND_LEN)
327 """True while a handshake is in progress and has run longer than ``t_connect_ms`` (0 = never)."""
332 """AU2 -> returns AU3 (waits for AR); AR -> returns None (now DATA). Raises ``SafError`` (to IDLE)."""
333 if self.
state == WAIT_AU2:
335 self.
_fail(
"AUTH_FAILED",
"AU2 shape")
337 self.
_fail(
"AUTH_FAILED",
"AU2 sender")
338 self.
rb = frame[5:13]
343 self.
_fail(
"AUTH_FAILED",
"AU2 MAC")
348 if self.
state == WAIT_AR:
350 self.
_fail(
"AUTH_FAILED",
"AR shape")
352 self.
_fail(
"AUTH_FAILED",
"AR MAC")
357 raise SafError(
"STATE",
"no handshake in progress")
363 self.
_fail(
"AUTH_FAILED",
"AU1 shape")
365 self.
_fail(
"AUTH_FAILED",
"AU1 sender")
367 self.
state = WAIT_AU3
368 self.
rb = self.
_rng(RAND_LEN)
380 self.
_fail(
"AUTH_FAILED",
"AU3 shape")
383 self.
_fail(
"AUTH_FAILED",
"AU3 MAC")
391 """The DT frame for ``pdu`` at the current transmit sequence number (does not advance it)."""
392 if self.
state != DATA:
393 raise SafError(
"STATE",
"not connected")
394 if len(pdu) > MAX_PDU:
395 raise SafError(
"INVALID_PARAM",
"pdu too long")
396 df = 0
if self.
role ==
"initiator" else 1
406 """Receive one authenticated PDU. Any protection failure returns the session to IDLE."""
407 if self.
state != DATA:
408 raise SafError(
"STATE",
"not connected")
412 raise SafError(
"TIMEOUT",
"no frame")
from None
416 """Verify one already-received Sa-DATA / Sa-DISCONNECT frame and return its PDU (``recv_pdu``'s checks)."""
417 if self.
state != DATA:
418 raise SafError(
"STATE",
"not connected")
420 self.
_fail(
"TRANSPORT",
"empty frame")
424 raise SafError(
"TRANSPORT",
"malformed disconnect")
427 expected_df = 1
if self.
role ==
"initiator" else 0
428 if _header_mti(frame[0]) != MTI_DT
or len(frame) < (1 + 4 + MAC_LEN):
429 self.
_fail(
"STATE",
"unexpected pdu type")
431 self.
_fail(
"AUTH_FAILED",
"direction flag (reflection)")
432 body = frame[:-MAC_LEN]
434 self.
_fail(
"MAC_MISMATCH")
435 rx_seq = int.from_bytes(frame[1:5],
"big")
436 if (rx_seq - self.
seq_rx) & 0xFFFFFFFF > SEQ_WINDOW:
437 self.
_fail(
"SEQ_ERROR", f
"got {rx_seq}, expected {self.seq_rx}..+{SEQ_WINDOW}")
438 self.
seq_rx = (rx_seq + 1) & 0xFFFFFFFF
443 """Call periodically: drop the session when nothing valid arrived within ``t_supervision_ms``."""
444 if self.
state != DATA:
445 raise SafError(
"STATE",
"not connected")
447 self.
_fail(
"TIMEOUT",
"supervision")
450 """Send an unauthenticated Sa-DISCONNECT (reason 0, sub-reason 0) and go to IDLE."""
451 if self.
state == DATA:
452 df = 0
if self.
role ==
"initiator" else 1
463 return int(time.monotonic() * 1000) & 0xFFFFFFFF
__init__(self, status, detail="")
_check_deadline(self, deadline)
_connect_initiator(self, deadline)
_connect_responder(self, deadline)
__init__(self, role, id_self, id_peer, kmac, send, recv, *, ety=0, t_connect_ms=0, t_supervision_ms=0, clock=None, rng=None)
initiator_on_frame(self, frame)
accept_frame(self, frame)
_fail(self, status, detail="")
_pack_header(ety, mti, df)
_algo3_reversed(key24, block8)
_permute(value, table, width)
des_encrypt_block(key8, block8)
derive_ksmac(kmac, ra, rb)
_des_block(block8, subkeys)
des_decrypt_block(key8, block8)
mac_over_da_body(ksmac, da_id, body)