From dc626faa60031f1ba536f5c8ba136354758bd845 Mon Sep 17 00:00:00 2001 From: michael Date: Sun, 5 Jul 2026 09:53:52 -0700 Subject: [PATCH] Big transponder support update --- docs/TRANSPONDERS.md | 258 ++++++++++ pm3py/trace/decode_iso14a.py | 78 +++ pm3py/trace/decode_iso14b.py | 98 ++++ pm3py/trace/decode_iso15.py | 39 ++ .../hf/iso14443a/infineon/__init__.py | 1 + .../hf/iso14443a/infineon/optiga_nbt.py | 475 ++++++++++++++++++ .../transponders/hf/iso14443a/nxp/__init__.py | 10 +- .../transponders/hf/iso14443a/nxp/ntag21x.py | 172 +++++++ .../transponders/hf/iso14443a/nxp/ntag_i2c.py | 245 +++++++++ pm3py/transponders/hf/iso14443a/nxp/type2.py | 395 +++++++++++++++ .../hf/iso14443a/nxp/ultralight.py | 265 ++++++++++ .../transponders/hf/iso14443a/st/__init__.py | 1 + pm3py/transponders/hf/iso14443a/st/st25ta.py | 353 +++++++++++++ pm3py/transponders/hf/iso14443a/st/st25tn.py | 99 ++++ pm3py/transponders/hf/iso14443b/__init__.py | 1 + pm3py/transponders/hf/iso14443b/base.py | 270 ++++++++++ .../transponders/hf/iso14443b/st/__init__.py | 1 + pm3py/transponders/hf/iso14443b/st/st25tb.py | 118 +++++ .../transponders/hf/iso14443b/ti/__init__.py | 1 + .../hf/iso14443b/ti/rf430cl330h.py | 152 ++++++ .../hf/iso15693/infineon/__init__.py | 1 + .../hf/iso15693/infineon/myd_vicinity.py | 150 ++++++ pm3py/transponders/hf/iso15693/st/st25dv.py | 380 ++++++++++++++ pm3py/transponders/hf/iso15693/st/st25tv.py | 333 ++++++++++++ pm3py/transponders/hf/iso15693/ti/__init__.py | 1 + pm3py/transponders/hf/iso15693/ti/rf430frl.py | 50 ++ pm3py/transponders/hf/iso15693/ti/tagit.py | 156 ++++++ pm3py/transponders/implants.py | 13 +- .../test_crc.cpython-312-pytest-9.0.1.pyc | Bin 4164 -> 6016 bytes tests/test_sim_myd_vicinity.py | 85 ++++ tests/test_sim_ntag21x.py | 220 ++++++++ tests/test_sim_ntag_i2c.py | 128 +++++ tests/test_sim_optiga_nbt.py | 181 +++++++ tests/test_sim_rf430cl330h.py | 143 ++++++ tests/test_sim_rf430frl.py | 53 ++ tests/test_sim_st25dv.py | 115 +++++ tests/test_sim_st25ta.py | 153 ++++++ tests/test_sim_st25tb.py | 141 ++++++ tests/test_sim_st25tn.py | 92 ++++ tests/test_sim_st25tv.py | 145 ++++++ tests/test_sim_tagit.py | 120 +++++ tests/test_sim_ultralight.py | 173 +++++++ 42 files changed, 5859 insertions(+), 6 deletions(-) create mode 100644 docs/TRANSPONDERS.md create mode 100644 pm3py/trace/decode_iso14b.py create mode 100644 pm3py/transponders/hf/iso14443a/infineon/__init__.py create mode 100644 pm3py/transponders/hf/iso14443a/infineon/optiga_nbt.py create mode 100644 pm3py/transponders/hf/iso14443a/nxp/ntag21x.py create mode 100644 pm3py/transponders/hf/iso14443a/nxp/ntag_i2c.py create mode 100644 pm3py/transponders/hf/iso14443a/nxp/type2.py create mode 100644 pm3py/transponders/hf/iso14443a/nxp/ultralight.py create mode 100644 pm3py/transponders/hf/iso14443a/st/__init__.py create mode 100644 pm3py/transponders/hf/iso14443a/st/st25ta.py create mode 100644 pm3py/transponders/hf/iso14443a/st/st25tn.py create mode 100644 pm3py/transponders/hf/iso14443b/__init__.py create mode 100644 pm3py/transponders/hf/iso14443b/base.py create mode 100644 pm3py/transponders/hf/iso14443b/st/__init__.py create mode 100644 pm3py/transponders/hf/iso14443b/st/st25tb.py create mode 100644 pm3py/transponders/hf/iso14443b/ti/__init__.py create mode 100644 pm3py/transponders/hf/iso14443b/ti/rf430cl330h.py create mode 100644 pm3py/transponders/hf/iso15693/infineon/__init__.py create mode 100644 pm3py/transponders/hf/iso15693/infineon/myd_vicinity.py create mode 100644 pm3py/transponders/hf/iso15693/st/st25dv.py create mode 100644 pm3py/transponders/hf/iso15693/st/st25tv.py create mode 100644 pm3py/transponders/hf/iso15693/ti/__init__.py create mode 100644 pm3py/transponders/hf/iso15693/ti/rf430frl.py create mode 100644 pm3py/transponders/hf/iso15693/ti/tagit.py create mode 100644 tests/test_sim_myd_vicinity.py create mode 100644 tests/test_sim_ntag21x.py create mode 100644 tests/test_sim_ntag_i2c.py create mode 100644 tests/test_sim_optiga_nbt.py create mode 100644 tests/test_sim_rf430cl330h.py create mode 100644 tests/test_sim_rf430frl.py create mode 100644 tests/test_sim_st25dv.py create mode 100644 tests/test_sim_st25ta.py create mode 100644 tests/test_sim_st25tb.py create mode 100644 tests/test_sim_st25tn.py create mode 100644 tests/test_sim_st25tv.py create mode 100644 tests/test_sim_tagit.py create mode 100644 tests/test_sim_ultralight.py diff --git a/docs/TRANSPONDERS.md b/docs/TRANSPONDERS.md new file mode 100644 index 0000000..07eca24 --- /dev/null +++ b/docs/TRANSPONDERS.md @@ -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.* diff --git a/pm3py/trace/decode_iso14a.py b/pm3py/trace/decode_iso14a.py index f478c24..97ccbaf 100644 --- a/pm3py/trace/decode_iso14a.py +++ b/pm3py/trace/decode_iso14a.py @@ -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 + return f"READ p{payload[1]}" + if b0 == 0x3A and len(payload) >= 3: # FAST_READ + return f"FAST_READ p{payload[1]}-{payload[2]}" + if b0 in (0xA2, 0xA0) and len(payload) >= 2: # WRITE / COMPAT_WRITE + 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 + return f"READ_CNT #{payload[1]}" + if b0 == 0xA5 and len(payload) >= 2: # INCR_CNT + return f"INCR_CNT #{payload[1]}" + if b0 == 0x3E and len(payload) >= 2: # CHECK_TEARING + 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] diff --git a/pm3py/trace/decode_iso14b.py b/pm3py/trace/decode_iso14b.py new file mode 100644 index 0000000..1cb6720 --- /dev/null +++ b/pm3py/trace/decode_iso14b.py @@ -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]) diff --git a/pm3py/trace/decode_iso15.py b/pm3py/trace/decode_iso15.py index 47b5b93..3da25d2 100644 --- a/pm3py/trace/decode_iso15.py +++ b/pm3py/trace/decode_iso15.py @@ -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 diff --git a/pm3py/transponders/hf/iso14443a/infineon/__init__.py b/pm3py/transponders/hf/iso14443a/infineon/__init__.py new file mode 100644 index 0000000..1c5922d --- /dev/null +++ b/pm3py/transponders/hf/iso14443a/infineon/__init__.py @@ -0,0 +1 @@ +"""Infineon ISO 14443-A transponders (OPTIGA Authenticate NBT).""" diff --git a/pm3py/transponders/hf/iso14443a/infineon/optiga_nbt.py b/pm3py/transponders/hf/iso14443a/infineon/optiga_nbt.py new file mode 100644 index 0000000..ca1a4ed --- /dev/null +++ b/pm3py/transponders/hf/iso14443a/infineon/optiga_nbt.py @@ -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: + 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 diff --git a/pm3py/transponders/hf/iso14443a/nxp/__init__.py b/pm3py/transponders/hf/iso14443a/nxp/__init__.py index c6c8211..be0cf9f 100644 --- a/pm3py/transponders/hf/iso14443a/nxp/__init__.py +++ b/pm3py/transponders/hf/iso14443a/nxp/__init__.py @@ -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). +""" diff --git a/pm3py/transponders/hf/iso14443a/nxp/ntag21x.py b/pm3py/transponders/hf/iso14443a/nxp/ntag21x.py new file mode 100644 index 0000000..607b8d8 --- /dev/null +++ b/pm3py/transponders/hf/iso14443a/nxp/ntag21x.py @@ -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 diff --git a/pm3py/transponders/hf/iso14443a/nxp/ntag_i2c.py b/pm3py/transponders/hf/iso14443a/nxp/ntag_i2c.py new file mode 100644 index 0000000..e9c569a --- /dev/null +++ b/pm3py/transponders/hf/iso14443a/nxp/ntag_i2c.py @@ -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" diff --git a/pm3py/transponders/hf/iso14443a/nxp/type2.py b/pm3py/transponders/hf/iso14443a/nxp/type2.py new file mode 100644 index 0000000..1f46cd1 --- /dev/null +++ b/pm3py/transponders/hf/iso14443a/nxp/type2.py @@ -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) diff --git a/pm3py/transponders/hf/iso14443a/nxp/ultralight.py b/pm3py/transponders/hf/iso14443a/nxp/ultralight.py new file mode 100644 index 0000000..ffe9d3b --- /dev/null +++ b/pm3py/transponders/hf/iso14443a/nxp/ultralight.py @@ -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 diff --git a/pm3py/transponders/hf/iso14443a/st/__init__.py b/pm3py/transponders/hf/iso14443a/st/__init__.py new file mode 100644 index 0000000..c62cdeb --- /dev/null +++ b/pm3py/transponders/hf/iso14443a/st/__init__.py @@ -0,0 +1 @@ +"""STMicroelectronics ISO 14443-A transponders (ST25TA Type 4, ST25TN Type 2).""" diff --git a/pm3py/transponders/hf/iso14443a/st/st25ta.py b/pm3py/transponders/hf/iso14443a/st/st25ta.py new file mode 100644 index 0000000..8433f96 --- /dev/null +++ b/pm3py/transponders/hf/iso14443a/st/st25ta.py @@ -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" diff --git a/pm3py/transponders/hf/iso14443a/st/st25tn.py b/pm3py/transponders/hf/iso14443a/st/st25tn.py new file mode 100644 index 0000000..b7de59d --- /dev/null +++ b/pm3py/transponders/hf/iso14443a/st/st25tn.py @@ -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 diff --git a/pm3py/transponders/hf/iso14443b/__init__.py b/pm3py/transponders/hf/iso14443b/__init__.py new file mode 100644 index 0000000..f41d02d --- /dev/null +++ b/pm3py/transponders/hf/iso14443b/__init__.py @@ -0,0 +1 @@ +"""ISO 14443 Type B transponders (ST25TB / SRIX family).""" diff --git a/pm3py/transponders/hf/iso14443b/base.py b/pm3py/transponders/hf/iso14443b/base.py new file mode 100644 index 0000000..5b57236 --- /dev/null +++ b/pm3py/transponders/hf/iso14443b/base.py @@ -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" diff --git a/pm3py/transponders/hf/iso14443b/st/__init__.py b/pm3py/transponders/hf/iso14443b/st/__init__.py new file mode 100644 index 0000000..5cfd074 --- /dev/null +++ b/pm3py/transponders/hf/iso14443b/st/__init__.py @@ -0,0 +1 @@ +"""STMicroelectronics ISO 14443 Type B transponders (ST25TB / SRIX).""" diff --git a/pm3py/transponders/hf/iso14443b/st/st25tb.py b/pm3py/transponders/hf/iso14443b/st/st25tb.py new file mode 100644 index 0000000..2b8bae8 --- /dev/null +++ b/pm3py/transponders/hf/iso14443b/st/st25tb.py @@ -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 ), 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 diff --git a/pm3py/transponders/hf/iso14443b/ti/__init__.py b/pm3py/transponders/hf/iso14443b/ti/__init__.py new file mode 100644 index 0000000..06a2d04 --- /dev/null +++ b/pm3py/transponders/hf/iso14443b/ti/__init__.py @@ -0,0 +1 @@ +"""Texas Instruments ISO 14443 Type B transponders (RF430CL330H NFC Type 4B).""" diff --git a/pm3py/transponders/hf/iso14443b/ti/rf430cl330h.py b/pm3py/transponders/hf/iso14443b/ti/rf430cl330h.py new file mode 100644 index 0000000..f09ff60 --- /dev/null +++ b/pm3py/transponders/hf/iso14443b/ti/rf430cl330h.py @@ -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 diff --git a/pm3py/transponders/hf/iso15693/infineon/__init__.py b/pm3py/transponders/hf/iso15693/infineon/__init__.py new file mode 100644 index 0000000..a7cf71b --- /dev/null +++ b/pm3py/transponders/hf/iso15693/infineon/__init__.py @@ -0,0 +1 @@ +"""Infineon ISO 15693 transponders (my-d vicinity SRF55VxxP).""" diff --git a/pm3py/transponders/hf/iso15693/infineon/myd_vicinity.py b/pm3py/transponders/hf/iso15693/infineon/myd_vicinity.py new file mode 100644 index 0000000..f4ed262 --- /dev/null +++ b/pm3py/transponders/hf/iso15693/infineon/myd_vicinity.py @@ -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 ). + +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 + 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 diff --git a/pm3py/transponders/hf/iso15693/st/st25dv.py b/pm3py/transponders/hf/iso15693/st/st25dv.py new file mode 100644 index 0000000..971840e --- /dev/null +++ b/pm3py/transponders/hf/iso15693/st/st25dv.py @@ -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(" 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 diff --git a/pm3py/transponders/hf/iso15693/st/st25tv.py b/pm3py/transponders/hf/iso15693/st/st25tv.py new file mode 100644 index 0000000..38af97e --- /dev/null +++ b/pm3py/transponders/hf/iso15693/st/st25tv.py @@ -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 diff --git a/pm3py/transponders/hf/iso15693/ti/__init__.py b/pm3py/transponders/hf/iso15693/ti/__init__.py new file mode 100644 index 0000000..e680333 --- /dev/null +++ b/pm3py/transponders/hf/iso15693/ti/__init__.py @@ -0,0 +1 @@ +"""Texas Instruments ISO 15693 transponders (Tag-it HF-I, RF430FRL15xH).""" diff --git a/pm3py/transponders/hf/iso15693/ti/rf430frl.py b/pm3py/transponders/hf/iso15693/ti/rf430frl.py new file mode 100644 index 0000000..1493710 --- /dev/null +++ b/pm3py/transponders/hf/iso15693/ti/rf430frl.py @@ -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" diff --git a/pm3py/transponders/hf/iso15693/ti/tagit.py b/pm3py/transponders/hf/iso15693/ti/tagit.py new file mode 100644 index 0000000..ff801b2 --- /dev/null +++ b/pm3py/transponders/hf/iso15693/ti/tagit.py @@ -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"") diff --git a/pm3py/transponders/implants.py b/pm3py/transponders/implants.py index 8ef3db7..d5d8d80 100644 --- a/pm3py/transponders/implants.py +++ b/pm3py/transponders/implants.py @@ -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: diff --git a/tests/__pycache__/test_crc.cpython-312-pytest-9.0.1.pyc b/tests/__pycache__/test_crc.cpython-312-pytest-9.0.1.pyc index 62d108d291a77f3fa3103a56b167e2575091fb4f..f4ab22aa8810432adb40a4a99623f86d7e828ac7 100644 GIT binary patch delta 1771 zcmai!U1%It6oBuYo!OcFZT_>VX%h3h)7Ui4W;c;`t(p`RLDa{9ESsH4-8B2-ok?|< zS$4q)3FKkJ6pW(S_AT~7OJC}f3L=6p`DvRl2#OCvo*EM$BtFD*X7Yy!US_{J=iGD8 z&b{ZH`+Y~{V(_Oxz)N8K{q)soPFM55t4Mw?f7%atz-B8Cvd zh>?BsNA45wNV#s&F7q$fA{8He$iKoh?s*{mrJ=}hG&VB&d|LR15GI+wxvx>b85YB5 z>tI@TvtD>vz8CX_L{igKrVp`pb6SkiPV<7nRnFpnG5G4h$x~F(S)c+G$TTY z7Q|UZ13>mzQl6>F%;b#9hS7Qs;Ot{67h{>E4(E8Xt3mc0I<+GP5ElU&o>0fpV4dc8 zu!F`QsKGGhMVPu@cuLEx2c&ei3Tq^g7-pUE2p^&bQ45egj@ABb{KQjR`LA6(11_2y z;QT-91kq}SjOz&}{Wr3?_p*udO*N(G7`qD77*+cKP$9q8o?GR^i_vQ9hOZ5z+r5BQ zU)$~8HD7p>$LH~(!O@PnhRYmn<3xPKhpVlQb=_WfB$pxd@!DhI2_I&7LMiwi4>Vvl z_kO2@d_;7hGp-Yb-lg{r^%60Nj(+wq$dSDWdWkAri8JUpc^e9+HH>c7g9+?@?FT@G{OKnC+V$G54PSRR>+-U`QQy7h@2*_i^43(wwrU!8 ziAxxx^Zb_No7We7^RAz3o2u`cp~Pg6FZLI?6HE(CTO#C*mVMRE5UDWv8EYK4dqJ ze8!R{XVWu!I;*ip^Yb&2GFFFGpU*_{g@II#sRMbI({riZjB`a|McTqNQy)!i zbH5nDkJt6JR$nW+Zbj^5MMNI!d5Frt`Uqy<>*t`go5rm=YT&4_35pi!B1xo>*B9GwOGIVWvLV+#vQiMzu+O_ZqqOT(cbqL(^va)u(_qQ}gRzxi zO5j8exnP2(MHG293Y**I-D+h)6z~YRUAlkp0l*z&4R*tlk19a?(XT?{sau2C>)$WV C@Nk*{ diff --git a/tests/test_sim_myd_vicinity.py b/tests/test_sim_myd_vicinity.py new file mode 100644 index 0000000..abbab20 --- /dev/null +++ b/tests/test_sim_myd_vicinity.py @@ -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 diff --git a/tests/test_sim_ntag21x.py b/tests/test_sim_ntag21x.py new file mode 100644 index 0000000..5dea9ab --- /dev/null +++ b/tests/test_sim_ntag21x.py @@ -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" diff --git a/tests/test_sim_ntag_i2c.py b/tests/test_sim_ntag_i2c.py new file mode 100644 index 0000000..1325c1b --- /dev/null +++ b/tests/test_sim_ntag_i2c.py @@ -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" diff --git a/tests/test_sim_optiga_nbt.py b/tests/test_sim_optiga_nbt.py new file mode 100644 index 0000000..8624d72 --- /dev/null +++ b/tests/test_sim_optiga_nbt.py @@ -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" diff --git a/tests/test_sim_rf430cl330h.py b/tests/test_sim_rf430cl330h.py new file mode 100644 index 0000000..5c8b0ea --- /dev/null +++ b/tests/test_sim_rf430cl330h.py @@ -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 diff --git a/tests/test_sim_rf430frl.py b/tests/test_sim_rf430frl.py new file mode 100644 index 0000000..41a8cf4 --- /dev/null +++ b/tests/test_sim_rf430frl.py @@ -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 diff --git a/tests/test_sim_st25dv.py b/tests/test_sim_st25dv.py new file mode 100644 index 0000000..6eaa240 --- /dev/null +++ b/tests/test_sim_st25dv.py @@ -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(" 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 diff --git a/tests/test_sim_st25tb.py b/tests/test_sim_st25tb.py new file mode 100644 index 0000000..80ff7c5 --- /dev/null +++ b/tests/test_sim_st25tb.py @@ -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") diff --git a/tests/test_sim_st25tn.py b/tests/test_sim_st25tn.py new file mode 100644 index 0000000..2abba25 --- /dev/null +++ b/tests/test_sim_st25tn.py @@ -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" diff --git a/tests/test_sim_st25tv.py b/tests/test_sim_st25tv.py new file mode 100644 index 0000000..d6dd8ea --- /dev/null +++ b/tests/test_sim_st25tv.py @@ -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 diff --git a/tests/test_sim_tagit.py b/tests/test_sim_tagit.py new file mode 100644 index 0000000..ada367a --- /dev/null +++ b/tests/test_sim_tagit.py @@ -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 diff --git a/tests/test_sim_ultralight.py b/tests/test_sim_ultralight.py new file mode 100644 index 0000000..982d8ad --- /dev/null +++ b/tests/test_sim_ultralight.py @@ -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"