Files
pm3py/pm3py/transponders/hf/iso14443a/infineon/optiga_nbt.py
2026-07-05 09:53:52 -07:00

476 lines
17 KiB
Python

"""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: <offset(2)><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