Skip to content

Commit ad4a0dc

Browse files
authored
Add NTAG 424 key update and validation support (#5)
2 parents b5bba15 + 28793d0 commit ad4a0dc

15 files changed

Lines changed: 1224 additions & 91 deletions

File tree

.pre-commit-config.yaml

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,18 @@
11
repos:
22
- repo: local
33
hooks:
4-
- id: nox
5-
name: Run Nox
4+
- id: nox-pre-commit
5+
name: Run Nox pre-commit hook
66
entry: uv run nox -s lint type_check --
77
language: system
88
types: [python]
99
pass_filenames: true
10+
stages: ["pre-commit"]
11+
12+
- id: nox-pre-push
13+
name: Run Nox pre-push hook
14+
entry: uv run nox -s test
15+
language: system
16+
types: [python]
17+
pass_filenames: false
18+
stages: ["pre-push"]

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ target-version = "py314"
6666
[tool.ruff.lint]
6767
select = ["ALL"]
6868
ignore = [
69+
"COM812",
6970
"D100",
7071
"D104",
7172
"D107",

src/schnee/adapters/backend/contracts.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,22 @@
33
from typing import TYPE_CHECKING, Protocol, runtime_checkable
44

55
if TYPE_CHECKING:
6+
from schnee.adapters.ntag.apdu import CommandAPDU, ResponseAPDU
67
from schnee.adapters.ntag.profile import ChangePlan, Ntag424DnaProfile, NtagProfile
78

89

910
@runtime_checkable
1011
class ProfileReaderBackend(Protocol):
11-
"""Backend adapter that can read an NTAG profile."""
12+
"""Backend adapter that can communicate with and read an NTAG profile."""
13+
14+
def send_apdu(
15+
self,
16+
apdu: CommandAPDU | list[int],
17+
*,
18+
check_status: bool = True,
19+
ok_statuses: tuple[tuple[int, int], ...] | None = None,
20+
) -> ResponseAPDU:
21+
"""Transmit an APDU and optionally raise when the status word is not OK."""
1222

1323
def read_profile(self) -> NtagProfile:
1424
"""Read the current tag profile."""

src/schnee/adapters/backend/pcsc/backend.py

Lines changed: 28 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -32,16 +32,6 @@
3232
class PcscBackend:
3333
"""Backend adapter that wraps a PC/SC reader."""
3434

35-
ntag_application_df_name: ClassVar[list[int]] = [
36-
0xD2,
37-
0x76,
38-
0x00,
39-
0x00,
40-
0x85,
41-
0x01,
42-
0x01,
43-
]
44-
ndef_file_no: ClassVar[int] = 0x02
4535
ndef_length_header_size: ClassVar[int] = 2
4636
ntag424_key_slots: ClassVar[int] = 5
4737
type2_cc_page: ClassVar[int] = 3
@@ -82,9 +72,19 @@ def connect(self) -> PcscConnection:
8272
"""Connect to the wrapped PC/SC reader."""
8373
return self.client.connect()
8474

85-
def send_apdu(self, apdu: CommandAPDU | list[int]) -> ResponseAPDU:
75+
def send_apdu(
76+
self,
77+
apdu: CommandAPDU | list[int],
78+
*,
79+
check_status: bool = True,
80+
ok_statuses: tuple[tuple[int, int], ...] | None = None,
81+
) -> ResponseAPDU:
8682
"""Transmit an APDU through the wrapped PC/SC reader."""
87-
return self.client.send_apdu(apdu)
83+
return self.client.send_apdu(
84+
apdu,
85+
check_status=check_status,
86+
ok_statuses=ok_statuses,
87+
)
8888

8989
def read_profile(self) -> NtagProfile:
9090
"""Read the currently reachable NTAG profile."""
@@ -95,7 +95,7 @@ def read_profile(self) -> NtagProfile:
9595
return self._read_type2_profile(uid)
9696

9797
file_settings = Ntag424FileSettings.from_response(
98-
self._get_file_settings(self.ndef_file_no),
98+
self._get_file_settings(Ntag424ApduPreset.ndef_file_no),
9999
)
100100
sections = Ntag424ProfileSections.from_parsed_data(
101101
file_settings=file_settings,
@@ -127,22 +127,23 @@ def apply_plan(self, plan: ChangePlan) -> Ntag424DnaProfile:
127127

128128
def _read_uid(self) -> str:
129129
"""Read UID using the common PC/SC contactless reader command."""
130-
response = self.send_apdu(PcscContactlessApduPreset.get_uid())
130+
response = self.send_apdu(
131+
PcscContactlessApduPreset.get_uid(),
132+
check_status=False,
133+
)
131134
if not response.ok:
132135
msg = f"Unable to read tag UID: status {response.status:#x}"
133136
raise self.UnsupportedProfileReadError(msg)
134137
return bytes(response.data).hex().upper()
135138

136139
def _select_ntag_application(self) -> None:
137140
"""Select the NTAG 424 DNA application by DF name."""
138-
self.client.send_checked(
139-
Ntag424ApduPreset.select_application(self.ntag_application_df_name),
140-
)
141+
self.send_apdu(Ntag424ApduPreset.select_application())
141142

142143
def _read_ndef_profile(self) -> NdefProfile:
143144
"""Read and parse the NDEF file into profile records."""
144145
length_data = self._read_data_file(
145-
file_no=self.ndef_file_no,
146+
file_no=Ntag424ApduPreset.ndef_file_no,
146147
offset=0,
147148
length=self.ndef_length_header_size,
148149
)
@@ -155,7 +156,7 @@ def _read_ndef_profile(self) -> NdefProfile:
155156
return NdefProfile(present=False)
156157

157158
message = self._read_data_file(
158-
file_no=self.ndef_file_no,
159+
file_no=Ntag424ApduPreset.ndef_file_no,
159160
offset=2,
160161
length=ndef_length,
161162
)
@@ -168,17 +169,19 @@ def _read_ndef_profile(self) -> NdefProfile:
168169
def _read_data_file(self, *, file_no: int, offset: int, length: int) -> list[int]:
169170
"""Read bytes from an NTAG 424 DNA data file."""
170171
offset_bytes = int_to_3bytes_le(offset)
171-
return self.client.send_checked(
172+
return self.send_apdu(
172173
Ntag424ApduPreset.read_data_file(
173174
file_no=file_no,
174175
offset=offset_bytes,
175176
length=int_to_3bytes_le(length),
176177
),
177-
)
178+
).data
178179

179180
def _get_file_settings(self, file_no: int) -> list[int]:
180181
"""Send GetFileSettings for an NTAG 424 DNA file."""
181-
return self.client.send_checked(Ntag424ApduPreset.get_file_settings(file_no))
182+
return self.send_apdu(
183+
Ntag424ApduPreset.get_file_settings(file_no),
184+
).data
182185

183186
def _get_key_versions(self) -> list[int]:
184187
"""Send GetKeyVersion for all NTAG 424 DNA application key slots."""
@@ -188,7 +191,7 @@ def _get_key_versions(self) -> list[int]:
188191

189192
def _get_key_version(self, key_no: int) -> int:
190193
"""Send GetKeyVersion for one NTAG 424 DNA application key."""
191-
data = self.client.send_checked(Ntag424ApduPreset.get_key_version(key_no))
194+
data = self.send_apdu(Ntag424ApduPreset.get_key_version(key_no)).data
192195
if len(data) != 1:
193196
msg = "NTAG 424 DNA key version response must be 1 byte"
194197
raise self.UnsupportedProfileReadError(msg)
@@ -236,12 +239,12 @@ def _detect_type2_tag_type(self, capacity_bytes: int) -> TagType:
236239

237240
def _read_type2_page(self, page: int) -> list[int]:
238241
"""Read one Type 2 Tag page using the PC/SC READ BINARY command."""
239-
data = self.client.send_checked(
242+
data = self.send_apdu(
240243
PcscContactlessApduPreset.read_binary(
241244
page=page,
242245
length=self.type2_page_size,
243246
),
244-
)
247+
).data
245248
if len(data) != self.type2_page_size:
246249
msg = "Type 2 Tag page reads must return 4 bytes"
247250
raise self.NdefParseError(msg)

src/schnee/adapters/backend/pcsc/client.py

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -44,21 +44,20 @@ def connect(self) -> PcscConnection:
4444
self.connection.connect()
4545
return self.connection
4646

47-
def send_apdu(self, apdu: CommandAPDU | list[int]) -> ResponseAPDU:
48-
"""Transmit an APDU and return the full response."""
49-
command = apdu.to_list() if isinstance(apdu, CommandAPDU) else apdu
50-
response, sw1, sw2 = self.connect().transmit(command)
51-
return ResponseAPDU(data=response, sw1=sw1, sw2=sw2)
52-
53-
def send_checked(
47+
def send_apdu(
5448
self,
5549
apdu: CommandAPDU | list[int],
5650
*,
51+
check_status: bool = True,
5752
ok_statuses: tuple[tuple[int, int], ...] | None = None,
58-
) -> list[int]:
59-
"""Transmit an APDU and return data for successful status words."""
60-
response = self.send_apdu(apdu)
53+
) -> ResponseAPDU:
54+
"""Transmit an APDU and optionally raise for unsuccessful status words."""
55+
command = apdu.to_list() if isinstance(apdu, CommandAPDU) else apdu
56+
response, sw1, sw2 = self.connect().transmit(command)
57+
response_apdu = ResponseAPDU(data=response, sw1=sw1, sw2=sw2)
58+
if not check_status:
59+
return response_apdu
6160
statuses = self.ok_statuses if ok_statuses is None else ok_statuses
62-
if (response.sw1, response.sw2) in statuses:
63-
return response.data
64-
raise self.ApduStatusError(response.sw1, response.sw2)
61+
if (response_apdu.sw1, response_apdu.sw2) in statuses:
62+
return response_apdu
63+
raise self.ApduStatusError(response_apdu.sw1, response_apdu.sw2)

src/schnee/adapters/ntag/apdu/presets.py

Lines changed: 87 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
from __future__ import annotations
44

5+
from typing import ClassVar
6+
57
from .base import Byte, CommandAPDU
68

79

@@ -22,10 +24,71 @@ def read_binary(*, page: int, length: int) -> CommandAPDU:
2224
class Ntag424ApduPreset:
2325
"""Factory for NTAG 424 DNA native APDUs wrapped for ISO transport."""
2426

27+
application_df_name: ClassVar[list[Byte]] = [
28+
0xD2,
29+
0x76,
30+
0x00,
31+
0x00,
32+
0x85,
33+
0x01,
34+
0x01,
35+
]
36+
ndef_file_no: ClassVar[int] = 0x02
37+
2538
@staticmethod
26-
def select_application(df_name: list[Byte]) -> CommandAPDU:
39+
def select_application(
40+
df_name: list[Byte] | None = None,
41+
) -> CommandAPDU:
2742
"""Build a SELECT command for the NTAG 424 DNA application DF name."""
28-
return CommandAPDU(cla=0x00, ins=0xA4, p1=0x04, p2=0x00, data=df_name)
43+
data = Ntag424ApduPreset.application_df_name if df_name is None else df_name
44+
return CommandAPDU(
45+
cla=0x00,
46+
ins=0xA4,
47+
p1=0x04,
48+
p2=0x00,
49+
data=data,
50+
)
51+
52+
@staticmethod
53+
def authenticate_ev2_first(key_no: int) -> CommandAPDU:
54+
"""Build an NTAG 424 DNA AuthenticateEV2First command."""
55+
return CommandAPDU(
56+
cla=0x90,
57+
ins=0x71,
58+
p1=0x00,
59+
p2=0x00,
60+
data=[key_no, 0x00],
61+
le=0x00,
62+
)
63+
64+
@staticmethod
65+
def additional_frame(data: list[Byte]) -> CommandAPDU:
66+
"""Build an NTAG 424 DNA AdditionalFrame command."""
67+
return CommandAPDU(
68+
cla=0x90,
69+
ins=0xAF,
70+
p1=0x00,
71+
p2=0x00,
72+
data=data,
73+
le=0x00,
74+
)
75+
76+
@staticmethod
77+
def change_key(
78+
*,
79+
key_no: int,
80+
encrypted_key_data: list[Byte],
81+
mac: list[Byte],
82+
) -> CommandAPDU:
83+
"""Build an NTAG 424 DNA ChangeKey command."""
84+
return CommandAPDU(
85+
cla=0x90,
86+
ins=0xC4,
87+
p1=0x00,
88+
p2=0x00,
89+
data=[key_no, *encrypted_key_data, *mac],
90+
le=0x00,
91+
)
2992

3093
@staticmethod
3194
def read_data_file(
@@ -44,6 +107,28 @@ def read_data_file(
44107
le=0x00,
45108
)
46109

110+
@staticmethod
111+
def write_data_file(
112+
*,
113+
file_no: int,
114+
offset: list[Byte],
115+
data: list[Byte],
116+
) -> CommandAPDU:
117+
"""Build an NTAG 424 DNA WriteData command."""
118+
length = [
119+
len(data) & 0xFF,
120+
(len(data) >> 8) & 0xFF,
121+
(len(data) >> 16) & 0xFF,
122+
]
123+
return CommandAPDU(
124+
cla=0x90,
125+
ins=0x8D,
126+
p1=0x00,
127+
p2=0x00,
128+
data=[file_no, *offset, *length, *data],
129+
le=0x00,
130+
)
131+
47132
@staticmethod
48133
def get_file_settings(file_no: int) -> CommandAPDU:
49134
"""Build an NTAG 424 DNA GetFileSettings command."""

0 commit comments

Comments
 (0)