- Java 49.9%
- Rust 31.2%
- TypeScript 18.7%
- JavaScript 0.2%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
All checks were successful
Publish (NPM) / publish (release) Successful in 9s
Publish (JSR) / publish (release) Successful in 13s
Publish (Maven) / publish (release) Successful in 1m0s
ci-orchestrator / provision running on cpx42 in fsn1
Publish (crates.io) / publish (release) Successful in 23s
|
||
| .forgejo/workflows | ||
| .github | ||
| .vscode | ||
| docs | ||
| lib | ||
| .gitignore | ||
| LEGAL.md | ||
| LICENSE | ||
| README.md | ||
Zaragoza & Aragon Transport Card Spec
See also: Zaragoza Tarjeta Bus Android App
Important
⚠️ Aviso legal: Este repositorio es un proyecto de investigación de seguridad independiente.
No está destinado a cometer fraude ni a facilitar el uso indebido del transporte público.
No se proporcionará asistencia para ningún uso ilícito.
Consulta LEGAL.md para el descargo de responsabilidad completo.
Zaragoza and Aragon Avanza/Lazo bus & tram public transport card full up-to-date specification, reverse engineered from multiple MIFARE Classic card dumps. This project intends to be a security research paper, publicly available for anyone for free and is not qualified as a guide. There are no step-by-step instructions on how to commit fraud.
- Zaragoza & Aragon Transport Card Spec
Getting started
Zaragoza and Aragon public transport uses Avanza and Lazo cards, both of which are MIFARE Classic, meaning they're vulnerable to the Crypto-1 attack. The keys can be dumped with a Proxmark, but since they're static and well-known, you can use the ones listed for each card below.
Every sector has two keys (Key A and Key B) that control access to its blocks, and the last block of every sector is its trailer, holding both keys and the access conditions.
| Avanza Tarjeta Bus | Lazo card | |
|---|---|---|
| Chip | MIFARE Classic 1K | MIFARE Classic 4K |
| SAK | 88 |
18 |
| UID | 4 bytes | 4 or 7 bytes |
| Sectors | 16 | 40 (sectors 0-31 with 4 blocks, 32-39 with 16 blocks) |
| Blocks | 64 | 256 |
A 4-byte UID is followed by a BCC, the XOR of its four bytes; a 7-byte UID has none, so its SAK and ATQA sit two bytes later. Check [07] first: a 7-byte UID can hold a SAK value in [05] by chance.
What is stored inside the blocks is the same on both cards and is documented once under data structures. Block 1 tells the products apart, see card type. Everything that is still unresolved is collected under unconfirmed.
Avanza Tarjeta Bus
Avanza keys
| Sectors | Key A | Key B |
|---|---|---|
| 0-8 | 04000C0F0903 |
0B02070A0409 |
| 9-15 (unused) on top up cards | A0A1A2A3A4A5 |
B0B1B2B3B4B5 |
| 9-15 (unused) on personal cards | 04000C0F0903 |
0B02070A0409 |
On top up cards Key B can rewrite the keys and access conditions of every sector (trailer access bits 011); on personal cards the trailers are locked (110), so no key can change a sector key or an access bit again. The data blocks keep the same access conditions on both, so the lock does not freeze the card's contents.
Sectors 9-15 of a top up card are not in factory condition: they carry the NXP sample keys with access bytes 7F0788 and user byte 00, not FFFFFFFFFFFF / FF0780 / 69.
Each trailer's three access bytes are fixed, and differ per sector and card kind:
| Sectors | Access bytes (top up) | Access bytes (personal) |
|---|---|---|
| 0 | 2C378D |
24BF0D |
| 1 | 7E1788 |
769F08 |
| 2 | 4C378B |
44BF0B |
| 3-6 | 787788 |
70FF08 |
| 7 | 7F0788 |
778F08 |
| 8 | 3B478C |
33CF0C |
| 9-15 | 7F0788 |
778F08 |
The user byte is 00 on every Avanza trailer.
Avanza blocks
| Sector | Block | Description | Template | Access Conditions |
|---|---|---|---|---|
| 0 | 0 | [00-03] RFID's UID [04] BCC, the XOR of the four UID bytes [05] SAK ( 88 for MIFARE Classic 1K)[06-07] ATQA ( 0400)[11] 20 and [15] the last two digits of the manufacturing year: read together they spell it in BCD, 20 25 = 2025 |
..,,..,,..880400C8,,0020000000.. |
Read-only |
| 1 | Card type | ..,,..000000000000000000000000,, |
Only Key B can write | |
| 2 | Card ID | 42,,..,,..00000000000000000000.. |
Read-only | |
| 3 | 0th sector's trailer block | 04000C0F0903..,,..,,0B02070A0409 |
Trailer | |
| 1 | 4 | Empty on top up cards, see block 4 [15] XOR of all previous bytes |
00000000000000000000000000000000 |
Only Key B can write |
| 5 | Latest transaction log entry | ..,,..,,..,,..,,..,,..,,..,,..,, |
No restrictions | |
| 6 | Always empty | 00000000000000000000000000000000 |
No restrictions | |
| 7 | 1st sector's trailer block | 04000C0F0903..,,..,,0B02070A0409 |
Trailer | |
| 2 | 8 | Balance | ..,,0000..,,FFFF..,,000002FD02FD |
Value block |
| 9 | Always has the same value as block 8 | ..,,0000..,,FFFF..,,000002FD02FD |
Value block | |
| 10 | Journey summary, empty on a new card Not maintained on personal cards: all zero, or 000000000000000A000000000000000A |
..,,..,,..,,..,,..,,..0000..00.. |
No restrictions | |
| 11 | 2nd sector's trailer block | 04000C0F0903..,,..,,0B02070A0409 |
Trailer | |
| 3 | 12 | Subscription metadata | 00000000000000000000000000000000 |
Only Key B can write |
| 13 | Subscription on personal cards | 00000000000000000000000000000000 |
Only Key B can write | |
| 14 | Mirror of block 13, not always written; do not rely on the copy | 00000000000000000000000000000000 |
Only Key B can write | |
| 15 | 3rd sector's trailer block | 04000C0F0903..,,..,,0B02070A0409 |
Trailer | |
| 4 | 16 | Subscription metadata of a second product | 00000000000000000000000000000000 |
Only Key B can write |
| 17 | Subscription of the second product | 00000000000000000000000000000000 |
Only Key B can write | |
| 18 | Mirror of block 17, see block 14 | 00000000000000000000000000000000 |
Only Key B can write | |
| 19 | 4th sector's trailer block | 04000C0F0903..,,..,,0B02070A0409 |
Trailer | |
| 5 | 20 | Empty | 00000000000000000000000000000000 |
Only Key B can write |
| 21 | Empty | 00000000000000000000000000000000 |
Only Key B can write | |
| 22 | Empty | 00000000000000000000000000000000 |
Only Key B can write | |
| 23 | 5th sector's trailer blocks | 04000C0F0903..,,..,,0B02070A0409 |
Trailer | |
| 6 | 24 | Empty on most cards, see block 24 | 00000000000000000000000000000000 |
Only Key B can write |
| 25 | Empty | 00000000000000000000000000000000 |
Only Key B can write | |
| 26 | Empty | 00000000000000000000000000000000 |
Only Key B can write | |
| 27 | 6th sector's trailer block | 04000C0F0903..,,..,,0B02070A0409 |
Trailer | |
| 7 | 28 | Transaction logs; value of block 5 right before overwriting it, archived here when its sequence counter is 0 and in blocks 29, 30, 32 and 33 for counters 1 to 4 |
..,,..,,..,,..,,..,,..,,..,,..,, |
No restrictions |
| 29 | See block 28 | ..,,..,,..,,..,,..,,..,,..,,..,, |
No restrictions | |
| 30 | See block 28 | ..,,..,,..,,..,,..,,..,,..,,..,, |
No restrictions | |
| 31 | 7th sector's trailer block | 04000C0F0903..,,..,,0B02070A0409 |
Trailer | |
| 8 | 32 | See block 28 | ..,,..,,..,,..,,..,,..,,..,,..,, |
No restrictions |
| 33 | See block 28 | ..,,..,,..,,..,,..,,..,,..,,..,, |
No restrictions | |
| 34 | Expiration date, a guess; encoding unknown. Usually an empty value block, see below | 00000000FFFFFFFF0000000000FF00FF |
Value block | |
| 35 | 8th sector's trailer block | 04000C0F0903..,,..,,0B02070A0409 |
Trailer |
Blocks 8, 9 and 34 are value blocks: a 32-bit integer, its bitwise complement, then the integer again, with value block access conditions (Key A can read, decrement, restore and transfer, Key B can also write and increment).
Block 34 usually holds 00000000FFFFFFFF0000000000FF00FF, an empty value block with the address bytes 00FF00FF. A personal card can instead hold a value with the address bytes 22DD22DD, such as 37810005C87EFFFA3781000522DD22DD, where bytes [00-01] 3781 read as a date give 2027-12-01. The field is unidentified and the expiration date is a guess from that reading.
The chip write-protects block 0; its access bits say 110, the value block condition, rather than the 010 of a read-only block. That shows only on a magic card, where block 0 is writable.
Lazo card
Lazo keys
| Sectors | Blocks | Key A | Key B |
|---|---|---|---|
| 0-31 | 0-127 | 4E303D402F20 |
243372407C2E |
| 32 | 128-143 | 216F5B212A7A |
44202E476E5B |
| 33 | 144-159 | 5148755C3427 |
3C4520753758 |
| 34 | 160-175 | Unknown | 206F7C4C4F36 |
| 35 | 176-191 | 5246612E7C4B |
Unknown |
| 36 | 192-207 | 354B39454861 |
567D734C403C |
| 37 | 208-223 | 455D732C385F |
2426217B3B3B |
| 38-39 (unused) | 224-255 | FFFFFFFFFFFF |
FFFFFFFFFFFF |
Each trailer's three access bytes are fixed per sector:
| Sectors | Access bytes |
|---|---|
| 0 | 7B4788 |
| 1-2, 7-9 | 7F0788 |
| 3-6 | 7E1788 |
| 10, 15 | 787788 |
| 11-14 | 0F00FF |
| 16-31 | 08778F |
| 32-37 | 787788 |
| 38-39 | FF0780 |
The user byte is 69 on sectors 0-31 and 38-39, 00 on 32-37. Key B can rewrite every sector's keys and access conditions (trailer access bits 011) except 38 and 39, which still have the factory default keys and access conditions.
Lazo blocks
| Sector | Block | Description | Template | Access Conditions |
|---|---|---|---|---|
| 0 | 0 | [00-06] RFID's UID (7 bytes, so no BCC) [07] SAK ( 18 for MIFARE Classic 4K)[08-09] ATQA ( 0200)[10-15] manufacturer data; [15] is most likely the last two digits of the manufacturing year 4-byte UID: [00-03] UID, [04] BCC, [05] SAK, [06-07] ATQA |
..,,..,,..,,..1802008100000023.. |
Read-only |
| 1 | Card type | ..,,..000000000000000000000000,, |
No restrictions | |
| 2 | Card ID | 4354..,,..00000000000000000000.. |
Only Key B can write | |
| 3 | 0th sector's trailer block | 4E303D402F20..,,..,,243372407C2E |
Trailer | |
| 1 | 4 | Empty on most cards, see block 4 | 00000000000000000000000000000000 |
No restrictions |
| 5 | Latest transaction log entry | 0D..,,..,,..,,..,,..,,..,,..,,.. |
No restrictions | |
| 6 | Always empty | 00000000000000000000000000000000 |
No restrictions | |
| 7 | 1st sector's trailer block | 4E303D402F20..,,..,,243372407C2E |
Trailer | |
| 2 | 8 | Balance | ..,,0000..,,FFFF..,,000002FD02FD |
No restrictions |
| 9 | Always has the same value as block 8 | ..,,0000..,,FFFF..,,000002FD02FD |
No restrictions | |
| 10 | Journey summary, empty on a new card | ..,,..,,..,,..0D..,,..0000..00.. |
No restrictions | |
| 11 | 2nd sector's trailer block | 4E303D402F20..,,..,,243372407C2E |
Trailer | |
| 3-6 | 12, 16, 20, 24 | Empty on top up cards | 00000000000000000000000000000000 |
Only Key B can write |
| 13-14, 17-18, 21-22, 25-26 | Empty on top up cards | 00000000000000000000000000000000 |
No restrictions | |
| 15, 19, 23, 27 | Trailer blocks of sectors 3-6 | 4E303D402F20..,,..,,243372407C2E |
Trailer | |
| 7 | 28-30 | Transaction log archive slots for sequence counters 0, 1 and 2 | 0D..,,..,,..,,..,,..,,..,,..,,.. |
No restrictions |
| 31 | 7th sector's trailer block | 4E303D402F20..,,..,,243372407C2E |
Trailer | |
| 8 | 32-33 | Transaction log archive slots for sequence counters 3 and 4 | 0D..,,..,,..,,..,,..,,..,,..,,.. |
No restrictions |
| 34 | Always empty | 00000000000000000000000000000000 |
No restrictions | |
| 35 | 8th sector's trailer block | 4E303D402F20..,,..,,243372407C2E |
Trailer | |
| 9 | 36-38 | Empty | 00000000000000000000000000000000 |
No restrictions |
| 10, 15 | 40-42, 60-62 | Empty | 00000000000000000000000000000000 |
Only Key B can write |
| 11-14 | data blocks 44-46, 48-50, 52-54, 56-58 | Empty | 00000000000000000000000000000000 |
Only Key B can read and write |
| 16-31 | data blocks 64-126 | Empty | 00000000000000000000000000000000 |
Value block |
| 32-37 | data blocks 128-222 | Empty | 00000000000000000000000000000000 |
Only Key B can write |
| 38-39 | data blocks 224-254 | Empty, never personalized | 00000000000000000000000000000000 |
No restrictions |
The trailer blocks of sectors 9-39 (39, 43, ..., 127, then 143, 159, 175, 191, 207, 223, 239 and 255) hold the keys from the table above.
Blocks 8 and 9 use the value block format (integer, complement, integer) but their sector is unrestricted, so both keys can write them directly. Only the empty sectors 16-31 carry real value block access conditions.
The chip write-protects block 0 here too, though its access bits say 000, no restriction at all.
Data structures
These are the same on both cards. Where a field differs on personal cards, the field list says so.
Date
Two bytes, read as 16 bits:
- 7 bits: year, 2000-based
- 4 bits: month (1-12)
- 5 bits: day (1-31)
34 4E = 0011010 0010 01110 = 2026-02-14.
See implementation for JS, Rust, Java.
Card ID
- [00-01] ASCII prefix:
BEon Avanza top up cards,BPon Avanza personal cards,CTon Lazo cards - [02-04] Card number, one decimal digit per nibble
- [05-14] zero
- [15] XOR of all previous bytes
The prefix identifies a personal Avanza card without reading block 1.
The id is the number printed on the card.
See implementation for JS, Rust, Java.
Card type
Block 1 says which product a card is:
- [00] Kind of card:
02and0Don the top up cards,0Aon the personal ones. Whether it is a field of its own is unconfirmed, so read the three bytes as one card type - [01-02] The rest of the card type, opaque
- [03-14] zero
- [15] XOR of all previous bytes
| Card type | Chip | Block 1 value |
|---|---|---|
| Balance top-up Avanza card | MIFARE Classic 1K | 02699F000000000000000000000000 |
| Personal Avanza card | MIFARE Classic 1K | 0A9775000000000000000000000000 |
| Personal Avanza card, second profile | MIFARE Classic 1K | 0A98DA000000000000000000000000 |
| Balance top-up Lazo card | MIFARE Classic 4K | 0D371F000000000000000000000000 |
| Balance top-up Lazo card, second value | MIFARE Classic 4K | 0D375F000000000000000000000000 |
Block 1 identifies the product, not the card. What separates the two personal values is unknown, and so is what separates the two Lazo values; cards carrying 0A98DA are printed "Abono de transporte".
Top up cards pay for each journey out of a balance; personal cards use a subscription and never change balance.
See implementation for JS, Rust, Java.
Balance
Blocks 8 and 9, identical, in the MIFARE value block format. €1.00 = 1000 units.
- [00-03] units, little-endian
- [04-07] bitwise complement of [00-03]
- [08-11] [00-03] again
- [12-15] address bytes, always
02FD02FD
€5.00 is 8813000077ECFFFF8813000002FD02FD. Personal cards always hold zero: 00000000FFFFFFFF0000000002FD02FD.
See implementation for JS, Rust, Java.
Transaction log
Six 16-byte records: block 5 holds the newest, blocks 28, 29, 30, 32 and 33 the five before it. When a new transaction is written, the previous block 5 is copied into the archive slot selected by its sequence byte [15] (0 to block 28, 1 to 29, 2 to 30, 3 to 32, 4 to 33), so the counter wraps every five transactions. Block 34 is never part of the ring.
- [00] The product that paid: the subscription metadata product id on a personal card, the first byte of the card type on a top up card, which has no products
- [01]
00on top up cards;01or02on personal cards, matching bit 15 of [05-06] - [02-03] Amount, big-endian, in balance units;
0000when free of charge, always0000on personal cards - [04] Consecutive payments of this card at one terminal, counting from 1;
00on top ups and on a check-out - [05-06] Stop. Bit 15 set: an urban bus stop, the other 15 bits an internal stop id. Bit 15 clear: the tram, where the value is the stop number × 100 (
0514= 1300 = Plaza España), or another operator - [07] Route id of the operator's GTFS feed: numbered buses have their number, Ci1 to Ci4 are 11 to 14, N1 to N7 are 111 to 117, the tram is 210
- [08]
01or02: a journey and its direction;08: a top up - [09] See byte 09
- [10-11] Date
- [12] Hour, [13] minute, [14] second, plain binary
- [15] Sequence counter, 0 to 4
A journey subtracts the amount from the balance, a top up adds it. On a top up card a journey with amount 0000 is a free transfer; on a personal card every journey carries 0000 and none is a transfer. Byte [01] tells them apart: 00 on a top up card, non-zero on a personal one. The operator grants one per paid ride, to a different line, within 60 minutes for the urban Tarjeta BUS and 75 when a CTAZ card enters Zaragoza; bus and tram count as one network. A top up carries the point of sale id in [05-06] and zeros in [04], [07] and [09], unless it was made on board, where it carries the line and stop like a journey. Top ups do not touch block 10, and the balance a new card is sold with leaves no record.
Byte [08] is the operator's GTFS direction_id plus one, so it picks one of the two headsigns the feed gives that route: 01 is direction_id 0 and 02 is direction_id 1. Ci1 and Ci2 carry 02 as well as 01, so either the ring routes are not one-way in the feed or the 11 to 14 mapping needs revisiting. The tram directions are still unconfirmed, probably 01 runs south to Mago de Oz and 02 north to Avenida de la Academia.
See implementation for JS, Rust, Java.
Journey summary (block 10)
Rewritten on every journey, untouched by top ups, so it stays all zero on a card that has only ever been topped up. Personal cards do not maintain it: it stays all zero, or holds a product id in [07] with every other byte zero. The layout below is the top up card one.
- [00-01] Line and direction of the previous journey,
0000on the first; consecutive payments at one terminal count as one journey - [02-03] Date and [04-05] hour and minute of the last journey that was charged; on a free transfer these still point at the paid ride it belongs to
- [06] Consecutive payments counter of the current journey, the same as byte [04] of block 5
- [07] The same product id as byte [00] of a transaction
- [08]
01when the current journey was free,00when paid - [09] Line of the current journey
- [10] Direction of the current journey, except on
0D375F, see block 10 on a 0D375F card - [11-12]
0000 - [13]
63after a paid journey,62after a free transfer, except on0D375F - [14]
00 - [15] XOR of all previous bytes
A free transfer onto the tram 33 minutes after a paid ride on bus 31, block 5 above block 10:
pi ?1 amt cp stop ln dr ?9 date HH MM SS sq
5: 0D 00 0000 01 05DC D2 02 01 3518 16 2C 20 00
10: 1F 01 3518 16 0B 01 0D 01 D2 02 0000 62 00 91
pl pd date HH MM cp pi fr ln dr 0000 pm 00 xr
pi = product id cp = consecutive payments fr = free flag
amt = amount ln dr = line, direction pm = 63 paid / 62 free
stop = stop id pl pd = previous line, direction
date = date HH MM SS = time sq = sequence
?1 = byte 01 ?9 = byte 09 xr = XOR
See implementation for JS, Rust, Java.
Subscription metadata
Blocks 12 and 16 on personal cards, one product per sector. Still work in progress.
- [00] Product id, the code a transaction carries in byte [00] and block 4 lists. It is not a duration: one id covers passes of different lengths
- [01] Unknown, appears to always be
01 - [02-03] Purchase date
- [04-07] Unknown, appears to always be
00210000 - [08-09] Validity in days
- [10-14] Unknown, appears to always be
0021000000 - [15] XOR of all previous bytes
See implementation for JS, Rust, Java.
Subscription
Block 13 for the product of block 12, block 17 for the product of block 16. Blocks 14 and 18 mirror them, but not always, so read 13 and 17 and ignore the mirror. Still work in progress.
- [00-01] Start date
- [02-03] End date, the start date plus the metadata's validity in days minus one. The pass stays valid through the whole of that day
- [04-05] Unknown, appears to always be
0000 - [06-09] Unknown, and zero except on a year long pass
- [10-11] Date of the last usage, [12] hour, [13] minute, [14] second. Only a journey whose stop has bit 15 clear moves it, the tram and other operators, so a pass used on urban buses alone keeps it all zero
- [15] XOR of all previous bytes
The metadata purchase date and the start date are independent: a pass can be bought days before it starts, and two passes on one card need not cover consecutive days.
See implementation for JS, Rust, Java.
Unconfirmed
Theories and open questions.
Byte 09 of a transaction
On buses it's most likely which trip of the vehicle's daily duty this is, counting from 1. Avanza's GTFS names every trip <service>__<N>, where N is the trip's chronological position within one vehicle's day, and the byte matches the N of the trip in progress. It never exceeds the longest duty on its route, which a count of the day's trips would.
It's incremented through the day, stays same across consecutive payments and across an on-board top up and the ride paid in the same second, repeats at the same hour on different days, and differs widely between routes at one hour. It is neither the stop's position along the route nor the passenger's order at the stop.
On the tram it is something else: it increments by one per validation on an Avanza balance card and is 01 on a Lazo card. Off the tram a Lazo card's value varies by route; route 150 always writes 01. On a personal Avanza card off the Avanza network it's 00.
Route ids 150, 152 and 251
None is in Avanza's feed, whose ids stop at 210, and their stop ids are in neither the urban nor the tram space. 152 and 251 appear only on personal cards. 150 charges 810 units where the urban fare is 550, at stops 19, 58, 64 and 3016. All three are unidentified.
Cercanias
Cercanías is the Renfe commuter rail that runs under the city. It charges its own fare, and a journey is a check-in and a check-out where the second validation costs nothing.
Byte [04] is 00. Byte [06] looks like the station's position along the line, counting from the far terminal stop. Byte [05] is 00 on the check-in and 23 on the check-out.
The only known Cercanías route id is 169.
Stop ids
The urban stop id in [05-06] looks like one network-wide location space rather than one scoped to the route: the ids of a single route span a range far wider than its stop count, and one id can appear on two routes. Within one route the id means a location rather than a platform, the same id appearing in both directions. No public identifier has been found. Neither the PA number on the stop nor the GTFS stop_id, nor the rank of the stop in any ordering of the pole data, nor a position along the route.
| Line | 05-06 bytes | 09 byte | Pole | Name |
|---|---|---|---|---|
| 22 | 81AF |
12 |
676 | P. María Agustín 37 |
| 35 | 804C |
11 |
471 | Fueros de Aragón 15 |
| 31 | 81DB |
0B |
3071 | Av. de Madrid / Aljafería |
| 35 | 8099 |
09 |
707 | Plaza Aragón 1 |
| 31 | 807E |
08 |
147 | Av. Francisco de Goya 83 |
| 22 | 81C8 |
0D |
434 | Duquesa Villahermosa 3 |
| 30 | 802E |
0F |
430 | Doctor Iranzo N.º 61 |
| 40 | 805D |
25 |
633 | P. de la Constitución 16 |
| Ci4 | 808F |
0C |
3030 | Av. de San José 7 |
| 22 | 81B8 |
03 |
||
| 22 | 819E |
04 |
||
| 22 | 80CF |
06 |
||
| 30 | 805D |
05 |
||
| 30 | 806F |
0A |
||
| Ci3 | 80AE |
03 |
||
| Ci3 | 81DB |
09 |
||
| 44 | 80BE |
13 |
The values 1, 3, 4 and 5 appear on unrelated routes and may be placeholders. A70F is 9999 with the urban bit set, a sentinel a validator writes when it has no stop configured.
Block 10 on a 0D375F card
Bytes [00-09] and the XOR follow the documented layout, but [10] holds 3D where the direction belongs and [13] holds 01 where 63 or 62 belongs. Either [13] carries the direction on this product, or [10] packs it in its low two bits, as 3D ends in 01. A journey in direction 02 would tell them apart.
Block 24
Empty on most top up cards. On the others it's 0200, something shaped like a date at [02-03] repeated at [10-11], zeros, 01 at [14] and an XOR of all previous bytes at [15].
Block 4
Empty on Avanza top up cards, so a non-zero block 4 marks a personal Avanza card. A Lazo top up card can hold 10 in [12], meaning unknown. On a personal card it is a list of fixed 3-byte records packed from byte 0, one per product: [product id][unknown byte][sector]. A single product gives 061203, two give 060003 then 0A1204. The middle byte is 12 on a product still valid and 00 on an expired one, which is a guess: only those two values are known.
Subscription blocks
Personal cards have one product per sector in sectors 3 and 4, each with its own metadata and subscription blocks. The last usage field does not move on every journey, see subscription.
Notes
- A top up can carry
21in the sequence byte instead of 0 to 4. It goes into block 33 and does not advance the counter - Before a ring has wrapped, an unused archive slot can hold
00000000000000000000000000000004instead of all zeroes - A personal card has four product slots: sectors 3, 4, 5 and 6 carry the same access bytes
70FF08and operator keys, with blocks 12, 16, 20 and 24 as their heads. Only sectors 3 and 4 are ever occupied - Blocks 20, 21, 22, 25 and 26 are always zero, as is every data block of sectors 9 to 15
Implementations
JavaScript, TypeScript
lib/javascript is the spec implementation library in JavaScript/TypeScript. Zero dependencies, compatible with Node.js >= 25, Bun, Deno and 2025 browsers, MIT License. The package is published on npm as zgz-transport and on JSR as @hloth/zgz-transport.
Start from src/index.ts.
Rust
lib/rust is the spec implementation library in Rust. Zero dependencies, no_std with alloc, MIT License. The crate is published on crates.io as zgz-transport.
Start from src/lib.rs.
Java, Kotlin
lib/java is the spec implementation library in Java. Zero dependencies, usable from Java >= 17, Kotlin and Android API >= 26, MIT License. The library is published on Maven Central and on self-hosted Maven repository at git.hloth.dev as dev.hloth:zgz-transport.
Start from src/main/java/dev/hloth/zgztransport/Card.java.
Contributing
If you'd like to contribute to the project's development, consider the following resources:
- MifareClassicTool for Android
- GTFS feeds mirrored by the Mobility Database: Avanza urban buses and the tram; the originals are on the Spanish National Access Point, which needs a login
- Zaragoza open data: bus lines (routes last updated in 2013, pole numbers have moved since), bus poles (live, with the lines serving each pole) and tram stops
The spreadsheet with publicly disclosed dumps and highlights:
Acknowledgements
Huge thanks to li0ard for help with decoding RFIDs and dates!