"""OPTIGA(TM) Authenticate NBT (NBT2000A8K0T4) — Infineon NFC bridge tag. Sourced from the Infineon Extended Datasheet Rev 2.1. NFC Forum Type 4 Tag over ISO/IEC 14443-A (7-byte UID, Infineon mfg code 0x05), with an 8 KB file system (4 KB NDEF + 4x1 KB proprietary files), a File Access Policy (EF.FAP) gating per-file read/write, 32-bit password management, and ECDSA (NIST P-256) one-way tag authentication. Modelled here (the NFC side): activation + ISO-DEP, the Type 4 file tree (EF.CC / NDEF / 4 proprietary / EF.FAP), SELECT (by AID and by file id with inline password verification), READ BINARY / UPDATE BINARY with FAP enforcement, CREATE / DELETE / CHANGE / UNBLOCK PASSWORD, AUTHENTICATE TAG (ECDSA-P256), GET DATA (applet version) and the PERSONALIZATION -> OPERATIONAL life cycle. The I2C bridge / pass-through side is not simulated. """ from __future__ import annotations import os import struct from pm3py.sim.frame import RFFrame from ..base import Tag14443A_4 # File IDs FID_CC = b"\xE1\x03" FID_NDEF = b"\xE1\x04" FID_PP = [b"\xE1\xA1", b"\xE1\xA2", b"\xE1\xA3", b"\xE1\xA4"] FID_FAP = b"\xE1\xAF" NDEF_AID = b"\xD2\x76\x00\x00\x85\x01\x01" # Command INS (CLA 0x00) INS_SELECT = 0xA4 INS_PERSONALIZE = 0xE2 INS_READ_BINARY = 0xB0 INS_AUTH_TAG = 0x88 INS_UPDATE_BINARY = 0xD6 INS_CHANGE_UNBLOCK_PWD = 0x24 INS_CREATE_PWD = 0xE1 INS_DELETE_PWD = 0xE4 INS_GET_DATA = 0x30 # FAP config byte values FAP_NEVER = 0x00 FAP_ALWAYS = 0x40 FAP_PWD = 0x80 # | password-id in low 5 bits # Status words SW_OK = b"\x90\x00" SW_WRONG_LENGTH = b"\x67\x00" SW_SEC_NOT_SATISFIED = b"\x69\x82" SW_COND_NOT_SATISFIED = b"\x69\x85" SW_WRONG_DATA = b"\x6A\x80" SW_FILE_NOT_FOUND = b"\x6A\x82" SW_WRONG_P1P2 = b"\x6A\x86" SW_REF_NOT_FOUND = b"\x6A\x88" SW_CMD_NOT_ALLOWED = b"\x69\x86" # Default ATS: length 0C, T0=78, TA1=77, TB1=70, TC1=02, hist "NBT2000" DEFAULT_ATS = b"\x0C\x78\x77\x70\x02" + b"NBT2000" class _Password: def __init__(self, value: bytes, response: bytes = b"\x00\x00", limit: int = 0x7F): self.value = value self.response = response self.limit = limit self.tries = limit self.blocked = False class OptigaAuthenticateNBT(Tag14443A_4): """Infineon OPTIGA Authenticate NBT NFC Type 4 tag.""" def __init__(self, uid: bytes | None = None, ndef_message: bytes = b"", ecc_key=None, atqa: bytes = b"\x44\x00", sak: int = 0x20): if uid is None: uid = b"\x05" + os.urandom(6) # Infineon mfg code 0x05 # ATQA/SAK are NOT published in the NBT2000 extended datasheet or the # hardware integration guide (verified 2026-07: both omit them). They are # fixed by the tag being a 7-byte-UID ISO 14443-4 (Type 4A) device: # ATQA 0x0044 (wire 44 00) = bit-frame anticollision + double-size UID, # SAK 0x20 = ISO-DEP compliant, UID complete. # These match every 7-byte-UID Type-4A tag (NTAG21x, DESFire, ST25TA). super().__init__(uid=uid, atqa=atqa, sak=sak, ats=DEFAULT_ATS) self._lifecycle = "PERSONALIZATION" self._ecc_key = ecc_key # ECC P-256 private key (lazy-generated) self._passwords: dict[int, _Password] = {} self._files: dict[bytes, bytearray] = { FID_CC: bytearray(64), FID_NDEF: bytearray(4096), FID_PP[0]: bytearray(1024), FID_PP[1]: bytearray(1024), FID_PP[2]: bytearray(1024), FID_PP[3]: bytearray(1024), FID_FAP: bytearray(42), } # File Access Policy: fileid -> [i2c_r, i2c_w, nfc_r, nfc_w] self._fap: dict[bytes, list[int]] = { FID_CC: [FAP_ALWAYS, FAP_NEVER, FAP_ALWAYS, FAP_NEVER], FID_NDEF: [FAP_ALWAYS, FAP_ALWAYS, FAP_ALWAYS, FAP_ALWAYS], FID_PP[0]: [FAP_ALWAYS] * 4, FID_PP[1]: [FAP_ALWAYS] * 4, FID_PP[2]: [FAP_ALWAYS] * 4, FID_PP[3]: [FAP_ALWAYS] * 4, FID_FAP: [FAP_ALWAYS] * 4, } self._app_selected = False self._selected_file: bytes | None = None self._read_ok: set[bytes] = set() self._write_ok: set[bytes] = set() self._build_cc(ndef_message) def _build_cc(self, ndef_message: bytes) -> None: cc = bytearray(64) struct.pack_into(">H", cc, 0, 0x002F) # CCLEN cc[2] = 0x20 # mapping version 2.0 struct.pack_into(">H", cc, 3, 0x0100) # MLe struct.pack_into(">H", cc, 5, 0x00FF) # MLc cc[7:15] = b"\x04\x06" + FID_NDEF + struct.pack(">H", 0x1000) + b"\x00\x00" off = 15 for fid in FID_PP: cc[off:off + 8] = b"\x05\x06" + fid + struct.pack(">H", 0x0400) + b"\x00\x00" off += 8 self._files[FID_CC] = cc self._write_fap_file() if ndef_message: self.set_ndef(ndef_message) def _write_fap_file(self) -> None: fap = bytearray() for fid in (FID_CC, FID_NDEF, *FID_PP, FID_FAP): fap += fid + bytes(self._fap[fid]) self._files[FID_FAP] = fap # ----- NDEF convenience ----- def set_ndef(self, message: bytes) -> None: if len(message) > 4096 - 2: raise ValueError("NDEF message too large (max 4094 bytes)") f = self._files[FID_NDEF] struct.pack_into(">H", f, 0, len(message)) f[2:2 + len(message)] = message for i in range(2 + len(message), len(f)): f[i] = 0 def finalize(self) -> None: """Move to OPERATIONAL life cycle (config locked).""" self._lifecycle = "OPERATIONAL" def add_password(self, pwid: int, value: bytes, response: bytes = b"\x00\x00", limit: int = 0x7F) -> None: self._passwords[pwid] = _Password(value, response, limit) def set_fap(self, fileid: bytes, nfc_read: int, nfc_write: int) -> None: self._fap[fileid][2] = nfc_read self._fap[fileid][3] = nfc_write self._write_fap_file() @property def lifecycle(self) -> str: return self._lifecycle # ----- ISO-DEP reset ----- async def power_on(self) -> None: await super().power_on() self._app_selected = False self._selected_file = None self._read_ok.clear() self._write_ok.clear() def _handle_rats(self, frame: RFFrame) -> RFFrame | None: self._app_selected = False self._selected_file = None self._read_ok.clear() self._write_ok.clear() return super()._handle_rats(frame) # ----- APDU dispatch ----- def _handle_apdu(self, apdu: bytes) -> bytes: if len(apdu) < 4: # need at least CLA INS P1 P2 return SW_WRONG_LENGTH cla, ins = apdu[0], apdu[1] if cla != 0x00: return b"\x6E\x00" # only the NFC CLA 0x00 set is modelled if ins == INS_SELECT: return self._handle_select(apdu) if ins == INS_READ_BINARY: return self._handle_read_binary(apdu) if ins == INS_UPDATE_BINARY: return self._handle_update_binary(apdu) if ins == INS_AUTH_TAG: return self._handle_authenticate(apdu) if ins == INS_CREATE_PWD: return self._handle_create_password(apdu) if ins == INS_DELETE_PWD: return self._handle_delete_password(apdu) if ins == INS_CHANGE_UNBLOCK_PWD: return self._handle_change_unblock(apdu) if ins == INS_GET_DATA: return self._handle_get_data(apdu) if ins == INS_PERSONALIZE: return self._handle_personalize(apdu) return b"\x6D\x00" # ----- SELECT ----- def _handle_select(self, apdu: bytes) -> bytes: p1, p2 = apdu[2], apdu[3] lc = apdu[4] if len(apdu) > 4 else 0 data = apdu[5:5 + lc] if p1 == 0x04: # select by AID if data == NDEF_AID: self._app_selected = True self._selected_file = None return SW_OK return SW_FILE_NOT_FOUND if p1 == 0x00 and p2 == 0x0C: # select by file id if not self._app_selected or len(data) < 2: return SW_FILE_NOT_FOUND fid = bytes(data[0:2]) if fid not in self._files: return SW_FILE_NOT_FOUND self._selected_file = fid fci = self._verify_select_passwords(fid, data[2:]) return fci + SW_OK return SW_WRONG_P1P2 def _verify_select_passwords(self, fid: bytes, tlvs: bytes) -> bytes: """Parse 52h (read) / 54h (write) password TLVs, mark verified files.""" fci = b"" i = 0 while i + 6 <= len(tlvs): tag, ln = tlvs[i], tlvs[i + 1] if ln != 0x04: break pwd = bytes(tlvs[i + 2:i + 6]) if tag == 0x52: if self._check_password(self._fap[fid][2], pwd): self._read_ok.add(fid) fci += b"\x02\x52" elif tag == 0x54: if self._check_password(self._fap[fid][3], pwd): self._write_ok.add(fid) fci += b"\x02\x54" i += 6 if fci: return b"\x6F" + bytes([len(fci)]) + fci return b"" def _check_password(self, fap_byte: int, supplied: bytes) -> bool: if not (fap_byte & FAP_PWD): return False pwid = fap_byte & 0x1F pw = self._passwords.get(pwid) if pw is None or pw.blocked: return False if supplied == pw.value: pw.tries = pw.limit return True if pw.limit != 0xFFFF: pw.tries -= 1 if pw.tries <= 0: pw.blocked = True return False # ----- access-control helpers ----- def _read_allowed(self, fid: bytes) -> bool: cfg = self._fap[fid][2] if cfg == FAP_ALWAYS: return True if cfg == FAP_NEVER: return False return fid in self._read_ok def _write_allowed(self, fid: bytes) -> bool: cfg = self._fap[fid][3] if cfg == FAP_ALWAYS: return True if cfg == FAP_NEVER: return False return fid in self._write_ok # ----- READ / UPDATE BINARY ----- def _handle_read_binary(self, apdu: bytes) -> bytes: if self._selected_file is None: return SW_CMD_NOT_ALLOWED fid = self._selected_file content = self._files[fid] offset = (apdu[2] << 8) | apdu[3] if len(apdu) < 5: return SW_WRONG_LENGTH le = apdu[4] if offset > len(content): return SW_WRONG_LENGTH if not self._read_allowed(fid): return SW_SEC_NOT_SATISFIED if offset + le > len(content): avail = len(content) - offset return bytes([0x6C, avail & 0xFF]) return bytes(content[offset:offset + le]) + SW_OK def _handle_update_binary(self, apdu: bytes) -> bytes: if self._selected_file is None: return SW_CMD_NOT_ALLOWED fid = self._selected_file offset = (apdu[2] << 8) | apdu[3] lc = apdu[4] if len(apdu) > 4 else 0 data = apdu[5:5 + lc] if fid == FID_CC and 0x02 <= offset <= 0x0E: return SW_COND_NOT_SATISFIED # CC 02..0E never updatable if not self._write_allowed(fid): return SW_SEC_NOT_SATISFIED if fid == FID_FAP: return self._update_fap(data) content = self._files[fid] if offset + len(data) > len(content): return SW_WRONG_P1P2 content[offset:offset + len(data)] = data return SW_OK def _update_fap(self, data: bytes) -> bytes: if len(data) != 6: return SW_WRONG_LENGTH fid = bytes(data[0:2]) if fid not in self._fap: return SW_WRONG_DATA self._fap[fid] = list(data[2:6]) self._write_fap_file() return SW_OK # ----- AUTHENTICATE TAG (ECDSA P-256) ----- def _handle_authenticate(self, apdu: bytes) -> bytes: if apdu[2] != 0x00 or apdu[3] != 0x00: return SW_WRONG_P1P2 lc = apdu[4] if len(apdu) > 4 else 0 challenge = apdu[5:5 + lc] if lc == 0: return SW_WRONG_LENGTH try: from Crypto.PublicKey import ECC from Crypto.Signature import DSS from Crypto.Hash import SHA256 except ImportError: return SW_COND_NOT_SATISFIED if self._ecc_key is None: self._ecc_key = ECC.generate(curve="P-256") h = SHA256.new(bytes(challenge)) sig = DSS.new(self._ecc_key, "fips-186-3", encoding="der").sign(h) return sig + SW_OK # ----- password management ----- def _handle_create_password(self, apdu: bytes) -> bytes: if apdu[2] != 0x00 or apdu[3] != 0x00: return SW_WRONG_P1P2 lc = apdu[4] if len(apdu) > 4 else 0 data = apdu[5:5 + lc] if lc == 0x09: body = data elif lc == 0x0D: body = data[4:] # skip master password (verify skipped) else: return SW_WRONG_LENGTH if len(body) != 9: return SW_WRONG_LENGTH pwid = body[0] value = bytes(body[1:5]) response = bytes(body[5:7]) limit = (body[7] << 8) | body[8] if pwid == 0x00 or pwid > 0x1F: return SW_WRONG_DATA if response in (b"\x00\x00", b"\xFF\xFF"): return SW_WRONG_DATA if limit == 0x0000 or (0x0080 <= limit <= 0xFFFE): return SW_WRONG_DATA if pwid in self._passwords: return SW_COND_NOT_SATISFIED if len(self._passwords) >= 0x1C: return SW_COND_NOT_SATISFIED self._passwords[pwid] = _Password(value, response, limit) return SW_OK def _handle_delete_password(self, apdu: bytes) -> bytes: if apdu[2] != 0x00: return SW_WRONG_P1P2 pwid = apdu[3] & 0x1F if pwid not in self._passwords: return SW_REF_NOT_FOUND del self._passwords[pwid] return SW_OK def _handle_change_unblock(self, apdu: bytes) -> bytes: if apdu[2] != 0x00: return SW_WRONG_P1P2 p2 = apdu[3] op = (p2 >> 6) & 0x03 # b8,b7: 00=UNBLOCK, 01=CHANGE pwid = p2 & 0x1F lc = apdu[4] if len(apdu) > 4 else 0 data = apdu[5:5 + lc] pw = self._passwords.get(pwid) if pw is None: return SW_REF_NOT_FOUND if op == 0x00: # UNBLOCK if lc not in (0x00, 0x04): return SW_WRONG_LENGTH pw.blocked = False pw.tries = pw.limit return SW_OK if op == 0x01: # CHANGE if lc not in (0x04, 0x08): return SW_WRONG_LENGTH if pw.blocked: return SW_COND_NOT_SATISFIED new_pwd = data[-4:] pw.value = bytes(new_pwd) return SW_OK return SW_WRONG_P1P2 # ----- GET DATA (applet version) ----- def _handle_get_data(self, apdu: bytes) -> bytes: if apdu[2] == 0xDF and apdu[3] == 0x3A: return b"\x6F\x05\x04\x03\x01\x01\x02" + SW_OK # version 1.1.2 return SW_WRONG_P1P2 # ----- PERSONALIZE DATA (PERSONALIZATION only) ----- def _handle_personalize(self, apdu: bytes) -> bytes: if self._lifecycle != "PERSONALIZATION": return SW_COND_NOT_SATISFIED if apdu[2] != 0x00 or apdu[3] != 0x00: return SW_WRONG_P1P2 lc = apdu[4] if len(apdu) > 4 else 0 data = apdu[5:5 + lc] if len(data) < 3: return SW_WRONG_LENGTH dgi = bytes(data[0:2]) dlen = data[2] body = data[3:3 + dlen] if dgi == b"\xBF\x63": # FINALIZE PERSONALIZATION self._lifecycle = "OPERATIONAL" return SW_OK if dgi == b"\xA0\x03": # password data set if len(body) != 9: return SW_WRONG_DATA self._passwords[body[0]] = _Password( bytes(body[1:5]), bytes(body[5:7]), (body[7] << 8) | body[8]) return SW_OK if dgi == b"\xE1\xAF": # FAP config if len(body) != 42: return SW_WRONG_DATA for i in range(0, 42, 6): fid = bytes(body[i:i + 2]) if fid in self._fap: self._fap[fid] = list(body[i + 2:i + 6]) self._write_fap_file() return SW_OK if dgi in self._files: # file content: if len(body) < 2: return SW_WRONG_DATA off = (body[0] << 8) | body[1] content = self._files[dgi] payload = body[2:] if off + len(payload) > len(content): return SW_WRONG_DATA content[off:off + len(payload)] = payload return SW_OK return b"\x6A\x88" # unsupported data group