test: AN12196 vectors and a software NTAG 424 simulator

The vector tests replay the application note's worked examples
(auth key 0/3, IV, ChangeKey 0/2, WriteData) byte-for-byte. The
simulator implements the card side of secure messaging so the
provision and wipe flows run end to end without hardware.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-09-20 21:41:24 +02:00
commit f37026793b
5 changed files with 547 additions and 0 deletions

187
tests/simcard.py Normal file
View file

@ -0,0 +1,187 @@
"""A software NTAG 424 DNA: enough of the card side of the protocol to run
the provisioning and wipe flows end to end without hardware.
It implements the same secure messaging from the card's perspective
(independent derivation of session keys, MAC verification, decryption), keeps
five keys, the NDEF file and its settings, and renders SDM mirroring on
ISO ReadBinary the way a real card does — so a successful flow test means
the host side produced bytes a real card would accept.
"""
from __future__ import annotations
import os
from boltcard_writer import boltcard as bc
from boltcard_writer import ntag424 as nt
DESFIRE_ATR = bytes.fromhex("3B8180018080")
class SimCard:
def __init__(self, uid: bytes = bytes.fromhex("04A1B2C3D4E5F6"), read_ctr: int = 0):
self.uid = uid
self.keys = [bytes(16) for _ in range(5)]
self.key_versions = [0] * 5
self.ndef = bytearray(256)
self.file_option = 0x00
self.access_rights = bytes.fromhex("E0EE")
self.sdm_options = None
self.sdm_access = None
self.picc_off = self.mac_in_off = self.mac_off = None
self.read_ctr = read_ctr
self.session: nt.Session | None = None
self._rnd_b: bytes | None = None
self._auth_key_no: int | None = None
self.selected_fid: bytes | None = None
self.log: list[str] = []
# PC/SC-ish surface used by the host code
@property
def atr(self) -> bytes:
return DESFIRE_ATR
def transmit(self, apdu: bytes) -> bytes:
self.log.append(apdu.hex().upper())
cla, ins, p1, p2 = apdu[0], apdu[1], apdu[2], apdu[3]
if cla == 0xFF and ins == 0xCA:
return self.uid + b"\x90\x00"
if cla == 0x00:
return self._iso(ins, p1, p2, apdu[4:])
if cla == 0x90:
lc = apdu[4]
data = apdu[5 : 5 + lc] if lc else b""
return self._native(ins, data)
return b"\x6e\x00"
# -- ISO --
def _iso(self, ins: int, p1: int, p2: int, rest: bytes) -> bytes:
if ins == 0xA4:
lc = rest[0]
body = rest[1 : 1 + lc]
if p1 == 0x04:
assert body == nt.NDEF_AID
self.session = None
self.selected_fid = None
return b"\x90\x00"
self.selected_fid = body
return b"\x90\x00"
if ins == 0xD6:
assert self.selected_fid == nt.FID_NDEF
if (self.access_rights[1] & 0x0F) != 0x0E:
return b"\x69\x82" # write not free
off = (p1 << 8) | p2
lc = rest[0]
self.ndef[off : off + lc] = rest[1 : 1 + lc]
return b"\x90\x00"
if ins == 0xB0:
assert self.selected_fid == nt.FID_NDEF
off = (p1 << 8) | p2
le = rest[0] or 256
return bytes(self._rendered_ndef()[off : off + le]) + b"\x90\x00"
return b"\x6d\x00"
def _rendered_ndef(self) -> bytearray:
img = bytearray(self.ndef)
if self.file_option & 0x40:
self.read_ctr += 1
meta_key = self.keys[self.sdm_access[1] >> 4]
file_key = self.keys[self.sdm_access[1] & 0x0F]
plain = b"\xc7" + self.uid + self.read_ctr.to_bytes(3, "little") + os.urandom(5)
p = nt.aes_cbc_encrypt(meta_key, bytes(16), plain).hex().upper().encode()
c = bc.sun_mac(self.uid, self.read_ctr, file_key).hex().upper().encode()
img[self.picc_off : self.picc_off + 32] = p
img[self.mac_off : self.mac_off + 16] = c
return img
# -- native --
def _native(self, ins: int, data: bytes) -> bytes:
if ins == 0x60:
self._chain = [
bytes.fromhex("0404023000110591AF"),
bytes.fromhex("0404020100110591AF"),
self.uid + bytes.fromhex("0102030405") + b"\x00\x00" + b"\x91\x00",
]
return self._chain.pop(0)
if ins == 0xAF:
if self._rnd_b is not None:
return self._auth_part2(data)
return self._chain.pop(0)
if ins == 0x64:
return bytes([self.key_versions[data[0]]]) + b"\x91\x00"
if ins == 0xF5:
assert data[0] == nt.FILE_NDEF
out = bytes([0x00, self.file_option]) + self.access_rights + (256).to_bytes(3, "little")
if self.file_option & 0x40:
out += bytes([self.sdm_options]) + self.sdm_access + nt.le3(self.picc_off) + nt.le3(self.mac_in_off) + nt.le3(self.mac_off)
return out + b"\x91\x00"
if ins == 0x71:
key_no = data[0]
self._auth_key_no = key_no
self._rnd_b = os.urandom(16)
return nt.aes_cbc_encrypt(self.keys[key_no], bytes(16), self._rnd_b) + b"\x91\xaf"
if ins in (0x5F, 0xC4, 0x51):
return self._secure(ins, data)
return b"\x91\x1c"
def _auth_part2(self, data: bytes) -> bytes:
key = self.keys[self._auth_key_no]
plain = nt.aes_cbc_decrypt(key, bytes(16), data)
rnd_a, rnd_b_p = plain[:16], plain[16:]
rnd_b, self._rnd_b = self._rnd_b, None
if rnd_b_p != nt.rotate_left(rnd_b):
return b"\x91\xae"
ti = os.urandom(4)
enc, mac = nt.derive_session_keys(key, rnd_a, rnd_b)
self.session = nt.Session(key_no=self._auth_key_no, ti=ti, enc_key=enc, mac_key=mac, cmd_ctr=0)
resp = ti + nt.rotate_left(rnd_a) + bytes(12)
return nt.aes_cbc_encrypt(key, bytes(16), resp) + b"\x91\x00"
def _secure(self, ins: int, body: bytes) -> bytes:
s = self.session
if s is None:
return b"\x91\x9d"
header_len = {0x5F: 1, 0xC4: 1, 0x51: 0}[ins]
header, enc, mac = body[:header_len], body[header_len:-8], body[-8:]
if s.mac_cmd(ins, header, enc) != mac:
self.session = None
return b"\x91\x1e"
# commands are encrypted under the command IV with the pre-increment counter
plain = nt.unpad_enc(nt.aes_cbc_decrypt(s.enc_key, s.iv_cmd(), enc)) if enc else b""
s.cmd_ctr += 1
if ins == 0x5F:
self._change_file_settings(plain)
return self._reply(b"")
if ins == 0xC4:
key_no = header[0]
if key_no == 0:
self.keys[0], self.key_versions[0] = plain[:16], plain[16]
self.session = None
return b"\x91\x00"
new_key = nt.xor(plain[:16], self.keys[key_no])
if nt.crc32_nk(new_key) != plain[17:21]:
self.session = None
return b"\x91\x1e"
self.keys[key_no], self.key_versions[key_no] = new_key, plain[16]
return self._reply(b"")
if ins == 0x51:
return self._reply(self.uid, encrypt=True)
raise AssertionError
def _reply(self, data: bytes, encrypt: bool = False) -> bytes:
s = self.session
payload = nt.aes_cbc_encrypt(s.enc_key, s.iv_resp(), nt.pad_enc(data)) if encrypt else data
return payload + s.mac_resp(0x00, payload) + b"\x91\x00"
def _change_file_settings(self, plain: bytes) -> None:
self.file_option = plain[0]
self.access_rights = plain[1:3]
if self.file_option & 0x40:
self.sdm_options = plain[3]
self.sdm_access = plain[4:6]
self.picc_off = int.from_bytes(plain[6:9], "little")
self.mac_in_off = int.from_bytes(plain[9:12], "little")
self.mac_off = int.from_bytes(plain[12:15], "little")
else:
self.sdm_options = self.sdm_access = None
self.picc_off = self.mac_in_off = self.mac_off = None