BAC (Basic Access Control) and PACE (Password Authenticated Connection Establishment) are the two protocols an electronic passport or ID card uses to decide whether a reader may see its contents. Both are specified in ICAO Doc 9303 Part 11. BAC turns data printed on the document into static keys, which limits it to the entropy of that data. PACE starts from the same kind of short password but runs a Diffie-Hellman key agreement on top of it, so the session keys don't inherit the password's weakness. New documents use PACE; readers keep BAC for the many older chips still in circulation.
I had to get this distinction straight while working on NFC eID reading on Android. The full read-and-verify flow is in reading an eID chip on Android. This post covers one step of that flow: access control.
Why an ID chip needs access control
A contactless chip talks over ISO/IEC 14443. Without access control, anyone close enough could read your name, date of birth and photo without you noticing, or record the exchange between your document and a legitimate terminal. So before the chip returns a single data group, the reader has to show it is entitled to read the document.
In the ICAO design, "entitled" means the reader knows something you can only get by holding the document: the machine-readable zone (MRZ), the two lines at the bottom of a passport data page or the three lines on the back of an ID card. Newer documents can also use the CAN (Card Access Number), usually six digits printed on the card. The assumption is that if you can read the MRZ or CAN, you have the document in your hand.
This is about privacy, not about trusting the data. Whether the contents are genuine is a separate question: Passive Authentication checks the issuer's signature over the data, and Active Authentication or Chip Authentication checks that the chip is not a clone. Keeping these apart makes the rest of the flow much easier to follow.
BAC: keys derived from the MRZ
BAC came with the first generation of ePassports. The reader builds a string from three MRZ fields, each followed by its check digit: document number, date of birth and date of expiry. It hashes that string with SHA-1 and takes the first 16 bytes as the key seed K_seed. Two keys are derived from the seed, K_enc for encryption and K_mac for the MAC, each a two-key 3DES key:
import java.security.MessageDigest
private val CHECK_WEIGHTS = intArrayOf(7, 3, 1)
/** ICAO 9303 check digit: weights 7-3-1, '<' counts as 0, letters A=10..Z=35. */
fun checkDigit(field: String): Char {
val sum = field.foldIndexed(0) { index, acc, c ->
val value = when (c) {
in '0'..'9' -> c - '0'
in 'A'..'Z' -> c - 'A' + 10
'<' -> 0
else -> throw IllegalArgumentException("Invalid MRZ character: $c")
}
acc + value * CHECK_WEIGHTS[index % 3]
}
return '0' + sum % 10
}
/** Dates are YYMMDD. Document numbers shorter than 9 characters are padded with '<'. */
fun bacKeySeed(documentNumber: String, dateOfBirth: String, dateOfExpiry: String): ByteArray {
val docNumber = documentNumber.padEnd(9, '<')
val mrzInfo = docNumber + checkDigit(docNumber) +
dateOfBirth + checkDigit(dateOfBirth) +
dateOfExpiry + checkDigit(dateOfExpiry)
val sha1 = MessageDigest.getInstance("SHA-1").digest(mrzInfo.toByteArray(Charsets.US_ASCII))
return sha1.copyOf(16)
}
/** counter = 1 gives K_enc, counter = 2 gives K_mac (16 bytes, DES parity not yet adjusted). */
fun deriveKey(keySeed: ByteArray, counter: Int): ByteArray {
val input = keySeed + byteArrayOf(0, 0, 0, counter.toByte())
return MessageDigest.getInstance("SHA-1").digest(input).copyOf(16)
}
With these keys the reader and chip authenticate each other. The reader sends GET CHALLENGE and receives an 8-byte random from the chip. It then generates its own random and its own key material, encrypts all of it with K_enc, MACs it with K_mac, and sends it with EXTERNAL AUTHENTICATE. The chip answers the same way with its key material. Both sides XOR the two key-material values into a new seed and derive the session keys from it. From then on every APDU goes through 3DES Secure Messaging with a send sequence counter.
The weakness comes from the protocol design itself. K_enc and K_mac never change for a given document, and they come entirely from printed data with little entropy. A date of birth has some tens of thousands of plausible values, a date of expiry within a ten-year validity window a few thousand, and in many countries document numbers follow a predictable pattern. The output is a 112-bit 3DES key, but the effective search space is set by those fields, and ICAO itself notes that the entropy of BAC keys is low.
In practice: an attacker who records a single BAC session can take the recorded EXTERNAL AUTHENTICATE message, compute keys for candidate MRZ values offline and check each one against the MAC. Once a candidate matches, the key material in the messages can be decrypted, the session keys rebuilt and the whole session read. None of this needs the chip after the recording.
PACE: a password-authenticated key exchange
PACE was designed by BSI, first deployed on the German ID card in 2010, and later adopted by ICAO as part of Supplemental Access Control (SAC). It is a password-authenticated key exchange (PAKE): it takes a possibly weak shared password and produces strong session keys that an eavesdropper cannot recover offline.
The password is still something the holder has. It is either derived from the MRZ (the same SHA-1 over the three fields as in BAC) or it is the CAN. The password is still low entropy; what changes is what the protocol does with it.
Everything starts with EF.CardAccess (file ID 011C). The reader can read this file without authentication. It contains SecurityInfos, and a PACEInfo entry there names the protocol with an OID and the domain parameters with an ID. For example, id-PACE-ECDH-GM-AES-CBC-CMAC-128 (0.4.0.127.0.7.2.2.4.2.2) means ECDH, Generic Mapping and AES-128 Secure Messaging; parameter ID 13 means the brainpoolP256r1 curve.
The reader selects the protocol and password type (MRZ or CAN) with MSE:Set AT, then the handshake runs over a series of GENERAL AUTHENTICATE commands:
- Encrypted nonce. A key
K_πis derived from the password. The chip picks a random nonces, encrypts it withK_πand sends it. The reader decrypts it with the same key. - Mapping. Both sides use
sto compute a new generator for this session only. In Generic Mapping they run an anonymous Diffie-Hellman exchange, and the generator becomesG' = s·G + H, whereHis the shared secret of that exchange. In Integrated Mapping the reader sends a second nonce, and a pseudorandom function over the two nonces is mapped directly onto a point, without an extra DH round. Doc 9303 also defines Chip Authentication Mapping, which is based on Generic Mapping and authenticates the chip in the same run. - Key agreement. Both sides generate fresh ephemeral key pairs on
G'and run DH or ECDH.KS_encandKS_macare derived from the shared secret. - Token exchange. Each side sends a MAC over the other side's ephemeral public key computed with
KS_mac. If the tokens verify, both ends know they reached the same keys, and so that the other side knew the password.
After that, Secure Messaging uses the cipher in the OID: AES with CMAC for the AES variants, 3DES with Retail MAC for the 3DES variants.
What matters is this: a passive observer with the full transcript cannot test password guesses offline, which is exactly the attack that breaks BAC. The encrypted nonce decrypts to a random-looking value under any key, so a wrong guess can't be told apart from the right one, and checking a guess would require the ephemeral DH secrets. PACE does not make a weak password impossible to guess online: an attacker who can talk to the chip again and again can try one password per protocol run, limited by how fast the chip responds and whatever limits it applies. What PACE removes is offline key recovery from a recording.
Negotiation and fallback
The reader does not choose freely; it follows what the chip offers. With JMRTD on Android the flow looks roughly like this:
import android.nfc.tech.IsoDep
import net.sf.scuba.smartcards.CardService
import org.jmrtd.BACKey
import org.jmrtd.PassportService
import org.jmrtd.lds.CardAccessFile
import org.jmrtd.lds.PACEInfo
fun openSecureChannel(isoDep: IsoDep, bacKey: BACKey): PassportService {
val service = PassportService(
CardService.getInstance(isoDep),
PassportService.NORMAL_MAX_TRANCEIVE_LENGTH,
PassportService.DEFAULT_MAX_BLOCKSIZE,
false,
false,
)
service.open()
var paceSucceeded = false
try {
val cardAccess = CardAccessFile(service.getInputStream(PassportService.EF_CARD_ACCESS))
val paceInfo = cardAccess.securityInfos.filterIsInstance<PACEInfo>().firstOrNull()
if (paceInfo != null) {
service.doPACE(
bacKey,
paceInfo.objectIdentifier,
PACEInfo.toParameterSpec(paceInfo.parameterId),
null,
)
paceSucceeded = true
}
} catch (e: Exception) {
// No EF.CardAccess, or PACE failed: fall back to BAC below.
}
// EF.CardAccess sits in the master file; the eMRTD application is selected afterwards.
service.sendSelectApplet(paceSucceeded)
if (!paceSucceeded) {
service.doBAC(bacKey)
}
return service
}
BACKey takes the document number, date of birth and date of expiry; for CAN-based PACE you pass PACEKeySpec.createCANKey(can) to doPACE instead. BAC has no CAN variant, so in that case there is nothing to fall back to. Method signatures move a little between JMRTD releases, so check them against the version you use. In real code, also don't collapse every failure into one catch: "EF.CardAccess not found" and "PACE ran but the password was wrong" need different handling. If the MRZ was read incorrectly, falling back to BAC with the same data will fail too.
As for the timeline: during the transition, ICAO expected chips that offer PACE to keep offering BAC as well, so older inspection systems could still read them. That is why many passports in circulation support both. Current editions of Doc 9303 present PACE as the mechanism to implement and keep BAC for compatibility. Documents stay valid for up to ten years, though, so BAC-only chips won't disappear on a specific date. On the other hand, there are PACE-only documents such as the German ID card. A reader written today should try PACE first and keep BAC as a fallback.
What it means for an Android NFC reader
The Android side is the same for both protocols: put the adapter into reader mode, get an IsoDep connection and exchange APDUs. Reader mode stops the system and other NFC apps from interfering while the card is on the phone:
import android.app.Activity
import android.nfc.NfcAdapter
import android.nfc.tech.IsoDep
class PassportReader(private val activity: Activity) {
private val adapter: NfcAdapter? = NfcAdapter.getDefaultAdapter(activity)
fun startReading(onConnected: (IsoDep) -> Unit) {
adapter?.enableReaderMode(
activity,
{ tag ->
IsoDep.get(tag)?.use { isoDep ->
isoDep.timeout = 10_000
isoDep.connect()
onConnected(isoDep)
}
},
NfcAdapter.FLAG_READER_NFC_A or
NfcAdapter.FLAG_READER_NFC_B or
NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK,
null,
)
}
fun stopReading() {
adapter?.disableReaderMode(activity)
}
}
Enable both FLAG_READER_NFC_A and FLAG_READER_NFC_B: ePassports and ID cards come as both ISO 14443 Type A and Type B, and with only one flag some documents are never detected. The reader-mode callback does not run on the main thread, so blocking IsoDep calls are fine there, but UI updates need to be posted back.
Inside onConnected the order is fixed: read EF.CardAccess, choose the protocol, set up Secure Messaging, then select and read the data groups. Without the handshake, the chip answers reads with a "security status not satisfied" error (6982).
In diagnostics, don't reduce access control to a single pass/fail. "Couldn't reach the chip", "handshake failed" and "data read but verification failed" have different causes and different fixes, and your logs and error messages need to tell them apart. For other device-side security checks on Android, such as root and tamper detection, see SecureCheck.
Wrapping up
BAC keys are static, come from printed data and can be brute-forced offline from a recorded session. PACE uses the same password but makes the session keys independent of its entropy. When writing a reader: check EF.CardAccess, try PACE first, keep BAC as a fallback, and keep access control (who may read) separate from authenticity checks (whether to trust what was read).
Frequently asked questions
- What is the difference between BAC and PACE for eID chips?
- BAC derives static 3DES keys from three MRZ fields, so its strength is limited by the low entropy of that data. PACE uses the same kind of short password, but runs an ephemeral Diffie-Hellman key agreement on top of it, so the session keys do not depend on the password's entropy.
- Why is BAC considered cryptographically weak?
- Its access keys are static and built entirely from document number, date of birth and date of expiry. An attacker who records one BAC session can try candidate MRZ values offline, check them against the recorded authentication message, and then decrypt the whole session.
- How does an NFC reader choose between PACE and BAC?
- It reads EF.CardAccess, which is available without authentication. If the file contains a PACEInfo entry, the reader runs PACE with the protocol and domain parameters listed there; if the file is missing or PACE is not offered, it falls back to BAC.
- Does eID access control prove the chip's data is authentic?
- No. Access control only decides who may read the chip. Passive Authentication proves the data was signed by the issuer, and Active or Chip Authentication proves the chip itself is not a copy.