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,51 @@
1
+ """The DoIP facet, shipped beside the client that consumes it.
2
+
3
+ Moved here from ``iotsploit_django.tools.doip_facet``: core deliberately
4
+ registers no protocol facets, and a facet belongs with the code that reads it.
5
+ The lookup helpers that used to live alongside this class did not come with it
6
+ -- they read the current target through Django, so they stayed in the binding
7
+ adapter. What is left is the part that is genuinely about DoIP.
8
+
9
+ The addresses this replaces were class constants on ``DoIP_Mgr``, which meant
10
+ adding a vehicle was a code edit. With a facet on the ECU component it is a
11
+ target edit.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import Optional
17
+
18
+ from pydantic import Field
19
+
20
+ from iotsploit_core.domain.facet import Facet, register_facet
21
+
22
+ FACET_KEY = "doip"
23
+
24
+ # Stored as an int, written and read by humans as hex. An editor that only knew
25
+ # the JSON type would make you type 4113 for an address every document in the
26
+ # field calls 0x1011. The facet that knows the convention declares it; core
27
+ # stays ignorant of what any protocol's numbers mean.
28
+ HEX = {"format": "hex"}
29
+
30
+ # Same idea for uniqueness. A logical address identifies one ECU, so two of them
31
+ # on one address is a fault worth showing; a CAN facet's bus_id is a reference to
32
+ # something shared and every component on a segment repeats it. Nothing in JSON
33
+ # Schema tells those apart, and a view that guessed from the field name would
34
+ # flag correct configuration as broken.
35
+ HEX_UNIQUE = {"format": "hex", "unique": True}
36
+
37
+
38
+ @register_facet(FACET_KEY)
39
+ class DoipFacet(Facet):
40
+ """DoIP/UDS addressing for one ECU.
41
+
42
+ ``logical_address`` is an int on purpose: it is the join key to the
43
+ reference catalog, and "0x1011", "1011" and 4113 must not be three different
44
+ keys. Secrets are deliberately absent -- PINs stay in ClassifiedInfo/Env_Mgr
45
+ rather than being copied into a JSON column.
46
+ """
47
+
48
+ logical_address: int = Field(json_schema_extra=HEX_UNIQUE)
49
+ tester_address: int = Field(default=0x0E80, json_schema_extra=HEX)
50
+ host: Optional[str] = None
51
+ port: int = 13400
@@ -0,0 +1,262 @@
1
+ """UDS on top of a DoIP connection.
2
+
3
+ Separate from ``client.py`` because UDS is not DoIP: the same services run over
4
+ ISO-TP on CAN, and a layer that knows about session control should not also know
5
+ about routing activation. ``UdsClient`` needs only something with a
6
+ ``request(bytes) -> bytes`` method.
7
+
8
+ What this replaces was a set of magic offsets -- ``resp_buf[12]``,
9
+ ``resp_buf[-3]``, ``resp_buf[-5]`` -- each call site indexing the raw buffer
10
+ differently and each one wrong for some message shape. One layer parses the
11
+ response once and hands back something with a name.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ import time
18
+ from dataclasses import dataclass
19
+ from typing import Callable, Optional, Protocol
20
+
21
+ from iotsploit_protocols.errors import ProtocolError
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ NEGATIVE_RESPONSE = 0x7F
26
+ #: requestCorrectlyReceived-ResponsePending. The ECU is working; wait for it.
27
+ NRC_RESPONSE_PENDING = 0x78
28
+
29
+ #: Services used here. Names, so no caller writes a bare byte again.
30
+ SERVICE_DIAGNOSTIC_SESSION_CONTROL = 0x10
31
+ SERVICE_ROUTINE_CONTROL = 0x31
32
+ SERVICE_READ_DATA_BY_IDENTIFIER = 0x22
33
+ SERVICE_SECURITY_ACCESS = 0x27
34
+ SERVICE_TESTER_PRESENT = 0x3E
35
+
36
+ SESSION_DEFAULT = 0x01
37
+ SESSION_PROGRAMMING = 0x02
38
+ SESSION_EXTENDED = 0x03
39
+
40
+ ROUTINE_START = 0x01
41
+ ROUTINE_STOP = 0x02
42
+ ROUTINE_REQUEST_RESULTS = 0x03
43
+
44
+
45
+ class Transport(Protocol):
46
+ """Anything that can carry one UDS payload and bring back the answer."""
47
+
48
+ def request(self, payload: bytes) -> bytes: ...
49
+
50
+ def read(self) -> bytes:
51
+ """The next response with no new request.
52
+
53
+ Needed for responsePending: after a 0x78 the ECU sends the real answer
54
+ unprompted, so re-sending would put the request on the bus twice --
55
+ which for a routine that does something physical is not a retry, it is
56
+ doing it again.
57
+ """
58
+ ...
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class UdsResponse:
63
+ """One parsed UDS response.
64
+
65
+ ``ok`` and ``nrc`` are the whole interface most callers need. A negative
66
+ response is not an exception here: "the ECU said no" is frequently the
67
+ finding being looked for, and raising would make the normal case awkward.
68
+ Callers that prefer an exception can check ``ok`` and raise their own.
69
+ """
70
+
71
+ service: int
72
+ data: bytes
73
+ nrc: Optional[int] = None
74
+ raw: bytes = b""
75
+
76
+ @property
77
+ def ok(self) -> bool:
78
+ return self.nrc is None
79
+
80
+ @property
81
+ def nrc_name(self) -> str:
82
+ if self.nrc is None:
83
+ return ""
84
+ return _nrc_name(self.nrc)
85
+
86
+
87
+ def _nrc_name(nrc: int) -> str:
88
+ """The standard label for a negative response code, via scapy's table."""
89
+ try:
90
+ from scapy.contrib.automotive.uds import UDS_NR
91
+
92
+ name = UDS_NR.fields_desc[1].i2s.get(nrc)
93
+ if name:
94
+ return str(name)
95
+ except Exception: # pragma: no cover - scapy layout changed
96
+ logger.debug("could not resolve NRC name from scapy", exc_info=True)
97
+ return f"0x{nrc:02X}"
98
+
99
+
100
+ class UdsClient:
101
+ """UDS requests over any transport that can carry them.
102
+
103
+ ``response_pending_attempts`` bounds how many 0x78 replies are waited
104
+ through. The old code had this handling commented out, which meant a busy
105
+ ECU -- the normal state during a routine -- was recorded as a failure.
106
+ """
107
+
108
+ def __init__(
109
+ self,
110
+ transport: Transport,
111
+ response_pending_attempts: int = 10,
112
+ response_pending_deadline: float = 30.0,
113
+ ) -> None:
114
+ self.transport = transport
115
+ self.response_pending_attempts = response_pending_attempts
116
+ self.response_pending_deadline = response_pending_deadline
117
+
118
+ # ── services ──────────────────────────────────────────────────────────
119
+
120
+ def session(self, kind: int = SESSION_DEFAULT) -> UdsResponse:
121
+ return self.request(bytes([SERVICE_DIAGNOSTIC_SESSION_CONTROL, kind]))
122
+
123
+ def tester_present(self, suppress_response: bool = False) -> UdsResponse:
124
+ sub = 0x80 if suppress_response else 0x00
125
+ return self.request(bytes([SERVICE_TESTER_PRESENT, sub]))
126
+
127
+ def read_did(self, did: int) -> UdsResponse:
128
+ return self.request(bytes([SERVICE_READ_DATA_BY_IDENTIFIER]) + did.to_bytes(2, "big"))
129
+
130
+ def routine(self, control: int, routine_id: int, data: bytes = b"") -> UdsResponse:
131
+ return self.request(
132
+ bytes([SERVICE_ROUTINE_CONTROL, control]) + routine_id.to_bytes(2, "big") + data
133
+ )
134
+
135
+ def is_alive(self) -> bool:
136
+ """Whether the ECU answers at all.
137
+
138
+ A negative response still counts: it proves something is there and
139
+ speaking UDS, which is what "alive" means here.
140
+ """
141
+ try:
142
+ self.session(SESSION_DEFAULT)
143
+ return True
144
+ except (ProtocolError, TimeoutError, OSError):
145
+ return False
146
+
147
+ def security_access(
148
+ self,
149
+ level: int,
150
+ pin: bytes,
151
+ key_fn: Callable[[bytes, bytes], bytes],
152
+ ) -> bool:
153
+ """Request a seed, derive the key with ``key_fn``, send it back.
154
+
155
+ ``key_fn`` is passed in rather than looked up by name. The derivation is
156
+ one manufacturer's proprietary algorithm; a library that shipped a
157
+ registry of them would be a library that ships them.
158
+
159
+ ``level`` is the requestSeed sub-function; the sendKey sub-function is
160
+ ``level + 1``, as ISO 14229 defines.
161
+ """
162
+ seed_response = self.request(bytes([SERVICE_SECURITY_ACCESS, level]))
163
+ if not seed_response.ok:
164
+ logger.error("security access: seed refused (%s)", seed_response.nrc_name)
165
+ return False
166
+
167
+ # The response is 67 <echoed sub-function> <seed...>. The echo is not
168
+ # part of the seed, and feeding it to the derivation yields a key the
169
+ # ECU will reject -- which looks exactly like a wrong PIN.
170
+ if len(seed_response.data) < 2:
171
+ raise ProtocolError("security access: ECU returned an empty seed")
172
+ echoed, seed = seed_response.data[0], seed_response.data[1:]
173
+ if echoed != level:
174
+ raise ProtocolError(
175
+ f"security access: ECU echoed sub-function 0x{echoed:02X}, "
176
+ f"expected 0x{level:02X}"
177
+ )
178
+ if not seed:
179
+ raise ProtocolError("security access: ECU returned an empty seed")
180
+ if not any(seed):
181
+ # An all-zero seed means the ECU considers this level already open.
182
+ logger.info("security access: level 0x%02X already unlocked", level)
183
+ return True
184
+
185
+ key = key_fn(seed, pin)
186
+ key_response = self.request(bytes([SERVICE_SECURITY_ACCESS, level + 1]) + key)
187
+ if not key_response.ok:
188
+ logger.error("security access: key rejected (%s)", key_response.nrc_name)
189
+ return False
190
+ return True
191
+
192
+ # ── the one place a response is parsed ────────────────────────────────
193
+
194
+ def request(self, payload: bytes) -> UdsResponse:
195
+ """Send a UDS payload, waiting through any responsePending replies."""
196
+ if not payload:
197
+ raise ValueError("UDS payload must not be empty")
198
+ service = payload[0]
199
+ deadline = time.monotonic() + self.response_pending_deadline
200
+
201
+ raw = self.transport.request(payload)
202
+
203
+ for attempt in range(1, self.response_pending_attempts + 1):
204
+ response = self._parse(service, raw)
205
+ if response.nrc != NRC_RESPONSE_PENDING:
206
+ return response
207
+ if time.monotonic() >= deadline:
208
+ raise ProtocolError(
209
+ f"UDS service 0x{service:02X} still pending after "
210
+ f"{self.response_pending_deadline}s"
211
+ )
212
+ logger.debug("UDS service 0x%02X response pending (%d)", service, attempt)
213
+ # Read, do not re-send: the ECU is already working on it.
214
+ raw = self.transport.read()
215
+
216
+ raise ProtocolError(
217
+ f"UDS service 0x{service:02X} stayed pending for "
218
+ f"{self.response_pending_attempts} attempts"
219
+ )
220
+
221
+ @staticmethod
222
+ def _parse(service: int, raw: bytes) -> UdsResponse:
223
+ if not raw:
224
+ raise ProtocolError(f"UDS service 0x{service:02X}: empty response")
225
+
226
+ if raw[0] == NEGATIVE_RESPONSE:
227
+ if len(raw) < 3:
228
+ raise ProtocolError(f"UDS negative response truncated: {raw.hex()}")
229
+ echoed, nrc = raw[1], raw[2]
230
+ if echoed != service:
231
+ raise ProtocolError(
232
+ f"UDS negative response echoes service 0x{echoed:02X}, "
233
+ f"expected 0x{service:02X}"
234
+ )
235
+ return UdsResponse(service=service, data=b"", nrc=nrc, raw=raw)
236
+
237
+ expected = service + 0x40
238
+ if raw[0] != expected:
239
+ raise ProtocolError(
240
+ f"UDS response starts 0x{raw[0]:02X}, expected 0x{expected:02X} "
241
+ f"for service 0x{service:02X}"
242
+ )
243
+ return UdsResponse(service=service, data=raw[1:], nrc=None, raw=raw)
244
+
245
+
246
+ class DoipUdsClient(UdsClient):
247
+ """A :class:`UdsClient` bound to a :class:`~.client.DoipClient`.
248
+
249
+ Exists only so callers do not have to write ``UdsClient(DoipClient(cfg))``
250
+ and remember which one is the context manager.
251
+ """
252
+
253
+ def __init__(self, doip_client, **kwargs) -> None:
254
+ super().__init__(doip_client, **kwargs)
255
+ self.doip = doip_client
256
+
257
+ def __enter__(self) -> "DoipUdsClient":
258
+ self.doip.connect()
259
+ return self
260
+
261
+ def __exit__(self, *exc_info: object) -> None:
262
+ self.doip.close()
@@ -0,0 +1,45 @@
1
+ """Errors a protocol client raises.
2
+
3
+ Three types, not a hierarchy for its own sake. Each one exists because a caller
4
+ can do something different about it:
5
+
6
+ ``NotConfigured`` the target is missing settings -- fix the target, not the code.
7
+ ``NegativeResponse`` the peer answered and said no -- the scan worked, the ECU refused.
8
+ ``ProtocolError`` everything else that is ours.
9
+
10
+ Transport failures are *not* wrapped. A connection refused is ``ConnectionError``
11
+ and a read timeout is ``TimeoutError``, both stdlib: re-spelling them in a new
12
+ namespace buys nothing and forces callers to catch two names for one condition.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import Optional
18
+
19
+
20
+ class ProtocolError(Exception):
21
+ """Base class for protocol-level failures."""
22
+
23
+
24
+ class NotConfigured(ProtocolError):
25
+ """Required configuration is absent.
26
+
27
+ Raised instead of falling back to a built-in address. A helper that guesses
28
+ a host silently probes the wrong device and reports its silence as a
29
+ finding, which is worse than not running.
30
+ """
31
+
32
+
33
+ class NegativeResponse(ProtocolError):
34
+ """The peer replied, and the reply says the request was refused.
35
+
36
+ Carries the raw code so a caller can branch on it. ``code`` is the SOME/IP
37
+ return code or the UDS NRC depending on who raised it; ``name`` is the
38
+ protocol's own label for that code when one is known.
39
+ """
40
+
41
+ def __init__(self, code: int, name: Optional[str] = None, message: str = "") -> None:
42
+ self.code = code
43
+ self.name = name
44
+ label = name or f"0x{code:02X}"
45
+ super().__init__(message or f"negative response: {label}")
@@ -0,0 +1,19 @@
1
+ """SOME/IP: call a method on an ECU, and hear what a segment offers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "FACET_KEY",
7
+ "SdConfig",
8
+ "ServiceDiscovery",
9
+ "ServiceOffer",
10
+ "SomeIpClient",
11
+ "SomeIpConfig",
12
+ "SomeIpResponse",
13
+ "SomeipFacet",
14
+ "canonical_service_id",
15
+ ]
16
+
17
+ from iotsploit_protocols.someip.client import SomeIpClient, SomeIpConfig, SomeIpResponse
18
+ from iotsploit_protocols.someip.facet import FACET_KEY, SomeipFacet, canonical_service_id
19
+ from iotsploit_protocols.someip.sd import SdConfig, ServiceDiscovery, ServiceOffer