feat(hitag): Hitag 2 software-sim transponder + reader (Tier 1)

First piece of Hitag support. Adds the Hitag2 48-bit stream cipher and a
software-sim transponder/reader over SoftwareMedium (parity with the
other LF tags).

- hitag2_crypto.py: pure-Python port of the Proxmark reference cipher
  (firmware tools/hitag2crack/hitag2_gen_nRaR.py). Validated bit-for-bit
  against the classic MIKRON golden vector (keystream 0xD7237FCE, full
  16-byte sequence). Hitag2Cipher wraps a running session (bits() for the
  authenticator, transcrypt() for link encryption).
- hitag2.py: Hitag2Tag (8 x 32-bit pages, UID/key/config/password) and
  Hitag2Reader. Faithful UID request (5-bit START_AUTH), password-mode
  auth (page-1 RWD password), and crypto-mode auth (64-bit nR||aR + aR
  verify + transcrypt). Wrong key is rejected; UID page is read-only.

Software-sim command framing between our own tag/reader; the crypto and
auth handshake are bit-faithful to real tags. 12 tests.

Hitag S / Hitag u, the reader client commands, and sniff/trace decode
follow in subsequent commits.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
michael
2026-07-14 09:34:24 -07:00
parent 130bcd1a90
commit 5d4af2c8e4
5 changed files with 410 additions and 0 deletions

View File

