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,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)
|