Skip to main content

Handheld personalization (Android + QR)

How to personalize a MIFARE Classic 1K card with a phone instead of the desk station in scripts/perso-classic.js.

The phone runs the same four API calls the desk station runs. What changes is the credential: the station holds a platform key, the phone holds a perso job token scanned out of a QR code — one card, a few minutes, spent at confirm.


1. Check the handset first​

android.nfc.tech.MifareClassic only works on phones whose NFC controller implements Crypto1, which in practice means an NXP part. Broadcom and Qualcomm controllers return null from MifareClassic.get(tag) and there is no way around it in software.

val supported = packageManager.hasSystemFeature("com.nxp.mifare")

Most Samsung handsets pass. Pixels and many Xiaomi models do not. Test the exact model you intend to deploy before building anything on top of this — if it fails, the fallback is an ACR122U/ACR1252U over USB OTG, which puts Crypto1 in the reader and makes the handset's chipset irrelevant.

2. Issue a job (back office)​

POST /cards/{card_id}/chip/perso/job ← platform key or system admin
{
"chip_generation": "MIFARE_CLASSIC_EV1_1K",
"transport_key": "FFFFFFFFFFFF",
"ttl_seconds": 600
}

Returns job_token (once — only its SHA-256 is stored), qr_payload, and qr_svg you can drop straight into an <img> or print.

Issuing a job closes any older open job for the same card. A reprint invalidates the printout it replaces, so two operators cannot work one card from two QRs.

3. The QR payload​

{
"v": 1,
"api": "https://api-staging-core-card.uts.network",
"card_id": "6f1c…",
"gen": "MIFARE_CLASSIC_EV1_1K",
"tk": "FFFFFFFFFFFF",
"jt": "pj_…",
"exp": "2026-07-27T10:05:00.000Z"
}

No key material. Crypto1 keys are diversified from chip_uid, which nobody knows until the blank is presented, so they could not be pre-baked into a QR even if that were safe — the station fetches them over TLS at tap time, and every fetch lands in chip_auth_logs as mode=KEY_ISSUE.

4. What the app does​

Every call carries X-Perso-Job: <jt> and X-Perso-Device: <station label>. The device label is pinned to the job on first use and recorded as chip_auth_logs.reader_id.

read UID from the tag
POST /cards/{card_id}/chip/perso/prepare { chip_uid, chip_generation,
interface_caps: "CONTACTLESS",
applet_aid: "" }
POST /chip/keys { chip_uid, reader_id }
→ card_token, issuer_block, expected_mac, counter_block,
sector_keys[16] (each with a ready-made 16-byte trailer), layout
write layout.token_block / issuer_block / mac_block / counter_block
write all 16 sector trailers ← trailers LAST, always
read the four data blocks back using the NEW Key A
POST /cards/{card_id}/chip/perso/confirm { credential_id, chip_uid,
result: "WRITTEN", key_loaded: true }

Two ordering rules carry over from the desk station and are not negotiable:

  • Data blocks before trailers. Writing a trailer swaps the key out from under you mid-sector and strands the remaining writes in that sector.
  • Verify with the new keys, not the transport key. Reading back through FFFFFFFFFFFF proves nothing about whether the trailer took.

Never recompute access bits on the device. sector_keys[].trailer arrives fully assembled (KeyA ‖ access bits ‖ GPB ‖ KeyB) precisely because one wrong bit bricks a sector permanently.

5. Android specifics​

MifareClassic authenticates per sector, not per block, so convert with blockToSector(blockIndex) and authenticate once per sector before writing its blocks.

val mfc = MifareClassic.get(tag) ?: error("this handset cannot do Crypto1")
mfc.use {
it.connect()
it.timeout = 5_000

fun write(block: Int, keyA: ByteArray, data: ByteArray) {
require(data.size == 16)
check(it.authenticateSectorWithKeyA(it.blockToSector(block), keyA)) {
"auth failed on sector ${it.blockToSector(block)}"
}
it.writeBlock(block, data)
}

val transport = "FFFFFFFFFFFF".hexToBytes()
write(layout.tokenBlock, transport, bundle.cardToken.hexToBytes())
write(layout.issuerBlock, transport, bundle.issuerBlock.hexToBytes())
write(layout.macBlock, transport, bundle.expectedMac.hexToBytes())
write(layout.counterBlock, transport, bundle.counterBlock.hexToBytes())

for (k in bundle.sectorKeys) { // trailers last
write(k.sector * 4 + 3, transport, k.trailer.hexToBytes())
}
}

Drive the tag with reader mode rather than foreground dispatch — it suppresses the system's NDEF handling and the tap sound, and keeps the tag alive across a long write:

nfcAdapter.enableReaderMode(
activity, callback,
NfcAdapter.FLAG_READER_NFC_A or NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK,
Bundle().apply { putInt(NfcAdapter.EXTRA_READER_PRESENCE_CHECK_DELAY, 250) },
)

Keep the phone still on the card for the whole write. A tag lost between the data blocks and the trailers leaves the card in a mixed key state — see below.

6. When it goes wrong​

A card that failed midway holds diversified keys in some sectors and the transport key in others. Retrying blind authenticates with the wrong key and looks like a dead card. Confirm the failure instead:

POST /cards/{card_id}/chip/perso/confirm { credential_id, chip_uid,
result: "LOCKED", key_loaded: false }

then set the plastic aside physically. Do not re-tap it.

prepare is not idempotent — chip_uid is unique. A station that crashed after writing resumes by reading GET /cards/{card_id}/chip and continuing from the existing PENDING credential, exactly as prepareOrResume does in the desk script.

Error codes the app should handle by name:

codemeaning
perso_job_expiredQR timed out — ask the back office for a new one
perso_job_consumedjob already closed (confirmed, or superseded by a reprint)
perso_job_scopethis QR belongs to a different card
chip_already_personalizedthe chip is bound elsewhere; resume only if it is this card's
credential_existsthe card already has a live PHYSICAL credential

7. Testing without the app​

The desk station accepts the QR contents directly, so the whole job-token path can be exercised from a laptop with a PC/SC reader before any APK exists:

node scripts/perso-classic.js --qr '<decoded QR JSON>' --dry-run

--dry-run still prepares the credential server-side (that is the only way to get real keys to print) and leaves it PENDING, so a dry run followed by a live run is the intended sequence. Run both against scrap stock first.