CocoaPods trunk is moving to be read-only. Read more on the blog, there are 8 months to go.

AmSemnatSDK 0.1.0

AmSemnatSDK 0.1.0

Maintained by Andrei Cracanau.



  • By
  • am-semnat contributors

am-semnat-ios-sdk

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.

Status

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.

Requirements

  • iOS 15.0+
  • Swift 5.9+
  • Device with an NFC chip that supports NFCTagReaderSession (practically every iPhone from iPhone 7 onward)
  • The NFCReaderUsageDescription key in your app's Info.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 = TAG entitlement (Xcode handles this when you enable the capability)

Installation

CocoaPods (primary)

pod 'AmSemnatSDK', '~> 0.1'

Then:

pod install

AmSemnatSDK pulls in OpenSSL-Universal ~> 3.3 transitively — no extra work on your side.

SwiftPM (experimental in 0.1.0)

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.

Quick start

Read identity

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)")
    }
}

Sign a PDF byte-range hash (PAdES)

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 attributes

Assemble a CMS SignedData on-device or upload the three blobs for server-side assembly.

Low-level (supply your own NFCISO7816Tag)

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.

Localizing the NFC sheet

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.

Cancelling the in-flight session

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).

Authenticity verification

Two questions with two different answers:

  • Is the chip genuine (not a clone)?RomanianIdentity.chipAuthenticated is 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 passiveAuthenticated flag — 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-node reference 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 for AmSemnat.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 by AmSemnat.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.

Export compliance

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.

Licensing and attribution

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.

License

This SDK is licensed under the Apache License, Version 2.0. See LICENSE. Third-party component licenses are in LICENSE-THIRD-PARTY.

Support

"Maintained, best-effort." Used in production for am-semnat. Issues and PRs welcome; no SLA guarantees.