Skip to content

Commit 6653510

Browse files
committed
✨ add NDEF URL writing functionality for NTAG 424 DNA and NTAG21x tags
1 parent ad4a0dc commit 6653510

11 files changed

Lines changed: 526 additions & 34 deletions

File tree

src/schnee/adapters/backend/contracts.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,6 @@ class ProfileBackend(ProfileReaderBackend, Protocol):
3030

3131
def apply_plan(self, plan: ChangePlan) -> Ntag424DnaProfile:
3232
"""Apply a profile change plan."""
33+
34+
def write_ndef_url(self, url: str) -> None:
35+
"""Write one URL NDEF record to the current tag."""

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

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,11 @@
2020
TagInfo,
2121
TagType,
2222
)
23-
from schnee.adapters.ntag.utils import int_to_3bytes_le
23+
from schnee.adapters.ntag.utils import (
24+
build_ndef_url_file_data,
25+
build_type2_ndef_url_tlv,
26+
int_to_3bytes_le,
27+
)
2428

2529
from .client import PcscApduClient
2630
from .reader import PcscConnection, PcscReader, PcscReaderProvider
@@ -59,6 +63,9 @@ class UnsupportedProfileReadError(PcscBackendError):
5963
class NdefParseError(PcscBackendError):
6064
"""Raised when NDEF data cannot be represented as a profile."""
6165

66+
class NdefWriteError(PcscBackendError):
67+
"""Raised when NDEF data cannot be written to the current tag."""
68+
6269
def __init__(self, reader: PcscReader) -> None:
6370
self.reader = reader
6471
self.client = PcscApduClient(reader)
@@ -125,6 +132,16 @@ def apply_plan(self, plan: ChangePlan) -> Ntag424DnaProfile:
125132
msg = "PC/SC profile writes are not implemented yet"
126133
raise self.UnsupportedPlanError(msg)
127134

135+
def write_ndef_url(self, url: str) -> None:
136+
"""Write one URL NDEF record to NTAG 424 DNA or NTAG21x tags."""
137+
try:
138+
self._select_ntag_application()
139+
except CardConnectionException, PcscApduClient.PcscApduClientError:
140+
self._write_type2_ndef_url(url)
141+
return
142+
143+
self._write_ntag424_ndef_url(url)
144+
128145
def _read_uid(self) -> str:
129146
"""Read UID using the common PC/SC contactless reader command."""
130147
response = self.send_apdu(
@@ -177,6 +194,16 @@ def _read_data_file(self, *, file_no: int, offset: int, length: int) -> list[int
177194
),
178195
).data
179196

