<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Forem: Catalin Toma</title>
    <description>The latest articles on Forem by Catalin Toma (@ctoma2005).</description>
    <link>https://forem.com/ctoma2005</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3857891%2F45609081-9255-4202-a1e3-f43feb73a051.png</url>
      <title>Forem: Catalin Toma</title>
      <link>https://forem.com/ctoma2005</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/ctoma2005"/>
    <language>en</language>
    <item>
      <title>What It Actually Takes to Integrate the Romanian Electronic ID Card Over NFC</title>
      <dc:creator>Catalin Toma</dc:creator>
      <pubDate>Thu, 02 Apr 2026 15:31:08 +0000</pubDate>
      <link>https://forem.com/ctoma2005/what-it-actually-takes-to-integrate-the-romanian-electronic-id-card-over-nfc-dg9</link>
      <guid>https://forem.com/ctoma2005/what-it-actually-takes-to-integrate-the-romanian-electronic-id-card-over-nfc-dg9</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 a completed spike on a Pixel 8 with a real Romanian CEI card — a spike that took considerably longer than expected, not because the problem is theoretically complex, but because every reasonable assumption had to be verified through direct testing.&lt;/p&gt;




&lt;h2&gt;
  
  
  What You Already Know — and What Still Applies
&lt;/h2&gt;

