diff --git a/pm3py/sim/__init__.py b/pm3py/sim/__init__.py index c6cced5..99e08b2 100644 --- a/pm3py/sim/__init__.py +++ b/pm3py/sim/__init__.py @@ -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", diff --git a/pm3py/transponders/lf/nxp/__init__.py b/pm3py/transponders/lf/nxp/__init__.py new file mode 100644 index 0000000..39aa313 --- /dev/null +++ b/pm3py/transponders/lf/nxp/__init__.py @@ -0,0 +1 @@ +"""NXP LF transponders (Hitag family).""" diff --git a/pm3py/transponders/lf/nxp/hitag2.py b/pm3py/transponders/lf/nxp/hitag2.py new file mode 100644 index 0000000..3e0e4ff --- /dev/null +++ b/pm3py/transponders/lf/nxp/hitag2.py @@ -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 diff --git a/pm3py/transponders/lf/nxp/hitag2_crypto.py b/pm3py/transponders/lf/nxp/hitag2_crypto.py new file mode 100644 index 0000000..cc3eb2b --- /dev/null +++ b/pm3py/transponders/lf/nxp/hitag2_crypto.py @@ -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) diff --git a/tests/test_hitag2.py b/tests/test_hitag2.py new file mode 100644 index 0000000..001886a --- /dev/null +++ b/tests/test_hitag2.py @@ -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