know.2nth.ai Technology tech android-hce tokenization
tech/android-hce · Tokenization · Skill Leaf

The real PAN
never touches the phone.

Payment tokenization replaces the cardholder's real PAN with a Device PAN (DPAN) that is worthless outside its bound device. Each transaction uses a single-use Limited Use Key (LUK) to generate a cryptogram that can't authorise a second payment. Even if someone intercepts the full NFC exchange, they get a token and a spent key — neither is reusable.

Payment DPAN LUK VTS MDES

DPAN, LUKs, and why a stolen token is worthless.

DPAN vs PAN. The cardholder's real PAN (Primary Account Number) lives at the issuer and in the Token Service Provider's vault. It never reaches the device. Instead, the TSP provisions a Device PAN — a substitute number in the same BIN range that maps back to the real PAN at the network level. The acquirer, the terminal, and your app only ever see the DPAN.

LUK (Limited Use Key). Each LUK generates exactly one transaction cryptogram. The cryptogram is computed from the LUK, the transaction amount, a terminal-supplied Unpredictable Number, and a transaction counter. Once used, the LUK is discarded. Even if an attacker captures the cryptogram, they cannot compute a new one without the next LUK — which they don't have.

Why this works. The security model doesn't depend on the device being tamper-proof. It depends on the key material being single-use and the DPAN being device-bound. A stolen DPAN + spent LUK = nothing usable. This is why HCE without a hardware Secure Element is still considered safe enough for contactless payments.

Concept What it is Where it lives
PAN Real card number. 16–19 digits. What the issuer knows. Issuer + TSP vault. Never on device.
DPAN Device PAN / token PAN. Substitute number in the same BIN range. On device (encrypted). Maps to real PAN at the network.
LUK Limited Use Key. Single-use cryptographic key for generating one transaction cryptogram. On device as encrypted file wrapped by Keystore key.
Cryptogram ARQC (Authorization Request Cryptogram). Proves the transaction came from a legitimate device with a valid LUK. Generated during tap, sent to acquirer, verified by issuer.
TSP Token Service Provider. Manages the DPAN-to-PAN mapping and LUK provisioning. Visa VTS or Mastercard MDES — cloud service.

Provisioning, storage, usage, replenishment.

The token lifecycle has five phases. Each phase has its own failure modes, and a bug in any phase can leave the user with a dead wallet.

1. PROVISIONING
User adds card → biometric enrolment
Server verifies identity with issuer
App generates attestation-bound EC keypair in Keystore
Submit attestation chain + device info to TSP
TSP returns DPAN + initial batch of LUKs

2. STORAGE
DPAN + card metadata encrypted and cached locally
LUKs stored as encrypted files, wrapped by Keystore AES key
NOT stored as individual Keystore entries (entry limit ~256)

3. USAGE
User taps → BiometricPrompt unlocks Keystore wrap-key
Decrypt next available LUK
Generate cryptogram: LUK + amount + reader UN + counter
Return cryptogram in GENERATE AC response
Discard used LUK immediately

4. REPLENISHMENT
LUK count drops below threshold (e.g., 5 remaining)
Check Play Integrity → pass verdict to server
Server requests new LUK batch from TSP
Encrypt and store new LUKs locally

5. DEACTIVATION
User removes card / issuer suspends / fraud trigger
Delete DPAN + all remaining LUKs from device
Notify TSP to deactivate the token

LUK storage pattern. Store LUKs as AES-GCM encrypted files in the app's internal storage. The encryption key lives in Keystore. Each file contains one LUK plus metadata (index, creation timestamp, scheme). Use a simple counter-based filename to track inventory.