&lt;p&gt;Any ICAO-based electronic identity card uses &lt;strong&gt;PACE&lt;/strong&gt; (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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The library that handles PACE on Android is &lt;strong&gt;jMRTD&lt;/strong&gt; — the same one you would use for passports. Passive authentication works on familiar principles: the chip contains a Security Object Document that chains SHA-256 hashes of the data groups up to MAI's CSCA root certificate, bundled with the SDK:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;sod&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SODFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sodRaw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;inputStream&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;dsc&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sod&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;docSigningCertificate&lt;/span&gt;
&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;csca&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;assets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"csca_romania.der"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;CertificateFactory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getInstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"X.509"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;generateCertificate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nc"&gt;X509Certificate&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;dsc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;csca&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;publicKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// throws if invalid&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where the Assumptions Start to Break
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Chip Has Four Applets, Not One
&lt;/h3&gt;

&lt;p&gt;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:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Applet&lt;/th&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;AID1 / National App&lt;/td&gt;
&lt;td&gt;PACE entry point, hosts security parameters&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GenPKI&lt;/td&gt;
&lt;td&gt;Keys and certificates for active authentication and signing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ESIGN&lt;/td&gt;
&lt;td&gt;Present on the card — not used in practice&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;EDATA&lt;/td&gt;
&lt;td&gt;Personal data: name, CNP, address, photo&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;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. This was one of the first things that surprised us and cost considerable time to clarify.&lt;/p&gt;

&lt;p&gt;Each applet follows its own selection and authentication flow. You do not select an applet and read what you need.&lt;/p&gt;

&lt;h3&gt;
  
  
  Two Phases, Different Requirements
&lt;/h3&gt;

&lt;p&gt;Reading data from the CEI splits naturally into two phases:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Phase 1 — CAN only:&lt;/strong&gt; 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.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  The Order of Operations Before PACE Is Not Documented — and Matters
&lt;/h3&gt;

&lt;p&gt;This is the problem that cost the most time. What you must do &lt;em&gt;before&lt;/em&gt; PACE depends on what you want to do &lt;em&gt;after&lt;/em&gt; PACE, and the rules are asymmetric depending on the usage scenario.&lt;/p&gt;

&lt;p&gt;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. There is no explanation for this asymmetry in any public documentation — it was discovered by elimination.&lt;/p&gt;

&lt;p&gt;The general shape of a correct flow looks roughly like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// [scenario-specific preparation — different for Phase 1 vs Phase 2/GenPKI]&lt;/span&gt;

&lt;span class="n"&gt;isoDep&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;timeout&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;20000&lt;/span&gt; &lt;span class="c1"&gt;// default timeout is insufficient&lt;/span&gt;

&lt;span class="c1"&gt;// PACE with CAN — establishes SM channel&lt;/span&gt;
&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;paceResult&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;doPACE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;canKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;paceOid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;paceParams&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;wrapper&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;paceResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wrapper&lt;/span&gt;

&lt;span class="c1"&gt;// all commands from here go through the wrapper&lt;/span&gt;
&lt;span class="c1"&gt;// wrapper.wrap(command) → cs.transmit() → wrapper.unwrap(response)&lt;/span&gt;

&lt;span class="c1"&gt;// [SELECT destination applet via wrapper]&lt;/span&gt;
&lt;span class="c1"&gt;// [VERIFY PIN via wrapper — if the scenario requires it]&lt;/span&gt;
&lt;span class="c1"&gt;// [SELECT FILE + READ BINARY in a loop via wrapper]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Loop, because in SM mode the card does not return all data in a single call — it returns chunks, and you are responsible for knowing when you have finished reading.&lt;/p&gt;




&lt;h2&gt;
  
  
  Data Formats: Where the Romanian Implementation Diverges
&lt;/h2&gt;

&lt;h3&gt;
  
  
  DG1 Is Not MRZ
&lt;/h3&gt;

&lt;p&gt;This is where code that works perfectly for passports breaks completely. 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.&lt;/p&gt;

&lt;p&gt;You need to write a custom parser. The format is not publicly documented — it was determined by direct inspection of bytes returned by the card.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Two Cryptographic Keys in GenPKI
&lt;/h3&gt;

&lt;p&gt;GenPKI contains two distinct keys, on different elliptic curves, with different internal signing behaviours:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Operation&lt;/th&gt;
&lt;th&gt;PIN&lt;/th&gt;
&lt;th&gt;Internal behaviour&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Active authentication&lt;/td&gt;
&lt;td&gt;4 digits&lt;/td&gt;
&lt;td&gt;Key on secp384r1, reference 0x81&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Document signing&lt;/td&gt;
&lt;td&gt;6 digits&lt;/td&gt;
&lt;td&gt;Key on brainpoolP384r1, reference 0x8E&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The two keys have different behaviours at the protocol level. Confusing them produces an incorrect signature with no error message indicating the cause.&lt;/p&gt;




&lt;h2&gt;
  
  
  Things That Break Before You Reach Business Logic
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The cryptographic provider&lt;/strong&gt; must be explicitly registered before any chip operation. Registration order matters and produces silent failures if wrong:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nc"&gt;Security&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;removeProvider&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"BC"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nc"&gt;Security&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insertProviderAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;BouncyCastleProvider&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Android 13+&lt;/strong&gt; changed the API for NFC tag interception. If you support older Android versions, you handle two variants with slightly different behaviour.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;PIN counter query does not work&lt;/strong&gt; in SM mode. There is no way to query remaining attempts before sending the actual PIN. You handle &lt;code&gt;SW=63CX&lt;/code&gt; in the VERIFY response (X = attempts remaining) and &lt;code&gt;SW=6983&lt;/code&gt; for a blocked card. This is a detail that directly affects application UX and appears mentioned nowhere.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The PACE library parameters&lt;/strong&gt; matter exactly. The correct initialisation values were confirmed through testing; others produce PACE failures with no error message indicating the cause.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Required Direct Card Verification
&lt;/h2&gt;

&lt;p&gt;Things that do not appear in public documentation and had to be discovered through testing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The asymmetric pre-PACE preparation behaviour depending on scenario&lt;/li&gt;
&lt;li&gt;Which applet is actually used for signing (not the one implied by its name)&lt;/li&gt;
&lt;li&gt;The Romanian implementation-specific data format for personal data&lt;/li&gt;
&lt;li&gt;The difference in internal behaviour between the two GenPKI keys&lt;/li&gt;
&lt;li&gt;The exact PACE library parameters that work with this card&lt;/li&gt;
&lt;li&gt;PIN counter query limitations in SM mode&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The official MAI desktop middleware does not work with all issued cards — which means that even access to a standard card reader does not guarantee a working reference point from which to start.&lt;/p&gt;

&lt;p&gt;Each of the points above represents real time lost if discovered independently. And there is no way to know in advance how many such details exist.&lt;/p&gt;




&lt;p&gt;I've built &lt;a href="https://eidkit.ro" rel="noopener noreferrer"&gt;EidKit&lt;/a&gt; — an Android (Kotlin) and iOS (Swift) SDK that handles all of the above. Demo mode works without a license key. Available on Maven Central and SPM.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One ask:&lt;/strong&gt; I've only tested on one Romanian CEI card and a handful of devices. If you have access to a Romanian electronic ID card and want to help test compatibility on devices I don't have, drop a comment or email &lt;a href="mailto:hello@eidkit.ro"&gt;hello@eidkit.ro&lt;/a&gt; — I'll add you to the Play Store testing track.&lt;/p&gt;

&lt;p&gt;Happy to answer questions about any of the protocol quirks in the comments.&lt;/p&gt;

</description>
      <category>android</category>
      <category>kotlin</category>
      <category>security</category>
      <category>nfc</category>
    </item>
  </channel>
</rss>
