mirror of
https://github.com/RfidResearchGroup/ChameleonUltra.git
synced 2026-10-22 21:52:04 +00:00
protocol ISO 14443-4 and emv scan, loading json file from PM3rdv4
This commit is contained in:
1 parent
93c1e150ab
commit
efa2ea2c7b
13 files changed
+1593
-9
No files matched your search
@@ -755,6 +755,7 @@ lf = root.subgroup("lf", "Low Frequency commands")
|
||||
lf_em = lf.subgroup("em", "EM commands")
|
||||
lf_em_4x05 = lf_em.subgroup("4x05", "EM4x05/EM4x69 commands")
|
||||
data = root.subgroup('data', 'Data analysis and visualization commands')
|
||||
emv = root.subgroup('emv', 'EMV contactless payment card commands')
|
||||
|
||||
|
||||
lf_em_410x = lf_em.subgroup("410x", "EM410x commands")
|
||||
@@ -7939,3 +7940,531 @@ class DataModulation(BaseCLIUnit):
|
||||
else:
|
||||
print(f" RTF gaps : {CR}none ^`^t no gap commands detected{C0}")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# EMV contactless payment card commands (emv subgroup)
|
||||
# ============================================================================
|
||||
|
||||
def _emv_decode_apdu(data: bytes) -> str:
|
||||
"""Return a brief human-readable description of a command APDU."""
|
||||
if len(data) < 4:
|
||||
return ''
|
||||
cla, ins, p1, p2 = data[0], data[1], data[2], data[3]
|
||||
lc = data[4] if len(data) > 4 else 0
|
||||
body = data[5:5 + lc] if len(data) > 5 else b''
|
||||
if cla == 0x00 and ins == 0xA4 and p1 == 0x04 and body:
|
||||
known = {
|
||||
bytes.fromhex('325041592e5359532e4444463031'): 'PPSE (2PAY.SYS.DDF01)',
|
||||
bytes.fromhex('a0000000031010'): 'Visa Credit/Debit',
|
||||
bytes.fromhex('a0000000041010'): 'Mastercard Debit',
|
||||
bytes.fromhex('a000000025010402'): 'Amex',
|
||||
}
|
||||
return 'SELECT AID ' + known.get(body.lower(), body.hex().upper())
|
||||
if cla == 0x80 and ins == 0xA8:
|
||||
return 'GET PROCESSING OPTIONS (GPO)'
|
||||
if cla == 0x00 and ins == 0xB2:
|
||||
return f'READ RECORD SFI={(p2 >> 3) & 0x1F} rec={p1}'
|
||||
return f'CLA={cla:02x} INS={ins:02x} P1={p1:02x} P2={p2:02x}'
|
||||
|
||||
|
||||
@emv.command('scan')
|
||||
class EMVScan(DeviceRequiredUnit):
|
||||
"""
|
||||
Full EMV contactless card scan — equivalent to PM3 'emv scan -at'.
|
||||
|
||||
Scans an ISO14443-4 card, performs the full EMV transaction sequence
|
||||
(SELECT PPSE, SELECT AID, GPO, READ RECORDs) and saves results to a
|
||||
JSON file compatible with PM3's emv scan output format.
|
||||
|
||||
Place the card on the CU antenna before running.
|
||||
|
||||
Usage:
|
||||
emv scan print results to terminal
|
||||
emv scan -f /tmp/card.json save to JSON file
|
||||
"""
|
||||
|
||||
def args_parser(self) -> ArgumentParserNoExit:
|
||||
parser = ArgumentParserNoExit()
|
||||
parser.description = 'EMV contactless card scan (reader mode) — like PM3 emv scan -at'
|
||||
parser.add_argument('-f', '--file', default='', metavar='<path>',
|
||||
help='Save results to JSON file (PM3-compatible format)')
|
||||
parser.add_argument('-s', '--slot', type=int, default=None,
|
||||
metavar='<1-8>', help='Also load scanned card into this slot for emulation')
|
||||
return parser
|
||||
|
||||
def on_exec(self, args: argparse.Namespace):
|
||||
import time, json as jsonlib
|
||||
cmd = self.cmd
|
||||
|
||||
# Ensure reader mode
|
||||
try:
|
||||
if not cmd.is_device_reader_mode():
|
||||
cmd.set_device_reader_mode(True)
|
||||
time.sleep(0.5)
|
||||
except Exception:
|
||||
time.sleep(0.3)
|
||||
|
||||
print(f' {CY}Scanning... (place card on antenna){C0}')
|
||||
|
||||
# Single firmware call — full EMV sequence without USB round-trips
|
||||
resp = cmd.hf14a_4_emv_scan()
|
||||
if resp.status != Status.HF_TAG_OK or not resp.data:
|
||||
print(f' {CR}No card found or scan failed (status={resp.status}){C0}')
|
||||
return
|
||||
|
||||
# Parse packed response
|
||||
d = bytes(resp.data)
|
||||
off = 0
|
||||
|
||||
uid_len = d[off]; off += 1
|
||||
uid = d[off:off+uid_len]; off += uid_len
|
||||
atqa = d[off:off+2]; off += 2
|
||||
sak = d[off]; off += 1
|
||||
ats_len = d[off]; off += 1
|
||||
ats = d[off:off+ats_len]; off += ats_len
|
||||
|
||||
uid_str = ' '.join(f'{b:02X}' for b in uid)
|
||||
atqa_str = ' '.join(f'{b:02X}' for b in atqa)
|
||||
ats_str = ' '.join(f'{b:02X}' for b in ats)
|
||||
print(f' {CG}UID : {uid_str}{C0}')
|
||||
print(f' {CG}ATQA: {atqa_str} SAK: {sak:02X}{C0}')
|
||||
print(f' {CG}ATS : {ats_str}{C0}')
|
||||
|
||||
num_apdus = d[off]; off += 1
|
||||
pairs = []
|
||||
for _ in range(num_apdus):
|
||||
cl = d[off]; off += 1
|
||||
c = d[off:off+cl]; off += cl
|
||||
rl = d[off] | (d[off+1] << 8); off += 2
|
||||
r = d[off:off+rl]; off += rl
|
||||
pairs.append((c, r))
|
||||
|
||||
if not pairs:
|
||||
print(f' {CR}No APDU responses captured{C0}')
|
||||
return
|
||||
result = {}
|
||||
result['File'] = {'Created': 'chameleon emv scan'}
|
||||
result['Card'] = {'Contactless': {
|
||||
'Communication': 'iso14443-4a',
|
||||
'UID': uid_str, 'ATQA': atqa_str,
|
||||
'SAK': f'{sak:02X}', 'ATS': ats_str,
|
||||
}}
|
||||
|
||||
def tlv_to_dict(data):
|
||||
if not data: return {}
|
||||
i = 0
|
||||
tl = 2 if (data[i] & 0x1F) == 0x1F else 1
|
||||
tag_hex = data[:tl].hex().upper(); i += tl
|
||||
if i >= len(data): return {}
|
||||
if data[i] & 0x80:
|
||||
nb = data[i] & 0x7F; i += 1
|
||||
vlen = int.from_bytes(data[i:i+nb], 'big'); i += nb
|
||||
else:
|
||||
vlen = data[i]; i += 1
|
||||
val = data[i:i+vlen]
|
||||
return {'tag': tag_hex, 'length': f'{vlen:02X}',
|
||||
'value': ' '.join(f'{b:02X}' for b in val)}
|
||||
|
||||
def find_tag(data, tag):
|
||||
results = []; i = 0
|
||||
while i < len(data) - 1:
|
||||
tl = 2 if (data[i] & 0x1F) == 0x1F else 1
|
||||
if i + tl > len(data): break
|
||||
cur = data[i:i+tl]; i += tl
|
||||
if i >= len(data): break
|
||||
if data[i] & 0x80:
|
||||
nb = data[i] & 0x7F; i += 1
|
||||
vlen = int.from_bytes(data[i:i+nb], 'big'); i += nb
|
||||
else:
|
||||
vlen = data[i]; i += 1
|
||||
val = data[i:i+vlen]; i += vlen
|
||||
if int.from_bytes(cur, 'big') == tag: results.append(val)
|
||||
elif cur[0] & 0x20: results.extend(find_tag(val, tag))
|
||||
return results
|
||||
|
||||
# PPSE
|
||||
if pairs:
|
||||
ppse_cmd, ppse_resp = pairs[0]
|
||||
ppse_body = ppse_resp[:-2] if len(ppse_resp) >= 2 else ppse_resp
|
||||
print(f'\n {CG}PPSE OK ({len(ppse_resp)}b){C0}')
|
||||
result['PPSE'] = {
|
||||
'AID': '32 50 41 59 2E 53 59 53 2E 44 44 46 30 31',
|
||||
'FCITemplate': tlv_to_dict(ppse_body),
|
||||
}
|
||||
|
||||
if len(pairs) >= 2:
|
||||
sel_cmd, sel_resp = pairs[1]
|
||||
sel_body = sel_resp[:-2] if len(sel_resp) >= 2 else sel_resp
|
||||
aid_bytes = sel_cmd[5:-1] if len(sel_cmd) > 6 else b''
|
||||
aid_str = ' '.join(f'{b:02X}' for b in aid_bytes)
|
||||
print(f' {CG}SELECT AID OK ({len(sel_resp)}b){C0}')
|
||||
result['Application'] = {'AID': aid_str,
|
||||
'FCITemplate': tlv_to_dict(sel_body)}
|
||||
|
||||
if len(pairs) >= 3:
|
||||
gpo_cmd, gpo_resp = pairs[2]
|
||||
gpo_body = gpo_resp[:-2] if len(gpo_resp) >= 2 else gpo_resp
|
||||
print(f' {CG}GPO OK ({len(gpo_resp)}b){C0}')
|
||||
result['Application']['GPO'] = tlv_to_dict(gpo_body)
|
||||
records = []
|
||||
for cb, rb in pairs[3:]:
|
||||
sfi_n = (cb[3] >> 3) & 0x1F if len(cb) >= 4 else 0
|
||||
rec_n = cb[2] if len(cb) >= 3 else 0
|
||||
r_body = rb[:-2] if len(rb) >= 2 else rb
|
||||
print(f' {CG}READ RECORD SFI={sfi_n} rec={rec_n} OK ({len(rb)}b){C0}')
|
||||
records.append({'SFI': f'{sfi_n:02X}', 'RecordNum': f'{rec_n:02X}',
|
||||
'Offline': '01', 'Data': tlv_to_dict(r_body)})
|
||||
result['Application']['Records'] = records
|
||||
|
||||
json_str = jsonlib.dumps(result, indent=2)
|
||||
if args.file:
|
||||
try:
|
||||
with open(args.file, 'w') as fp: fp.write(json_str)
|
||||
print(f'\n {CG}Saved to {args.file}{C0}')
|
||||
except Exception as e:
|
||||
print(f' {CR}Save failed: {e}{C0}')
|
||||
else:
|
||||
print(f'\n{json_str}')
|
||||
|
||||
if args.slot is not None and pairs:
|
||||
target_slot = SlotNumber(args.slot)
|
||||
print(f'\n {CY}Loading into slot {target_slot}...{C0}')
|
||||
try:
|
||||
cmd.set_slot_tag_type(target_slot, TagSpecificType.HF14A_4)
|
||||
cmd.set_slot_data_default(target_slot, TagSpecificType.HF14A_4)
|
||||
cmd.set_slot_enable(target_slot, TagSenseType.HF, True)
|
||||
cmd.hf14a_4_set_anti_coll(uid, atqa, sak, ats) # atqa already in wire order
|
||||
cmd.hf14a_4_clear_static_responses()
|
||||
for c, r in pairs:
|
||||
cmd.hf14a_4_add_static_response(c, r) # use full cmd as match key
|
||||
cmd.slot_data_config_save()
|
||||
print(f' {CG}Slot {target_slot} ready. Run: hw slot change -s {args.slot} && hw mode -e{C0}')
|
||||
except Exception as e:
|
||||
print(f' {CR}Slot load failed: {e}{C0}')
|
||||
|
||||
|
||||
@emv.command('debug')
|
||||
class EMVDebug(DeviceRequiredUnit):
|
||||
"""Show T=CL emulation debug counters (I-blocks rx/tx, last PCB, last match)."""
|
||||
|
||||
def args_parser(self) -> ArgumentParserNoExit:
|
||||
parser = ArgumentParserNoExit()
|
||||
parser.description = 'Show T=CL emulation debug counters'
|
||||
return parser
|
||||
|
||||
def on_exec(self, args: argparse.Namespace):
|
||||
resp = self.cmd.device.send_cmd_sync(6010, b'')
|
||||
if resp.status != Status.SUCCESS or not resp.data or len(resp.data) < 4:
|
||||
print(f' {CR}Debug command failed{C0}')
|
||||
return
|
||||
d = resp.data
|
||||
print(f' {CY}T=CL debug counters:{C0}')
|
||||
print(f' I-blocks received : {d[0]}')
|
||||
print(f' I-blocks sent : {d[1]}')
|
||||
print(f' Last rx PCB : {d[2]:02x} (blk_num={(d[2] & 0x01)}, chain={(d[2]>>5)&1}, cid={(d[2]>>4)&1})')
|
||||
print(f' Last static match : {"yes" if d[3] else "no"}')
|
||||
|
||||
|
||||
@emv.command('load')
|
||||
class EMVLoad(DeviceRequiredUnit):
|
||||
"""
|
||||
Load EMV card data into an HF14A_4 slot for emulation.
|
||||
|
||||
Supports two modes:
|
||||
1. Load from a JSON file (PM3 emv scan -at output)
|
||||
2. Add a single custom APDU command/response pair
|
||||
|
||||
Usage:
|
||||
emv load -f /tmp/card.json -s 3 load full card from PM3 JSON
|
||||
emv load --clear clear static responses
|
||||
emv load --cmd 00A4... --resp 6F.. add single APDU pair
|
||||
emv load --defaults load Mastercard test defaults
|
||||
"""
|
||||
|
||||
def args_parser(self) -> ArgumentParserNoExit:
|
||||
parser = ArgumentParserNoExit()
|
||||
parser.description = 'Load EMV APDU responses into HF14A_4 slot for autonomous emulation'
|
||||
parser.add_argument('-f', '--file', default='', metavar='<path>',
|
||||
help='Load from PM3 emv scan JSON file')
|
||||
parser.add_argument('-s', '--slot', type=int, default=None,
|
||||
metavar='<1-8>', help='Target slot when using --file (default: active)')
|
||||
parser.add_argument('--clear', action='store_true',
|
||||
help='Clear all static responses from active slot')
|
||||
parser.add_argument('--cmd', default='', metavar='<hex>',
|
||||
help='Command APDU prefix to match (hex)')
|
||||
parser.add_argument('--resp', default='', metavar='<hex>',
|
||||
help='Response APDU to return (hex)')
|
||||
parser.add_argument('--defaults', action='store_true',
|
||||
help='Load built-in Mastercard test responses')
|
||||
return parser
|
||||
|
||||
def on_exec(self, args: argparse.Namespace):
|
||||
cmd = self.cmd
|
||||
|
||||
if args.clear:
|
||||
cmd.hf14a_4_clear_static_responses()
|
||||
print(f' {CG}Static responses cleared.{C0}')
|
||||
return
|
||||
|
||||
if args.cmd and args.resp:
|
||||
try:
|
||||
c = bytes.fromhex(args.cmd.replace(' ', ''))
|
||||
r = bytes.fromhex(args.resp.replace(' ', ''))
|
||||
cmd.hf14a_4_add_static_response(c, r)
|
||||
print(f' {CG}Added: {c.hex().upper()} → {r.hex().upper()}{C0}')
|
||||
except ValueError as e:
|
||||
print(f' {CR}Invalid hex: {e}{C0}')
|
||||
return
|
||||
|
||||
if args.defaults:
|
||||
self._load_defaults(cmd)
|
||||
return
|
||||
|
||||
if args.file:
|
||||
if args.slot is not None:
|
||||
target_slot = SlotNumber(args.slot)
|
||||
else:
|
||||
target_slot = SlotNumber.from_fw(cmd.get_active_slot())
|
||||
self._load_from_json(args.file, target_slot, cmd)
|
||||
return
|
||||
|
||||
print(f' {CY}Specify --file, --cmd/--resp, --clear, or --defaults{C0}')
|
||||
|
||||
def _load_defaults(self, cmd):
|
||||
"""Load built-in Mastercard test APDU responses."""
|
||||
cmd.hf14a_4_clear_static_responses()
|
||||
pairs = [
|
||||
# SELECT PPSE
|
||||
(bytes.fromhex('00a404000e325041592e5359532e4444463031'),
|
||||
bytes.fromhex('6f23840e325041592e5359532e4444463031'
|
||||
'a511bf0c0e610c4f07a000000004101087010190 00'.replace(' ', '')),
|
||||
'SELECT PPSE'),
|
||||
# SELECT Mastercard Debit AID
|
||||
(bytes.fromhex('00a4040007a0000000041010'),
|
||||
bytes.fromhex('6f1d8407a0000000041010a512500a'
|
||||
'4d6173746572436172648701019f38009000'),
|
||||
'SELECT Mastercard AID'),
|
||||
# GPO — decline gracefully
|
||||
(bytes.fromhex('80a80000'),
|
||||
bytes.fromhex('6985'),
|
||||
'GPO (conditions not satisfied)'),
|
||||
]
|
||||
for c, r, name in pairs:
|
||||
resp = cmd.hf14a_4_add_static_response(c, r)
|
||||
if resp.status == Status.SUCCESS:
|
||||
print(f' {CG}Loaded: {name}{C0}')
|
||||
else:
|
||||
print(f' {CR}Failed: {name}{C0}')
|
||||
print(f'\n {CY}Default responses loaded. Run: hw mode -e{C0}')
|
||||
|
||||
def _tlv_encode_len(self, n: int) -> bytes:
|
||||
"""Encode integer n as BER-TLV length (short or long form)."""
|
||||
if n < 0x80:
|
||||
return bytes([n])
|
||||
elif n <= 0xFF:
|
||||
return bytes([0x81, n])
|
||||
else:
|
||||
return bytes([0x82, (n >> 8) & 0xFF, n & 0xFF])
|
||||
|
||||
def _load_from_json(self, filepath, target_slot, cmd):
|
||||
"""Load card data from a PM3 emv scan JSON file."""
|
||||
import json as jsonlib, os
|
||||
if not os.path.exists(filepath):
|
||||
print(f' {CR}File not found: {filepath}{C0}')
|
||||
return
|
||||
try:
|
||||
with open(filepath) as f:
|
||||
data = jsonlib.load(f)
|
||||
except Exception as e:
|
||||
print(f' {CR}JSON parse error: {e}{C0}')
|
||||
return
|
||||
|
||||
# Parse card info
|
||||
try:
|
||||
card = data['Card']['Contactless']
|
||||
uid = bytes.fromhex(card['UID'].replace(' ', ''))
|
||||
atqa = bytes.fromhex(card['ATQA'].replace(' ', ''))
|
||||
sak = int(card['SAK'], 16)
|
||||
ats_raw = bytes.fromhex(card['ATS'].replace(' ', ''))
|
||||
ats = ats_raw[:ats_raw[0]] if ats_raw else b''
|
||||
except Exception as e:
|
||||
print(f' {CR}Card info parse error: {e}{C0}')
|
||||
return
|
||||
|
||||
uid_str = ' '.join(f'{b:02X}' for b in uid)
|
||||
print(f' {CG}Card from JSON:{C0}')
|
||||
print(f' UID : {CG}{uid_str}{C0}')
|
||||
print(f' ATQA : {CG}{atqa.hex().upper()}{C0} SAK: {CG}{sak:02X}{C0}')
|
||||
print(f' ATS : {CG}{ats.hex().upper()}{C0}')
|
||||
|
||||
static_pairs = []
|
||||
|
||||
def tlv_resp(tag_hex, len_hex, val_hex):
|
||||
"""Reconstruct TLV response with proper BER length encoding + SW 9000."""
|
||||
tag_b = bytes.fromhex(tag_hex)
|
||||
val_b = bytes.fromhex(val_hex)
|
||||
n = int(len_hex, 16)
|
||||
len_b = self._tlv_encode_len(n)
|
||||
return tag_b + len_b + val_b + bytes([0x90, 0x00])
|
||||
|
||||
try:
|
||||
v = data['PPSE']['FCITemplate']['value'].replace(' ', '')
|
||||
l = data['PPSE']['FCITemplate']['length']
|
||||
static_pairs.append((
|
||||
bytes.fromhex('00a404000e325041592e5359532e4444463031'),
|
||||
tlv_resp('6F', l, v),
|
||||
'SELECT PPSE'))
|
||||
except Exception as e:
|
||||
print(f' {CR}PPSE: {e}{C0}')
|
||||
|
||||
try:
|
||||
v = data['Application']['FCITemplate']['value'].replace(' ', '')
|
||||
l = data['Application']['FCITemplate']['length']
|
||||
aid = data['Application']['AID'].replace(' ', '')
|
||||
static_pairs.append((
|
||||
bytes.fromhex('00a4040007' + aid),
|
||||
tlv_resp('6F', l, v),
|
||||
'SELECT AID'))
|
||||
except Exception as e:
|
||||
print(f' {CR}Application FCI: {e}{C0}')
|
||||
|
||||
try:
|
||||
v = data['Application']['GPO']['value'].replace(' ', '')
|
||||
l = data['Application']['GPO']['length']
|
||||
tag = data['Application']['GPO'].get('tag', '77')
|
||||
static_pairs.append((
|
||||
bytes.fromhex('80a80000'),
|
||||
tlv_resp(tag, l, v),
|
||||
'GPO'))
|
||||
except Exception as e:
|
||||
print(f' {CR}GPO: {e}{C0}')
|
||||
|
||||
try:
|
||||
for rec in data['Application'].get('Records', []):
|
||||
sfi_n = int(rec['SFI'], 16)
|
||||
rec_n = int(rec['RecordNum'], 16)
|
||||
v = rec['Data']['value'].replace(' ', '')
|
||||
l = rec['Data']['length']
|
||||
tag = rec['Data'].get('tag', '70')
|
||||
p2 = (sfi_n << 3) | 4
|
||||
static_pairs.append((
|
||||
bytes([0x00, 0xB2, rec_n, p2, 0x00]),
|
||||
tlv_resp(tag, l, v),
|
||||
f'READ RECORD SFI={sfi_n} rec={rec_n}'))
|
||||
except Exception as e:
|
||||
print(f' {CR}Records: {e}{C0}')
|
||||
|
||||
# Configure slot
|
||||
print(f'\n {CY}Configuring slot {target_slot}...{C0}')
|
||||
cmd.set_slot_tag_type(target_slot, TagSpecificType.HF14A_4)
|
||||
cmd.set_slot_data_default(target_slot, TagSpecificType.HF14A_4)
|
||||
cmd.set_slot_enable(target_slot, TagSenseType.HF, True)
|
||||
|
||||
# PM3 JSON stores ATQA in display order (byte1,byte0) — swap to wire order
|
||||
atqa_wire = bytes([atqa[1], atqa[0]]) if len(atqa) == 2 else atqa
|
||||
cmd.hf14a_4_set_anti_coll(uid, atqa_wire, sak, ats)
|
||||
|
||||
cmd.hf14a_4_clear_static_responses()
|
||||
for c, r, name in static_pairs:
|
||||
try:
|
||||
cmd.hf14a_4_add_static_response(c, r)
|
||||
print(f' {CG}+ {name} ({len(r)}b){C0}')
|
||||
except Exception as e:
|
||||
print(f' {CR} Failed {name}: {e}{C0}')
|
||||
|
||||
cmd.slot_data_config_save()
|
||||
print(f'\n {CG}Done! Slot {target_slot} ready with {len(static_pairs)} response(s).{C0}')
|
||||
print(f' {C0}Next: hw slot change -s {target_slot} && hw mode -e{C0}')
|
||||
|
||||
|
||||
@emv.command('apdu')
|
||||
class EMVApdu(DeviceRequiredUnit):
|
||||
"""
|
||||
ISO14443-4 T=CL interactive APDU relay.
|
||||
|
||||
CU emulates an ISO14443-4 card and relays APDUs to/from the terminal.
|
||||
For each APDU from the reader, you type the hex response bytes.
|
||||
|
||||
Requires HF14A_4 slot configured with SAK=20 and ATS. Run hw mode -e first.
|
||||
|
||||
Usage:
|
||||
emv apdu
|
||||
emv apdu --timeout 30000
|
||||
"""
|
||||
|
||||
def args_parser(self) -> ArgumentParserNoExit:
|
||||
parser = ArgumentParserNoExit()
|
||||
parser.description = 'ISO14443-4 T=CL interactive APDU relay (manual response mode)'
|
||||
parser.add_argument('--timeout', type=int, default=15000, metavar='<ms>',
|
||||
help='Total relay timeout in ms (default: 15000)')
|
||||
return parser
|
||||
|
||||
def on_exec(self, args: argparse.Namespace):
|
||||
import time
|
||||
cmd = self.cmd
|
||||
timeout_ms = max(1000, min(60000, args.timeout))
|
||||
|
||||
print(f' {CY}ISO14443-4 T=CL APDU relay started{C0}')
|
||||
print(f' Waiting for a reader to connect (SAK=20 slot required)...')
|
||||
print(f' Type {CY}quit{C0} to exit, or enter hex response bytes when prompted.')
|
||||
|
||||
exchange_count = 0
|
||||
|
||||
while True:
|
||||
resp = None
|
||||
deadline = time.monotonic() + (timeout_ms / 1000.0)
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
r = cmd.hf14a_4_apdu_recv()
|
||||
except Exception as e:
|
||||
print(f' {CR}Error polling for APDU: {e}{C0}')
|
||||
resp = None
|
||||
break
|
||||
if r.status == Status.SUCCESS:
|
||||
resp = r
|
||||
break
|
||||
elif r.status != Status.HF_TAG_NO:
|
||||
print(f' {CR}Firmware error: {r.status}{C0}')
|
||||
resp = None
|
||||
break
|
||||
time.sleep(0.02)
|
||||
|
||||
if resp is None:
|
||||
print(f' {C0}No APDU received within timeout.{C0}')
|
||||
break
|
||||
|
||||
apdu = bytes(resp.data)
|
||||
desc = _emv_decode_apdu(apdu)
|
||||
exchange_count += 1
|
||||
apdu_hex = ' '.join(f'{b:02x}' for b in apdu)
|
||||
print(f'\n [{exchange_count}] {CY}APDU →→ {apdu_hex}{C0}')
|
||||
if desc:
|
||||
print(f' {C0}{desc}{C0}')
|
||||
|
||||
try:
|
||||
user_input = input(f' Response (hex) [{CG}90 00{C0}]: ').strip()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
break
|
||||
|
||||
if user_input.lower() == 'quit':
|
||||
break
|
||||
if not user_input:
|
||||
user_input = '9000'
|
||||
|
||||
try:
|
||||
response_bytes = bytes.fromhex(user_input.replace(' ', ''))
|
||||
except ValueError:
|
||||
print(f' {CR}Invalid hex — sending 6F00 (error){C0}')
|
||||
response_bytes = bytes.fromhex('6F00')
|
||||
|
||||
try:
|
||||
cmd.hf14a_4_apdu_send(response_bytes)
|
||||
resp_hex = ' '.join(f'{b:02x}' for b in response_bytes)
|
||||
print(f' {CG}←← Response {resp_hex}{C0}')
|
||||
except Exception as e:
|
||||
print(f' {CR}Error sending response: {e}{C0}')
|
||||
break
|
||||
|
||||
print(f'\n {C0}Relay ended. {exchange_count} APDU exchange(s) completed.{C0}')
|
||||
|
||||
|
||||
@@ -235,6 +235,101 @@ class ChameleonCMD:
|
||||
return resp
|
||||
|
||||
@expect_response(Status.HF_TAG_OK)
|
||||
def hf14a_scan_keep(self):
|
||||
"""
|
||||
Scan ISO14443-A tag with full select + RATS, keeping field alive.
|
||||
|
||||
Identical to hf14a_scan but does NOT tear down the RF field afterward.
|
||||
The card remains powered and in ISO14443-4 T=CL state so subsequent
|
||||
hf14a_raw calls can exchange APDUs without re-selecting.
|
||||
"""
|
||||
resp = self.device.send_cmd_sync(Command.HF14A_SCAN_KEEP)
|
||||
if resp.status == Status.HF_TAG_OK:
|
||||
offset = 0
|
||||
data = []
|
||||
while offset < len(resp.data):
|
||||
uidlen, = struct.unpack_from('!B', resp.data, offset); offset += 1
|
||||
uid, atqa, sak, atslen = struct.unpack_from(
|
||||
f'!{uidlen}s2s1sB', resp.data, offset)
|
||||
offset += struct.calcsize(f'!{uidlen}s2s1sB')
|
||||
ats, = struct.unpack_from(f'!{atslen}s', resp.data, offset)
|
||||
offset += atslen
|
||||
data.append({'uid': uid, 'atqa': atqa, 'sak': sak, 'ats': ats})
|
||||
resp.parsed = data
|
||||
return resp
|
||||
|
||||
def hf14a_4_set_anti_coll(self, uid: bytes, atqa: bytes, sak: int, ats: bytes):
|
||||
"""
|
||||
Set UID / ATQA / SAK / ATS for the active HF14A_4 slot.
|
||||
|
||||
:param uid: UID bytes (4 or 7 bytes)
|
||||
:param atqa: ATQA 2 bytes (wire order, e.g. b'\x04\x00' for ATQA 00 04)
|
||||
:param sak: SAK byte value (int), use 0x20 for ISO14443-4
|
||||
:param ats: ATS bytes (without CRC)
|
||||
"""
|
||||
uid_size = len(uid)
|
||||
payload = (bytes([uid_size]) + bytes(uid) + bytes(atqa) +
|
||||
bytes([sak]) + bytes([len(ats)]) + bytes(ats))
|
||||
return self.device.send_cmd_sync(Command.HF14A_4_SET_ANTI_COLL, payload)
|
||||
|
||||
def hf14a_4_apdu_recv(self):
|
||||
"""
|
||||
Non-blocking poll for a pending APDU from the ISO14443-4 T=CL stack.
|
||||
|
||||
Returns immediately: STATUS_SUCCESS + APDU bytes if one is pending,
|
||||
STATUS_HF_TAG_NO if no APDU is waiting. Call in a tight loop from
|
||||
the host side for relay/capture use cases.
|
||||
"""
|
||||
return self.device.send_cmd_sync(Command.HF14A_4_APDU_RECV, b'', timeout=2)
|
||||
|
||||
def hf14a_4_apdu_send(self, resp: bytes):
|
||||
"""Send an APDU response to the ISO14443-4 T=CL stack."""
|
||||
payload = bytes([(len(resp) >> 8) & 0xFF, len(resp) & 0xFF]) + bytes(resp)
|
||||
return self.device.send_cmd_sync(Command.HF14A_4_APDU_SEND, payload)
|
||||
|
||||
def hf14a_4_add_static_response(self, cmd: bytes, resp: bytes):
|
||||
"""
|
||||
Add a static APDU command→response pair to the HF14A_4 slot.
|
||||
|
||||
The firmware will automatically reply with resp whenever it receives
|
||||
an APDU whose first len(cmd) bytes match cmd, without USB involvement.
|
||||
Must be called before hw mode -e.
|
||||
"""
|
||||
rlen = len(resp); payload = bytes([len(cmd)]) + bytes(cmd) + bytes([(rlen >> 8) & 0xFF, rlen & 0xFF]) + bytes(resp)
|
||||
return self.device.send_cmd_sync(Command.HF14A_4_STATIC_RESP, payload)
|
||||
|
||||
def hf14a_4_reader_apdu(self, apdu: bytes):
|
||||
"""
|
||||
Select card (with RATS) and send one ISO14443-4 T=CL APDU in a single
|
||||
firmware call — avoiding the USB round-trip gap that would depower the card.
|
||||
|
||||
:param apdu: raw APDU bytes (no PCB wrapping needed)
|
||||
:return: response object with resp.data = APDU response bytes (no PCB/CRC)
|
||||
"""
|
||||
return self.device.send_cmd_sync(
|
||||
Command.HF14A_4_READER_APDU, bytes(apdu), timeout=3)
|
||||
|
||||
def hf14a_4_emv_scan(self):
|
||||
"""
|
||||
Full EMV card scan in a single firmware call.
|
||||
|
||||
The firmware performs the complete sequence (field cycle, select, RATS,
|
||||
PPSE, SELECT AID, GPO, READ RECORDs) without returning to the host
|
||||
between APDUs, avoiding the field-drop issue with separate calls.
|
||||
|
||||
Response format:
|
||||
uid_len(1) uid(n) atqa(2) sak(1) ats_len(1) ats(m)
|
||||
num_apdus(1)
|
||||
for each APDU pair:
|
||||
cmd_len(1) cmd(n) resp_len_le(2) resp(m)
|
||||
"""
|
||||
resp = self.device.send_cmd_sync(Command.HF14A_4_EMV_SCAN, b'', timeout=10)
|
||||
return resp
|
||||
|
||||
def hf14a_4_clear_static_responses(self):
|
||||
"""Clear all static APDU responses from the active HF14A_4 slot."""
|
||||
return self.device.send_cmd_sync(Command.HF14A_4_STATIC_RESP, b'\x00')
|
||||
|
||||
def hf14a_raw(self, options, resp_timeout_ms=100, data=[], bitlen=None):
|
||||
"""
|
||||
Send raw cmd to 14a tag.
|
||||
|
||||
@@ -68,6 +68,7 @@ class Command(enum.IntEnum):
|
||||
MF1_READ_ONE_BLOCK = 2008
|
||||
MF1_WRITE_ONE_BLOCK = 2009
|
||||
HF14A_RAW = 2010
|
||||
HF14A_SCAN_KEEP = 2016
|
||||
MF1_MANIPULATE_VALUE_BLOCK = 2011
|
||||
MF1_CHECK_KEYS_OF_SECTORS = 2012
|
||||
MF1_HARDNESTED_ACQUIRE = 2013
|
||||
@@ -138,6 +139,14 @@ class Command(enum.IntEnum):
|
||||
MF1_SET_FIELD_OFF_DO_RESET = 4038
|
||||
MF1_GET_FIELD_OFF_DO_RESET = 4039
|
||||
|
||||
# ISO14443-4 T=CL emulation
|
||||
HF14A_4_APDU_RECV = 6000
|
||||
HF14A_4_APDU_SEND = 6001
|
||||
HF14A_4_SET_ANTI_COLL = 6002
|
||||
HF14A_4_STATIC_RESP = 6003
|
||||
HF14A_4_READER_APDU = 6004
|
||||
HF14A_4_EMV_SCAN = 6005
|
||||
|
||||
EM410X_SET_EMU_ID = 5000
|
||||
EM410X_GET_EMU_ID = 5001
|
||||
HIDPROX_SET_EMU_ID = 5002
|
||||
@@ -333,7 +342,8 @@ class TagSpecificType(enum.IntEnum):
|
||||
|
||||
# ST25TA series 2000
|
||||
|
||||
# HF14A-4 series 3000
|
||||
# ISO14443-4 T=CL emulation
|
||||
HF14A_4 = 3000
|
||||
|
||||
@staticmethod
|
||||
def list(exclude_meta=True):
|
||||
|
||||
Reference in new issue
Block a user