@@ -76,6 +76,7 @@ from pm3py.transponders.lf.em.em4100 import EM4100Tag, EM4100Reader, EM4100Code
from pm3py.transponders.lf.hid.hid import HIDProxTag, HIDReader from pm3py.transponders.lf.hid.hid import HIDProxTag, HIDReader
from pm3py.transponders.bitfield import BitField, Register from pm3py.transponders.bitfield import BitField, Register
from pm3py.transponders.lf.atmel.t5577 import T5577Tag, T5577Reader, T5577Config from pm3py.transponders.lf.atmel.t5577 import T5577Tag, T5577Reader, T5577Config
from pm3py.transponders.lf.nxp.hitag2 import Hitag2Tag, Hitag2Reader
from pm3py.transponders.implants import xEM, xNT, xM1, FlexDF, NExT, MagicMifareClassicTag from pm3py.transponders.implants import xEM, xNT, xM1, FlexDF, NExT, MagicMifareClassicTag
__all__ = [ __all__ = [
@@ -92,6 +93,7 @@ __all__ = [
"EM4100Tag", "EM4100Reader", "EM4100Code", "EM4100Tag", "EM4100Reader", "EM4100Code",
"HIDProxTag", "HIDReader", "HIDProxTag", "HIDReader",
"T5577Tag", "T5577Reader", "T5577Config", "T5577Tag", "T5577Reader", "T5577Config",
"Hitag2Tag", "Hitag2Reader",
"BitField", "Register", "BitField", "Register",
"NfcType2Tag", "NfcType4Tag", "NfcType5Tag", "NfcType2Tag", "NfcType4Tag", "NfcType5Tag",
"NxpType2Tag", "Type2Config", "NxpType2Tag", "Type2Config",

View File

@@ -0,0 +1 @@
"""NXP LF transponders (Hitag family)."""

View File

@@ -0,0 +1,206 @@
"""Hitag2 (NXP PCF7936/46/47/52) — software-sim transponder + reader driver.
Models the real Hitag2 memory (8 x 32-bit pages) and both authentication modes using the
validated cipher in :mod:`hitag2_crypto`:
- **UID request** — the reader's 5-bit START_AUTH; the tag answers with its serial (page 0).
- **Password mode** — the reader presents the 32-bit RWD password (page 1); the tag answers
with its configuration page (page 3).
- **Crypto mode** — the reader sends a 64-bit ``nR || aR`` challenge; the tag reruns the
cipher, verifies aR, and thereafter encrypts the link (transcrypt).
The authentication is bit-faithful to real tags (START_AUTH length, nR/aR, the aR check and
the transcrypt convention all match the Proxmark firmware). Post-auth read/write *command*
framing is a self-consistent software protocol between :class:`Hitag2Tag` and
:class:`Hitag2Reader` (page data responses are transcrypted exactly like the real link).
"""
from __future__ import annotations
from bitarray import bitarray
from pm3py.sim.frame import RFFrame
from ..base import TagLF, ReaderLF, Modulation
from .hitag2_crypto import Hitag2Cipher
# 5-bit HITAG2_START_AUTH (0b11000) and the software-sim read/write markers
_START_AUTH = bitarray("11000")
READ_CMD = 0x30
WRITE_CMD = 0xA0
#: NXP transport / I.C. Wiener "MIKRON" default key
DEFAULT_KEY = bytes.fromhex("4F4E4D494B52")
def _to_int(value: "int | bytes", nbytes: int) -> int:
if isinstance(value, int):
return value & ((1 << (8 * nbytes)) - 1)
b = bytes(value)
if len(b) != nbytes:
raise ValueError(f"expected {nbytes} bytes, got {len(b)}")
return int.from_bytes(b, "big")
class Hitag2Tag(TagLF):
"""A simulated Hitag2 transponder.
``uid`` (4 bytes) is the serial. ``key`` (6 bytes) is the 48-bit crypto key; its first four
bytes double as the page-1 RWD password for password-mode auth. ``config`` is the page-3
configuration byte, ``password`` the 3-byte tag password (page 3 bytes 1-3), and ``data`` an
optional list of 4-byte user pages (pages 4-7).
"""
def __init__(self, uid: "int | bytes" = b"\x00\x00\x00\x00", key: "int | bytes" = DEFAULT_KEY,
config: int = 0x06, password: bytes = b"\x00\x00\x00",
data: "list[bytes] | None" = None):
super().__init__(modulation=Modulation.ASK)
self._uid = _to_int(uid, 4)
self._key = _to_int(key, 6)
self._pages = [bytearray(4) for _ in range(8)]
self._pages[0][:] = self._uid.to_bytes(4, "big")
kb = self._key.to_bytes(6, "big")
self._pages[1][:] = kb[0:4] # page 1: key low / RWD password
self._pages[2][0:2] = kb[4:6] # page 2: key high
self._pages[3][0] = config & 0xFF
self._pages[3][1:4] = bytes(password[:3]).ljust(3, b"\x00")
for i, page in enumerate(data or []):
if 4 + i < 8:
self._pages[4 + i][:] = bytes(page).ljust(4, b"\x00")[:4]
self._authenticated = False
self._cipher: Hitag2Cipher | None = None
self._pending_write: int | None = None
async def power_on(self) -> None:
await super().power_on()
self._reset_session()
def _reset_session(self) -> None:
self._authenticated = False
self._cipher = None
self._pending_write = None
@property
def pages(self) -> list[bytearray]:
return self._pages
@property
def uid(self) -> bytes:
return self._uid.to_bytes(4, "big")
async def handle_frame(self, frame: RFFrame) -> RFFrame | None:
if not self._powered:
return None
n = frame.bit_count
if n == 5: # START_AUTH -> UID (clear)
if frame.bits[:5] != _START_AUTH:
return None
self._reset_session()
return RFFrame.from_bytes(bytes(self._pages[0]))
if n == 64: # crypto challenge nR||aR
return self._crypto_auth(frame)
if n == 16: # read/write command
return self._command(frame)
if n == 32: # write data or RWD password
return self._data_or_password(frame)
return None
def _crypto_auth(self, frame: RFFrame) -> RFFrame | None:
d = frame.data
nR = int.from_bytes(d[0:4], "big")
aR = int.from_bytes(d[4:8], "big")
cipher = Hitag2Cipher(self._key, self._uid, nR)
if cipher.bits(32) != aR:
return None # bad key: tag stays silent
self._cipher = cipher
self._authenticated = True
return RFFrame.from_bytes(cipher.transcrypt(bytes(self._pages[3])))
def _command(self, frame: RFFrame) -> RFFrame | None:
cmd, page = frame.data[0], frame.data[1]
if page >= 8 or not self._authenticated:
return None
if cmd == READ_CMD:
payload = bytes(self._pages[page])
if self._cipher is not None:
payload = self._cipher.transcrypt(payload)
return RFFrame.from_bytes(payload)
if cmd == WRITE_CMD:
if page == 0: # UID is read-only
return None
self._pending_write = page
return RFFrame.from_bytes(bytes(frame.data)) # ack by echo
return None
def _data_or_password(self, frame: RFFrame) -> RFFrame | None:
d = frame.data
if self._pending_write is not None:
page, self._pending_write = self._pending_write, None
if self._cipher is not None:
d = self._cipher.transcrypt(d)
self._pages[page][:] = d
return None
if d == bytes(self._pages[1]): # password-mode auth
self._authenticated = True
return RFFrame.from_bytes(bytes(self._pages[3]))
return None
class Hitag2Reader(ReaderLF):
"""Drives a :class:`Hitag2Tag` over a medium: request UID, authenticate (password or
crypto), then read/write pages. ``key`` is the 48-bit crypto key; ``nonce`` is the reader
challenge nR used for crypto-mode auth."""
def __init__(self, medium=None, key: "int | bytes" = DEFAULT_KEY, nonce: int = 0x11223344):
super().__init__(medium)
self._key = _to_int(key, 6)
self._nonce = nonce & 0xFFFFFFFF
self._uid: int | None = None
self._cipher: Hitag2Cipher | None = None
async def _exchange(self, frame: RFFrame) -> RFFrame | None:
await self._medium.transmit_reader(frame)
return await self._medium.receive_reader()
async def request_uid(self) -> bytes | None:
resp = await self._exchange(RFFrame(bits=bitarray(_START_AUTH), bit_count=5))
if resp is None:
return None
self._uid = int.from_bytes(resp.data, "big")
self._cipher = None
return resp.data
async def crypto_auth(self) -> bytes | None:
"""Full crypto-mode authentication. Returns the decrypted config page (page 3), or
``None`` if the key was wrong."""
if self._uid is None and await self.request_uid() is None:
return None
cipher = Hitag2Cipher(self._key, self._uid, self._nonce)
aR = cipher.bits(32)
frame = RFFrame.from_bytes(self._nonce.to_bytes(4, "big") + aR.to_bytes(4, "big"))
resp = await self._exchange(frame)
if resp is None:
self._cipher = None
return None
self._cipher = cipher
return cipher.transcrypt(resp.data)
async def password_auth(self, password: bytes) -> bytes | None:
"""Password-mode authentication. Returns the config page (page 3), or ``None``."""
if self._uid is None and await self.request_uid() is None:
return None
resp = await self._exchange(RFFrame.from_bytes(bytes(password)))
return None if resp is None else resp.data
async def read_page(self, page: int) -> bytes | None:
resp = await self._exchange(RFFrame.from_bytes(bytes([READ_CMD, page & 0xFF])))
if resp is None:
return None
return self._cipher.transcrypt(resp.data) if self._cipher is not None else resp.data
async def write_page(self, page: int, data: bytes) -> bool:
ack = await self._exchange(RFFrame.from_bytes(bytes([WRITE_CMD, page & 0xFF])))
if ack is None:
return False
payload = self._cipher.transcrypt(data) if self._cipher is not None else bytes(data)
await self._medium.transmit_reader(RFFrame.from_bytes(payload))
await self._medium.receive_reader()
return True

View File

@@ -0,0 +1,92 @@
"""Hitag2 48-bit stream cipher — pure-Python port of the Proxmark3 reference implementation
(firmware/tools/hitag2crack/hitag2_gen_nRaR.py, by Aram Verstegen), the same cipher the fork's
crack tools use.
Validated against the classic "MIKRON" golden vector (see the tests): with key 0x4F4E4D494B52,
UID 0x49435769 and reader nonce 0x656E4572 the first 32 keystream bits are 0x28DC8031, so the
authenticator aR = ~0x28DC8031 = 0xD7237FCE.
Integers are plain: key is 48-bit, UID and nonce are 32-bit; keystream is emitted MSB-first.
"""
from __future__ import annotations
_MASK48 = (1 << 48) - 1
def _i4(x: int, a: int, b: int, c: int, d: int) -> int:
return (((x >> a) & 1) * 8) + (((x >> b) & 1) * 4) + (((x >> c) & 1) * 2) + ((x >> d) & 1)
def f20(state: int) -> int:
"""The nonlinear filter: one keystream bit from the 48-bit state."""
s0 = (0x3C65 >> _i4(state, 2, 3, 5, 6)) & 1
s1 = (0x0EE5 >> _i4(state, 8, 12, 14, 15)) & 1
s2 = (0x0EE5 >> _i4(state, 17, 21, 23, 26)) & 1
s3 = (0x0EE5 >> _i4(state, 28, 29, 31, 33)) & 1
s4 = (0x3C65 >> _i4(state, 34, 43, 44, 46)) & 1
return (0xDD3929B >> ((s0 * 16) + (s1 * 8) + (s2 * 4) + (s3 * 2) + s4)) & 1
def hitag2_init(key: int, uid: int, nonce: int) -> int:
"""Initialise the cipher from the 48-bit key, 32-bit UID and 32-bit reader nonce."""
state = 0
for i in range(32, 48):
state = (state << 1) | ((key >> i) & 1)
for i in range(0, 32):
state = (state << 1) | ((uid >> i) & 1)
for i in range(0, 32):
nonce_bit = f20(state) ^ ((nonce >> (31 - i)) & 1)
state = (state >> 1) | (((nonce_bit ^ (key >> (31 - i))) & 1) << 47)
return state
def _lfsr_feedback(state: int) -> int:
return (((state >> 0) ^ (state >> 2) ^ (state >> 3) ^ (state >> 6) ^ (state >> 7)
^ (state >> 8) ^ (state >> 16) ^ (state >> 22) ^ (state >> 23) ^ (state >> 26)
^ (state >> 30) ^ (state >> 41) ^ (state >> 42) ^ (state >> 43) ^ (state >> 46)
^ (state >> 47)) & 1)
def lfsr(state: int) -> int:
"""Advance the LFSR one step."""
return (state >> 1) + (_lfsr_feedback(state) << 47)
def keystream(state: int, length: int = 48) -> tuple[int, int]:
"""Emit ``length`` keystream bits (MSB first) and return ``(bits, new_state)``.
This is the "authenticator" convention (matches the firmware's ``cipher_authenticate``,
which compares against the *complement* of the raw keystream). The raw keystream used to
encrypt/decrypt link data is the bitwise inverse — see :meth:`Hitag2Cipher.transcrypt`."""
c = 0
for _ in range(length):
c = (c << 1) | f20(state)
state = lfsr(state)
return c, state
def authenticator(key: int, uid: int, nonce: int) -> int:
"""The 32-bit authenticator aR the reader sends and the tag verifies (first 32 bits of the
authenticator-convention keystream). MIKRON: aR = 0xD7237FCE."""
ks, _ = keystream(hitag2_init(key, uid, nonce), 32)
return ks
class Hitag2Cipher:
"""A running Hitag2 cipher session (key + UID + reader nonce), used by both the tag and the
reader models. ``bits()`` pulls authenticator-convention keystream (for aR); ``transcrypt()``
encrypts/decrypts link data with the raw (inverse) keystream, exactly like the firmware."""
def __init__(self, key: int, uid: int, nonce: int):
self.state = hitag2_init(key, uid, nonce)
def bits(self, n: int) -> int:
c, self.state = keystream(self.state, n)
return c
def transcrypt(self, data: bytes) -> bytes:
out = bytearray()
for b in data:
ks_byte, self.state = keystream(self.state, 8)
out.append(b ^ (~ks_byte & 0xFF)) # raw keystream = ~authenticator keystream
return bytes(out)

109
tests/test_hitag2.py Normal file
View File

@@ -0,0 +1,109 @@
"""Hitag2 cipher + software-sim transponder/reader. Hardware-free."""
import asyncio
import pytest
from pm3py.sim import SoftwareMedium, Hitag2Tag, Hitag2Reader
from pm3py.transponders.lf.nxp.hitag2 import DEFAULT_KEY
from pm3py.transponders.lf.nxp.hitag2_crypto import (
hitag2_init, keystream, authenticator, Hitag2Cipher,
)
UID = b"\x11\x22\x33\x44"
def run(coro):
return asyncio.new_event_loop().run_until_complete(coro)
# --- The MIKRON golden vector pins the cipher to the Proxmark reference (hitag2_gen_nRaR.py) ---
class TestHitag2Cipher:
def test_mikron_keystream(self):
ks, _ = keystream(hitag2_init(0x4F4E4D494B52, 0x49435769, 0x656E4572), 32)
assert ks == 0xD7237FCE
def test_mikron_authenticator(self):
assert authenticator(0x4F4E4D494B52, 0x49435769, 0x656E4572) == 0xD7237FCE
def test_mikron_16_bytes(self):
ks, _ = keystream(hitag2_init(0x4F4E4D494B52, 0x49435769, 0x656E4572), 128)
assert ks.to_bytes(16, "big").hex().upper() == "D7237FCE8CD037A95749C1E648008AB6"
def test_transcrypt_roundtrip(self):
a = Hitag2Cipher(0x4F4E4D494B52, 0x49435769, 0x656E4572)
b = Hitag2Cipher(0x4F4E4D494B52, 0x49435769, 0x656E4572)
a.bits(32) # advance both past aR, identically
b.bits(32)
data = b"\xDE\xAD\xBE\xEF"
enc = a.transcrypt(data)
assert enc != data
assert b.transcrypt(enc) == data # b decrypts what a encrypted
async def _setup(**tag_kw):
m = SoftwareMedium()
tag = Hitag2Tag(uid=UID, key=DEFAULT_KEY,
data=[b"AAAA", b"BBBB", b"CCCC", b"DDDD"], **tag_kw)
await m.attach(tag)
return m, tag, Hitag2Reader(m, key=DEFAULT_KEY)
class TestHitag2Sim:
def test_request_uid(self):
async def go():
_, _, r = await _setup()
return await r.request_uid()
assert run(go()) == UID
def test_crypto_auth_success(self):
async def go():
_, _, r = await _setup(config=0x06)
return await r.crypto_auth()
page3 = run(go())
assert page3 is not None
assert page3[0] == 0x06 # decrypted config byte
def test_crypto_auth_wrong_key(self):
async def go():
m, _, _ = await _setup()
bad = Hitag2Reader(m, key=bytes(6))
return await bad.crypto_auth()
assert run(go()) is None
def test_crypto_read_page(self):
async def go():
_, _, r = await _setup()
await r.crypto_auth()
return await r.read_page(4)
assert run(go()) == b"AAAA" # transcrypt lockstep decrypts correctly
def test_crypto_write_then_read(self):
async def go():
_, _, r = await _setup()
await r.crypto_auth()
ok = await r.write_page(5, b"WXYZ")
return ok, await r.read_page(5)
ok, val = run(go())
assert ok is True and val == b"WXYZ"
def test_read_requires_auth(self):
async def go():
_, _, r = await _setup()
await r.request_uid() # no auth
return await r.read_page(4)
assert run(go()) is None
def test_password_auth(self):
async def go():
_, _, r = await _setup(config=0x06)
return await r.password_auth(DEFAULT_KEY[0:4]) # page 1 = key[0:4]
page3 = run(go())
assert page3 is not None and page3[0] == 0x06
def test_uid_is_read_only(self):
async def go():
_, tag, r = await _setup()
await r.crypto_auth()
await r.write_page(0, b"\xFF\xFF\xFF\xFF") # must be rejected
return tag.uid
assert run(go()) == UID