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.
- cryptnox_id_cli/__init__.py +22 -0
- cryptnox_id_cli/__main__.py +6 -0
- cryptnox_id_cli/applets/__init__.py +2 -0
- cryptnox_id_cli/applets/fido/__init__.py +7 -0
- cryptnox_id_cli/applets/fido/authdata.py +81 -0
- cryptnox_id_cli/applets/fido/constants.py +155 -0
- cryptnox_id_cli/applets/fido/ctap.py +430 -0
- cryptnox_id_cli/applets/fido/errors.py +90 -0
- cryptnox_id_cli/applets/fido/pinproto.py +166 -0
- cryptnox_id_cli/applets/genuine/__init__.py +12 -0
- cryptnox_id_cli/applets/genuine/constants.py +32 -0
- cryptnox_id_cli/applets/genuine/genuine.py +85 -0
- cryptnox_id_cli/applets/genuine/verify.py +150 -0
- cryptnox_id_cli/applets/mifare/__init__.py +10 -0
- cryptnox_id_cli/applets/mifare/desfire.py +353 -0
- cryptnox_id_cli/applets/mifare/ev2.py +421 -0
- cryptnox_id_cli/applets/piv/__init__.py +9 -0
- cryptnox_id_cli/applets/piv/admin.py +182 -0
- cryptnox_id_cli/applets/piv/apt.py +58 -0
- cryptnox_id_cli/applets/piv/constants.py +66 -0
- cryptnox_id_cli/applets/piv/keyimport.py +244 -0
- cryptnox_id_cli/applets/piv/objects.py +126 -0
- cryptnox_id_cli/applets/piv/perso.py +138 -0
- cryptnox_id_cli/applets/piv/piv.py +171 -0
- cryptnox_id_cli/applets/piv/preperso.py +174 -0
- cryptnox_id_cli/applets/piv/profiles.py +486 -0
- cryptnox_id_cli/applets/piv/slots.py +44 -0
- cryptnox_id_cli/cli/__init__.py +1 -0
- cryptnox_id_cli/cli/commands/__init__.py +1 -0
- cryptnox_id_cli/cli/commands/apdu.py +123 -0
- cryptnox_id_cli/cli/commands/doctor.py +144 -0
- cryptnox_id_cli/cli/commands/factory.py +312 -0
- cryptnox_id_cli/cli/commands/fido.py +729 -0
- cryptnox_id_cli/cli/commands/genuine.py +192 -0
- cryptnox_id_cli/cli/commands/info.py +94 -0
- cryptnox_id_cli/cli/commands/mifare.py +998 -0
- cryptnox_id_cli/cli/commands/piv.py +2558 -0
- cryptnox_id_cli/cli/commands/readers.py +64 -0
- cryptnox_id_cli/cli/commands/report.py +217 -0
- cryptnox_id_cli/cli/commands/shell.py +130 -0
- cryptnox_id_cli/cli/context.py +71 -0
- cryptnox_id_cli/cli/dryrun.py +181 -0
- cryptnox_id_cli/cli/main.py +117 -0
- cryptnox_id_cli/crypto/__init__.py +1 -0
- cryptnox_id_cli/crypto/attestation.py +169 -0
- cryptnox_id_cli/crypto/csr.py +135 -0
- cryptnox_id_cli/crypto/piv_objects.py +79 -0
- cryptnox_id_cli/crypto/x509util.py +37 -0
- cryptnox_id_cli/output/__init__.py +5 -0
- cryptnox_id_cli/output/render.py +106 -0
- cryptnox_id_cli/secrets/__init__.py +5 -0
- cryptnox_id_cli/secrets/redaction.py +141 -0
- cryptnox_id_cli/secrets/resolver.py +93 -0
- cryptnox_id_cli/state/__init__.py +19 -0
- cryptnox_id_cli/state/detector.py +273 -0
- cryptnox_id_cli/state/model.py +147 -0
- cryptnox_id_cli/transport/__init__.py +28 -0
- cryptnox_id_cli/transport/apdu.py +72 -0
- cryptnox_id_cli/transport/elevation.py +165 -0
- cryptnox_id_cli/transport/errors.py +185 -0
- cryptnox_id_cli/transport/pcsc.py +351 -0
- cryptnox_id_cli/transport/scp02.py +247 -0
- cryptnox_id_cli/transport/scp03.py +213 -0
- cryptnox_id_cli/trust/__init__.py +90 -0
- cryptnox_id_cli/trust/genuine/cryptnox-attestation-ca.pem +18 -0
- cryptnox_id_cli/trust/genuine/cryptnox-dlt-cards-ca.pem +17 -0
- cryptnox_id_cli/trust/genuine/cryptnox-genuineness-ca.pem +18 -0
- cryptnox_id_cli/trust/genuine/cryptnox-intermediate-ca-2.pem +19 -0
- cryptnox_id_cli/trust/genuine/cryptnox-intermediate-ca.pem +19 -0
- cryptnox_id_cli/trust/genuine/cryptnox-root-ca.pem +19 -0
- cryptnox_id_cli/util/__init__.py +1 -0
- cryptnox_id_cli/util/hexutil.py +36 -0
- cryptnox_id_cli/util/tlv.py +144 -0
- cryptnox_id_cli-1.0.2.dist-info/METADATA +227 -0
- cryptnox_id_cli-1.0.2.dist-info/RECORD +79 -0
- cryptnox_id_cli-1.0.2.dist-info/WHEEL +5 -0
- cryptnox_id_cli-1.0.2.dist-info/entry_points.txt +4 -0
- cryptnox_id_cli-1.0.2.dist-info/licenses/LICENSE +165 -0
- 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
|