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:
parent
7d35d3e2c7
commit
f37026793b
5 changed files with 547 additions and 0 deletions
187
tests/simcard.py
Normal file
187
tests/simcard.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue