476 lines
17 KiB
Python
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
|