Big transponder support update
This commit is contained in:
258
docs/TRANSPONDERS.md
Normal file
258
docs/TRANSPONDERS.md
Normal file
@@ -0,0 +1,258 @@
|
||||
# pm3py Simulated Transponders — Reference & Comparison
|
||||
|
||||
Every transponder model that `pm3py` can simulate, organized by RF frequency and
|
||||
protocol, with feature-comparison tables. Import any model from `pm3py.sim`:
|
||||
|
||||
```python
|
||||
from pm3py.sim import NTAG216, ST25DV16K, OptigaAuthenticateNBT, SimSession
|
||||
```
|
||||
|
||||
Memory figures are **user memory** (excludes UID/lock/config/OTP pages) per the
|
||||
manufacturer datasheet. "Faithful" models reproduce the documented command set,
|
||||
UID prefix, and access rules; a ✓ in a feature column means the model implements
|
||||
that behaviour (not merely that the silicon has it).
|
||||
|
||||
Legend: **T2/T4/T5** = NFC Forum Tag type · **A/B/V** = ISO 14443-A / 14443-B /
|
||||
15693 (NFC-V) · **DI** = dual interface (RF + wired I²C/SPI) · **EH** = energy
|
||||
harvesting · **—** = not applicable / not present.
|
||||
|
||||
---
|
||||
|
||||
## 1. Master list — by frequency and protocol
|
||||
|
||||
| Model(s) | Vendor | Freq | Protocol | NFC type | Module |
|
||||
|---|---|---|---|---|---|
|
||||
| MIFARE Ultralight / C / EV1 (MF0UL11/21) | NXP | 13.56 MHz | ISO 14443-A | T2 | `iso14443a/nxp/ultralight` |
|
||||
| NTAG210/212/213/215/216 | NXP | 13.56 MHz | ISO 14443-A | T2 | `iso14443a/nxp/ntag21x` |
|
||||
| NTAG I²C plus (NT3H2111/2211) | NXP | 13.56 MHz | ISO 14443-A | T2 + I²C | `iso14443a/nxp/ntag_i2c` |
|
||||
| MIFARE Classic 1K/4K | NXP | 13.56 MHz | ISO 14443-A | — (Crypto1) | `iso14443a/nxp/mifare_classic` |
|
||||
| MIFARE DESFire EV1/2/3 | NXP | 13.56 MHz | ISO 14443-A | T4 | `iso14443a/nxp/desfire` |
|
||||
| ST25TN512/01K | ST | 13.56 MHz | ISO 14443-A | T2 | `iso14443a/st/st25tn` |
|
||||
| ST25TA512B/02KB/16K/64K | ST | 13.56 MHz | ISO 14443-A | T4 | `iso14443a/st/st25ta` |
|
||||
| OPTIGA Authenticate NBT | Infineon | 13.56 MHz | ISO 14443-A | T4 + I²C | `iso14443a/infineon/optiga_nbt` |
|
||||
| ST25TB512-AC/AT, 02K, 04K | ST | 13.56 MHz | ISO 14443-B (SRIX) | — | `iso14443b/st/st25tb` |
|
||||
| RF430CL330H | TI | 13.56 MHz | ISO 14443-B | T4 + I²C | `iso14443b/ti/rf430cl330h` |
|
||||
| ICODE SLIX / SLIX2 / 3 / DNA | NXP | 13.56 MHz | ISO 15693 | T5 | `iso15693/nxp/icode_*` |
|
||||
| NTAG 5 Switch / Link / Boost | NXP | 13.56 MHz | ISO 15693 | T5 (+I²C on Link/Boost) | `iso15693/nxp/ntag5_*` |
|
||||
| ST25TV512C/02KC | ST | 13.56 MHz | ISO 15693 | T5 | `iso15693/st/st25tv` |
|
||||
| ST25DV04K/16K/64K | ST | 13.56 MHz | ISO 15693 | T5 + I²C | `iso15693/st/st25dv` |
|
||||
| my-d vicinity SRF55V02P/10P | Infineon | 13.56 MHz | ISO 15693 | T5 | `iso15693/infineon/myd_vicinity` |
|
||||
| Tag-it HF-I Plus | TI | 13.56 MHz | ISO 15693 | T5 | `iso15693/ti/tagit` |
|
||||
| RF430FRL152/153/154H | TI | 13.56 MHz | ISO 15693 + sensor | T5 + I²C/SPI | `iso15693/ti/rf430frl` |
|
||||
| EM4100 | EM | 125 kHz | LF ASK | — | `lf/em/em4100` |
|
||||
| HID Prox | HID | 125 kHz | LF FSK (Wiegand) | — | `lf/hid/hid` |
|
||||
| T5577 | Atmel | 125 kHz | LF (multi) | — | `lf/atmel/t5577` |
|
||||
|
||||
Generic bases you can subclass: `NfcType2Tag` / `NfcType4Tag` (14443-A),
|
||||
`NfcType5Tag`/`Tag15693` (15693), `Tag14443B` (SRIX) / `Tag14443B_4` (standard
|
||||
Type B), `TagLF`.
|
||||
|
||||
---
|
||||
|
||||
## 2. HF · ISO 14443-A · NFC Forum Type 2 (Ultralight / NTAG)
|
||||
|
||||
| Model | User mem | Pages | GET_VERSION | FAST_READ | PWD_AUTH | Counter | Signature | Extra |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| MIFARE Ultralight (MF0ICU1) | 48 B | 16 | — | — | — | — | — | OTP, COMPAT_WRITE |
|
||||
| MIFARE Ultralight C (MF0ICU2) | 144 B | 48 | — | — | — | 16-bit | — | **3DES AUTHENTICATE** |
|
||||
| Ultralight EV1 MF0UL11 | 48 B | 20 | ✓ | ✓ | 32-bit | 3 × 24-bit | ✓ | INCR_CNT, tearing |
|
||||
| Ultralight EV1 MF0UL21 | 128 B | 41 | ✓ | ✓ | 32-bit | 3 × 24-bit | ✓ | INCR_CNT, tearing |
|
||||
| NTAG210 | 48 B | 20 | ✓ | ✓ | 32-bit | — | ✓ | — |
|
||||
| NTAG212 | 128 B | 41 | ✓ | ✓ | 32-bit | — | ✓ | — |
|
||||
| NTAG213 | 144 B | 45 | ✓ | ✓ | 32-bit | 1 × 24-bit NFC | ✓ | mirror |
|
||||
| NTAG215 | 504 B | 135 | ✓ | ✓ | 32-bit | 1 × 24-bit NFC | ✓ | mirror |
|
||||
| NTAG216 | 888 B | 231 | ✓ | ✓ | 32-bit | 1 × 24-bit NFC | ✓ | mirror |
|
||||
| NTAG I²C plus 1K (NT3H2111) | 888 B | — | ✓ | ✓ | 32-bit | — | ✓ | **SECTOR_SELECT**, 64 B SRAM, DI |
|
||||
| NTAG I²C plus 2K (NT3H2211) | 1912 B | — | ✓ | ✓ | 32-bit | — | ✓ | 2 sectors, FAST_WRITE, DI |
|
||||
| ST25TN512 | 64 B | 64 | — | — | — | — | TruST25 | Kill password |
|
||||
| ST25TN01K | 160 B | 64 | — | — | — | — | TruST25 | Kill password |
|
||||
|
||||
All: ATQA `0x0044`, SAK `0x00`, 7-byte UID. NXP UID prefix `04…`; ST prefix `02…`.
|
||||
NFC counter on NTAG213/215/216 auto-increments once per power cycle when enabled
|
||||
(`ACCESS.NFC_CNT_EN`). Ultralight EV1 counters are read/increment (one-way) with
|
||||
`CHECK_TEARING_EVENT`.
|
||||
|
||||
---
|
||||
|
||||
## 3. HF · ISO 14443-A · NFC Forum Type 4 (ISO-DEP / APDU)
|
||||
|
||||
| Model | NDEF | Extra files | Password | Crypto auth | Counter | Extra |
|
||||
|---|---|---|---|---|---|---|
|
||||
| ST25TA512B | 64 B | System | 128-bit R/W | — | — | — |
|
||||
| ST25TA02KB | 256 B | System | 128-bit R/W | — | 20-bit event | TruST25, GPO (-D/-P) |
|
||||
| ST25TA16K | 2 KB | System | 128-bit R/W | — | — | — |
|
||||
| ST25TA64K | 8 KB | System | 128-bit R/W | — | — | *constants extrapolated* |
|
||||
| OPTIGA Authenticate NBT | 4 KB | 4×1 KB proprietary + FAP | 32-bit (per-file, FAP) | **ECDSA P-256** + AES-CMAC COTT | — | DI (I²C), life-cycle |
|
||||
|
||||
All ISO-DEP with per-model ATS. ST25TA UID prefix `02…`; OPTIGA prefix `05…`,
|
||||
ATS historical bytes = `"NBT2000"`. OPTIGA read/write access is gated by the
|
||||
File Access Policy (per file, per interface); AUTHENTICATE TAG (INS `0x88`)
|
||||
signs a challenge with the device's NIST P-256 key.
|
||||
|
||||
*MIFARE DESFire (EV1/2/3) is also Type 4 but uses the proprietary DESFire
|
||||
command set + DES/3DES/AES auth — see `iso14443a/nxp/desfire`.*
|
||||
|
||||
---
|
||||
|
||||
## 4. HF · ISO 14443-B
|
||||
|
||||
| Model | Anticollision | Blocks × size | User | Counters | OTP area | Extra |
|
||||
|---|---|---|---|---|---|---|
|
||||
| ST25TB512-AC | SRIX slot-marker | 16 × 4 B | 36 B | 2 × count-down | blocks 0-4 | resettable OTP |
|
||||
| ST25TB512-AT | SRIX slot-marker | 16 × 4 B | 36 B | 2 × count-down | — | plain EEPROM 0-4 |
|
||||
| ST25TB02K | SRIX slot-marker | 64 × 4 B | 228 B | 2 × count-down | blocks 0-4 | reload counter |
|
||||
| ST25TB04K | SRIX slot-marker | 128 × 4 B | 484 B | 2 × count-down | blocks 0-4 | reload counter |
|
||||
| RF430CL330H | **standard REQB/ATTRIB** | 3 KB SRAM | 3 KB NDEF | — | — | NFC **Type 4**, DI (I²C/SPI) |
|
||||
|
||||
ST25TB use the simplified SRIX/SRI anticollision (Initiate/Pcall16/Slot_marker
|
||||
/Select-by-Chip_ID), 64-bit UID prefix `D0 02 …`, decrement-only counters at
|
||||
blocks 5/6. RF430CL330H uses full ISO 14443-3 Type B (REQB→ATQB→ATTRIB) and then
|
||||
Type 4 NDEF APDUs; its NDEF SRAM is volatile (host-filled over I²C/SPI).
|
||||
|
||||
---
|
||||
|
||||
## 5. HF · ISO 15693 (NFC-V / NFC Forum Type 5)
|
||||
|
||||
| Model | User mem | Blocks | UID prefix | Password | Privacy | Signature | Extra |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| ICODE SLIX | 112 B | 28 × 4 B | `E0 04 01` | EAS/AFI | — | ✓ | EAS |
|
||||
| ICODE SLIX2 | 320 B | 80 × 4 B | `E0 04 02` | 4 × 32-bit | privacy | ✓ | DESTROY, protect-page |
|
||||
| ICODE 3 | 304 B | 76 × 4 B | `E0 04 01` | 4 × 32-bit | privacy | ✓ | — |
|
||||
| ICODE DNA | 256 B | 64 × 4 B | `E0 04 01` | 4 × 32-bit | privacy | ✓ | **AES-128** mutual auth |
|
||||
| NTAG 5 Switch (NTP5210) | 512 B | 128 × 4 B | `E0 04 01` | password | privacy | ✓ | PWM/GPIO, EH, ED — **no I²C**, no AES |
|
||||
| NTAG 5 Link (NTP53x2) | 2 KB | 512 × 4 B | `E0 04 01` | password | privacy | ✓ | **DI (I²C)**, 256 B SRAM, EH, ED, AES (NTP5332) |
|
||||
| NTAG 5 Boost (NTA5332) | 2 KB | 512 × 4 B | `E0 04 01` | password | privacy | ✓ | **DI (I²C)**, SRAM, EH, AES always, **ALM**³ |
|
||||
| ST25TV512C | 64 B | 16 × 4 B | `E0 02 08` | 64/32-bit areas | Kill/Silent/Discreet | TruST25 | tamper (-T) |
|
||||
| ST25TV02KC | 320 B | 80 × 4 B | `E0 02 08` | 64/32-bit areas | Kill/Silent/Discreet | TruST25 | tamper (-T) |
|
||||
| ST25DV04K | 512 B | 128 × 4 B | `E0 02 24` | 3 × 64-bit | — | — | **EH**, mailbox/FTM, DI |
|
||||
| ST25DV16K | 2 KB | 512 × 4 B | `E0 02 26` | 3 × 64-bit | — | — | **EH**, 4 areas, GPO, DI |
|
||||
| ST25DV64K | 8 KB | 2048 × 4 B | `E0 02 26` | 3 × 64-bit | — | — | **EH**, mailbox, DI |
|
||||
| my-d vicinity SRF55V02P | 224 B | 56 × 4 B | `E0 05 40` | — | — | — | value counters, page mode¹ |
|
||||
| my-d vicinity SRF55V10P | 992 B | 248 × 4 B | `E0 05 00` | — | — | — | value counters, page mode¹ |
|
||||
| Tag-it HF-I Plus | 256 B | 64 × 4 B | `E0 07 …` | — | — | — | Write_2/Lock_2 (0xA2/0xA3) |
|
||||
| RF430FRL152/3/4H | 256 B² | 64 × 4 B² | `E0 07 A2` | — | — | — | **ADC + temp sensor**, DI |
|
||||
|
||||
¹ my-d vicinity has **no GET SYSTEM INFO** (0x2B) — identify via the Chip-ID UID
|
||||
byte. ² RF430FRL block map + sensor commands are defined by ROM firmware
|
||||
(SLAU603, not modelled); the model provides identity + standard 15693 I/O.
|
||||
³ **ALM** = Active Load Modulation, unique to the NTAG 5 Boost (NTA5332) among
|
||||
the NTAG 5 family — a physical RF feature (no command-layer effect; at the APDU
|
||||
layer Boost is a Link NTP5332 with AES permanently enabled).
|
||||
|
||||
---
|
||||
|
||||
## 6. LF · 125 kHz
|
||||
|
||||
| Model | Data | Encoding | Writable | Notes |
|
||||
|---|---|---|---|---|
|
||||
| EM4100 | 40-bit ID | ASK / Manchester | read-only | classic access-control tag |
|
||||
| HID Prox | 26/35/… bit Wiegand | FSK | read-only | facility + card number |
|
||||
| T5577 | 4×32-bit blocks + config | ASK/FSK/PSK (multi) | ✓ | emulates EM4100/HID/etc. via config |
|
||||
|
||||
---
|
||||
|
||||
## 7. Specialized feature comparison
|
||||
|
||||
### 7.1 Energy harvesting (EH output for external circuits)
|
||||
|
||||
| Model | Protocol | EH mechanism |
|
||||
|---|---|---|
|
||||
| ST25DV04K/16K/64K | 15693 | V_EH analog output pin; `EH_CTRL_Dyn` register (EH_EN/EH_ON) |
|
||||
| NTAG I²C plus | 14443-A | field-powered I²C host supply (V_OUT) |
|
||||
| **NTAG 5 — all variants (Switch/Link/Boost)** | 15693 | energy-harvesting output + event-detection (ED) pin, shared via the NTAG 5 platform |
|
||||
| RF430FRL15xH | 15693 | passive (field-powered) or battery-assisted operation |
|
||||
|
||||
### 7.2 Dual interface — RF + wired host (I²C / SPI bridge)
|
||||
|
||||
| Model | RF | Wired | Shared-memory / bridge feature |
|
||||
|---|---|---|---|
|
||||
| NTAG I²C plus (NT3H2111/2211) | 14443-A T2 | I²C | 64-byte SRAM, pass-through, SECTOR_SELECT |
|
||||
| ST25DV04K/16K/64K | 15693 T5 | I²C | 256-byte mailbox (Fast Transfer Mode) |
|
||||
| OPTIGA Authenticate NBT | 14443-A T4 | I²C (GP T=1′) | NFC↔I²C pass-through, IRQ |
|
||||
| RF430CL330H | 14443-B T4 | I²C / SPI | 3 KB NDEF SRAM, host writes NDEF |
|
||||
| NTAG 5 Link (NTP53x2) | 15693 T5 | I²C | 256 B SRAM, arbiter, pass-through/mirror/PHDC |
|
||||
| NTAG 5 Boost (NTA5332) | 15693 T5 | I²C | = Link NTP5332 + AES always on + ALM |
|
||||
| RF430FRL15xH | 15693 | I²C / SPI | MSP430 host access to FRAM |
|
||||
|
||||
*(NTAG 5 **Switch** (NTP5210) is single-interface — RF + PWM/GPIO only, no I²C.)*
|
||||
|
||||
### 7.3 Cryptographic authentication
|
||||
|
||||
| Model | Protocol | Scheme |
|
||||
|---|---|---|
|
||||
| MIFARE Classic 1K/4K | 14443-A | Crypto1 (48-bit, per-sector keys A/B) |
|
||||
| MIFARE Ultralight C | 14443-A | 2-key **3DES** challenge-response (AUTHENTICATE 0x1A) |
|
||||
| MIFARE DESFire | 14443-A T4 | DES / 3DES / **AES-128** |
|
||||
| ICODE DNA | 15693 | **AES-128** tag + mutual auth |
|
||||
| NTAG 5 Link (NTP5332) / Boost (NTA5332) | 15693 | **AES-128** TAM/MAM (Switch has none; Link NTP5312 is password-only) |
|
||||
| OPTIGA Authenticate NBT | 14443-A T4 | **ECDSA P-256** (offline) + AES-128-CMAC COTT (online) |
|
||||
|
||||
### 7.4 Password protection (symmetric secret / verify)
|
||||
|
||||
| Model | Protocol | Password |
|
||||
|---|---|---|
|
||||
| NTAG21x, Ultralight EV1 | 14443-A | 32-bit PWD_AUTH (+16-bit PACK), AUTH0 page gate |
|
||||
| ST25TA family | 14443-A T4 | 128-bit read + write passwords (VERIFY) |
|
||||
| OPTIGA NBT | 14443-A T4 | 32-bit per-file passwords via File Access Policy |
|
||||
| ICODE SLIX2 / 3 / DNA | 15693 | 4 × 32-bit (read/write/privacy/destroy) |
|
||||
| ST25TV | 15693 | 64-bit (single-area) or 2 × 32-bit (dual-area) + config pwd |
|
||||
| ST25DV | 15693 | 3 × 64-bit RF passwords + area protection |
|
||||
| ST25TN | 14443-A | 32-bit Kill password only |
|
||||
|
||||
### 7.5 Counters
|
||||
|
||||
| Model | Counter(s) | Type |
|
||||
|---|---|---|
|
||||
| NTAG213/215/216 | 1 × 24-bit | NFC read counter (auto-increment) |
|
||||
| Ultralight EV1 | 3 × 24-bit | one-way, increment + tearing check |
|
||||
| Ultralight C | 1 × 16-bit | one-way |
|
||||
| ST25TA02KB | 20-bit | event counter (anti-tearing) |
|
||||
| my-d vicinity | per-page | value counters (decrement-only, 0…65535) |
|
||||
| ST25TB (all) | 2 × 32-bit | count-down (decrement-only) |
|
||||
|
||||
### 7.6 Tamper detection, privacy & kill
|
||||
|
||||
| Model | Feature |
|
||||
|---|---|
|
||||
| ST25TV02KC-T | tamper loop (TD0/TD1), seal/unseal/reseal event memorization |
|
||||
| ST25TV | Kill (permanent), Silent + Discreet (reversible), NUID masking |
|
||||
| ST25TN | Kill (permanent RF mute) |
|
||||
| ICODE SLIX2 / 3 / DNA | privacy mode, DESTROY (permanent) |
|
||||
| ST25DV | RF-disable / RF-sleep dynamic registers |
|
||||
|
||||
### 7.7 Originality / authenticity signatures
|
||||
|
||||
| Signature scheme | Models |
|
||||
|---|---|
|
||||
| NXP originality (32-byte ECC, READ_SIG) | NTAG21x, Ultralight EV1, NTAG I²C, ICODE SLIX/SLIX2/3/DNA, NTAG5 |
|
||||
| ST TruST25 digital signature | ST25TN, ST25TA (02KB), ST25TV |
|
||||
| Certificate-based (X.509 + ECDSA) | OPTIGA Authenticate NBT |
|
||||
|
||||
### 7.8 Sensors
|
||||
|
||||
| Model | Sensor |
|
||||
|---|---|
|
||||
| RF430FRL152/153/154H | 14-bit sigma-delta ADC + internal temperature sensor (over 15693, via ROM firmware) |
|
||||
|
||||
---
|
||||
|
||||
## 8. Implant presets
|
||||
|
||||
Pre-configured instances for the common dual/single-frequency implants
|
||||
(`from pm3py.sim import xEM, xNT, xM1, FlexDF, NExT`):
|
||||
|
||||
| Preset | Backing model | Notes |
|
||||
|---|---|---|
|
||||
| `xEM` | T5577 (EM4100 mode) | LF |
|
||||
| `xNT` | NTAG216 | HF, NDEF |
|
||||
| `xM1` | MIFARE Classic 1K | HF |
|
||||
| `FlexDF` | DESFire EV2 | HF |
|
||||
| `NExT` | T5577 + NTAG216 | dual-frequency (LF + HF) |
|
||||
| `MagicMifareClassicTag` | gen1a / gen2 Classic | writable block 0 |
|
||||
|
||||
---
|
||||
|
||||
*Generated from the `pm3py.sim` model set. Memory/feature values follow the
|
||||
manufacturer datasheets; where a datasheet was not supplied (ST25TA64K,
|
||||
RF430FRL firmware command set) the model is marked accordingly in its module
|
||||
docstring.*
|
||||
@@ -13,6 +13,84 @@ def decode_14443a(direction: int, payload: bytes) -> str | None:
|
||||
return _decode_14443a_response(payload)
|
||||
|
||||
|
||||
# ---- NFC Forum Type 2 / MIFARE Ultralight / NTAG command set ----
|
||||
#
|
||||
# These decoders cover the byte-oriented command set shared by MIFARE
|
||||
# Ultralight (MF0ICU2), Ultralight EV1 (MF0UL11/21), NTAG21x
|
||||
# (NTAG210/212/213/215/216) and NTAG I2C plus (NT3H2111/2211). They are
|
||||
# layered on top of the generic 14443-A decoder by the tag models'
|
||||
# ``decode_trace`` (see NfcType2Tag), mirroring how the NXP ICODE tags
|
||||
# layer ``decode_15693_nxp`` over the base 15693 decoder.
|
||||
|
||||
_TYPE2_CMDS = {
|
||||
0x30: "READ",
|
||||
0x3A: "FAST_READ",
|
||||
0xA2: "WRITE",
|
||||
0xA0: "COMPAT_WRITE",
|
||||
0x60: "GET_VERSION",
|
||||
0x1B: "PWD_AUTH",
|
||||
0x39: "READ_CNT",
|
||||
0xA5: "INCR_CNT",
|
||||
0x3E: "CHECK_TEARING",
|
||||
0x3C: "READ_SIG",
|
||||
0xC2: "SECTOR_SELECT",
|
||||
0x50: "HALT",
|
||||
}
|
||||
|
||||
|
||||
def _decode_type2_request(payload: bytes) -> str | None:
|
||||
b0 = payload[0]
|
||||
name = _TYPE2_CMDS.get(b0)
|
||||
if name is None:
|
||||
return None
|
||||
|
||||
if b0 == 0x30 and len(payload) >= 2: # READ <page>
|
||||
return f"READ p{payload[1]}"
|
||||
if b0 == 0x3A and len(payload) >= 3: # FAST_READ <start> <end>
|
||||
return f"FAST_READ p{payload[1]}-{payload[2]}"
|
||||
if b0 in (0xA2, 0xA0) and len(payload) >= 2: # WRITE / COMPAT_WRITE <page>
|
||||
return f"{name} p{payload[1]}"
|
||||
if b0 == 0x1B and len(payload) >= 5: # PWD_AUTH <4-byte pwd>
|
||||
return f"PWD_AUTH {payload[1:5].hex().upper()}"
|
||||
if b0 == 0x39 and len(payload) >= 2: # READ_CNT <counter>
|
||||
return f"READ_CNT #{payload[1]}"
|
||||
if b0 == 0xA5 and len(payload) >= 2: # INCR_CNT <counter>
|
||||
return f"INCR_CNT #{payload[1]}"
|
||||
if b0 == 0x3E and len(payload) >= 2: # CHECK_TEARING <counter>
|
||||
return f"CHECK_TEARING #{payload[1]}"
|
||||
if b0 == 0xC2 and len(payload) >= 2 and payload[1] == 0xFF:
|
||||
return "SECTOR_SELECT p1" # packet 1 (C2 FF)
|
||||
return name
|
||||
|
||||
|
||||
def _decode_type2_response(payload: bytes) -> str | None:
|
||||
# 4-bit ACK/NAK acknowledge responses
|
||||
if len(payload) == 1:
|
||||
b0 = payload[0]
|
||||
if b0 == 0x0A:
|
||||
return "ACK"
|
||||
if b0 in (0x00, 0x01, 0x04, 0x05):
|
||||
return f"NAK 0x{b0:X}"
|
||||
if len(payload) == 8:
|
||||
return f"VERSION {payload.hex().upper()}"
|
||||
return None
|
||||
|
||||
|
||||
def decode_14443a_type2(direction: int, payload: bytes) -> str | None:
|
||||
"""Decode a Type 2 (Ultralight/NTAG) frame, falling back to generic 14443-A.
|
||||
|
||||
Used by the ``decode_trace`` of NfcType2Tag and its subclasses so that
|
||||
byte-oriented commands (READ, FAST_READ, GET_VERSION, PWD_AUTH, counters,
|
||||
SECTOR_SELECT) get named while anticollision/REQA/SAK still decode via the
|
||||
base decoder.
|
||||
"""
|
||||
if not payload:
|
||||
return None
|
||||
if direction == 0:
|
||||
return _decode_type2_request(payload) or _decode_14443a_request(payload)
|
||||
return _decode_type2_response(payload) or _decode_14443a_response(payload)
|
||||
|
||||
|
||||
def _decode_14443a_request(payload: bytes) -> str | None:
|
||||
b0 = payload[0]
|
||||
|
||||
|
||||
98
pm3py/trace/decode_iso14b.py
Normal file
98
pm3py/trace/decode_iso14b.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""ISO 14443 Type B (SRIX / ST25TB lineage) request/response decoders + CRC_B."""
|
||||
from __future__ import annotations
|
||||
|
||||
# ST25TB / SRIX command opcodes
|
||||
_14B_CMDS = {
|
||||
0x08: "READ_BLOCK",
|
||||
0x09: "WRITE_BLOCK",
|
||||
0x0B: "GET_UID",
|
||||
0x0C: "RESET_TO_INVENTORY",
|
||||
0x0E: "SELECT",
|
||||
0x0F: "COMPLETION",
|
||||
}
|
||||
|
||||
|
||||
def decode_14443b(direction: int, payload: bytes) -> str | None:
|
||||
"""Decode an ISO 14443 Type B (ST25TB/SRIX) frame."""
|
||||
if not payload:
|
||||
return None
|
||||
if direction == 0:
|
||||
return _decode_request(payload)
|
||||
return _decode_response(payload)
|
||||
|
||||
|
||||
def _decode_request(payload: bytes) -> str | None:
|
||||
b0 = payload[0]
|
||||
|
||||
# SRIX / ST25TB anticollision (opcode 0x06)
|
||||
if b0 == 0x06 and len(payload) >= 2:
|
||||
if payload[1] == 0x00:
|
||||
return "INITIATE"
|
||||
if payload[1] == 0x04:
|
||||
return "PCALL16"
|
||||
return f"ANTICOL 0x{payload[1]:02X}"
|
||||
|
||||
# SRIX slot marker: low nibble = 6, high nibble = slot 1..15
|
||||
if (b0 & 0x0F) == 0x06 and (b0 >> 4) != 0:
|
||||
return f"SLOT_MARKER {b0 >> 4}"
|
||||
|
||||
# Standard ISO 14443-3 Type B (REQB/WUPB/ATTRIB/HLTB)
|
||||
if b0 == 0x05 and len(payload) >= 3:
|
||||
return "WUPB" if (payload[2] & 0x08) else "REQB"
|
||||
if (b0 & 0x0F) == 0x05 and (b0 >> 4) != 0:
|
||||
return f"SLOT-MARKER {b0 >> 4}"
|
||||
if b0 == 0x1D:
|
||||
return "ATTRIB"
|
||||
if b0 == 0x50 and len(payload) >= 5:
|
||||
return "HLTB"
|
||||
|
||||
# SRIX / ST25TB memory + select opcodes (must precede the I-block fallback,
|
||||
# since they share the PCB=00 bit pattern).
|
||||
name = _14B_CMDS.get(b0)
|
||||
if name is not None:
|
||||
if b0 == 0x08 and len(payload) >= 2:
|
||||
return f"READ_BLOCK #{payload[1]}"
|
||||
if b0 == 0x09 and len(payload) >= 2:
|
||||
return f"WRITE_BLOCK #{payload[1]}"
|
||||
if b0 == 0x0E and len(payload) >= 2:
|
||||
return f"SELECT chip=0x{payload[1]:02X}"
|
||||
return name
|
||||
|
||||
# ISO-DEP I-block (PCB) — fallback for standard Type B Layer 4.
|
||||
if b0 & 0xC0 == 0x00 and len(payload) > 1:
|
||||
return f"I-BLOCK({b0 & 0x01})"
|
||||
return None
|
||||
|
||||
|
||||
def _decode_response(payload: bytes) -> str | None:
|
||||
# ATQB: 50 | PUPI(4) | AppData(4) | ProtInfo(3) = 12 bytes
|
||||
if len(payload) == 12 and payload[0] == 0x50:
|
||||
return f"ATQB PUPI={payload[1:5].hex().upper()}"
|
||||
if len(payload) == 1:
|
||||
return f"CHIP_ID 0x{payload[0]:02X}"
|
||||
if len(payload) == 4:
|
||||
return f"BLOCK {payload.hex().upper()}"
|
||||
if len(payload) == 8:
|
||||
return f"UID {bytes(reversed(payload)).hex().upper()}"
|
||||
# ISO-DEP I-block response
|
||||
if payload[0] & 0xC0 == 0x00 and len(payload) > 1:
|
||||
return f"I-BLOCK({payload[0] & 0x01})"
|
||||
return None
|
||||
|
||||
|
||||
def crc14b(data: bytes) -> bytes:
|
||||
"""ISO/IEC 14443-3 Type B CRC (CRC_B), transmitted LSByte first.
|
||||
|
||||
Polynomial x^16 + x^12 + x^5 + 1, init 0xFFFF, result one's-complemented,
|
||||
reflected. Matches the reference routine in the ST25TB datasheet appendix.
|
||||
"""
|
||||
crc = 0xFFFF
|
||||
for b in data:
|
||||
crc ^= b
|
||||
for _ in range(8):
|
||||
if crc & 0x0001:
|
||||
crc = (crc >> 1) ^ 0x8408
|
||||
else:
|
||||
crc >>= 1
|
||||
crc = (~crc) & 0xFFFF
|
||||
return bytes([crc & 0xFF, (crc >> 8) & 0xFF])
|
||||
@@ -42,6 +42,26 @@ _15693_NXP_CMDS = {
|
||||
0xBD: "NXP READ SIGNATURE",
|
||||
}
|
||||
|
||||
_15693_ST_CMDS = {
|
||||
0xA0: "ST READ CONFIG",
|
||||
0xA1: "ST WRITE CONFIG",
|
||||
0xA6: "ST KILL",
|
||||
0xB1: "ST WRITE PASSWORD",
|
||||
0xB3: "ST PRESENT PASSWORD",
|
||||
0xB4: "ST GET RANDOM",
|
||||
0xBA: "ST TOGGLE DISCREET/SILENT",
|
||||
0xB5: "ST FAST READ DYN CONFIG",
|
||||
0xB7: "ST FAST WRITE DYN CONFIG",
|
||||
0xAA: "ST READ DYN CONFIG",
|
||||
0xAB: "ST WRITE DYN CONFIG",
|
||||
0xAC: "ST READ MAILBOX MSG LEN",
|
||||
0xAD: "ST READ MAILBOX MSG",
|
||||
0xAE: "ST WRITE MAILBOX MSG",
|
||||
0xC0: "ST MANAGE GPO",
|
||||
0xD1: "ST INVENTORY INITIATED",
|
||||
0xD2: "ST INITIATE",
|
||||
}
|
||||
|
||||
_15693_ERRORS = {
|
||||
0x01: "not supported",
|
||||
0x02: "not recognized",
|
||||
@@ -212,3 +232,22 @@ def decode_15693_nxp(direction: int, payload: bytes) -> str | None:
|
||||
if name is not None:
|
||||
return _annotate_nxp_request(name, cmd, payload)
|
||||
return None
|
||||
|
||||
|
||||
def decode_15693_st(direction: int, payload: bytes) -> str | None:
|
||||
"""Decode an ST (STMicroelectronics) custom ISO 15693 command."""
|
||||
if direction != 0:
|
||||
return None
|
||||
if len(payload) < 2:
|
||||
return None
|
||||
cmd = payload[1]
|
||||
name = _15693_ST_CMDS.get(cmd)
|
||||
if name is None:
|
||||
return None
|
||||
# ST custom commands carry the mfg code (0x02) then, often, FID/PID or a
|
||||
# password id. Annotate the common ones.
|
||||
if cmd in (0xA0, 0xA1) and len(payload) >= 5: # READ/WRITE CONFIG: FID/PID
|
||||
return f"{name} FID={payload[3]:02X} PID={payload[4]:02X}"
|
||||
if cmd in (0xB3, 0xB1, 0xBA) and len(payload) >= 4: # password id
|
||||
return f"{name} id={payload[3]}"
|
||||
return name
|
||||
|
||||
1
pm3py/transponders/hf/iso14443a/infineon/__init__.py
Normal file
1
pm3py/transponders/hf/iso14443a/infineon/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Infineon ISO 14443-A transponders (OPTIGA Authenticate NBT)."""
|
||||
475
pm3py/transponders/hf/iso14443a/infineon/optiga_nbt.py
Normal file
475
pm3py/transponders/hf/iso14443a/infineon/optiga_nbt.py
Normal file
@@ -0,0 +1,475 @@
|
||||
"""OPTIGA(TM) Authenticate NBT (NBT2000A8K0T4) — Infineon NFC bridge tag.
|
||||
|
||||
Sourced from the Infineon Extended Datasheet Rev 2.1. NFC Forum Type 4 Tag over
|
||||
ISO/IEC 14443-A (7-byte UID, Infineon mfg code 0x05), with an 8 KB file system
|
||||
(4 KB NDEF + 4x1 KB proprietary files), a File Access Policy (EF.FAP) gating
|
||||
per-file read/write, 32-bit password management, and ECDSA (NIST P-256) one-way
|
||||
tag authentication.
|
||||
|
||||
Modelled here (the NFC side): activation + ISO-DEP, the Type 4 file tree
|
||||
(EF.CC / NDEF / 4 proprietary / EF.FAP), SELECT (by AID and by file id with
|
||||
inline password verification), READ BINARY / UPDATE BINARY with FAP enforcement,
|
||||
CREATE / DELETE / CHANGE / UNBLOCK PASSWORD, AUTHENTICATE TAG (ECDSA-P256),
|
||||
GET DATA (applet version) and the PERSONALIZATION -> OPERATIONAL life cycle.
|
||||
The I2C bridge / pass-through side is not simulated.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import struct
|
||||
|
||||
from pm3py.sim.frame import RFFrame
|
||||
from ..base import Tag14443A_4
|
||||
|
||||
# File IDs
|
||||
FID_CC = b"\xE1\x03"
|
||||
FID_NDEF = b"\xE1\x04"
|
||||
FID_PP = [b"\xE1\xA1", b"\xE1\xA2", b"\xE1\xA3", b"\xE1\xA4"]
|
||||
FID_FAP = b"\xE1\xAF"
|
||||
|
||||
NDEF_AID = b"\xD2\x76\x00\x00\x85\x01\x01"
|
||||
|
||||
# Command INS (CLA 0x00)
|
||||
INS_SELECT = 0xA4
|
||||
INS_PERSONALIZE = 0xE2
|
||||
INS_READ_BINARY = 0xB0
|
||||
INS_AUTH_TAG = 0x88
|
||||
INS_UPDATE_BINARY = 0xD6
|
||||
INS_CHANGE_UNBLOCK_PWD = 0x24
|
||||
INS_CREATE_PWD = 0xE1
|
||||
INS_DELETE_PWD = 0xE4
|
||||
INS_GET_DATA = 0x30
|
||||
|
||||
# FAP config byte values
|
||||
FAP_NEVER = 0x00
|
||||
FAP_ALWAYS = 0x40
|
||||
FAP_PWD = 0x80 # | password-id in low 5 bits
|
||||
|
||||
# Status words
|
||||
SW_OK = b"\x90\x00"
|
||||
SW_WRONG_LENGTH = b"\x67\x00"
|
||||
SW_SEC_NOT_SATISFIED = b"\x69\x82"
|
||||
SW_COND_NOT_SATISFIED = b"\x69\x85"
|
||||
SW_WRONG_DATA = b"\x6A\x80"
|
||||
SW_FILE_NOT_FOUND = b"\x6A\x82"
|
||||
SW_WRONG_P1P2 = b"\x6A\x86"
|
||||
SW_REF_NOT_FOUND = b"\x6A\x88"
|
||||
SW_CMD_NOT_ALLOWED = b"\x69\x86"
|
||||
|
||||
# Default ATS: length 0C, T0=78, TA1=77, TB1=70, TC1=02, hist "NBT2000"
|
||||
DEFAULT_ATS = b"\x0C\x78\x77\x70\x02" + b"NBT2000"
|
||||
|
||||
|
||||
class _Password:
|
||||
def __init__(self, value: bytes, response: bytes = b"\x00\x00",
|
||||
limit: int = 0x7F):
|
||||
self.value = value
|
||||
self.response = response
|
||||
self.limit = limit
|
||||
self.tries = limit
|
||||
self.blocked = False
|
||||
|
||||
|
||||
class OptigaAuthenticateNBT(Tag14443A_4):
|
||||
"""Infineon OPTIGA Authenticate NBT NFC Type 4 tag."""
|
||||
|
||||
def __init__(self, uid: bytes | None = None, ndef_message: bytes = b"",
|
||||
ecc_key=None, atqa: bytes = b"\x44\x00", sak: int = 0x20):
|
||||
if uid is None:
|
||||
uid = b"\x05" + os.urandom(6) # Infineon mfg code 0x05
|
||||
# ATQA/SAK are NOT published in the NBT2000 extended datasheet or the
|
||||
# hardware integration guide (verified 2026-07: both omit them). They are
|
||||
# fixed by the tag being a 7-byte-UID ISO 14443-4 (Type 4A) device:
|
||||
# ATQA 0x0044 (wire 44 00) = bit-frame anticollision + double-size UID,
|
||||
# SAK 0x20 = ISO-DEP compliant, UID complete.
|
||||
# These match every 7-byte-UID Type-4A tag (NTAG21x, DESFire, ST25TA).
|
||||
super().__init__(uid=uid, atqa=atqa, sak=sak, ats=DEFAULT_ATS)
|
||||
self._lifecycle = "PERSONALIZATION"
|
||||
self._ecc_key = ecc_key # ECC P-256 private key (lazy-generated)
|
||||
self._passwords: dict[int, _Password] = {}
|
||||
self._files: dict[bytes, bytearray] = {
|
||||
FID_CC: bytearray(64),
|
||||
FID_NDEF: bytearray(4096),
|
||||
FID_PP[0]: bytearray(1024),
|
||||
FID_PP[1]: bytearray(1024),
|
||||
FID_PP[2]: bytearray(1024),
|
||||
FID_PP[3]: bytearray(1024),
|
||||
FID_FAP: bytearray(42),
|
||||
}
|
||||
# File Access Policy: fileid -> [i2c_r, i2c_w, nfc_r, nfc_w]
|
||||
self._fap: dict[bytes, list[int]] = {
|
||||
FID_CC: [FAP_ALWAYS, FAP_NEVER, FAP_ALWAYS, FAP_NEVER],
|
||||
FID_NDEF: [FAP_ALWAYS, FAP_ALWAYS, FAP_ALWAYS, FAP_ALWAYS],
|
||||
FID_PP[0]: [FAP_ALWAYS] * 4,
|
||||
FID_PP[1]: [FAP_ALWAYS] * 4,
|
||||
FID_PP[2]: [FAP_ALWAYS] * 4,
|
||||
FID_PP[3]: [FAP_ALWAYS] * 4,
|
||||
FID_FAP: [FAP_ALWAYS] * 4,
|
||||
}
|
||||
self._app_selected = False
|
||||
self._selected_file: bytes | None = None
|
||||
self._read_ok: set[bytes] = set()
|
||||
self._write_ok: set[bytes] = set()
|
||||
self._build_cc(ndef_message)
|
||||
|
||||
def _build_cc(self, ndef_message: bytes) -> None:
|
||||
cc = bytearray(64)
|
||||
struct.pack_into(">H", cc, 0, 0x002F) # CCLEN
|
||||
cc[2] = 0x20 # mapping version 2.0
|
||||
struct.pack_into(">H", cc, 3, 0x0100) # MLe
|
||||
struct.pack_into(">H", cc, 5, 0x00FF) # MLc
|
||||
cc[7:15] = b"\x04\x06" + FID_NDEF + struct.pack(">H", 0x1000) + b"\x00\x00"
|
||||
off = 15
|
||||
for fid in FID_PP:
|
||||
cc[off:off + 8] = b"\x05\x06" + fid + struct.pack(">H", 0x0400) + b"\x00\x00"
|
||||
off += 8
|
||||
self._files[FID_CC] = cc
|
||||
self._write_fap_file()
|
||||
if ndef_message:
|
||||
self.set_ndef(ndef_message)
|
||||
|
||||
def _write_fap_file(self) -> None:
|
||||
fap = bytearray()
|
||||
for fid in (FID_CC, FID_NDEF, *FID_PP, FID_FAP):
|
||||
fap += fid + bytes(self._fap[fid])
|
||||
self._files[FID_FAP] = fap
|
||||
|
||||
# ----- NDEF convenience -----
|
||||
|
||||
def set_ndef(self, message: bytes) -> None:
|
||||
if len(message) > 4096 - 2:
|
||||
raise ValueError("NDEF message too large (max 4094 bytes)")
|
||||
f = self._files[FID_NDEF]
|
||||
struct.pack_into(">H", f, 0, len(message))
|
||||
f[2:2 + len(message)] = message
|
||||
for i in range(2 + len(message), len(f)):
|
||||
f[i] = 0
|
||||
|
||||
def finalize(self) -> None:
|
||||
"""Move to OPERATIONAL life cycle (config locked)."""
|
||||
self._lifecycle = "OPERATIONAL"
|
||||
|
||||
def add_password(self, pwid: int, value: bytes, response: bytes = b"\x00\x00",
|
||||
limit: int = 0x7F) -> None:
|
||||
self._passwords[pwid] = _Password(value, response, limit)
|
||||
|
||||
def set_fap(self, fileid: bytes, nfc_read: int, nfc_write: int) -> None:
|
||||
self._fap[fileid][2] = nfc_read
|
||||
self._fap[fileid][3] = nfc_write
|
||||
self._write_fap_file()
|
||||
|
||||
@property
|
||||
def lifecycle(self) -> str:
|
||||
return self._lifecycle
|
||||
|
||||
# ----- ISO-DEP reset -----
|
||||
|
||||
async def power_on(self) -> None:
|
||||
await super().power_on()
|
||||
self._app_selected = False
|
||||
self._selected_file = None
|
||||
self._read_ok.clear()
|
||||
self._write_ok.clear()
|
||||
|
||||
def _handle_rats(self, frame: RFFrame) -> RFFrame | None:
|
||||
self._app_selected = False
|
||||
self._selected_file = None
|
||||
self._read_ok.clear()
|
||||
self._write_ok.clear()
|
||||
return super()._handle_rats(frame)
|
||||
|
||||
# ----- APDU dispatch -----
|
||||
|
||||
def _handle_apdu(self, apdu: bytes) -> bytes:
|
||||
if len(apdu) < 4: # need at least CLA INS P1 P2
|
||||
return SW_WRONG_LENGTH
|
||||
cla, ins = apdu[0], apdu[1]
|
||||
if cla != 0x00:
|
||||
return b"\x6E\x00" # only the NFC CLA 0x00 set is modelled
|
||||
if ins == INS_SELECT:
|
||||
return self._handle_select(apdu)
|
||||
if ins == INS_READ_BINARY:
|
||||
return self._handle_read_binary(apdu)
|
||||
if ins == INS_UPDATE_BINARY:
|
||||
return self._handle_update_binary(apdu)
|
||||
if ins == INS_AUTH_TAG:
|
||||
return self._handle_authenticate(apdu)
|
||||
if ins == INS_CREATE_PWD:
|
||||
return self._handle_create_password(apdu)
|
||||
if ins == INS_DELETE_PWD:
|
||||
return self._handle_delete_password(apdu)
|
||||
if ins == INS_CHANGE_UNBLOCK_PWD:
|
||||
return self._handle_change_unblock(apdu)
|
||||
if ins == INS_GET_DATA:
|
||||
return self._handle_get_data(apdu)
|
||||
if ins == INS_PERSONALIZE:
|
||||
return self._handle_personalize(apdu)
|
||||
return b"\x6D\x00"
|
||||
|
||||
# ----- SELECT -----
|
||||
|
||||
def _handle_select(self, apdu: bytes) -> bytes:
|
||||
p1, p2 = apdu[2], apdu[3]
|
||||
lc = apdu[4] if len(apdu) > 4 else 0
|
||||
data = apdu[5:5 + lc]
|
||||
if p1 == 0x04: # select by AID
|
||||
if data == NDEF_AID:
|
||||
self._app_selected = True
|
||||
self._selected_file = None
|
||||
return SW_OK
|
||||
return SW_FILE_NOT_FOUND
|
||||
if p1 == 0x00 and p2 == 0x0C: # select by file id
|
||||
if not self._app_selected or len(data) < 2:
|
||||
return SW_FILE_NOT_FOUND
|
||||
fid = bytes(data[0:2])
|
||||
if fid not in self._files:
|
||||
return SW_FILE_NOT_FOUND
|
||||
self._selected_file = fid
|
||||
fci = self._verify_select_passwords(fid, data[2:])
|
||||
return fci + SW_OK
|
||||
return SW_WRONG_P1P2
|
||||
|
||||
def _verify_select_passwords(self, fid: bytes, tlvs: bytes) -> bytes:
|
||||
"""Parse 52h (read) / 54h (write) password TLVs, mark verified files."""
|
||||
fci = b""
|
||||
i = 0
|
||||
while i + 6 <= len(tlvs):
|
||||
tag, ln = tlvs[i], tlvs[i + 1]
|
||||
if ln != 0x04:
|
||||
break
|
||||
pwd = bytes(tlvs[i + 2:i + 6])
|
||||
if tag == 0x52:
|
||||
if self._check_password(self._fap[fid][2], pwd):
|
||||
self._read_ok.add(fid)
|
||||
fci += b"\x02\x52"
|
||||
elif tag == 0x54:
|
||||
if self._check_password(self._fap[fid][3], pwd):
|
||||
self._write_ok.add(fid)
|
||||
fci += b"\x02\x54"
|
||||
i += 6
|
||||
if fci:
|
||||
return b"\x6F" + bytes([len(fci)]) + fci
|
||||
return b""
|
||||
|
||||
def _check_password(self, fap_byte: int, supplied: bytes) -> bool:
|
||||
if not (fap_byte & FAP_PWD):
|
||||
return False
|
||||
pwid = fap_byte & 0x1F
|
||||
pw = self._passwords.get(pwid)
|
||||
if pw is None or pw.blocked:
|
||||
return False
|
||||
if supplied == pw.value:
|
||||
pw.tries = pw.limit
|
||||
return True
|
||||
if pw.limit != 0xFFFF:
|
||||
pw.tries -= 1
|
||||
if pw.tries <= 0:
|
||||
pw.blocked = True
|
||||
return False
|
||||
|
||||
# ----- access-control helpers -----
|
||||
|
||||
def _read_allowed(self, fid: bytes) -> bool:
|
||||
cfg = self._fap[fid][2]
|
||||
if cfg == FAP_ALWAYS:
|
||||
return True
|
||||
if cfg == FAP_NEVER:
|
||||
return False
|
||||
return fid in self._read_ok
|
||||
|
||||
def _write_allowed(self, fid: bytes) -> bool:
|
||||
cfg = self._fap[fid][3]
|
||||
if cfg == FAP_ALWAYS:
|
||||
return True
|
||||
if cfg == FAP_NEVER:
|
||||
return False
|
||||
return fid in self._write_ok
|
||||
|
||||
# ----- READ / UPDATE BINARY -----
|
||||
|
||||
def _handle_read_binary(self, apdu: bytes) -> bytes:
|
||||
if self._selected_file is None:
|
||||
return SW_CMD_NOT_ALLOWED
|
||||
fid = self._selected_file
|
||||
content = self._files[fid]
|
||||
offset = (apdu[2] << 8) | apdu[3]
|
||||
if len(apdu) < 5:
|
||||
return SW_WRONG_LENGTH
|
||||
le = apdu[4]
|
||||
if offset > len(content):
|
||||
return SW_WRONG_LENGTH
|
||||
if not self._read_allowed(fid):
|
||||
return SW_SEC_NOT_SATISFIED
|
||||
if offset + le > len(content):
|
||||
avail = len(content) - offset
|
||||
return bytes([0x6C, avail & 0xFF])
|
||||
return bytes(content[offset:offset + le]) + SW_OK
|
||||
|
||||
def _handle_update_binary(self, apdu: bytes) -> bytes:
|
||||
if self._selected_file is None:
|
||||
return SW_CMD_NOT_ALLOWED
|
||||
fid = self._selected_file
|
||||
offset = (apdu[2] << 8) | apdu[3]
|
||||
lc = apdu[4] if len(apdu) > 4 else 0
|
||||
data = apdu[5:5 + lc]
|
||||
if fid == FID_CC and 0x02 <= offset <= 0x0E:
|
||||
return SW_COND_NOT_SATISFIED # CC 02..0E never updatable
|
||||
if not self._write_allowed(fid):
|
||||
return SW_SEC_NOT_SATISFIED
|
||||
if fid == FID_FAP:
|
||||
return self._update_fap(data)
|
||||
content = self._files[fid]
|
||||
if offset + len(data) > len(content):
|
||||
return SW_WRONG_P1P2
|
||||
content[offset:offset + len(data)] = data
|
||||
return SW_OK
|
||||
|
||||
def _update_fap(self, data: bytes) -> bytes:
|
||||
if len(data) != 6:
|
||||
return SW_WRONG_LENGTH
|
||||
fid = bytes(data[0:2])
|
||||
if fid not in self._fap:
|
||||
return SW_WRONG_DATA
|
||||
self._fap[fid] = list(data[2:6])
|
||||
self._write_fap_file()
|
||||
return SW_OK
|
||||
|
||||
# ----- AUTHENTICATE TAG (ECDSA P-256) -----
|
||||
|
||||
def _handle_authenticate(self, apdu: bytes) -> bytes:
|
||||
if apdu[2] != 0x00 or apdu[3] != 0x00:
|
||||
return SW_WRONG_P1P2
|
||||
lc = apdu[4] if len(apdu) > 4 else 0
|
||||
challenge = apdu[5:5 + lc]
|
||||
if lc == 0:
|
||||
return SW_WRONG_LENGTH
|
||||
try:
|
||||
from Crypto.PublicKey import ECC
|
||||
from Crypto.Signature import DSS
|
||||
from Crypto.Hash import SHA256
|
||||
except ImportError:
|
||||
return SW_COND_NOT_SATISFIED
|
||||
if self._ecc_key is None:
|
||||
self._ecc_key = ECC.generate(curve="P-256")
|
||||
h = SHA256.new(bytes(challenge))
|
||||
sig = DSS.new(self._ecc_key, "fips-186-3", encoding="der").sign(h)
|
||||
return sig + SW_OK
|
||||
|
||||
# ----- password management -----
|
||||
|
||||
def _handle_create_password(self, apdu: bytes) -> bytes:
|
||||
if apdu[2] != 0x00 or apdu[3] != 0x00:
|
||||
return SW_WRONG_P1P2
|
||||
lc = apdu[4] if len(apdu) > 4 else 0
|
||||
data = apdu[5:5 + lc]
|
||||
if lc == 0x09:
|
||||
body = data
|
||||
elif lc == 0x0D:
|
||||
body = data[4:] # skip master password (verify skipped)
|
||||
else:
|
||||
return SW_WRONG_LENGTH
|
||||
if len(body) != 9:
|
||||
return SW_WRONG_LENGTH
|
||||
pwid = body[0]
|
||||
value = bytes(body[1:5])
|
||||
response = bytes(body[5:7])
|
||||
limit = (body[7] << 8) | body[8]
|
||||
if pwid == 0x00 or pwid > 0x1F:
|
||||
return SW_WRONG_DATA
|
||||
if response in (b"\x00\x00", b"\xFF\xFF"):
|
||||
return SW_WRONG_DATA
|
||||
if limit == 0x0000 or (0x0080 <= limit <= 0xFFFE):
|
||||
return SW_WRONG_DATA
|
||||
if pwid in self._passwords:
|
||||
return SW_COND_NOT_SATISFIED
|
||||
if len(self._passwords) >= 0x1C:
|
||||
return SW_COND_NOT_SATISFIED
|
||||
self._passwords[pwid] = _Password(value, response, limit)
|
||||
return SW_OK
|
||||
|
||||
def _handle_delete_password(self, apdu: bytes) -> bytes:
|
||||
if apdu[2] != 0x00:
|
||||
return SW_WRONG_P1P2
|
||||
pwid = apdu[3] & 0x1F
|
||||
if pwid not in self._passwords:
|
||||
return SW_REF_NOT_FOUND
|
||||
del self._passwords[pwid]
|
||||
return SW_OK
|
||||
|
||||
def _handle_change_unblock(self, apdu: bytes) -> bytes:
|
||||
if apdu[2] != 0x00:
|
||||
return SW_WRONG_P1P2
|
||||
p2 = apdu[3]
|
||||
op = (p2 >> 6) & 0x03 # b8,b7: 00=UNBLOCK, 01=CHANGE
|
||||
pwid = p2 & 0x1F
|
||||
lc = apdu[4] if len(apdu) > 4 else 0
|
||||
data = apdu[5:5 + lc]
|
||||
pw = self._passwords.get(pwid)
|
||||
if pw is None:
|
||||
return SW_REF_NOT_FOUND
|
||||
if op == 0x00: # UNBLOCK
|
||||
if lc not in (0x00, 0x04):
|
||||
return SW_WRONG_LENGTH
|
||||
pw.blocked = False
|
||||
pw.tries = pw.limit
|
||||
return SW_OK
|
||||
if op == 0x01: # CHANGE
|
||||
if lc not in (0x04, 0x08):
|
||||
return SW_WRONG_LENGTH
|
||||
if pw.blocked:
|
||||
return SW_COND_NOT_SATISFIED
|
||||
new_pwd = data[-4:]
|
||||
pw.value = bytes(new_pwd)
|
||||
return SW_OK
|
||||
return SW_WRONG_P1P2
|
||||
|
||||
# ----- GET DATA (applet version) -----
|
||||
|
||||
def _handle_get_data(self, apdu: bytes) -> bytes:
|
||||
if apdu[2] == 0xDF and apdu[3] == 0x3A:
|
||||
return b"\x6F\x05\x04\x03\x01\x01\x02" + SW_OK # version 1.1.2
|
||||
return SW_WRONG_P1P2
|
||||
|
||||
# ----- PERSONALIZE DATA (PERSONALIZATION only) -----
|
||||
|
||||
def _handle_personalize(self, apdu: bytes) -> bytes:
|
||||
if self._lifecycle != "PERSONALIZATION":
|
||||
return SW_COND_NOT_SATISFIED
|
||||
if apdu[2] != 0x00 or apdu[3] != 0x00:
|
||||
return SW_WRONG_P1P2
|
||||
lc = apdu[4] if len(apdu) > 4 else 0
|
||||
data = apdu[5:5 + lc]
|
||||
if len(data) < 3:
|
||||
return SW_WRONG_LENGTH
|
||||
dgi = bytes(data[0:2])
|
||||
dlen = data[2]
|
||||
body = data[3:3 + dlen]
|
||||
if dgi == b"\xBF\x63": # FINALIZE PERSONALIZATION
|
||||
self._lifecycle = "OPERATIONAL"
|
||||
return SW_OK
|
||||
if dgi == b"\xA0\x03": # password data set
|
||||
if len(body) != 9:
|
||||
return SW_WRONG_DATA
|
||||
self._passwords[body[0]] = _Password(
|
||||
bytes(body[1:5]), bytes(body[5:7]), (body[7] << 8) | body[8])
|
||||
return SW_OK
|
||||
if dgi == b"\xE1\xAF": # FAP config
|
||||
if len(body) != 42:
|
||||
return SW_WRONG_DATA
|
||||
for i in range(0, 42, 6):
|
||||
fid = bytes(body[i:i + 2])
|
||||
if fid in self._fap:
|
||||
self._fap[fid] = list(body[i + 2:i + 6])
|
||||
self._write_fap_file()
|
||||
return SW_OK
|
||||
if dgi in self._files: # file content: <offset(2)><content>
|
||||
if len(body) < 2:
|
||||
return SW_WRONG_DATA
|
||||
off = (body[0] << 8) | body[1]
|
||||
content = self._files[dgi]
|
||||
payload = body[2:]
|
||||
if off + len(payload) > len(content):
|
||||
return SW_WRONG_DATA
|
||||
content[off:off + len(payload)] = payload
|
||||
return SW_OK
|
||||
return b"\x6A\x88" # unsupported data group
|
||||
@@ -1 +1,9 @@
|
||||
"""NXP ISO 14443-A transponders (MIFARE Classic, DESFire)."""
|
||||
"""NXP ISO 14443-A transponders.
|
||||
|
||||
- mifare_classic, desfire — MIFARE Classic / DESFire.
|
||||
- type2 — NxpType2Tag: shared Ultralight/NTAG command core.
|
||||
- ntag21x — NTAG210/212/213/215/216.
|
||||
- ultralight — MIFARE Ultralight, Ultralight C (3DES), Ultralight EV1
|
||||
(MF0UL11/MF0UL21).
|
||||
- ntag_i2c — NTAG I2C plus (NT3H2111 1k / NT3H2211 2k).
|
||||
"""
|
||||
|
||||
172
pm3py/transponders/hf/iso14443a/nxp/ntag21x.py
Normal file
172
pm3py/transponders/hf/iso14443a/nxp/ntag21x.py
Normal file
@@ -0,0 +1,172 @@
|
||||
"""NTAG21x — NXP NFC Forum Type 2 tags (NTAG210/212/213/215/216).
|
||||
|
||||
Sourced from the NXP datasheets:
|
||||
- NTAG210/212 (Rev. 3.0, doc 265331)
|
||||
- NTAG213/215/216 (Rev. 3.2, doc 265332)
|
||||
|
||||
Feature summary:
|
||||
- GET_VERSION, FAST_READ, PWD_AUTH (+PACK), READ_SIG on all five.
|
||||
- Single 24-bit NFC one-way counter (READ_CNT 0x39, addr 0x02) on 213/215/216
|
||||
only — enabled by ACCESS.NFC_CNT_EN, optionally password protected by
|
||||
ACCESS.NFC_CNT_PWD_PROT. NTAG210/212 have no counter.
|
||||
- Password protection from AUTH0 onwards (write, or read+write if ACCESS.PROT).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pm3py.sim.frame import RFFrame
|
||||
from .type2 import NxpType2Tag, READ_CNT, FAST_READ, READ_CMD, _nak, NAK_ARG
|
||||
|
||||
# ACCESS byte bits (CFG1 byte 0)
|
||||
ACCESS_PROT = 0x80
|
||||
ACCESS_CFGLCK = 0x40
|
||||
ACCESS_NFC_CNT_EN = 0x10
|
||||
ACCESS_NFC_CNT_PWD_PROT = 0x08
|
||||
|
||||
|
||||
class Ntag21x(NxpType2Tag):
|
||||
"""Base for the NTAG21x family. Concrete ICs set the class attributes."""
|
||||
|
||||
_has_nfc_counter: bool = False # True for 213/215/216
|
||||
_cc_default: bytes = b"\xE1\x10\x00\x00"
|
||||
|
||||
def __init__(self, uid: bytes | None = None, ndef_message: bytes = b"",
|
||||
password: bytes = b"\xFF\xFF\xFF\xFF",
|
||||
pack: bytes = b"\x00\x00", auth0: int = 0xFF,
|
||||
prot: bool = False, signature: bytes | None = None,
|
||||
nfc_cnt_en: bool = False):
|
||||
if uid is None:
|
||||
import os
|
||||
uid = b"\x04" + os.urandom(6)
|
||||
self._nfc_counter = 0
|
||||
self._counter_triggered = False
|
||||
super().__init__(uid=uid, password=password, pack=pack, auth0=auth0,
|
||||
prot=prot, signature=signature, cc=self._cc_default,
|
||||
ndef_message=ndef_message,
|
||||
total_pages=self._total_pages_cls,
|
||||
atqa=b"\x44\x00", sak=0x00)
|
||||
if nfc_cnt_en and self._cfg1_page is not None:
|
||||
off = self._cfg1_page * 4
|
||||
self._memory[off] |= ACCESS_NFC_CNT_EN
|
||||
|
||||
# ----- config bits -----
|
||||
|
||||
@property
|
||||
def _access(self) -> int:
|
||||
if self._cfg1_page is None:
|
||||
return 0
|
||||
return self._memory[self._cfg1_page * 4]
|
||||
|
||||
@property
|
||||
def nfc_counter_enabled(self) -> bool:
|
||||
return self._has_nfc_counter and bool(self._access & ACCESS_NFC_CNT_EN)
|
||||
|
||||
@property
|
||||
def nfc_counter(self) -> int:
|
||||
return self._nfc_counter
|
||||
|
||||
# ----- power cycle resets the "first read" trigger -----
|
||||
|
||||
async def power_on(self) -> None:
|
||||
await super().power_on()
|
||||
self._counter_triggered = False
|
||||
|
||||
# ----- NFC counter auto-increment on first READ/FAST_READ -----
|
||||
|
||||
def _maybe_bump_counter(self) -> None:
|
||||
if self.nfc_counter_enabled and not self._counter_triggered:
|
||||
self._counter_triggered = True
|
||||
if self._nfc_counter < 0xFFFFFF:
|
||||
self._nfc_counter += 1
|
||||
|
||||
def _handle_read(self, frame: RFFrame) -> RFFrame | None:
|
||||
resp = super()._handle_read(frame)
|
||||
# A data frame is >1 byte; a single-byte reply is an ACK/NAK.
|
||||
if resp is not None and len(resp.data) > 1:
|
||||
self._maybe_bump_counter()
|
||||
return resp
|
||||
|
||||
def _handle_fast_read(self, frame: RFFrame) -> RFFrame | None:
|
||||
resp = super()._handle_fast_read(frame)
|
||||
if resp is not None and len(resp.data) > 1:
|
||||
self._maybe_bump_counter()
|
||||
return resp
|
||||
|
||||
# ----- READ_CNT (0x39) -----
|
||||
|
||||
def _handle_application(self, frame: RFFrame) -> RFFrame | None:
|
||||
if (frame.data and frame.data[0] == READ_CNT and self._has_nfc_counter):
|
||||
return self._handle_read_cnt(frame)
|
||||
return super()._handle_application(frame)
|
||||
|
||||
def _handle_read_cnt(self, frame: RFFrame) -> RFFrame | None:
|
||||
if len(frame.data) < 2 or frame.data[1] != 0x02:
|
||||
return _nak(NAK_ARG)
|
||||
if not self.nfc_counter_enabled:
|
||||
return _nak(NAK_ARG)
|
||||
if (self._access & ACCESS_NFC_CNT_PWD_PROT) and not self._authenticated_pwd:
|
||||
return _nak(NAK_ARG)
|
||||
return RFFrame.from_bytes(self._nfc_counter.to_bytes(3, "little"))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Concrete ICs
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class NTAG213(Ntag21x):
|
||||
"""NTAG213 — 180 bytes total, 144 bytes user (pages 04h–27h)."""
|
||||
_total_pages_cls = 45
|
||||
_version_bytes = b"\x00\x04\x04\x02\x01\x00\x0F\x03"
|
||||
_cc_default = b"\xE1\x10\x12\x00"
|
||||
_has_nfc_counter = True
|
||||
_cfg0_page = 0x29
|
||||
_cfg1_page = 0x2A
|
||||
_pwd_page = 0x2B
|
||||
_pack_page = 0x2C
|
||||
|
||||
|
||||
class NTAG215(Ntag21x):
|
||||
"""NTAG215 — 540 bytes total, 504 bytes user (pages 04h–81h)."""
|
||||
_total_pages_cls = 135
|
||||
_version_bytes = b"\x00\x04\x04\x02\x01\x00\x11\x03"
|
||||
_cc_default = b"\xE1\x10\x3E\x00"
|
||||
_has_nfc_counter = True
|
||||
_cfg0_page = 0x83
|
||||
_cfg1_page = 0x84
|
||||
_pwd_page = 0x85
|
||||
_pack_page = 0x86
|
||||
|
||||
|
||||
class NTAG216(Ntag21x):
|
||||
"""NTAG216 — 924 bytes total, 888 bytes user (pages 04h–E1h)."""
|
||||
_total_pages_cls = 231
|
||||
_version_bytes = b"\x00\x04\x04\x02\x01\x00\x13\x03"
|
||||
_cc_default = b"\xE1\x10\x6D\x00"
|
||||
_has_nfc_counter = True
|
||||
_cfg0_page = 0xE3
|
||||
_cfg1_page = 0xE4
|
||||
_pwd_page = 0xE5
|
||||
_pack_page = 0xE6
|
||||
|
||||
|
||||
class NTAG210(Ntag21x):
|
||||
"""NTAG210 — 48 bytes user (pages 04h–0Fh). No NFC counter, no dyn lock."""
|
||||
_total_pages_cls = 20
|
||||
_version_bytes = b"\x00\x04\x04\x01\x01\x00\x0B\x03"
|
||||
_cc_default = b"\xE1\x10\x06\x00"
|
||||
_has_nfc_counter = False
|
||||
_cfg0_page = 0x10
|
||||
_cfg1_page = 0x11
|
||||
_pwd_page = 0x12
|
||||
_pack_page = 0x13
|
||||
|
||||
|
||||
class NTAG212(Ntag21x):
|
||||
"""NTAG212 — 128 bytes user (pages 04h–23h). No NFC counter."""
|
||||
_total_pages_cls = 41
|
||||
_version_bytes = b"\x00\x04\x04\x01\x01\x00\x0E\x03"
|
||||
_cc_default = b"\xE1\x10\x10\x00"
|
||||
_has_nfc_counter = False
|
||||
_cfg0_page = 0x25
|
||||
_cfg1_page = 0x26
|
||||
_pwd_page = 0x27
|
||||
_pack_page = 0x28
|
||||
245
pm3py/transponders/hf/iso14443a/nxp/ntag_i2c.py
Normal file
245
pm3py/transponders/hf/iso14443a/nxp/ntag_i2c.py
Normal file
@@ -0,0 +1,245 @@
|
||||
"""NTAG I2C plus — NXP NT3H2111 (1k) / NT3H2211 (2k).
|
||||
|
||||
Sourced from the NXP datasheet (Rev. 3.5, doc 359935), RF/NFC interface only.
|
||||
|
||||
Distinctive features vs NTAG21x:
|
||||
- Memory addressed in sectors reached via SECTOR_SELECT (0xC2). Sector 0 holds
|
||||
UID/CC/user/config; Sector 1 (2k only) is 1 KB extra user memory; Sector 3
|
||||
mirrors the session registers.
|
||||
- READ does not roll over — a window running into an invalid region reads 00h;
|
||||
an out-of-range start page returns NAK.
|
||||
- FAST_WRITE (0xA6) writes the 64-byte SRAM in one shot (pass-through mode).
|
||||
- Has GET_VERSION, FAST_READ, PWD_AUTH (+PACK), READ_SIG. No NFC counter.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from pm3py.sim.frame import RFFrame
|
||||
from .type2 import (
|
||||
NxpType2Tag, _ack, _nak, NAK_ARG, PWD_AUTH,
|
||||
SECTOR_SELECT, READ_CMD, WRITE_CMD, GET_VERSION, FAST_READ, READ_SIG,
|
||||
)
|
||||
|
||||
FAST_WRITE = 0xA6
|
||||
|
||||
# ACCESS byte (page E4h byte 0) bits
|
||||
NFC_PROT = 0x80
|
||||
NFC_DIS_SEC1 = 0x20
|
||||
|
||||
|
||||
class NtagI2C(NxpType2Tag):
|
||||
"""NTAG I2C plus base. Concrete ICs: NT3H2111 (1k), NT3H2211 (2k)."""
|
||||
|
||||
_is_2k: bool = False
|
||||
_has_cc = True
|
||||
|
||||
# Config / session pages within Sector 0.
|
||||
_cfg0_page = 0xE3 # RFU/RFU/RFU/AUTH0
|
||||
_cfg1_page = 0xE4 # ACCESS/RFU/RFU/RFU
|
||||
_pwd_page = 0xE5
|
||||
_pack_page = 0xE6
|
||||
|
||||
def __init__(self, uid: bytes | None = None, ndef_message: bytes = b"",
|
||||
password: bytes = b"\xFF\xFF\xFF\xFF", pack: bytes = b"\x00\x00",
|
||||
auth0: int = 0xFF, prot: bool = False,
|
||||
signature: bytes | None = None):
|
||||
if uid is None:
|
||||
uid = b"\x04" + os.urandom(6)
|
||||
self._current_sector = 0
|
||||
self._sector1 = bytearray(256 * 4) if self._is_2k else None
|
||||
self._sram = bytearray(64)
|
||||
self._passthru = False
|
||||
super().__init__(uid=uid, password=password, pack=pack, auth0=auth0,
|
||||
prot=prot, signature=signature, cc=self._cc_default,
|
||||
ndef_message=ndef_message, total_pages=256,
|
||||
atqa=b"\x44\x00", sak=0x00)
|
||||
self._init_registers()
|
||||
|
||||
def _init_registers(self) -> None:
|
||||
"""Lay down configuration + session register defaults (Sector 0)."""
|
||||
# Configuration registers (pages E8h/E9h) and session (ECh/EDh).
|
||||
self._set_page(0xE8, bytes([0x01, 0x00, 0xF8, 0x48])) # NC_REG/LAST_NDEF/SRAM_MIRROR/WDT_LS
|
||||
self._set_page(0xE9, bytes([0x08, 0x01, 0x00, 0x00])) # WDT_MS/I2C_CLK_STR/REG_LOCK/RFU
|
||||
self._set_page(0xEC, bytes([0x01, 0x00, 0xF8, 0x48])) # session copy
|
||||
self._set_page(0xED, bytes([0x08, 0x01, 0x00, 0x00])) # WDT_MS/I2C_CLK_STR/NS_REG/RFU
|
||||
self._set_page(0xE7, bytes([0x00, 0x00, 0x00, 0x00])) # PT_I2C
|
||||
|
||||
# ----- sector / page validity -----
|
||||
|
||||
@property
|
||||
def _access(self) -> int:
|
||||
return self._memory[self._cfg1_page * 4]
|
||||
|
||||
def _page_readable(self, sector: int, page: int) -> bool:
|
||||
if page > 0xFF:
|
||||
return False
|
||||
if sector == 0:
|
||||
if page <= 0xE9 or page in (0xEC, 0xED):
|
||||
return True
|
||||
if self._passthru and 0xF0 <= page <= 0xFF:
|
||||
return True
|
||||
return False
|
||||
if sector == 1:
|
||||
if not self._is_2k or (self._access & NFC_DIS_SEC1):
|
||||
return False
|
||||
return True
|
||||
if sector == 3:
|
||||
return page in (0xF8, 0xF9)
|
||||
return False
|
||||
|
||||
def _page_writable(self, sector: int, page: int) -> bool:
|
||||
if sector == 0:
|
||||
return 2 <= page <= 0xE9
|
||||
if sector == 1:
|
||||
return self._is_2k and not (self._access & NFC_DIS_SEC1)
|
||||
return False
|
||||
|
||||
def _page_bytes(self, sector: int, page: int) -> bytes:
|
||||
if sector == 0:
|
||||
return bytes(self._memory[page * 4:page * 4 + 4])
|
||||
if sector == 1 and self._sector1 is not None:
|
||||
return bytes(self._sector1[page * 4:page * 4 + 4])
|
||||
if sector == 3 and page in (0xF8, 0xF9):
|
||||
base = 0xEC + (page - 0xF8)
|
||||
return bytes(self._memory[base * 4:base * 4 + 4])
|
||||
if self._passthru and 0xF0 <= page <= 0xFF and sector == 0:
|
||||
off = (page - 0xF0) * 4
|
||||
return bytes(self._sram[off:off + 4])
|
||||
return b"\x00\x00\x00\x00"
|
||||
|
||||
def _masked_page(self, sector: int, page: int) -> bytes:
|
||||
b = self._page_bytes(sector, page)
|
||||
if sector == 0 and page in (self._pwd_page, self._pack_page):
|
||||
return b"\x00\x00\x00\x00"
|
||||
return b
|
||||
|
||||
# ----- command dispatch -----
|
||||
|
||||
async def power_on(self) -> None:
|
||||
await super().power_on()
|
||||
self._current_sector = 0
|
||||
self._passthru = False
|
||||
|
||||
def _handle_application(self, frame: RFFrame) -> RFFrame | None:
|
||||
if not frame.data:
|
||||
return None
|
||||
if self._pending == "sector_select":
|
||||
return self._handle_sector_select_p2(frame)
|
||||
cmd = frame.data[0]
|
||||
if cmd == SECTOR_SELECT:
|
||||
return self._handle_sector_select_p1(frame)
|
||||
if cmd == FAST_WRITE:
|
||||
return self._handle_fast_write(frame)
|
||||
return super()._handle_application(frame)
|
||||
|
||||
# ----- READ / FAST_READ / WRITE with sector routing, no rollover -----
|
||||
|
||||
def _handle_read(self, frame: RFFrame) -> RFFrame | None:
|
||||
if len(frame.data) < 2:
|
||||
return _nak(NAK_ARG)
|
||||
page = frame.data[1]
|
||||
sec = self._current_sector
|
||||
if not self._page_readable(sec, page):
|
||||
return _nak(NAK_ARG)
|
||||
if self._read_protected(page) and sec == 0:
|
||||
return _nak(NAK_ARG)
|
||||
data = bytearray()
|
||||
for i in range(4):
|
||||
p = page + i
|
||||
if self._page_readable(sec, p):
|
||||
data += self._masked_page(sec, p)
|
||||
else:
|
||||
data += b"\x00\x00\x00\x00"
|
||||
return RFFrame.from_bytes(bytes(data))
|
||||
|
||||
def _handle_fast_read(self, frame: RFFrame) -> RFFrame | None:
|
||||
if len(frame.data) < 3:
|
||||
return _nak(NAK_ARG)
|
||||
start, end = frame.data[1], frame.data[2]
|
||||
sec = self._current_sector
|
||||
if end < start or not self._page_readable(sec, start):
|
||||
return _nak(NAK_ARG)
|
||||
if self._read_protected(start) and sec == 0:
|
||||
return _nak(NAK_ARG)
|
||||
data = bytearray()
|
||||
for p in range(start, end + 1):
|
||||
if self._page_readable(sec, p):
|
||||
data += self._masked_page(sec, p)
|
||||
else:
|
||||
data += b"\x00\x00\x00\x00"
|
||||
return RFFrame.from_bytes(bytes(data))
|
||||
|
||||
def _handle_write(self, frame: RFFrame) -> RFFrame | None:
|
||||
if len(frame.data) < 6:
|
||||
return _nak(NAK_ARG)
|
||||
page = frame.data[1]
|
||||
sec = self._current_sector
|
||||
if not self._page_writable(sec, page):
|
||||
return _nak(NAK_ARG)
|
||||
if sec == 0 and self._write_protected(page):
|
||||
return _nak(NAK_ARG)
|
||||
payload = bytes(frame.data[2:6])
|
||||
if sec == 0:
|
||||
self._apply_page_write(page, payload)
|
||||
elif sec == 1 and self._sector1 is not None:
|
||||
self._sector1[page * 4:page * 4 + 4] = payload
|
||||
return _ack()
|
||||
|
||||
# ----- SECTOR_SELECT (0xC2), two packets -----
|
||||
|
||||
def _handle_sector_select_p1(self, frame: RFFrame) -> RFFrame | None:
|
||||
if len(frame.data) < 2 or frame.data[1] != 0xFF:
|
||||
return _nak(NAK_ARG)
|
||||
self._pending = "sector_select"
|
||||
return _ack()
|
||||
|
||||
def _handle_sector_select_p2(self, frame: RFFrame) -> RFFrame | None:
|
||||
self._pending = None
|
||||
sec = frame.data[0]
|
||||
valid = sec == 0 or sec == 3 or (sec == 1 and self._is_2k)
|
||||
if not valid:
|
||||
return _nak(NAK_ARG)
|
||||
self._current_sector = sec
|
||||
# Success is signalled by passive ACK (>1 ms of silence) — no reply.
|
||||
return None
|
||||
|
||||
# ----- FAST_WRITE (0xA6): 64-byte SRAM (pass-through) -----
|
||||
|
||||
def _handle_fast_write(self, frame: RFFrame) -> RFFrame | None:
|
||||
if len(frame.data) < 3 or frame.data[1] != 0xF0 or frame.data[2] != 0xFF:
|
||||
return _nak(NAK_ARG)
|
||||
data = bytes(frame.data[3:3 + 64])
|
||||
self._sram[:len(data)] = data
|
||||
return _ack()
|
||||
|
||||
def enumerate_responses(self):
|
||||
"""Table entries for the readable Sector 0 pages + GET_VERSION/READ_SIG."""
|
||||
FLAG_CRC = 0x01
|
||||
if self._version_bytes is not None:
|
||||
yield (bytes([GET_VERSION]), self._version_bytes, FLAG_CRC)
|
||||
for page in range(0xEA): # Sector 0 readable user/config span
|
||||
if self._read_protected(page):
|
||||
continue
|
||||
data = bytearray()
|
||||
for i in range(4):
|
||||
data += self._masked_page(0, page + i)
|
||||
yield (bytes([READ_CMD, page]), bytes(data), FLAG_CRC)
|
||||
if self._has_read_sig:
|
||||
yield (bytes([READ_SIG, 0x00]), self._signature, FLAG_CRC)
|
||||
|
||||
|
||||
class NT3H2111(NtagI2C):
|
||||
"""NTAG I2C plus 1k (NT3H2111) — 888 bytes user (Sector 0, pages 04h-E1h)."""
|
||||
_is_2k = False
|
||||
_version_bytes = b"\x00\x04\x04\x05\x02\x02\x13\x03"
|
||||
_cc_default = b"\xE1\x10\x6D\x00" # NFC-Forum recommended CC (datasheet Fig. 11)
|
||||
|
||||
|
||||
class NT3H2211(NtagI2C):
|
||||
"""NTAG I2C plus 2k (NT3H2211) — 888 + 1024 bytes user (Sector 0 + Sector 1)."""
|
||||
_is_2k = True
|
||||
_version_bytes = b"\x00\x04\x04\x05\x02\x02\x15\x03"
|
||||
# The datasheet does not tabulate a 2k CC; E1 10 EA 00 is the value NXP
|
||||
# ships on NT3H2211 (T2T area sized to 1872 bytes). Adjust with set CC if needed.
|
||||
_cc_default = b"\xE1\x10\xEA\x00"
|
||||
395
pm3py/transponders/hf/iso14443a/nxp/type2.py
Normal file
395
pm3py/transponders/hf/iso14443a/nxp/type2.py
Normal file
@@ -0,0 +1,395 @@
|
||||
"""NXP byte-oriented Type 2 tag platform (MIFARE Ultralight / NTAG).
|
||||
|
||||
Shared command handling for the NXP Type 2 family: GET_VERSION, FAST_READ,
|
||||
COMPATIBILITY_WRITE, PWD_AUTH, READ_SIG plus AUTH0-based password protection
|
||||
and the PWD/PACK config-page read-back masking. Concrete ICs (NTAG21x,
|
||||
Ultralight EV1, NTAG I2C) subclass this and add their product-specific bits
|
||||
(NFC counter, incrementable counters, sector select).
|
||||
|
||||
This mirrors the ISO 15693 side, where ``NxpIcodeTag`` centralises the NXP
|
||||
custom command set and per-IC classes extend it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pm3py.sim.frame import RFFrame
|
||||
from pm3py.trace.decode_iso14a import decode_14443a_type2
|
||||
from ..ndef import NfcType2Tag, PAGE_SIZE, READ_CMD, WRITE_CMD
|
||||
|
||||
# ---- Command codes (NFC Forum Type 2 / Ultralight / NTAG) ----
|
||||
GET_VERSION = 0x60
|
||||
FAST_READ = 0x3A
|
||||
COMPAT_WRITE = 0xA0
|
||||
PWD_AUTH = 0x1B
|
||||
READ_CNT = 0x39
|
||||
INCR_CNT = 0xA5
|
||||
CHECK_TEARING = 0x3E
|
||||
READ_SIG = 0x3C
|
||||
SECTOR_SELECT = 0xC2
|
||||
|
||||
# ---- 4-bit ACK / NAK codes (Table 23, NTAG21x datasheet) ----
|
||||
ACK = 0x0A
|
||||
NAK_ARG = 0x00 # invalid argument (invalid page address)
|
||||
NAK_CRC = 0x01 # parity or CRC error
|
||||
NAK_AUTH = 0x04 # invalid authentication counter overflow
|
||||
NAK_WRITE = 0x05 # EEPROM write error
|
||||
|
||||
|
||||
def _ack() -> RFFrame:
|
||||
return RFFrame.from_bytes(bytes([ACK]))
|
||||
|
||||
|
||||
def _nak(code: int = NAK_ARG) -> RFFrame:
|
||||
return RFFrame.from_bytes(bytes([code]))
|
||||
|
||||
|
||||
class NxpType2Tag(NfcType2Tag):
|
||||
"""NXP Type 2 platform: Ultralight EV1 / NTAG21x / NTAG I2C command core.
|
||||
|
||||
Subclasses set the capability/layout class attributes below. Everything
|
||||
that is common — version, fast read, compatibility write, password auth,
|
||||
originality signature, AUTH0 access control — lives here.
|
||||
"""
|
||||
|
||||
# Capabilities (subclasses override)
|
||||
_version_bytes: bytes | None = None # 8-byte GET_VERSION response, None = unsupported
|
||||
_has_fast_read: bool = True
|
||||
_has_read_sig: bool = True
|
||||
_has_pwd_auth: bool = True
|
||||
|
||||
_has_cc: bool = True # False for Ultralight-style parts (page 3 = OTP)
|
||||
_ndef_cc: bytes | None = None # CC stamped into OTP when NDEF-formatting a UL part
|
||||
|
||||
# Config-page indices (subclasses set to real page numbers, None = none)
|
||||
_cfg0_page: int | None = None # MIRROR / RFUI / MIRROR_PAGE / AUTH0
|
||||
_cfg1_page: int | None = None # ACCESS / RFUI / RFUI / RFUI
|
||||
_pwd_page: int | None = None # 4-byte password (reads back as 00)
|
||||
_pack_page: int | None = None # 2-byte PACK + RFUI (reads back as 00)
|
||||
|
||||
def __init__(self, uid: bytes, *,
|
||||
password: bytes = b"\xFF\xFF\xFF\xFF",
|
||||
pack: bytes = b"\x00\x00",
|
||||
auth0: int = 0xFF,
|
||||
prot: bool = False,
|
||||
signature: bytes | None = None,
|
||||
cc: bytes | None = None,
|
||||
ndef_message: bytes = b"",
|
||||
total_pages: int = 45,
|
||||
atqa: bytes = b"\x44\x00", sak: int = 0x00):
|
||||
# Stash product config so the overridden _init_memory() can use it.
|
||||
self._cc = cc
|
||||
self._init_password = password
|
||||
self._init_pack = pack
|
||||
self._init_auth0 = auth0
|
||||
self._init_prot = prot
|
||||
self._signature = (signature or bytes(32))
|
||||
self._authenticated_pwd = False
|
||||
self._pending: str | None = None # two-phase command state
|
||||
self._pending_page: int = 0
|
||||
super().__init__(uid=uid, ndef_message=ndef_message,
|
||||
total_pages=total_pages, atqa=atqa, sak=sak)
|
||||
|
||||
# ----- memory / config initialisation -----
|
||||
|
||||
def _init_memory(self, ndef_message: bytes) -> None:
|
||||
"""Lay out UID/BCC, CC, config pages, then optional NDEF."""
|
||||
self._ndef_data_offset = 4 * PAGE_SIZE
|
||||
self._ndef_capacity = (self._total_pages - 4) * PAGE_SIZE - 3
|
||||
|
||||
# Mirror the 7-byte UID + BCC into pages 0-2 so READ returns it
|
||||
# (page0 = SN0..2+BCC0, page1 = SN3..6, page2 byte0 = BCC1).
|
||||
uid = self._uid
|
||||
if len(uid) == 7:
|
||||
bcc0 = 0x88 ^ uid[0] ^ uid[1] ^ uid[2]
|
||||
bcc1 = uid[3] ^ uid[4] ^ uid[5] ^ uid[6]
|
||||
self._memory[0:4] = bytes(uid[0:3]) + bytes([bcc0])
|
||||
self._memory[4:8] = bytes(uid[3:7])
|
||||
self._memory[8] = bcc1
|
||||
|
||||
# Page 3 — Capability Container (NTAG) or OTP (Ultralight). Only lay
|
||||
# down a CC for parts that carry one; Ultralight-family parts leave
|
||||
# page 3 as the (zeroed) OTP area.
|
||||
if self._has_cc:
|
||||
if self._cc is not None:
|
||||
self._memory[3 * PAGE_SIZE:4 * PAGE_SIZE] = self._cc
|
||||
else:
|
||||
data_size = (self._total_pages - 4) * PAGE_SIZE
|
||||
self._memory[3 * PAGE_SIZE:4 * PAGE_SIZE] = bytes(
|
||||
[0xE1, 0x10, data_size // 8, 0x00])
|
||||
elif ndef_message and self._ndef_cc is not None:
|
||||
# Ultralight parts carry OTP at page 3; NDEF-formatting writes the
|
||||
# product CC there so a phone recognises the tag as Type 2 NDEF.
|
||||
self._memory[3 * PAGE_SIZE:4 * PAGE_SIZE] = self._ndef_cc
|
||||
|
||||
# Config pages
|
||||
if self._pwd_page is not None:
|
||||
self._set_page(self._pwd_page, self._init_password)
|
||||
if self._pack_page is not None:
|
||||
self._set_page(self._pack_page, self._init_pack + b"\x00\x00")
|
||||
if self._cfg0_page is not None:
|
||||
cfg0 = bytearray(self._get_page(self._cfg0_page))
|
||||
cfg0[3] = self._init_auth0 & 0xFF # AUTH0
|
||||
self._set_page(self._cfg0_page, bytes(cfg0))
|
||||
if self._cfg1_page is not None:
|
||||
cfg1 = bytearray(self._get_page(self._cfg1_page))
|
||||
if self._init_prot:
|
||||
cfg1[0] |= 0x80 # ACCESS.PROT
|
||||
self._set_page(self._cfg1_page, bytes(cfg1))
|
||||
|
||||
if ndef_message:
|
||||
self.set_ndef(ndef_message)
|
||||
|
||||
# ----- page helpers -----
|
||||
|
||||
def _get_page(self, page: int) -> bytes:
|
||||
off = page * PAGE_SIZE
|
||||
return bytes(self._memory[off:off + PAGE_SIZE])
|
||||
|
||||
def get_page(self, page: int) -> bytes:
|
||||
return self._get_page(page)
|
||||
|
||||
def _set_page(self, page: int, data: bytes) -> None:
|
||||
off = page * PAGE_SIZE
|
||||
self._memory[off:off + PAGE_SIZE] = data[:PAGE_SIZE].ljust(PAGE_SIZE, b"\x00")
|
||||
|
||||
def set_page(self, page: int, data: bytes) -> None:
|
||||
if len(data) != PAGE_SIZE:
|
||||
raise ValueError(f"Expected {PAGE_SIZE} bytes")
|
||||
self._set_page(page, data)
|
||||
|
||||
def _apply_page_write(self, page: int, data: bytes) -> None:
|
||||
"""Write a page honouring the OR-once bytes.
|
||||
|
||||
Page 2 bytes 0-1 are read-only (UID / internal); bytes 2-3 are the
|
||||
static lock bytes and are bit-wise OR'd. Page 3 (OTP on Ultralight,
|
||||
CC on NTAG) is OR'd whole. Everything else overwrites.
|
||||
"""
|
||||
data = data[:PAGE_SIZE].ljust(PAGE_SIZE, b"\x00")
|
||||
off = page * PAGE_SIZE
|
||||
cur = bytearray(self._memory[off:off + PAGE_SIZE])
|
||||
if page == 2:
|
||||
cur[2] |= data[2]
|
||||
cur[3] |= data[3]
|
||||
elif page == 3:
|
||||
for i in range(PAGE_SIZE):
|
||||
cur[i] |= data[i]
|
||||
else:
|
||||
cur[:] = data
|
||||
self._memory[off:off + PAGE_SIZE] = bytes(cur)
|
||||
|
||||
# ----- config accessors (derived from memory, faithful to the IC) -----
|
||||
|
||||
@property
|
||||
def auth0(self) -> int:
|
||||
if self._cfg0_page is None:
|
||||
return 0xFF
|
||||
return self._memory[self._cfg0_page * PAGE_SIZE + 3]
|
||||
|
||||
@property
|
||||
def memory(self) -> bytes:
|
||||
return bytes(self._memory)
|
||||
|
||||
@property
|
||||
def prot(self) -> bool:
|
||||
if self._cfg1_page is None:
|
||||
return False
|
||||
return bool(self._memory[self._cfg1_page * PAGE_SIZE] & 0x80)
|
||||
|
||||
@property
|
||||
def password(self) -> bytes:
|
||||
if self._pwd_page is None:
|
||||
return b""
|
||||
return self._get_page(self._pwd_page)
|
||||
|
||||
@password.setter
|
||||
def password(self, value: bytes) -> None:
|
||||
if self._pwd_page is not None and len(value) == 4:
|
||||
self._set_page(self._pwd_page, value)
|
||||
|
||||
@property
|
||||
def pack(self) -> bytes:
|
||||
if self._pack_page is None:
|
||||
return b""
|
||||
return self._get_page(self._pack_page)[:2]
|
||||
|
||||
@pack.setter
|
||||
def pack(self, value: bytes) -> None:
|
||||
if self._pack_page is not None and len(value) == 2:
|
||||
self._set_page(self._pack_page, value + b"\x00\x00")
|
||||
|
||||
def _is_config_page(self, page: int) -> bool:
|
||||
return page in (self._pwd_page, self._pack_page)
|
||||
|
||||
# ----- trace decode -----
|
||||
|
||||
def decode_trace(self, direction: int, payload: bytes) -> str | None:
|
||||
return decode_14443a_type2(direction, payload)
|
||||
|
||||
# ----- power / reset -----
|
||||
|
||||
async def power_on(self) -> None:
|
||||
await super().power_on()
|
||||
self._authenticated_pwd = False
|
||||
self._pending = None
|
||||
|
||||
async def power_off(self) -> None:
|
||||
await super().power_off()
|
||||
self._authenticated_pwd = False
|
||||
self._pending = None
|
||||
|
||||
# ----- command dispatch -----
|
||||
|
||||
def _handle_application(self, frame: RFFrame) -> RFFrame | None:
|
||||
if not frame.data:
|
||||
return None
|
||||
|
||||
# Second phase of COMPATIBILITY_WRITE: 16 data bytes (not a command).
|
||||
if self._pending == "compat_write":
|
||||
return self._handle_compat_write_data(frame)
|
||||
|
||||
cmd = frame.data[0]
|
||||
|
||||
if cmd == GET_VERSION and self._version_bytes is not None:
|
||||
return RFFrame.from_bytes(self._version_bytes)
|
||||
if cmd == FAST_READ and self._has_fast_read:
|
||||
return self._handle_fast_read(frame)
|
||||
if cmd == COMPAT_WRITE:
|
||||
return self._handle_compat_write_cmd(frame)
|
||||
if cmd == PWD_AUTH and self._has_pwd_auth:
|
||||
return self._handle_pwd_auth(frame)
|
||||
if cmd == READ_SIG and self._has_read_sig:
|
||||
return self._handle_read_sig(frame)
|
||||
|
||||
# READ / WRITE handled by NfcType2Tag, but with our access rules.
|
||||
if cmd == READ_CMD:
|
||||
return self._handle_read(frame)
|
||||
if cmd == WRITE_CMD:
|
||||
return self._handle_write(frame)
|
||||
|
||||
return None
|
||||
|
||||
# ----- access control -----
|
||||
|
||||
def _read_protected(self, page: int) -> bool:
|
||||
"""Read protection applies only if PROT=1 (read+write protected)."""
|
||||
return (self.prot and not self._authenticated_pwd
|
||||
and page >= self.auth0 and self.auth0 <= self._total_pages)
|
||||
|
||||
def _write_protected(self, page: int) -> bool:
|
||||
return (not self._authenticated_pwd
|
||||
and page >= self.auth0 and self.auth0 <= self._total_pages)
|
||||
|
||||
def _mask_config(self, start_page: int, data: bytearray) -> None:
|
||||
"""Blank out PWD/PACK bytes in a READ window (they never read back)."""
|
||||
for i in range(len(data)):
|
||||
pg = (start_page + i // PAGE_SIZE)
|
||||
# honour rollover
|
||||
pg = pg % self._total_pages
|
||||
if self._is_config_page(pg):
|
||||
data[i] = 0x00
|
||||
|
||||
# ----- READ (override to add protection + config masking) -----
|
||||
|
||||
def _handle_read(self, frame: RFFrame) -> RFFrame | None:
|
||||
if len(frame.data) < 2:
|
||||
return None
|
||||
page = frame.data[1]
|
||||
if page >= self._total_pages:
|
||||
return _nak(NAK_ARG)
|
||||
if self._read_protected(page):
|
||||
return _nak(NAK_ARG)
|
||||
data = bytearray(16)
|
||||
for i in range(16):
|
||||
offset = (page * PAGE_SIZE + i) % len(self._memory)
|
||||
data[i] = self._memory[offset]
|
||||
self._mask_config(page, data)
|
||||
return RFFrame.from_bytes(bytes(data))
|
||||
|
||||
def _handle_fast_read(self, frame: RFFrame) -> RFFrame | None:
|
||||
"""FAST_READ (0x3A): return pages start..end inclusive."""
|
||||
if len(frame.data) < 3:
|
||||
return _nak(NAK_ARG)
|
||||
start = frame.data[1]
|
||||
end = frame.data[2]
|
||||
if start >= self._total_pages or end >= self._total_pages or end < start:
|
||||
return _nak(NAK_ARG)
|
||||
if self._read_protected(start) or self._read_protected(end):
|
||||
return _nak(NAK_ARG)
|
||||
data = bytearray(self._memory[start * PAGE_SIZE:(end + 1) * PAGE_SIZE])
|
||||
self._mask_config(start, data)
|
||||
return RFFrame.from_bytes(bytes(data))
|
||||
|
||||
# ----- WRITE (override to add protection) -----
|
||||
|
||||
def _handle_write(self, frame: RFFrame) -> RFFrame | None:
|
||||
if len(frame.data) < 6:
|
||||
return _nak(NAK_ARG)
|
||||
page = frame.data[1]
|
||||
if page < 2 or page >= self._total_pages:
|
||||
return _nak(NAK_ARG)
|
||||
if self._write_protected(page):
|
||||
return _nak(NAK_ARG)
|
||||
self._apply_page_write(page, bytes(frame.data[2:6]))
|
||||
return _ack()
|
||||
|
||||
# ----- COMPATIBILITY_WRITE (0xA0), two-phase -----
|
||||
|
||||
def _handle_compat_write_cmd(self, frame: RFFrame) -> RFFrame | None:
|
||||
if len(frame.data) < 2:
|
||||
return _nak(NAK_ARG)
|
||||
page = frame.data[1]
|
||||
if page < 2 or page >= self._total_pages:
|
||||
return _nak(NAK_ARG)
|
||||
if self._write_protected(page):
|
||||
return _nak(NAK_ARG)
|
||||
self._pending = "compat_write"
|
||||
self._pending_page = page
|
||||
return _ack()
|
||||
|
||||
def _handle_compat_write_data(self, frame: RFFrame) -> RFFrame | None:
|
||||
self._pending = None
|
||||
if len(frame.data) < 4:
|
||||
return _nak(NAK_ARG)
|
||||
# Only the least-significant 4 bytes are written (rest must be 00).
|
||||
self._apply_page_write(self._pending_page, bytes(frame.data[:4]))
|
||||
return _ack()
|
||||
|
||||
# ----- PWD_AUTH (0x1B) -----
|
||||
|
||||
def _handle_pwd_auth(self, frame: RFFrame) -> RFFrame | None:
|
||||
if len(frame.data) < 5:
|
||||
return _nak(NAK_ARG)
|
||||
supplied = bytes(frame.data[1:5])
|
||||
if self._pwd_page is None or supplied != self.password:
|
||||
return _nak(NAK_AUTH)
|
||||
self._authenticated_pwd = True
|
||||
return RFFrame.from_bytes(self.pack)
|
||||
|
||||
# ----- READ_SIG (0x3C) -----
|
||||
|
||||
def _handle_read_sig(self, frame: RFFrame) -> RFFrame | None:
|
||||
return RFFrame.from_bytes(self._signature)
|
||||
|
||||
# ----- table compilation hook -----
|
||||
|
||||
def enumerate_responses(self):
|
||||
"""Yield (cmd_bytes, response_bytes, flags) for the firmware table.
|
||||
|
||||
Covers the static, stateless commands a phone issues before any
|
||||
password/auth interaction: GET_VERSION, per-page READ, FAST_READ of
|
||||
the whole memory, READ_SIG. Stateful commands (PWD_AUTH, counters,
|
||||
writes) are served by the tag model over the WTX/retry relay.
|
||||
"""
|
||||
FLAG_CRC = 0x01
|
||||
if self._version_bytes is not None:
|
||||
yield (bytes([GET_VERSION]), self._version_bytes, FLAG_CRC)
|
||||
# READ for each unprotected page (16 bytes, rolling over).
|
||||
for page in range(self._total_pages):
|
||||
if self._read_protected(page):
|
||||
continue
|
||||
data = bytearray(16)
|
||||
for i in range(16):
|
||||
data[i] = self._memory[(page * PAGE_SIZE + i) % len(self._memory)]
|
||||
self._mask_config(page, data)
|
||||
yield (bytes([READ_CMD, page]), bytes(data), FLAG_CRC)
|
||||
if self._has_read_sig:
|
||||
yield (bytes([READ_SIG, 0x00]), self._signature, FLAG_CRC)
|
||||
265
pm3py/transponders/hf/iso14443a/nxp/ultralight.py
Normal file
265
pm3py/transponders/hf/iso14443a/nxp/ultralight.py
Normal file
@@ -0,0 +1,265 @@
|
||||
"""MIFARE Ultralight family — original, Ultralight C, Ultralight EV1.
|
||||
|
||||
Sourced from the NXP datasheets:
|
||||
- MF0ICU2 = MIFARE Ultralight C (Rev. 3.5) — 3DES AUTHENTICATE, 16-bit counter.
|
||||
- MF0ULX1 = MIFARE Ultralight EV1 (Rev. 3.3) — MF0UL11 / MF0UL21.
|
||||
The original MIFARE Ultralight (MF0ICU1) datasheet was not supplied; MifareUltralight
|
||||
below models its well-established behaviour (16 pages, READ/WRITE/COMPAT_WRITE, OTP).
|
||||
|
||||
All three share the Type 2 command core in ``type2.NxpType2Tag`` (READ, WRITE,
|
||||
COMPATIBILITY_WRITE, OTP/lock OR-on-write). Product-specific pieces are added here.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from pm3py.sim.frame import RFFrame
|
||||
from .type2 import (
|
||||
NxpType2Tag, _ack, _nak, NAK_ARG, NAK_AUTH,
|
||||
READ_CNT, INCR_CNT, CHECK_TEARING, READ_CMD, WRITE_CMD,
|
||||
)
|
||||
|
||||
AUTHENTICATE = 0x1A # Ultralight C 3DES authenticate
|
||||
AUTH_CONT = 0xAF # continuation byte (part 2)
|
||||
|
||||
# NXP factory default Ultralight C 3DES key (K1||K2, 16 bytes)
|
||||
UL_C_DEFAULT_KEY = bytes.fromhex("49454D4B41455242214E4143554F5946")
|
||||
|
||||
|
||||
def _rotl1(b: bytes) -> bytes:
|
||||
"""Rotate an 8-byte block left by one byte."""
|
||||
return b[1:] + b[:1]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Original MIFARE Ultralight (MF0ICU1)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class MifareUltralight(NxpType2Tag):
|
||||
"""Original MIFARE Ultralight (MF0ICU1) — 64 bytes, 16 pages.
|
||||
|
||||
Pages 0-1 UID, page 2 internal + static lock, page 3 OTP, pages 4-15 user.
|
||||
Commands: READ (0x30), WRITE (0xA2), COMPATIBILITY_WRITE (0xA0). No
|
||||
GET_VERSION / FAST_READ / PWD_AUTH — those are EV1 features.
|
||||
"""
|
||||
_version_bytes = None
|
||||
_has_fast_read = False
|
||||
_has_read_sig = False
|
||||
_has_pwd_auth = False
|
||||
_has_cc = False
|
||||
_ndef_cc = b"\xE1\x10\x06\x00" # 48 bytes user
|
||||
|
||||
def __init__(self, uid: bytes | None = None, ndef_message: bytes = b""):
|
||||
if uid is None:
|
||||
uid = b"\x04" + os.urandom(6)
|
||||
super().__init__(uid=uid, ndef_message=ndef_message, total_pages=16,
|
||||
atqa=b"\x44\x00", sak=0x00)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# MIFARE Ultralight C (MF0ICU2)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class MifareUltralightC(NxpType2Tag):
|
||||
"""MIFARE Ultralight C (MF0ICU2) — 192 bytes, 48 pages, 3DES auth.
|
||||
|
||||
Layout: page 3 OTP, pages 04h-27h user, page 28h lock bytes 2/3,
|
||||
page 29h 16-bit counter, page 2Ah AUTH0, page 2Bh AUTH1, pages 2Ch-2Fh
|
||||
16-byte 3DES key. Protection from AUTH0 onward is gated by a successful
|
||||
3DES AUTHENTICATE (0x1A) rather than PWD_AUTH.
|
||||
"""
|
||||
_version_bytes = None
|
||||
_has_fast_read = False
|
||||
_has_read_sig = False
|
||||
_has_pwd_auth = False
|
||||
_has_cc = False
|
||||
_ndef_cc = b"\xE1\x10\x12\x00" # 144 bytes user
|
||||
|
||||
AUTH0_PAGE = 0x2A
|
||||
AUTH1_PAGE = 0x2B
|
||||
KEY_PAGE = 0x2C # 4 pages: 2C..2F
|
||||
COUNTER_PAGE = 0x29
|
||||
|
||||
def __init__(self, uid: bytes | None = None, ndef_message: bytes = b"",
|
||||
key: bytes = UL_C_DEFAULT_KEY, auth0: int = 0x30,
|
||||
auth1_read_protect: bool = True):
|
||||
if uid is None:
|
||||
uid = b"\x04" + os.urandom(6)
|
||||
self._auth_state = "NONE" # NONE, WAIT_RESP, AUTHENTICATED
|
||||
self._rnd_b = b""
|
||||
self._resp_iv = b""
|
||||
super().__init__(uid=uid, ndef_message=ndef_message, total_pages=48,
|
||||
atqa=b"\x44\x00", sak=0x00)
|
||||
# Install key + AUTH0/AUTH1 into their pages.
|
||||
for i in range(4):
|
||||
self._set_page(self.KEY_PAGE + i, key[i * 4:i * 4 + 4])
|
||||
self._set_page(self.AUTH0_PAGE, bytes([auth0, 0, 0, 0]))
|
||||
self._set_page(self.AUTH1_PAGE, bytes([0x00 if auth1_read_protect else 0x01, 0, 0, 0]))
|
||||
self._counter = 0
|
||||
|
||||
@property
|
||||
def key(self) -> bytes:
|
||||
return b"".join(self._get_page(self.KEY_PAGE + i) for i in range(4))
|
||||
|
||||
@property
|
||||
def auth0(self) -> int:
|
||||
return self._get_page(self.AUTH0_PAGE)[0]
|
||||
|
||||
@property
|
||||
def _auth1_read_protected(self) -> bool:
|
||||
return (self._get_page(self.AUTH1_PAGE)[0] & 0x01) == 0x00
|
||||
|
||||
async def power_on(self) -> None:
|
||||
await super().power_on()
|
||||
self._auth_state = "NONE"
|
||||
|
||||
# UL-C protection is gated by 3DES auth, not PWD_AUTH.
|
||||
def _read_protected(self, page: int) -> bool:
|
||||
return (self._auth1_read_protected and self._auth_state != "AUTHENTICATED"
|
||||
and page >= self.auth0 and self.auth0 <= self._total_pages)
|
||||
|
||||
def _write_protected(self, page: int) -> bool:
|
||||
return (self._auth_state != "AUTHENTICATED"
|
||||
and page >= self.auth0 and self.auth0 <= self._total_pages)
|
||||
|
||||
def _is_config_page(self, page: int) -> bool:
|
||||
# The 3DES key never reads back.
|
||||
return self.KEY_PAGE <= page < self.KEY_PAGE + 4
|
||||
|
||||
def _handle_application(self, frame: RFFrame) -> RFFrame | None:
|
||||
if not frame.data:
|
||||
return None
|
||||
# Second phase of AUTHENTICATE (reader sends 0xAF + 16 bytes).
|
||||
if self._auth_state == "WAIT_RESP" and frame.data[0] == AUTH_CONT:
|
||||
return self._handle_auth_part2(frame)
|
||||
if frame.data[0] == AUTHENTICATE:
|
||||
return self._handle_auth_part1(frame)
|
||||
if frame.data[0] == WRITE_CMD and len(frame.data) >= 2 and frame.data[1] == self.COUNTER_PAGE:
|
||||
return self._handle_counter_write(frame)
|
||||
return super()._handle_application(frame)
|
||||
|
||||
# ----- 16-bit one-way counter (page 0x29) -----
|
||||
|
||||
def _handle_counter_write(self, frame: RFFrame) -> RFFrame | None:
|
||||
"""WRITE to the counter page increments the 16-bit one-way counter."""
|
||||
if len(frame.data) < 6:
|
||||
return _nak(NAK_ARG)
|
||||
incr = frame.data[2] | (frame.data[3] << 8)
|
||||
self._counter = min(0xFFFF, self._counter + incr)
|
||||
self._set_page(self.COUNTER_PAGE,
|
||||
bytes([self._counter & 0xFF, (self._counter >> 8) & 0xFF, 0, 0]))
|
||||
return _ack()
|
||||
|
||||
# ----- 3DES AUTHENTICATE (0x1A / 0xAF) -----
|
||||
|
||||
def _handle_auth_part1(self, frame: RFFrame) -> RFFrame | None:
|
||||
"""AUTHENTICATE part 1: tag returns ek(RndB)."""
|
||||
try:
|
||||
from Crypto.Cipher import DES3
|
||||
except ImportError:
|
||||
return _nak(NAK_AUTH)
|
||||
self._rnd_b = os.urandom(8)
|
||||
cipher = DES3.new(self.key, DES3.MODE_CBC, iv=bytes(8))
|
||||
ek_rnd_b = cipher.encrypt(self._rnd_b)
|
||||
self._resp_iv = ek_rnd_b # IV that the reader will chain from
|
||||
self._auth_state = "WAIT_RESP"
|
||||
return RFFrame.from_bytes(bytes([AUTH_CONT]) + ek_rnd_b)
|
||||
|
||||
def _handle_auth_part2(self, frame: RFFrame) -> RFFrame | None:
|
||||
"""AUTHENTICATE part 2: verify reader token, return ek(RndA')."""
|
||||
from Crypto.Cipher import DES3
|
||||
token = bytes(frame.data[1:17])
|
||||
if len(token) != 16:
|
||||
self._auth_state = "NONE"
|
||||
return _nak(NAK_AUTH)
|
||||
cipher = DES3.new(self.key, DES3.MODE_CBC, iv=self._resp_iv)
|
||||
plain = cipher.decrypt(token)
|
||||
rnd_a = plain[0:8]
|
||||
rnd_b_prime = plain[8:16]
|
||||
if rnd_b_prime != _rotl1(self._rnd_b):
|
||||
self._auth_state = "NONE"
|
||||
return _nak(NAK_AUTH)
|
||||
# Success: return ek(RndA'), IV chains from last received cipher block.
|
||||
enc = DES3.new(self.key, DES3.MODE_CBC, iv=token[8:16])
|
||||
ek_rnd_a_prime = enc.encrypt(_rotl1(rnd_a))
|
||||
self._auth_state = "AUTHENTICATED"
|
||||
return RFFrame.from_bytes(bytes([0x00]) + ek_rnd_a_prime)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# MIFARE Ultralight EV1 (MF0UL11 / MF0UL21)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class MifareUltralightEV1(NxpType2Tag):
|
||||
"""MIFARE Ultralight EV1 base — GET_VERSION, FAST_READ, PWD_AUTH, READ_SIG,
|
||||
plus three independent 24-bit one-way counters (READ_CNT / INCR_CNT /
|
||||
CHECK_TEARING_EVENT). Concrete ICs: MF0UL11, MF0UL21."""
|
||||
|
||||
_has_cc = False # page 3 is OTP; format for NDEF to add a CC
|
||||
|
||||
def __init__(self, uid: bytes | None = None, ndef_message: bytes = b"",
|
||||
password: bytes = b"\xFF\xFF\xFF\xFF", pack: bytes = b"\x00\x00",
|
||||
auth0: int = 0xFF, prot: bool = False,
|
||||
signature: bytes | None = None):
|
||||
if uid is None:
|
||||
uid = b"\x04" + os.urandom(6)
|
||||
self._counters = [0, 0, 0]
|
||||
self._tearing = [0xBD, 0xBD, 0xBD] # BD = no tearing event
|
||||
super().__init__(uid=uid, password=password, pack=pack, auth0=auth0,
|
||||
prot=prot, signature=signature, cc=None,
|
||||
ndef_message=ndef_message,
|
||||
total_pages=self._total_pages_cls,
|
||||
atqa=b"\x44\x00", sak=0x00)
|
||||
|
||||
def _handle_application(self, frame: RFFrame) -> RFFrame | None:
|
||||
if frame.data:
|
||||
cmd = frame.data[0]
|
||||
if cmd == READ_CNT:
|
||||
return self._handle_read_cnt(frame)
|
||||
if cmd == INCR_CNT:
|
||||
return self._handle_incr_cnt(frame)
|
||||
if cmd == CHECK_TEARING:
|
||||
return self._handle_check_tearing(frame)
|
||||
return super()._handle_application(frame)
|
||||
|
||||
def _handle_read_cnt(self, frame: RFFrame) -> RFFrame | None:
|
||||
if len(frame.data) < 2 or frame.data[1] > 2:
|
||||
return _nak(NAK_ARG)
|
||||
return RFFrame.from_bytes(self._counters[frame.data[1]].to_bytes(3, "little"))
|
||||
|
||||
def _handle_incr_cnt(self, frame: RFFrame) -> RFFrame | None:
|
||||
if len(frame.data) < 6 or frame.data[1] > 2:
|
||||
return _nak(NAK_ARG)
|
||||
n = frame.data[1]
|
||||
incr = frame.data[2] | (frame.data[3] << 8) | (frame.data[4] << 16)
|
||||
if self._counters[n] + incr > 0xFFFFFF:
|
||||
return _nak(NAK_AUTH) # counter overflow (NAK 0x4)
|
||||
self._counters[n] += incr
|
||||
return _ack()
|
||||
|
||||
def _handle_check_tearing(self, frame: RFFrame) -> RFFrame | None:
|
||||
if len(frame.data) < 2 or frame.data[1] > 2:
|
||||
return _nak(NAK_ARG)
|
||||
return RFFrame.from_bytes(bytes([self._tearing[frame.data[1]]]))
|
||||
|
||||
|
||||
class MF0UL11(MifareUltralightEV1):
|
||||
"""MIFARE Ultralight EV1 MF0UL11 — 48 bytes user (pages 04h-0Fh)."""
|
||||
_total_pages_cls = 20
|
||||
_version_bytes = b"\x00\x04\x03\x01\x01\x00\x0B\x03"
|
||||
_ndef_cc = b"\xE1\x10\x06\x00" # 48 bytes user
|
||||
_cfg0_page = 0x10
|
||||
_cfg1_page = 0x11
|
||||
_pwd_page = 0x12
|
||||
_pack_page = 0x13
|
||||
|
||||
|
||||
class MF0UL21(MifareUltralightEV1):
|
||||
"""MIFARE Ultralight EV1 MF0UL21 — 128 bytes user (pages 04h-23h)."""
|
||||
_total_pages_cls = 41
|
||||
_version_bytes = b"\x00\x04\x03\x01\x01\x00\x0E\x03"
|
||||
_ndef_cc = b"\xE1\x10\x10\x00" # 128 bytes user
|
||||
_cfg0_page = 0x25
|
||||
_cfg1_page = 0x26
|
||||
_pwd_page = 0x27
|
||||
_pack_page = 0x28
|
||||
1
pm3py/transponders/hf/iso14443a/st/__init__.py
Normal file
1
pm3py/transponders/hf/iso14443a/st/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""STMicroelectronics ISO 14443-A transponders (ST25TA Type 4, ST25TN Type 2)."""
|
||||
353
pm3py/transponders/hf/iso14443a/st/st25ta.py
Normal file
353
pm3py/transponders/hf/iso14443a/st/st25ta.py
Normal file
@@ -0,0 +1,353 @@
|
||||
"""ST25TA512B / ST25TA02KB / ST25TA16K / ST25TA64K — ST NFC Forum Type 4 tags.
|
||||
|
||||
Sourced from ST datasheets DS12365 Rev 2 (ST25TA512B/02KB) and DocID027786 Rev 4
|
||||
(ST25TA16K). ISO/IEC 14443 Type A, ISO-DEP, NDEF via ISO 7816 APDUs.
|
||||
|
||||
Three files: System (0xE101), CC (0xE103), NDEF (0x0001). Adds over the generic
|
||||
NfcType4Tag: the ST System file, the exact ST CC contents, 128-bit Read/Write
|
||||
password protection (VERIFY / Enable / Disable / Change Reference Data), the
|
||||
20-bit anti-tearing event counter (02KB family) and ExtendedReadBinary (CLA A2h).
|
||||
|
||||
ST25TA64K is not covered by a supplied datasheet; its constants below are
|
||||
extrapolated from the family (FLEN=0x2000) — flagged in the class docstring.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import struct
|
||||
|
||||
from pm3py.sim.frame import RFFrame
|
||||
from ..ndef import NfcType4Tag
|
||||
from ..base import State14443A
|
||||
|
||||
# File IDs
|
||||
SYSTEM_FILE_ID = b"\xE1\x01"
|
||||
CC_FILE_ID = b"\xE1\x03"
|
||||
NDEF_FILE_ID = b"\x00\x01"
|
||||
NDEF_AID = b"\xD2\x76\x00\x00\x85\x01\x01"
|
||||
|
||||
# ISO 7816 instructions
|
||||
INS_SELECT = 0xA4
|
||||
INS_READ_BINARY = 0xB0
|
||||
INS_UPDATE_BINARY = 0xD6
|
||||
INS_VERIFY = 0x20
|
||||
INS_CHANGE_REF_DATA = 0x24
|
||||
INS_DISABLE_VERIF = 0x26
|
||||
INS_ENABLE_VERIF = 0x28
|
||||
|
||||
CLA_STD = 0x00
|
||||
CLA_ST = 0xA2
|
||||
|
||||
# Status words
|
||||
SW_OK = b"\x90\x00"
|
||||
SW_PWD_REQUIRED = b"\x63\x00"
|
||||
SW_FILE_NOT_FOUND = b"\x6A\x82"
|
||||
SW_WRONG_P1P2 = b"\x6A\x86"
|
||||
SW_WRONG_LENGTH = b"\x67\x00"
|
||||
SW_SEC_NOT_SATISFIED = b"\x69\x82"
|
||||
SW_REF_NOT_USABLE = b"\x69\x84"
|
||||
SW_COND_NOT_SATISFIED = b"\x69\x85"
|
||||
SW_WRONG_LE_LC = b"\x6A\x80"
|
||||
|
||||
|
||||
class ST25TA(NfcType4Tag):
|
||||
"""Base for the ST25TA family. Concrete ICs set the class attributes."""
|
||||
|
||||
_cc_bytes: bytes = b"" # 15-byte CC file
|
||||
_max_ndef: int = 256 # NDEF file size (FLEN)
|
||||
_ic_ref: int = 0x00
|
||||
_product_code: int = 0x00 # UID byte1
|
||||
_ats_bytes: bytes = b"\x05\x75\x80\x60\x02"
|
||||
_has_counter: bool = False # 20-bit event counter (02KB family)
|
||||
|
||||
def __init__(self, uid: bytes | None = None, ndef_message: bytes = b"",
|
||||
read_password: bytes | None = None,
|
||||
write_password: bytes | None = None):
|
||||
if uid is None:
|
||||
uid = bytes([0x02, self._product_code]) + os.urandom(5)
|
||||
# Event counter state (read by _build_files inside super().__init__).
|
||||
self._counter = 0
|
||||
self._counter_cfg = 0x00 # b1 enable, b0 source(0=read,1=write)
|
||||
self._counter_bumped = False
|
||||
super().__init__(uid=uid, ndef_message=ndef_message,
|
||||
max_ndef_size=self._max_ndef,
|
||||
atqa=b"\x44\x00", sak=0x20, ats=self._ats_bytes)
|
||||
# 16-byte passwords; None → protection disabled for that operation.
|
||||
self._read_password = read_password
|
||||
self._write_password = write_password
|
||||
self._read_verified = read_password is None
|
||||
self._write_verified = write_password is None
|
||||
self._read_retries = 3
|
||||
self._write_retries = 3
|
||||
|
||||
# ----- file contents -----
|
||||
|
||||
def _build_files(self) -> None:
|
||||
self._cc_file = self._cc_bytes
|
||||
self._update_ndef_file()
|
||||
self._system_file = self._build_system_file()
|
||||
|
||||
def _build_system_file(self) -> bytes:
|
||||
sf = bytearray(0x12)
|
||||
struct.pack_into(">H", sf, 0x00, 0x12) # length
|
||||
sf[0x02] = 0x80 # GPO / ST reserved
|
||||
if self._has_counter:
|
||||
sf[0x03] = self._counter_cfg
|
||||
sf[0x08:0x0F] = self._uid # 7-byte UID
|
||||
struct.pack_into(">H", sf, 0x0F, self._max_ndef - 1) # memory size - 1
|
||||
sf[0x11] = self._ic_ref
|
||||
return bytes(sf)
|
||||
|
||||
def _current_system_file(self) -> bytes:
|
||||
sf = bytearray(self._build_system_file())
|
||||
if self._has_counter:
|
||||
sf[0x04:0x07] = self._counter.to_bytes(3, "big")
|
||||
return bytes(sf)
|
||||
|
||||
# ----- ISO-DEP reset -----
|
||||
|
||||
async def power_on(self) -> None:
|
||||
await super().power_on()
|
||||
self._app_selected = False
|
||||
self._selected_file = None
|
||||
self._read_verified = self._read_password is None
|
||||
self._write_verified = self._write_password is None
|
||||
self._read_retries = 3
|
||||
self._write_retries = 3
|
||||
self._counter_bumped = False
|
||||
|
||||
def _handle_rats(self, frame: RFFrame) -> RFFrame | None:
|
||||
# App/file selection state resets on a new activation.
|
||||
self._app_selected = False
|
||||
self._selected_file = None
|
||||
self._counter_bumped = False
|
||||
return super()._handle_rats(frame)
|
||||
|
||||
# ----- APDU dispatch -----
|
||||
|
||||
def _handle_apdu(self, apdu: bytes) -> bytes:
|
||||
if len(apdu) < 4: # need at least CLA INS P1 P2
|
||||
return SW_WRONG_LENGTH
|
||||
cla, ins = apdu[0], apdu[1]
|
||||
|
||||
if cla == CLA_ST and ins == INS_READ_BINARY:
|
||||
return self._handle_read_binary(apdu, extended=True)
|
||||
if cla != CLA_STD and cla != CLA_ST:
|
||||
return b"\x6E\x00" # class not supported
|
||||
if ins == INS_SELECT:
|
||||
return self._handle_select(apdu)
|
||||
if ins == INS_READ_BINARY:
|
||||
return self._handle_read_binary(apdu)
|
||||
if ins == INS_UPDATE_BINARY:
|
||||
return self._handle_update_binary(apdu)
|
||||
if ins == INS_VERIFY:
|
||||
return self._handle_verify(apdu)
|
||||
if ins == INS_ENABLE_VERIF:
|
||||
return self._handle_toggle_verif(apdu, enable=True)
|
||||
if ins == INS_DISABLE_VERIF:
|
||||
return self._handle_toggle_verif(apdu, enable=False)
|
||||
if ins == INS_CHANGE_REF_DATA:
|
||||
return self._handle_change_ref(apdu)
|
||||
return b"\x6D\x00" # INS not supported
|
||||
|
||||
def _handle_select(self, apdu: bytes) -> bytes:
|
||||
p1, p2 = apdu[2], apdu[3]
|
||||
lc = apdu[4] if len(apdu) > 4 else 0
|
||||
data = apdu[5:5 + lc] if lc else b""
|
||||
|
||||
if p1 == 0x04: # select by AID
|
||||
if data == NDEF_AID:
|
||||
self._app_selected = True
|
||||
self._selected_file = None
|
||||
return SW_OK
|
||||
return SW_FILE_NOT_FOUND
|
||||
if p1 == 0x00 and p2 == 0x0C: # select by file id
|
||||
if not self._app_selected:
|
||||
return SW_FILE_NOT_FOUND
|
||||
if data in (CC_FILE_ID, NDEF_FILE_ID, SYSTEM_FILE_ID):
|
||||
self._selected_file = data
|
||||
return SW_OK
|
||||
return SW_FILE_NOT_FOUND
|
||||
return SW_WRONG_P1P2
|
||||
|
||||
def _selected_bytes(self) -> bytes | None:
|
||||
if self._selected_file == CC_FILE_ID:
|
||||
return self._cc_file
|
||||
if self._selected_file == NDEF_FILE_ID:
|
||||
return self._ndef_file
|
||||
if self._selected_file == SYSTEM_FILE_ID:
|
||||
return self._current_system_file()
|
||||
return None
|
||||
|
||||
def _handle_read_binary(self, apdu: bytes, extended: bool = False) -> bytes:
|
||||
content = self._selected_bytes()
|
||||
if content is None:
|
||||
return SW_FILE_NOT_FOUND
|
||||
if self._selected_file == NDEF_FILE_ID and not self._read_verified:
|
||||
return SW_SEC_NOT_SATISFIED
|
||||
offset = (apdu[2] << 8) | apdu[3]
|
||||
le = apdu[4] if len(apdu) > 4 else 0
|
||||
if offset >= len(content):
|
||||
return SW_WRONG_P1P2
|
||||
self._maybe_bump_counter(is_write=False)
|
||||
return content[offset:offset + le] + SW_OK
|
||||
|
||||
def _handle_update_binary(self, apdu: bytes) -> bytes:
|
||||
if self._selected_file != NDEF_FILE_ID:
|
||||
return SW_FILE_NOT_FOUND
|
||||
if not self._write_verified:
|
||||
return SW_SEC_NOT_SATISFIED
|
||||
offset = (apdu[2] << 8) | apdu[3]
|
||||
lc = apdu[4] if len(apdu) > 4 else 0
|
||||
data = apdu[5:5 + lc]
|
||||
if offset + len(data) > len(self._ndef_file):
|
||||
return SW_WRONG_LE_LC # write past end of file
|
||||
ndef = bytearray(self._ndef_file)
|
||||
ndef[offset:offset + len(data)] = data
|
||||
self._ndef_file = bytes(ndef)
|
||||
self._maybe_bump_counter(is_write=True)
|
||||
return SW_OK
|
||||
|
||||
# ----- password protection -----
|
||||
|
||||
def _pwd_for(self, p2: int):
|
||||
if p2 == 0x01:
|
||||
return "read"
|
||||
if p2 == 0x02:
|
||||
return "write"
|
||||
return None
|
||||
|
||||
def _handle_verify(self, apdu: bytes) -> bytes:
|
||||
p2 = apdu[3]
|
||||
which = self._pwd_for(p2)
|
||||
if which is None:
|
||||
return SW_WRONG_LE_LC
|
||||
lc = apdu[4] if len(apdu) > 4 else 0
|
||||
pwd = self._read_password if which == "read" else self._write_password
|
||||
verified_attr = "_read_verified" if which == "read" else "_write_verified"
|
||||
|
||||
if lc == 0: # query: is a password required?
|
||||
if pwd is None:
|
||||
return SW_OK
|
||||
return SW_OK if getattr(self, verified_attr) else SW_PWD_REQUIRED
|
||||
if lc != 0x10:
|
||||
return SW_WRONG_LE_LC
|
||||
if pwd is None:
|
||||
return SW_OK
|
||||
retries_attr = "_read_retries" if which == "read" else "_write_retries"
|
||||
if getattr(self, retries_attr) == 0:
|
||||
return SW_COND_NOT_SATISFIED
|
||||
supplied = apdu[5:5 + 0x10]
|
||||
if supplied == pwd:
|
||||
setattr(self, verified_attr, True)
|
||||
setattr(self, retries_attr, 3)
|
||||
return SW_OK
|
||||
n = getattr(self, retries_attr) - 1
|
||||
setattr(self, retries_attr, n)
|
||||
return bytes([0x63, 0xC0 | n])
|
||||
|
||||
def _handle_toggle_verif(self, apdu: bytes, enable: bool) -> bytes:
|
||||
which = self._pwd_for(apdu[3])
|
||||
if which is None:
|
||||
return SW_WRONG_LE_LC
|
||||
if enable:
|
||||
if which == "read":
|
||||
self._read_password = self._read_password or b"\x00" * 16
|
||||
self._read_verified = False
|
||||
else:
|
||||
self._write_password = self._write_password or b"\x00" * 16
|
||||
self._write_verified = False
|
||||
else:
|
||||
# disabling requires the write password to have been verified
|
||||
if not self._write_verified:
|
||||
return SW_SEC_NOT_SATISFIED
|
||||
if which == "read":
|
||||
self._read_password = None
|
||||
self._read_verified = True
|
||||
else:
|
||||
self._write_password = None
|
||||
self._write_verified = True
|
||||
return SW_OK
|
||||
|
||||
def _handle_change_ref(self, apdu: bytes) -> bytes:
|
||||
which = self._pwd_for(apdu[3])
|
||||
if which is None:
|
||||
return SW_WRONG_LE_LC
|
||||
if not self._write_verified:
|
||||
return SW_SEC_NOT_SATISFIED
|
||||
lc = apdu[4] if len(apdu) > 4 else 0
|
||||
if lc != 0x10:
|
||||
return SW_WRONG_LE_LC
|
||||
new_pwd = apdu[5:5 + 0x10]
|
||||
if which == "read":
|
||||
self._read_password = new_pwd
|
||||
else:
|
||||
self._write_password = new_pwd
|
||||
return SW_OK
|
||||
|
||||
# ----- event counter -----
|
||||
|
||||
def _maybe_bump_counter(self, is_write: bool) -> None:
|
||||
if not self._has_counter or not (self._counter_cfg & 0x02):
|
||||
return
|
||||
source_is_write = bool(self._counter_cfg & 0x01)
|
||||
if is_write != source_is_write:
|
||||
return
|
||||
if not self._counter_bumped:
|
||||
self._counter_bumped = True
|
||||
if self._counter < 0xFFFFF:
|
||||
self._counter += 1
|
||||
|
||||
@property
|
||||
def event_counter(self) -> int:
|
||||
return self._counter
|
||||
|
||||
def enable_counter(self, on_write: bool = False) -> None:
|
||||
"""Enable the 20-bit event counter (02KB family)."""
|
||||
self._counter_cfg = 0x02 | (0x01 if on_write else 0x00)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Concrete ICs
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class ST25TA512B(ST25TA):
|
||||
"""ST25TA512B — 64-byte NDEF file."""
|
||||
_cc_bytes = bytes.fromhex("000F2000400036040600010040 0000".replace(" ", ""))
|
||||
_max_ndef = 0x40
|
||||
_ic_ref = 0xE5
|
||||
_product_code = 0xE4
|
||||
_ats_bytes = b"\x05\x75\x80\x60\x02"
|
||||
|
||||
|
||||
class ST25TA02KB(ST25TA):
|
||||
"""ST25TA02KB — 256-byte NDEF file, 20-bit event counter + TruST25."""
|
||||
_cc_bytes = bytes.fromhex("000F2000FF0036040600010100 0000".replace(" ", ""))
|
||||
_max_ndef = 0x100
|
||||
_ic_ref = 0xE2
|
||||
_product_code = 0xE3
|
||||
_ats_bytes = b"\x05\x75\x80\x60\x02"
|
||||
_has_counter = True
|
||||
|
||||
|
||||
class ST25TA16K(ST25TA):
|
||||
"""ST25TA16K — 2048-byte NDEF file."""
|
||||
_cc_bytes = bytes.fromhex("000F2000F600F6040600010800 0000".replace(" ", ""))
|
||||
_max_ndef = 0x800
|
||||
_ic_ref = 0xC5
|
||||
_product_code = 0xC5
|
||||
_ats_bytes = b"\x05\x78\x80\x90\x02"
|
||||
|
||||
|
||||
class ST25TA64K(ST25TA):
|
||||
"""ST25TA64K — 8192-byte NDEF file.
|
||||
|
||||
Not covered by a supplied datasheet; CC/memory-size constants are
|
||||
extrapolated from the ST25TA family (FLEN=0x2000). Verify against
|
||||
hardware before relying on the exact CC bytes.
|
||||
"""
|
||||
_cc_bytes = bytes.fromhex("000F2000F600F6040600012000 0000".replace(" ", ""))
|
||||
_max_ndef = 0x2000
|
||||
_ic_ref = 0xC5
|
||||
_product_code = 0xC5
|
||||
_ats_bytes = b"\x05\x78\x80\x90\x02"
|
||||
99
pm3py/transponders/hf/iso14443a/st/st25tn.py
Normal file
99
pm3py/transponders/hf/iso14443a/st/st25tn.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""ST25TN512 / ST25TN01K — STMicroelectronics NFC Forum Type 2 tags.
|
||||
|
||||
Sourced from ST datasheet DS13433 Rev 5. ISO/IEC 14443 Type A, 7-byte UID with
|
||||
ST manufacturer code 0x02. 64 blocks (256 bytes) of 4 bytes each.
|
||||
|
||||
Commands: READ (0x30), WRITE (0xA2) only — no GET_VERSION / FAST_READ / PWD_AUTH.
|
||||
Write protection is via OTP lock bits. The only password is the 32-bit Kill
|
||||
password (block 2Fh); a WRITE of that password to the kill keyhole (block 30h)
|
||||
permanently mutes the RF interface.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from pm3py.sim.frame import RFFrame
|
||||
from ..nxp.type2 import NxpType2Tag, _ack, _nak, NAK_ARG, READ_CMD, WRITE_CMD
|
||||
|
||||
SYSBLOCK_PAGE = 0x02 # block 2, byte 1 holds the SYSBLOCK value
|
||||
PRODUCT_ID_PAGE = 0x2D
|
||||
ANDEF_CFG_PAGE = 0x2E
|
||||
KILL_PWD_PAGE = 0x2F
|
||||
KILL_KEYHOLE_PAGE = 0x30
|
||||
|
||||
SYSBLOCK_VALUE = 0x2C # points at the product-identification block
|
||||
|
||||
|
||||
class ST25TN(NxpType2Tag):
|
||||
"""Base for ST25TN. Concrete ICs set memory size / CC / product code."""
|
||||
|
||||
_version_bytes = None
|
||||
_has_fast_read = False
|
||||
_has_read_sig = False
|
||||
_has_pwd_auth = False
|
||||
_has_cc = True
|
||||
_product_code = b"\x00\x00" # LE product code in product-id block
|
||||
|
||||
def __init__(self, uid: bytes | None = None, ndef_message: bytes = b"",
|
||||
kill_password: bytes = b"\x00\x00\x00\x00",
|
||||
kill_enabled: bool = True):
|
||||
if uid is None:
|
||||
uid = b"\x02" + os.urandom(6) # ST manufacturer code 0x02
|
||||
self._kill_armed = False
|
||||
self._killed = False
|
||||
self._kill_enabled = kill_enabled
|
||||
super().__init__(uid=uid, cc=self._cc_default, ndef_message=ndef_message,
|
||||
total_pages=64, atqa=b"\x44\x00", sak=0x00)
|
||||
# System blocks: SYSBLOCK pointer, product identification, kill password.
|
||||
p2 = bytearray(self._get_page(SYSBLOCK_PAGE))
|
||||
p2[1] = SYSBLOCK_VALUE
|
||||
self._set_page(SYSBLOCK_PAGE, bytes(p2))
|
||||
self._set_page(PRODUCT_ID_PAGE, self._product_code + b"\x13\x05") # PC, REV, KID
|
||||
self._set_page(KILL_PWD_PAGE, kill_password)
|
||||
|
||||
@property
|
||||
def killed(self) -> bool:
|
||||
return self._killed
|
||||
|
||||
def _is_config_page(self, page: int) -> bool:
|
||||
# KILL_PWD (2Fh) and kill keyhole (30h) always read back as 00h.
|
||||
return page in (KILL_PWD_PAGE, KILL_KEYHOLE_PAGE)
|
||||
|
||||
async def power_on(self) -> None:
|
||||
await super().power_on()
|
||||
if self._kill_armed:
|
||||
self._killed = True
|
||||
|
||||
async def handle_frame(self, frame: RFFrame) -> RFFrame | None:
|
||||
if self._killed:
|
||||
return None # KILLED state: mute forever
|
||||
return await super().handle_frame(frame)
|
||||
|
||||
def _handle_write(self, frame: RFFrame) -> RFFrame | None:
|
||||
if len(frame.data) < 6:
|
||||
return _nak(NAK_ARG)
|
||||
page = frame.data[1]
|
||||
# Kill keyhole: WRITE the kill password here to permanently kill.
|
||||
if page == KILL_KEYHOLE_PAGE:
|
||||
if not self._kill_enabled:
|
||||
return _nak(NAK_ARG)
|
||||
if bytes(frame.data[2:6]) == self._get_page(KILL_PWD_PAGE):
|
||||
self._kill_armed = True
|
||||
return _ack()
|
||||
return _nak(NAK_ARG)
|
||||
# Product identification block is read-only.
|
||||
if page == PRODUCT_ID_PAGE:
|
||||
return _nak(NAK_ARG)
|
||||
return super()._handle_write(frame)
|
||||
|
||||
|
||||
class ST25TN512(ST25TN):
|
||||
"""ST25TN512 — 64 bytes user (blocks 04h-13h)."""
|
||||
_cc_default = b"\xE1\x10\x08\x00"
|
||||
_product_code = b"\x91\x90" # PC = 0x9091, LE on wire
|
||||
|
||||
|
||||
class ST25TN01K(ST25TN):
|
||||
"""ST25TN01K — 160 bytes user (blocks 04h-2Bh)."""
|
||||
_cc_default = b"\xE1\x10\x14\x00"
|
||||
_product_code = b"\x90\x90" # PC = 0x9090
|
||||
1
pm3py/transponders/hf/iso14443b/__init__.py
Normal file
1
pm3py/transponders/hf/iso14443b/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""ISO 14443 Type B transponders (ST25TB / SRIX family)."""
|
||||
270
pm3py/transponders/hf/iso14443b/base.py
Normal file
270
pm3py/transponders/hf/iso14443b/base.py
Normal file
@@ -0,0 +1,270 @@
|
||||
"""ISO 14443 Type B transponder base — SRIX / ST25TB slot-marker family.
|
||||
|
||||
This family does NOT use the standard ISO 14443-3 Type B REQB/ATQB/ATTRIB flow.
|
||||
It uses the simplified SRIX anticollision built from Initiate / Pcall16 /
|
||||
Slot_marker with an 8-bit Chip_ID, plus Select(Chip_ID) / Get_UID / Read_block /
|
||||
Write_block / Completion / Reset_to_inventory. RF framing (SOF/EOF/CRC_B) is
|
||||
handled at the wire layer; this model works on the command payload like the
|
||||
14443-A and 15693 models.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from enum import IntEnum
|
||||
|
||||
from pm3py.sim.frame import RFFrame
|
||||
from pm3py.sim.transponder import Transponder
|
||||
from pm3py.trace.decode_iso14b import decode_14443b
|
||||
|
||||
# Command opcodes
|
||||
CMD_INITIATE = 0x06 # 06 00
|
||||
CMD_PCALL16 = 0x06 # 06 04 (same first byte)
|
||||
CMD_READ_BLOCK = 0x08
|
||||
CMD_WRITE_BLOCK = 0x09
|
||||
CMD_GET_UID = 0x0B
|
||||
CMD_RESET_TO_INVENTORY = 0x0C
|
||||
CMD_SELECT = 0x0E
|
||||
CMD_COMPLETION = 0x0F
|
||||
|
||||
|
||||
class StateB(IntEnum):
|
||||
POWER_OFF = 0
|
||||
READY = 1
|
||||
INVENTORY = 2
|
||||
SELECTED = 3
|
||||
DEACTIVATED = 4
|
||||
|
||||
|
||||
class Tag14443B(Transponder):
|
||||
"""SRIX-style ISO 14443 Type B tag: anticollision + block memory."""
|
||||
|
||||
def __init__(self, uid: bytes):
|
||||
super().__init__()
|
||||
if len(uid) != 8:
|
||||
raise ValueError(f"Type B UID must be 8 bytes, got {len(uid)}")
|
||||
self._uid = uid # MSB-first (uid[0]=D0h, uid[7]=LS byte)
|
||||
self._state = StateB.POWER_OFF
|
||||
self._chip_id = 0x00
|
||||
self._slot = 0 # slot drawn on last Pcall16
|
||||
|
||||
def decode_trace(self, direction: int, payload: bytes) -> str | None:
|
||||
return decode_14443b(direction, payload)
|
||||
|
||||
@property
|
||||
def state(self) -> str:
|
||||
return self._state.name
|
||||
|
||||
@property
|
||||
def chip_id(self) -> int:
|
||||
return self._chip_id
|
||||
|
||||
async def power_on(self) -> None:
|
||||
self._state = StateB.READY
|
||||
self._chip_id = os.urandom(1)[0]
|
||||
|
||||
async def power_off(self) -> None:
|
||||
self._state = StateB.POWER_OFF
|
||||
|
||||
async def handle_frame(self, frame: RFFrame) -> RFFrame | None:
|
||||
data = frame.data
|
||||
if not data:
|
||||
return None
|
||||
b0 = data[0]
|
||||
|
||||
# Anticollision opcode 0x06 carries a sub-command byte.
|
||||
if b0 == 0x06 and len(data) >= 2:
|
||||
if data[1] == 0x00:
|
||||
return self._handle_initiate()
|
||||
if data[1] == 0x04:
|
||||
return self._handle_pcall16()
|
||||
return None
|
||||
|
||||
# Slot marker: low nibble 6, high nibble = slot number (1..15).
|
||||
if (b0 & 0x0F) == 0x06 and (b0 >> 4) != 0:
|
||||
return self._handle_slot_marker(b0 >> 4)
|
||||
|
||||
if b0 == CMD_SELECT and len(data) >= 2:
|
||||
return self._handle_select(data[1])
|
||||
if b0 == CMD_COMPLETION:
|
||||
return self._handle_completion()
|
||||
if b0 == CMD_RESET_TO_INVENTORY:
|
||||
return self._handle_reset_to_inventory()
|
||||
|
||||
# Memory commands work only in Selected state.
|
||||
if self._state != StateB.SELECTED:
|
||||
return None
|
||||
if b0 == CMD_GET_UID:
|
||||
return RFFrame.from_bytes(self._uid[::-1])
|
||||
if b0 == CMD_READ_BLOCK and len(data) >= 2:
|
||||
return self._handle_read_block(data[1])
|
||||
if b0 == CMD_WRITE_BLOCK and len(data) >= 6:
|
||||
self._handle_write_block(data[1], bytes(data[2:6]))
|
||||
return None # Write_block has no response
|
||||
return None
|
||||
|
||||
# ----- anticollision -----
|
||||
|
||||
def _handle_initiate(self) -> RFFrame | None:
|
||||
if self._state in (StateB.READY, StateB.INVENTORY):
|
||||
self._state = StateB.INVENTORY
|
||||
self._chip_id = os.urandom(1)[0]
|
||||
return RFFrame.from_bytes(bytes([self._chip_id]))
|
||||
return None
|
||||
|
||||
def _handle_pcall16(self) -> RFFrame | None:
|
||||
if self._state != StateB.INVENTORY:
|
||||
return None
|
||||
self._slot = os.urandom(1)[0] & 0x0F
|
||||
if self._slot == 0:
|
||||
return RFFrame.from_bytes(bytes([self._chip_id]))
|
||||
return None
|
||||
|
||||
def _handle_slot_marker(self, slot: int) -> RFFrame | None:
|
||||
if self._state != StateB.INVENTORY:
|
||||
return None
|
||||
if slot == self._slot:
|
||||
return RFFrame.from_bytes(bytes([self._chip_id]))
|
||||
return None
|
||||
|
||||
def _handle_select(self, chip_id: int) -> RFFrame | None:
|
||||
if chip_id == self._chip_id:
|
||||
self._state = StateB.SELECTED
|
||||
return RFFrame.from_bytes(bytes([self._chip_id]))
|
||||
if self._state == StateB.SELECTED:
|
||||
self._state = StateB.INVENTORY # deselected
|
||||
return None
|
||||
|
||||
def _handle_completion(self) -> RFFrame | None:
|
||||
if self._state == StateB.SELECTED:
|
||||
self._state = StateB.DEACTIVATED
|
||||
return None
|
||||
|
||||
def _handle_reset_to_inventory(self) -> RFFrame | None:
|
||||
if self._state == StateB.SELECTED:
|
||||
self._state = StateB.INVENTORY
|
||||
return None
|
||||
|
||||
# ----- memory (override in subclasses) -----
|
||||
|
||||
def _handle_read_block(self, addr: int) -> RFFrame | None:
|
||||
return None
|
||||
|
||||
def _handle_write_block(self, addr: int, data: bytes) -> None:
|
||||
pass
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Standard ISO 14443-3/4 Type B (REQB / ATQB / ATTRIB / ISO-DEP)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
APF = 0x05 # REQB/WUPB/Slot-MARKER anticollision prefix
|
||||
ATTRIB_CMD = 0x1D
|
||||
HLTB_CMD = 0x50
|
||||
|
||||
|
||||
class StateB4(IntEnum):
|
||||
IDLE = 0
|
||||
READY = 1 # answered REQB/WUPB (ATQB sent)
|
||||
ACTIVE = 2 # ATTRIB done, ISO-DEP running
|
||||
HALT = 3
|
||||
|
||||
|
||||
class Tag14443B_4(Transponder):
|
||||
"""Standard ISO 14443 Type B tag with ISO-DEP (ISO 14443-4).
|
||||
|
||||
Implements the REQB/WUPB -> ATQB, Slot-MARKER, ATTRIB -> answer flow and
|
||||
an I-block APDU transport, with a ``_handle_apdu`` hook for the higher
|
||||
layer (e.g. NFC Forum Type 4 NDEF). Used by the TI RF430CL330H.
|
||||
"""
|
||||
|
||||
def __init__(self, pupi: bytes | None = None, afi: int = 0x00,
|
||||
app_data: bytes = b"\x00\x00\x00\x00",
|
||||
fsci: int = 0x08, fwi: int = 0x04):
|
||||
super().__init__()
|
||||
self._pupi = pupi if pupi is not None else os.urandom(4)
|
||||
self._afi = afi
|
||||
self._app_data = app_data
|
||||
self._fsci = fsci & 0x0F
|
||||
self._fwi = fwi & 0x0F
|
||||
self._state = StateB4.IDLE
|
||||
|
||||
def decode_trace(self, direction: int, payload: bytes) -> str | None:
|
||||
return decode_14443b(direction, payload)
|
||||
|
||||
@property
|
||||
def state(self) -> str:
|
||||
return self._state.name
|
||||
|
||||
@property
|
||||
def pupi(self) -> bytes:
|
||||
return self._pupi
|
||||
|
||||
async def power_on(self) -> None:
|
||||
self._state = StateB4.IDLE
|
||||
|
||||
async def power_off(self) -> None:
|
||||
self._state = StateB4.IDLE
|
||||
|
||||
def _atqb(self) -> RFFrame:
|
||||
# 50 | PUPI(4) | AppData(4) | ProtInfo(3)
|
||||
prot_info = bytes([
|
||||
0x00, # bit-rate capability: 106 kbps only (default)
|
||||
(self._fsci << 4) | 0x01, # max frame size | protocol type (ISO 14443-4)
|
||||
(self._fwi << 4), # FWI | ADC(00) | FO(00)
|
||||
])
|
||||
return RFFrame.from_bytes(bytes([0x50]) + self._pupi + self._app_data + prot_info)
|
||||
|
||||
async def handle_frame(self, frame: RFFrame) -> RFFrame | None:
|
||||
data = frame.data
|
||||
if not data:
|
||||
return None
|
||||
b0 = data[0]
|
||||
|
||||
# REQB / WUPB / Slot-MARKER (all start with APf=0x05)
|
||||
if b0 == APF and len(data) >= 3:
|
||||
afi = data[1]
|
||||
param = data[2]
|
||||
is_wupb = bool(param & 0x08)
|
||||
if self._state == StateB4.HALT and not is_wupb:
|
||||
return None # halted tags answer only WUPB
|
||||
if afi != 0x00 and afi != self._afi:
|
||||
return None # AFI filter
|
||||
self._state = StateB4.READY
|
||||
return self._atqb()
|
||||
|
||||
# Slot-MARKER: APn = (slot<<4) | 0x05
|
||||
if (b0 & 0x0F) == APF and (b0 >> 4) != 0:
|
||||
if self._state == StateB4.READY:
|
||||
return self._atqb()
|
||||
return None
|
||||
|
||||
if b0 == ATTRIB_CMD and len(data) >= 5:
|
||||
if bytes(data[1:5]) != self._pupi:
|
||||
return None
|
||||
self._state = StateB4.ACTIVE
|
||||
cid = data[8] & 0x0F if len(data) > 8 else 0
|
||||
return RFFrame.from_bytes(bytes([cid])) # Answer-to-ATTRIB: MBLI|CID
|
||||
|
||||
if b0 == HLTB_CMD and len(data) >= 5:
|
||||
if bytes(data[1:5]) == self._pupi:
|
||||
self._state = StateB4.HALT
|
||||
return RFFrame.from_bytes(b"\x00")
|
||||
return None
|
||||
|
||||
# ISO-DEP I-block in Active state
|
||||
if self._state == StateB4.ACTIVE:
|
||||
return self._handle_layer4(frame)
|
||||
return None
|
||||
|
||||
def _handle_layer4(self, frame: RFFrame) -> RFFrame | None:
|
||||
data = frame.data
|
||||
pcb = data[0]
|
||||
if pcb & 0xC0 == 0x00: # I-block
|
||||
resp = self._handle_apdu(bytes(data[1:]))
|
||||
resp_pcb = 0x02 | (pcb & 0x01)
|
||||
return RFFrame.from_bytes(bytes([resp_pcb]) + resp)
|
||||
return None
|
||||
|
||||
def _handle_apdu(self, apdu: bytes) -> bytes:
|
||||
"""Override in subclasses. Default: 6A82 (file not found)."""
|
||||
return b"\x6A\x82"
|
||||
1
pm3py/transponders/hf/iso14443b/st/__init__.py
Normal file
1
pm3py/transponders/hf/iso14443b/st/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""STMicroelectronics ISO 14443 Type B transponders (ST25TB / SRIX)."""
|
||||
118
pm3py/transponders/hf/iso14443b/st/st25tb.py
Normal file
118
pm3py/transponders/hf/iso14443b/st/st25tb.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""ST25TB512-AC / ST25TB512-AT / ST25TB02K / ST25TB04K — ST ISO 14443 Type B.
|
||||
|
||||
Sourced from ST datasheets DS11469/DS11364/DS11495/DS11456. 32-bit blocks,
|
||||
64-bit UID (prefix D0 02 <product-code>), two decrement-only count-down counters
|
||||
at blocks 5 and 6, a resettable OTP area (blocks 0-4, except the -AT variant),
|
||||
and the OTP_Lock_Reg system block at address 255.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from pm3py.sim.frame import RFFrame
|
||||
from ..base import Tag14443B
|
||||
|
||||
SYSTEM_BLOCK = 0xFF
|
||||
COUNTER_BLOCKS = (5, 6)
|
||||
OTP_BLOCKS = range(0, 5) # blocks 0-4
|
||||
|
||||
|
||||
class ST25TB(Tag14443B):
|
||||
"""Base for the ST25TB family. Concrete ICs set block count / product code."""
|
||||
|
||||
_num_blocks: int = 16
|
||||
_product_code: int = 0x1B
|
||||
_resettable_otp: bool = True # False for -AT (blocks 0-4 are plain EEPROM)
|
||||
|
||||
def __init__(self, uid: bytes | None = None):
|
||||
if uid is None:
|
||||
uid = bytes([0xD0, 0x02, self._product_code]) + os.urandom(5)
|
||||
super().__init__(uid=uid)
|
||||
self._memory = bytearray(self._num_blocks * 4)
|
||||
self._system = bytearray(4) # block 255: OTP_Lock_Reg + reserved
|
||||
# Resettable OTP area ships erased to all-1s (bits then only clear).
|
||||
if self._resettable_otp:
|
||||
for blk in OTP_BLOCKS:
|
||||
self._set_block(blk, b"\xFF\xFF\xFF\xFF")
|
||||
# Count-down counters factory values.
|
||||
self._set_block(5, (0xFFFFFFFE).to_bytes(4, "little"))
|
||||
self._set_block(6, (0xFFFFFFFF).to_bytes(4, "little"))
|
||||
|
||||
# ----- block helpers -----
|
||||
|
||||
def _get_block(self, addr: int) -> bytes:
|
||||
if addr == SYSTEM_BLOCK:
|
||||
return bytes(self._system)
|
||||
off = addr * 4
|
||||
return bytes(self._memory[off:off + 4])
|
||||
|
||||
def _set_block(self, addr: int, data: bytes) -> None:
|
||||
if addr == SYSTEM_BLOCK:
|
||||
self._system[:] = data[:4]
|
||||
return
|
||||
off = addr * 4
|
||||
self._memory[off:off + 4] = data[:4]
|
||||
|
||||
def _valid_addr(self, addr: int) -> bool:
|
||||
return addr < self._num_blocks or addr == SYSTEM_BLOCK
|
||||
|
||||
# ----- read / write -----
|
||||
|
||||
def _handle_read_block(self, addr: int) -> RFFrame | None:
|
||||
if not self._valid_addr(addr):
|
||||
return None
|
||||
return RFFrame.from_bytes(self._get_block(addr))
|
||||
|
||||
def _handle_write_block(self, addr: int, data: bytes) -> None:
|
||||
if not self._valid_addr(addr):
|
||||
return
|
||||
new_val = int.from_bytes(data, "little")
|
||||
|
||||
if addr in COUNTER_BLOCKS:
|
||||
# Decrement-only: accept only a strictly lower value.
|
||||
cur = int.from_bytes(self._get_block(addr), "little")
|
||||
if new_val < cur:
|
||||
self._set_block(addr, new_val.to_bytes(4, "little"))
|
||||
return
|
||||
|
||||
if self._resettable_otp and addr in OTP_BLOCKS:
|
||||
# OTP: no auto-erase, bits can only clear 1 -> 0.
|
||||
cur = int.from_bytes(self._get_block(addr), "little")
|
||||
self._set_block(addr, (cur & new_val).to_bytes(4, "little"))
|
||||
return
|
||||
|
||||
if addr == SYSTEM_BLOCK:
|
||||
# OTP_Lock_Reg: bit-clear only.
|
||||
cur = int.from_bytes(self._get_block(addr), "little")
|
||||
self._set_block(addr, (cur & new_val).to_bytes(4, "little"))
|
||||
return
|
||||
|
||||
self._set_block(addr, data)
|
||||
|
||||
|
||||
class ST25TB512AC(ST25TB):
|
||||
"""ST25TB512-AC — 16 blocks, resettable OTP area."""
|
||||
_num_blocks = 16
|
||||
_product_code = 0x1B
|
||||
_resettable_otp = True
|
||||
|
||||
|
||||
class ST25TB512AT(ST25TB):
|
||||
"""ST25TB512-AT — 16 blocks, blocks 0-4 are plain EEPROM (no OTP reset)."""
|
||||
_num_blocks = 16
|
||||
_product_code = 0x33
|
||||
_resettable_otp = False
|
||||
|
||||
|
||||
class ST25TB02K(ST25TB):
|
||||
"""ST25TB02K — 64 blocks (2 Kbit)."""
|
||||
_num_blocks = 64
|
||||
_product_code = 0x3F
|
||||
_resettable_otp = True
|
||||
|
||||
|
||||
class ST25TB04K(ST25TB):
|
||||
"""ST25TB04K — 128 blocks (4 Kbit)."""
|
||||
_num_blocks = 128
|
||||
_product_code = 0x1F
|
||||
_resettable_otp = True
|
||||
1
pm3py/transponders/hf/iso14443b/ti/__init__.py
Normal file
1
pm3py/transponders/hf/iso14443b/ti/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Texas Instruments ISO 14443 Type B transponders (RF430CL330H NFC Type 4B)."""
|
||||
152
pm3py/transponders/hf/iso14443b/ti/rf430cl330h.py
Normal file
152
pm3py/transponders/hf/iso14443b/ti/rf430cl330h.py
Normal file
@@ -0,0 +1,152 @@
|
||||
"""RF430CL330H — Texas Instruments Dynamic NFC Interface Transponder.
|
||||
|
||||
Sourced from TI SLAS916C. NFC Forum Type 4 tag over ISO/IEC 14443 Type B
|
||||
(standard REQB/ATQB/ATTRIB + ISO-DEP), backed by 3 KB of volatile SRAM that a
|
||||
host controller fills over SPI/I2C. This models the RF/NFC side: Type B
|
||||
activation (via Tag14443B_4) plus the ISO 7816 Type 4 NDEF file system.
|
||||
|
||||
The datasheet defers the low-level ISO 14443-3 Type B byte layouts to the ISO
|
||||
standard; the CC contents (MLe=00F9, MLc=00F6), the AID, and the APDU flow are
|
||||
taken from SLAS916C §5.8-5.9. The NDEF file ID is host-programmable; this model
|
||||
defaults it to the NFC Forum well-known E104 for phone compatibility.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
|
||||
from pm3py.sim.frame import RFFrame
|
||||
from ..base import Tag14443B_4
|
||||
|
||||
NDEF_AID = b"\xD2\x76\x00\x00\x85\x01\x01"
|
||||
CC_FILE_ID = b"\xE1\x03"
|
||||
NDEF_FILE_ID = b"\xE1\x04"
|
||||
|
||||
INS_SELECT = 0xA4
|
||||
INS_READ_BINARY = 0xB0
|
||||
INS_UPDATE_BINARY = 0xD6
|
||||
|
||||
SW_OK = b"\x90\x00"
|
||||
SW_FILE_NOT_FOUND = b"\x6A\x82"
|
||||
SW_WRONG_P1P2 = b"\x6A\x86"
|
||||
SW_WRONG_LENGTH = b"\x67\x00"
|
||||
SW_SEC_NOT_SATISFIED = b"\x69\x82"
|
||||
|
||||
|
||||
class RF430CL330H(Tag14443B_4):
|
||||
"""TI RF430CL330H NFC Type 4 dynamic tag (ISO 14443 Type B)."""
|
||||
|
||||
def __init__(self, ndef_message: bytes = b"", max_ndef_size: int = 2048,
|
||||
pupi: bytes | None = None, ndef_file_id: bytes = NDEF_FILE_ID):
|
||||
# ISO-DEP: FSCI=8 (256-byte frame), FWI=4. AppData=0.
|
||||
super().__init__(pupi=pupi, afi=0x00, fsci=0x08, fwi=0x04)
|
||||
self._max_ndef = max_ndef_size
|
||||
self._ndef_file_id = ndef_file_id
|
||||
self._ndef_message = ndef_message
|
||||
self._app_selected = False
|
||||
self._selected_file: bytes | None = None
|
||||
self._build_files()
|
||||
|
||||
def _build_files(self) -> None:
|
||||
# CC file (15 bytes) — MLe=00F9, MLc=00F6 per SLAS916C.
|
||||
self._cc_file = (
|
||||
struct.pack(">H", 0x000F) # CCLEN
|
||||
+ b"\x20" # mapping version 2.0
|
||||
+ struct.pack(">H", 0x00F9) # MLe (max R-APDU)
|
||||
+ struct.pack(">H", 0x00F6) # MLc (max C-APDU)
|
||||
+ b"\x04\x06" # NDEF file control TLV: T=04, L=06
|
||||
+ self._ndef_file_id # NDEF file id
|
||||
+ struct.pack(">H", self._max_ndef) # max NDEF file size
|
||||
+ b"\x00" # read access: granted
|
||||
+ b"\x00" # write access: granted
|
||||
)
|
||||
self._update_ndef_file()
|
||||
|
||||
def _update_ndef_file(self) -> None:
|
||||
nlen = len(self._ndef_message)
|
||||
self._ndef_file = struct.pack(">H", nlen) + self._ndef_message
|
||||
if len(self._ndef_file) < self._max_ndef:
|
||||
self._ndef_file += b"\x00" * (self._max_ndef - len(self._ndef_file))
|
||||
|
||||
def set_ndef(self, message: bytes) -> None:
|
||||
"""Set the NDEF message (takes effect immediately, no sync needed)."""
|
||||
if len(message) > self._max_ndef - 2:
|
||||
raise ValueError(
|
||||
f"NDEF message too large: {len(message)} bytes, "
|
||||
f"max is {self._max_ndef - 2} bytes")
|
||||
self._ndef_message = message
|
||||
self._update_ndef_file()
|
||||
|
||||
def clear_ndef(self) -> None:
|
||||
self._ndef_message = b""
|
||||
self._update_ndef_file()
|
||||
|
||||
async def power_on(self) -> None:
|
||||
await super().power_on()
|
||||
self._app_selected = False
|
||||
self._selected_file = None
|
||||
|
||||
# ----- ISO 7816 Type 4 APDU handling -----
|
||||
|
||||
def _handle_apdu(self, apdu: bytes) -> bytes:
|
||||
if len(apdu) < 4: # need at least CLA INS P1 P2
|
||||
return SW_WRONG_LENGTH
|
||||
ins = apdu[1]
|
||||
if ins == INS_SELECT:
|
||||
return self._handle_select(apdu)
|
||||
if ins == INS_READ_BINARY:
|
||||
return self._handle_read_binary(apdu)
|
||||
if ins == INS_UPDATE_BINARY:
|
||||
return self._handle_update_binary(apdu)
|
||||
return b"\x6D\x00" # INS not supported
|
||||
|
||||
def _handle_select(self, apdu: bytes) -> bytes:
|
||||
p1, p2 = apdu[2], apdu[3]
|
||||
lc = apdu[4] if len(apdu) > 4 else 0
|
||||
data = apdu[5:5 + lc] if lc else b""
|
||||
if p1 == 0x04: # select by AID
|
||||
if data == NDEF_AID:
|
||||
self._app_selected = True
|
||||
self._selected_file = None
|
||||
return SW_OK
|
||||
return SW_FILE_NOT_FOUND
|
||||
if p1 == 0x00 and p2 == 0x0C: # select by file id
|
||||
if not self._app_selected:
|
||||
return SW_FILE_NOT_FOUND
|
||||
if data in (CC_FILE_ID, self._ndef_file_id):
|
||||
self._selected_file = data
|
||||
return SW_OK
|
||||
return SW_FILE_NOT_FOUND
|
||||
return SW_WRONG_P1P2
|
||||
|
||||
def _selected_bytes(self) -> bytes | None:
|
||||
if self._selected_file == CC_FILE_ID:
|
||||
return self._cc_file
|
||||
if self._selected_file == self._ndef_file_id:
|
||||
return self._ndef_file
|
||||
return None
|
||||
|
||||
def _handle_read_binary(self, apdu: bytes) -> bytes:
|
||||
content = self._selected_bytes()
|
||||
if content is None:
|
||||
return SW_FILE_NOT_FOUND
|
||||
offset = (apdu[2] << 8) | apdu[3]
|
||||
le = apdu[4] if len(apdu) > 4 else 0
|
||||
if offset >= len(content):
|
||||
return SW_WRONG_P1P2
|
||||
return content[offset:offset + le] + SW_OK
|
||||
|
||||
def _handle_update_binary(self, apdu: bytes) -> bytes:
|
||||
if self._selected_file != self._ndef_file_id:
|
||||
return SW_FILE_NOT_FOUND
|
||||
offset = (apdu[2] << 8) | apdu[3]
|
||||
lc = apdu[4] if len(apdu) > 4 else 0
|
||||
data = apdu[5:5 + lc]
|
||||
if offset + len(data) > len(self._ndef_file):
|
||||
return SW_WRONG_P1P2 # write past end of file
|
||||
ndef = bytearray(self._ndef_file)
|
||||
ndef[offset:offset + len(data)] = data
|
||||
self._ndef_file = bytes(ndef)
|
||||
# keep _ndef_message in sync with NLEN
|
||||
nlen = (self._ndef_file[0] << 8) | self._ndef_file[1]
|
||||
self._ndef_message = self._ndef_file[2:2 + nlen]
|
||||
return SW_OK
|
||||
1
pm3py/transponders/hf/iso15693/infineon/__init__.py
Normal file
1
pm3py/transponders/hf/iso15693/infineon/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Infineon ISO 15693 transponders (my-d vicinity SRF55VxxP)."""
|
||||
150
pm3py/transponders/hf/iso15693/infineon/myd_vicinity.py
Normal file
150
pm3py/transponders/hf/iso15693/infineon/myd_vicinity.py
Normal file
@@ -0,0 +1,150 @@
|
||||
"""Infineon my-d vicinity — SRF55V02P / SRF55V10P (ISO 15693 / ISO 18000-3 M1).
|
||||
|
||||
Sourced from the Infineon my-d vicinity plain Extended Datasheet Rev 4.0.
|
||||
8-byte UID with Infineon manufacturer code 0x05 (prefix E0 05 <chip-id>).
|
||||
|
||||
Notable differences from the NXP/ST 15693 parts:
|
||||
- **No GET SYSTEM INFO (0x2B)** — identification is via the Chip-ID byte (UID
|
||||
byte 5). A reader must not rely on 0x2B.
|
||||
- Two memory views: ISO block mode (4-byte blocks) and a custom page mode
|
||||
(8-byte pages) reached through the single custom opcode 0xA0. Plain variants
|
||||
have no password/authentication (open access).
|
||||
- Any user page can be a decrement-only value counter (custom Restricted Write).
|
||||
This model implements the ISO block interface plus the custom Read/Write page
|
||||
commands; the secure (SRF55VxxS) authentication protocol is not modelled.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pm3py.sim.frame import RFFrame
|
||||
from ..base import Tag15693, FLAG_ADDRESS, FLAG_INVENTORY
|
||||
|
||||
INFINEON_MFG = 0x05
|
||||
|
||||
# Custom sub-commands (inside the 0xA0 custom frame)
|
||||
SUB_READ = 0x10 # read one 8-byte page
|
||||
SUB_WRITE = 0x30 # write one 8-byte page
|
||||
SUB_WRITE_BYTE = 0x90 # write one byte (admin bytes included)
|
||||
SUB_WRITE_REREAD = 0xB0 # write then return the page
|
||||
SUB_RESTRICTED_WRITE = 0x00 # value-counter decrement
|
||||
SUB_RESTRICTED_WRITE_REREAD = 0x80
|
||||
|
||||
|
||||
class MydVicinity(Tag15693):
|
||||
"""Infineon my-d vicinity plain. Concrete ICs set the memory size."""
|
||||
|
||||
_uid_prefix = b"\xE0\x05" # E0 + Infineon mfg 0x05; chip-id appended
|
||||
_chip_id: int = 0x40 # UID byte 5 (also encodes EEPROM size)
|
||||
|
||||
def __init__(self, uid: bytes | str | None = None, dsfid: int = 0, afi: int = 0):
|
||||
super().__init__(uid=uid, dsfid=dsfid, afi=afi,
|
||||
block_size=4, num_blocks=self._num_blocks_cls,
|
||||
ic_reference=0x00)
|
||||
# Page-mode view: 8-byte data pages (custom addressing). Physical
|
||||
# memory is shared with the block view (block N ↔ its bytes).
|
||||
self._page_size = 8
|
||||
|
||||
async def handle_frame(self, frame: RFFrame) -> RFFrame | None:
|
||||
if len(frame.data) < 2:
|
||||
return None
|
||||
cmd = frame.data[1]
|
||||
if cmd == 0x2B:
|
||||
# my-d vicinity does not implement Get System Info.
|
||||
return self._make_error(0x01)
|
||||
if cmd == 0xA0:
|
||||
return self._handle_custom(frame)
|
||||
return await super().handle_frame(frame)
|
||||
|
||||
# ----- custom page commands (opcode 0xA0, mfg 0x05) -----
|
||||
|
||||
def _handle_custom(self, frame: RFFrame) -> RFFrame | None:
|
||||
data = frame.data
|
||||
flags = data[0]
|
||||
if len(data) < 3 or data[2] != INFINEON_MFG:
|
||||
return None
|
||||
addressed = bool(flags & FLAG_ADDRESS) and not (flags & FLAG_INVENTORY)
|
||||
# flags, cmd(A0), mfg [+uid], then <sub-cmd, addr0(page), addr1(00), data...>
|
||||
off = 3 + (8 if addressed else 0)
|
||||
if addressed and (len(data) < 11 or bytes(reversed(data[3:11])) != self._uid):
|
||||
return None
|
||||
if len(data) < off + 3:
|
||||
return self._make_error(0x0F)
|
||||
sub = data[off]
|
||||
page = data[off + 1]
|
||||
params = data[off + 3:]
|
||||
|
||||
if sub == SUB_READ:
|
||||
return self._read_page(page)
|
||||
if sub == SUB_WRITE:
|
||||
return self._write_page(page, params, reread=False)
|
||||
if sub == SUB_WRITE_REREAD:
|
||||
return self._write_page(page, params, reread=True)
|
||||
if sub == SUB_WRITE_BYTE:
|
||||
return self._write_byte(page, params)
|
||||
if sub in (SUB_RESTRICTED_WRITE, SUB_RESTRICTED_WRITE_REREAD):
|
||||
return self._restricted_write(page, params,
|
||||
reread=(sub == SUB_RESTRICTED_WRITE_REREAD))
|
||||
return self._make_error(0x01)
|
||||
|
||||
def _page_offset(self, page: int) -> int:
|
||||
return page * self._page_size
|
||||
|
||||
def _num_pages(self) -> int:
|
||||
return (self._num_blocks * self._block_size) // self._page_size
|
||||
|
||||
def _read_page(self, page: int) -> RFFrame | None:
|
||||
if page >= self._num_pages():
|
||||
return self._make_error(0x10)
|
||||
off = self._page_offset(page)
|
||||
return self._make_response(bytes(self._memory[off:off + self._page_size]))
|
||||
|
||||
def _write_page(self, page: int, data: bytes, reread: bool) -> RFFrame | None:
|
||||
if page >= self._num_pages():
|
||||
return self._make_error(0x10)
|
||||
if len(data) < self._page_size:
|
||||
return self._make_error(0x0F)
|
||||
off = self._page_offset(page)
|
||||
self._memory[off:off + self._page_size] = data[:self._page_size]
|
||||
if reread:
|
||||
return self._make_response(bytes(self._memory[off:off + self._page_size]))
|
||||
return self._make_response(b"")
|
||||
|
||||
def _write_byte(self, page: int, params: bytes) -> RFFrame | None:
|
||||
# params = byte-number(1) + value(1)
|
||||
if len(params) < 2:
|
||||
return self._make_error(0x0F)
|
||||
byte_no, value = params[0], params[1]
|
||||
if page >= self._num_pages() or byte_no > 0x09:
|
||||
return self._make_error(0x10)
|
||||
# Only the 8 data bytes live in _memory; admin bytes 8/9 are accepted
|
||||
# but not stored in the block view.
|
||||
if byte_no < 8:
|
||||
self._memory[self._page_offset(page) + byte_no] = value
|
||||
return self._make_response(b"")
|
||||
|
||||
def _restricted_write(self, page: int, params: bytes, reread: bool) -> RFFrame | None:
|
||||
# Decrement-only value counter: accept only a strictly lower 32-bit value.
|
||||
if page >= self._num_pages() or len(params) < 4:
|
||||
return self._make_error(0x0F)
|
||||
off = self._page_offset(page)
|
||||
new_val = int.from_bytes(params[0:4], "little")
|
||||
cur = int.from_bytes(self._memory[off:off + 4], "little")
|
||||
if new_val >= cur and cur != 0:
|
||||
return self._make_error(0x0F) # not lower → rejected
|
||||
self._memory[off:off + 4] = new_val.to_bytes(4, "little")
|
||||
if reread:
|
||||
return self._make_response(bytes(self._memory[off:off + self._page_size]))
|
||||
return self._make_response(b"")
|
||||
|
||||
|
||||
class SRF55V02P(MydVicinity):
|
||||
"""my-d vicinity SRF55V02P — 2.5 kbit, 56 ISO blocks (00h-37h)."""
|
||||
_uid_prefix = b"\xE0\x05\x40"
|
||||
_chip_id = 0x40
|
||||
_num_blocks_cls = 56
|
||||
|
||||
|
||||
class SRF55V10P(MydVicinity):
|
||||
"""my-d vicinity SRF55V10P — 10 kbit, 248 ISO blocks (00h-F7h)."""
|
||||
_uid_prefix = b"\xE0\x05\x00"
|
||||
_chip_id = 0x00
|
||||
_num_blocks_cls = 248
|
||||
380
pm3py/transponders/hf/iso15693/st/st25dv.py
Normal file
380
pm3py/transponders/hf/iso15693/st/st25dv.py
Normal file
@@ -0,0 +1,380 @@
|
||||
"""ST25DV04K / ST25DV16K / ST25DV64K — ST dynamic NFC/RFID tags (RF side).
|
||||
|
||||
Sourced from ST datasheet DS10925 Rev 11. ISO/IEC 15693 + I2C dynamic tag,
|
||||
NFC Forum Type 5. This models the RF interface: standard + extended 15693 block
|
||||
commands, the ST custom command set (config registers, dynamic registers,
|
||||
64-bit password sessions, mailbox / fast-transfer mode), and up to four
|
||||
password-protectable memory areas (ENDA1-3 / RFA1-4SS).
|
||||
|
||||
The I2C side is not simulated; dynamic registers that reflect I2C state
|
||||
(FIELD_ON, VCC_ON, etc.) are modelled only as far as RF reads need them.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
|
||||
from pm3py.sim.frame import RFFrame
|
||||
from pm3py.trace.decode_iso15 import decode_15693_st
|
||||
from ..type5 import NfcType5Tag
|
||||
from ..base import FLAG_ADDRESS, FLAG_INVENTORY
|
||||
|
||||
ST_MFG = 0x02
|
||||
|
||||
# Custom command codes (subset modelled)
|
||||
CMD_READ_CFG = 0xA0
|
||||
CMD_WRITE_CFG = 0xA1
|
||||
CMD_MANAGE_GPO = 0xA9
|
||||
CMD_WRITE_MSG = 0xAA
|
||||
CMD_READ_MSG_LEN = 0xAB
|
||||
CMD_READ_MSG = 0xAC
|
||||
CMD_READ_DYN_CFG = 0xAD
|
||||
CMD_WRITE_DYN_CFG = 0xAE
|
||||
CMD_WRITE_PASSWORD = 0xB1
|
||||
CMD_PRESENT_PASSWORD = 0xB3
|
||||
CMD_FAST_READ_SINGLE = 0xC0
|
||||
CMD_EXT_GET_SYSINFO = 0x3B
|
||||
|
||||
# Static config register pointers
|
||||
REG_RFA1SS = 0x04
|
||||
REG_ENDA1 = 0x05
|
||||
REG_RFA2SS = 0x06
|
||||
REG_ENDA2 = 0x07
|
||||
REG_RFA3SS = 0x08
|
||||
REG_ENDA3 = 0x09
|
||||
REG_RFA4SS = 0x0A
|
||||
REG_MB_MODE = 0x0D
|
||||
REG_LOCK_CFG = 0x0F
|
||||
|
||||
# Dynamic register pointers
|
||||
DYN_GPO_CTRL = 0x00
|
||||
DYN_EH_CTRL = 0x02
|
||||
DYN_MB_CTRL = 0x0D
|
||||
DYN_MB_LEN = 0x0E
|
||||
|
||||
|
||||
class ST25DV(NfcType5Tag):
|
||||
"""Base for ST25DVxxK. Concrete ICs set block count / product code / IC ref."""
|
||||
|
||||
_uid_prefix = b"\xE0\x02" # product code appended per variant
|
||||
_product_code: int = 0x24
|
||||
_ic_ref: int = 0x24
|
||||
_has_memsize_in_sysinfo: bool = True # only 04K returns memsize in 0x2B
|
||||
|
||||
def __init__(self, uid: bytes | str | None = None, ndef_message: bytes = b"",
|
||||
passwords: dict[int, bytes] | None = None):
|
||||
super().__init__(uid=uid, ndef_message=ndef_message,
|
||||
block_size=4, num_blocks=self._num_blocks_cls,
|
||||
ic_reference=self._ic_ref)
|
||||
self._last_block = self._num_blocks - 1
|
||||
# 64-bit passwords: 0 = config, 1-3 = user areas.
|
||||
self._passwords = passwords or {i: bytes(8) for i in range(4)}
|
||||
self._sessions: set[int] = set()
|
||||
# Static config registers (pointer → value). Factory: single area.
|
||||
endmax = self._last_block // 8
|
||||
self._cfg = {
|
||||
REG_RFA1SS: 0x00, REG_ENDA1: endmax,
|
||||
REG_RFA2SS: 0x00, REG_ENDA2: endmax,
|
||||
REG_RFA3SS: 0x00, REG_ENDA3: endmax,
|
||||
REG_RFA4SS: 0x00, REG_MB_MODE: 0x00, REG_LOCK_CFG: 0x00,
|
||||
}
|
||||
# Dynamic registers
|
||||
self._dyn = {DYN_GPO_CTRL: 0x88, DYN_EH_CTRL: 0x04, DYN_MB_CTRL: 0x00}
|
||||
self._mailbox = bytearray(256)
|
||||
self._mb_len = 0
|
||||
|
||||
def _write_cc(self) -> None:
|
||||
"""Type 5 CC — use the 8-byte extended form when size/8 > 255."""
|
||||
data_bytes = (self._num_blocks - 1) * self._block_size
|
||||
mlen = data_bytes // 8
|
||||
if mlen <= 0xFF:
|
||||
self._memory[0:4] = bytes([0xE1, 0x40, mlen, 0x01])
|
||||
else:
|
||||
self._memory[0:8] = (bytes([0xE1, 0x40, 0x00, 0x01, 0x00, 0x00])
|
||||
+ struct.pack(">H", mlen))
|
||||
# NDEF data then starts after the 2-block CC.
|
||||
self._ndef_data_offset = 2 * self._block_size
|
||||
self._ndef_capacity = (self._num_blocks - 2) * self._block_size - 3
|
||||
|
||||
def decode_trace(self, direction: int, payload: bytes) -> str | None:
|
||||
return decode_15693_st(direction, payload) or super().decode_trace(direction, payload)
|
||||
|
||||
async def power_on(self) -> None:
|
||||
await super().power_on()
|
||||
self._sessions.clear()
|
||||
self._dyn[DYN_MB_CTRL] = 0x00 # mailbox disabled at boot
|
||||
|
||||
# ----- dispatch -----
|
||||
|
||||
async def handle_frame(self, frame: RFFrame) -> RFFrame | None:
|
||||
if len(frame.data) < 2:
|
||||
return None
|
||||
flags, cmd = frame.data[0], frame.data[1]
|
||||
addressed = bool(flags & FLAG_ADDRESS) and not (flags & FLAG_INVENTORY)
|
||||
if cmd == CMD_EXT_GET_SYSINFO:
|
||||
return self._handle_ext_sysinfo(frame, addressed)
|
||||
if cmd >= 0xA0:
|
||||
return self._handle_st_custom(cmd, frame, addressed)
|
||||
return await super().handle_frame(frame)
|
||||
|
||||
def _params_offset(self, addressed: bool) -> int:
|
||||
return 3 + (8 if addressed else 0) # flags,cmd,mfg [+uid]
|
||||
|
||||
def _handle_st_custom(self, cmd: int, frame: RFFrame, addressed: bool) -> RFFrame | None:
|
||||
data = frame.data
|
||||
if len(data) < 3 or data[2] != ST_MFG:
|
||||
return None
|
||||
if addressed and (len(data) < 11 or bytes(reversed(data[3:11])) != self._uid):
|
||||
return None
|
||||
po = self._params_offset(addressed)
|
||||
|
||||
if cmd == CMD_PRESENT_PASSWORD:
|
||||
return self._handle_present_password(data, po)
|
||||
if cmd == CMD_WRITE_PASSWORD:
|
||||
return self._handle_write_password(data, po)
|
||||
if cmd == CMD_READ_CFG:
|
||||
return self._handle_read_cfg(data, po)
|
||||
if cmd == CMD_WRITE_CFG:
|
||||
return self._handle_write_cfg(data, po)
|
||||
if cmd == CMD_READ_DYN_CFG:
|
||||
return self._handle_read_dyn(data, po)
|
||||
if cmd == CMD_WRITE_DYN_CFG:
|
||||
return self._handle_write_dyn(data, po)
|
||||
if cmd == CMD_MANAGE_GPO:
|
||||
return self._make_response(b"")
|
||||
if cmd == CMD_WRITE_MSG:
|
||||
return self._handle_write_msg(data, po)
|
||||
if cmd == CMD_READ_MSG_LEN:
|
||||
return self._handle_read_msg_len()
|
||||
if cmd == CMD_READ_MSG:
|
||||
return self._handle_read_msg(data, po)
|
||||
if cmd == CMD_FAST_READ_SINGLE:
|
||||
return self._handle_fast_read_single(data, po)
|
||||
return self._make_error(0x01)
|
||||
|
||||
# ----- system info (variant-dependent) -----
|
||||
|
||||
def _handle_system_info(self, frame: RFFrame) -> RFFrame | None:
|
||||
resp = bytearray([0x00])
|
||||
if self._has_memsize_in_sysinfo:
|
||||
info_flags = 0x0F
|
||||
resp.append(info_flags)
|
||||
resp += self._uid[::-1]
|
||||
resp += bytes([self._dsfid, self._afi])
|
||||
resp += bytes([self._last_block & 0xFF, self._block_size - 1])
|
||||
resp += bytes([self._ic_reference])
|
||||
else:
|
||||
info_flags = 0x0B # DSFID + AFI + ICref, no memsize
|
||||
resp.append(info_flags)
|
||||
resp += self._uid[::-1]
|
||||
resp += bytes([self._dsfid, self._afi])
|
||||
resp += bytes([self._ic_reference])
|
||||
return RFFrame.from_bytes(bytes(resp))
|
||||
|
||||
def _handle_ext_sysinfo(self, frame: RFFrame, addressed: bool) -> RFFrame | None:
|
||||
# Response always carries the full memory size (2-byte block count-1).
|
||||
resp = bytearray([0x00, 0x0F])
|
||||
resp += self._uid[::-1]
|
||||
resp += bytes([self._dsfid, self._afi])
|
||||
resp += struct.pack("<H", self._last_block) # number of blocks - 1
|
||||
resp += bytes([self._block_size - 1])
|
||||
resp += bytes([self._ic_reference])
|
||||
return RFFrame.from_bytes(bytes(resp))
|
||||
|
||||
# ----- passwords -----
|
||||
|
||||
def _handle_present_password(self, data: bytes, po: int) -> RFFrame | None:
|
||||
if len(data) < po + 9:
|
||||
return self._make_error(0x0F)
|
||||
num = data[po]
|
||||
if num not in self._passwords:
|
||||
return self._make_error(0x10)
|
||||
supplied = bytes(data[po + 1:po + 9])
|
||||
if supplied != self._passwords[num]:
|
||||
self._sessions.discard(num)
|
||||
return self._make_error(0x0F)
|
||||
self._sessions = {num} # opening a session closes the previous
|
||||
return self._make_response(b"")
|
||||
|
||||
def _handle_write_password(self, data: bytes, po: int) -> RFFrame | None:
|
||||
if len(data) < po + 9:
|
||||
return self._make_error(0x0F)
|
||||
num = data[po]
|
||||
if num not in self._passwords:
|
||||
return self._make_error(0x10)
|
||||
if num not in self._sessions:
|
||||
return self._make_error(0x12)
|
||||
self._passwords[num] = bytes(data[po + 1:po + 9])
|
||||
return self._make_response(b"")
|
||||
|
||||
# ----- config registers -----
|
||||
|
||||
def _read_static_reg(self, ptr: int) -> int | None:
|
||||
if ptr in self._cfg:
|
||||
return self._cfg[ptr]
|
||||
return None
|
||||
|
||||
def _handle_read_cfg(self, data: bytes, po: int) -> RFFrame | None:
|
||||
if len(data) < po + 1:
|
||||
return self._make_error(0x0F)
|
||||
val = self._read_static_reg(data[po])
|
||||
if val is None:
|
||||
return self._make_error(0x10)
|
||||
return self._make_response(bytes([val]))
|
||||
|
||||
def _handle_write_cfg(self, data: bytes, po: int) -> RFFrame | None:
|
||||
if 0 not in self._sessions or (self._cfg[REG_LOCK_CFG] & 0x01):
|
||||
return self._make_error(0x12)
|
||||
if len(data) < po + 2:
|
||||
return self._make_error(0x0F)
|
||||
ptr, val = data[po], data[po + 1]
|
||||
if ptr not in self._cfg:
|
||||
return self._make_error(0x10)
|
||||
self._cfg[ptr] = val
|
||||
return self._make_response(b"")
|
||||
|
||||
def _handle_read_dyn(self, data: bytes, po: int) -> RFFrame | None:
|
||||
if len(data) < po + 1:
|
||||
return self._make_error(0x0F)
|
||||
ptr = data[po]
|
||||
return self._make_response(bytes([self._dyn.get(ptr, 0x00)]))
|
||||
|
||||
def _handle_write_dyn(self, data: bytes, po: int) -> RFFrame | None:
|
||||
if len(data) < po + 2:
|
||||
return self._make_error(0x0F)
|
||||
ptr, val = data[po], data[po + 1]
|
||||
self._dyn[ptr] = val
|
||||
return self._make_response(b"")
|
||||
|
||||
# ----- mailbox / fast transfer -----
|
||||
|
||||
def _mb_enabled(self) -> bool:
|
||||
return bool(self._dyn.get(DYN_MB_CTRL, 0) & 0x01)
|
||||
|
||||
def _handle_write_msg(self, data: bytes, po: int) -> RFFrame | None:
|
||||
if not self._mb_enabled():
|
||||
return self._make_error(0x0F)
|
||||
if len(data) < po + 1:
|
||||
return self._make_error(0x0F)
|
||||
length = data[po] + 1
|
||||
msg = bytes(data[po + 1:po + 1 + length])
|
||||
self._mailbox[0:len(msg)] = msg
|
||||
self._mb_len = len(msg)
|
||||
return self._make_response(b"")
|
||||
|
||||
def _handle_read_msg_len(self) -> RFFrame | None:
|
||||
if not self._mb_enabled():
|
||||
return self._make_error(0x0F)
|
||||
return self._make_response(bytes([max(self._mb_len - 1, 0)]))
|
||||
|
||||
def _handle_read_msg(self, data: bytes, po: int) -> RFFrame | None:
|
||||
if not self._mb_enabled():
|
||||
return self._make_error(0x0F)
|
||||
if len(data) < po + 2:
|
||||
return self._make_error(0x0F)
|
||||
ptr, nb = data[po], data[po + 1] + 1
|
||||
return self._make_response(bytes(self._mailbox[ptr:ptr + nb]))
|
||||
|
||||
def _handle_fast_read_single(self, data: bytes, po: int) -> RFFrame | None:
|
||||
if len(data) < po + 1:
|
||||
return self._make_error(0x0F)
|
||||
block = data[po]
|
||||
if block >= self._num_blocks:
|
||||
return self._make_error(0x10)
|
||||
if not self._read_allowed(block):
|
||||
return self._make_error(0x15)
|
||||
off = block * self._block_size
|
||||
return self._make_response(bytes(self._memory[off:off + self._block_size]))
|
||||
|
||||
# ----- area protection -----
|
||||
|
||||
def _area_index(self, block: int) -> int:
|
||||
e1 = 8 * self._cfg[REG_ENDA1] + 7
|
||||
e2 = 8 * self._cfg[REG_ENDA2] + 7
|
||||
e3 = 8 * self._cfg[REG_ENDA3] + 7
|
||||
if block <= e1:
|
||||
return 1
|
||||
if block <= e2:
|
||||
return 2
|
||||
if block <= e3:
|
||||
return 3
|
||||
return 4
|
||||
|
||||
def _rfaiss(self, area: int) -> int:
|
||||
return self._cfg[{1: REG_RFA1SS, 2: REG_RFA2SS, 3: REG_RFA3SS, 4: REG_RFA4SS}[area]]
|
||||
|
||||
def _session_for_area(self, area: int) -> int:
|
||||
pwd_ctrl = self._rfaiss(area) & 0x03
|
||||
return pwd_ctrl # 0 = none, 1-3 = RF_PWD_1..3
|
||||
|
||||
def _read_allowed(self, block: int) -> bool:
|
||||
area = self._area_index(block)
|
||||
rw = (self._rfaiss(area) >> 2) & 0x03
|
||||
if area == 1:
|
||||
need = (rw == 0x03) # A1: read forbidden-without-session only at 0b11
|
||||
else:
|
||||
need = (rw >= 0x02)
|
||||
if not need:
|
||||
return True
|
||||
return self._session_for_area(area) in self._sessions
|
||||
|
||||
def _write_allowed(self, block: int) -> bool:
|
||||
area = self._area_index(block)
|
||||
rw = (self._rfaiss(area) >> 2) & 0x03
|
||||
if rw == 0x03:
|
||||
return False
|
||||
if rw == 0x00:
|
||||
return True
|
||||
return self._session_for_area(area) in self._sessions
|
||||
|
||||
def _handle_read_single(self, frame: RFFrame) -> RFFrame | None:
|
||||
block = self._extract_block_number(frame)
|
||||
if block is None:
|
||||
return None
|
||||
if block < self._num_blocks and not self._read_allowed(block):
|
||||
return self._make_error(0x15)
|
||||
return super()._handle_read_single(frame)
|
||||
|
||||
def _handle_write_single(self, frame: RFFrame) -> RFFrame | None:
|
||||
block = self._extract_block_number(frame)
|
||||
if block is not None and block < self._num_blocks and not self._write_allowed(block):
|
||||
return self._make_error(0x12)
|
||||
return super()._handle_write_single(frame)
|
||||
|
||||
def _handle_extended_read_single(self, frame: RFFrame) -> RFFrame | None:
|
||||
block = self._extract_block_number_ext(frame)
|
||||
if block is not None and block < self._num_blocks and not self._read_allowed(block):
|
||||
return self._make_error(0x15)
|
||||
return super()._handle_extended_read_single(frame)
|
||||
|
||||
def _handle_extended_write_single(self, frame: RFFrame) -> RFFrame | None:
|
||||
block = self._extract_block_number_ext(frame)
|
||||
if block is not None and block < self._num_blocks and not self._write_allowed(block):
|
||||
return self._make_error(0x12)
|
||||
return super()._handle_extended_write_single(frame)
|
||||
|
||||
|
||||
class ST25DV04K(ST25DV):
|
||||
"""ST25DV04K — 512 bytes user (128 blocks). GetSystemInfo carries memsize."""
|
||||
_uid_prefix = b"\xE0\x02\x24"
|
||||
_num_blocks_cls = 128
|
||||
_product_code = 0x24
|
||||
_ic_ref = 0x24
|
||||
_has_memsize_in_sysinfo = True
|
||||
|
||||
|
||||
class ST25DV16K(ST25DV):
|
||||
"""ST25DV16K — 2048 bytes user (512 blocks). Use ExtGetSystemInfo for memsize."""
|
||||
_uid_prefix = b"\xE0\x02\x26"
|
||||
_num_blocks_cls = 512
|
||||
_product_code = 0x26
|
||||
_ic_ref = 0x26
|
||||
_has_memsize_in_sysinfo = False
|
||||
|
||||
|
||||
class ST25DV64K(ST25DV):
|
||||
"""ST25DV64K — 8192 bytes user (2048 blocks). Use ExtGetSystemInfo for memsize."""
|
||||
_uid_prefix = b"\xE0\x02\x26"
|
||||
_num_blocks_cls = 2048
|
||||
_product_code = 0x26
|
||||
_ic_ref = 0x26
|
||||
_has_memsize_in_sysinfo = False
|
||||
333
pm3py/transponders/hf/iso15693/st/st25tv.py
Normal file
333
pm3py/transponders/hf/iso15693/st/st25tv.py
Normal file
@@ -0,0 +1,333 @@
|
||||
"""ST25TV02KC / ST25TV512C — STMicroelectronics NFC Forum Type 5 tags.
|
||||
|
||||
Sourced from ST datasheet DS13304 Rev 7. ISO/IEC 15693, 8-byte UID with the
|
||||
ST prefix E0 02 08, 4-byte blocks. ST25TV512C = 16 blocks, ST25TV02KC = 80.
|
||||
|
||||
Adds ST custom commands over the standard 15693 set: GetRandomNumber (0xB4),
|
||||
PresentPassword (0xB3, cover-coded), WritePassword (0xB1), Kill (0xA6),
|
||||
ToggleDiscreetSilent (0xBA), Initiate (0xD2), InventoryInitiated (0xD1),
|
||||
Read/WriteConfiguration (0xA0/0xA1). User memory is a single 64-bit-password
|
||||
area or two 32-bit-password areas depending on the END_A1 register.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from pm3py.sim.frame import RFFrame
|
||||
from pm3py.trace.decode_iso15 import decode_15693_st
|
||||
from ..type5 import NfcType5Tag
|
||||
from ..base import State15693, FLAG_ADDRESS, FLAG_INVENTORY
|
||||
|
||||
ST_MFG = 0x02
|
||||
|
||||
# Custom command codes
|
||||
CMD_READ_CONFIG = 0xA0
|
||||
CMD_WRITE_CONFIG = 0xA1
|
||||
CMD_KILL = 0xA6
|
||||
CMD_WRITE_PASSWORD = 0xB1
|
||||
CMD_PRESENT_PASSWORD = 0xB3
|
||||
CMD_GET_RANDOM = 0xB4
|
||||
CMD_TOGGLE_DS = 0xBA
|
||||
CMD_INVENTORY_INITIATED = 0xD1
|
||||
CMD_INITIATE = 0xD2
|
||||
|
||||
# Password ids
|
||||
PWD_CFG = 0x00
|
||||
PWD_A1 = 0x01
|
||||
PWD_A2 = 0x02
|
||||
PWD_PRIV = 0x03
|
||||
|
||||
NUID = b"\xE0\x02\x00\x00\x00\x00\x00\x00" # masked UID in discreet/silent
|
||||
|
||||
|
||||
class ST25TV(NfcType5Tag):
|
||||
"""Base for ST25TVxxxC. Concrete ICs set the block count."""
|
||||
|
||||
_uid_prefix = b"\xE0\x02\x08" # E0 + ST mfg 02 + product code 08
|
||||
|
||||
def __init__(self, uid: bytes | str | None = None, ndef_message: bytes = b"",
|
||||
passwords: dict[int, bytes] | None = None,
|
||||
end_a1: int | None = None):
|
||||
super().__init__(uid=uid, ndef_message=ndef_message,
|
||||
block_size=4, num_blocks=self._num_blocks_cls,
|
||||
ic_reference=0x08)
|
||||
self._end_mem = self._num_blocks - 1
|
||||
# Passwords: single-area default → 64-bit PWD_A1.
|
||||
self._passwords = passwords or {
|
||||
PWD_CFG: bytes(4), PWD_A1: bytes(8), PWD_A2: bytes(4), PWD_PRIV: bytes(4),
|
||||
}
|
||||
self._sessions: set[int] = set()
|
||||
self._last_rnd = b"\x00\x00"
|
||||
self._end_a1 = self._end_mem if end_a1 is None else end_a1
|
||||
self._rwprot = {PWD_A1: 0, PWD_A2: 0} # RW_PROTECTION_A1 / _A2 (bits1:0)
|
||||
self._privacy = 0x00 # PRIVACY register (DS_MODE/DIS_KILL)
|
||||
self._killed = False
|
||||
self._ds_state = "READY" # READY / DISCREET / SILENT
|
||||
self._initiated = False
|
||||
|
||||
def decode_trace(self, direction: int, payload: bytes) -> str | None:
|
||||
return decode_15693_st(direction, payload) or super().decode_trace(direction, payload)
|
||||
|
||||
@property
|
||||
def killed(self) -> bool:
|
||||
return self._killed
|
||||
|
||||
@property
|
||||
def ds_state(self) -> str:
|
||||
return self._ds_state
|
||||
|
||||
async def power_on(self) -> None:
|
||||
await super().power_on()
|
||||
self._sessions.clear()
|
||||
self._initiated = False
|
||||
# DS_MODE bit0-2: 001=DISCREET always, 101=SILENT always (subset)
|
||||
ds_mode = self._privacy & 0x07
|
||||
if ds_mode == 0x01:
|
||||
self._ds_state = "DISCREET"
|
||||
elif ds_mode == 0x05:
|
||||
self._ds_state = "SILENT"
|
||||
else:
|
||||
self._ds_state = "READY"
|
||||
|
||||
# ----- top-level dispatch (handles ST custom framing + privacy gating) -----
|
||||
|
||||
async def handle_frame(self, frame: RFFrame) -> RFFrame | None:
|
||||
if self._killed:
|
||||
return None
|
||||
if len(frame.data) < 2:
|
||||
return None
|
||||
flags, cmd = frame.data[0], frame.data[1]
|
||||
addressed = bool(flags & FLAG_ADDRESS) and not (flags & FLAG_INVENTORY)
|
||||
|
||||
# Privacy gating (SILENT/DISCREET)
|
||||
if self._ds_state == "SILENT":
|
||||
if cmd == CMD_GET_RANDOM and not addressed:
|
||||
return self._handle_get_random(frame)
|
||||
if cmd == CMD_TOGGLE_DS:
|
||||
return self._handle_toggle_ds(cmd, frame, addressed)
|
||||
return None
|
||||
if self._ds_state == "DISCREET":
|
||||
if cmd == CMD_GET_RANDOM and not addressed:
|
||||
return self._handle_get_random(frame)
|
||||
if cmd == CMD_TOGGLE_DS:
|
||||
return self._handle_toggle_ds(cmd, frame, addressed)
|
||||
if cmd == 0x01: # Inventory → masked NUID
|
||||
return RFFrame.from_bytes(bytes([0x00, 0x00]) + NUID[::-1])
|
||||
if cmd == 0x20 and addressed: # ReadSingleBlock, block 0 only
|
||||
block = frame.data[10] if len(frame.data) > 10 else 0xFF
|
||||
if block == 0:
|
||||
return await super().handle_frame(frame)
|
||||
return None
|
||||
|
||||
if cmd >= 0xA0:
|
||||
return self._handle_st_custom(cmd, frame, addressed)
|
||||
return await super().handle_frame(frame)
|
||||
|
||||
# ----- ST custom command dispatch -----
|
||||
|
||||
def _params_offset(self, addressed: bool) -> int:
|
||||
# flags(1) + cmd(1) + mfg(1) [+ uid(8) if addressed]
|
||||
return 3 + (8 if addressed else 0)
|
||||
|
||||
def _handle_st_custom(self, cmd: int, frame: RFFrame, addressed: bool) -> RFFrame | None:
|
||||
data = frame.data
|
||||
if len(data) < 3 or data[2] != ST_MFG:
|
||||
return None
|
||||
if addressed:
|
||||
if len(data) < 11 or bytes(reversed(data[3:11])) != self._uid:
|
||||
return None
|
||||
po = self._params_offset(addressed)
|
||||
|
||||
if cmd == CMD_GET_RANDOM:
|
||||
return self._handle_get_random(frame)
|
||||
if cmd == CMD_PRESENT_PASSWORD:
|
||||
return self._handle_present_password(data, po)
|
||||
if cmd == CMD_WRITE_PASSWORD:
|
||||
return self._handle_write_password(data, po)
|
||||
if cmd == CMD_KILL:
|
||||
return self._handle_kill(data, po)
|
||||
if cmd == CMD_TOGGLE_DS:
|
||||
return self._handle_toggle_ds(cmd, frame, addressed)
|
||||
if cmd == CMD_INITIATE:
|
||||
return self._handle_initiate(addressed)
|
||||
if cmd == CMD_INVENTORY_INITIATED:
|
||||
return self._handle_inventory_initiated()
|
||||
if cmd == CMD_READ_CONFIG:
|
||||
return self._handle_read_config(data, po)
|
||||
if cmd == CMD_WRITE_CONFIG:
|
||||
return self._handle_write_config(data, po)
|
||||
return self._make_error(0x01)
|
||||
|
||||
# ----- password / cover coding -----
|
||||
|
||||
def _handle_get_random(self, frame: RFFrame) -> RFFrame | None:
|
||||
self._last_rnd = os.urandom(2)
|
||||
return RFFrame.from_bytes(bytes([0x00]) + self._last_rnd)
|
||||
|
||||
def _uncover(self, covered: bytes) -> bytes:
|
||||
"""Undo cover coding: PASSWORD = covered XOR (RND repeated)."""
|
||||
rnd = self._last_rnd
|
||||
keystream = (rnd * ((len(covered) // 2) + 1))[:len(covered)]
|
||||
return bytes(a ^ b for a, b in zip(covered, keystream))
|
||||
|
||||
def _handle_present_password(self, data: bytes, po: int) -> RFFrame | None:
|
||||
if len(data) < po + 1:
|
||||
return self._make_error(0x0F)
|
||||
pwd_id = data[po]
|
||||
if pwd_id not in self._passwords:
|
||||
return self._make_error(0x10)
|
||||
expected = self._passwords[pwd_id]
|
||||
covered = bytes(data[po + 1:po + 1 + len(expected)])
|
||||
if len(covered) != len(expected):
|
||||
return self._make_error(0x0F)
|
||||
if self._uncover(covered) != expected:
|
||||
self._sessions.clear() # wrong password closes all sessions
|
||||
return self._make_error(0x0F)
|
||||
self._sessions = {pwd_id} # opening a session closes the previous one
|
||||
return self._make_response(b"")
|
||||
|
||||
def _handle_write_password(self, data: bytes, po: int) -> RFFrame | None:
|
||||
if len(data) < po + 1:
|
||||
return self._make_error(0x0F)
|
||||
pwd_id = data[po]
|
||||
if pwd_id not in self._passwords:
|
||||
return self._make_error(0x10)
|
||||
if pwd_id not in self._sessions:
|
||||
return self._make_error(0x12) # update right not granted
|
||||
expected_len = len(self._passwords[pwd_id])
|
||||
covered = bytes(data[po + 1:po + 1 + expected_len])
|
||||
if len(covered) != expected_len:
|
||||
return self._make_error(0x0F)
|
||||
self._passwords[pwd_id] = self._uncover(covered)
|
||||
return self._make_response(b"")
|
||||
|
||||
def _handle_kill(self, data: bytes, po: int) -> RFFrame | None:
|
||||
if self._privacy & 0x08: # DIS_KILL
|
||||
return self._make_error(0x0F)
|
||||
if len(data) < po + 1:
|
||||
return self._make_error(0x0F)
|
||||
covered = bytes(data[po + 1:po + 5])
|
||||
if self._uncover(covered) != self._passwords[PWD_CFG]:
|
||||
return self._make_error(0x0F)
|
||||
self._killed = True
|
||||
return self._make_response(b"")
|
||||
|
||||
def _handle_toggle_ds(self, cmd: int, frame: RFFrame, addressed: bool) -> RFFrame | None:
|
||||
po = self._params_offset(addressed)
|
||||
data = frame.data
|
||||
if len(data) < po + 5:
|
||||
return self._make_error(0x0F)
|
||||
covered = bytes(data[po + 1:po + 5])
|
||||
if self._uncover(covered) != self._passwords[PWD_PRIV]:
|
||||
return self._make_error(0x0F)
|
||||
if addressed:
|
||||
# enter discreet/silent per DS_MODE
|
||||
self._ds_state = "SILENT" if (self._privacy & 0x04) else "DISCREET"
|
||||
else:
|
||||
self._ds_state = "READY"
|
||||
return self._make_response(b"")
|
||||
|
||||
def _handle_initiate(self, addressed: bool) -> RFFrame | None:
|
||||
if addressed:
|
||||
return None # Initiate is non-addressed only
|
||||
self._initiated = True
|
||||
return RFFrame.from_bytes(bytes([0x00, self._dsfid]) + self._uid[::-1])
|
||||
|
||||
def _handle_inventory_initiated(self) -> RFFrame | None:
|
||||
if not self._initiated:
|
||||
return None
|
||||
return RFFrame.from_bytes(bytes([0x00, self._dsfid]) + self._uid[::-1])
|
||||
|
||||
# ----- configuration registers -----
|
||||
|
||||
def _read_config_value(self, fid: int, pid: int) -> bytes | None:
|
||||
if fid == 0x00 and pid == 0x00:
|
||||
return bytes([self._rwprot[PWD_A1]])
|
||||
if fid == 0x00 and pid == 0x01:
|
||||
return bytes([self._end_a1])
|
||||
if fid == 0x01 and pid == 0x00:
|
||||
return bytes([self._rwprot[PWD_A2]])
|
||||
if fid == 0x05 and pid == 0x00:
|
||||
return bytes([self._privacy])
|
||||
if fid == 0xFE and pid == 0x00:
|
||||
return bytes([0x12]) # REV
|
||||
if fid == 0xFE and pid == 0x01:
|
||||
return self._uid[::-1] # UID, always unmasked
|
||||
return None
|
||||
|
||||
def _handle_read_config(self, data: bytes, po: int) -> RFFrame | None:
|
||||
if len(data) < po + 2:
|
||||
return self._make_error(0x0F)
|
||||
val = self._read_config_value(data[po], data[po + 1])
|
||||
if val is None:
|
||||
return self._make_error(0x10)
|
||||
return self._make_response(val)
|
||||
|
||||
def _handle_write_config(self, data: bytes, po: int) -> RFFrame | None:
|
||||
if PWD_CFG not in self._sessions:
|
||||
return self._make_error(0x12) # config session not open
|
||||
if len(data) < po + 3:
|
||||
return self._make_error(0x0F)
|
||||
fid, pid, val = data[po], data[po + 1], data[po + 2]
|
||||
if fid == 0x00 and pid == 0x00:
|
||||
self._rwprot[PWD_A1] = val & 0x03
|
||||
elif fid == 0x00 and pid == 0x01:
|
||||
self._end_a1 = val
|
||||
elif fid == 0x01 and pid == 0x00:
|
||||
self._rwprot[PWD_A2] = val & 0x03
|
||||
elif fid == 0x05 and pid == 0x00:
|
||||
self._privacy = val
|
||||
else:
|
||||
return self._make_error(0x10)
|
||||
return self._make_response(b"")
|
||||
|
||||
# ----- area read/write protection -----
|
||||
|
||||
def _area_of(self, block: int) -> int:
|
||||
return PWD_A1 if block <= self._end_a1 else PWD_A2
|
||||
|
||||
def _read_allowed(self, block: int) -> bool:
|
||||
if block == 0:
|
||||
return True # CC block always readable
|
||||
area = self._area_of(block)
|
||||
prot = self._rwprot[area]
|
||||
# RW_PROTECTION bits: 0b10/0b11 require a session to read (A2); A1 read
|
||||
# is always allowed except 0b11.
|
||||
if area == PWD_A1:
|
||||
need = (prot == 0x03)
|
||||
else:
|
||||
need = (prot >= 0x02)
|
||||
return (not need) or (area in self._sessions)
|
||||
|
||||
def _write_allowed(self, block: int) -> bool:
|
||||
area = self._area_of(block)
|
||||
prot = self._rwprot[area]
|
||||
if prot == 0x03:
|
||||
return False # write always forbidden
|
||||
if prot == 0x00:
|
||||
return True
|
||||
return area in self._sessions
|
||||
|
||||
def _handle_read_single(self, frame: RFFrame) -> RFFrame | None:
|
||||
block = self._extract_block_number(frame)
|
||||
if block is None:
|
||||
return None
|
||||
if not self._read_allowed(block):
|
||||
return self._make_error(0x15) # block read-protected
|
||||
return super()._handle_read_single(frame)
|
||||
|
||||
def _handle_write_single(self, frame: RFFrame) -> RFFrame | None:
|
||||
block = self._extract_block_number(frame)
|
||||
if block is not None and not self._write_allowed(block):
|
||||
return self._make_error(0x12)
|
||||
return super()._handle_write_single(frame)
|
||||
|
||||
|
||||
class ST25TV512C(ST25TV):
|
||||
"""ST25TV512C — 64 bytes user (16 blocks, END_MEM=0Fh)."""
|
||||
_num_blocks_cls = 16
|
||||
|
||||
|
||||
class ST25TV02KC(ST25TV):
|
||||
"""ST25TV02KC — 320 bytes user (80 blocks, END_MEM=4Fh)."""
|
||||
_num_blocks_cls = 80
|
||||
1
pm3py/transponders/hf/iso15693/ti/__init__.py
Normal file
1
pm3py/transponders/hf/iso15693/ti/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Texas Instruments ISO 15693 transponders (Tag-it HF-I, RF430FRL15xH)."""
|
||||
50
pm3py/transponders/hf/iso15693/ti/rf430frl.py
Normal file
50
pm3py/transponders/hf/iso15693/ti/rf430frl.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""RF430FRL15xH — Texas Instruments NFC ISO 15693 Sensor Transponder.
|
||||
|
||||
Sourced from TI SLAS834C. MSP430-based ISO/IEC 15693 + ISO 18000-3 Mode 1 sensor
|
||||
transponder (FRL152H/153H/154H) with a 14-bit sigma-delta ADC and temperature
|
||||
sensor. The RF command behaviour is provided by the on-chip ROM firmware, whose
|
||||
command set — the ISO 15693 block map, the custom ADC/sensor-trigger commands
|
||||
and the result-block mapping — is documented in the *Firmware User's Guide*
|
||||
(SLAU603), which is NOT among the supplied datasheets.
|
||||
|
||||
This model therefore implements what SLAS834C fully specifies: the ISO 15693
|
||||
identity (8-byte UID E0 07 A2/A3 + 5-byte serial, per-device Device ID) and the
|
||||
standard ISO 15693 block interface. The custom sensor commands are left as a
|
||||
documented gap pending SLAU603.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from ..base import Tag15693
|
||||
|
||||
|
||||
class RF430FRL15xH(Tag15693):
|
||||
"""Base for the RF430FRL15xH family (standard ISO 15693 interface)."""
|
||||
|
||||
_uid_prefix = b"\xE0\x07\xA2" # E0 + TI mfg 07 + UID5 (0xA2; 0xA3 if TI[40]=1)
|
||||
_device_id: bytes = b"\xE7\x81" # SLAS834C Device Descriptor Device ID
|
||||
|
||||
def __init__(self, uid=None, dsfid: int = 0, afi: int = 0,
|
||||
num_blocks: int = 64):
|
||||
# SLAS834C does not fix the RF block count/size (that is in SLAU603);
|
||||
# default to 64 x 4-byte blocks as a generic user area.
|
||||
super().__init__(uid=uid, dsfid=dsfid, afi=afi,
|
||||
block_size=4, num_blocks=num_blocks, ic_reference=0x00)
|
||||
|
||||
@property
|
||||
def device_id(self) -> bytes:
|
||||
return self._device_id
|
||||
|
||||
|
||||
class RF430FRL152H(RF430FRL15xH):
|
||||
"""RF430FRL152H — Device ID 0x81E7."""
|
||||
_device_id = b"\xE7\x81"
|
||||
|
||||
|
||||
class RF430FRL153H(RF430FRL15xH):
|
||||
"""RF430FRL153H — Device ID 0x81FB."""
|
||||
_device_id = b"\xFB\x81"
|
||||
|
||||
|
||||
class RF430FRL154H(RF430FRL15xH):
|
||||
"""RF430FRL154H — Device ID 0x81FC."""
|
||||
_device_id = b"\xFC\x81"
|
||||
156
pm3py/transponders/hf/iso15693/ti/tagit.py
Normal file
156
pm3py/transponders/hf/iso15693/ti/tagit.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""Tag-it HF-I Plus — Texas Instruments ISO 15693 transponder.
|
||||
|
||||
Sourced from TI SCBU046 (Tag-it HF-I Plus Reference Guide). 2 kbit user memory
|
||||
in 64 blocks of 4 bytes, TI ISO 15693 manufacturer code 0x07. Adds the two TI
|
||||
custom commands Write_2_Blocks (0xA2) and Lock_2_Blocks (0xA3); Write Multiple
|
||||
Blocks (0x24) is deliberately NOT supported (per the datasheet command table).
|
||||
|
||||
The datasheet is a hardware reference guide and does not print UID prefix bytes,
|
||||
GET SYSTEM INFO layout, IC reference, or default AFI/DSFID — those follow the
|
||||
ISO 15693 convention (E0 07 …, memsize = blocks-1 / block_size-1).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pm3py.sim.frame import RFFrame
|
||||
from ..base import Tag15693, FLAG_ADDRESS, FLAG_INVENTORY
|
||||
|
||||
TI_MFG = 0x07
|
||||
|
||||
CMD_LOCK_BLOCK = 0x22
|
||||
CMD_READ_MULTIPLE = 0x23
|
||||
CMD_WRITE_MULTIPLE = 0x24 # not supported by Tag-it HF-I Plus
|
||||
CMD_SELECT = 0x25
|
||||
CMD_WRITE_2_BLOCKS = 0xA2 # TI custom
|
||||
CMD_LOCK_2_BLOCKS = 0xA3 # TI custom
|
||||
|
||||
|
||||
class TagItHFIPlus(Tag15693):
|
||||
"""TI Tag-it HF-I Plus — 64 blocks x 4 bytes, TI mfg code 0x07."""
|
||||
|
||||
_uid_prefix = b"\xE0\x07" # ISO 15693 magic + TI manufacturer code
|
||||
|
||||
def __init__(self, uid=None, dsfid: int = 0, afi: int = 0):
|
||||
super().__init__(uid=uid, dsfid=dsfid, afi=afi,
|
||||
block_size=4, num_blocks=64, ic_reference=0x00)
|
||||
self._locks = [False] * self._num_blocks # user lock bit per block
|
||||
|
||||
def _addr_offset(self, frame: RFFrame) -> int:
|
||||
flags = frame.data[0]
|
||||
addressed = not (flags & FLAG_INVENTORY) and (flags & FLAG_ADDRESS)
|
||||
return 10 if addressed else 2
|
||||
|
||||
def _addressed_mismatch(self, frame: RFFrame) -> bool:
|
||||
"""True if the frame is addressed to a different tag (standard layout)."""
|
||||
flags = frame.data[0]
|
||||
if (flags & FLAG_INVENTORY) or not (flags & FLAG_ADDRESS):
|
||||
return False
|
||||
return len(frame.data) < 10 or bytes(reversed(frame.data[2:10])) != self._uid
|
||||
|
||||
async def handle_frame(self, frame: RFFrame) -> RFFrame | None:
|
||||
if len(frame.data) < 2:
|
||||
return None
|
||||
cmd = frame.data[1]
|
||||
|
||||
# Custom commands (0xA0+) carry the mfg code at data[2], so the UID (if
|
||||
# addressed) sits at data[3:11] — the base's data[2:10] UID gate would
|
||||
# wrongly reject them. Route them straight to the custom handler, which
|
||||
# performs its own addressed-UID check.
|
||||
if cmd >= 0xA0:
|
||||
return self._handle_custom_command(cmd, frame)
|
||||
|
||||
# Standard commands we intercept before super() must honour the
|
||||
# addressed-UID filter themselves (super() won't run for them).
|
||||
if cmd in (CMD_WRITE_MULTIPLE, CMD_LOCK_BLOCK, CMD_READ_MULTIPLE, CMD_SELECT):
|
||||
if self._addressed_mismatch(frame):
|
||||
from ..base import State15693
|
||||
if cmd == CMD_SELECT and self._state == State15693.SELECTED:
|
||||
self._state = State15693.READY # deselect on non-matching SELECT
|
||||
return None
|
||||
if cmd == CMD_WRITE_MULTIPLE:
|
||||
return self._make_error(0x01) # not supported on Tag-it
|
||||
if cmd == CMD_LOCK_BLOCK:
|
||||
return self._handle_lock_block(frame)
|
||||
if cmd == CMD_READ_MULTIPLE:
|
||||
return self._handle_read_multiple(frame)
|
||||
if cmd == CMD_SELECT:
|
||||
from ..base import State15693
|
||||
self._state = State15693.SELECTED
|
||||
return self._make_response(b"")
|
||||
|
||||
return await super().handle_frame(frame)
|
||||
|
||||
# ----- block locking -----
|
||||
|
||||
def _handle_write_single(self, frame: RFFrame) -> RFFrame | None:
|
||||
block = self._extract_block_number(frame)
|
||||
if block is not None and block < self._num_blocks and self._locks[block]:
|
||||
return self._make_error(0x12) # block locked, cannot change
|
||||
return super()._handle_write_single(frame)
|
||||
|
||||
def _handle_lock_block(self, frame: RFFrame) -> RFFrame | None:
|
||||
off = self._addr_offset(frame)
|
||||
if len(frame.data) <= off:
|
||||
return None
|
||||
block = frame.data[off]
|
||||
if block >= self._num_blocks:
|
||||
return self._make_error(0x10)
|
||||
if self._locks[block]:
|
||||
return self._make_error(0x11) # already locked
|
||||
self._locks[block] = True
|
||||
return self._make_response(b"")
|
||||
|
||||
def _handle_read_multiple(self, frame: RFFrame) -> RFFrame | None:
|
||||
off = self._addr_offset(frame)
|
||||
if len(frame.data) < off + 2:
|
||||
return None
|
||||
first = frame.data[off]
|
||||
count = frame.data[off + 1] + 1
|
||||
if first + count > self._num_blocks:
|
||||
return self._make_error(0x10)
|
||||
out = bytearray()
|
||||
for i in range(count):
|
||||
b = (first + i) * self._block_size
|
||||
out += self._memory[b:b + self._block_size]
|
||||
return self._make_response(bytes(out))
|
||||
|
||||
# ----- TI custom commands (0xA2 / 0xA3, mfg 0x07) -----
|
||||
|
||||
def _handle_custom_command(self, cmd: int, frame: RFFrame) -> RFFrame | None:
|
||||
data = frame.data
|
||||
if len(data) < 3 or data[2] != TI_MFG:
|
||||
return self._make_error(0x01)
|
||||
flags = data[0]
|
||||
addressed = not (flags & FLAG_INVENTORY) and (flags & FLAG_ADDRESS)
|
||||
if addressed and (len(data) < 11 or bytes(reversed(data[3:11])) != self._uid):
|
||||
return None
|
||||
po = 3 + (8 if addressed else 0) # after flags,cmd,mfg [+uid]
|
||||
|
||||
if cmd == CMD_WRITE_2_BLOCKS:
|
||||
return self._handle_write_2(data, po)
|
||||
if cmd == CMD_LOCK_2_BLOCKS:
|
||||
return self._handle_lock_2(data, po)
|
||||
return self._make_error(0x01)
|
||||
|
||||
def _handle_write_2(self, data: bytes, po: int) -> RFFrame | None:
|
||||
if len(data) < po + 1 + 8:
|
||||
return self._make_error(0x0F)
|
||||
block = data[po]
|
||||
if block + 1 >= self._num_blocks:
|
||||
return self._make_error(0x10)
|
||||
for i in (0, 1):
|
||||
if self._locks[block + i]:
|
||||
return self._make_error(0x12)
|
||||
payload = data[po + 1:po + 9]
|
||||
off = block * self._block_size
|
||||
self._memory[off:off + 8] = payload
|
||||
return self._make_response(b"")
|
||||
|
||||
def _handle_lock_2(self, data: bytes, po: int) -> RFFrame | None:
|
||||
if len(data) < po + 1:
|
||||
return self._make_error(0x0F)
|
||||
block = data[po]
|
||||
if block + 1 >= self._num_blocks:
|
||||
return self._make_error(0x10)
|
||||
self._locks[block] = True
|
||||
self._locks[block + 1] = True
|
||||
return self._make_response(b"")
|
||||
@@ -5,6 +5,7 @@ import os
|
||||
|
||||
from .lf.atmel.t5577 import T5577Tag
|
||||
from .hf.iso14443a.ndef import NfcType2Tag
|
||||
from .hf.iso14443a.nxp.ntag21x import NTAG216
|
||||
from .hf.iso14443a.nxp.mifare_classic import MifareClassicTag
|
||||
from .hf.iso14443a.nxp.desfire import DesfireTag
|
||||
|
||||
@@ -19,13 +20,15 @@ def xEM(tag_id: int = 0x0000000000) -> T5577Tag:
|
||||
return T5577Tag.preset("em4100", tag_id=tag_id)
|
||||
|
||||
|
||||
def xNT(uid: bytes | None = None, ndef_message: bytes = b"") -> NfcType2Tag:
|
||||
"""xNT implant — NTAG216, 888 bytes, NDEF capable."""
|
||||
def xNT(uid: bytes | None = None, ndef_message: bytes = b"") -> NTAG216:
|
||||
"""xNT implant — NTAG216, 888 bytes user, NDEF capable.
|
||||
|
||||
Full NTAG216 model: GET_VERSION, FAST_READ, PWD_AUTH, READ_SIG and the
|
||||
NFC counter, not just the generic Type 2 READ/WRITE.
|
||||
"""
|
||||
if uid is None:
|
||||
uid = _random_uid(7)
|
||||
return NfcType2Tag(uid=uid, ndef_message=ndef_message,
|
||||
total_pages=231, # NTAG216: 924 bytes / 4
|
||||
atqa=b"\x44\x00", sak=0x00)
|
||||
return NTAG216(uid=uid, ndef_message=ndef_message)
|
||||
|
||||
|
||||
def xM1(uid: bytes | None = None) -> MifareClassicTag:
|
||||
|
||||
Binary file not shown.
85
tests/test_sim_myd_vicinity.py
Normal file
85
tests/test_sim_myd_vicinity.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""Tests for the Infineon my-d vicinity (SRF55V02P / SRF55V10P)."""
|
||||
import asyncio
|
||||
import pytest
|
||||
|
||||
from pm3py.sim.frame import RFFrame
|
||||
from pm3py.transponders.hf.iso15693.infineon.myd_vicinity import SRF55V02P, SRF55V10P
|
||||
|
||||
|
||||
def run(coro):
|
||||
return asyncio.new_event_loop().run_until_complete(coro)
|
||||
|
||||
|
||||
def cmd(tag, payload: bytes):
|
||||
return run(tag.handle_frame(RFFrame.from_bytes(payload)))
|
||||
|
||||
|
||||
class TestIdentity:
|
||||
@pytest.mark.parametrize("cls,prefix,blocks", [
|
||||
(SRF55V02P, b"\xE0\x05\x40", 56),
|
||||
(SRF55V10P, b"\xE0\x05\x00", 248),
|
||||
])
|
||||
def test_uid_and_blocks(self, cls, prefix, blocks):
|
||||
tag = cls()
|
||||
assert tag._uid[0:3] == prefix
|
||||
assert tag._num_blocks == blocks
|
||||
|
||||
def test_infineon_mfg_code(self):
|
||||
assert SRF55V02P()._uid[1] == 0x05
|
||||
|
||||
def test_no_get_system_info(self):
|
||||
tag = SRF55V02P()
|
||||
run(tag.power_on())
|
||||
r = cmd(tag, bytes([0x02, 0x2B]))
|
||||
assert r.data[0] == 0x01 and r.data[1] == 0x01 # command not supported
|
||||
|
||||
def test_inventory(self):
|
||||
tag = SRF55V02P()
|
||||
run(tag.power_on())
|
||||
r = cmd(tag, bytes([0x26, 0x01, 0x00]))
|
||||
assert r.data[0] == 0x00
|
||||
assert bytes(reversed(r.data[2:10])) == tag._uid
|
||||
|
||||
|
||||
class TestBlockMode:
|
||||
def test_iso_read_write(self):
|
||||
tag = SRF55V02P()
|
||||
run(tag.power_on())
|
||||
cmd(tag, bytes([0x02, 0x21, 0x05, 0xCA, 0xFE, 0xBA, 0xBE]))
|
||||
assert cmd(tag, bytes([0x02, 0x20, 0x05])).data[1:5] == b"\xCA\xFE\xBA\xBE"
|
||||
|
||||
|
||||
class TestPageMode:
|
||||
def test_custom_read_page_reflects_blocks(self):
|
||||
tag = SRF55V02P()
|
||||
run(tag.power_on())
|
||||
# block 5 lives in page 2 (bytes 4-7 of the 8-byte page)
|
||||
cmd(tag, bytes([0x02, 0x21, 0x05, 0xCA, 0xFE, 0xBA, 0xBE]))
|
||||
r = cmd(tag, bytes([0x02, 0xA0, 0x05, 0x10, 0x02, 0x00]))
|
||||
assert len(r.data) == 9
|
||||
assert r.data[5:9] == b"\xCA\xFE\xBA\xBE"
|
||||
|
||||
def test_custom_write_page(self):
|
||||
tag = SRF55V02P()
|
||||
run(tag.power_on())
|
||||
cmd(tag, bytes([0x02, 0xA0, 0x05, 0x30, 0x03, 0x00]) + bytes(range(8)))
|
||||
r = cmd(tag, bytes([0x02, 0xA0, 0x05, 0x10, 0x03, 0x00]))
|
||||
assert r.data[1:9] == bytes(range(8))
|
||||
|
||||
def test_write_and_reread(self):
|
||||
tag = SRF55V02P()
|
||||
run(tag.power_on())
|
||||
r = cmd(tag, bytes([0x02, 0xA0, 0x05, 0xB0, 0x04, 0x00]) + bytes([9] * 8))
|
||||
assert r.data[0] == 0x00 and r.data[1:9] == bytes([9] * 8)
|
||||
|
||||
|
||||
class TestValueCounter:
|
||||
def test_decrement_only(self):
|
||||
tag = SRF55V02P()
|
||||
run(tag.power_on())
|
||||
# set page 6 counter0 to 16
|
||||
cmd(tag, bytes([0x02, 0xA0, 0x05, 0x30, 0x06, 0x00]) + bytes([0x10, 0, 0, 0, 0, 0, 0, 0]))
|
||||
# decrement to 5 → ok
|
||||
assert cmd(tag, bytes([0x02, 0xA0, 0x05, 0x00, 0x06, 0x00]) + bytes([5, 0, 0, 0])).data[0] == 0x00
|
||||
# increment to 9 → rejected
|
||||
assert cmd(tag, bytes([0x02, 0xA0, 0x05, 0x00, 0x06, 0x00]) + bytes([9, 0, 0, 0])).data[0] == 0x01
|
||||
220
tests/test_sim_ntag21x.py
Normal file
220
tests/test_sim_ntag21x.py
Normal file
@@ -0,0 +1,220 @@
|
||||
"""Tests for the NTAG21x models (NTAG210/212/213/215/216)."""
|
||||
import asyncio
|
||||
import pytest
|
||||
|
||||
from pm3py.sim.frame import RFFrame
|
||||
from pm3py.transponders.hf.iso14443a.base import _compute_bcc
|
||||
from pm3py.transponders.hf.iso14443a.nxp.ntag21x import (
|
||||
NTAG210, NTAG212, NTAG213, NTAG215, NTAG216, Ntag21x,
|
||||
)
|
||||
|
||||
UID = b"\x04\x01\x02\x03\x04\x05\x06"
|
||||
|
||||
|
||||
def run(coro):
|
||||
return asyncio.new_event_loop().run_until_complete(coro)
|
||||
|
||||
|
||||
def select(tag):
|
||||
"""Bring a 7-byte-UID tag through REQA + 2-cascade SELECT to ACTIVE."""
|
||||
run(tag.handle_frame(RFFrame.from_hex("26")))
|
||||
uid = tag._uid
|
||||
ct = b"\x88" + uid[0:3]
|
||||
run(tag.handle_frame(RFFrame.from_bytes(b"\x93\x70" + ct + bytes([_compute_bcc(ct)]))))
|
||||
cl2 = uid[3:7]
|
||||
run(tag.handle_frame(RFFrame.from_bytes(b"\x95\x70" + cl2 + bytes([_compute_bcc(cl2)]))))
|
||||
assert tag.state == "ACTIVE"
|
||||
|
||||
|
||||
def activate(cls, **kw):
|
||||
tag = cls(uid=UID, **kw)
|
||||
run(tag.power_on())
|
||||
select(tag)
|
||||
return tag
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Identity / GET_VERSION
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestVersion:
|
||||
CASES = {
|
||||
NTAG210: b"\x00\x04\x04\x01\x01\x00\x0B\x03",
|
||||
NTAG212: b"\x00\x04\x04\x01\x01\x00\x0E\x03",
|
||||
NTAG213: b"\x00\x04\x04\x02\x01\x00\x0F\x03",
|
||||
NTAG215: b"\x00\x04\x04\x02\x01\x00\x11\x03",
|
||||
NTAG216: b"\x00\x04\x04\x02\x01\x00\x13\x03",
|
||||
}
|
||||
|
||||
@pytest.mark.parametrize("cls,version", CASES.items())
|
||||
def test_get_version(self, cls, version):
|
||||
tag = activate(cls)
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(b"\x60")))
|
||||
assert resp.data == version
|
||||
|
||||
@pytest.mark.parametrize("cls,pages", [
|
||||
(NTAG210, 20), (NTAG212, 41), (NTAG213, 45), (NTAG215, 135), (NTAG216, 231),
|
||||
])
|
||||
def test_page_count(self, cls, pages):
|
||||
assert cls(uid=UID)._total_pages == pages
|
||||
|
||||
def test_atqa_sak(self):
|
||||
tag = NTAG213(uid=UID)
|
||||
run(tag.power_on())
|
||||
assert run(tag.handle_frame(RFFrame.from_hex("26"))).data == b"\x44\x00"
|
||||
select(tag) # SAK 0x00 verified via ACTIVE reach with 2 cascades
|
||||
|
||||
def test_cc_defaults(self):
|
||||
assert NTAG213(uid=UID)._get_page(3) == b"\xE1\x10\x12\x00"
|
||||
assert NTAG215(uid=UID)._get_page(3) == b"\xE1\x10\x3E\x00"
|
||||
assert NTAG216(uid=UID)._get_page(3) == b"\xE1\x10\x6D\x00"
|
||||
assert NTAG210(uid=UID)._get_page(3) == b"\xE1\x10\x06\x00"
|
||||
assert NTAG212(uid=UID)._get_page(3) == b"\xE1\x10\x10\x00"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# READ / FAST_READ / WRITE
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestReadWrite:
|
||||
def test_read_returns_16_bytes(self):
|
||||
tag = activate(NTAG213)
|
||||
assert len(run(tag.handle_frame(RFFrame.from_bytes(b"\x30\x00"))).data) == 16
|
||||
|
||||
def test_read_rolls_over(self):
|
||||
tag = activate(NTAG213) # 45 pages
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(b"\x30\x2C"))) # last page
|
||||
assert len(resp.data) == 16 # wraps to page 0
|
||||
|
||||
def test_read_out_of_range_naks(self):
|
||||
tag = activate(NTAG213)
|
||||
assert run(tag.handle_frame(RFFrame.from_bytes(b"\x30\xFF"))).data == b"\x00"
|
||||
|
||||
def test_fast_read(self):
|
||||
tag = activate(NTAG216)
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(b"\x3A\x04\x07")))
|
||||
assert len(resp.data) == 16 # pages 4..7 inclusive
|
||||
|
||||
def test_fast_read_bad_range_naks(self):
|
||||
tag = activate(NTAG213)
|
||||
assert run(tag.handle_frame(RFFrame.from_bytes(b"\x3A\x05\x04"))).data == b"\x00"
|
||||
|
||||
def test_write_and_readback(self):
|
||||
tag = activate(NTAG215)
|
||||
assert run(tag.handle_frame(RFFrame.from_bytes(b"\xA2\x08\xDE\xAD\xBE\xEF"))).data == b"\x0A"
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(b"\x30\x08")))
|
||||
assert resp.data[0:4] == b"\xDE\xAD\xBE\xEF"
|
||||
|
||||
def test_write_uid_page_naks(self):
|
||||
tag = activate(NTAG213)
|
||||
assert run(tag.handle_frame(RFFrame.from_bytes(b"\xA2\x00\x01\x02\x03\x04"))).data == b"\x00"
|
||||
|
||||
def test_compat_write_two_phase(self):
|
||||
tag = activate(NTAG213)
|
||||
assert run(tag.handle_frame(RFFrame.from_bytes(b"\xA0\x06"))).data == b"\x0A"
|
||||
data = b"\x11\x22\x33\x44" + bytes(12)
|
||||
assert run(tag.handle_frame(RFFrame.from_bytes(data))).data == b"\x0A"
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(b"\x30\x06")))
|
||||
assert resp.data[0:4] == b"\x11\x22\x33\x44"
|
||||
|
||||
def test_otp_or_on_write(self):
|
||||
tag = activate(NTAG213)
|
||||
# NTAG page 3 is CC; OR semantics — writes set bits, never clear.
|
||||
run(tag.handle_frame(RFFrame.from_bytes(b"\xA2\x03\x00\x00\x00\x0F")))
|
||||
cc = run(tag.handle_frame(RFFrame.from_bytes(b"\x30\x03"))).data[0:4]
|
||||
assert cc == b"\xE1\x10\x12\x0F" # original OR new bits
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# READ_SIG
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestReadSig:
|
||||
def test_read_sig_32_bytes(self):
|
||||
sig = bytes(range(32))
|
||||
tag = NTAG216(uid=UID, signature=sig)
|
||||
run(tag.power_on()); select(tag)
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(b"\x3C\x00")))
|
||||
assert resp.data == sig
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PWD_AUTH + AUTH0 protection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestPwdAuth:
|
||||
def test_pwd_auth_returns_pack(self):
|
||||
tag = activate(NTAG213, password=b"\x11\x22\x33\x44", pack=b"\xAA\xBB")
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(b"\x1B\x11\x22\x33\x44")))
|
||||
assert resp.data == b"\xAA\xBB"
|
||||
|
||||
def test_wrong_pwd_naks(self):
|
||||
tag = activate(NTAG213, password=b"\x11\x22\x33\x44")
|
||||
assert run(tag.handle_frame(RFFrame.from_bytes(b"\x1B\x00\x00\x00\x00"))).data == b"\x04"
|
||||
|
||||
def test_read_protection_gates_on_auth0_and_prot(self):
|
||||
tag = activate(NTAG213, password=b"\x11\x22\x33\x44", pack=b"\xAA\xBB",
|
||||
auth0=0x10, prot=True)
|
||||
# page above AUTH0 blocked pre-auth
|
||||
assert run(tag.handle_frame(RFFrame.from_bytes(b"\x30\x20"))).data == b"\x00"
|
||||
# page below AUTH0 open
|
||||
assert len(run(tag.handle_frame(RFFrame.from_bytes(b"\x30\x08"))).data) == 16
|
||||
# authenticate, then protected read works
|
||||
run(tag.handle_frame(RFFrame.from_bytes(b"\x1B\x11\x22\x33\x44")))
|
||||
assert len(run(tag.handle_frame(RFFrame.from_bytes(b"\x30\x20"))).data) == 16
|
||||
|
||||
def test_write_protection_without_prot(self):
|
||||
# prot=False → write protected above AUTH0, but reads still open.
|
||||
tag = activate(NTAG213, password=b"\x11\x22\x33\x44", auth0=0x10, prot=False)
|
||||
assert len(run(tag.handle_frame(RFFrame.from_bytes(b"\x30\x20"))).data) == 16 # read open
|
||||
assert run(tag.handle_frame(RFFrame.from_bytes(b"\xA2\x20\x01\x02\x03\x04"))).data == b"\x00"
|
||||
|
||||
def test_pwd_pack_read_back_as_zero(self):
|
||||
tag = activate(NTAG213, password=b"\x11\x22\x33\x44", pack=b"\xAA\xBB")
|
||||
pwd_page = NTAG213._pwd_page
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(bytes([0x30, pwd_page]))))
|
||||
assert resp.data[0:4] == b"\x00\x00\x00\x00"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# NFC counter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestNfcCounter:
|
||||
def test_counter_bumps_once_per_power_cycle(self):
|
||||
tag = activate(NTAG216, nfc_cnt_en=True)
|
||||
run(tag.handle_frame(RFFrame.from_bytes(b"\x30\x00")))
|
||||
run(tag.handle_frame(RFFrame.from_bytes(b"\x30\x04"))) # 2nd read, same cycle
|
||||
cnt = run(tag.handle_frame(RFFrame.from_bytes(b"\x39\x02"))).data
|
||||
assert int.from_bytes(cnt, "little") == 1
|
||||
run(tag.power_on()); select(tag)
|
||||
run(tag.handle_frame(RFFrame.from_bytes(b"\x30\x00")))
|
||||
cnt = run(tag.handle_frame(RFFrame.from_bytes(b"\x39\x02"))).data
|
||||
assert int.from_bytes(cnt, "little") == 2
|
||||
|
||||
def test_counter_disabled_naks(self):
|
||||
tag = activate(NTAG213, nfc_cnt_en=False)
|
||||
assert run(tag.handle_frame(RFFrame.from_bytes(b"\x39\x02"))).data == b"\x00"
|
||||
|
||||
def test_ntag210_212_have_no_counter(self):
|
||||
for cls in (NTAG210, NTAG212):
|
||||
tag = activate(cls)
|
||||
# READ_CNT is not a command for these ICs → no response
|
||||
assert run(tag.handle_frame(RFFrame.from_bytes(b"\x39\x02"))) is None
|
||||
assert cls._has_nfc_counter is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Trace decode
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDecode:
|
||||
def test_decode_commands(self):
|
||||
tag = NTAG213(uid=UID)
|
||||
assert tag.decode_trace(0, b"\x30\x04") == "READ p4"
|
||||
assert tag.decode_trace(0, b"\x3A\x04\x10") == "FAST_READ p4-16"
|
||||
assert tag.decode_trace(0, b"\x60") == "GET_VERSION"
|
||||
assert tag.decode_trace(0, b"\x1B\x11\x22\x33\x44") == "PWD_AUTH 11223344"
|
||||
assert tag.decode_trace(0, b"\x39\x02") == "READ_CNT #2"
|
||||
assert tag.decode_trace(1, b"\x0A") == "ACK"
|
||||
assert tag.decode_trace(1, b"\x00") == "NAK 0x0"
|
||||
128
tests/test_sim_ntag_i2c.py
Normal file
128
tests/test_sim_ntag_i2c.py
Normal file
@@ -0,0 +1,128 @@
|
||||
"""Tests for the NTAG I2C plus models (NT3H2111 1k, NT3H2211 2k)."""
|
||||
import asyncio
|
||||
import pytest
|
||||
|
||||
from pm3py.sim.frame import RFFrame
|
||||
from pm3py.transponders.hf.iso14443a.base import _compute_bcc
|
||||
from pm3py.transponders.hf.iso14443a.nxp.ntag_i2c import NT3H2111, NT3H2211
|
||||
|
||||
UID = b"\x04\x01\x02\x03\x04\x05\x06"
|
||||
|
||||
|
||||
def run(coro):
|
||||
return asyncio.new_event_loop().run_until_complete(coro)
|
||||
|
||||
|
||||
def select(tag):
|
||||
run(tag.handle_frame(RFFrame.from_hex("26")))
|
||||
uid = tag._uid
|
||||
ct = b"\x88" + uid[0:3]
|
||||
run(tag.handle_frame(RFFrame.from_bytes(b"\x93\x70" + ct + bytes([_compute_bcc(ct)]))))
|
||||
cl2 = uid[3:7]
|
||||
run(tag.handle_frame(RFFrame.from_bytes(b"\x95\x70" + cl2 + bytes([_compute_bcc(cl2)]))))
|
||||
assert tag.state == "ACTIVE"
|
||||
|
||||
|
||||
def activate(cls, **kw):
|
||||
tag = cls(uid=UID, **kw)
|
||||
run(tag.power_on())
|
||||
select(tag)
|
||||
return tag
|
||||
|
||||
|
||||
def sector_select(tag, sector):
|
||||
r1 = run(tag.handle_frame(RFFrame.from_bytes(b"\xC2\xFF")))
|
||||
r2 = run(tag.handle_frame(RFFrame.from_bytes(bytes([sector, 0, 0, 0]))))
|
||||
return r1, r2
|
||||
|
||||
|
||||
class TestIdentity:
|
||||
@pytest.mark.parametrize("cls,version", [
|
||||
(NT3H2111, b"\x00\x04\x04\x05\x02\x02\x13\x03"),
|
||||
(NT3H2211, b"\x00\x04\x04\x05\x02\x02\x15\x03"),
|
||||
])
|
||||
def test_get_version(self, cls, version):
|
||||
tag = activate(cls)
|
||||
assert run(tag.handle_frame(RFFrame.from_bytes(b"\x60"))).data == version
|
||||
|
||||
def test_cc(self):
|
||||
assert activate(NT3H2111)._get_page(3) == b"\xE1\x10\x6D\x00"
|
||||
assert activate(NT3H2211)._get_page(3) == b"\xE1\x10\xEA\x00"
|
||||
|
||||
|
||||
class TestReadWrite:
|
||||
def test_read_no_rollover_into_invalid(self):
|
||||
tag = activate(NT3H2111)
|
||||
# Page EDh is the last readable session-register page; EEh/EFh/F0h are
|
||||
# invalid, so those bytes come back as 00h rather than wrapping to page 0.
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(b"\x30\xED")))
|
||||
assert len(resp.data) == 16
|
||||
assert resp.data[4:16] == bytes(12)
|
||||
|
||||
def test_read_invalid_start_naks(self):
|
||||
tag = activate(NT3H2111)
|
||||
assert run(tag.handle_frame(RFFrame.from_bytes(b"\x30\xEA"))).data == b"\x00"
|
||||
|
||||
def test_write_readback(self):
|
||||
tag = activate(NT3H2111)
|
||||
assert run(tag.handle_frame(RFFrame.from_bytes(b"\xA2\x10\x01\x02\x03\x04"))).data == b"\x0A"
|
||||
assert run(tag.handle_frame(RFFrame.from_bytes(b"\x30\x10"))).data[0:4] == b"\x01\x02\x03\x04"
|
||||
|
||||
def test_pwd_pack_masked(self):
|
||||
tag = activate(NT3H2111, password=b"\x11\x22\x33\x44", pack=b"\xAA\xBB")
|
||||
assert run(tag.handle_frame(RFFrame.from_bytes(b"\x30\xE5"))).data[0:4] == bytes(4)
|
||||
assert run(tag.handle_frame(RFFrame.from_bytes(b"\x30\xE6"))).data[0:4] == bytes(4)
|
||||
|
||||
|
||||
class TestSectorSelect:
|
||||
def test_packet1_acks(self):
|
||||
tag = activate(NT3H2211)
|
||||
r1, _ = sector_select(tag, 1)
|
||||
assert r1.data == b"\x0A"
|
||||
|
||||
def test_success_is_silent(self):
|
||||
tag = activate(NT3H2211)
|
||||
_, r2 = sector_select(tag, 1)
|
||||
assert r2 is None # passive ACK (>1 ms silence)
|
||||
assert tag._current_sector == 1
|
||||
|
||||
def test_invalid_sector_naks(self):
|
||||
tag = activate(NT3H2211)
|
||||
_, r2 = sector_select(tag, 2) # sector 2 is invalid
|
||||
assert r2.data == b"\x00"
|
||||
|
||||
def test_sector1_only_on_2k(self):
|
||||
tag1k = activate(NT3H2111)
|
||||
_, r2 = sector_select(tag1k, 1)
|
||||
assert r2.data == b"\x00" # 1k has no sector 1
|
||||
|
||||
def test_sector1_user_memory(self):
|
||||
tag = activate(NT3H2211)
|
||||
sector_select(tag, 1)
|
||||
run(tag.handle_frame(RFFrame.from_bytes(b"\xA2\x10\xDE\xAD\xBE\xEF")))
|
||||
assert run(tag.handle_frame(RFFrame.from_bytes(b"\x30\x10"))).data[0:4] == b"\xDE\xAD\xBE\xEF"
|
||||
# switching back to sector 0 keeps the CC visible
|
||||
sector_select(tag, 0)
|
||||
assert run(tag.handle_frame(RFFrame.from_bytes(b"\x30\x03"))).data[0:4] == b"\xE1\x10\xEA\x00"
|
||||
|
||||
def test_sector3_mirrors_session_regs(self):
|
||||
tag = activate(NT3H2211)
|
||||
sector_select(tag, 3)
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(b"\x30\xF8")))
|
||||
assert len(resp.data) == 16
|
||||
|
||||
|
||||
class TestFastWrite:
|
||||
def test_fast_write_sram(self):
|
||||
tag = activate(NT3H2111)
|
||||
payload = bytes(range(64))
|
||||
frame = b"\xA6\xF0\xFF" + payload
|
||||
assert run(tag.handle_frame(RFFrame.from_bytes(frame))).data == b"\x0A"
|
||||
assert bytes(tag._sram) == payload
|
||||
|
||||
|
||||
class TestDecode:
|
||||
def test_sector_select_decode(self):
|
||||
tag = NT3H2111(uid=UID)
|
||||
assert tag.decode_trace(0, b"\xC2\xFF") == "SECTOR_SELECT p1"
|
||||
assert tag.decode_trace(0, b"\x3A\x00\x0F") == "FAST_READ p0-15"
|
||||
181
tests/test_sim_optiga_nbt.py
Normal file
181
tests/test_sim_optiga_nbt.py
Normal file
@@ -0,0 +1,181 @@
|
||||
"""Tests for the Infineon OPTIGA Authenticate NBT (ISO 14443-A, NFC Type 4)."""
|
||||
import asyncio
|
||||
import pytest
|
||||
|
||||
from pm3py.sim.frame import RFFrame
|
||||
from pm3py.transponders.hf.iso14443a.base import _compute_bcc
|
||||
from pm3py.transponders.hf.iso14443a.infineon.optiga_nbt import OptigaAuthenticateNBT
|
||||
|
||||
UID = b"\x05\x84\x89\x02\x95\x43\x00"
|
||||
NDEF = b"\xD1\x01\x04\x54\x02enHi" # 9 bytes
|
||||
|
||||
|
||||
def run(coro):
|
||||
return asyncio.new_event_loop().run_until_complete(coro)
|
||||
|
||||
|
||||
class Session:
|
||||
def __init__(self, tag):
|
||||
self.tag = tag
|
||||
self.blk = 0
|
||||
run(tag.power_on())
|
||||
self.atqa = run(tag.handle_frame(RFFrame.from_hex("26"))).data
|
||||
uid = tag._uid
|
||||
ct = b"\x88" + uid[0:3]
|
||||
run(tag.handle_frame(RFFrame.from_bytes(b"\x93\x70" + ct + bytes([_compute_bcc(ct)]))))
|
||||
r = run(tag.handle_frame(RFFrame.from_bytes(b"\x95\x70" + uid[3:7] + bytes([_compute_bcc(uid[3:7])]))))
|
||||
self.sak = r.data
|
||||
self.ats = run(tag.handle_frame(RFFrame.from_bytes(b"\xE0\x50"))).data
|
||||
|
||||
def apdu(self, hexstr: str) -> bytes:
|
||||
pcb = 0x02 | self.blk
|
||||
self.blk ^= 1
|
||||
return run(self.tag.handle_frame(RFFrame.from_bytes(bytes([pcb]) + bytes.fromhex(hexstr)))).data[1:]
|
||||
|
||||
def select_app(self):
|
||||
return self.apdu("00A4040007D276000085010100")
|
||||
|
||||
def select_file(self, fid):
|
||||
return self.apdu("00A4000C02" + fid)
|
||||
|
||||
|
||||
class TestActivation:
|
||||
def test_atqa_sak(self):
|
||||
s = Session(OptigaAuthenticateNBT(uid=UID))
|
||||
assert s.atqa == b"\x44\x00" # ATQA 0x0044 (7-byte UID)
|
||||
assert s.sak[0] & 0x20 # SAK ISO-DEP bit
|
||||
|
||||
def test_ats_historical_nbt2000(self):
|
||||
s = Session(OptigaAuthenticateNBT(uid=UID))
|
||||
assert s.ats == b"\x0C\x78\x77\x70\x02NBT2000"
|
||||
|
||||
def test_infineon_uid_prefix(self):
|
||||
assert OptigaAuthenticateNBT()._uid[0] == 0x05
|
||||
|
||||
|
||||
class TestFileSystem:
|
||||
def test_cc_contents(self):
|
||||
s = Session(OptigaAuthenticateNBT(uid=UID))
|
||||
s.select_app()
|
||||
assert s.select_file("E103") == b"\x90\x00"
|
||||
cc = s.apdu("00B0000020")
|
||||
assert cc[0:3] == b"\x00\x2F\x20" # CCLEN, mapping ver 2.0
|
||||
assert cc[3:5] == b"\x01\x00" # MLe 0x0100
|
||||
assert cc[5:7] == b"\x00\xFF" # MLc 0x00FF
|
||||
assert cc[7:15] == b"\x04\x06\xE1\x04\x10\x00\x00\x00" # NDEF TLV
|
||||
assert cc[15:23] == b"\x05\x06\xE1\xA1\x04\x00\x00\x00" # proprietary TLV
|
||||
|
||||
def test_ndef_read(self):
|
||||
s = Session(OptigaAuthenticateNBT(uid=UID, ndef_message=NDEF))
|
||||
s.select_app()
|
||||
s.select_file("E104")
|
||||
assert s.apdu("00B0000002")[0:2] == len(NDEF).to_bytes(2, "big")
|
||||
assert s.apdu("00B00002%02X" % len(NDEF))[:-2] == NDEF
|
||||
|
||||
def test_proprietary_files_present(self):
|
||||
s = Session(OptigaAuthenticateNBT(uid=UID))
|
||||
s.select_app()
|
||||
for fid in ("E1A1", "E1A2", "E1A3", "E1A4", "E1AF"):
|
||||
assert s.select_file(fid) == b"\x90\x00"
|
||||
|
||||
def test_cc_write_never(self):
|
||||
s = Session(OptigaAuthenticateNBT(uid=UID))
|
||||
s.select_app()
|
||||
s.select_file("E103")
|
||||
# EF.CC write policy is NEVER → 6982 (offset 0x20 is outside the fixed 02..0E range)
|
||||
assert s.apdu("00D6002001FF") == b"\x69\x82"
|
||||
|
||||
def test_cc_fixed_region_protected(self):
|
||||
s = Session(OptigaAuthenticateNBT(uid=UID))
|
||||
s.select_app()
|
||||
s.select_file("E103")
|
||||
assert s.apdu("00D6000201FF") == b"\x69\x85" # offset 02 in fixed region
|
||||
|
||||
def test_read_binary_6cxx(self):
|
||||
s = Session(OptigaAuthenticateNBT(uid=UID))
|
||||
s.select_app()
|
||||
s.select_file("E103") # 64-byte CC
|
||||
r = s.apdu("00B00000FF") # Le 255 > file size
|
||||
assert r[0] == 0x6C and r[1] == 64
|
||||
|
||||
|
||||
class TestAuthenticate:
|
||||
def test_ecdsa_signature(self):
|
||||
s = Session(OptigaAuthenticateNBT(uid=UID))
|
||||
s.select_app()
|
||||
sig = s.apdu("0088000004DEADBEEF00")
|
||||
assert sig[-2:] == b"\x90\x00"
|
||||
assert sig[0] == 0x30 # DER SEQUENCE
|
||||
# verify the signature against the tag's public key
|
||||
from Crypto.Signature import DSS
|
||||
from Crypto.Hash import SHA256
|
||||
h = SHA256.new(bytes.fromhex("DEADBEEF"))
|
||||
DSS.new(s.tag._ecc_key.public_key(), "fips-186-3", encoding="der").verify(h, sig[:-2])
|
||||
|
||||
def test_empty_challenge_rejected(self):
|
||||
s = Session(OptigaAuthenticateNBT(uid=UID))
|
||||
s.select_app()
|
||||
assert s.apdu("0088000000") == b"\x67\x00"
|
||||
|
||||
|
||||
class TestPasswordManagement:
|
||||
def test_create_and_fap_gating(self):
|
||||
tag = OptigaAuthenticateNBT(uid=UID)
|
||||
s = Session(tag)
|
||||
s.select_app()
|
||||
assert s.apdu("00E10000090111223344AABB0003") == b"\x90\x00" # create pwid 1
|
||||
tag.set_fap(b"\xE1\x04", nfc_read=0x81, nfc_write=0x40) # NDEF read pw-protected
|
||||
# read without verify → 6982
|
||||
s.select_file("E104")
|
||||
assert s.apdu("00B0000002") == b"\x69\x82"
|
||||
# select with read password (tag 52) then read
|
||||
assert s.apdu("00A4000C08E1045204 11223344".replace(" ", ""))[-2:] == b"\x90\x00"
|
||||
assert s.apdu("00B0000002")[-2:] == b"\x90\x00"
|
||||
|
||||
def test_create_password_validation(self):
|
||||
s = Session(OptigaAuthenticateNBT(uid=UID))
|
||||
s.select_app()
|
||||
# password response 0000 is RFU/rejected
|
||||
assert s.apdu("00E1000009011122334400000003") == b"\x6A\x80"
|
||||
|
||||
def test_change_password(self):
|
||||
tag = OptigaAuthenticateNBT(uid=UID)
|
||||
s = Session(tag)
|
||||
s.select_app()
|
||||
s.apdu("00E10000090111223344AABB0003")
|
||||
# CHANGE (P2 b7=1 → 0x41 for pwid 1): new password
|
||||
assert s.apdu("0024004104AABBCCDD") == b"\x90\x00"
|
||||
assert tag._passwords[1].value == b"\xAA\xBB\xCC\xDD"
|
||||
|
||||
def test_delete_password(self):
|
||||
tag = OptigaAuthenticateNBT(uid=UID)
|
||||
s = Session(tag)
|
||||
s.select_app()
|
||||
s.apdu("00E10000090111223344AABB0003")
|
||||
assert s.apdu("00E4000100") == b"\x90\x00" # delete pwid 1
|
||||
assert 1 not in tag._passwords
|
||||
|
||||
|
||||
class TestLifecycle:
|
||||
def test_finalize_personalization(self):
|
||||
tag = OptigaAuthenticateNBT(uid=UID)
|
||||
s = Session(tag)
|
||||
s.select_app()
|
||||
assert tag.lifecycle == "PERSONALIZATION"
|
||||
assert s.apdu("00E2000003BF6300") == b"\x90\x00"
|
||||
assert tag.lifecycle == "OPERATIONAL"
|
||||
# PERSONALIZE DATA rejected in OPERATIONAL
|
||||
assert s.apdu("00E2000003BF6300") == b"\x69\x85"
|
||||
|
||||
def test_get_data_applet_version(self):
|
||||
s = Session(OptigaAuthenticateNBT(uid=UID))
|
||||
s.select_app()
|
||||
assert s.apdu("0030DF3A00")[-2:] == b"\x90\x00"
|
||||
|
||||
|
||||
class TestRobustness:
|
||||
def test_short_apdu_no_crash(self):
|
||||
s = Session(OptigaAuthenticateNBT(uid=UID))
|
||||
s.select_app()
|
||||
assert s.apdu("00B0") == b"\x67\x00" # truncated, no IndexError
|
||||
assert s.apdu("00A4") == b"\x67\x00"
|
||||
143
tests/test_sim_rf430cl330h.py
Normal file
143
tests/test_sim_rf430cl330h.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""Tests for the TI RF430CL330H (ISO 14443-B, NFC Forum Type 4)."""
|
||||
import asyncio
|
||||
import pytest
|
||||
|
||||
from pm3py.sim.frame import RFFrame
|
||||
from pm3py.transponders.hf.iso14443b.ti.rf430cl330h import RF430CL330H
|
||||
|
||||
PUPI = bytes([0x11, 0x22, 0x33, 0x44])
|
||||
NDEF = b"\xD1\x01\x04\x54\x02enHi" # 9 bytes
|
||||
|
||||
|
||||
def run(coro):
|
||||
return asyncio.new_event_loop().run_until_complete(coro)
|
||||
|
||||
|
||||
class Session:
|
||||
def __init__(self, tag):
|
||||
self.tag = tag
|
||||
self.blk = 0
|
||||
run(tag.power_on())
|
||||
self.atqb = run(tag.handle_frame(RFFrame.from_bytes(bytes([0x05, 0x00, 0x00])))).data
|
||||
run(tag.handle_frame(RFFrame.from_bytes(bytes([0x1D]) + tag.pupi + bytes(4))))
|
||||
|
||||
def apdu(self, hexstr: str) -> bytes:
|
||||
pcb = 0x02 | self.blk
|
||||
self.blk ^= 1
|
||||
return run(self.tag.handle_frame(RFFrame.from_bytes(bytes([pcb]) + bytes.fromhex(hexstr)))).data[1:]
|
||||
|
||||
def select_app(self):
|
||||
return self.apdu("00A4040007D276000085010100")
|
||||
|
||||
def select_file(self, fid):
|
||||
return self.apdu("00A4000C02" + fid)
|
||||
|
||||
|
||||
class TestTypeBActivation:
|
||||
def test_reqb_returns_atqb(self):
|
||||
tag = RF430CL330H(pupi=PUPI)
|
||||
run(tag.power_on())
|
||||
r = run(tag.handle_frame(RFFrame.from_bytes(bytes([0x05, 0x00, 0x00]))))
|
||||
assert r.data[0] == 0x50
|
||||
assert r.data[1:5] == PUPI
|
||||
assert len(r.data) == 12
|
||||
assert tag.state == "READY"
|
||||
|
||||
def test_attrib_activates(self):
|
||||
tag = RF430CL330H(pupi=PUPI)
|
||||
run(tag.power_on())
|
||||
run(tag.handle_frame(RFFrame.from_bytes(bytes([0x05, 0x00, 0x00]))))
|
||||
r = run(tag.handle_frame(RFFrame.from_bytes(bytes([0x1D]) + PUPI + bytes(4))))
|
||||
assert r is not None
|
||||
assert tag.state == "ACTIVE"
|
||||
|
||||
def test_attrib_wrong_pupi_ignored(self):
|
||||
tag = RF430CL330H(pupi=PUPI)
|
||||
run(tag.power_on())
|
||||
run(tag.handle_frame(RFFrame.from_bytes(bytes([0x05, 0x00, 0x00]))))
|
||||
assert run(tag.handle_frame(RFFrame.from_bytes(bytes([0x1D]) + bytes(4) + bytes(4)))) is None
|
||||
|
||||
def test_wupb_wakes_halted(self):
|
||||
tag = RF430CL330H(pupi=PUPI)
|
||||
run(tag.power_on())
|
||||
run(tag.handle_frame(RFFrame.from_bytes(bytes([0x05, 0x00, 0x00]))))
|
||||
run(tag.handle_frame(RFFrame.from_bytes(bytes([0x50]) + PUPI))) # HLTB
|
||||
assert tag.state == "HALT"
|
||||
# REQB ignored while halted, WUPB (param bit3) wakes
|
||||
assert run(tag.handle_frame(RFFrame.from_bytes(bytes([0x05, 0x00, 0x00])))) is None
|
||||
r = run(tag.handle_frame(RFFrame.from_bytes(bytes([0x05, 0x00, 0x08]))))
|
||||
assert r.data[0] == 0x50
|
||||
|
||||
def test_atqb_protocol_type_is_iso14443_4(self):
|
||||
tag = RF430CL330H(pupi=PUPI)
|
||||
run(tag.power_on())
|
||||
atqb = run(tag.handle_frame(RFFrame.from_bytes(bytes([0x05, 0x00, 0x00])))).data
|
||||
# ProtInfo byte 1 low nibble = 1 → ISO 14443-4 compliant
|
||||
assert atqb[10] & 0x0F == 0x01
|
||||
|
||||
|
||||
class TestType4Ndef:
|
||||
def test_cc_read(self):
|
||||
s = Session(RF430CL330H(pupi=PUPI))
|
||||
assert s.select_app() == b"\x90\x00"
|
||||
assert s.select_file("E103") == b"\x90\x00"
|
||||
cc = s.apdu("00B000000F")
|
||||
assert cc[-2:] == b"\x90\x00"
|
||||
assert cc[0:4] == b"\x00\x0F\x20\x00" # CCLEN, ver, MLe hi
|
||||
assert cc[3:5] == b"\x00\xF9" # MLe = 0x00F9
|
||||
assert cc[5:7] == b"\x00\xF6" # MLc = 0x00F6
|
||||
assert cc[9:11] == b"\xE1\x04" # NDEF file id
|
||||
|
||||
def test_ndef_read(self):
|
||||
s = Session(RF430CL330H(pupi=PUPI, ndef_message=NDEF))
|
||||
s.select_app()
|
||||
assert s.select_file("E104") == b"\x90\x00"
|
||||
assert s.apdu("00B0000002")[0:2] == len(NDEF).to_bytes(2, "big")
|
||||
assert s.apdu("00B00002%02X" % len(NDEF))[:-2] == NDEF
|
||||
|
||||
def test_ndef_update(self):
|
||||
s = Session(RF430CL330H(pupi=PUPI))
|
||||
s.select_app()
|
||||
s.select_file("E104")
|
||||
new = b"\x00\x03\xD0\x00\x00"
|
||||
assert s.apdu("00D60000%02X%s" % (len(new), new.hex())) == b"\x90\x00"
|
||||
assert s.apdu("00B00000%02X" % len(new))[:-2] == new
|
||||
|
||||
def test_select_unknown_file(self):
|
||||
s = Session(RF430CL330H(pupi=PUPI))
|
||||
s.select_app()
|
||||
assert s.select_file("E105") == b"\x6A\x82"
|
||||
|
||||
def test_read_before_app_select(self):
|
||||
s = Session(RF430CL330H(pupi=PUPI))
|
||||
assert s.select_file("E103") == b"\x6A\x82"
|
||||
|
||||
def test_set_ndef_api(self):
|
||||
tag = RF430CL330H(pupi=PUPI)
|
||||
tag.set_ndef(b"\xD1\x01\x01\x54\x00")
|
||||
s = Session(tag)
|
||||
s.select_app()
|
||||
s.select_file("E104")
|
||||
assert s.apdu("00B0000002")[0:2] == (5).to_bytes(2, "big")
|
||||
|
||||
|
||||
class TestRobustness:
|
||||
"""Regression for the review findings: short APDUs crashed, and UPDATE
|
||||
BINARY past the file end silently grew the NDEF buffer."""
|
||||
|
||||
def test_short_apdu_no_crash(self):
|
||||
s = Session(RF430CL330H(pupi=PUPI))
|
||||
s.select_app()
|
||||
assert s.apdu("00B0") == b"\x67\x00"
|
||||
assert s.apdu("00A4") == b"\x67\x00"
|
||||
|
||||
def test_update_past_end_rejected(self):
|
||||
s = Session(RF430CL330H(pupi=PUPI))
|
||||
s.select_app()
|
||||
s.select_file("E104")
|
||||
assert s.apdu("00D6FFFF0141") == b"\x6A\x86"
|
||||
|
||||
|
||||
class TestVolatileNote:
|
||||
def test_default_pupi_is_4_bytes(self):
|
||||
assert len(RF430CL330H().pupi) == 4
|
||||
53
tests/test_sim_rf430frl.py
Normal file
53
tests/test_sim_rf430frl.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""Tests for the TI RF430FRL15xH ISO 15693 sensor transponder (identity + base)."""
|
||||
import asyncio
|
||||
import pytest
|
||||
|
||||
from pm3py.sim.frame import RFFrame
|
||||
from pm3py.transponders.hf.iso15693.ti.rf430frl import (
|
||||
RF430FRL152H, RF430FRL153H, RF430FRL154H,
|
||||
)
|
||||
|
||||
|
||||
def run(coro):
|
||||
return asyncio.new_event_loop().run_until_complete(coro)
|
||||
|
||||
|
||||
def cmd(tag, payload: bytes):
|
||||
return run(tag.handle_frame(RFFrame.from_bytes(payload)))
|
||||
|
||||
|
||||
class TestIdentity:
|
||||
@pytest.mark.parametrize("cls,dev_id", [
|
||||
(RF430FRL152H, b"\xE7\x81"),
|
||||
(RF430FRL153H, b"\xFB\x81"),
|
||||
(RF430FRL154H, b"\xFC\x81"),
|
||||
])
|
||||
def test_device_id_and_uid_prefix(self, cls, dev_id):
|
||||
tag = cls()
|
||||
assert tag.device_id == dev_id
|
||||
assert tag._uid[0:3] == b"\xE0\x07\xA2" # E0 + TI mfg 07 + UID5
|
||||
|
||||
def test_ti_manufacturer_code(self):
|
||||
assert RF430FRL152H()._uid[1] == 0x07
|
||||
|
||||
|
||||
class TestIso15693:
|
||||
def test_inventory(self):
|
||||
tag = RF430FRL152H()
|
||||
run(tag.power_on())
|
||||
r = cmd(tag, bytes([0x26, 0x01, 0x00]))
|
||||
assert r.data[0] == 0x00
|
||||
assert bytes(reversed(r.data[2:10])) == tag._uid
|
||||
|
||||
def test_read_write_block(self):
|
||||
tag = RF430FRL152H()
|
||||
run(tag.power_on())
|
||||
cmd(tag, bytes([0x02, 0x21, 0x04, 0xAA, 0xBB, 0xCC, 0xDD]))
|
||||
assert cmd(tag, bytes([0x02, 0x20, 0x04])).data[1:5] == b"\xAA\xBB\xCC\xDD"
|
||||
|
||||
def test_get_system_info(self):
|
||||
tag = RF430FRL152H()
|
||||
run(tag.power_on())
|
||||
r = cmd(tag, bytes([0x02, 0x2B]))
|
||||
assert r.data[1] == 0x0F
|
||||
assert bytes(reversed(r.data[2:10])) == tag._uid
|
||||
115
tests/test_sim_st25dv.py
Normal file
115
tests/test_sim_st25dv.py
Normal file
@@ -0,0 +1,115 @@
|
||||
"""Tests for the ST25DV04K/16K/64K (ISO 15693 + I2C dynamic tag, RF side)."""
|
||||
import asyncio
|
||||
import struct
|
||||
import pytest
|
||||
|
||||
from pm3py.sim.frame import RFFrame
|
||||
from pm3py.transponders.hf.iso15693.st.st25dv import (
|
||||
ST25DV04K, ST25DV16K, ST25DV64K, REG_ENDA1, REG_RFA2SS, DYN_MB_CTRL,
|
||||
)
|
||||
|
||||
|
||||
def run(coro):
|
||||
return asyncio.new_event_loop().run_until_complete(coro)
|
||||
|
||||
|
||||
def cmd(tag, payload: bytes):
|
||||
return run(tag.handle_frame(RFFrame.from_bytes(payload)))
|
||||
|
||||
|
||||
class TestIdentity:
|
||||
@pytest.mark.parametrize("cls,prefix,blocks", [
|
||||
(ST25DV04K, b"\xE0\x02\x24", 128),
|
||||
(ST25DV16K, b"\xE0\x02\x26", 512),
|
||||
(ST25DV64K, b"\xE0\x02\x26", 2048),
|
||||
])
|
||||
def test_uid_and_blocks(self, cls, prefix, blocks):
|
||||
tag = cls()
|
||||
assert tag._uid[0:3] == prefix
|
||||
assert tag._num_blocks == blocks
|
||||
|
||||
def test_04k_sysinfo_has_memsize(self):
|
||||
tag = ST25DV04K()
|
||||
run(tag.power_on())
|
||||
r = cmd(tag, bytes([0x02, 0x2B]))
|
||||
assert r.data[1] == 0x0F
|
||||
assert r.data[-1] == 0x24 # IC ref
|
||||
assert r.data[-3] == 0x7F # 128 blocks - 1
|
||||
|
||||
def test_16k_sysinfo_omits_memsize(self):
|
||||
tag = ST25DV16K()
|
||||
run(tag.power_on())
|
||||
r = cmd(tag, bytes([0x02, 0x2B]))
|
||||
assert r.data[1] == 0x0B # DSFID+AFI+ICref, no memsize
|
||||
assert r.data[-1] == 0x26
|
||||
|
||||
def test_ext_sysinfo_gives_memsize(self):
|
||||
tag = ST25DV64K()
|
||||
run(tag.power_on())
|
||||
r = cmd(tag, bytes([0x02, 0x3B, 0x02, 0x0F]))
|
||||
assert struct.unpack_from("<H", r.data, 12)[0] == 2047
|
||||
|
||||
|
||||
class TestBlocks:
|
||||
def test_read_write(self):
|
||||
tag = ST25DV04K()
|
||||
run(tag.power_on())
|
||||
cmd(tag, bytes([0x02, 0x21, 0x08, 0xDE, 0xAD, 0xBE, 0xEF]))
|
||||
assert cmd(tag, bytes([0x02, 0x20, 0x08])).data[1:5] == b"\xDE\xAD\xBE\xEF"
|
||||
|
||||
def test_extended_read_write_high_block(self):
|
||||
tag = ST25DV16K()
|
||||
run(tag.power_on())
|
||||
cmd(tag, bytes([0x02, 0x31]) + struct.pack("<H", 300) + b"\x11\x22\x33\x44")
|
||||
r = cmd(tag, bytes([0x02, 0x30]) + struct.pack("<H", 300))
|
||||
assert r.data[1:5] == b"\x11\x22\x33\x44"
|
||||
|
||||
def test_fast_read_single(self):
|
||||
tag = ST25DV04K()
|
||||
run(tag.power_on())
|
||||
cmd(tag, bytes([0x02, 0x21, 0x08, 1, 2, 3, 4]))
|
||||
r = cmd(tag, bytes([0x02, 0xC0, 0x02, 0x08]))
|
||||
assert r.data[1:5] == b"\x01\x02\x03\x04"
|
||||
|
||||
|
||||
class TestPasswordConfig:
|
||||
def test_present_password_and_write_config(self):
|
||||
tag = ST25DV04K()
|
||||
run(tag.power_on())
|
||||
# write config denied without config session
|
||||
assert cmd(tag, bytes([0x02, 0xA1, 0x02, REG_ENDA1, 0x03])).data[0] == 0x01
|
||||
assert cmd(tag, bytes([0x02, 0xB3, 0x02, 0x00]) + bytes(8)).data[0] == 0x00
|
||||
assert cmd(tag, bytes([0x02, 0xA1, 0x02, REG_ENDA1, 0x03])).data[0] == 0x00
|
||||
assert cmd(tag, bytes([0x02, 0xA0, 0x02, REG_ENDA1])).data[1] == 0x03
|
||||
|
||||
def test_wrong_password(self):
|
||||
tag = ST25DV04K(passwords={0: b"\x01" * 8, 1: bytes(8), 2: bytes(8), 3: bytes(8)})
|
||||
run(tag.power_on())
|
||||
assert cmd(tag, bytes([0x02, 0xB3, 0x02, 0x00]) + bytes(8)).data[0] == 0x01
|
||||
|
||||
def test_area_read_protection(self):
|
||||
pw1 = b"\x01\x02\x03\x04\x05\x06\x07\x08"
|
||||
tag = ST25DV04K(passwords={0: bytes(8), 1: pw1, 2: bytes(8), 3: bytes(8)})
|
||||
run(tag.power_on())
|
||||
cmd(tag, bytes([0x02, 0xB3, 0x02, 0x00]) + bytes(8)) # config session
|
||||
cmd(tag, bytes([0x02, 0xA1, 0x02, REG_ENDA1, 0x00])) # Area1 = blocks 0-7
|
||||
cmd(tag, bytes([0x02, 0xA1, 0x02, REG_RFA2SS, 0x09])) # A2 rwprot=10, pwd_ctrl=1
|
||||
r = cmd(tag, bytes([0x02, 0x20, 20])) # block in Area2
|
||||
assert r.data[0] == 0x01 and r.data[1] == 0x15
|
||||
assert cmd(tag, bytes([0x02, 0xB3, 0x02, 0x01]) + pw1).data[0] == 0x00
|
||||
assert cmd(tag, bytes([0x02, 0x20, 20])).data[0] == 0x00
|
||||
|
||||
|
||||
class TestMailbox:
|
||||
def test_mailbox_round_trip(self):
|
||||
tag = ST25DV04K()
|
||||
run(tag.power_on())
|
||||
# mailbox disabled → error
|
||||
assert cmd(tag, bytes([0x02, 0xAB, 0x02])).data[0] == 0x01
|
||||
# enable via dynamic register MB_CTRL_Dyn b0
|
||||
cmd(tag, bytes([0x02, 0xAE, 0x02, DYN_MB_CTRL, 0x01]))
|
||||
msg = b"hello"
|
||||
cmd(tag, bytes([0x02, 0xAA, 0x02, len(msg) - 1]) + msg)
|
||||
assert cmd(tag, bytes([0x02, 0xAB, 0x02])).data[1] == len(msg) - 1
|
||||
r = cmd(tag, bytes([0x02, 0xAC, 0x02, 0x00, len(msg) - 1]))
|
||||
assert r.data[1:1 + len(msg)] == msg
|
||||
153
tests/test_sim_st25ta.py
Normal file
153
tests/test_sim_st25ta.py
Normal file
@@ -0,0 +1,153 @@
|
||||
"""Tests for the ST25TA family (ST NFC Forum Type 4)."""
|
||||
import asyncio
|
||||
import pytest
|
||||
|
||||
from pm3py.sim.frame import RFFrame
|
||||
from pm3py.transponders.hf.iso14443a.base import _compute_bcc
|
||||
from pm3py.transponders.hf.iso14443a.st.st25ta import (
|
||||
ST25TA512B, ST25TA02KB, ST25TA16K, ST25TA64K,
|
||||
)
|
||||
|
||||
NDEF_MSG = b"\xD1\x01\x04\x54\x02enHi" # 9 bytes
|
||||
|
||||
|
||||
def run(coro):
|
||||
return asyncio.new_event_loop().run_until_complete(coro)
|
||||
|
||||
|
||||
class Session:
|
||||
"""Drives a Type 4 tag through activation + APDU exchange."""
|
||||
|
||||
def __init__(self, tag):
|
||||
self.tag = tag
|
||||
self.blk = 0
|
||||
run(tag.power_on())
|
||||
run(tag.handle_frame(RFFrame.from_hex("26")))
|
||||
uid = tag._uid
|
||||
ct = b"\x88" + uid[0:3]
|
||||
run(tag.handle_frame(RFFrame.from_bytes(b"\x93\x70" + ct + bytes([_compute_bcc(ct)]))))
|
||||
run(tag.handle_frame(RFFrame.from_bytes(b"\x95\x70" + uid[3:7] + bytes([_compute_bcc(uid[3:7])]))))
|
||||
self.ats = run(tag.handle_frame(RFFrame.from_bytes(b"\xE0\x50"))).data
|
||||
|
||||
def apdu(self, hexstr: str) -> bytes:
|
||||
pcb = 0x02 | self.blk
|
||||
self.blk ^= 1
|
||||
resp = run(self.tag.handle_frame(RFFrame.from_bytes(bytes([pcb]) + bytes.fromhex(hexstr))))
|
||||
return resp.data[1:]
|
||||
|
||||
def select_app(self):
|
||||
return self.apdu("00A4040007D276000085010100")
|
||||
|
||||
def select_file(self, fid: str):
|
||||
return self.apdu("00A4000C02" + fid)
|
||||
|
||||
def read(self, off: int, le: int) -> bytes:
|
||||
return self.apdu(f"00B0{off:04X}{le:02X}")
|
||||
|
||||
|
||||
class TestActivation:
|
||||
@pytest.mark.parametrize("cls,ats", [
|
||||
(ST25TA512B, b"\x05\x75\x80\x60\x02"),
|
||||
(ST25TA02KB, b"\x05\x75\x80\x60\x02"),
|
||||
(ST25TA16K, b"\x05\x78\x80\x90\x02"),
|
||||
])
|
||||
def test_ats(self, cls, ats):
|
||||
s = Session(cls())
|
||||
assert s.ats == ats
|
||||
|
||||
def test_uid_prefix(self):
|
||||
assert ST25TA02KB()._uid[0] == 0x02
|
||||
assert ST25TA02KB()._uid[1] == 0xE3
|
||||
|
||||
|
||||
class TestFileSystem:
|
||||
def test_cc_read(self):
|
||||
s = Session(ST25TA02KB())
|
||||
assert s.select_app() == b"\x90\x00"
|
||||
assert s.select_file("E103") == b"\x90\x00"
|
||||
cc = s.read(0, 15)
|
||||
assert cc[-2:] == b"\x90\x00"
|
||||
assert cc[0:4] == b"\x00\x0F\x20\x00"
|
||||
|
||||
def test_cc_per_variant(self):
|
||||
assert ST25TA512B()._cc_file[3:5] == b"\x00\x40"
|
||||
assert ST25TA02KB()._cc_file[3:5] == b"\x00\xFF"
|
||||
assert ST25TA16K()._cc_file[3:5] == b"\x00\xF6"
|
||||
assert ST25TA64K()._cc_file[11:13] == b"\x20\x00"
|
||||
|
||||
def test_ndef_read(self):
|
||||
s = Session(ST25TA02KB(ndef_message=NDEF_MSG))
|
||||
s.select_app()
|
||||
assert s.select_file("0001") == b"\x90\x00"
|
||||
assert s.read(0, 2)[0:2] == len(NDEF_MSG).to_bytes(2, "big")
|
||||
assert s.read(2, len(NDEF_MSG))[:-2] == NDEF_MSG
|
||||
|
||||
def test_ndef_update(self):
|
||||
s = Session(ST25TA02KB())
|
||||
s.select_app()
|
||||
s.select_file("0001")
|
||||
new = b"\x00\x03\xD0\x00\x00"
|
||||
assert s.apdu("00D60000%02X%s" % (len(new), new.hex())) == b"\x90\x00"
|
||||
assert s.read(0, len(new))[:-2] == new
|
||||
|
||||
def test_system_file_uid_and_icref(self):
|
||||
s = Session(ST25TA02KB())
|
||||
s.select_app()
|
||||
assert s.select_file("E101") == b"\x90\x00"
|
||||
assert s.read(8, 7)[:-2] == s.tag._uid
|
||||
assert s.read(0x11, 1)[0] == 0xE2
|
||||
|
||||
def test_select_unknown_file(self):
|
||||
s = Session(ST25TA02KB())
|
||||
s.select_app()
|
||||
assert s.select_file("E104") == b"\x6A\x82"
|
||||
|
||||
|
||||
class TestPassword:
|
||||
def test_read_protection(self):
|
||||
s = Session(ST25TA02KB(read_password=b"\x11" * 16))
|
||||
s.select_app()
|
||||
s.select_file("0001")
|
||||
assert s.read(0, 2) == b"\x69\x82"
|
||||
assert s.apdu("0020000110" + ("11" * 16)) == b"\x90\x00"
|
||||
assert s.read(0, 2)[-2:] == b"\x90\x00"
|
||||
|
||||
def test_wrong_password_retry_counter(self):
|
||||
s = Session(ST25TA02KB(read_password=b"\x11" * 16))
|
||||
s.select_app()
|
||||
s.select_file("0001")
|
||||
assert s.apdu("0020000110" + ("00" * 16)) == b"\x63\xC2"
|
||||
assert s.apdu("0020000110" + ("00" * 16)) == b"\x63\xC1"
|
||||
assert s.apdu("0020000110" + ("00" * 16)) == b"\x63\xC0"
|
||||
# blocked after 3 failures
|
||||
assert s.apdu("0020000110" + ("00" * 16)) == b"\x69\x85"
|
||||
|
||||
def test_write_protection(self):
|
||||
s = Session(ST25TA02KB(write_password=b"\x22" * 16))
|
||||
s.select_app()
|
||||
s.select_file("0001")
|
||||
assert s.apdu("00D6000003000000") == b"\x69\x82"
|
||||
assert s.apdu("0020000210" + ("22" * 16)) == b"\x90\x00"
|
||||
assert s.apdu("00D6000003000000") == b"\x90\x00"
|
||||
|
||||
def test_verify_query(self):
|
||||
s = Session(ST25TA02KB(read_password=b"\x11" * 16))
|
||||
s.select_app()
|
||||
s.select_file("0001")
|
||||
# Lc=0 query → password required
|
||||
assert s.apdu("00200001") == b"\x63\x00"
|
||||
|
||||
|
||||
class TestCounter:
|
||||
def test_event_counter_bumps_once_per_session(self):
|
||||
tag = ST25TA02KB()
|
||||
tag.enable_counter(on_write=False)
|
||||
s = Session(tag)
|
||||
s.select_app()
|
||||
s.select_file("0001")
|
||||
s.read(0, 2)
|
||||
s.read(0, 2)
|
||||
assert tag.event_counter == 1
|
||||
|
||||
def test_16k_has_no_counter(self):
|
||||
assert ST25TA16K()._has_counter is False
|
||||
141
tests/test_sim_st25tb.py
Normal file
141
tests/test_sim_st25tb.py
Normal file
@@ -0,0 +1,141 @@
|
||||
"""Tests for the ISO 14443 Type B stack + ST25TB family (SRIX lineage)."""
|
||||
import asyncio
|
||||
import pytest
|
||||
|
||||
from pm3py.sim.frame import RFFrame
|
||||
from pm3py.transponders.hf.iso14443b.st.st25tb import (
|
||||
ST25TB512AC, ST25TB512AT, ST25TB02K, ST25TB04K,
|
||||
)
|
||||
from pm3py.trace.decode_iso14b import decode_14443b, crc14b
|
||||
|
||||
|
||||
def run(coro):
|
||||
return asyncio.new_event_loop().run_until_complete(coro)
|
||||
|
||||
|
||||
def frame(tag, payload: bytes):
|
||||
return run(tag.handle_frame(RFFrame.from_bytes(payload)))
|
||||
|
||||
|
||||
def selected(cls, uid=None):
|
||||
tag = cls(uid=uid)
|
||||
run(tag.power_on())
|
||||
cid = frame(tag, b"\x06\x00").data[0]
|
||||
frame(tag, bytes([0x0E, cid]))
|
||||
assert tag.state == "SELECTED"
|
||||
return tag
|
||||
|
||||
|
||||
class TestAnticollision:
|
||||
def test_initiate_moves_to_inventory(self):
|
||||
tag = ST25TB02K()
|
||||
run(tag.power_on())
|
||||
assert tag.state == "READY"
|
||||
r = frame(tag, b"\x06\x00")
|
||||
assert len(r.data) == 1
|
||||
assert tag.state == "INVENTORY"
|
||||
|
||||
def test_memory_commands_need_select(self):
|
||||
tag = ST25TB02K()
|
||||
run(tag.power_on())
|
||||
frame(tag, b"\x06\x00") # INVENTORY, not yet selected
|
||||
assert frame(tag, b"\x08\x07") is None
|
||||
|
||||
def test_select_and_get_uid(self):
|
||||
tag = selected(ST25TB02K, uid=bytes([0xD0, 0x02, 0x3F, 1, 2, 3, 4, 5]))
|
||||
r = frame(tag, b"\x0B")
|
||||
assert r.data == tag._uid[::-1] # LSByte first
|
||||
|
||||
def test_wrong_chip_id_not_selected(self):
|
||||
tag = ST25TB02K()
|
||||
run(tag.power_on())
|
||||
cid = frame(tag, b"\x06\x00").data[0]
|
||||
assert frame(tag, bytes([0x0E, (cid + 1) & 0xFF])) is None
|
||||
assert tag.state != "SELECTED"
|
||||
|
||||
def test_completion_deactivates(self):
|
||||
tag = selected(ST25TB02K)
|
||||
assert frame(tag, b"\x0F") is None
|
||||
assert tag.state == "DEACTIVATED"
|
||||
|
||||
def test_reset_to_inventory(self):
|
||||
tag = selected(ST25TB02K)
|
||||
frame(tag, b"\x0C")
|
||||
assert tag.state == "INVENTORY"
|
||||
|
||||
|
||||
class TestIdentity:
|
||||
@pytest.mark.parametrize("cls,pc,blocks", [
|
||||
(ST25TB512AC, 0x1B, 16),
|
||||
(ST25TB512AT, 0x33, 16),
|
||||
(ST25TB02K, 0x3F, 64),
|
||||
(ST25TB04K, 0x1F, 128),
|
||||
])
|
||||
def test_uid_and_size(self, cls, pc, blocks):
|
||||
tag = cls()
|
||||
assert tag._uid[0:3] == bytes([0xD0, 0x02, pc])
|
||||
assert tag._num_blocks == blocks
|
||||
|
||||
|
||||
class TestMemory:
|
||||
def test_write_read_user_block(self):
|
||||
tag = selected(ST25TB04K)
|
||||
frame(tag, b"\x09\x10\xDE\xAD\xBE\xEF") # no response expected
|
||||
assert frame(tag, b"\x08\x10").data == b"\xDE\xAD\xBE\xEF"
|
||||
|
||||
def test_write_block_has_no_response(self):
|
||||
tag = selected(ST25TB02K)
|
||||
assert frame(tag, b"\x09\x10\x01\x02\x03\x04") is None
|
||||
|
||||
def test_system_block_255(self):
|
||||
tag = selected(ST25TB02K)
|
||||
assert len(frame(tag, b"\x08\xFF").data) == 4
|
||||
|
||||
def test_invalid_block_no_response(self):
|
||||
tag = selected(ST25TB02K) # 64 blocks
|
||||
assert frame(tag, b"\x08\x64") is None # block 100 invalid
|
||||
|
||||
|
||||
class TestCounters:
|
||||
def test_factory_values(self):
|
||||
tag = selected(ST25TB02K)
|
||||
assert frame(tag, b"\x08\x05").data == (0xFFFFFFFE).to_bytes(4, "little")
|
||||
assert frame(tag, b"\x08\x06").data == (0xFFFFFFFF).to_bytes(4, "little")
|
||||
|
||||
def test_decrement_only(self):
|
||||
tag = selected(ST25TB02K)
|
||||
frame(tag, bytes([0x09, 0x05]) + (0x1000).to_bytes(4, "little"))
|
||||
assert int.from_bytes(frame(tag, b"\x08\x05").data, "little") == 0x1000
|
||||
# higher value rejected
|
||||
frame(tag, bytes([0x09, 0x05]) + (0x2000).to_bytes(4, "little"))
|
||||
assert int.from_bytes(frame(tag, b"\x08\x05").data, "little") == 0x1000
|
||||
|
||||
|
||||
class TestOTP:
|
||||
def test_otp_bit_clear_only(self):
|
||||
tag = selected(ST25TB02K)
|
||||
assert frame(tag, b"\x08\x00").data == b"\xFF\xFF\xFF\xFF" # ships all-1s
|
||||
frame(tag, b"\x09\x00\x0F\x00\x00\x00")
|
||||
assert frame(tag, b"\x08\x00").data == b"\x0F\x00\x00\x00"
|
||||
# cannot set bits back
|
||||
frame(tag, b"\x09\x00\xFF\xFF\xFF\xFF")
|
||||
assert frame(tag, b"\x08\x00").data == b"\x0F\x00\x00\x00"
|
||||
|
||||
def test_at_variant_blocks_are_plain_eeprom(self):
|
||||
tag = selected(ST25TB512AT)
|
||||
frame(tag, b"\x09\x00\x0F\xF0\x00\x00")
|
||||
assert frame(tag, b"\x08\x00").data == b"\x0F\xF0\x00\x00"
|
||||
|
||||
|
||||
class TestDecode:
|
||||
def test_request_decode(self):
|
||||
assert decode_14443b(0, b"\x06\x00") == "INITIATE"
|
||||
assert decode_14443b(0, b"\x06\x04") == "PCALL16"
|
||||
assert decode_14443b(0, b"\x26") == "SLOT_MARKER 2"
|
||||
assert decode_14443b(0, b"\x0E\x5A") == "SELECT chip=0x5A"
|
||||
assert decode_14443b(0, b"\x08\x07") == "READ_BLOCK #7"
|
||||
assert decode_14443b(0, b"\x09\x07") == "WRITE_BLOCK #7"
|
||||
|
||||
def test_crc_b(self):
|
||||
# ST25TB datasheet reference vector.
|
||||
assert crc14b(bytes.fromhex("0A123456")) == bytes.fromhex("2CF6")
|
||||
92
tests/test_sim_st25tn.py
Normal file
92
tests/test_sim_st25tn.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""Tests for the ST25TN512 / ST25TN01K (ST NFC Forum Type 2) models."""
|
||||
import asyncio
|
||||
import pytest
|
||||
|
||||
from pm3py.sim.frame import RFFrame
|
||||
from pm3py.transponders.hf.iso14443a.base import _compute_bcc
|
||||
from pm3py.transponders.hf.iso14443a.st.st25tn import ST25TN512, ST25TN01K
|
||||
|
||||
UID = b"\x02\x11\x22\x33\x44\x55\x66"
|
||||
|
||||
|
||||
def run(coro):
|
||||
return asyncio.new_event_loop().run_until_complete(coro)
|
||||
|
||||
|
||||
def select(tag):
|
||||
run(tag.handle_frame(RFFrame.from_hex("26")))
|
||||
uid = tag._uid
|
||||
ct = b"\x88" + uid[0:3]
|
||||
run(tag.handle_frame(RFFrame.from_bytes(b"\x93\x70" + ct + bytes([_compute_bcc(ct)]))))
|
||||
run(tag.handle_frame(RFFrame.from_bytes(b"\x95\x70" + uid[3:7] + bytes([_compute_bcc(uid[3:7])]))))
|
||||
assert tag.state == "ACTIVE"
|
||||
|
||||
|
||||
def activate(cls, **kw):
|
||||
tag = cls(uid=UID, **kw)
|
||||
run(tag.power_on())
|
||||
select(tag)
|
||||
return tag
|
||||
|
||||
|
||||
class TestIdentity:
|
||||
def test_st_uid_prefix_autogen(self):
|
||||
assert ST25TN512()._uid[0] == 0x02 # ST manufacturer code
|
||||
|
||||
def test_atqa_sak(self):
|
||||
tag = ST25TN512(uid=UID)
|
||||
run(tag.power_on())
|
||||
assert run(tag.handle_frame(RFFrame.from_hex("26"))).data == b"\x44\x00"
|
||||
select(tag) # reaches ACTIVE with SAK 0x00
|
||||
|
||||
def test_cc_defaults(self):
|
||||
assert ST25TN512(uid=UID)._get_page(3) == b"\xE1\x10\x08\x00"
|
||||
assert ST25TN01K(uid=UID)._get_page(3) == b"\xE1\x10\x14\x00"
|
||||
|
||||
def test_read_returns_uid(self):
|
||||
tag = activate(ST25TN512)
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(b"\x30\x00")))
|
||||
assert resp.data[0:3] == UID[0:3]
|
||||
|
||||
def test_product_id_block(self):
|
||||
tag = activate(ST25TN512)
|
||||
assert run(tag.handle_frame(RFFrame.from_bytes(b"\x30\x2D"))).data[0:4] == b"\x91\x90\x13\x05"
|
||||
tag = activate(ST25TN01K)
|
||||
assert run(tag.handle_frame(RFFrame.from_bytes(b"\x30\x2D"))).data[0:4] == b"\x90\x90\x13\x05"
|
||||
|
||||
|
||||
class TestCommands:
|
||||
def test_no_fast_read_version_pwd(self):
|
||||
tag = activate(ST25TN512)
|
||||
assert run(tag.handle_frame(RFFrame.from_bytes(b"\x3A\x00\x03"))) is None
|
||||
assert run(tag.handle_frame(RFFrame.from_bytes(b"\x60"))) is None
|
||||
assert run(tag.handle_frame(RFFrame.from_bytes(b"\x1B\x00\x00\x00\x00"))) is None
|
||||
|
||||
def test_write_readback(self):
|
||||
tag = activate(ST25TN512)
|
||||
assert run(tag.handle_frame(RFFrame.from_bytes(b"\xA2\x05\xDE\xAD\xBE\xEF"))).data == b"\x0A"
|
||||
assert run(tag.handle_frame(RFFrame.from_bytes(b"\x30\x05"))).data[0:4] == b"\xDE\xAD\xBE\xEF"
|
||||
|
||||
def test_product_id_read_only(self):
|
||||
tag = activate(ST25TN512)
|
||||
assert run(tag.handle_frame(RFFrame.from_bytes(b"\xA2\x2D\x01\x02\x03\x04"))).data == b"\x00"
|
||||
|
||||
def test_kill_pwd_reads_zero(self):
|
||||
tag = activate(ST25TN512)
|
||||
assert run(tag.handle_frame(RFFrame.from_bytes(b"\x30\x2F"))).data[0:4] == bytes(4)
|
||||
|
||||
|
||||
class TestKill:
|
||||
def test_kill_with_correct_password(self):
|
||||
tag = activate(ST25TN512, kill_password=b"\x11\x22\x33\x44")
|
||||
# wrong password → NAK
|
||||
assert run(tag.handle_frame(RFFrame.from_bytes(b"\xA2\x30\x00\x00\x00\x00"))).data == b"\x00"
|
||||
# correct → ACK, killed on next boot
|
||||
assert run(tag.handle_frame(RFFrame.from_bytes(b"\xA2\x30\x11\x22\x33\x44"))).data == b"\x0A"
|
||||
run(tag.power_on())
|
||||
assert tag.killed
|
||||
assert run(tag.handle_frame(RFFrame.from_hex("26"))) is None
|
||||
|
||||
def test_kill_disabled(self):
|
||||
tag = activate(ST25TN512, kill_password=b"\x00\x00\x00\x00", kill_enabled=False)
|
||||
assert run(tag.handle_frame(RFFrame.from_bytes(b"\xA2\x30\x00\x00\x00\x00"))).data == b"\x00"
|
||||
145
tests/test_sim_st25tv.py
Normal file
145
tests/test_sim_st25tv.py
Normal file
@@ -0,0 +1,145 @@
|
||||
"""Tests for the ST25TV02KC / ST25TV512C (ST NFC Forum Type 5) models."""
|
||||
import asyncio
|
||||
import pytest
|
||||
|
||||
from pm3py.sim.frame import RFFrame
|
||||
from pm3py.transponders.hf.iso15693.st.st25tv import (
|
||||
ST25TV512C, ST25TV02KC, PWD_A2, PWD_CFG, PWD_PRIV, NUID,
|
||||
)
|
||||
|
||||
|
||||
def run(coro):
|
||||
return asyncio.new_event_loop().run_until_complete(coro)
|
||||
|
||||
|
||||
def _cover(rnd: bytes, pwd: bytes) -> bytes:
|
||||
ks = (rnd * ((len(pwd) // 2) + 1))[:len(pwd)]
|
||||
return bytes(a ^ b for a, b in zip(pwd, ks))
|
||||
|
||||
|
||||
def _get_random(tag) -> bytes:
|
||||
return run(tag.handle_frame(RFFrame.from_bytes(bytes([0x02, 0xB4, 0x02])))).data[1:3]
|
||||
|
||||
|
||||
def _present(tag, pwd_id, pwd) -> RFFrame:
|
||||
rnd = _get_random(tag)
|
||||
frame = bytes([0x02, 0xB3, 0x02, pwd_id]) + _cover(rnd, pwd)
|
||||
return run(tag.handle_frame(RFFrame.from_bytes(frame)))
|
||||
|
||||
|
||||
class TestIdentity:
|
||||
def test_uid_prefix(self):
|
||||
assert ST25TV512C()._uid[0:3] == b"\xE0\x02\x08"
|
||||
|
||||
@pytest.mark.parametrize("cls,blocks", [(ST25TV512C, 16), (ST25TV02KC, 80)])
|
||||
def test_block_count(self, cls, blocks):
|
||||
assert cls()._num_blocks == blocks
|
||||
|
||||
def test_inventory(self):
|
||||
tag = ST25TV512C()
|
||||
run(tag.power_on())
|
||||
r = run(tag.handle_frame(RFFrame.from_bytes(bytes([0x26, 0x01, 0x00]))))
|
||||
assert r.data[0] == 0x00
|
||||
assert bytes(reversed(r.data[2:10])) == tag._uid
|
||||
|
||||
def test_system_info(self):
|
||||
tag = ST25TV512C()
|
||||
run(tag.power_on())
|
||||
r = run(tag.handle_frame(RFFrame.from_bytes(bytes([0x02, 0x2B]))))
|
||||
assert r.data[1] == 0x0F # info flags
|
||||
assert r.data[-1] == 0x08 # IC ref
|
||||
assert r.data[-2] == 0x03 # block size - 1
|
||||
assert r.data[-3] == 0x0F # END_MEM (16 blocks - 1)
|
||||
|
||||
def test_cc_readable(self):
|
||||
tag = ST25TV512C()
|
||||
run(tag.power_on())
|
||||
r = run(tag.handle_frame(RFFrame.from_bytes(bytes([0x02, 0x20, 0x00]))))
|
||||
assert r.data[0] == 0x00 and r.data[1] == 0xE1
|
||||
|
||||
|
||||
class TestPasswords:
|
||||
def test_get_random(self):
|
||||
tag = ST25TV512C()
|
||||
run(tag.power_on())
|
||||
r = run(tag.handle_frame(RFFrame.from_bytes(bytes([0x02, 0xB4, 0x02]))))
|
||||
assert r.data[0] == 0x00 and len(r.data) == 3
|
||||
|
||||
def test_area_read_protection(self):
|
||||
tag = ST25TV02KC(end_a1=0x0F,
|
||||
passwords={0: bytes(4), 1: bytes(4), 2: b"\xAA\xBB\xCC\xDD", 3: bytes(4)})
|
||||
tag._rwprot[PWD_A2] = 0x02 # A2 read+write need a session
|
||||
run(tag.power_on())
|
||||
# A1 (block 5) open, A2 (block 20) protected
|
||||
assert run(tag.handle_frame(RFFrame.from_bytes(bytes([0x02, 0x20, 5])))).data[0] == 0x00
|
||||
r = run(tag.handle_frame(RFFrame.from_bytes(bytes([0x02, 0x20, 20]))))
|
||||
assert r.data[0] == 0x01 and r.data[1] == 0x15 # block read-protected
|
||||
# authenticate A2 → read works
|
||||
assert _present(tag, PWD_A2, b"\xAA\xBB\xCC\xDD").data[0] == 0x00
|
||||
assert run(tag.handle_frame(RFFrame.from_bytes(bytes([0x02, 0x20, 20])))).data[0] == 0x00
|
||||
|
||||
def test_wrong_password_errors(self):
|
||||
tag = ST25TV02KC(passwords={0: bytes(4), 1: bytes(4), 2: b"\x01\x02\x03\x04", 3: bytes(4)})
|
||||
run(tag.power_on())
|
||||
rnd = _get_random(tag)
|
||||
frame = bytes([0x02, 0xB3, 0x02, PWD_A2]) + _cover(rnd, b"\x00\x00\x00\x00")
|
||||
assert run(tag.handle_frame(RFFrame.from_bytes(frame))).data[0] == 0x01
|
||||
|
||||
|
||||
class TestPrivacy:
|
||||
def test_kill(self):
|
||||
tag = ST25TV02KC()
|
||||
run(tag.power_on())
|
||||
rnd = _get_random(tag)
|
||||
uid_lsb = tag._uid[::-1]
|
||||
frame = bytes([0x22, 0xA6, 0x02]) + uid_lsb + bytes([PWD_CFG]) + _cover(rnd, bytes(4))
|
||||
assert run(tag.handle_frame(RFFrame.from_bytes(frame))).data[0] == 0x00
|
||||
assert tag.killed
|
||||
assert run(tag.handle_frame(RFFrame.from_bytes(bytes([0x26, 0x01, 0x00])))) is None
|
||||
|
||||
def test_discreet_toggle_masks_uid(self):
|
||||
tag = ST25TV02KC()
|
||||
run(tag.power_on())
|
||||
rnd = _get_random(tag)
|
||||
uid_lsb = tag._uid[::-1]
|
||||
frame = bytes([0x22, 0xBA, 0x02]) + uid_lsb + bytes([PWD_PRIV]) + _cover(rnd, bytes(4))
|
||||
assert run(tag.handle_frame(RFFrame.from_bytes(frame))).data[0] == 0x00
|
||||
assert tag.ds_state == "DISCREET"
|
||||
r = run(tag.handle_frame(RFFrame.from_bytes(bytes([0x26, 0x01, 0x00]))))
|
||||
assert bytes(reversed(r.data[2:10])) == NUID
|
||||
|
||||
def test_leave_discreet_unaddressed(self):
|
||||
tag = ST25TV02KC()
|
||||
run(tag.power_on())
|
||||
# enter
|
||||
rnd = _get_random(tag)
|
||||
uid_lsb = tag._uid[::-1]
|
||||
run(tag.handle_frame(RFFrame.from_bytes(
|
||||
bytes([0x22, 0xBA, 0x02]) + uid_lsb + bytes([PWD_PRIV]) + _cover(rnd, bytes(4)))))
|
||||
assert tag.ds_state == "DISCREET"
|
||||
# leave: non-addressed toggle
|
||||
rnd = _get_random(tag)
|
||||
run(tag.handle_frame(RFFrame.from_bytes(
|
||||
bytes([0x02, 0xBA, 0x02, PWD_PRIV]) + _cover(rnd, bytes(4)))))
|
||||
assert tag.ds_state == "READY"
|
||||
|
||||
|
||||
class TestConfig:
|
||||
def test_read_config_uid(self):
|
||||
tag = ST25TV512C()
|
||||
run(tag.power_on())
|
||||
# ReadConfiguration FID=0xFE PID=0x01 → UID
|
||||
r = run(tag.handle_frame(RFFrame.from_bytes(bytes([0x02, 0xA0, 0x02, 0xFE, 0x01]))))
|
||||
assert r.data[0] == 0x00 and bytes(reversed(r.data[1:9])) == tag._uid
|
||||
|
||||
def test_write_config_needs_session(self):
|
||||
tag = ST25TV512C()
|
||||
run(tag.power_on())
|
||||
# WriteConfiguration without CFG session → error
|
||||
r = run(tag.handle_frame(RFFrame.from_bytes(bytes([0x02, 0xA1, 0x02, 0x00, 0x01, 0x08]))))
|
||||
assert r.data[0] == 0x01
|
||||
# present PWD_CFG, then write END_A1
|
||||
assert _present(tag, PWD_CFG, bytes(4)).data[0] == 0x00
|
||||
r = run(tag.handle_frame(RFFrame.from_bytes(bytes([0x02, 0xA1, 0x02, 0x00, 0x01, 0x08]))))
|
||||
assert r.data[0] == 0x00
|
||||
assert tag._end_a1 == 0x08
|
||||
120
tests/test_sim_tagit.py
Normal file
120
tests/test_sim_tagit.py
Normal file
@@ -0,0 +1,120 @@
|
||||
"""Tests for the TI Tag-it HF-I Plus (ISO 15693)."""
|
||||
import asyncio
|
||||
import pytest
|
||||
|
||||
from pm3py.sim.frame import RFFrame
|
||||
from pm3py.transponders.hf.iso15693.ti.tagit import TagItHFIPlus
|
||||
|
||||
UID = bytes([0xE0, 0x07, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66])
|
||||
|
||||
|
||||
def run(coro):
|
||||
return asyncio.new_event_loop().run_until_complete(coro)
|
||||
|
||||
|
||||
def cmd(tag, payload: bytes):
|
||||
return run(tag.handle_frame(RFFrame.from_bytes(payload)))
|
||||
|
||||
|
||||
class TestIdentity:
|
||||
def test_ti_mfg_prefix(self):
|
||||
assert TagItHFIPlus()._uid[0:2] == b"\xE0\x07"
|
||||
|
||||
def test_64_blocks(self):
|
||||
assert TagItHFIPlus()._num_blocks == 64
|
||||
assert TagItHFIPlus()._block_size == 4
|
||||
|
||||
def test_inventory(self):
|
||||
tag = TagItHFIPlus(uid=UID)
|
||||
run(tag.power_on())
|
||||
r = cmd(tag, bytes([0x26, 0x01, 0x00]))
|
||||
assert r.data[0] == 0x00
|
||||
assert bytes(reversed(r.data[2:10])) == UID
|
||||
|
||||
def test_system_info(self):
|
||||
tag = TagItHFIPlus(uid=UID)
|
||||
run(tag.power_on())
|
||||
r = cmd(tag, bytes([0x02, 0x2B]))
|
||||
assert r.data[-3] == 0x3F # 64 blocks - 1
|
||||
assert r.data[-2] == 0x03 # block size - 1
|
||||
|
||||
|
||||
class TestBlocks:
|
||||
def test_read_write(self):
|
||||
tag = TagItHFIPlus(uid=UID)
|
||||
run(tag.power_on())
|
||||
cmd(tag, bytes([0x02, 0x21, 0x05, 0xDE, 0xAD, 0xBE, 0xEF]))
|
||||
assert cmd(tag, bytes([0x02, 0x20, 0x05])).data[1:5] == b"\xDE\xAD\xBE\xEF"
|
||||
|
||||
def test_read_multiple(self):
|
||||
tag = TagItHFIPlus(uid=UID)
|
||||
run(tag.power_on())
|
||||
cmd(tag, bytes([0x02, 0x21, 0x05, 1, 2, 3, 4]))
|
||||
cmd(tag, bytes([0x02, 0x21, 0x06, 5, 6, 7, 8]))
|
||||
r = cmd(tag, bytes([0x02, 0x23, 0x05, 0x01])) # read 2 blocks from 5
|
||||
assert r.data[1:9] == bytes([1, 2, 3, 4, 5, 6, 7, 8])
|
||||
|
||||
|
||||
class TestCustomCommands:
|
||||
def test_write_2_blocks(self):
|
||||
tag = TagItHFIPlus(uid=UID)
|
||||
run(tag.power_on())
|
||||
cmd(tag, bytes([0x02, 0xA2, 0x07, 0x08]) + bytes(range(8)))
|
||||
assert cmd(tag, bytes([0x02, 0x20, 0x08])).data[1:5] == bytes(range(4))
|
||||
assert cmd(tag, bytes([0x02, 0x20, 0x09])).data[1:5] == bytes(range(4, 8))
|
||||
|
||||
def test_lock_2_blocks_then_write_fails(self):
|
||||
tag = TagItHFIPlus(uid=UID)
|
||||
run(tag.power_on())
|
||||
cmd(tag, bytes([0x02, 0xA3, 0x07, 0x0A])) # lock blocks 10,11
|
||||
r = cmd(tag, bytes([0x02, 0x21, 0x0A, 0, 0, 0, 0]))
|
||||
assert r.data[0] == 0x01 and r.data[1] == 0x12
|
||||
|
||||
def test_write_multiple_not_supported(self):
|
||||
tag = TagItHFIPlus(uid=UID)
|
||||
run(tag.power_on())
|
||||
r = cmd(tag, bytes([0x02, 0x24, 0x00, 0x01]) + bytes(8))
|
||||
assert r.data[0] == 0x01 and r.data[1] == 0x01
|
||||
|
||||
|
||||
class TestAddressedMode:
|
||||
"""Regression for the review finding: custom commands were dead in addressed
|
||||
mode, and SELECT/LOCK/READ_MULTIPLE bypassed the addressed-UID filter."""
|
||||
|
||||
def test_addressed_write_2_blocks(self):
|
||||
tag = TagItHFIPlus(uid=UID)
|
||||
run(tag.power_on())
|
||||
uid_lsb = UID[::-1]
|
||||
frame = bytes([0x22, 0xA2, 0x07]) + uid_lsb + bytes([0x08]) + bytes(range(8))
|
||||
assert cmd(tag, frame).data[0] == 0x00
|
||||
assert cmd(tag, bytes([0x02, 0x20, 0x08])).data[1:5] == bytes(range(4))
|
||||
|
||||
def test_addressed_wrong_uid_dropped(self):
|
||||
tag = TagItHFIPlus(uid=UID)
|
||||
run(tag.power_on())
|
||||
assert cmd(tag, bytes([0x22, 0x20, 0x00]) + bytes(8)) is None # read
|
||||
assert cmd(tag, bytes([0x22, 0x25]) + bytes(8)) is None # select
|
||||
|
||||
def test_select_uid_gated(self):
|
||||
from pm3py.transponders.hf.iso15693.base import State15693
|
||||
tag = TagItHFIPlus(uid=UID)
|
||||
run(tag.power_on())
|
||||
assert cmd(tag, bytes([0x22, 0x25]) + bytes(8)) is None
|
||||
assert tag._state != State15693.SELECTED
|
||||
assert cmd(tag, bytes([0x22, 0x25]) + UID[::-1]).data[0] == 0x00
|
||||
assert tag._state == State15693.SELECTED
|
||||
|
||||
|
||||
class TestLocking:
|
||||
def test_lock_block_then_write_fails(self):
|
||||
tag = TagItHFIPlus(uid=UID)
|
||||
run(tag.power_on())
|
||||
assert cmd(tag, bytes([0x02, 0x22, 0x05])).data[0] == 0x00 # lock block 5
|
||||
r = cmd(tag, bytes([0x02, 0x21, 0x05, 0, 0, 0, 0]))
|
||||
assert r.data[0] == 0x01 and r.data[1] == 0x12
|
||||
|
||||
def test_double_lock_errors(self):
|
||||
tag = TagItHFIPlus(uid=UID)
|
||||
run(tag.power_on())
|
||||
cmd(tag, bytes([0x02, 0x22, 0x05]))
|
||||
assert cmd(tag, bytes([0x02, 0x22, 0x05])).data[1] == 0x11 # already locked
|
||||
173
tests/test_sim_ultralight.py
Normal file
173
tests/test_sim_ultralight.py
Normal file
@@ -0,0 +1,173 @@
|
||||
"""Tests for the MIFARE Ultralight family (original, Ultralight C, EV1)."""
|
||||
import asyncio
|
||||
import os
|
||||
import pytest
|
||||
|
||||
from pm3py.sim.frame import RFFrame
|
||||
from pm3py.transponders.hf.iso14443a.base import _compute_bcc
|
||||
from pm3py.transponders.hf.iso14443a.nxp.ultralight import (
|
||||
MifareUltralight, MifareUltralightC, MF0UL11, MF0UL21, UL_C_DEFAULT_KEY,
|
||||
)
|
||||
|
||||
UID = b"\x04\x01\x02\x03\x04\x05\x06"
|
||||
|
||||
|
||||
def run(coro):
|
||||
return asyncio.new_event_loop().run_until_complete(coro)
|
||||
|
||||
|
||||
def select(tag):
|
||||
run(tag.handle_frame(RFFrame.from_hex("26")))
|
||||
uid = tag._uid
|
||||
ct = b"\x88" + uid[0:3]
|
||||
run(tag.handle_frame(RFFrame.from_bytes(b"\x93\x70" + ct + bytes([_compute_bcc(ct)]))))
|
||||
cl2 = uid[3:7]
|
||||
run(tag.handle_frame(RFFrame.from_bytes(b"\x95\x70" + cl2 + bytes([_compute_bcc(cl2)]))))
|
||||
assert tag.state == "ACTIVE"
|
||||
|
||||
|
||||
def activate(cls, **kw):
|
||||
tag = cls(uid=UID, **kw)
|
||||
run(tag.power_on())
|
||||
select(tag)
|
||||
return tag
|
||||
|
||||
|
||||
def _rotl1(b):
|
||||
return b[1:] + b[:1]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Original MIFARE Ultralight (MF0ICU1)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestUltralight:
|
||||
def test_16_pages(self):
|
||||
assert MifareUltralight(uid=UID)._total_pages == 16
|
||||
|
||||
def test_no_get_version(self):
|
||||
tag = activate(MifareUltralight)
|
||||
assert run(tag.handle_frame(RFFrame.from_bytes(b"\x60"))) is None
|
||||
|
||||
def test_no_fast_read_or_pwd_auth(self):
|
||||
tag = activate(MifareUltralight)
|
||||
assert run(tag.handle_frame(RFFrame.from_bytes(b"\x3A\x00\x03"))) is None
|
||||
assert run(tag.handle_frame(RFFrame.from_bytes(b"\x1B\x00\x00\x00\x00"))) is None
|
||||
|
||||
def test_read_write(self):
|
||||
tag = activate(MifareUltralight)
|
||||
assert run(tag.handle_frame(RFFrame.from_bytes(b"\xA2\x04\xCA\xFE\xBA\xBE"))).data == b"\x0A"
|
||||
assert run(tag.handle_frame(RFFrame.from_bytes(b"\x30\x04"))).data[0:4] == b"\xCA\xFE\xBA\xBE"
|
||||
|
||||
def test_otp_is_or_write(self):
|
||||
tag = activate(MifareUltralight) # page 3 = OTP, starts at 0
|
||||
run(tag.handle_frame(RFFrame.from_bytes(b"\xA2\x03\x01\x00\x00\x00")))
|
||||
run(tag.handle_frame(RFFrame.from_bytes(b"\xA2\x03\x02\x00\x00\x00")))
|
||||
otp = run(tag.handle_frame(RFFrame.from_bytes(b"\x30\x03"))).data[0:4]
|
||||
assert otp == b"\x03\x00\x00\x00" # OR of 0x01 and 0x02
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MIFARE Ultralight C (MF0ICU2) — 3DES
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestUltralightC:
|
||||
def test_48_pages_no_get_version(self):
|
||||
assert MifareUltralightC(uid=UID)._total_pages == 48
|
||||
tag = activate(MifareUltralightC)
|
||||
assert run(tag.handle_frame(RFFrame.from_bytes(b"\x60"))) is None
|
||||
|
||||
def test_default_key_installed(self):
|
||||
assert MifareUltralightC(uid=UID).key == UL_C_DEFAULT_KEY
|
||||
|
||||
def _do_auth(self, tag, key=UL_C_DEFAULT_KEY):
|
||||
from Crypto.Cipher import DES3
|
||||
r = run(tag.handle_frame(RFFrame.from_bytes(b"\x1A\x00")))
|
||||
assert r.data[0] == 0xAF and len(r.data) == 9
|
||||
ek_rndb = r.data[1:9]
|
||||
rndb = DES3.new(key, DES3.MODE_CBC, iv=bytes(8)).decrypt(ek_rndb)
|
||||
rnda = os.urandom(8)
|
||||
token = DES3.new(key, DES3.MODE_CBC, iv=ek_rndb).encrypt(rnda + _rotl1(rndb))
|
||||
r = run(tag.handle_frame(RFFrame.from_bytes(b"\xAF" + token)))
|
||||
if r.data[0] != 0x00:
|
||||
return None
|
||||
got = DES3.new(key, DES3.MODE_CBC, iv=token[8:16]).decrypt(r.data[1:9])
|
||||
return got == _rotl1(rnda)
|
||||
|
||||
def test_3des_auth_round_trip(self):
|
||||
tag = activate(MifareUltralightC, auth0=0x04)
|
||||
assert self._do_auth(tag) is True
|
||||
assert tag._auth_state == "AUTHENTICATED"
|
||||
|
||||
def test_auth_gates_protected_read(self):
|
||||
tag = activate(MifareUltralightC, auth0=0x04, auth1_read_protect=True)
|
||||
assert run(tag.handle_frame(RFFrame.from_bytes(b"\x30\x04"))).data == b"\x00"
|
||||
assert self._do_auth(tag) is True
|
||||
assert len(run(tag.handle_frame(RFFrame.from_bytes(b"\x30\x04"))).data) == 16
|
||||
|
||||
def test_wrong_key_fails(self):
|
||||
tag = activate(MifareUltralightC, auth0=0x04)
|
||||
wrong = bytes(range(16))
|
||||
assert self._do_auth(tag, key=wrong) is None
|
||||
assert tag._auth_state != "AUTHENTICATED"
|
||||
|
||||
def test_key_not_readable(self):
|
||||
tag = activate(MifareUltralightC, auth0=0x30) # protection off
|
||||
resp = run(tag.handle_frame(RFFrame.from_bytes(b"\x30\x2C")))
|
||||
assert resp.data[0:4] == b"\x00\x00\x00\x00"
|
||||
|
||||
def test_counter_increments(self):
|
||||
tag = activate(MifareUltralightC)
|
||||
run(tag.handle_frame(RFFrame.from_bytes(b"\xA2\x29\x03\x00\x00\x00")))
|
||||
run(tag.handle_frame(RFFrame.from_bytes(b"\xA2\x29\x02\x00\x00\x00")))
|
||||
cnt = run(tag.handle_frame(RFFrame.from_bytes(b"\x30\x29"))).data[0:2]
|
||||
assert int.from_bytes(cnt, "little") == 5
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MIFARE Ultralight EV1 (MF0UL11 / MF0UL21)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestUltralightEV1:
|
||||
@pytest.mark.parametrize("cls,version,pages", [
|
||||
(MF0UL11, b"\x00\x04\x03\x01\x01\x00\x0B\x03", 20),
|
||||
(MF0UL21, b"\x00\x04\x03\x01\x01\x00\x0E\x03", 41),
|
||||
])
|
||||
def test_version_and_pages(self, cls, version, pages):
|
||||
tag = activate(cls)
|
||||
assert run(tag.handle_frame(RFFrame.from_bytes(b"\x60"))).data == version
|
||||
assert cls(uid=UID)._total_pages == pages
|
||||
|
||||
def test_page3_is_otp_not_cc(self):
|
||||
# EV1 ships with OTP (zeros) at page 3, no CC.
|
||||
tag = activate(MF0UL11)
|
||||
assert run(tag.handle_frame(RFFrame.from_bytes(b"\x30\x03"))).data[0:4] == b"\x00\x00\x00\x00"
|
||||
|
||||
def test_ndef_stamps_cc_into_otp(self):
|
||||
# Supplying an NDEF message NDEF-formats the tag: product CC into page 3.
|
||||
tag = activate(MF0UL21, ndef_message=b"\xD1\x01\x04\x54\x02enHi")
|
||||
assert run(tag.handle_frame(RFFrame.from_bytes(b"\x30\x03"))).data[0:4] == b"\xE1\x10\x10\x00"
|
||||
# NDEF TLV present at page 4
|
||||
assert run(tag.handle_frame(RFFrame.from_bytes(b"\x30\x04"))).data[0] == 0x03
|
||||
|
||||
def test_three_counters(self):
|
||||
tag = activate(MF0UL21)
|
||||
for n in range(3):
|
||||
assert run(tag.handle_frame(RFFrame.from_bytes(bytes([0x39, n])))).data == b"\x00\x00\x00"
|
||||
run(tag.handle_frame(RFFrame.from_bytes(b"\xA5\x01\x0A\x00\x00\x00"))) # counter 1 += 10
|
||||
assert int.from_bytes(run(tag.handle_frame(RFFrame.from_bytes(b"\x39\x01"))).data, "little") == 10
|
||||
assert run(tag.handle_frame(RFFrame.from_bytes(b"\x39\x00"))).data == b"\x00\x00\x00" # unchanged
|
||||
|
||||
def test_counter_overflow_naks(self):
|
||||
tag = activate(MF0UL11)
|
||||
run(tag.handle_frame(RFFrame.from_bytes(b"\xA5\x00\xFF\xFF\xFF\x00"))) # to 0xFFFFFF
|
||||
assert run(tag.handle_frame(RFFrame.from_bytes(b"\xA5\x00\x01\x00\x00\x00"))).data == b"\x04"
|
||||
|
||||
def test_check_tearing(self):
|
||||
tag = activate(MF0UL11)
|
||||
assert run(tag.handle_frame(RFFrame.from_bytes(b"\x3E\x00"))).data == b"\xBD"
|
||||
|
||||
def test_pwd_auth(self):
|
||||
tag = activate(MF0UL21, password=b"\xDE\xAD\xBE\xEF", pack=b"\x12\x34")
|
||||
assert run(tag.handle_frame(RFFrame.from_bytes(b"\x1B\xDE\xAD\xBE\xEF"))).data == b"\x12\x34"
|
||||
assert run(tag.handle_frame(RFFrame.from_bytes(b"\x1B\x00\x00\x00\x00"))).data == b"\x04"
|
||||
Reference in New Issue
Block a user