cryptnox-id-cli 1.0.2__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.
Files changed (79) hide show
  1. cryptnox_id_cli/__init__.py +22 -0
  2. cryptnox_id_cli/__main__.py +6 -0
  3. cryptnox_id_cli/applets/__init__.py +2 -0
  4. cryptnox_id_cli/applets/fido/__init__.py +7 -0
  5. cryptnox_id_cli/applets/fido/authdata.py +81 -0
  6. cryptnox_id_cli/applets/fido/constants.py +155 -0
  7. cryptnox_id_cli/applets/fido/ctap.py +430 -0
  8. cryptnox_id_cli/applets/fido/errors.py +90 -0
  9. cryptnox_id_cli/applets/fido/pinproto.py +166 -0
  10. cryptnox_id_cli/applets/genuine/__init__.py +12 -0
  11. cryptnox_id_cli/applets/genuine/constants.py +32 -0
  12. cryptnox_id_cli/applets/genuine/genuine.py +85 -0
  13. cryptnox_id_cli/applets/genuine/verify.py +150 -0
  14. cryptnox_id_cli/applets/mifare/__init__.py +10 -0
  15. cryptnox_id_cli/applets/mifare/desfire.py +353 -0
  16. cryptnox_id_cli/applets/mifare/ev2.py +421 -0
  17. cryptnox_id_cli/applets/piv/__init__.py +9 -0
  18. cryptnox_id_cli/applets/piv/admin.py +182 -0
  19. cryptnox_id_cli/applets/piv/apt.py +58 -0
  20. cryptnox_id_cli/applets/piv/constants.py +66 -0
  21. cryptnox_id_cli/applets/piv/keyimport.py +244 -0
  22. cryptnox_id_cli/applets/piv/objects.py +126 -0
  23. cryptnox_id_cli/applets/piv/perso.py +138 -0
  24. cryptnox_id_cli/applets/piv/piv.py +171 -0
  25. cryptnox_id_cli/applets/piv/preperso.py +174 -0
  26. cryptnox_id_cli/applets/piv/profiles.py +486 -0
  27. cryptnox_id_cli/applets/piv/slots.py +44 -0
  28. cryptnox_id_cli/cli/__init__.py +1 -0
  29. cryptnox_id_cli/cli/commands/__init__.py +1 -0
  30. cryptnox_id_cli/cli/commands/apdu.py +123 -0
  31. cryptnox_id_cli/cli/commands/doctor.py +144 -0
  32. cryptnox_id_cli/cli/commands/factory.py +312 -0
  33. cryptnox_id_cli/cli/commands/fido.py +729 -0
  34. cryptnox_id_cli/cli/commands/genuine.py +192 -0
  35. cryptnox_id_cli/cli/commands/info.py +94 -0
  36. cryptnox_id_cli/cli/commands/mifare.py +998 -0
  37. cryptnox_id_cli/cli/commands/piv.py +2558 -0
  38. cryptnox_id_cli/cli/commands/readers.py +64 -0
  39. cryptnox_id_cli/cli/commands/report.py +217 -0
  40. cryptnox_id_cli/cli/commands/shell.py +130 -0
  41. cryptnox_id_cli/cli/context.py +71 -0
  42. cryptnox_id_cli/cli/dryrun.py +181 -0
  43. cryptnox_id_cli/cli/main.py +117 -0
  44. cryptnox_id_cli/crypto/__init__.py +1 -0
  45. cryptnox_id_cli/crypto/attestation.py +169 -0
  46. cryptnox_id_cli/crypto/csr.py +135 -0
  47. cryptnox_id_cli/crypto/piv_objects.py +79 -0
  48. cryptnox_id_cli/crypto/x509util.py +37 -0
  49. cryptnox_id_cli/output/__init__.py +5 -0
  50. cryptnox_id_cli/output/render.py +106 -0
  51. cryptnox_id_cli/secrets/__init__.py +5 -0
  52. cryptnox_id_cli/secrets/redaction.py +141 -0
  53. cryptnox_id_cli/secrets/resolver.py +93 -0
  54. cryptnox_id_cli/state/__init__.py +19 -0
  55. cryptnox_id_cli/state/detector.py +273 -0
  56. cryptnox_id_cli/state/model.py +147 -0
  57. cryptnox_id_cli/transport/__init__.py +28 -0
  58. cryptnox_id_cli/transport/apdu.py +72 -0
  59. cryptnox_id_cli/transport/elevation.py +165 -0
  60. cryptnox_id_cli/transport/errors.py +185 -0
  61. cryptnox_id_cli/transport/pcsc.py +351 -0
  62. cryptnox_id_cli/transport/scp02.py +247 -0
  63. cryptnox_id_cli/transport/scp03.py +213 -0
  64. cryptnox_id_cli/trust/__init__.py +90 -0
  65. cryptnox_id_cli/trust/genuine/cryptnox-attestation-ca.pem +18 -0
  66. cryptnox_id_cli/trust/genuine/cryptnox-dlt-cards-ca.pem +17 -0
  67. cryptnox_id_cli/trust/genuine/cryptnox-genuineness-ca.pem +18 -0
  68. cryptnox_id_cli/trust/genuine/cryptnox-intermediate-ca-2.pem +19 -0
  69. cryptnox_id_cli/trust/genuine/cryptnox-intermediate-ca.pem +19 -0
  70. cryptnox_id_cli/trust/genuine/cryptnox-root-ca.pem +19 -0
  71. cryptnox_id_cli/util/__init__.py +1 -0
  72. cryptnox_id_cli/util/hexutil.py +36 -0
  73. cryptnox_id_cli/util/tlv.py +144 -0
  74. cryptnox_id_cli-1.0.2.dist-info/METADATA +227 -0
  75. cryptnox_id_cli-1.0.2.dist-info/RECORD +79 -0
  76. cryptnox_id_cli-1.0.2.dist-info/WHEEL +5 -0
  77. cryptnox_id_cli-1.0.2.dist-info/entry_points.txt +4 -0
  78. cryptnox_id_cli-1.0.2.dist-info/licenses/LICENSE +165 -0
  79. cryptnox_id_cli-1.0.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,351 @@
