Skip to main content

What It Actually Takes to Integrate the Romanian ID Card Over NFC

· 8 min read
Cătălin Toma
Founder, EidKit

This is the third article in our series on Romania's electronic identity card. The previous articles cover the address problem that's quietly breaking KYC flows and what your electronic ID card can and can't sign.

If you have integrated an electronic passport or another chip-based identity document before, you will enter this project with a reasonable set of assumptions. Most of them are partially wrong for the Romanian CEI.

It is not that the ICAO standards do not apply — they do, as a starting point. The problem is that the CEI is a national identity card with country-specific extensions that appear nowhere in complete public documentation. What follows is a map of the terrain based on complete implementations for both Android and iOS, tested against real CEI cards.


What You Already Know — and What Still Applies

Any ICAO-based electronic identity card uses PACE (Password Authenticated Connection Establishment) to establish a secure channel before any data can be read. The CEI does the same, using the CAN code — 6 digits printed on the front of the card — as the password.

The result of PACE is a Secure Messaging (SM) channel that wraps all subsequent APDU commands. Any command sent raw after the channel is established is rejected by the card — standard behaviour, not specific to the CEI.

Passive authentication works on familiar principles on both platforms: the chip contains a Security Object Document that chains SHA-256 hashes of the data groups up to MAI's CSCA root certificate. The verification looks different depending on the platform, but the principle is the same:

On Android, jMRTD handles SOD parsing. Chain verification is done manually against the CSCA certificate bundled with the SDK:

val sod = SODFile(sodRaw.inputStream())
val dsc = sod.docSigningCertificate
val csca = assets.open("csca_romania.der").use {
CertificateFactory.getInstance("X.509").generateCertificate(it) as X509Certificate
}
dsc.verify(csca.publicKey) // throws if invalid

Passive authentication must always run before trusting any data read from the card. An engineer with ICAO document experience will be comfortable up to this point. This is roughly where the familiar terrain ends.


Where the Assumptions Start to Break

The Chip Has Four Applets, Not One

Standard ICAO travel documents have a relatively predictable applet structure. The CEI does not follow the same pattern. The chip contains four applets with distinct roles:

AppletRole
AID1 / National AppPACE entry point, hosts security parameters
ICAO LDSPhoto, digitised handwritten signature, SOD for passive authentication
EDATAPersonal data: name, CNP, home address
GenPKIKeys and certificates for active authentication and signing

The ESIGN applet exists on the chip and appears in some reference documents. It is not used. Signing goes through GenPKI, via a command different from what you would assume from reading the standards.

Each applet follows its own selection and authentication flow. You do not select an applet and read what you need.

Two Phases, Different Requirements

Reading data from the CEI splits naturally into two phases:

Phase 1 — CAN only: accesses data available without a PIN — the holder's photo, the digitised handwritten signature, and the data needed for passive authentication. This phase uses the standard ICAO applet.

Phase 2 — CAN + 4-digit PIN: accesses the full personal data from the EDATA applet, including the home address — which is no longer printed on the physical card.

The Order of Operations Before PACE Is Not Documented — and Matters

What you must do before PACE depends on what you want to do after PACE, and the rules are asymmetric depending on the usage scenario. Phase 1 requires different preparation from Phase 2 and GenPKI. If the preparation is wrong for the given scenario, failures appear at unexpected points with error codes that do not indicate the real problem.

The situation differs between the two platforms for architectural reasons:

The Android NFC stack starts in MF context — no AID is pre-selected. This is the correct state for Phase 1: PACE works directly in MF context, after which SM SELECT ICAO gives access to DG2/DG7/SOD.

// Phase 1: no pre-selection needed — Android stack starts in MF context
isoDep.timeout = 20_000 // default timeout is insufficient for CEI

val paceResult = ps.doPACE(canKey, paceOid, paceParams, null)
val wrapper = paceResult.wrapper
// all commands from here go through the wrapper

// Phase 2: SELECT AID1 before PACE — chip requires AID1 context for EDATA/GenPKI

The asymmetric pre-PACE preparation behaviour is not documented anywhere — it was discovered by elimination on both platforms.


Data Formats: Where the Romanian Implementation Diverges

DG1 Is Not MRZ

This is where code that works perfectly for passports breaks completely, on both platforms. The identity data returned by the EDATA applet is not in the MRZ format that standard ICAO libraries parse — it is in a Romanian implementation-specific ASN.1 format, with correctly encoded diacritics and differently structured fields:

SEQUENCE (0x30)
[0] (0x80) lastName UTF-8 e.g. "TOMA"
[1] (0x81) firstName UTF-8 e.g. "CĂTĂLIN" (diacritics preserved)
[2] (0x82) sex UTF-8 "M" or "F"
[3] (0x83) dateOfBirth UTF-8 DDMMYYYY
[4] (0x84) cnp UTF-8 13 digits
[5] (0x85) nationality UTF-8 "ROU"

jMRTD's DG1File cannot parse this format. A custom parser is required:

// jMRTD won't help here — Romanian-specific ASN.1 format
val tags = parseContextTags(dg1Bytes) // custom parser
val lastName = tags[0x80]?.toString(Charsets.UTF_8)
val firstName = tags[0x81]?.toString(Charsets.UTF_8) // diacritics are correct
val cnp = tags[0x84]?.toString(Charsets.UTF_8)

The format is not publicly documented — it was determined by direct inspection of bytes returned by the card.

The Two Cryptographic Keys in GenPKI

GenPKI contains two distinct keys, on different elliptic curves, with different internal signing behaviours:

OperationPINInternal behaviour
Active authentication4 digitsKey on secp384r1, reference 0x81
Document signing6 digitsKey on brainpoolP384r1, reference 0x8E

Confusing them produces an incorrect signature with no error message indicating the cause. The behaviour is identical on Android and iOS — this is a chip constraint, not a platform one.


Things That Break Before You Reach Business Logic

The cryptographic provider must be explicitly registered in the correct order before any chip operation. Wrong order produces silent failures:

Security.removeProvider("BC")
Security.insertProviderAt(BouncyCastleProvider(), 1)

Android 13+ changed the API for NFC tag interception. If you support older Android versions, you handle two variants with slightly different behaviour.

PIN counter query does not work in SM mode, on either platform. There is no way to query remaining attempts before sending the actual PIN. You handle SW=63CX in the VERIFY response (X = attempts remaining) and SW=6983 for a blocked card — a detail that directly affects application UX.


We write about the Romanian CEI — its capabilities, its integration challenges, and the regulatory context around it. If a topic here is relevant to something you're building, feel free to reach out.