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:
@@ -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.bitfield import BitField, Register
|
||||
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
|
||||
|
||||
__all__ = [
|
||||
@@ -92,6 +93,7 @@ __all__ = [
|
||||
"EM4100Tag", "EM4100Reader", "EM4100Code",
|
||||
"HIDProxTag", "HIDReader",
|
||||
"T5577Tag", "T5577Reader", "T5577Config",
|
||||
"Hitag2Tag", "Hitag2Reader",
|
||||
"BitField", "Register",
|
||||
"NfcType2Tag", "NfcType4Tag", "NfcType5Tag",
|
||||
"NxpType2Tag", "Type2Config",
|
||||
|
||||
1
pm3py/transponders/lf/nxp/__init__.py
Normal file
1
pm3py/transponders/lf/nxp/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""NXP LF transponders (Hitag family)."""
|
||||
206
pm3py/transponders/lf/nxp/hitag2.py
Normal file
206
pm3py/transponders/lf/nxp/hitag2.py
Normal 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
|
||||
92
pm3py/transponders/lf/nxp/hitag2_crypto.py
Normal file
92
pm3py/transponders/lf/nxp/hitag2_crypto.py
Normal 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
109
tests/test_hitag2.py
Normal 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
|
||||
Reference in New Issue
Block a user