iOS SDK for reading and signing with Romanian electronic identity cards
(CEI / eID) over NFC. Wraps the eMRTD / Romanian national applet protocols
behind a small public API focused on readIdentity and sign.
0.1.0 — pre-stable. The underlying protocol code is production-tested
(used by the am-semnat app); the public AmSemnat
surface is new and may change ahead of 1.0.0.
- iOS 15.0+
- Swift 5.9+
- Device with an NFC chip that supports
NFCTagReaderSession(practically every iPhone from iPhone 7 onward) - The
NFCReaderUsageDescriptionkey in your app'sInfo.plist - The Near Field Communication Tag Reading capability enabled for your target (Xcode: Signing & Capabilities → + Capability → NFC tag reading)
- A provisioning profile with the
com.apple.developer.nfc.readersession.formats = TAGentitlement (Xcode handles this when you enable the capability)
pod 'AmSemnatSDK', '~> 0.1'Then:
pod installAmSemnatSDK pulls in OpenSSL-Universal
~> 3.3 transitively — no extra work on your side.
dependencies: [
.package(url: "https://github.com/am-semnat/am-semnat-ios-sdk.git", from: "0.1.0"),
]Caveat: the SPM manifest does not declare OpenSSL as a dependency
because SPM does not yet have a widely-agreed OpenSSL distribution. If you
choose SwiftPM over CocoaPods you must vendor your own OpenSSL
XCFramework (ensure OpenSSL is importable as a Swift module) at link
time. The supported, dependency-complete path for 0.1.0 is CocoaPods.
A prebuilt-XCFramework binaryTarget is planned for a later release.
import AmSemnatSDK
do {
let identity = try await AmSemnat.readIdentity(
can: "123456", // 6-digit CAN from the card
pin1: "1234", // optional PIN1 for eDATA (empty = skip)
dataGroups: .default,
onProgress: { step in print("step: \(step)") }
)
// identity.cnp, identity.firstName, identity.lastName, ...
// identity.chipAuthenticated (local anti-clone check; UX signal only)
// identity.rawSod / .rawDg1 / .rawDg2 (for server-side passive auth)
} catch let error as AmSemnatError {
switch error {
case .pinVerifyFailed(let retriesRemaining):
print("PIN1 wrong, \(retriesRemaining) retries left")
case .paceAuthFailed:
print("Wrong CAN — prompt user to re-enter")
case .tagLost:
print("Card moved — ask user to hold still")
default:
print("error: \(error.localizedDescription)")
}
}let sig = try await AmSemnat.sign(
can: "123456",
pin2: "123456", // 6-digit PIN2
pdfHash: byteRangeSha384, // 48 bytes; SHA-384 of the PDF byte range
signingTime: Date(),
onProgress: { step in print("step: \(step)") }
)
// sig.signature — 96 bytes, raw ECDSA P-384 r‖s
// sig.certificate — DER-encoded signing cert
// sig.signedAttributes — DER-encoded SET of CMS signed attributesAssemble a CMS SignedData on-device or upload the three blobs for
server-side assembly.
If your app already runs its own NFCTagReaderSession delegate and owns
the session lifecycle, skip the SDK's session and pass the tag directly:
let identity = try await AmSemnat.readIdentity(
tag: myConnectedTag,
can: "123456",
pin1: "1234"
)The SDK never invalidates the caller-supplied session — it just runs the protocol against the tag.
iOS owns the NFC reader-session sheet; the SDK writes phase-specific
strings into it via NfcMessages. Defaults are neutral English so a
basic integration works out of the box — production apps should pass a
localized instance instead.
let messages = NfcMessages(
readyToScan: NSLocalizedString("nfc.holdCard", comment: ""),
authenticating: NSLocalizedString("nfc.authenticating", comment: ""),
scanning: NSLocalizedString("nfc.reading", comment: ""),
progressFormat: NSLocalizedString("nfc.progressFormat", comment: ""),
success: NSLocalizedString("nfc.done", comment: ""),
tagLost: NSLocalizedString("nfc.cardMoved", comment: "")
)
let identity = try await AmSemnat.readIdentity(
can: "123456", pin1: "1234", messages: messages, onProgress: nil
)progressFormat composes the live per-DG percentage into the sheet.
Tokens: {phase} (substituted with scanning) and {percent} (the
reader's 0-100 progress). The default is "{phase} — {percent}%"
rendering as "Reading your card… — 40%". Pass progressFormat = ""
to suppress the percentage. Token substitution (not String(format:))
so malformed templates never crash the SDK.
The SDK ships no Romanian copy — all on-screen strings belong in the consumer app's localization layer.
iOS normally invalidates the NFCTagReaderSession when the app
backgrounds, but a consumer that navigates within the foreground app
(e.g. SwiftUI NavigationStack push/pop) keeps the sheet up. Call
AmSemnat.cancelCurrentOp() to invalidate the session explicitly:
.onDisappear {
AmSemnat.cancelCurrentOp()
}The awaiting readIdentity / sign throws
AmSemnatError.sessionCancelled. Safe no-op if no op is in flight,
and a no-op for callers using the tag: overloads (caller owns the
session).
Two questions with two different answers:
-
Is the chip genuine (not a clone)? →
RomanianIdentity.chipAuthenticatedis set from the on-device EAC-Chip-Authentication challenge/response. It's a UX signal, not a legal trust decision. -
Does the card data match what MAI signed? → Passive Authentication. The SDK does not set a
passiveAuthenticatedflag — any flag the client writes can be forged by a compromised client. For load-bearing checks (KYC, qualified signing), verify server-side against your own CSCA trust store.The SDK exposes raw bytes on
RomanianIdentity(rawSod,rawDg1,rawDg2,rawDg14) that you can upload and verify wherever the decision actually matters. A companion@amsemnat/verifier-nodereference implementation is on the roadmap.For offline / research / air-gapped use, the SDK ships
AmSemnat.verifyPassiveOffline(rawSod:dataGroups:trustAnchors:). The caller supplies the CSCA trust anchors; freshness and revocation become the caller's problem.// `identity` from AmSemnat.readIdentity(…); `cscaAnchors` is your // `CSCA Romania` trust store as an array of DER-encoded X.509 certificates. var groups: [DataGroup: Data] = [:] if let dg1 = identity.rawDg1 { groups[.dg1] = dg1 } if let dg2 = identity.rawDg2 { groups[.dg2] = dg2 } if let dg14 = identity.rawDg14 { groups[.dg14] = dg14 } let result = AmSemnat.verifyPassiveOffline( rawSod: identity.rawSod ?? Data(), dataGroups: groups, trustAnchors: cscaAnchors ) if result.valid { // result.signerCommonName — DSC subject CN // result.signedAt — SOD signing time, if present } else { // result.errors — non-empty list describing each failure }
Neither the SDK nor the companion verifier bundles any certificates. Two Romanian authorities publish the certs the SDK interacts with, one per PKI:
- DGP —
CSCA Romania, published at https://pasapoarte.mai.gov.ro/csca.html. Self-signed ICAO CSCA that issues the Document Signer embedded in the eMRTD SOD. This is the trust anchor forAmSemnat.verifyPassiveOffline(...). Use the self-signed certificate; the link certificates on that page are only useful when migrating trust from a prior CSCA key. - DGEP —
RO CEI MAI Root-CA/Sub-CA, published at https://hub.mai.gov.ro/cei/info/descarca-cert. Issues the per-citizen signing certificates stored in the CEI applet and used byAmSemnat.sign(...); those are the anchors for verifying the PAdES signatures the SDK produces.
Your app owns freshness and revocation — re-fetch on a cadence appropriate for your trust window.
The SDK links OpenSSL. Apps that link a non-Apple cryptography library must
self-classify against U.S. export regulations on App Store submission. Set
ITSAppUsesNonExemptEncryption in your Info.plist:
false("NO") — if your app's crypto usage is limited to what this SDK does on your behalf (PACE, Chip Authentication, CMS/PKCS#7 verification, X.509 parsing). These are standard authentication and digital-signature operations that fall under the EAR §740.17 mass-market exemption Apple's submission flow recognizes as the "non-exempt: NO" path.true— if your app does additional non-exempt encryption. You'll need a year-end ENC self-classification report unless an exemption applies.
See ATTRIBUTION.md and Apple's Complying With Encryption Export Regulations for details. When in doubt, consult your export-compliance counsel.
This SDK is Apache-2.0. It vendors
NFCPassportReader (MIT,
Andy Qua and contributors) under Sources/AmSemnat/Internal/Vendored/
with access levels lowered to internal. Romania-specific additions
(CAN-based PACE support, EDataReader, SigningReader) are also MIT,
authored by the am-semnat project. OpenSSL is linked via the
CocoaPods OpenSSL-Universal distribution (Apache-2.0).
What you need to do: surface third-party attribution in your app. An
"Open-Source Licenses" screen listing NFCPassportReader (MIT) and OpenSSL
(Apache-2.0) is the standard pattern. See ATTRIBUTION.md
for a ready-to-drop SwiftUI snippet and the full guide.
This SDK is licensed under the Apache License, Version 2.0. See LICENSE.
Third-party component licenses are in LICENSE-THIRD-PARTY.
"Maintained, best-effort." Used in production for am-semnat. Issues and PRs welcome; no SLA guarantees.