iotsploit-protocols 0.0.9__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,322 @@
1
+ """A SOME/IP client: build a request, send it, match the response.
2
+
3
+ scapy is used as a **codec, not as a socket**. Its sending paths want root and a
4
+ live interface, which would make calling a method a privileged operation for no
5
+ reason; the wire format is the hard part and that is what scapy is for. So this
6
+ module builds and parses with ``scapy.contrib.automotive.someip`` and does I/O
7
+ on an ordinary socket.
8
+
9
+ Framing is done here rather than delegated, because scapy will not do it: given
10
+ two concatenated SOME/IP messages, ``SOMEIP(raw)`` returns one packet whose
11
+ payload is *both*. Over TCP, where one ``recv`` is not one message, that is the
12
+ same class of bug as reading a fixed 13 bytes and hoping. The length field is
13
+ authoritative and this module reads it.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+ import socket
20
+ from dataclasses import dataclass
21
+ from typing import Optional
22
+
23
+ from iotsploit_protocols.errors import NotConfigured, ProtocolError
24
+ from iotsploit_protocols.someip.codec import application_payload
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+ #: Bytes before the payload: srv_id, sub_id, len, client_id, session_id,
29
+ #: proto_ver, iface_ver, msg_type, retcode.
30
+ HEADER_LEN = 16
31
+ #: srv_id(2) + sub_id(2) + len(4). Everything after this is covered by ``len``.
32
+ LEN_PREFIX = 8
33
+
34
+ TYPE_REQUEST = 0x00
35
+ TYPE_REQUEST_NO_RETURN = 0x01
36
+ TYPE_RESPONSE = 0x80
37
+ TYPE_ERROR = 0x81
38
+
39
+ RETURN_CODE_NAMES = {
40
+ 0x00: "E_OK",
41
+ 0x01: "E_NOT_OK",
42
+ 0x02: "E_UNKNOWN_SERVICE",
43
+ 0x03: "E_UNKNOWN_METHOD",
44
+ 0x04: "E_NOT_READY",
45
+ 0x05: "E_NOT_REACHABLE",
46
+ 0x06: "E_TIMEOUT",
47
+ 0x07: "E_WRONG_PROTOCOL_VERSION",
48
+ 0x08: "E_WRONG_INTERFACE_VERSION",
49
+ 0x09: "E_MALFORMED_MESSAGE",
50
+ 0x0A: "E_WRONG_MESSAGE_TYPE",
51
+ }
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class SomeIpConfig:
56
+ """Where to send SOME/IP, and as whom.
57
+
58
+ ``host`` has no default on purpose. A helper that falls back to a built-in
59
+ address probes whatever happens to be at that address and reports its
60
+ silence as a finding. An unconfigured target must fail, loudly, here.
61
+ """
62
+
63
+ host: str
64
+ port: int
65
+ transport: str = "tcp"
66
+ client_id: int = 0x0001
67
+ timeout: float = 5.0
68
+
69
+ def __post_init__(self) -> None:
70
+ if not self.host:
71
+ raise NotConfigured("SOME/IP host is required; there is no default")
72
+ if not 0 < self.port < 65536:
73
+ raise NotConfigured(f"SOME/IP port {self.port!r} is out of range")
74
+ if self.transport not in ("tcp", "udp"):
75
+ raise NotConfigured(f"transport must be 'tcp' or 'udp', got {self.transport!r}")
76
+
77
+
78
+ @dataclass(frozen=True)
79
+ class SomeIpResponse:
80
+ """One reply, already parsed.
81
+
82
+ ``ok`` is the only thing most callers need. It is deliberately not "did we
83
+ get bytes back": an ERROR message with E_UNKNOWN_METHOD is a perfectly
84
+ well-formed reply that means no.
85
+ """
86
+
87
+ service_id: int
88
+ method_id: int
89
+ client_id: int
90
+ session_id: int
91
+ message_type: int
92
+ return_code: int
93
+ payload: bytes
94
+
95
+ @property
96
+ def ok(self) -> bool:
97
+ return self.message_type == TYPE_RESPONSE and self.return_code == 0x00
98
+
99
+ @property
100
+ def return_code_name(self) -> str:
101
+ return RETURN_CODE_NAMES.get(self.return_code, f"0x{self.return_code:02X}")
102
+
103
+
104
+ class SomeIpClient:
105
+ """One client is one socket to one endpoint.
106
+
107
+ Not a singleton and not shared: two endpoints means two clients. Use it as a
108
+ context manager so the socket is closed even when a call raises.
109
+ """
110
+
111
+ def __init__(self, config: SomeIpConfig) -> None:
112
+ self.config = config
113
+ self._sock: Optional[socket.socket] = None
114
+ # Wraps at 16 bits like the field it fills. Starts at 1 because 0 is
115
+ # conventionally "session handling disabled".
116
+ self._session_id = 0
117
+
118
+ # ── lifecycle ─────────────────────────────────────────────────────────
119
+
120
+ def __enter__(self) -> "SomeIpClient":
121
+ self.connect()
122
+ return self
123
+
124
+ def __exit__(self, *exc_info: object) -> None:
125
+ self.close()
126
+
127
+ def connect(self) -> None:
128
+ if self._sock is not None:
129
+ return
130
+ kind = socket.SOCK_STREAM if self.config.transport == "tcp" else socket.SOCK_DGRAM
131
+ sock = socket.socket(socket.AF_INET, kind)
132
+ sock.settimeout(self.config.timeout)
133
+ # connect() on a UDP socket sets the default peer; it does not handshake,
134
+ # so this is cheap and lets send()/recv() be used for both transports.
135
+ sock.connect((self.config.host, self.config.port))
136
+ self._sock = sock
137
+ logger.debug(
138
+ "SOME/IP connected %s://%s:%d", self.config.transport, self.config.host, self.config.port
139
+ )
140
+
141
+ def close(self) -> None:
142
+ if self._sock is None:
143
+ return
144
+ try:
145
+ self._sock.close()
146
+ except OSError:
147
+ logger.debug("SOME/IP socket close failed", exc_info=True)
148
+ finally:
149
+ self._sock = None
150
+
151
+ # ── requests ──────────────────────────────────────────────────────────
152
+
153
+ def call(
154
+ self,
155
+ service: int,
156
+ instance: int,
157
+ method: int,
158
+ payload: bytes = b"",
159
+ interface_version: int = 0x01,
160
+ ) -> SomeIpResponse:
161
+ """Send a request and wait for the matching response.
162
+
163
+ ``instance`` is accepted because callers think in service instances, but
164
+ it is not on the wire: SOME/IP addresses an instance by *endpoint*, and
165
+ the endpoint is what this client is connected to. It is used for logging
166
+ and to keep call sites honest about which instance they meant.
167
+ """
168
+ session_id = self._next_session_id()
169
+ request = self._build(
170
+ service, method, session_id, TYPE_REQUEST, payload, interface_version
171
+ )
172
+ logger.debug(
173
+ "SOME/IP -> service=%04X instance=%04X method=%04X session=%d len=%d",
174
+ service, instance, method, session_id, len(payload),
175
+ )
176
+ self._send(request)
177
+ return self._await_response(service, method, session_id)
178
+
179
+ def notify(
180
+ self,
181
+ service: int,
182
+ instance: int,
183
+ method: int,
184
+ payload: bytes = b"",
185
+ interface_version: int = 0x01,
186
+ ) -> None:
187
+ """Fire-and-forget. No response is expected and none is waited for."""
188
+ session_id = self._next_session_id()
189
+ logger.debug(
190
+ "SOME/IP -> (no return) service=%04X instance=%04X method=%04X session=%d",
191
+ service, instance, method, session_id,
192
+ )
193
+ self._send(
194
+ self._build(
195
+ service, method, session_id, TYPE_REQUEST_NO_RETURN, payload, interface_version
196
+ )
197
+ )
198
+
199
+ # ── internals ─────────────────────────────────────────────────────────
200
+
201
+ def _next_session_id(self) -> int:
202
+ # 1..0xFFFF, skipping 0.
203
+ self._session_id = (self._session_id % 0xFFFF) + 1
204
+ return self._session_id
205
+
206
+ def _build(
207
+ self,
208
+ service: int,
209
+ method: int,
210
+ session_id: int,
211
+ message_type: int,
212
+ payload: bytes,
213
+ interface_version: int,
214
+ ) -> bytes:
215
+ from scapy.contrib.automotive.someip import SOMEIP
216
+ from scapy.packet import Raw
217
+
218
+ packet = SOMEIP(
219
+ srv_id=service,
220
+ sub_id=method,
221
+ client_id=self.config.client_id,
222
+ session_id=session_id,
223
+ iface_ver=interface_version,
224
+ msg_type=message_type,
225
+ )
226
+ if payload:
227
+ packet = packet / Raw(payload)
228
+ return bytes(packet)
229
+
230
+ def _require_socket(self) -> socket.socket:
231
+ if self._sock is None:
232
+ raise ProtocolError("SOME/IP client is not connected; use it as a context manager")
233
+ return self._sock
234
+
235
+ def _send(self, data: bytes) -> None:
236
+ self._require_socket().sendall(data)
237
+
238
+ def _await_response(self, service: int, method: int, session_id: int) -> SomeIpResponse:
239
+ """Read messages until the one we asked for arrives.
240
+
241
+ Responses can interleave -- notably over UDP, where an event from
242
+ another service may land between our request and its answer. Matching on
243
+ (client_id, session_id) is what keeps a reply attached to its request;
244
+ anything else is logged and dropped rather than returned to the caller
245
+ as if it were the answer.
246
+ """
247
+ while True:
248
+ response = self._parse(self._read_message())
249
+ if response.client_id == self.config.client_id and response.session_id == session_id:
250
+ if response.service_id != service or response.method_id != method:
251
+ # Same session id, different message id: the peer is
252
+ # confused or something is spoofing. Do not silently accept.
253
+ raise ProtocolError(
254
+ f"response session {session_id} carries service "
255
+ f"{response.service_id:04X}/{response.method_id:04X}, "
256
+ f"expected {service:04X}/{method:04X}"
257
+ )
258
+ return response
259
+ logger.debug(
260
+ "SOME/IP dropping unrelated message client=%04X session=%d",
261
+ response.client_id, response.session_id,
262
+ )
263
+
264
+ def _read_message(self) -> bytes:
265
+ """One whole message, using the length field rather than hoping.
266
+
267
+ UDP delivers a datagram at a time, but a truncated or padded datagram
268
+ still has to agree with its own length field, so both transports are
269
+ validated the same way.
270
+ """
271
+ sock = self._require_socket()
272
+ if self.config.transport == "udp":
273
+ datagram = sock.recv(65535)
274
+ declared = self._declared_length(datagram)
275
+ if len(datagram) < LEN_PREFIX + declared:
276
+ raise ProtocolError(
277
+ f"SOME/IP datagram is {len(datagram)} bytes but declares "
278
+ f"{LEN_PREFIX + declared}"
279
+ )
280
+ return datagram[: LEN_PREFIX + declared]
281
+
282
+ head = self._read_exactly(LEN_PREFIX)
283
+ declared = self._declared_length(head)
284
+ return head + self._read_exactly(declared)
285
+
286
+ @staticmethod
287
+ def _declared_length(data: bytes) -> int:
288
+ if len(data) < LEN_PREFIX:
289
+ raise ProtocolError(f"SOME/IP message truncated: {len(data)} bytes")
290
+ declared = int.from_bytes(data[4:LEN_PREFIX], "big")
291
+ if declared < HEADER_LEN - LEN_PREFIX:
292
+ raise ProtocolError(f"SOME/IP length field {declared} is shorter than the header")
293
+ return declared
294
+
295
+ def _read_exactly(self, count: int) -> bytes:
296
+ sock = self._require_socket()
297
+ chunks = []
298
+ remaining = count
299
+ while remaining > 0:
300
+ chunk = sock.recv(remaining)
301
+ if not chunk:
302
+ raise ProtocolError(
303
+ f"SOME/IP peer closed after {count - remaining} of {count} bytes"
304
+ )
305
+ chunks.append(chunk)
306
+ remaining -= len(chunk)
307
+ return b"".join(chunks)
308
+
309
+ @staticmethod
310
+ def _parse(raw: bytes) -> SomeIpResponse:
311
+ from scapy.contrib.automotive.someip import SOMEIP
312
+
313
+ packet = SOMEIP(raw)
314
+ return SomeIpResponse(
315
+ service_id=packet.srv_id,
316
+ method_id=packet.sub_id,
317
+ client_id=packet.client_id,
318
+ session_id=packet.session_id,
319
+ message_type=int(packet.msg_type),
320
+ return_code=int(packet.retcode),
321
+ payload=application_payload(packet),
322
+ )
@@ -0,0 +1,11 @@
1
+ """Compatibility helpers for Scapy's SOME/IP packet representations."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ def application_payload(packet) -> bytes:
7
+ """Return application bytes from Scapy 2.6 and 2.7 SOME/IP packets."""
8
+ data = getattr(packet, "data", None)
9
+ if data is not None:
10
+ return b"".join(bytes(item) for item in data)
11
+ return bytes(packet.payload) if packet.payload else b""
@@ -0,0 +1,71 @@
1
+ """The SOME/IP facet, shipped beside the client that consumes it.
2
+
3
+ Core registers no protocol facets (``iotsploit_core.domain.facet``), so this
4
+ lives with ``SomeIpClient`` rather than in core, exactly like ``doip_facet`` and
5
+ ``can_facet``.
6
+
7
+ Three fields, and the omissions are the interesting part.
8
+
9
+ **No host.** A component already carries an address, and ``target.py`` already
10
+ resolves one three different ways -- the wart that facets exist to remove. A
11
+ fourth place to write an address would not add capability, it would add a
12
+ "which one wins" question. Address resolution lives in exactly one function, in
13
+ the Django binding layer.
14
+
15
+ **No service catalogue.** A list of services and their methods is a *vendor
16
+ description* (ARXML/FIBEX), and per ``can_facet``'s docstring that belongs in
17
+ the reference catalog, not in a JSON column that gets rewritten on every target
18
+ save. The same argument that keeps a 2000-signal DBC out of ``CanFacet`` keeps
19
+ a service catalogue out of this one.
20
+
21
+ **No SD multicast group.** That describes an Ethernet segment, not a component:
22
+ every component on the segment repeats the same value. It belongs on a ``Bus``
23
+ once buses can carry facets. Until then it is a plugin parameter, which is
24
+ honest about being segment-wide rather than pretending to be per-component.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from typing import Optional
30
+
31
+ from pydantic import Field
32
+
33
+ from iotsploit_core.domain.facet import Facet, register_facet
34
+
35
+ FACET_KEY = "someip"
36
+
37
+ # Same convention as the DoIP and CAN facets: the number is stored as an int so
38
+ # "0x1234", "1234" and 4660 cannot become three different keys, and the facet --
39
+ # not core, and not the UI -- declares that humans read it as hex.
40
+ HEX = {"format": "hex"}
41
+
42
+
43
+ @register_facet(FACET_KEY)
44
+ class SomeipFacet(Facet):
45
+ """How to reach one component's SOME/IP endpoint.
46
+
47
+ ``client_id`` identifies *this tester* to the ECU. It is configuration
48
+ rather than a constant because two testers on one bus must not share one,
49
+ and a collision shows up as responses being delivered to the wrong client.
50
+ """
51
+
52
+ port: Optional[int] = None
53
+ # "tcp" or "udp". Spelled out rather than a bool, because a third transport
54
+ # is not unthinkable and a bool would have to be renamed to add one.
55
+ transport: str = "tcp"
56
+ client_id: Optional[int] = Field(default=None, json_schema_extra=HEX)
57
+
58
+
59
+ def canonical_service_id(service_id: int, instance_id: int) -> str:
60
+ """The string form of a service instance, as an observation's ``subject_id``.
61
+
62
+ Fixed here rather than at each call site for the reason ``canonical_frame_id``
63
+ gives in ``can_facet``: reconciliation joins on this string, and a mismatch
64
+ does not fail loudly -- it silently matches nothing, which reads as "the
65
+ catalog knows nothing about this service".
66
+
67
+ Uppercase hex, four digits each, service first:
68
+ ``canonical_service_id(0x1234, 1) == "1234:0001"``. That is the form
69
+ ``docs/target_data_model_plan.md`` uses for SOME/IP subject ids.
70
+ """
71
+ return f"{service_id:04X}:{instance_id:04X}"