1
+ """pyscard wrapper: passive reader status, Cryptnox/ACS-first selection, and a CardSession
2
+ that handles 6Cxx/61xx chaining and writes a redacted APDU transcript."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import contextlib
7
+ import re
8
+ from collections.abc import Callable
9
+ from dataclasses import dataclass
10
+ from typing import Protocol, TextIO
11
+
12
+ from cryptnox_id_cli.secrets.redaction import Redactor
13
+ from cryptnox_id_cli.transport.apdu import APDU, Response
14
+ from cryptnox_id_cli.transport.elevation import looks_like_no_access
15
+ from cryptnox_id_cli.transport.errors import (
16
+ CardAccessDeniedError,
17
+ NoCardError,
18
+ NoReadersError,
19
+ ReaderNotFoundError,
20
+ TransportError,
21
+ )
22
+
23
+ #: Substrings that identify the project's own readers: the Cryptnox-branded units
24
+ #: ("CryptnoxCR" contact, "Cryptnox NFC" contactless) and the ACS readers they are
25
+ #: based on. Selection prefers these so we never auto-target another project's card.
26
+ PREFERRED_HINTS = ("Cryptnox", "ACS", "ACR39")
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class ReaderInfo:
31
+ index: int
32
+ name: str
33
+ present: bool
34
+ atr: bytes
35
+
36
+ @property
37
+ def atr_hex(self) -> str:
38
+ return self.atr.hex().upper()
39
+
40
+ @property
41
+ def is_preferred(self) -> bool:
42
+ return any(h.lower() in self.name.lower() for h in PREFERRED_HINTS)
43
+
44
+
45
+ class RawConnection(Protocol):
46
+ """Minimal card-connection interface (satisfied by pyscard and the test mock)."""
47
+
48
+ def transmit(self, apdu: list[int]) -> tuple[list[int], int, int]: ...
49
+ def get_atr(self) -> bytes: ...
50
+ def disconnect(self) -> None: ...
51
+
52
+
53
+ # --------------------------------------------------------------------------- #
54
+ # Reader enumeration (passive — no card session, no APDU) #
55
+ # --------------------------------------------------------------------------- #
56
+ def reader_states() -> list[ReaderInfo]:
57
+ """List readers with card-present + ATR, passively via SCardGetStatusChange.
58
+
59
+ This does NOT open a card session or transmit anything — safe for readers that
60
+ hold other projects' cards.
61
+ """
62
+ try:
63
+ from smartcard.scard import (
64
+ SCARD_S_SUCCESS,
65
+ SCARD_SCOPE_USER,
66
+ SCARD_STATE_PRESENT,
67
+ SCARD_STATE_UNAWARE,
68
+ SCardEstablishContext,
69
+ SCardGetStatusChange,
70
+ SCardListReaders,
71
+ SCardReleaseContext,
72
+ )
73
+ except ImportError as exc: # pragma: no cover - pyscard always present at runtime
74
+ raise TransportError(f"pyscard unavailable: {exc}") from exc
75
+
76
+ hresult, hcontext = SCardEstablishContext(SCARD_SCOPE_USER)
77
+ if hresult != SCARD_S_SUCCESS:
78
+ raise TransportError(
79
+ "Cannot reach the PC/SC service. On Windows ensure 'Smart Card' (SCardSvr) "
80
+ "is running; on Linux start pcscd."
81
+ )
82
+ try:
83
+ hresult, names = SCardListReaders(hcontext, [])
84
+ if hresult != SCARD_S_SUCCESS or not names:
85
+ return []
86
+ states = [(name, SCARD_STATE_UNAWARE) for name in names]
87
+ hresult, new_states = SCardGetStatusChange(hcontext, 0, states)
88
+ infos: list[ReaderInfo] = []
89
+ for index, entry in enumerate(new_states):
90
+ name, event_state, atr = entry
91
+ present = bool(event_state & SCARD_STATE_PRESENT)
92
+ infos.append(ReaderInfo(index, name, present, bytes(atr)))
93
+ return infos
94
+ finally:
95
+ SCardReleaseContext(hcontext)
96
+
97
+
98
+ def list_reader_names() -> list[str]:
99
+ try:
100
+ from smartcard.System import readers as _readers
101
+ except ImportError as exc: # pragma: no cover
102
+ raise TransportError(f"pyscard unavailable: {exc}") from exc
103
+ try:
104
+ return [str(r) for r in _readers()]
105
+ except Exception as exc: # pragma: no cover - defensive
106
+ raise TransportError(f"Cannot list readers: {exc}") from exc
107
+
108
+
109
+ def _listing(infos: list[ReaderInfo]) -> str:
110
+ return ", ".join(f"[{r.index}] {r.name}" for r in infos)
111
+
112
+
113
+ def select_reader(infos: list[ReaderInfo], preference: str | None) -> str:
114
+ """Resolve a reader name from a reader list + an optional ``--reader`` preference.
115
+
116
+ Pure (no PC/SC calls) so it is unit-testable. ``--reader`` accepts an index or a
117
+ case-insensitive name substring; an ambiguous substring is an error, never a
118
+ silent first-match. With no preference it prefers the single Cryptnox/ACS reader
119
+ that has a card, and REFUSES TO GUESS when several do (e.g. a contact + a
120
+ contactless reader both loaded) — the caller must disambiguate with ``--reader``.
121
+ """
122
+ if not infos:
123
+ raise NoReadersError("No PC/SC readers found.")
124
+ if preference is not None:
125
+ pref = preference.strip()
126
+ if pref.isdigit():
127
+ match = next((r for r in infos if r.index == int(pref)), None)
128
+ if match is not None:
129
+ return match.name
130
+ raise ReaderNotFoundError(f"No reader at index {pref} (found {len(infos)}).")
131
+ matches = [r for r in infos if pref.lower() in r.name.lower()]
132
+ if len(matches) == 1:
133
+ return matches[0].name
134
+ if not matches:
135
+ raise ReaderNotFoundError(
136
+ f"No reader matching {preference!r}. Available: {_listing(infos)}"
137
+ )
138
+ raise ReaderNotFoundError(f"Reader {preference!r} is ambiguous: {_listing(matches)}")
139
+ # No preference: prefer a Cryptnox/ACS reader that actually has a card.
140
+ preferred = [r for r in infos if r.is_preferred]
141
+ with_card = [r for r in preferred if r.present]
142
+ if len(with_card) == 1:
143
+ return with_card[0].name
144
+ if len(with_card) > 1:
145
+ raise ReaderNotFoundError(
146
+ "Multiple Cryptnox/ACS readers have a card; pass --reader <index|name> to "
147
+ "choose (e.g. PICC for contactless, ICC for contact). "
148
+ f"Candidates: {_listing(with_card)}"
149
+ )
150
+ if len(preferred) == 1:
151
+ return preferred[0].name # present but cardless -> let the session report NoCard
152
+ if preferred:
153
+ raise ReaderNotFoundError(
154
+ "Multiple Cryptnox/ACS readers found (none with a card); pass --reader. "
155
+ f"{_listing(preferred)}"
156
+ )
157
+ if len(infos) == 1:
158
+ return infos[0].name
159
+ raise ReaderNotFoundError(
160
+ "Multiple readers found and no Cryptnox/ACS reader detected. "
161
+ f"Pass --reader <name|index>. Available: {_listing(infos)}"
162
+ )
163
+
164
+
165
+ def pick_reader(preference: str | None) -> str:
166
+ """Resolve a reader name from ``--reader`` (index or substring), else Cryptnox/ACS-with-card."""
167
+ return select_reader(reader_states(), preference)
168
+
169
+
170
+ #: Reader-name tokens that mark a contactless (PICC/NFC) interface. Vendor names are
171
+ #: free-form, so this is evidence, not proof - `is_contactless_interface` also looks at
172
+ #: the ATR before claiming contactless.
173
+ _CONTACTLESS_NAME_TOKENS = frozenset({"picc", "contactless", "nfc", "cl"})
174
+ #: Model numbers fold the marker into the token ("OMNIKEY 5422CL") - digits then "cl".
175
+ _MODEL_CL_RE = re.compile(r"\d+cl")
176
+
177
+
178
+ def is_contactless_interface(reader_name: str | None, atr: bytes | None) -> bool:
179
+ """Best-effort: is this session on a contactless (ISO 14443 / PICC) interface?
180
+
181
+ Two independent signals, either suffices:
182
+
183
+ * the reader name advertises a PICC/CL/NFC interface (e.g. ``ACS ACR1552 1S CL
184
+ Reader PICC 0``, ``OMNIKEY 5422CL``) - matched on whole name tokens, so ``SCL011``
185
+ alone does not count while its ``Contactless`` suffix does, or
186
+ * the ATR follows the PC/SC v2 Part 3 contactless construction ``3B 8x 80 01 …``,
187
+ which readers synthesise for ISO 14443 cards (a wired card answers with its own
188
+ ATR instead - e.g. the D600's ``3B FA 13 …``).
189
+
190
+ False means "no evidence of contactless", not "proven contact" - callers should
191
+ phrase contact-side advice accordingly.
192
+ """
193
+ if reader_name:
194
+ tokens = re.split(r"[^a-z0-9]+", reader_name.lower())
195
+ if any(t in _CONTACTLESS_NAME_TOKENS or _MODEL_CL_RE.fullmatch(t) for t in tokens):
196
+ return True
197
+ return (
198
+ atr is not None
199
+ and len(atr) >= 4
200
+ and atr[0] == 0x3B
201
+ and (atr[1] & 0xF0) == 0x80
202
+ and atr[2] == 0x80
203
+ and atr[3] == 0x01
204
+ )
205
+
206
+
207
+ class PCSCConnection:
208
+ """Adapts a pyscard connection to :class:`RawConnection`, mapping exceptions."""
209
+
210
+ def __init__(self, conn: object) -> None:
211
+ self._conn = conn
212
+
213
+ def transmit(self, apdu: list[int]) -> tuple[list[int], int, int]:
214
+ from smartcard.Exceptions import CardConnectionException
215
+
216
+ try:
217
+ data, sw1, sw2 = self._conn.transmit(apdu) # type: ignore[attr-defined]
218
+ return list(data), sw1, sw2
219
+ except CardConnectionException as exc:
220
+ if looks_like_no_access(exc):
221
+ raise CardAccessDeniedError(
222
+ str(exc), hresult=getattr(exc, "hresult", None)
223
+ ) from exc
224
+ raise TransportError(f"APDU transmit failed: {exc}") from exc
225
+
226
+ def get_atr(self) -> bytes:
227
+ return bytes(self._conn.getATR()) # type: ignore[attr-defined]
228
+
229
+ def disconnect(self) -> None:
230
+ with contextlib.suppress(Exception): # pragma: no cover - best effort
231
+ self._conn.disconnect() # type: ignore[attr-defined]
232
+
233
+
234
+ def connect(reader_name: str) -> PCSCConnection:
235
+ """Open a card connection on ``reader_name`` (tries default then T1/T0)."""
236
+ from smartcard.CardConnection import CardConnection
237
+ from smartcard.Exceptions import CardConnectionException, NoCardException
238
+ from smartcard.System import readers as _readers
239
+
240
+ target = next((r for r in _readers() if str(r) == reader_name), None)
241
+ if target is None:
242
+ raise ReaderNotFoundError(f"Reader not found: {reader_name}")
243
+ conn = target.createConnection()
244
+ try:
245
+ conn.connect()
246
+ return PCSCConnection(conn)
247
+ except NoCardException as exc:
248
+ raise NoCardError(f"No card present in reader {reader_name!r}.") from exc
249
+ except CardConnectionException as exc:
250
+ if looks_like_no_access(exc):
251
+ raise CardAccessDeniedError(str(exc), hresult=getattr(exc, "hresult", None)) from exc
252
+ for proto in (CardConnection.T1_protocol, CardConnection.T0_protocol):
253
+ try:
254
+ conn.connect(proto)
255
+ return PCSCConnection(conn)
256
+ except Exception: # noqa: S112 - try next protocol
257
+ continue
258
+ raise TransportError(f"Cannot connect to card in {reader_name!r}: {exc}") from exc
259
+
260
+
261
+ class CardSession:
262
+ """High-level APDU exchange with chaining and a redacted transcript."""
263
+
264
+ def __init__(
265
+ self,
266
+ conn: RawConnection,
267
+ *,
268
+ redactor: Redactor | None = None,
269
+ apdu_log: TextIO | None = None,
270
+ trace: Callable[[str], None] | None = None,
271
+ reader_name: str | None = None,
272
+ ) -> None:
273
+ self._conn = conn
274
+ self._redactor = redactor or Redactor()
275
+ self._log = apdu_log
276
+ self._trace = trace
277
+ #: Name of the PC/SC reader this session runs on, when known. Diagnostic only
278
+ #: (interface heuristics); never used to route APDUs.
279
+ self.reader_name = reader_name
280
+
281
+ @property
282
+ def redactor(self) -> Redactor:
283
+ return self._redactor
284
+
285
+ @property
286
+ def atr(self) -> bytes:
287
+ return self._conn.get_atr()
288
+
289
+ def _emit(self, line: str) -> None:
290
+ if self._log is not None:
291
+ self._log.write(line + "\n")
292
+ self._log.flush()
293
+ if self._trace is not None:
294
+ self._trace(line)
295
+
296
+ def transmit(self, apdu: APDU | bytes | bytearray, *, context: str | None = None) -> Response:
297
+ raw = apdu.to_bytes() if isinstance(apdu, APDU) else bytes(apdu)
298
+ ins = raw[1] if len(raw) > 1 else 0
299
+ self._emit("> " + self._redactor.redact_command(raw))
300
+
301
+ data, sw1, sw2 = self._conn.transmit(list(raw))
302
+
303
+ # 6Cxx: wrong Le — resend with the corrected Le in the trailing byte.
304
+ if sw1 == 0x6C and len(raw) >= 5:
305
+ data, sw1, sw2 = self._conn.transmit(list(raw[:-1]) + [sw2])
306
+
307
+ # 61xx: more data — pull it with GET RESPONSE.
308
+ acc = list(data)
309
+ while sw1 == 0x61:
310
+ more, sw1, sw2 = self._conn.transmit([0x00, 0xC0, 0x00, 0x00, sw2])
311
+ acc += list(more)
312
+
313
+ resp = Response(bytes(acc), sw1, sw2)
314
+ self._emit("< " + self._redactor.redact_response(resp.data, resp.sw1, resp.sw2, ins=ins))
315
+ return resp
316
+
317
+ def transmit_chained(
318
+ self, apdu: APDU, *, block_size: int = 200, context: str | None = None
319
+ ) -> Response:
320
+ """Send a command whose data exceeds a short APDU via ISO command chaining
321
+ (CLA bit 0x10 on every block but the last, which keeps the original CLA/Le)."""
322
+ blocks = [apdu.data[i : i + block_size] for i in range(0, len(apdu.data), block_size)] or [
323
+ b""
324
+ ]
325
+ resp: Response | None = None
326
+ for index, block in enumerate(blocks):
327
+ final = index == len(blocks) - 1
328
+ frame = APDU(
329
+ apdu.cla if final else (apdu.cla | 0x10),
330
+ apdu.ins,
331
+ apdu.p1,
332
+ apdu.p2,
333
+ data=block,
334
+ le=apdu.le if final else None,
335
+ )
336
+ label = f"{context} [{index + 1}/{len(blocks)}]" if context else None
337
+ resp = self.transmit(frame, context=label)
338
+ if not final and not resp.ok:
339
+ return resp
340
+ if resp is None: # unreachable: chained transmit always sends at least one frame
341
+ raise RuntimeError("no APDU frames were sent")
342
+ return resp
343
+
344
+ def disconnect(self) -> None:
345
+ self._conn.disconnect()
346
+
347
+ def __enter__(self) -> CardSession:
348
+ return self
349
+
350
+ def __exit__(self, *exc: object) -> None:
351
+ self.disconnect()
@@ -0,0 +1,247 @@
1
+ """SCP02 secure channel (GlobalPlatform Card Spec 2.x), used for PIV admin commands.
2
+
3
+ Some Cryptnox cards present an SCP02 admin channel rather than SCP03 -- notably the
4
+ JCOP4 D321 / A27F 110KB family, whose PIV admin SSD is an SCP02 Security Domain. On
5
+ those, ``INITIALIZE UPDATE`` returns a 28-byte body with ``keyInfo[1] == 0x02`` and the
6
+ SCP03 layer cannot speak to the card. This module mirrors :mod:`scp03`'s shape
7
+ (:class:`Scp02Keys`, :class:`Scp02Session`, :func:`open_channel`) so that
8
+ :class:`~cryptnox_id_cli.applets.piv.admin.PivAdmin` is agnostic to the SCP version.
9
+
10
+ The crypto follows GlobalPlatformPro (the proven ``gp`` reference) for implementation
11
+ option **i = 0x55** (``icvEnc = true``, ``macModifiedAPDU = true``):
12
+
13
+ * **Session keys** (3DES, 2-key) derived by 3DES-CBC with a zero IV over a 16-byte block
14
+ ``<const(2)> | seqCounter(2) | 00*12``: ``S-ENC`` (``0182``), ``S-MAC`` (``0101``),
15
+ ``S-DEK`` (``0181``).
16
+ * **Cryptograms** use the full 3DES-CBC MAC (every block 3DES) with ``S-ENC`` and a zero
17
+ IV: card = ``MAC(hostChallenge | cardChallenge)``, host = ``MAC(cardChallenge |
18
+ hostChallenge)`` -- where ``cardChallenge`` is the 8 bytes ``seqCounter(2) |
19
+ random(6)``.
20
+ * **C-MAC** is the ISO 9797-1 retail MAC (single-DES chain, final block 3DES) computed
21
+ over the *plaintext* modified APDU (CLA|0x04, INS, P1, P2, origLc+8, data). The ICV
22
+ starts at zero and, for every command after the first, is the previous C-MAC encrypted
23
+ with single-DES under ``S-MAC``.
24
+ * **C-ENC** (when the security level includes C-DECRYPTION) is 3DES-CBC under ``S-ENC``
25
+ with a zero IV over 80-padded data, applied *after* the C-MAC (encrypt-and-MAC). The
26
+ transmitted ``Lc`` is the encrypted length + 8, while the C-MAC used origLc + 8.
27
+
28
+ Correctness is proven end-to-end by the card: wrong keys fail the card-cryptogram check
29
+ locally and EXTERNAL AUTHENTICATE is refused -- no silent corruption.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import os
35
+ from dataclasses import dataclass
36
+
37
+ from cryptography.hazmat.decrepit.ciphers.algorithms import TripleDES
38
+ from cryptography.hazmat.primitives.ciphers import Cipher, modes
39
+
40
+ from cryptnox_id_cli.transport.apdu import APDU, Response
41
+ from cryptnox_id_cli.transport.errors import Scp02Error
42
+
43
+ _BLOCK = 8
44
+ _ZERO_IV = b"\x00" * _BLOCK
45
+
46
+ # Session-key derivation constants (GP Card Spec 2.2, E.4.1).
47
+ _DERIV_S_ENC = b"\x01\x82"
48
+ _DERIV_S_MAC = b"\x01\x01"
49
+ _DERIV_S_DEK = b"\x01\x81"
50
+
51
+ # Security-level bits (EXTERNAL AUTHENTICATE P1) -- same encoding as SCP03.
52
+ SL_C_MAC = 0x01
53
+ SL_C_DECRYPTION = 0x02
54
+ SL_CMAC_CENC = SL_C_MAC | SL_C_DECRYPTION # 0x03
55
+
56
+ # Largest plaintext we wrap into a single short APDU. Encrypted+padded data plus the
57
+ # 8-byte C-MAC must leave the Lc <= 0xFF; larger bodies go via ISO command chaining
58
+ # (PivAdmin.send_chained, block_size=200), so this ceiling is never hit in practice.
59
+ _MAX_SHORT_PLAINTEXT = 239
60
+
61
+
62
+ def _k3(key: bytes) -> bytes:
63
+ """Expand a 16-byte 2-key 3DES key to the 24-byte K1|K2|K1 form GP uses."""
64
+ return key + key[:8] if len(key) == 16 else key
65
+
66
+
67
+ def _k1(key: bytes) -> bytes:
68
+ """The first 8 key bytes as a 24-byte single-DES key (K1|K1|K1)."""
69
+ return key[:8] * 3
70
+
71
+
72
+ def _des_ecb(key: bytes, block: bytes) -> bytes:
73
+ """Single-DES ECB (the first 8 key bytes), used for ICV encryption."""
74
+ enc = Cipher(TripleDES(_k1(key)), modes.ECB()).encryptor() # noqa: S304 - single-block ICV
75
+ return enc.update(block) + enc.finalize()
76
+
77
+
78
+ def _des_cbc(key: bytes, iv: bytes, data: bytes) -> bytes:
79
+ """Single-DES CBC (the first 8 key bytes)."""
80
+ enc = Cipher(TripleDES(_k1(key)), modes.CBC(iv)).encryptor()
81
+ return enc.update(data) + enc.finalize()
82
+
83
+
84
+ def _des3_cbc(key: bytes, iv: bytes, data: bytes) -> bytes:
85
+ """3DES CBC (2-key static/session keys expanded to the 24-byte K1|K2|K1 form)."""
86
+ enc = Cipher(TripleDES(_k3(key)), modes.CBC(iv)).encryptor()
87
+ return enc.update(data) + enc.finalize()
88
+
89
+
90
+ def _pad80(data: bytes) -> bytes:
91
+ """ISO 7816-4 padding: 0x80 then 0x00s to the next 8-byte boundary (always adds)."""
92
+ padded = data + b"\x80"
93
+ if len(padded) % _BLOCK:
94
+ padded += b"\x00" * (_BLOCK - len(padded) % _BLOCK)
95
+ return padded
96
+
97
+
98
+ def _mac_3des(s_enc: bytes, data: bytes) -> bytes:
99
+ """Full 3DES-CBC MAC over 80-padded data, zero IV -- used for the cryptograms."""
100
+ out = _des3_cbc(s_enc, _ZERO_IV, _pad80(data))
101
+ return out[-_BLOCK:]
102
+
103
+
104
+ def _retail_mac(s_mac: bytes, data: bytes, icv: bytes) -> bytes:
105
+ """ISO 9797-1 MAC algorithm 3 (retail MAC): single-DES chain, final block 3DES."""
106
+ padded = _pad80(data)
107
+ iv = icv
108
+ if len(padded) > _BLOCK:
109
+ chain = _des_cbc(s_mac, iv, padded[:-_BLOCK])
110
+ iv = chain[-_BLOCK:]
111
+ return _des3_cbc(s_mac, iv, padded[-_BLOCK:])[-_BLOCK:]
112
+
113
+
114
+ def _derive_key(static_key: bytes, constant: bytes, seq: bytes) -> bytes:
115
+ """Derive one 16-byte session key: 3DES-CBC(zero IV) over const | seq | 00*12."""
116
+ return _des3_cbc(static_key, _ZERO_IV, constant + seq + b"\x00" * 12)
117
+
118
+
119
+ @dataclass
120
+ class Scp02Keys:
121
+ enc: bytes
122
+ mac: bytes
123
+ dek: bytes
124
+
125
+ @classmethod
126
+ def same(cls, key: bytes) -> Scp02Keys:
127
+ return cls(key, key, key)
128
+
129
+
130
+ class Scp02Session:
131
+ """An open SCP02 session: holds session keys and wraps/unwraps command APDUs."""
132
+
133
+ def __init__(self, s_enc: bytes, s_mac: bytes, s_dek: bytes, security_level: int) -> None:
134
+ self.s_enc = s_enc
135
+ self.s_mac = s_mac
136
+ self.s_dek = s_dek
137
+ self.security_level = security_level
138
+ #: last computed C-MAC; ``None`` until the first MAC (EXTERNAL AUTHENTICATE).
139
+ self.icv: bytes | None = None
140
+
141
+ def _cmac(self, mac_input: bytes) -> bytes:
142
+ """Retail MAC over ``mac_input`` with ICV chaining; advances the session ICV."""
143
+ # First MAC uses the zero ICV; later commands chain on single-DES(previous C-MAC).
144
+ icv = _ZERO_IV if self.icv is None else _des_ecb(self.s_mac, self.icv)
145
+ mac = _retail_mac(self.s_mac, mac_input, icv)
146
+ self.icv = mac
147
+ return mac
148
+
149
+ def wrap(self, apdu: APDU) -> bytes:
150
+ """Return the wrapped command bytes (CLA|0x04, optional C-ENC, C-MAC appended).
151
+
152
+ Short Lc only -- callers chain larger bodies (see ``_MAX_SHORT_PLAINTEXT``). The
153
+ C-MAC is computed over the *plaintext* modified APDU (origLc+8); the data is then
154
+ encrypted and the wire Lc becomes the encrypted length + 8.
155
+ """
156
+ data = apdu.data
157
+ if len(data) > _MAX_SHORT_PLAINTEXT:
158
+ raise Scp02Error(
159
+ f"SCP02 wraps short APDUs only ({len(data)} > {_MAX_SHORT_PLAINTEXT} "
160
+ "plaintext bytes); send large bodies via command chaining."
161
+ )
162
+ cla = apdu.cla | 0x04
163
+ header = bytes([cla, apdu.ins, apdu.p1, apdu.p2])
164
+
165
+ # C-MAC over the plaintext modified APDU: header | (origLc + 8) | data.
166
+ mac = self._cmac(header + bytes([len(data) + 8]) + data)
167
+
168
+ body = data
169
+ if self.security_level & SL_C_DECRYPTION and data:
170
+ body = _des3_cbc(self.s_enc, _ZERO_IV, _pad80(data))
171
+
172
+ out = header + bytes([len(body) + 8]) + body + mac
173
+ if apdu.le is not None:
174
+ out += bytes([0x00 if apdu.le >= 256 else apdu.le & 0xFF])
175
+ return out
176
+
177
+ def unwrap(self, resp: Response) -> Response:
178
+ """At security level 0x03 the response is plaintext; pass it through."""
179
+ return resp
180
+
181
+
182
+ def derive_session_keys(keys: Scp02Keys, seq: bytes) -> tuple[bytes, bytes, bytes]:
183
+ s_enc = _derive_key(keys.enc, _DERIV_S_ENC, seq)
184
+ s_mac = _derive_key(keys.mac, _DERIV_S_MAC, seq)
185
+ s_dek = _derive_key(keys.dek, _DERIV_S_DEK, seq)
186
+ return s_enc, s_mac, s_dek
187
+
188
+
189
+ def open_channel(
190
+ transmit,
191
+ keys: Scp02Keys,
192
+ *,
193
+ key_version: int = 0,
194
+ security_level: int = SL_CMAC_CENC,
195
+ host_challenge: bytes | None = None,
196
+ init_response: Response | None = None,
197
+ ) -> Scp02Session:
198
+ """Run INITIALIZE UPDATE + EXTERNAL AUTHENTICATE for SCP02.
199
+
200
+ ``transmit(APDU|bytes) -> Response``. If ``init_response`` is given (the caller has
201
+ already issued INITIALIZE UPDATE to auto-detect the SCP version), it is reused with
202
+ the matching ``host_challenge`` instead of issuing a second one.
203
+
204
+ Raises :class:`Scp02Error` on a wrong key (card-cryptogram mismatch) or a card
205
+ rejection.
206
+ """
207
+ if init_response is None:
208
+ host_challenge = host_challenge or os.urandom(8)
209
+ resp = transmit(APDU(0x80, 0x50, key_version, 0x00, data=host_challenge, le=256))
210
+ else:
211
+ if host_challenge is None:
212
+ raise Scp02Error("host_challenge is required when init_response is supplied.")
213
+ resp = init_response
214
+
215
+ if not resp.ok:
216
+ raise Scp02Error(f"INITIALIZE UPDATE rejected (SW={resp.sw_hex()}).")
217
+ body = resp.data
218
+ if len(body) < 28:
219
+ raise Scp02Error(f"INITIALIZE UPDATE response too short ({len(body)} bytes) for SCP02.")
220
+
221
+ # keyDivData(10) | keyInfo(2: ver, 0x02) | seqCounter(2) | cardChallenge(6) | cardCrypto(8).
222
+ seq = body[12:14]
223
+ card_challenge = body[12:20] # full 8-byte challenge = seqCounter | random(6)
224
+ card_cryptogram = body[20:28]
225
+
226
+ s_enc, s_mac, s_dek = derive_session_keys(keys, seq)
227
+
228
+ expected = _mac_3des(s_enc, host_challenge + card_challenge)
229
+ if expected != card_cryptogram:
230
+ raise Scp02Error("Card cryptogram mismatch - the SCP02 keys are wrong for this card.")
231
+
232
+ session = Scp02Session(s_enc, s_mac, s_dek, security_level)
233
+ host_cryptogram = _mac_3des(s_enc, card_challenge + host_challenge)
234
+
235
+ # EXTERNAL AUTHENTICATE: CLA 0x84, P1 = security level, Lc = 0x10 (host cryptogram + C-MAC).
236
+ # The host cryptogram is MACed in clear (never encrypted), with the zero ICV.
237
+ header = bytes([0x84, 0x82, security_level, 0x00])
238
+ mac = session._cmac(header + bytes([len(host_cryptogram) + 8]) + host_cryptogram)
239
+ ext_auth = header + bytes([len(host_cryptogram) + 8]) + host_cryptogram + mac
240
+
241
+ resp = transmit(ext_auth)
242
+ if not resp.ok:
243
+ raise Scp02Error(
244
+ f"EXTERNAL AUTHENTICATE rejected (SW={resp.sw_hex()}); "
245
+ "keys or security level may be wrong."
246
+ )
247
+ return session