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,421 @@
1
+ """DESFire EV2 authentication (AuthenticateEV2First) and secure messaging.
2
+
3
+ Session keys are derived per NXP AN12343 with an AES-CMAC KDF:
4
+
5
+ SV1 = A5 5A 00 01 00 80 || RndA[15..14] || (RndA[13..8] XOR RndB[15..10])
6
+ || RndB[9..0] || RndA[7..0] -> SesAuthENC = CMAC(Kx, SV1)
7
+ SV2 = 5A A5 00 01 00 80 || (same tail) -> SesAuthMAC = CMAC(Kx, SV2)
8
+
9
+ (``RndA[15..14]`` is MSB-first notation: the first two bytes on the wire.)
10
+
11
+ Command MAC input is ``Cmd || CmdCtr(2 LE) || TI(4) || header+data``; the
12
+ transmitted MACt is the odd-indexed 8 bytes of the full CMAC. The counter
13
+ increments after each command; the response MAC covers
14
+ ``RC || CmdCtr' || TI || data``. Authentication itself encrypts with the
15
+ *auth key* (AES-CBC, zero IV).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import os
21
+ import zlib
22
+ from dataclasses import dataclass, field
23
+
24
+ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
25
+ from cryptography.hazmat.primitives.cmac import CMAC
26
+
27
+ from cryptnox_id_cli.applets.mifare.desfire import (
28
+ CMD_CHANGE_FILE_SETTINGS,
29
+ CMD_CHANGE_KEY,
30
+ CMD_FORMAT_PICC,
31
+ STATUS_ADDITIONAL_FRAME,
32
+ STATUS_OK,
33
+ DesfireError,
34
+ DesfireTransport,
35
+ )
36
+ from cryptnox_id_cli.transport.errors import CryptnoxError
37
+
38
+ CMD_AUTHENTICATE_EV2_FIRST = 0x71
39
+ CMD_ADDITIONAL_FRAME = 0xAF
40
+
41
+
42
+ class Ev2Error(CryptnoxError):
43
+ """EV2 authentication / secure-messaging failure."""
44
+
45
+ code = "ev2_error"
46
+ exit_code = 11
47
+
48
+
49
+ def _aes_cmac(key: bytes, data: bytes) -> bytes:
50
+ c = CMAC(algorithms.AES(key))
51
+ c.update(data)
52
+ return c.finalize()
53
+
54
+
55
+ def _cbc(key: bytes, data: bytes, *, encrypt: bool) -> bytes:
56
+ cipher = Cipher(algorithms.AES(key), modes.CBC(b"\x00" * 16))
57
+ op = cipher.encryptor() if encrypt else cipher.decryptor()
58
+ return op.update(data) + op.finalize()
59
+
60
+
61
+ def _aes_ecb(key: bytes, block: bytes) -> bytes:
62
+ enc = Cipher(algorithms.AES(key), modes.ECB()).encryptor()
63
+ return enc.update(block) + enc.finalize()
64
+
65
+
66
+ def _aes_cbc_iv(key: bytes, iv: bytes, data: bytes, *, encrypt: bool) -> bytes:
67
+ cipher = Cipher(algorithms.AES(key), modes.CBC(iv))
68
+ op = cipher.encryptor() if encrypt else cipher.decryptor()
69
+ return op.update(data) + op.finalize()
70
+
71
+
72
+ def _pad_full(data: bytes) -> bytes:
73
+ """EV2 encrypted-data padding (ISO 9797-1 method 2): ALWAYS append 0x80 then 0x00s to
74
+ the next 16-byte boundary - a *whole* padding block when the data is already aligned.
75
+
76
+ DESFire FULL writes require this. A block-aligned plaintext sent without the extra pad
77
+ block desyncs the card's secure messaging: it keeps requesting additional frames and the
78
+ command never completes (live-reproduced: a 16-byte FULL write hangs; 20-byte works)."""
79
+ pad_len = 16 - (len(data) % 16) # 1..16 - always >= 1, a full block when aligned
80
+ return data + b"\x80" + b"\x00" * (pad_len - 1)
81
+
82
+
83
+ def desfire_crc32(data: bytes) -> bytes:
84
+ """DESFire CRC32 (reflected, poly 0xEDB88320, init 0xFFFFFFFF, NO final inversion),
85
+ 4 bytes little-endian. This is zlib's CRC32 *without* its final XOR, so the empty
86
+ string maps to FF FF FF FF. Used inside the cross-key ChangeKey cryptogram."""
87
+ return ((zlib.crc32(data) ^ 0xFFFFFFFF) & 0xFFFFFFFF).to_bytes(4, "little")
88
+
89
+
90
+ def rot_left(data: bytes) -> bytes:
91
+ """Rotate a byte string left by one byte (RndA' / RndB')."""
92
+ return data[1:] + data[:1]
93
+
94
+
95
+ def build_sv_base(rnda: bytes, rndb: bytes) -> bytes:
96
+ """The 26-byte tail shared by SV1 and SV2."""
97
+ xored = bytes(a ^ b for a, b in zip(rnda[2:8], rndb[0:6], strict=True))
98
+ return rnda[0:2] + xored + rndb[6:16] + rnda[8:16]
99
+
100
+
101
+ def derive_session_keys(key: bytes, rnda: bytes, rndb: bytes) -> tuple[bytes, bytes]:
102
+ base = build_sv_base(rnda, rndb)
103
+ ses_enc = _aes_cmac(key, bytes([0xA5, 0x5A, 0x00, 0x01, 0x00, 0x80]) + base)
104
+ ses_mac = _aes_cmac(key, bytes([0x5A, 0xA5, 0x00, 0x01, 0x00, 0x80]) + base)
105
+ return ses_enc, ses_mac
106
+
107
+
108
+ def truncate_mac(full_cmac: bytes) -> bytes:
109
+ """EV2 MACt: the odd-indexed bytes (1, 3, ..., 15) of the 16-byte CMAC."""
110
+ return full_cmac[1::2]
111
+
112
+
113
+ @dataclass
114
+ class Ev2Session:
115
+ k_enc: bytes
116
+ k_mac: bytes
117
+ ti: bytes
118
+ cmd_ctr: int = 0
119
+ key_no: int = field(default=0)
120
+
121
+ def _mact(self, data: bytes) -> bytes:
122
+ return truncate_mac(_aes_cmac(self.k_mac, data))
123
+
124
+ def command_mac(self, cmd: int, payload: bytes) -> bytes:
125
+ return self._mact(bytes([cmd]) + self.cmd_ctr.to_bytes(2, "little") + self.ti + payload)
126
+
127
+ def response_mac(self, rc: int, data: bytes) -> bytes:
128
+ return self._mact(bytes([rc]) + self.cmd_ctr.to_bytes(2, "little") + self.ti + data)
129
+
130
+
131
+ def authenticate_ev2_first(
132
+ transport: DesfireTransport,
133
+ key_no: int,
134
+ key: bytes,
135
+ *,
136
+ rnda: bytes | None = None,
137
+ ) -> Ev2Session:
138
+ """Run AuthenticateEV2First with an AES key; returns an authenticated session.
139
+
140
+ Raises :class:`Ev2Error` if the card's RndA' proof fails (wrong key / bad
141
+ crypto) and :class:`DesfireError` on card-side rejection.
142
+ """
143
+ if len(key) != 16:
144
+ raise Ev2Error("EV2 authentication requires a 16-byte AES key.")
145
+ status, enc_rndb = transport.raw_command(
146
+ CMD_AUTHENTICATE_EV2_FIRST, bytes([key_no, 0x00]), context="AuthenticateEV2First"
147
+ )
148
+ if status != STATUS_ADDITIONAL_FRAME:
149
+ raise DesfireError(status, "AuthenticateEV2First")
150
+ if len(enc_rndb) != 16:
151
+ raise Ev2Error(f"unexpected E(RndB) length {len(enc_rndb)}.")
152
+ rndb = _cbc(key, enc_rndb, encrypt=False)
153
+ rnda = rnda or os.urandom(16)
154
+ part2 = _cbc(key, rnda + rot_left(rndb), encrypt=True)
155
+ status, enc_final = transport.raw_command(
156
+ CMD_ADDITIONAL_FRAME, part2, context="AuthenticateEV2First (part 2)"
157
+ )
158
+ if status != STATUS_OK:
159
+ raise DesfireError(status, "AuthenticateEV2First (part 2)")
160
+ if len(enc_final) != 32:
161
+ raise Ev2Error(f"unexpected final auth response length {len(enc_final)}.")
162
+ final = _cbc(key, enc_final, encrypt=False)
163
+ ti, rnda_proof = final[0:4], final[4:20]
164
+ if rnda_proof != rot_left(rnda):
165
+ raise Ev2Error("card RndA' proof mismatch - wrong key or broken session crypto.")
166
+ ses_enc, ses_mac = derive_session_keys(key, rnda, rndb)
167
+ return Ev2Session(k_enc=ses_enc, k_mac=ses_mac, ti=ti, key_no=key_no)
168
+
169
+
170
+ # Max bytes of the (header+data+MAC) stream per native frame. The ACR1252+card accept
171
+ # a single-frame Lc up to 54 here (probed); 48 keeps a safety margin. Larger payloads
172
+ # are split across 0x3D then 0xAF command-chaining frames.
173
+ MAX_COMMAND_FRAME = 0x30
174
+
175
+ # Upper bound on response-chaining (0xAF) frames pulled for one command. Generous - real
176
+ # responses fit in a handful of frames - but finite, so a desynced card that keeps asking
177
+ # for frames raises instead of hanging the CLI forever (see _pad_full for one trigger).
178
+ MAX_RESPONSE_FRAMES = 64
179
+
180
+
181
+ def _drain_response_frames(
182
+ transport: DesfireTransport, status: int, acc: bytearray, ctx: str
183
+ ) -> int:
184
+ """Pull 0xAF response-continuation frames into ``acc``; return the final status."""
185
+ frames = 0
186
+ while status == STATUS_ADDITIONAL_FRAME:
187
+ frames += 1
188
+ if frames > MAX_RESPONSE_FRAMES:
189
+ raise Ev2Error(
190
+ f"{ctx}: card kept requesting response frames (>{MAX_RESPONSE_FRAMES}); "
191
+ "aborting to avoid a hang."
192
+ )
193
+ status, more = transport.raw_command(CMD_ADDITIONAL_FRAME, context=f"{ctx} (AF)")
194
+ acc += more
195
+ return status
196
+
197
+
198
+ def command_macked(
199
+ transport: DesfireTransport,
200
+ session: Ev2Session,
201
+ cmd: int,
202
+ payload: bytes = b"",
203
+ *,
204
+ context: str | None = None,
205
+ terminates_session: bool = False,
206
+ max_frame: int = MAX_COMMAND_FRAME,
207
+ ) -> bytes:
208
+ """Send a command in EV2 CommMode.MAC and verify the response MAC.
209
+
210
+ The MAC is computed over the whole command and appended; the resulting
211
+ ``header||data||MAC`` stream is split across native frames when it exceeds one
212
+ frame (first frame carries the real opcode, continuations use 0xAF, the card
213
+ ACKs each non-final frame with 0x91AF). The command counter advances once.
214
+
215
+ Commands that destroy the authenticated context (e.g. DeleteApplication of the
216
+ selected application) end the session on success and reply WITHOUT a response
217
+ MAC - pass ``terminates_session=True`` for those.
218
+ """
219
+ ctx = context or f"DESFire {cmd:02X} (MAC)"
220
+ full = bytes(payload) + session.command_mac(cmd, payload)
221
+ chunks = [full[i : i + max_frame] for i in range(0, len(full), max_frame)] or [b""]
222
+
223
+ status, first = transport.raw_command(cmd, chunks[0], context=ctx)
224
+ acc = bytearray(first)
225
+ # Send the remaining command chunks; the card requests each with 0x91AF.
226
+ for idx, chunk in enumerate(chunks[1:], start=1):
227
+ if status != STATUS_ADDITIONAL_FRAME:
228
+ raise Ev2Error(
229
+ f"{ctx}: card did not request command frame {idx} (status 0x{status:02X})."
230
+ )
231
+ status, more = transport.raw_command(
232
+ CMD_ADDITIONAL_FRAME, chunk, context=f"{ctx} (cmd frame {idx})"
233
+ )
234
+ acc += more
235
+ # Then pull any response-chaining frames (card has more response data to send).
236
+ status = _drain_response_frames(transport, status, acc, ctx)
237
+
238
+ session.cmd_ctr += 1
239
+ if status != STATUS_OK:
240
+ raise DesfireError(status, ctx)
241
+ if terminates_session and len(acc) == 0:
242
+ return b""
243
+ if len(acc) < 8:
244
+ raise Ev2Error(f"{ctx}: response MAC missing.")
245
+ data, mact_recv = bytes(acc[:-8]), bytes(acc[-8:])
246
+ if session.response_mac(status, data) != mact_recv:
247
+ raise Ev2Error(f"{ctx}: response MAC verification failed.")
248
+ return data
249
+
250
+
251
+ def _full_iv(session: Ev2Session, marker: tuple[int, int]) -> bytes:
252
+ block = bytes(marker) + session.ti + session.cmd_ctr.to_bytes(2, "little") + bytes(8)
253
+ return _aes_ecb(session.k_enc, block)
254
+
255
+
256
+ def command_full(
257
+ transport: DesfireTransport,
258
+ session: Ev2Session,
259
+ cmd: int,
260
+ *,
261
+ header: bytes = b"",
262
+ plaintext: bytes = b"",
263
+ response_len: int = 0,
264
+ context: str | None = None,
265
+ max_frame: int = MAX_COMMAND_FRAME,
266
+ ) -> bytes:
267
+ """Send a command in EV2 CommMode.FULL: the command header stays in the clear, the
268
+ ``plaintext`` data is AES-CBC encrypted (IV = E(Kenc, A55A||TI||CmdCtr||0)), the
269
+ whole ``header||ciphertext`` is MACed and chain-sent, and the response data is
270
+ MAC-verified then decrypted (IV uses 5AA5||TI||CmdCtr after the counter advances).
271
+ Returns the decrypted response truncated to ``response_len`` (0 = all)."""
272
+ ctx = context or f"DESFire {cmd:02X} (FULL)"
273
+ enc = (
274
+ _aes_cbc_iv(
275
+ session.k_enc, _full_iv(session, (0xA5, 0x5A)), _pad_full(plaintext), encrypt=True
276
+ )
277
+ if plaintext
278
+ else b""
279
+ )
280
+ macked = bytes(header) + enc
281
+ full = macked + session.command_mac(cmd, macked)
282
+ chunks = [full[i : i + max_frame] for i in range(0, len(full), max_frame)] or [b""]
283
+
284
+ status, first = transport.raw_command(cmd, chunks[0], context=ctx)
285
+ acc = bytearray(first)
286
+ for idx, chunk in enumerate(chunks[1:], start=1):
287
+ if status != STATUS_ADDITIONAL_FRAME:
288
+ raise Ev2Error(f"{ctx}: card did not request command frame {idx} (0x{status:02X}).")
289
+ status, more = transport.raw_command(
290
+ CMD_ADDITIONAL_FRAME, chunk, context=f"{ctx} (cmd {idx})"
291
+ )
292
+ acc += more
293
+ status = _drain_response_frames(transport, status, acc, ctx)
294
+
295
+ session.cmd_ctr += 1
296
+ if status != STATUS_OK:
297
+ raise DesfireError(status, ctx)
298
+ if len(acc) == 0:
299
+ return b""
300
+ if len(acc) < 8:
301
+ raise Ev2Error(f"{ctx}: response MAC missing.")
302
+ resp_enc, mact_recv = bytes(acc[:-8]), bytes(acc[-8:])
303
+ if session.response_mac(status, resp_enc) != mact_recv:
304
+ raise Ev2Error(f"{ctx}: response MAC verification failed.")
305
+ if not resp_enc:
306
+ return b""
307
+ plain = _aes_cbc_iv(session.k_enc, _full_iv(session, (0x5A, 0xA5)), resp_enc, encrypt=False)
308
+ return plain[:response_len] if response_len else plain
309
+
310
+
311
+ def change_key_same(
312
+ transport: DesfireTransport,
313
+ session: Ev2Session,
314
+ key_no: int,
315
+ new_key: bytes,
316
+ *,
317
+ key_version: int = 0,
318
+ context: str | None = None,
319
+ ) -> None:
320
+ """ChangeKey for the key the session is authenticated with (KeyNo == auth key).
321
+
322
+ The encrypted KeyData is just ``NewKey || KeyVersion`` (no CRC - the secure-channel
323
+ MAC provides integrity for the same-key case). The session is invalidated on
324
+ success; re-authenticate with the new key. Cross-key change (XOR + CRC32) is not
325
+ implemented.
326
+ """
327
+ if len(new_key) != 16:
328
+ raise Ev2Error("AES key must be 16 bytes.")
329
+ command_full(
330
+ transport,
331
+ session,
332
+ CMD_CHANGE_KEY,
333
+ header=bytes([key_no]),
334
+ plaintext=bytes(new_key) + bytes([key_version & 0xFF]),
335
+ context=context or "ChangeKey",
336
+ )
337
+
338
+
339
+ def change_key_cross(
340
+ transport: DesfireTransport,
341
+ session: Ev2Session,
342
+ key_no: int,
343
+ new_key: bytes,
344
+ old_key: bytes,
345
+ *,
346
+ key_version: int = 0,
347
+ context: str | None = None,
348
+ ) -> None:
349
+ """ChangeKey for a key OTHER than the authentication key (KeyNo != auth key).
350
+
351
+ The card cannot derive the target key from the session, so the encrypted KeyData is
352
+ ``(NewKey XOR OldKey) || KeyVersion || CRC32(NewKey)``: the card XORs the first field
353
+ with its stored old key and checks the recovered key against the CRC32. A wrong
354
+ ``old_key`` therefore makes the CRC mismatch and the card rejects the change - it
355
+ cannot silently brick the slot. Re-authenticate with the new key afterwards.
356
+ """
357
+ if len(new_key) != 16 or len(old_key) != 16:
358
+ raise Ev2Error("AES keys must be 16 bytes.")
359
+ xored = bytes(a ^ b for a, b in zip(new_key, old_key, strict=True))
360
+ plaintext = xored + bytes([key_version & 0xFF]) + desfire_crc32(new_key)
361
+ command_full(
362
+ transport,
363
+ session,
364
+ CMD_CHANGE_KEY,
365
+ header=bytes([key_no]),
366
+ plaintext=plaintext,
367
+ context=context or "ChangeKey (cross)",
368
+ )
369
+
370
+
371
+ def format_picc(
372
+ transport: DesfireTransport,
373
+ session: Ev2Session,
374
+ *,
375
+ context: str | None = None,
376
+ ) -> None:
377
+ """FormatPICC: erase ALL applications and files. The PICC master key and its
378
+ settings survive. Must be authenticated with the PICC master key (AID 000000);
379
+ sent in CommMode.MAC."""
380
+ command_macked(transport, session, CMD_FORMAT_PICC, b"", context=context or "FormatPICC")
381
+
382
+
383
+ def change_file_settings(
384
+ transport: DesfireTransport,
385
+ session: Ev2Session,
386
+ file_no: int,
387
+ settings: bytes,
388
+ *,
389
+ context: str | None = None,
390
+ ) -> None:
391
+ """ChangeFileSettings (CommMode.FULL). ``settings`` = FileOption || AccessRights(2)
392
+ [ || SDM config ]. To attach a Secure Dynamic Messaging config the file must have been
393
+ created with the SDM file-option bit set (EV3: SDM is enabled at file creation)."""
394
+ command_full(
395
+ transport,
396
+ session,
397
+ CMD_CHANGE_FILE_SETTINGS,
398
+ header=bytes([file_no]),
399
+ plaintext=settings,
400
+ context=context or "ChangeFileSettings",
401
+ )
402
+
403
+
404
+ # --- Secure Dynamic Messaging (SDM / SUN) verification -- per NXP AN12196 (public). ---
405
+ # The card mirrors EncryptedPICCData (UID + read counter) and an SDMMAC into a free-read
406
+ # file; these helpers recover and verify them host-side.
407
+ def sdm_decrypt_picc(meta_read_key: bytes, enc_picc_data: bytes) -> tuple[bytes, int]:
408
+ """Decrypt the mirrored EncryptedPICCData -> (uid, read_counter). AES-CBC, zero IV."""
409
+ plain = _aes_cbc_iv(meta_read_key, bytes(16), enc_picc_data, encrypt=False)
410
+ uid = plain[1:8] # plain[0] = PICCDataTag; UID is 7 bytes; counter is 3 bytes LE
411
+ read_counter = int.from_bytes(plain[8:11], "little")
412
+ return uid, read_counter
413
+
414
+
415
+ def sdm_file_read_mac(
416
+ file_read_key: bytes, uid: bytes, read_counter: int, mac_input: bytes = b""
417
+ ) -> bytes:
418
+ """SDMMAC over ``mac_input`` (8-byte truncated CMAC) under the per-read SDM session key."""
419
+ sv = b"\x3c\xc3\x00\x01\x00\x80" + uid + read_counter.to_bytes(3, "little")
420
+ session_key = _aes_cmac(file_read_key, sv)
421
+ return truncate_mac(_aes_cmac(session_key, mac_input))
@@ -0,0 +1,9 @@
1
+ """PIV applet support (OpenFIPS201 2.0.0 FIPS).
2
+
3
+ Phase 3 scope is strictly read-only: SELECT, GET DATA, VERIFY status query, and
4
+ discovery. Pre-personalization (factory) and personalization land in later phases.
5
+ """
6
+
7
+ from cryptnox_id_cli.applets.piv.piv import PivApplet
8
+
9
+ __all__ = ["PivApplet"]
@@ -0,0 +1,182 @@
1
+ """PIV administrative access over the OpenFIPS201 admin secure channel.
2
+
3
+ The PIV admin channel is the secure channel of the Security Domain the applet is
4
+ associated with: out of the box the ISD, or -- after extradition -- a dedicated PIV
5
+ admin SSD. We open the channel against the *selected PIV applet* (which handles
6
+ INITIALIZE UPDATE / EXTERNAL AUTHENTICATE), then send admin commands wrapped
7
+ (C-MAC + C-ENC).
8
+
9
+ The Security Domain may speak **SCP03** (e.g. the A484 / 180KB fleet) or **SCP02**
10
+ (the A27F / D321 / 110KB fleet, whose PIV admin SSD is an SCP02 domain). We auto-detect
11
+ from the INITIALIZE UPDATE response -- ``keyInfo[1]`` (``body[11]``) is ``0x03`` for
12
+ SCP03 and ``0x02`` for SCP02 -- and open the matching channel. Both sessions expose the
13
+ same ``wrap()`` / ``unwrap()`` contract, so the rest of this module is SCP-agnostic.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import os
19
+
20
+ from cryptnox_id_cli.applets.piv import constants as c
21
+ from cryptnox_id_cli.applets.piv.objects import get_data_apdu, object_by_name
22
+ from cryptnox_id_cli.transport.apdu import APDU, Response
23
+ from cryptnox_id_cli.transport.errors import Scp03Error
24
+ from cryptnox_id_cli.transport.pcsc import CardSession
25
+ from cryptnox_id_cli.transport.scp02 import Scp02Keys, Scp02Session
26
+ from cryptnox_id_cli.transport.scp02 import open_channel as open_channel_scp02
27
+ from cryptnox_id_cli.transport.scp03 import Scp03Keys, Scp03Session
28
+ from cryptnox_id_cli.transport.scp03 import open_channel as open_channel_scp03
29
+
30
+ #: ``keyInfo[1]`` byte values that select the SCP version (GP INITIALIZE UPDATE response).
31
+ SCP02 = 0x02
32
+ SCP03 = 0x03
33
+
34
+ _SCP_LABELS = {SCP02: "SCP02", SCP03: "SCP03"}
35
+
36
+
37
+ def scp_label(value: object) -> str:
38
+ """Human-readable SCP version label. ``value`` is untyped at the call sites
39
+ (loosely-typed probe dicts, optional session attributes), so narrow here
40
+ instead of duplicating the isinstance check at every caller."""
41
+ return _SCP_LABELS.get(value, "unknown") if isinstance(value, int) else "unknown"
42
+
43
+
44
+ def _as_scp02_keys(keys: Scp03Keys | Scp02Keys) -> Scp02Keys:
45
+ return keys if isinstance(keys, Scp02Keys) else Scp02Keys(keys.enc, keys.mac, keys.dek)
46
+
47
+
48
+ def _as_scp03_keys(keys: Scp03Keys | Scp02Keys) -> Scp03Keys:
49
+ return keys if isinstance(keys, Scp03Keys) else Scp03Keys(keys.enc, keys.mac, keys.dek)
50
+
51
+
52
+ class PivAdmin:
53
+ def __init__(self, session: CardSession) -> None:
54
+ self.card = session
55
+ self.scp: Scp03Session | Scp02Session | None = None
56
+ #: which SCP the open channel uses, set by :meth:`open` (``SCP02``/``SCP03``).
57
+ self.scp_version: int | None = None
58
+
59
+ def select(self) -> None:
60
+ resp = self.card.transmit(
61
+ APDU(0x00, c.INS_SELECT, 0x04, 0x00, data=c.PIV_AID, le=256), context="SELECT PIV"
62
+ )
63
+ if not resp.ok:
64
+ raise Scp03Error(f"SELECT PIV failed (SW={resp.sw_hex()}).")
65
+
66
+ def initialize_update_probe(self, key_version: int = 0) -> dict[str, object]:
67
+ """Read-only probe: INITIALIZE UPDATE only (auth not completed).
68
+
69
+ Reports the SCP version from ``keyInfo[1]`` so the caller can tell an SCP02 card
70
+ (28-byte body) from an SCP03 one (29/32-byte body)."""
71
+ resp = self.card.transmit(
72
+ APDU(0x80, 0x50, key_version, 0x00, data=bytes(8), le=256),
73
+ context="INITIALIZE UPDATE (probe)",
74
+ )
75
+ if not resp.ok:
76
+ raise Scp03Error(f"INITIALIZE UPDATE not supported / failed (SW={resp.sw_hex()}).")
77
+ b = resp.data
78
+ scp_id = b[11] if len(b) > 11 else None
79
+ # SCP03 puts the i-param at body[12]; SCP02 body[12:14] is the sequence counter.
80
+ scp_i = b[12] if (scp_id == SCP03 and len(b) > 12) else None
81
+ return {
82
+ "supported": (scp_id == SCP03 and len(b) >= 29) or (scp_id == SCP02 and len(b) >= 28),
83
+ "scp_version": scp_id,
84
+ "key_version": b[10] if len(b) > 10 else None,
85
+ "scp_id": scp_id,
86
+ "scp_i": scp_i,
87
+ "key_diversification": b[0:10].hex().upper(),
88
+ }
89
+
90
+ def open(
91
+ self,
92
+ keys: Scp03Keys | Scp02Keys,
93
+ *,
94
+ key_version: int = 0,
95
+ security_level: int = 0x03,
96
+ ) -> None:
97
+ """Open the admin secure channel, auto-detecting SCP02 vs SCP03.
98
+
99
+ One INITIALIZE UPDATE is issued; the SCP version is read from ``body[11]`` and the
100
+ matching channel is opened (reusing that response, no second INITIALIZE UPDATE).
101
+ ``keys`` may be either key triple -- the default GP key applies to both -- and is
102
+ adapted to the detected version.
103
+ """
104
+ host_challenge = os.urandom(8)
105
+ resp = self.card.transmit(
106
+ APDU(0x80, 0x50, key_version, 0x00, data=host_challenge, le=256),
107
+ context="INITIALIZE UPDATE",
108
+ )
109
+ if not resp.ok:
110
+ raise Scp03Error(f"INITIALIZE UPDATE rejected (SW={resp.sw_hex()}).")
111
+ body = resp.data
112
+ if len(body) < 12:
113
+ raise Scp03Error(f"INITIALIZE UPDATE response too short ({len(body)} bytes).")
114
+
115
+ if body[11] == SCP02:
116
+ self.scp = open_channel_scp02(
117
+ self.card.transmit,
118
+ _as_scp02_keys(keys),
119
+ key_version=key_version,
120
+ security_level=security_level,
121
+ host_challenge=host_challenge,
122
+ init_response=resp,
123
+ )
124
+ self.scp_version = SCP02
125
+ elif body[11] == SCP03:
126
+ self.scp = open_channel_scp03(
127
+ self.card.transmit,
128
+ _as_scp03_keys(keys),
129
+ key_version=key_version,
130
+ security_level=security_level,
131
+ host_challenge=host_challenge,
132
+ init_response=resp,
133
+ )
134
+ self.scp_version = SCP03
135
+ else:
136
+ raise Scp03Error(
137
+ f"Unsupported admin secure channel (keyInfo SCP id = {body[11]:#04x}; "
138
+ "expected 0x02 or 0x03)."
139
+ )
140
+
141
+ def send(self, apdu: APDU, *, context: str | None = None) -> Response:
142
+ if self.scp is None:
143
+ raise Scp03Error("secure channel not open")
144
+ wrapped = self.scp.wrap(apdu)
145
+ return self.scp.unwrap(self.card.transmit(wrapped, context=context))
146
+
147
+ def send_chained(
148
+ self,
149
+ ins: int,
150
+ p1: int,
151
+ p2: int,
152
+ data: bytes,
153
+ *,
154
+ block_size: int = 200,
155
+ context: str | None = None,
156
+ ) -> Response:
157
+ """Send a large command via ISO command chaining (CLA bit 0x10 on all but the
158
+ final block). Each block is its own SCP03-wrapped short APDU; the applet
159
+ buffers the chained object and processes it on the final block."""
160
+ if self.scp is None:
161
+ raise Scp03Error("secure channel not open")
162
+ blocks = [data[i : i + block_size] for i in range(0, len(data), block_size)] or [b""]
163
+ resp: Response | None = None
164
+ for index, block in enumerate(blocks):
165
+ final = index == len(blocks) - 1
166
+ cla = 0x00 if final else 0x10 # chaining bit on non-final blocks
167
+ label = f"{context} [{index + 1}/{len(blocks)}]" if context else None
168
+ resp = self.scp.unwrap(
169
+ self.card.transmit(self.scp.wrap(APDU(cla, ins, p1, p2, data=block)), context=label)
170
+ )
171
+ if not final and not resp.ok:
172
+ return resp
173
+ if resp is None: # unreachable: send() always transmits at least one block
174
+ raise RuntimeError("no APDU blocks were sent")
175
+ return resp
176
+
177
+ def self_test_read(self) -> Response:
178
+ """Harmless wrapped GET DATA (Printed Info) proving the C-MAC+C-ENC path."""
179
+ obj = object_by_name("printed")
180
+ if obj is None: # unreachable: "printed" is a built-in object name
181
+ raise RuntimeError("PIV object table is missing 'printed'")
182
+ return self.send(get_data_apdu(obj.oid), context="GET DATA (admin self-test)")
@@ -0,0 +1,58 @@
1
+ """Parse the PIV Application Property Template (returned by SELECT)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from cryptnox_id_cli.util import tlv
8
+
9
+ TAG_APT = 0x61
10
+ TAG_AID = 0x4F
11
+ TAG_APP_LABEL = 0x50
12
+ TAG_URL = 0x5F50
13
+ TAG_ALLOC_AUTHORITY = 0x79
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class APTInfo:
18
+ raw: bytes
19
+ aid: bytes | None
20
+ label: str | None
21
+ url: str | None
22
+
23
+ @property
24
+ def aid_hex(self) -> str | None:
25
+ return self.aid.hex().upper() if self.aid is not None else None
26
+
27
+ def to_dict(self) -> dict[str, object]:
28
+ return {"aid": self.aid_hex, "label": self.label, "url": self.url}
29
+
30
+
31
+ def _decode_ascii(value: bytes | None) -> str | None:
32
+ if value is None:
33
+ return None
34
+ try:
35
+ return value.decode("ascii").rstrip("\x00") or None
36
+ except UnicodeDecodeError:
37
+ return value.decode("latin-1", "replace")
38
+
39
+
40
+ def parse_apt(data: bytes) -> APTInfo:
41
+ """Parse the APT, extracting AID, application label and discovery URL."""
42
+ try:
43
+ tlvs = tlv.parse(data)
44
+ except ValueError:
45
+ return APTInfo(raw=bytes(data), aid=None, label=None, url=None)
46
+
47
+ template = tlv.find(tlvs, TAG_APT)
48
+ scope = template.children if (template and template.children) else tlvs
49
+
50
+ aid_node = next((t for t in scope if t.tag == TAG_AID), None) # the direct AID, not the RID
51
+ label_node = tlv.find(scope, TAG_APP_LABEL)
52
+ url_node = tlv.find(scope, TAG_URL)
53
+ return APTInfo(
54
+ raw=bytes(data),
55
+ aid=aid_node.value if aid_node else None,
56
+ label=_decode_ascii(label_node.value if label_node else None),
57
+ url=_decode_ascii(url_node.value if url_node else None),
58
+ )