197+
def _write_ntag424_ndef_url(self, url: str) -> None:
198+
"""Write one URL NDEF record to the NTAG 424 DNA NDEF file."""
199+
self.send_apdu(
200+
Ntag424ApduPreset.write_data_file(
201+
file_no=Ntag424ApduPreset.ndef_file_no,
202+
offset=[0x00, 0x00, 0x00],
203+
data=build_ndef_url_file_data(url),
204+
),
205+
)
206+
180207
def _get_file_settings(self, file_no: int) -> list[int]:
181208
"""Send GetFileSettings for an NTAG 424 DNA file."""
182209
return self.send_apdu(
@@ -263,6 +290,28 @@ def _read_type2_ndef_memory(self, capacity_bytes: int) -> list[int]:
263290
break
264291
return memory[:capacity_bytes]
265292

293+
def _write_type2_ndef_url(self, url: str) -> None:
294+
"""Write one URL NDEF TLV to an NTAG21x/Type 2 Tag."""
295+
capacity_bytes = self._read_type2_capacity_bytes()
296+
tlv = build_type2_ndef_url_tlv(url)
297+
if len(tlv) > capacity_bytes:
298+
msg = (
299+
f"NDEF URL TLV is {len(tlv)} bytes, "
300+
f"but tag capacity is {capacity_bytes} bytes"
301+
)
302+
raise self.NdefWriteError(msg)
303+
304+
padded = [*tlv, *([0x00] * (self.type2_page_size - 1))]
305+
page_count = (len(tlv) + self.type2_page_size - 1) // self.type2_page_size
306+
for page_index in range(page_count):
307+
start = page_index * self.type2_page_size
308+
self.send_apdu(
309+
PcscContactlessApduPreset.update_binary(
310+
page=self.type2_ndef_start_page + page_index,
311+
data=padded[start : start + self.type2_page_size],
312+
),
313+
)
314+
266315
@classmethod
267316
def create_pcsc_backend(cls, reader_name: str | None = None) -> PcscBackend:
268317
"""Create a PC/SC backend adapter."""

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@ def read_binary(*, page: int, length: int) -> CommandAPDU:
2020
"""Build a PC/SC READ BINARY command."""
2121
return CommandAPDU(cla=0xFF, ins=0xB0, p1=0x00, p2=page, le=length)
2222

23+
@staticmethod
24+
def update_binary(*, page: int, data: list[Byte]) -> CommandAPDU:
25+
"""Build a PC/SC UPDATE BINARY command."""
26+
return CommandAPDU(cla=0xFF, ins=0xD6, p1=0x00, p2=page, data=data)
27+
2328

2429
class Ntag424ApduPreset:
2530
"""Factory for NTAG 424 DNA native APDUs wrapped for ISO transport."""

src/schnee/adapters/ntag/core.py

Lines changed: 68 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,9 @@
1616
from schnee.adapters.ntag.crypt import aes_decrypt, aes_encrypt, xor_bytes
1717
from schnee.adapters.ntag.utils import (
1818
Offset,
19+
build_ndef_url_file_data,
1920
calculate_offsets,
2021
int_to_3bytes_le,
21-
wrap_ndef_record,
2222
)
2323
from schnee.utils.logger import get_logger
2424

@@ -536,18 +536,45 @@ def _change_key_iv(
536536

537537
def configure_sdm_url(self, url: str, *, cmd_ctr: int = 1) -> None:
538538
"""Authenticate, write an NDEF URL, and enable SDM explicitly."""
539+
self.write_ndef_url_with_auth(url)
540+
self.set_sdm_enabled(enabled=True, url_template=url, cmd_ctr=cmd_ctr)
541+
542+
def write_ndef_url_with_auth(self, url: str) -> None:
543+
"""Authenticate and write an NDEF URL to the NTAG 424 DNA NDEF file."""
544+
self.connect()
545+
self.authenticate()
546+
self.write_ndef_url(url_string=url)
547+
548+
def set_sdm_enabled(
549+
self,
550+
*,
551+
enabled: bool,
552+
url_template: str | None = None,
553+
cmd_ctr: int = 0,
554+
) -> None:
555+
"""Authenticate and enable or disable SDM for the NDEF file."""
539556
self.connect()
540557
k_ses_auth_enc, k_ses_auth_mac, ti, _iv = self.authenticate()
541-
self.write_ndef_url(
542-
url_string=url,
543-
)
544-
offset = calculate_offsets(url)
545-
self._enable_sdm(
558+
if enabled:
559+
if url_template is None:
560+
msg = "url_template is required when enabling SDM"
561+
raise ValueError(msg)
562+
offset = calculate_offsets(url_template)
563+
self._change_sdm_settings(
564+
session_key_enc=k_ses_auth_enc,
565+
session_key_mac=k_ses_auth_mac,
566+
ti=ti,
567+
cmd_ctr=cmd_ctr,
568+
payload=self._build_enable_sdm_payload(offset),
569+
)
570+
return
571+
572+
self._change_sdm_settings(
546573
session_key_enc=k_ses_auth_enc,
547574
session_key_mac=k_ses_auth_mac,
548575
ti=ti,
549576
cmd_ctr=cmd_ctr,
550-
offset=offset,
577+
payload=self._build_disable_sdm_payload(),
551578
)
552579

553580
def _apdu_select(self) -> None:
@@ -562,17 +589,12 @@ def write_ndef_url(
562589
"""Write NDEF URL to Tag"""
563590
_logger.debug("--- Write NDEF Data (URL) ---")
564591

565-
# 1. URLをNDEFデータに変換
566-
ndef_record = wrap_ndef_record(url_string)
567-
# ファイル全体のデータ: [Length(2bytes)] + [NDEF Record]
568-
file_data = [len(ndef_record) >> 8 & 255, len(ndef_record) & 255, *ndef_record]
569-
570592
_logger.debug("Writing URL: %s", url_string)
571593
response = self.connection.send_apdu(
572594
Ntag424ApduPreset.write_data_file(
573595
file_no=Ntag424ApduPreset.ndef_file_no,
574596
offset=[0x00, 0x00, 0x00],
575-
data=file_data,
597+
data=build_ndef_url_file_data(url_string),
576598
),
577599
)
578600
_logger.debug("Response: %s", toHexString(response.data))
@@ -639,29 +661,47 @@ def aes_cbc_encrypt_for_ev2(
639661
# 3. 暗号化実行
640662
return cipher.encrypt(padded_data)
641663

642-
def _enable_sdm(
664+
@staticmethod
665+
def _build_enable_sdm_payload(offset: Offset) -> list[int]:
666+
"""Build ChangeFileSettings plaintext payload that enables SDM."""
667+
file_option = 0x40
668+
access_rights = [0x00, 0xE0]
669+
sdm_options = 0xC1
670+
sdm_access_rights = [0xF1, 0xE1]
671+
672+
return [
673+
file_option,
674+
*access_rights,
675+
sdm_options,
676+
*sdm_access_rights,
677+
*int_to_3bytes_le(offset.uid_offset),
678+
*int_to_3bytes_le(offset.counter_offset),
679+
*int_to_3bytes_le(offset.mac_input_offset),
680+
*int_to_3bytes_le(offset.mac_offset),
681+
]
682+
683+
@staticmethod
684+
def _build_disable_sdm_payload() -> list[int]:
685+
"""Build ChangeFileSettings plaintext payload that disables SDM."""
686+
file_option = 0x00
687+
access_rights = [0x00, 0xE0]
688+
return [file_option, *access_rights]
689+
690+
def _change_sdm_settings(
643691
self,
644692
session_key_enc: bytes,
645693
session_key_mac: bytes,
646694
ti: bytes,
647695
cmd_ctr: int,
648-
offset: Offset,
696+
payload: list[int],
649697
) -> None:
650-
"""Disable SDM (ChangeFileSettings)"""
651-
_logger.debug("--- Phase 3: ChangeFileSettings (Disable SDM) ---")
698+
"""Send a protected ChangeFileSettings command for the NDEF file."""
699+
_logger.debug("--- ChangeFileSettings (SDM) ---")
700+
if not 0 <= cmd_ctr <= COMMAND_COUNTER_MAX:
701+
msg = "cmd_ctr must be between 0 and 65535"
702+
raise ValueError(msg)
652703

653-
# 設定値
654704
file_no = 0x02
655-
file_option = 0x40 # SDM OFF (Plain Communication)
656-
access_rights = [0x00, 0xE0] # Read=Free, Write=Key0 (現在の値を維持)
657-
sdm_options = 0xC1
658-
sdm_access_rights = [0xF1, 0x21]
659-
660-
# ペイロード組み立て
661-
payload = [file_option, *access_rights, sdm_options, *sdm_access_rights]
662-
payload += int_to_3bytes_le(offset.uid_offset) # 33. UID
663-
payload += int_to_3bytes_le(offset.mac_offset) # 7. MacIn
664-
payload += int_to_3bytes_le(offset.mac_offset) # 59. MacOut
665705

666706
iv = [
667707
0xA5,

src/schnee/adapters/ntag/utils.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
from dataclasses import dataclass
22

33
NDEF_HEADER_LEN = 7
4+
TYPE2_NDEF_TLV = 0x03
5+
TYPE2_TERMINATOR_TLV = 0xFE
6+
TYPE2_EXTENDED_TLV_LENGTH = 0xFF
47

58

69
class PlaceholderNotFoundError(ValueError):
@@ -29,6 +32,27 @@ def wrap_ndef_record(url_string: str) -> list[int]:
2932
return header + no_prefix + list(url_bytes)
3033

3134

35+
def build_ndef_url_file_data(url_string: str) -> list[int]:
36+
"""Build NTAG 424 NDEF file bytes for one URL record."""
37+
ndef_record = wrap_ndef_record(url_string)
38+
return [len(ndef_record) >> 8 & 255, len(ndef_record) & 255, *ndef_record]
39+
40+
41+
def build_type2_ndef_url_tlv(url_string: str) -> list[int]:
42+
"""Build Type 2 Tag NDEF TLV bytes for one URL record."""
43+
ndef_record = wrap_ndef_record(url_string)
44+
if len(ndef_record) < TYPE2_EXTENDED_TLV_LENGTH:
45+
return [TYPE2_NDEF_TLV, len(ndef_record), *ndef_record, TYPE2_TERMINATOR_TLV]
46+
return [
47+
TYPE2_NDEF_TLV,
48+
TYPE2_EXTENDED_TLV_LENGTH,
49+
len(ndef_record) >> 8 & 255,
50+
len(ndef_record) & 255,
51+
*ndef_record,
52+
TYPE2_TERMINATOR_TLV,
53+
]
54+
55+
3256
@dataclass
3357
class Offset:
3458
"""Offsets for SDM mirror placeholders inside an NDEF URL file."""

0 commit comments

Comments
 (0)