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