class LukStore(
    private val context: Context,
    private val wrapKeyAlias: String
) {
    private val lukDir = File(context.filesDir, "luks").also { it.mkdirs() }

    fun storeLuk(index: Int, lukBytes: ByteArray) {
        val ks = KeyStore.getInstance("AndroidKeyStore")
        ks.load(null)
        val key = ks.getKey(wrapKeyAlias, null) as SecretKey

        val cipher = Cipher.getInstance("AES/GCM/NoPadding")
        cipher.init(Cipher.ENCRYPT_MODE, key)

        val encrypted = cipher.doFinal(lukBytes)
        val iv = cipher.iv

        // Store IV + ciphertext together
        val file = File(lukDir, "luk_$index.enc")
        file.writeBytes(iv + encrypted)
    }

    fun popNextLuk(): ByteArray? {
        val files = lukDir.listFiles()
            ?.filter { it.name.startsWith("luk_") }
            ?.sortedBy { it.name }
            ?: return null

        val file = files.firstOrNull() ?: return null
        val raw = file.readBytes()

        // First 12 bytes = GCM IV, rest = ciphertext
        val iv = raw.copyOfRange(0, 12)
        val ciphertext = raw.copyOfRange(12, raw.size)

        val ks = KeyStore.getInstance("AndroidKeyStore")
        ks.load(null)
        val key = ks.getKey(wrapKeyAlias, null) as SecretKey

        val cipher = Cipher.getInstance("AES/GCM/NoPadding")
        cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(128, iv))
        val luk = cipher.doFinal(ciphertext)

        // Delete after retrieval — LUK is single-use
        file.delete()
        return luk
    }

    fun remaining(): Int =
        lukDir.listFiles()?.count { it.name.startsWith("luk_") } ?: 0

    fun needsReplenishment(threshold: Int = 5): Boolean =
        remaining() < threshold
}

Scheme integration. The two Token Service Providers you'll work with in practice.

TSP Scheme Integration surface
VTS Visa Token Service REST API for provisioning, lifecycle management, and LUK replenishment. Requires Visa Ready certification. Production access gated behind scheme approval.
MDES Mastercard Digital Enablement Service Similar REST API surface. Requires Mastercard lab certification. Separate sandbox and production environments with distinct credentials.

Pre-provision for load-shedding, not just convenience.

Keep 5–10 LUKs on device at all times. In South Africa, load-shedding can take your replenishment server offline for hours. If the user has zero LUKs when the power goes out, their wallet is dead until the server comes back. Pre-provisioning is your offline insurance policy. Trigger replenishment when you drop below 5, not when you hit zero.

Token lifecycle failure modes.

Each phase of the token lifecycle has its own way of failing silently. These are the ones that reach production.

LUKs stored as Keystore entries hit the entry limit

The Android Keystore has a practical limit of ~256 entries on many devices. If you store each LUK as a separate Keystore key, you'll hit this ceiling after a few replenishment cycles. Use a single wrap-key in Keystore and store LUKs as encrypted files on the filesystem. The Keystore is a vault, not a filing cabinet.

Replenishment fails silently on rooted devices

Play Integrity blocks replenishment on rooted devices or modified frameworks. The existing LUKs still work, but once they're consumed the user can't tap anymore — and your app shows "payment declined" with no explanation of the actual cause. Detect the integrity failure and surface a clear message.

Concurrent replenishment requests corrupt LUK inventory

If two replenishment triggers fire simultaneously (e.g., after extended offline), you can end up with duplicate LUK indices or missing files. Use a single-threaded executor or a mutex for all LUK storage operations. File system operations are not atomic on Android.

Biometric re-enrolment destroys the wrap-key

If the user adds a new fingerprint, setInvalidatedByBiometricEnrollment(true) permanently invalidates the wrap-key. All locally stored LUKs become undecryptable. Your app must detect KeyPermanentlyInvalidatedException, wipe local state, and re-provision from the TSP. The user experience is "your wallet reset itself" — design the recovery flow carefully.

TSP provisioning timeouts look like card-not-supported

If the provisioning call to VTS/MDES times out (network issues, load-shedding), your app might show a generic error. The user thinks their card isn't supported. Implement retry with exponential backoff and distinguish between "card not eligible" (permanent) and "network error" (transient) in your error handling.

Token architecture decisions.

Three decisions you make once at the architecture stage. Changing any of them later requires re-provisioning all active devices.

Decision Recommendation Why
LUK batch size 10 LUKs per replenishment Covers ~2 days of typical usage. Balances server load against offline resilience. Replenish at threshold 5.
LUK storage format Encrypted files, not Keystore entries Keystore entry limits (~256) are too low. File-based storage scales to any batch size. One Keystore wrap-key protects all files.
Replenishment gate Play Integrity + fallback to Key Attestation Play Integrity covers GMS devices. Key Attestation covers Huawei. Both prove the device hasn't been tampered with before issuing new LUKs.

The DPAN is not secret. The LUK is.

The DPAN can be displayed (last-4), logged (masked), and transmitted. It's a token — worthless without a valid LUK and the device it's bound to. Your security effort goes into protecting the LUK inventory and the Keystore wrap-key, not the DPAN itself. If you find yourself treating the DPAN like a PAN, you've misunderstood the security model.

Where tokenization fits in the tree.

Go deeper.