Files
pm3py/tests/test_hitag2.py
michael 5d4af2c8e4 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>
2026-07-14 09:34:24 -07:00

110 lines
3.7 KiB
Python

"""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