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,998 @@
1
+ """``mifare`` — MIFARE DESFire EV2 (contactless; reads AND writes).
2
+
3
+ DESFire is the card's default applet; these commands need a DESFire-capable
4
+ contactless reader and the card freshly presented (no JavaCard applet selected).
5
+ Several commands modify or erase card contents (``write``, ``record clear``,
6
+ ``app delete``, and the irreversible ``format``).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import contextlib
12
+ import os
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ import click
17
+ from rich.console import Console
18
+
19
+ from cryptnox_id_cli.applets.mifare import desfire as df
20
+ from cryptnox_id_cli.applets.mifare.desfire import DesfireTransport
21
+ from cryptnox_id_cli.applets.mifare.ev2 import (
22
+ authenticate_ev2_first,
23
+ change_file_settings,
24
+ change_key_cross,
25
+ change_key_same,
26
+ command_full,
27
+ command_macked,
28
+ format_picc,
29
+ sdm_decrypt_picc,
30
+ sdm_file_read_mac,
31
+ )
32
+ from cryptnox_id_cli.cli.context import AppContext
33
+ from cryptnox_id_cli.transport.errors import CryptnoxError
34
+ from cryptnox_id_cli.util.hexutil import from_hex
35
+
36
+
37
+ @click.group("mifare")
38
+ def command() -> None:
39
+ """Manage the MIFARE DESFire EV2 function (contactless; default applet)."""
40
+
41
+
42
+ @command.command("version")
43
+ @click.pass_obj
44
+ def version(app: AppContext) -> None:
45
+ """Show the DESFire hardware/software version, storage size and UID."""
46
+ with app.open_session() as session:
47
+ info = DesfireTransport(session).get_version().to_dict()
48
+
49
+ def human(c: Console) -> None:
50
+ c.print("[bold]MIFARE DESFire[/bold]")
51
+ c.print(f" Vendor: {info['vendor']}")
52
+ c.print(f" Hardware: v{info['hardware_version']} software: v{info['software_version']}")
53
+ c.print(f" Storage: {info['storage_bytes']} bytes")
54
+ c.print(f" UID: {info['uid']}")
55
+ c.print(f" Protocol: {info['protocol']} batch: {info['batch']} {info['production']}")
56
+
57
+ app.out.result(info, human)
58
+
59
+
60
+ @command.command("info")
61
+ @click.pass_obj
62
+ def info(app: AppContext) -> None:
63
+ """Detect DESFire: version, free memory and application list."""
64
+ with app.open_session() as session:
65
+ dtx = DesfireTransport(session)
66
+ version_info = dtx.get_version().to_dict()
67
+ free = None
68
+ with contextlib.suppress(CryptnoxError):
69
+ free = dtx.get_free_memory()
70
+ aids = [a.hex().upper() for a in dtx.application_ids()]
71
+ payload = {**version_info, "free_memory": free, "applications": aids}
72
+
73
+ def human(c: Console) -> None:
74
+ c.print("[bold]MIFARE DESFire EV2[/bold]")
75
+ c.print(f" Vendor/HW: {payload['vendor']} v{payload['hardware_version']}")
76
+ c.print(f" Storage: {payload['storage_bytes']} bytes")
77
+ c.print(f" UID: {payload['uid']}")
78
+ c.print(f" Free mem: {free if free is not None else 'n/a'}")
79
+ c.print(f" Applications ({len(aids)}): {', '.join(aids) if aids else 'none'}")
80
+
81
+ app.out.result(payload, human)
82
+
83
+
84
+ @command.command("free-memory")
85
+ @click.pass_obj
86
+ def free_memory(app: AppContext) -> None:
87
+ """Show free EEPROM (bytes), if the card permits the query."""
88
+ with app.open_session() as session:
89
+ free = DesfireTransport(session).get_free_memory()
90
+ app.out.result({"free_memory": free}, lambda c: c.print(f"Free memory: {free} bytes"))
91
+
92
+
93
+ @command.group("apps")
94
+ def apps() -> None:
95
+ """DESFire applications."""
96
+
97
+
98
+ @apps.command("list")
99
+ @click.pass_obj
100
+ def apps_list(app: AppContext) -> None:
101
+ """List application IDs (AIDs)."""
102
+ with app.open_session() as session:
103
+ aids = [a.hex().upper() for a in DesfireTransport(session).application_ids()]
104
+
105
+ def human(c: Console) -> None:
106
+ if not aids:
107
+ c.print("No applications (only the PICC master level).")
108
+ return
109
+ table = app.out.table("#", "AID")
110
+ for i, aid in enumerate(aids):
111
+ table.add_row(str(i), aid)
112
+ c.print(table)
113
+
114
+ app.out.result({"applications": aids}, human)
115
+
116
+
117
+ @command.group("files")
118
+ def files() -> None:
119
+ """DESFire files."""
120
+
121
+
122
+ @files.command("list")
123
+ @click.option("--aid", required=True, help="Application AID, 3 bytes hex (e.g. 010203).")
124
+ @click.pass_obj
125
+ def files_list(app: AppContext, aid: str) -> None:
126
+ """List file IDs in an application."""
127
+ aid_bytes = from_hex(aid)
128
+ with app.open_session() as session:
129
+ dtx = DesfireTransport(session)
130
+ dtx.select_application(aid_bytes)
131
+ file_ids = dtx.file_ids()
132
+
133
+ def human(c: Console) -> None:
134
+ if not file_ids:
135
+ c.print(f"No files in application {aid.upper()}.")
136
+ return
137
+ c.print(f"Files in {aid.upper()}: {', '.join(f'{f:02X}' for f in file_ids)}")
138
+
139
+ app.out.result({"aid": aid.upper(), "files": [f"{f:02X}" for f in file_ids]}, human)
140
+
141
+
142
+ def _resolve_aes_key(app: AppContext, zero_key: bool, key_env: str | None) -> bytes:
143
+ """Resolve a 16-byte AES key from --zero-key (factory default) or an env var."""
144
+ if zero_key and key_env:
145
+ raise click.UsageError("--zero-key and --key-env are mutually exclusive; pass only one.")
146
+ if zero_key:
147
+ key = bytes(16)
148
+ elif key_env:
149
+ value = os.environ.get(key_env)
150
+ if not value:
151
+ raise CryptnoxError(f"environment variable {key_env} is not set.")
152
+ key = from_hex(value)
153
+ if len(key) != 16:
154
+ raise CryptnoxError("DESFire EV2 keys must be 16 bytes (AES-128).")
155
+ else:
156
+ raise click.UsageError("provide --zero-key (factory default) or --key-env NAME")
157
+ app.redactor.register(key)
158
+ return key
159
+
160
+
161
+ def _aid3(aid: str) -> bytes:
162
+ aid_bytes = from_hex(aid)
163
+ if len(aid_bytes) != 3:
164
+ raise click.BadParameter("DESFire AID must be 3 bytes hex, e.g. 010203")
165
+ return aid_bytes
166
+
167
+
168
+ @command.group("app")
169
+ def app_group() -> None:
170
+ """Create / delete / select DESFire applications."""
171
+
172
+
173
+ @app_group.command("create")
174
+ @click.option("--aid", required=True, help="New application AID, 3 bytes hex.")
175
+ @click.option("--keys", "num_keys", default=3, show_default=True, type=int)
176
+ @click.option("--key-type", default="aes", show_default=True, help="Only 'aes' is supported.")
177
+ @click.pass_obj
178
+ def app_create(app: AppContext, aid: str, num_keys: int, key_type: str) -> None:
179
+ """Create an application (AES keys; new keys default to all-zero)."""
180
+ if key_type.lower() != "aes":
181
+ raise click.BadParameter("only AES applications are supported (no legacy DES/3DES)")
182
+ aid_bytes = _aid3(aid)
183
+ app.out.warn("this modifies the DESFire file system.")
184
+ if not app.yes and not click.confirm(f"Create application {aid.upper()}?", default=False):
185
+ raise click.Abort()
186
+ with app.open_session() as session:
187
+ DesfireTransport(session).create_application(aid_bytes, num_keys=num_keys)
188
+ app.out.result(
189
+ {"aid": aid.upper(), "created": True, "keys": num_keys, "key_type": "AES"},
190
+ lambda c: c.print(
191
+ f"[green]Created application {aid.upper()}[/green] ({num_keys} AES keys)."
192
+ ),
193
+ )
194
+
195
+
196
+ @app_group.command("delete")
197
+ @click.option("--aid", required=True, help="Application AID, 3 bytes hex.")
198
+ @click.option("--key-no", default=0, show_default=True, type=int, help="App master key number.")
199
+ @click.option(
200
+ "--zero-key",
201
+ is_flag=True,
202
+ help="Authenticate with the all-zero AES key (publicly known factory default) first.",
203
+ )
204
+ @click.option("--key-env", help="Authenticate with the AES key from this env var (hex).")
205
+ @click.pass_obj
206
+ def app_delete(app: AppContext, aid: str, key_no: int, zero_key: bool, key_env: str | None) -> None:
207
+ """Delete an application and ALL its files (authenticates first if a key is given)."""
208
+ aid_bytes = _aid3(aid)
209
+ app.out.warn(f"this permanently deletes application {aid.upper()} and all its files.")
210
+ if not app.yes and not click.confirm(f"Delete application {aid.upper()}?", default=False):
211
+ raise click.Abort()
212
+ with app.open_session() as session:
213
+ dtx = DesfireTransport(session)
214
+ if zero_key or key_env:
215
+ key = _resolve_aes_key(app, zero_key, key_env)
216
+ dtx.select_application(aid_bytes)
217
+ ev2 = authenticate_ev2_first(dtx, key_no, key)
218
+ # Deleting the selected app ends the session; the OK reply carries no MAC.
219
+ command_macked(
220
+ dtx,
221
+ ev2,
222
+ df.CMD_DELETE_APPLICATION,
223
+ bytes(aid_bytes),
224
+ context="DeleteApplication",
225
+ terminates_session=True,
226
+ )
227
+ else:
228
+ dtx.delete_application(aid_bytes)
229
+ app.out.result(
230
+ {"aid": aid.upper(), "deleted": True},
231
+ lambda c: c.print(f"[green]Deleted application {aid.upper()}.[/green]"),
232
+ )
233
+
234
+
235
+ @command.group("keys")
236
+ def keys_group() -> None:
237
+ """DESFire key operations."""
238
+
239
+
240
+ @keys_group.command("authenticate")
241
+ @click.option("--aid", required=True, help="Application AID, 3 bytes hex.")
242
+ @click.option("--key-no", default=0, show_default=True, type=int)
243
+ @click.option(
244
+ "--zero-key", is_flag=True, help="Use the all-zero AES key (publicly known factory default)."
245
+ )
246
+ @click.option("--key-env", help="Environment variable holding the AES key (hex).")
247
+ @click.pass_obj
248
+ def keys_authenticate(
249
+ app: AppContext, aid: str, key_no: int, zero_key: bool, key_env: str | None
250
+ ) -> None:
251
+ """AuthenticateEV2First with an AES key (proves the key and the session crypto)."""
252
+ aid_bytes = _aid3(aid)
253
+ key = _resolve_aes_key(app, zero_key, key_env)
254
+ with app.open_session() as session:
255
+ dtx = DesfireTransport(session)
256
+ dtx.select_application(aid_bytes)
257
+ ev2 = authenticate_ev2_first(dtx, key_no, key)
258
+ app.out.result(
259
+ {"aid": aid.upper(), "key_no": key_no, "authenticated": True, "ti": ev2.ti.hex().upper()},
260
+ lambda c: c.print(
261
+ f"[green]EV2 authentication OK[/green] (key {key_no}, TI {ev2.ti.hex().upper()})."
262
+ ),
263
+ )
264
+
265
+
266
+ @keys_group.command("change")
267
+ @click.option("--aid", required=True, help="Application AID, 3 bytes hex.")
268
+ @click.option("--key-no", default=0, show_default=True, type=int, help="Key number to change.")
269
+ @click.option(
270
+ "--zero-key",
271
+ is_flag=True,
272
+ help="CURRENT value of the target key is all-zero (publicly known factory default).",
273
+ )
274
+ @click.option("--key-env", help="Env var with the CURRENT value of the target key (hex).")
275
+ @click.option("--new-key-env", required=True, help="Env var with the NEW 16-byte AES key (hex).")
276
+ @click.option("--key-version", default=0, show_default=True, type=int)
277
+ @click.option(
278
+ "--auth-key-no",
279
+ type=int,
280
+ default=None,
281
+ help="Cross-key change: authenticate with THIS key (e.g. 0, the app master) instead.",
282
+ )
283
+ @click.option(
284
+ "--auth-zero-key",
285
+ is_flag=True,
286
+ help="Cross-key: the auth key is all-zero (publicly known factory default).",
287
+ )
288
+ @click.option("--auth-key-env", help="Cross-key: env var with the auth key (hex).")
289
+ @click.pass_obj
290
+ def keys_change(
291
+ app: AppContext,
292
+ aid: str,
293
+ key_no: int,
294
+ zero_key: bool,
295
+ key_env: str | None,
296
+ new_key_env: str,
297
+ key_version: int,
298
+ auth_key_no: int | None,
299
+ auth_zero_key: bool,
300
+ auth_key_env: str | None,
301
+ ) -> None:
302
+ """Change a DESFire AES key.
303
+
304
+ Same-key (default): authenticate with the key being changed; pass its current value
305
+ via --zero-key/--key-env. Cross-key: authenticate with a different key
306
+ (--auth-key-no plus --auth-zero-key/--auth-key-env, typically the app master key 0)
307
+ while --zero-key/--key-env give the CURRENT value of the target key - the card needs
308
+ it to recover and CRC-check the new key (a wrong value is cleanly rejected).
309
+ """
310
+ aid_bytes = _aid3(aid)
311
+ current = _resolve_aes_key(app, zero_key, key_env)
312
+ new_value = os.environ.get(new_key_env)
313
+ if not new_value:
314
+ raise CryptnoxError(f"environment variable {new_key_env} is not set.")
315
+ new_key = from_hex(new_value)
316
+ if len(new_key) != 16:
317
+ raise CryptnoxError("new AES key must be 16 bytes (AES-128).")
318
+ app.redactor.register(new_key)
319
+ cross = auth_key_no is not None and auth_key_no != key_no
320
+ if (auth_zero_key or auth_key_env) and not cross:
321
+ raise click.UsageError(
322
+ "--auth-* options need --auth-key-no set to a key other than --key-no "
323
+ "(that is what makes it a cross-key change)."
324
+ )
325
+ auth_key = None
326
+ if cross:
327
+ if not (auth_zero_key or auth_key_env):
328
+ raise click.UsageError(
329
+ "cross-key change needs --auth-zero-key or --auth-key-env "
330
+ "(the key you authenticate with)."
331
+ )
332
+ auth_key = _resolve_aes_key(app, auth_zero_key, auth_key_env)
333
+ app.out.warn(
334
+ f"changing key {key_no} of {aid.upper()} - keep the new key safe; a lost/wrong key "
335
+ "can lock the application (recoverable only via the PICC master key)."
336
+ )
337
+ if not app.yes and not click.confirm("Proceed with the key change?", default=False):
338
+ raise click.Abort()
339
+ with app.open_session() as session:
340
+ dtx = DesfireTransport(session)
341
+ dtx.select_application(aid_bytes)
342
+ if cross and auth_key is not None and auth_key_no is not None:
343
+ ev2 = authenticate_ev2_first(dtx, auth_key_no, auth_key)
344
+ change_key_cross(dtx, ev2, key_no, new_key, current, key_version=key_version)
345
+ else:
346
+ ev2 = authenticate_ev2_first(dtx, key_no, current)
347
+ change_key_same(dtx, ev2, key_no, new_key, key_version=key_version)
348
+ app.out.result(
349
+ {
350
+ "aid": aid.upper(),
351
+ "key_no": key_no,
352
+ "changed": True,
353
+ "mode": "cross" if cross else "same",
354
+ "auth_key_no": auth_key_no if cross else key_no,
355
+ },
356
+ lambda c: c.print(
357
+ f"[green]Changed key {key_no}[/green] of {aid.upper()} "
358
+ + (f"(cross-key; authenticated with key {auth_key_no})." if cross else "(same-key).")
359
+ + " Re-authenticate with the new key now."
360
+ ),
361
+ )
362
+
363
+
364
+ @files.command("create-standard")
365
+ @click.option("--aid", required=True, help="Application AID, 3 bytes hex.")
366
+ @click.option("--file-id", required=True, type=str, help="File number, hex (e.g. 01).")
367
+ @click.option("--size", required=True, type=int, help="File size in bytes.")
368
+ @click.option("--full", is_flag=True, help="CommMode.FULL (encrypted); key-0 for read+write.")
369
+ @click.pass_obj
370
+ def files_create_standard(app: AppContext, aid: str, file_id: str, size: int, full: bool) -> None:
371
+ """Create a standard data file. Default: MAC comm, free read / key-0 write.
372
+ With --full: encrypted (FULL) comm, key-0 for both read and write."""
373
+ aid_bytes = _aid3(aid)
374
+ file_no = int(file_id, 16)
375
+ comm, access = (0x03, 0x0000) if full else (0x01, 0xE000)
376
+ app.out.warn("this modifies the DESFire file system.")
377
+ if not app.yes and not click.confirm(
378
+ f"Create file {file_no:02X} ({size} B) in {aid.upper()}?", default=False
379
+ ):
380
+ raise click.Abort()
381
+ with app.open_session() as session:
382
+ dtx = DesfireTransport(session)
383
+ dtx.select_application(aid_bytes)
384
+ dtx.create_std_data_file(file_no, size, comm=comm, access=access)
385
+ mode = "FULL/encrypted, key-0 r+w" if full else "free read, key-0 wr"
386
+ app.out.result(
387
+ {"aid": aid.upper(), "file": f"{file_no:02X}", "size": size, "full": full, "created": True},
388
+ lambda c: c.print(
389
+ f"[green]Created standard file {file_no:02X}[/green] ({size} B, {mode})."
390
+ ),
391
+ )
392
+
393
+
394
+ @command.command("write")
395
+ @click.option("--aid", required=True, help="Application AID, 3 bytes hex.")
396
+ @click.option("--file-id", required=True, type=str, help="File number, hex.")
397
+ @click.option("--in", "in_", type=click.Path(exists=True, dir_okay=False), help="Data file.")
398
+ @click.option("--data", "data_hex", help="Inline data as hex (alternative to --in).")
399
+ @click.option("--offset", default=0, show_default=True, type=int)
400
+ @click.option("--full", is_flag=True, help="CommMode.FULL (encrypt the data on the wire).")
401
+ @click.option("--key-no", default=0, show_default=True, type=int)
402
+ @click.option(
403
+ "--zero-key", is_flag=True, help="Use the all-zero AES key (publicly known factory default)."
404
+ )
405
+ @click.option("--key-env", help="Environment variable holding the AES key (hex).")
406
+ @click.pass_obj
407
+ def write(
408
+ app: AppContext,
409
+ aid: str,
410
+ file_id: str,
411
+ in_: str | None,
412
+ data_hex: str | None,
413
+ offset: int,
414
+ full: bool,
415
+ key_no: int,
416
+ zero_key: bool,
417
+ key_env: str | None,
418
+ ) -> None:
419
+ """Write data to a file (EV2-authenticated). MACed by default, encrypted with --full."""
420
+ if bool(in_) == bool(data_hex):
421
+ raise click.UsageError("provide exactly one of --in or --data")
422
+ payload = Path(in_).read_bytes() if in_ else from_hex(data_hex or "")
423
+ aid_bytes = _aid3(aid)
424
+ file_no = int(file_id, 16)
425
+ key = _resolve_aes_key(app, zero_key, key_env)
426
+ app.out.warn(f"this overwrites data in file {file_no:02X} of {aid.upper()} on the card.")
427
+ if not app.yes and not click.confirm(
428
+ f"Write {len(payload)} bytes to file {file_no:02X} at offset {offset}?", default=False
429
+ ):
430
+ raise click.Abort()
431
+ with app.open_session() as session:
432
+ dtx = DesfireTransport(session)
433
+ dtx.select_application(aid_bytes)
434
+ ev2 = authenticate_ev2_first(dtx, key_no, key)
435
+ header = dtx.data_header(file_no, offset, len(payload))
436
+ if full:
437
+ command_full(
438
+ dtx,
439
+ ev2,
440
+ df.CMD_WRITE_DATA,
441
+ header=header,
442
+ plaintext=payload,
443
+ context="WriteData (FULL)",
444
+ )
445
+ else:
446
+ # Large payloads are split automatically (0x91AF chaining) by command_macked.
447
+ command_macked(dtx, ev2, df.CMD_WRITE_DATA, header + payload, context="WriteData")
448
+ app.out.result(
449
+ {
450
+ "aid": aid.upper(),
451
+ "file": f"{file_no:02X}",
452
+ "written": len(payload),
453
+ "offset": offset,
454
+ "full": full,
455
+ },
456
+ lambda c: c.print(
457
+ f"[green]Wrote {len(payload)} bytes[/green] to file {file_no:02X} "
458
+ f"({'EV2 encrypted' if full else 'EV2 MACed'})."
459
+ ),
460
+ )
461
+
462
+
463
+ @command.command("read")
464
+ @click.option("--aid", required=True, help="Application AID, 3 bytes hex.")
465
+ @click.option("--file-id", required=True, type=str, help="File number, hex.")
466
+ @click.option("--offset", default=0, show_default=True, type=int)
467
+ @click.option("--length", default=0, show_default=True, type=int, help="0 = whole file.")
468
+ @click.option("--out", "out_", type=click.Path(dir_okay=False), help="Write bytes to FILE.")
469
+ @click.option("--full", is_flag=True, help="CommMode.FULL (decrypt); needs a key and --length.")
470
+ @click.option("--key-no", default=0, show_default=True, type=int)
471
+ @click.option(
472
+ "--zero-key",
473
+ is_flag=True,
474
+ help="Use the all-zero AES key (publicly known factory default; FULL only).",
475
+ )
476
+ @click.option("--key-env", help="Env var holding the AES key (FULL only).")
477
+ @click.pass_obj
478
+ def read(
479
+ app: AppContext,
480
+ aid: str,
481
+ file_id: str,
482
+ offset: int,
483
+ length: int,
484
+ out_: str | None,
485
+ full: bool,
486
+ key_no: int,
487
+ zero_key: bool,
488
+ key_env: str | None,
489
+ ) -> None:
490
+ """Read data from a file. Plain by default (free-read files); --full decrypts (EV2)."""
491
+ aid_bytes = _aid3(aid)
492
+ file_no = int(file_id, 16)
493
+ if full and length <= 0:
494
+ raise click.UsageError("--full requires --length (the number of bytes to read).")
495
+ with app.open_session() as session:
496
+ dtx = DesfireTransport(session)
497
+ dtx.select_application(aid_bytes)
498
+ if full:
499
+ key = _resolve_aes_key(app, zero_key, key_env)
500
+ ev2 = authenticate_ev2_first(dtx, key_no, key)
501
+ data = command_full(
502
+ dtx,
503
+ ev2,
504
+ df.CMD_READ_DATA,
505
+ header=dtx.data_header(file_no, offset, length),
506
+ response_len=length,
507
+ context="ReadData (FULL)",
508
+ )
509
+ else:
510
+ data = dtx.read_data_plain(file_no, offset, length)
511
+ if out_:
512
+ Path(out_).write_bytes(data)
513
+ # Parity with `piv objects read`: pass read-back bytes through the redactor so a
514
+ # registered secret never echoes to stdout/JSON (file content is otherwise non-secret).
515
+ shown_hex = None if out_ else app.redactor.mask(data.hex().upper())
516
+ payload = {
517
+ "aid": aid.upper(),
518
+ "file": f"{file_no:02X}",
519
+ "length": len(data),
520
+ "out": out_,
521
+ "hex": shown_hex,
522
+ }
523
+ app.out.result(
524
+ payload,
525
+ lambda c: c.print(
526
+ f"[green]Read {len(data)} bytes -> {out_}[/green]"
527
+ if out_
528
+ else f"file {file_no:02X} ({len(data)} B): {shown_hex}"
529
+ ),
530
+ )
531
+
532
+
533
+ @command.command("format")
534
+ @click.option("--key-no", default=0, show_default=True, type=int, help="PICC master key number.")
535
+ @click.option(
536
+ "--zero-key",
537
+ is_flag=True,
538
+ help="PICC master key is the all-zero AES key (publicly known factory default).",
539
+ )
540
+ @click.option("--key-env", help="Env var with the PICC master AES key (hex).")
541
+ @click.option(
542
+ "--i-understand-this-erases-all-applications",
543
+ "understood",
544
+ is_flag=True,
545
+ help="Required for non-interactive use.",
546
+ )
547
+ @click.pass_obj
548
+ def format_command(
549
+ app: AppContext, key_no: int, zero_key: bool, key_env: str | None, understood: bool
550
+ ) -> None:
551
+ """IRREVERSIBLY erase ALL DESFire applications and files (the PICC master key is kept)."""
552
+ app.out.warn(
553
+ "This ERASES every DESFire application and file on the card. "
554
+ "The PICC master key and its settings are kept."
555
+ )
556
+ if not understood:
557
+ if app.json or not sys.stdin.isatty():
558
+ raise CryptnoxError(
559
+ "mifare format needs --i-understand-this-erases-all-applications non-interactively."
560
+ )
561
+ if click.prompt("Type FORMAT-PICC to continue") != "FORMAT-PICC":
562
+ raise click.Abort()
563
+ key = _resolve_aes_key(app, zero_key, key_env)
564
+ with app.open_session() as session:
565
+ dtx = DesfireTransport(session)
566
+ dtx.select_application(bytes(3)) # AID 000000 = PICC (master application) level
567
+ _settings, key_type, _max_keys = dtx.get_key_settings()
568
+ if key_type != 0x80: # not AES -> AuthenticateEV2First cannot be used
569
+ kind = {0x00: "DES/2K3DES", 0x40: "3K3DES"}.get(key_type, f"0x{key_type:02X}")
570
+ raise CryptnoxError(
571
+ f"the PICC master key is {kind}, not AES. FormatPICC must authenticate with "
572
+ "the PICC master key, and this AES-only CLI cannot use a legacy DES/3DES key. "
573
+ "Formatting is only possible once the PICC master key is provisioned to AES."
574
+ )
575
+ ev2 = authenticate_ev2_first(dtx, key_no, key)
576
+ format_picc(dtx, ev2)
577
+ app.out.result(
578
+ {"formatted": True},
579
+ lambda c: c.print(
580
+ "[green]PICC formatted.[/green] All applications and files erased; "
581
+ "the PICC master key is unchanged."
582
+ ),
583
+ )
584
+
585
+
586
+ @command.group("value")
587
+ def value_group() -> None:
588
+ """DESFire value files (credit / debit with transactions)."""
589
+
590
+
591
+ @value_group.command("create")
592
+ @click.option("--aid", required=True, help="Application AID, 3 bytes hex.")
593
+ @click.option("--file-id", required=True, type=str, help="File number, hex.")
594
+ @click.option("--initial", default=0, show_default=True, type=int, help="Initial value.")
595
+ @click.option("--lower", default=0, show_default=True, type=int, help="Lower limit.")
596
+ @click.option("--upper", default=1_000_000, show_default=True, type=int, help="Upper limit.")
597
+ @click.pass_obj
598
+ def value_create(
599
+ app: AppContext, aid: str, file_id: str, initial: int, lower: int, upper: int
600
+ ) -> None:
601
+ """Create a value file (comm mode MAC; key-0 for get/credit/debit)."""
602
+ aid_bytes = _aid3(aid)
603
+ file_no = int(file_id, 16)
604
+ app.out.warn("this modifies the DESFire file system.")
605
+ if not app.yes and not click.confirm(
606
+ f"Create value file {file_no:02X} in {aid.upper()} (initial {initial})?", default=False
607
+ ):
608
+ raise click.Abort()
609
+ with app.open_session() as session:
610
+ dtx = DesfireTransport(session)
611
+ dtx.select_application(aid_bytes)
612
+ dtx.create_value_file(file_no, lower, upper, initial)
613
+ app.out.result(
614
+ {"aid": aid.upper(), "file": f"{file_no:02X}", "initial": initial, "created": True},
615
+ lambda c: c.print(
616
+ f"[green]Created value file {file_no:02X}[/green] (initial {initial}, "
617
+ f"limits {lower}..{upper})."
618
+ ),
619
+ )
620
+
621
+
622
+ def _value_session(
623
+ app: AppContext, aid: str, file_id: str, key_no: int, zero_key: bool, key_env: str | None
624
+ ):
625
+ """Open a session, select the app, and EV2-authenticate; returns (dtx, ev2, file_no)."""
626
+ aid_bytes = _aid3(aid)
627
+ file_no = int(file_id, 16)
628
+ key = _resolve_aes_key(app, zero_key, key_env)
629
+ session = app.open_session()
630
+ dtx = DesfireTransport(session)
631
+ dtx.select_application(aid_bytes)
632
+ ev2 = authenticate_ev2_first(dtx, key_no, key)
633
+ return session, dtx, ev2, file_no
634
+
635
+
636
+ @value_group.command("get")
637
+ @click.option("--aid", required=True, help="Application AID, 3 bytes hex.")
638
+ @click.option("--file-id", required=True, type=str, help="File number, hex.")
639
+ @click.option("--key-no", default=0, show_default=True, type=int)
640
+ @click.option(
641
+ "--zero-key", is_flag=True, help="Use the all-zero AES key (publicly known factory default)."
642
+ )
643
+ @click.option("--key-env", help="Env var holding the AES key (hex).")
644
+ @click.pass_obj
645
+ def value_get(
646
+ app: AppContext, aid: str, file_id: str, key_no: int, zero_key: bool, key_env: str | None
647
+ ) -> None:
648
+ """Read a value file's current value (EV2 MACed)."""
649
+ session, dtx, ev2, file_no = _value_session(app, aid, file_id, key_no, zero_key, key_env)
650
+ with session:
651
+ data = command_macked(dtx, ev2, df.CMD_GET_VALUE, bytes([file_no]), context="GetValue")
652
+ value = df.parse_value(data)
653
+ app.out.result(
654
+ {"aid": aid.upper(), "file": f"{file_no:02X}", "value": value},
655
+ lambda c: c.print(f"value file {file_no:02X}: [bold]{value}[/bold]"),
656
+ )
657
+
658
+
659
+ def _credit_debit(app, aid, file_id, amount, key_no, zero_key, key_env, *, cmd, verb):
660
+ session, dtx, ev2, file_no = _value_session(app, aid, file_id, key_no, zero_key, key_env)
661
+ with session:
662
+ command_macked(dtx, ev2, cmd, dtx.value_arg(file_no, amount), context=verb)
663
+ command_macked(dtx, ev2, df.CMD_COMMIT_TXN, context="CommitTransaction")
664
+ app.out.result(
665
+ {"aid": aid.upper(), "file": f"{file_no:02X}", verb.lower(): amount, "committed": True},
666
+ lambda c: c.print(
667
+ f"[green]{verb} {amount}[/green] on value file {file_no:02X} (committed)."
668
+ ),
669
+ )
670
+
671
+
672
+ @value_group.command("credit")
673
+ @click.option("--aid", required=True, help="Application AID, 3 bytes hex.")
674
+ @click.option("--file-id", required=True, type=str, help="File number, hex.")
675
+ @click.option("--amount", required=True, type=int, help="Amount to add.")
676
+ @click.option("--key-no", default=0, show_default=True, type=int)
677
+ @click.option(
678
+ "--zero-key", is_flag=True, help="Use the all-zero AES key (publicly known factory default)."
679
+ )
680
+ @click.option("--key-env", help="Env var holding the AES key (hex).")
681
+ @click.pass_obj
682
+ def value_credit(app, aid, file_id, amount, key_no, zero_key, key_env):
683
+ """Credit (add to) a value file, then commit the transaction."""
684
+ _credit_debit(
685
+ app, aid, file_id, amount, key_no, zero_key, key_env, cmd=df.CMD_CREDIT, verb="Credit"
686
+ )
687
+
688
+
689
+ @value_group.command("debit")
690
+ @click.option("--aid", required=True, help="Application AID, 3 bytes hex.")
691
+ @click.option("--file-id", required=True, type=str, help="File number, hex.")
692
+ @click.option("--amount", required=True, type=int, help="Amount to subtract.")
693
+ @click.option("--key-no", default=0, show_default=True, type=int)
694
+ @click.option(
695
+ "--zero-key", is_flag=True, help="Use the all-zero AES key (publicly known factory default)."
696
+ )
697
+ @click.option("--key-env", help="Env var holding the AES key (hex).")
698
+ @click.pass_obj
699
+ def value_debit(app, aid, file_id, amount, key_no, zero_key, key_env):
700
+ """Debit (subtract from) a value file, then commit the transaction."""
701
+ _credit_debit(
702
+ app, aid, file_id, amount, key_no, zero_key, key_env, cmd=df.CMD_DEBIT, verb="Debit"
703
+ )
704
+
705
+
706
+ @command.group("record")
707
+ def record_group() -> None:
708
+ """DESFire record files (linear / cyclic)."""
709
+
710
+
711
+ @record_group.command("create")
712
+ @click.option("--aid", required=True, help="Application AID, 3 bytes hex.")
713
+ @click.option("--file-id", required=True, type=str, help="File number, hex.")
714
+ @click.option("--record-size", required=True, type=int, help="Bytes per record.")
715
+ @click.option("--max-records", required=True, type=int, help="Max records (cyclic keeps 1 spare).")
716
+ @click.option("--cyclic", is_flag=True, help="Cyclic (ring) instead of linear.")
717
+ @click.pass_obj
718
+ def record_create(
719
+ app: AppContext, aid: str, file_id: str, record_size: int, max_records: int, cyclic: bool
720
+ ) -> None:
721
+ """Create a record file (comm mode MAC; key-0 for write/read)."""
722
+ aid_bytes = _aid3(aid)
723
+ file_no = int(file_id, 16)
724
+ kind = "cyclic" if cyclic else "linear"
725
+ app.out.warn("this modifies the DESFire file system.")
726
+ if not app.yes and not click.confirm(
727
+ f"Create {kind} record file {file_no:02X} ({max_records}x{record_size}B)?", default=False
728
+ ):
729
+ raise click.Abort()
730
+ with app.open_session() as session:
731
+ dtx = DesfireTransport(session)
732
+ dtx.select_application(aid_bytes)
733
+ dtx.create_record_file(file_no, record_size, max_records, cyclic=cyclic)
734
+ app.out.result(
735
+ {
736
+ "aid": aid.upper(),
737
+ "file": f"{file_no:02X}",
738
+ "kind": kind,
739
+ "record_size": record_size,
740
+ "max_records": max_records,
741
+ "created": True,
742
+ },
743
+ lambda c: c.print(
744
+ f"[green]Created {kind} record file {file_no:02X}[/green] "
745
+ f"({max_records} x {record_size} B)."
746
+ ),
747
+ )
748
+
749
+
750
+ @record_group.command("write")
751
+ @click.option("--aid", required=True, help="Application AID, 3 bytes hex.")
752
+ @click.option("--file-id", required=True, type=str, help="File number, hex.")
753
+ @click.option("--in", "in_", type=click.Path(exists=True, dir_okay=False), help="Record data file.")
754
+ @click.option("--data", "data_hex", help="Inline record data as hex.")
755
+ @click.option("--key-no", default=0, show_default=True, type=int)
756
+ @click.option(
757
+ "--zero-key", is_flag=True, help="Use the all-zero AES key (publicly known factory default)."
758
+ )
759
+ @click.option("--key-env", help="Env var holding the AES key (hex).")
760
+ @click.pass_obj
761
+ def record_write(
762
+ app: AppContext,
763
+ aid: str,
764
+ file_id: str,
765
+ in_: str | None,
766
+ data_hex: str | None,
767
+ key_no: int,
768
+ zero_key: bool,
769
+ key_env: str | None,
770
+ ) -> None:
771
+ """Append a record (EV2 MACed) and commit the transaction."""
772
+ if bool(in_) == bool(data_hex):
773
+ raise click.UsageError("provide exactly one of --in or --data")
774
+ payload = Path(in_).read_bytes() if in_ else from_hex(data_hex or "")
775
+ session, dtx, ev2, file_no = _value_session(app, aid, file_id, key_no, zero_key, key_env)
776
+ with session:
777
+ command_macked(
778
+ dtx,
779
+ ev2,
780
+ df.CMD_WRITE_RECORD,
781
+ dtx.data_header(file_no, 0, len(payload)) + payload,
782
+ context="WriteRecord",
783
+ )
784
+ command_macked(dtx, ev2, df.CMD_COMMIT_TXN, context="CommitTransaction")
785
+ app.out.result(
786
+ {"aid": aid.upper(), "file": f"{file_no:02X}", "written": len(payload), "committed": True},
787
+ lambda c: c.print(
788
+ f"[green]Wrote a {len(payload)}-byte record[/green] to {file_no:02X} (committed)."
789
+ ),
790
+ )
791
+
792
+
793
+ @record_group.command("read")
794
+ @click.option("--aid", required=True, help="Application AID, 3 bytes hex.")
795
+ @click.option("--file-id", required=True, type=str, help="File number, hex.")
796
+ @click.option("--count", default=0, show_default=True, type=int, help="Records to read (0 = all).")
797
+ @click.option("--offset", default=0, show_default=True, type=int, help="Record offset.")
798
+ @click.option("--key-no", default=0, show_default=True, type=int)
799
+ @click.option(
800
+ "--zero-key", is_flag=True, help="Use the all-zero AES key (publicly known factory default)."
801
+ )
802
+ @click.option("--key-env", help="Env var holding the AES key (hex).")
803
+ @click.pass_obj
804
+ def record_read(
805
+ app: AppContext,
806
+ aid: str,
807
+ file_id: str,
808
+ count: int,
809
+ offset: int,
810
+ key_no: int,
811
+ zero_key: bool,
812
+ key_env: str | None,
813
+ ) -> None:
814
+ """Read records (EV2 MACed)."""
815
+ session, dtx, ev2, file_no = _value_session(app, aid, file_id, key_no, zero_key, key_env)
816
+ with session:
817
+ data = command_macked(
818
+ dtx,
819
+ ev2,
820
+ df.CMD_READ_RECORDS,
821
+ dtx.data_header(file_no, offset, count),
822
+ context="ReadRecords",
823
+ )
824
+ app.out.result(
825
+ {
826
+ "aid": aid.upper(),
827
+ "file": f"{file_no:02X}",
828
+ "length": len(data),
829
+ "hex": app.redactor.mask(data.hex().upper()),
830
+ },
831
+ lambda c: c.print(
832
+ f"records of {file_no:02X} ({len(data)} B): {app.redactor.mask(data.hex().upper())}"
833
+ ),
834
+ )
835
+
836
+
837
+ @record_group.command("clear")
838
+ @click.option("--aid", required=True, help="Application AID, 3 bytes hex.")
839
+ @click.option("--file-id", required=True, type=str, help="File number, hex.")
840
+ @click.option("--key-no", default=0, show_default=True, type=int)
841
+ @click.option(
842
+ "--zero-key", is_flag=True, help="Use the all-zero AES key (publicly known factory default)."
843
+ )
844
+ @click.option("--key-env", help="Env var holding the AES key (hex).")
845
+ @click.pass_obj
846
+ def record_clear(
847
+ app: AppContext, aid: str, file_id: str, key_no: int, zero_key: bool, key_env: str | None
848
+ ) -> None:
849
+ """Clear all records (EV2 MACed) and commit."""
850
+ file_no = int(file_id, 16)
851
+ app.out.warn(f"this permanently erases ALL records in file {file_no:02X} of {aid.upper()}.")
852
+ if not app.yes and not click.confirm(f"Clear record file {file_no:02X}?", default=False):
853
+ raise click.Abort()
854
+ session, dtx, ev2, file_no = _value_session(app, aid, file_id, key_no, zero_key, key_env)
855
+ with session:
856
+ command_macked(
857
+ dtx, ev2, df.CMD_CLEAR_RECORD_FILE, bytes([file_no]), context="ClearRecordFile"
858
+ )
859
+ command_macked(dtx, ev2, df.CMD_COMMIT_TXN, context="CommitTransaction")
860
+ app.out.result(
861
+ {"aid": aid.upper(), "file": f"{file_no:02X}", "cleared": True},
862
+ lambda c: c.print(f"[green]Cleared record file {file_no:02X}[/green] (committed)."),
863
+ )
864
+
865
+
866
+ @command.group("sdm")
867
+ def sdm_group() -> None:
868
+ """DESFire EV3 Secure Dynamic Messaging (SDM / SUN) - per NXP AN12196.
869
+
870
+ Configures a free-read NDEF file so the card mirrors an encrypted UID + read counter
871
+ and an SDMMAC into its URL on every read; `sdm read` decrypts and verifies them. The
872
+ application must already exist with at least 3 AES keys (SDM uses key 2 to encrypt the
873
+ PICCData and key 1 for the MAC); on a fresh app those keys are all-zero.
874
+ """
875
+
876
+
877
+ @sdm_group.command("setup")
878
+ @click.option("--aid", required=True, help="Application AID, 3 bytes hex (app needs >=3 keys).")
879
+ @click.option("--file-id", required=True, type=str, help="File number, hex (e.g. 02).")
880
+ @click.option("--url", default="https://cryptnox.example/t", show_default=True)
881
+ @click.option(
882
+ "--zero-key",
883
+ is_flag=True,
884
+ help="App master key 0 is the all-zero AES key (publicly known factory default).",
885
+ )
886
+ @click.option("--key-env", help="Env var holding the app master key 0 AES key (hex).")
887
+ @click.pass_obj
888
+ def sdm_setup(
889
+ app: AppContext, aid: str, file_id: str, url: str, zero_key: bool, key_env: str | None
890
+ ) -> None:
891
+ """Create an SDM-enabled file, write its NDEF URL template, and configure SDM (EV3 only).
892
+
893
+ ChangeFileSettings authenticates with app master key 0: pass --zero-key (the publicly
894
+ known factory default of a fresh application) or --key-env NAME.
895
+ """
896
+ aid_bytes = _aid3(aid)
897
+ file_no = int(file_id, 16)
898
+ key = _resolve_aes_key(app, zero_key, key_env)
899
+ prefix = (url + "?picc=").encode()
900
+ picc_off = len(prefix)
901
+ sep = b"&c="
902
+ mac_off = picc_off + 32 + len(sep) # PICCData mirror is 32 ASCII hex chars
903
+ template = prefix + b"0" * 32 + sep + b"0" * 16 # CMAC mirror is 16 ASCII hex chars
904
+ size = max(len(template), 96)
905
+ app.out.warn("creates + configures an SDM file (EV3); needs an app with >=3 AES keys.")
906
+ if not app.yes and not click.confirm(
907
+ f"Set up SDM on file {file_no:02X} of {aid.upper()}?", default=False
908
+ ):
909
+ raise click.Abort()
910
+
911
+ def le3(n: int) -> bytes:
912
+ return n.to_bytes(3, "little")
913
+
914
+ with app.open_session() as session:
915
+ dtx = DesfireTransport(session)
916
+ dtx.select_application(aid_bytes)
917
+ # SDM must be enabled at creation (FileOption bit 0x40); plain comm, free read/write,
918
+ # key-0 change-settings. Then write the template (free, plain, chunked under one frame).
919
+ dtx.create_std_data_file(file_no, size, comm=0x00, access=0xEEE0, sdm=True)
920
+ for off in range(0, len(template), 40):
921
+ chunk = template[off : off + 40]
922
+ st, _ = dtx.raw_command(
923
+ df.CMD_WRITE_DATA,
924
+ DesfireTransport.data_header(file_no, off, len(chunk)) + chunk,
925
+ context="WriteData (SDM template)",
926
+ )
927
+ if st != df.STATUS_OK:
928
+ raise df.DesfireError(st, "WriteData (SDM template)")
929
+ # SDM file settings (AN12196): SDMOptions C1 = UID + read-counter + ASCII mirroring;
930
+ # SDMAccessRights F1 21 = MetaRead key2 / FileRead key1 / CtrRet key1; then the 3-byte
931
+ # little-endian offsets PICCData, SDMMACInput(=MAC -> empty input), SDMMAC.
932
+ settings = (
933
+ bytes([0x40])
934
+ + (0xEEE0).to_bytes(2, "little")
935
+ + bytes([0xC1, 0xF1, 0x21])
936
+ + le3(picc_off)
937
+ + le3(mac_off)
938
+ + le3(mac_off)
939
+ )
940
+ ev2 = authenticate_ev2_first(dtx, 0, key)
941
+ change_file_settings(dtx, ev2, file_no, settings, context="ChangeFileSettings SDM")
942
+ app.out.result(
943
+ {"aid": aid.upper(), "file": f"{file_no:02X}", "sdm": True, "template": template.decode()},
944
+ lambda c: c.print(
945
+ f"[green]SDM configured[/green] on file {file_no:02X} of {aid.upper()}.\n"
946
+ f" template: {template.decode()}"
947
+ ),
948
+ )
949
+
950
+
951
+ @sdm_group.command("read")
952
+ @click.option("--aid", required=True, help="Application AID, 3 bytes hex.")
953
+ @click.option("--file-id", required=True, type=str, help="File number, hex.")
954
+ @click.pass_obj
955
+ def sdm_read(app: AppContext, aid: str, file_id: str) -> None:
956
+ """Free-read the SDM file; decrypt the mirrored PICCData and verify the SDMMAC (AN12196).
957
+
958
+ Verification assumes the SDM keys (app keys 2 and 1) still hold their all-zero value,
959
+ the publicly known factory default of a fresh application. A tag whose SDM keys were
960
+ rotated fails this check while being perfectly genuine.
961
+ """
962
+ import re
963
+
964
+ aid_bytes = _aid3(aid)
965
+ file_no = int(file_id, 16)
966
+ with app.open_session() as session:
967
+ dtx = DesfireTransport(session)
968
+ dtx.select_application(aid_bytes)
969
+ text = dtx.read_data_plain(file_no, 0, 0).decode("ascii", "replace")
970
+ m_picc = re.search(r"picc=([0-9A-Fa-f]{32})", text)
971
+ m_cmac = re.search(r"&c=([0-9A-Fa-f]{16})", text)
972
+ if not (m_picc and m_cmac):
973
+ raise CryptnoxError("no SDM mirrors (picc=/&c=) found in the file - is SDM configured?")
974
+ zero = bytes(16) # SDM keys are app keys 2/1, all-zero on a fresh app
975
+ uid, ctr = sdm_decrypt_picc(zero, bytes.fromhex(m_picc.group(1)))
976
+ valid = sdm_file_read_mac(zero, uid, ctr, b"") == bytes.fromhex(m_cmac.group(1))
977
+ payload = {
978
+ "url": text,
979
+ "uid": uid.hex().upper(),
980
+ "read_counter": ctr,
981
+ "sdmmac_valid": valid,
982
+ "keys_assumed": "all-zero factory default (app keys 2/1)",
983
+ }
984
+
985
+ def human(c: Console) -> None:
986
+ c.print(f"NDEF: {text}")
987
+ c.print(f" decrypted UID: {uid.hex().upper()}")
988
+ c.print(f" read counter: {ctr}")
989
+ if valid:
990
+ c.print(" SDMMAC: [green]verified[/green] (with the all-zero factory default keys)")
991
+ else:
992
+ c.print(
993
+ " SDMMAC: [red]did not verify with the all-zero factory default keys[/red]\n"
994
+ " a tag whose SDM keys were rotated fails this check while being genuine\n"
995
+ " (the decrypted UID and counter above are then garbage as well)."
996
+ )
997
+
998
+ app.out.result(payload, human)