Files
pm3py/pm3py/transponders/hf/iso15693/nxp/auth_aes.py
michael e795b5bb1d feat(registers): NXP AES key-privilege register (ICODE DNA / NTAG5)
Add NxpKeyPrivileges (Register) for the Table 5 AES key-privilege byte
and bind it on the NxpAesAuth mixin (ICODE DNA + NTAG5 Link/Boost):

- read / write / privacy / destroy / eas_afi / crypto_config /
  area1_read / area1_write, each a documented bit
- NxpKeyPrivileges.build(read=True, write=True, ...) keyword builder
- tag.key_privileges(key_id) / tag.set_key_privileges(key_id, value)

Additive: the existing PRIV_* constants and has_privilege() path are
unchanged and see the same bytes. 5 new tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 08:58:24 -07:00

392 lines
13 KiB
Python

"""NXP AES-128 key management mixin.
Reusable mixin for ICODE DNA and NTAG 5 Link/Boost AES auth:
- 4 key slots with key headers and privilege bitmasks
- Global Crypto Header (GCH) for access enforcement
- Authentication limit with failure counting
- TAM1 (Tag Authentication Method 1) — one-way tag authentication
- MAM1/MAM2 (Mutual Authentication Method) — two-phase mutual auth
- Helper methods for key state and privilege checks
"""
from __future__ import annotations
import os
from pm3py.transponders.bitfield import BitField, Register
try:
from Crypto.Cipher import AES as _AES
_HAS_AES = True
except ImportError:
_HAS_AES = False
# Key header values
KEY_HEADER_NOT_ACTIVE = 0x81
KEY_HEADER_ACTIVE_LOCKED = 0xE7
KEY_HEADER_DISABLED = 0xFF
# Key privilege bits (Table 5)
PRIV_READ = 0x01
PRIV_WRITE = 0x02
PRIV_PRIVACY = 0x04
PRIV_DESTROY = 0x08
PRIV_EAS_AFI = 0x10
PRIV_CRYPTO_CONFIG = 0x20
PRIV_AREA1_READ = 0x40
PRIV_AREA1_WRITE = 0x80
class NxpKeyPrivileges(Register):
"""NXP ICODE DNA / NTAG 5 AES key-privilege mask (Table 5) — the 8 bits defining what a
successful authentication with a key slot is allowed to do. Build one to configure a slot,
or decode a stored byte::
NxpKeyPrivileges.build(read=True, write=True, crypto_config=True)
NxpKeyPrivileges(0x21).crypto_config # -> True
"""
_width_bits = 8
_label = "NXP key privileges"
read = BitField(0, doc="read user memory")
write = BitField(1, doc="write user memory")
privacy = BitField(2, doc="toggle privacy (enable/disable) mode")
destroy = BitField(3, doc="permanently destroy the tag")
eas_afi = BitField(4, doc="set/reset EAS and write the AFI")
crypto_config = BitField(5, doc="change crypto configuration — keys, privileges, GCH")
area1_read = BitField(6, doc="read AREA1 (Link/Boost second memory area)")
area1_write = BitField(7, doc="write AREA1")
@classmethod
def build(cls, **flags: bool) -> "NxpKeyPrivileges":
"""Compose a privilege mask from named flags, e.g. ``build(read=True, write=True)``."""
valid = {name for name, _ in cls.fields()}
reg = cls(0)
for name, value in flags.items():
if name not in valid:
raise TypeError(f"unknown privilege {name!r}; valid: {sorted(valid)}")
setattr(reg, name, bool(value))
return reg
# NFC Global Crypto Header values
GCH_DEACTIVATED = 0x81
GCH_DEACTIVATED_PRIV_LOCKED = 0x87
GCH_ACTIVATED = 0xC1
GCH_ACTIVATED_PRIV_LOCKED = 0xC7
GCH_FINAL = 0xE7
class NxpAesAuth:
"""Mixin providing AES-128 key management for NXP ICODE/NTAG 5 tags.
Call ``_init_aes_auth(aes_keys)`` from the concrete class's
``__init__`` to set up AES key state.
"""
def _init_aes_auth(self, aes_keys: list[bytes | None] | None = None) -> None:
"""Initialize AES key management state.
Args:
aes_keys: optional list of up to 4 AES-128 keys (or None per slot).
"""
self._aes_keys: list[bytes | None] = list(aes_keys) if aes_keys else [None] * 4
self._aes_authenticated: set[int] = set()
# Key headers: controls whether key is active for authentication
self._key_headers: list[int] = [KEY_HEADER_NOT_ACTIVE] * 4
# Key privileges: bitmask per key defining what auth grants
self._key_privileges: list[int] = [0x00] * 4
# Global Crypto Header — controls enforcement of access conditions
self._nfc_gch: int = GCH_DEACTIVATED
# Authentication limit (0 = unlimited)
self._auth_limit: int = 0
self._auth_fail_count: int = 0
def is_key_active(self, key_id: int) -> bool:
"""Check if AES key slot is active (header == 0xE7)."""
return self._key_headers[key_id] == KEY_HEADER_ACTIVE_LOCKED
def has_privilege(self, key_id: int, privilege: int) -> bool:
"""Check if key_id is authenticated, active, and has the given privilege bit."""
if key_id not in self._aes_authenticated:
return False
if not self.is_key_active(key_id):
return False
return bool(self._key_privileges[key_id] & privilege)
def key_privileges(self, key_id: int) -> NxpKeyPrivileges:
"""Key slot ``key_id``'s privilege mask as a self-describing :class:`NxpKeyPrivileges`."""
return NxpKeyPrivileges(self._key_privileges[key_id])
def set_key_privileges(self, key_id: int, value: "NxpKeyPrivileges | int") -> None:
"""Program key slot ``key_id``'s privilege mask (accepts a register or a raw byte)."""
self._key_privileges[key_id] = int(value) & 0xFF
def is_access_enforced(self) -> bool:
"""Check if GCH is activated (>= 0xC1), meaning access conditions are enforced."""
return self._nfc_gch >= GCH_ACTIVATED
# ----- TAM1 (Tag Authentication Method 1) -----
# Crypto constants — first 2 bytes of plaintext block
_C_TAM1 = b"\x96\xC5"
_C_MAM1 = b"\xDA\x83"
_C_MAM2_PURPOSE = b"\xDA\x80"
def _handle_challenge(self, frame) -> None:
"""Handle CHALLENGE (0x39) — compute and store TResponse.
Format: flags(1) cmd(1) CSI(1) AuthMethod(1) KeyID(1) IChallenge(10)
Tag computes: AES-ECB-ENC(key, C_TAM1 || TRnd || IChallenge)
Stores TResponse reversed (wire byte order).
Returns None — CHALLENGE has no RF response.
"""
self._tam1_response = None
if not _HAS_AES:
return
data = frame.data
if len(data) < 15: # flags + cmd + CSI + AuthMethod + KeyID + 10 IChallenge
return
auth_method = data[3]
if auth_method != 0x00: # TAM1 only
return
key_id = data[4]
if key_id > 3:
return
if not self.is_key_active(key_id):
return
key = self._aes_keys[key_id]
if key is None:
return
ichallenge = data[5:15]
if len(ichallenge) != 10:
return
# Build plaintext: C_TAM1(2) + TRnd(4) + IChallenge(10) = 16 bytes
trnd = os.urandom(4)
plaintext = self._C_TAM1 + trnd + ichallenge
cipher = _AES.new(key, _AES.MODE_ECB)
tresponse = cipher.encrypt(plaintext)
# Store reversed for wire format
self._tam1_response = tresponse[::-1]
def _handle_readbuffer(self, frame):
"""Handle READBUFFER (0x3A) — return stored TResponse.
Format: flags(1) cmd(1)
Returns: flags(00) + TResponse_reversed(16 bytes)
Returns None if no TResponse is stored.
"""
from pm3py.sim.frame import RFFrame
resp = getattr(self, "_tam1_response", None)
if resp is None:
return None
result = RFFrame.from_bytes(bytes([0x00]) + resp)
# Clear after read (one-shot)
self._tam1_response = None
return result
# ----- MAM (Mutual Authentication Method) -----
def _handle_authenticate(self, frame):
"""Handle AUTHENTICATE (0x35) — dispatch MAM1 or MAM2.
Format: flags(1) cmd(1) CSI(1) AuthMethod(1) ...
AuthMethod 0x02 = MAM1, 0x06 = MAM2.
Returns RFFrame response or error frame.
"""
from pm3py.sim.frame import RFFrame
data = frame.data
if len(data) < 4:
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
auth_method = data[3]
if auth_method == 0x02:
return self._handle_mam1(frame)
elif auth_method == 0x06:
return self._handle_mam2(frame)
else:
# Unsupported AuthMethod (0x80, 0x90, etc.)
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
def _handle_mam1(self, frame):
"""Handle MAM1 (AuthMethod=0x02) — tag proves identity to reader.
Format: flags(1) cmd(1) CSI(1) AuthMethod(1) KeyID(1) IChallenge_reversed(10)
Total: 15 bytes minimum.
Tag computes: AES-ECB-ENC(key, C_MAM1 || TChallenge[31:0] || IChallenge)
Response: flags(04) header(A7) TChallenge_high_reversed(6) encrypted_reversed(16)
"""
from pm3py.sim.frame import RFFrame
self._mam_state = None
if not _HAS_AES:
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
data = frame.data
if len(data) < 15:
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
key_id = data[4]
if key_id > 3:
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
if not self.is_key_active(key_id):
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
key = self._aes_keys[key_id]
if key is None:
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
# De-reverse IChallenge from wire format
ichallenge_reversed = data[5:15]
ichallenge = ichallenge_reversed[::-1]
# Generate 10-byte TChallenge
tchallenge = os.urandom(10)
# Build plaintext: C_MAM1(2) + TChallenge[31:0](4) + IChallenge(10) = 16
# TChallenge[31:0] = last 4 bytes of TChallenge (bytes 6-9)
tc_low = tchallenge[6:10]
plaintext = self._C_MAM1 + tc_low + ichallenge
cipher = _AES.new(key, _AES.MODE_ECB)
encrypted = cipher.encrypt(plaintext)
# TChallenge_high = first 6 bytes (bytes 0-5), reversed for wire
tc_high = tchallenge[0:6]
tc_high_reversed = tc_high[::-1]
# Encrypted block reversed for wire
encrypted_reversed = encrypted[::-1]
# Store state for MAM2 verification
self._mam_state = {
"key_id": key_id,
"ichallenge": ichallenge,
"tchallenge": tchallenge,
}
# Response: flags(04) header(A7) tc_high_reversed(6) encrypted_reversed(16)
resp = bytes([0x04, 0xA7]) + tc_high_reversed + encrypted_reversed
return RFFrame.from_bytes(resp)
def _handle_mam2(self, frame):
"""Handle MAM2 (AuthMethod=0x06) — reader proves identity to tag.
Format: flags(1) cmd(1) CSI(1) AuthMethod(1) IResponse_reversed(16)
Total: 20 bytes minimum.
Tag verifies: AES-ECB-ENC(key, iresponse) == C_MAM2_PURPOSE || IChallenge[31:0] || TChallenge
On success: key_id added to _aes_authenticated, response = flags(00).
"""
from pm3py.sim.frame import RFFrame
if not _HAS_AES:
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
# Must have prior MAM1 state
mam_state = getattr(self, "_mam_state", None)
if mam_state is None:
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
data = frame.data
if len(data) < 20:
self._mam_state = None
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
key_id = mam_state["key_id"]
ichallenge = mam_state["ichallenge"]
tchallenge = mam_state["tchallenge"]
key = self._aes_keys[key_id]
if key is None:
self._mam_state = None
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
# De-reverse IResponse from wire format
iresponse_reversed = data[4:20]
iresponse = iresponse_reversed[::-1]
# Encrypt IResponse to get plaintext
cipher = _AES.new(key, _AES.MODE_ECB)
plaintext = cipher.encrypt(iresponse)
# Parse: C_MAM2[11:0]||Purpose[3:0] (2 bytes) + IChallenge[31:0](4) + TChallenge[79:0](10)
purpose = plaintext[0:2]
ich_31_0 = plaintext[2:6]
tc_check = plaintext[6:16]
# Verify upper 12 bits of C_MAM2 field (0xDA8x)
if purpose[0] != 0xDA or (purpose[1] & 0xF0) != 0x80:
self._mam_state = None
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
purpose_nibble = purpose[1] & 0x0F
# IChallenge[31:0] = last 4 bytes of original IChallenge
if ich_31_0 != ichallenge[6:10]:
self._mam_state = None
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
if tc_check != tchallenge:
self._mam_state = None
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
# Check privilege requirements for non-standard purposes
if purpose_nibble in (0x08, 0x09, 0x0A):
if not (self._key_privileges[key_id] & PRIV_PRIVACY):
self._mam_state = None
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
elif purpose_nibble == 0x0B:
if not (self._key_privileges[key_id] & PRIV_DESTROY):
self._mam_state = None
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
elif purpose_nibble != 0x00:
# Unknown purpose nibble
self._mam_state = None
return RFFrame.from_bytes(bytes([0x01, 0x0F]))
# Authentication successful
self._aes_authenticated.add(key_id)
# Dispatch purpose action
if purpose_nibble == 0x08:
# Temporary disable NFC Privacy Mode (until field reset)
if hasattr(self, "_privacy_mode"):
self._privacy_mode = False
elif purpose_nibble == 0x09:
# Enable NFC Privacy Mode
if hasattr(self, "_privacy_mode"):
self._privacy_mode = True
elif purpose_nibble == 0x0A:
# Permanent disable NFC Privacy Mode
if hasattr(self, "_privacy_mode"):
self._privacy_mode = False
elif purpose_nibble == 0x0B:
# Destroy
if hasattr(self, "_destroyed"):
self._destroyed = True
self._mam_state = None
return RFFrame.from_bytes(bytes([0x00]))