Skip to content

eID Mongolia — iOS SDK (GeregeSmartID)

GeregeSmartID is the iOS Swift SDK for the eID platform — it performs distributed keygen, 2-of-2 threshold ECDSA signing, App Attest, and Keychain storage on the citizen's phone. Its crypto is byte-compatible with the Go server's internal/crypto (validated via golden vectors), so the threshold-ECDSA / Paillier / Schnorr / PDL flows match exactly on both sides.

Source code: ios/ios-sdk/Sources/GeregeSmartID/GeregeSmartIDClient.swift (orchestrator), SecureKeyStore.swift (Keychain + PIN), PhoneCrypto.swift, Paillier.swift, Proofs.swift, AppAttestManager.swift.

Numbering terminology

Each citizen has one documentNumber (the device UUID) and TWO separate threshold keys: authentication (PIN1, login, clientAuth) and signing (PIN2, signature, non-repudiation). In the SDK these are PINSlot.auth / PINSlot.sign (docs/IDENTIFIERS.md, docs/EID_2CERT_MILESTONE.md).


1. Overview

GeregeSmartIDClient talks to the enrollment and threshold-signing endpoints of a backend (for example rp-api.eidmongolia.mn), orchestrating the App Attest + crypto flows. Core capabilities:

Action Description
enroll Runs distributed keygen with a DAN KYC consent code + PIN, obtains a certificate, and stores it. If authPin is provided, it runs two keygens (auth + sign) at once (eID 2-cert).
approve Approves an RP's QR/push session with a PIN and assembles a 3-round threshold ECDSA signature.
changePIN / verifyPIN Verify/change the PIN locally (the server NEVER sees the PIN).
pending / activity / sessionInfo Poll pending sessions, history, and session details.

The private key never exists fully in one place: one half is on the phone (encrypted with the PIN in the Keychain), one half is on the server.


2. Prerequisites

  • iOS 14.0+ (SDK), macOS 12.0+ — see platforms in Package.swift. (The main eIDMongolia app requires iOS 15.0+.)
  • App Attest does not work on the simulator. Simulators/emulators have no App Attest / Secure Enclave, so run the Go server with SMARTID_REQUIRE_ATTESTATION=false (the dev default). In production (=true), a real device + App Attest is required.
  • The backend URL is configurable (not hardcoded) — pass it to GeregeSmartIDClient(baseURL:). Example production base: https://rp-api.eidmongolia.mn.
  • BigInt dependency (attaswift/BigInt) — needed for the threshold protocol's raw EC scalar/point + Paillier (2048-bit) arithmetic (CryptoKit does not expose these). It is fetched automatically during swift build.

TLS pinning

The SDK validates every backend connection with CertPinner (public-key pinning). If the server presents a certificate from a CA other than the expected one (Let's Encrypt), the connection is cancelled with NSURLErrorCancelled (-999).


3. Installing (Swift Package Manager)

Package name: GeregeSmartID, product: GeregeSmartID (ios/ios-sdk/Package.swift).

The in-monorepo app (XcodeGen project.yml) consumes the SDK via a local path:

packages:
  GeregeSmartID:
    path: ../ios-sdk             # local Swift package (GeregeSmartID)

To consume it as an SPM dependency from an external project, in Package.swift:

dependencies: [
    .package(url: "https://github.com/gerege-systems/eid-platform-mn.git", branch: "main"),
    // or from a repo that hosts the SDK separately
],
targets: [
    .target(name: "MyApp", dependencies: [
        .product(name: "GeregeSmartID", package: "eid-platform-mn")
    ])
]

4. Basic usage

4.1 Creating a client

import GeregeSmartID

let client = GeregeSmartIDClient(
    baseURL: URL(string: "https://rp-api.eidmongolia.mn")!,
    account: "default")   // registration name inside the Keychain (default)

account is the name of the Keychain slot. The SDK stores signing under account and authentication under account + ".auth" (init(baseURL:account:)).

4.2 Enrollment (enroll)

enroll runs distributed keygen with a DAN KYC consent code + PIN, obtains a certificate, stores it in the Keychain, and returns the documentNumber (UUID). If authPin is provided, it runs two keygens (auth + sign), storing signing under pin and authentication under authPin in their respective slots; if authPin == nil, only the signing cert is created (legacy behavior).

@discardableResult
public func enroll(danAuthCode: String, pin: String, authPin: String? = nil,
                   pushToken: String,
                   reviewLatin: LatinNameReview? = nil,
                   progress: ProgressHandler? = nil) async throws -> String

Example (eID 2-cert — two PINs):

let documentNumber = try await client.enroll(
    danAuthCode: danState,   // DAN-verified consent code (state)
    pin: "1234",             // PIN2 → signing (signature)
    authPin: "5678",         // PIN1 → authentication (login); if nil, signing only
    pushToken: apnsToken)

The reviewLatin callback can show the Latin transliteration to the citizen for correction (LatinNameProposal → corrected (givenNameLatin, surnameLatin)). progress displays each round in the UI with a Mongolian label (ProgressHandler = @MainActor (String) -> Void).

DAN KYC flow

The danAuthCode passed to enroll is the DAN verification state. Beforehand: danInit(registrationNumber:) → open the verify URL → poll with danStatus(state:) → once verified, enroll(danAuthCode: state, …). (kycMethods() returns the available methods: DAN / G-Sign / passport / citizen card.)

4.3 Approving a session (approve)

Approve the sessionId obtained from the RP's QR / push with a PIN. When authentication: true (login flow), the authentication key (PIN1, .auth slot) is used; otherwise the signing key (PIN2) is used — matching the key the server selected. The flowType is known in advance via sessionInfo(sessionId:).

@discardableResult
public func approve(sessionId: String, pin: String,
                    authentication: Bool = false,
                    confirmVc: String? = nil,
                    progress: ProgressHandler? = nil) async throws -> String

Example:

// Login (authentication) session
let status = try await client.approve(
    sessionId: sessionId, pin: "5678", authentication: true)  // "OK"

// Signature session
let status = try await client.approve(
    sessionId: sessionId, pin: "1234")  // authentication: false (default)

Internally, approve runs the 3-round threshold ECDSA: commit → prove → finish (/v3/mobile/session/{id}/sign/{commit,prove,finish}). BEFORE SENDING the signature to the server, the phone independently computes the session's message (SIGN: digest; AUTH: ACSP_V2(rpChallenge, own SPKI)) and ECDSA-verifies the final (r,s) against its certificate's public key (transcript binding — WYSIWYS). To speed things up, you can prepare the PIN-free nonce rounds ahead of time with presign(sessionId:documentNumber:).

4.4 PINSlot and PIN management

public enum PINSlot { case auth, sign }

public func verifyPIN(slot: PINSlot, pin: String) -> Bool          // local check
public func changePIN(slot: PINSlot, oldPIN: String, newPIN: String) throws
public func deleteRegistration()                                    // local delete
  • verifyPIN — attempts to unlock the stored identity with the PIN (does not contact the server).
  • changePIN — unlocks with the old PIN and re-encrypts with the new PIN; the certificate + public key do not change. A wrong old PIN → SDKError.wrongPIN.
  • deleteRegistration — deletes LOCALLY only (both signing + auth slots); it does not revoke the server-side certificate (that is an admin action).

4.5 Errors (SDKError)

public enum SDKError: Error {
    case crypto(String)   // cryptographic error
    case http(String)     // network / server error
    case wrongPIN         // wrong PIN (key does not unwrap)
    case locked           // too many wrong PINs → registration deleted (re-enroll)
}

SDKError is a LocalizedError, so errorDescription gives the actual cause in Mongolian.


5. Keychain / Secure Enclave / biometrics

The phone's secrets are managed by SecureKeyStore (SecureKeyStore.swift):

  • PIN → PBKDF2 (HMAC-SHA256, 210,000 iterations) → AES-GCM encryption, then ECIES-wrapped with the Secure Enclave's P-256 key (hardware binding). The PIN NEVER leaves the device.
  • Storage format: [salt(16) | iv(12) | ciphertext | tag(16)] → SE wrap → [1 | wrapped]. If SE is unavailable on a real device, it is a hard error (SDKError.crypto); only on the simulator is [0 | blob] (no hardware binding, dev-only) permitted.
  • Biometrics (H8): the SE wrap key has .userPresence access control (Face ID / Touch ID or passcode). When unwrapping the signing key (= unlocking the identity with the PIN), a biometric prompt appears; if the user cancels, the unwrap fails → signing stops.
  • Brute-force protection: if 10 consecutive wrong PINs are exceeded, the encrypted registration is DELETED (SDKError.locked). The counter remains persistent in the Keychain even after the blob is deleted.
  • Accessibility: everything is kSecAttrAccessibleWhenUnlockedThisDeviceOnly — bound to this device, not synced to iCloud.
  • Device-binding token: a PIN-free bearer credential sent as X-Device-Token on every /v3/mobile/* request (pending/activity polling is needed before the PIN) — also ThisDeviceOnly.

Keychain namespace

GeregeSmartIDKeychain.service is the brand namespace (eIDMongolia"mn.eidmongolia.smartid"). Set it ONCE at app startup. DO NOT CHANGE it in an app that already has users — registrations under the previous name become unreadable.


6. Building the main app (eIDMongolia)

eIDMongolia (ios/eIDMongolia/) is this repo's sole core / reference app (XcodeGen project.yml, bundle mn.eidmongolia.dan). Details: ios/README.md.

brew install xcodegen
cd ios/eIDMongolia
export GEREGE_BACKEND_URL=https://rp-api.eidmongolia.mn   # or via the AppConfig default
xcodegen                                                  # generates eIDMongolia.xcodeproj
open eIDMongolia.xcodeproj                                # Run from Xcode

Build + install to a connected device via the CLI (automatic signing, use -scheme, not -target):

xcodebuild -project eIDMongolia.xcodeproj -scheme eIDMongolia \
  -configuration Debug -destination 'id=<DEVICE_ID>' \
  -allowProvisioningUpdates build
xcrun devicectl device install app --device <DEVICE_ID> <path>/eIDMongolia.app

Backend URL precedence (eIDMongolia/App/AppConfig.swift): Xcode scheme env (GEREGE_BACKEND_URL) → Info.plist (project.yml build setting) → code default (AppConfig.defaultBackendURL).

Checking the SDK via the CLI (iOS simulator target):

cd ios/ios-sdk
swift build --sdk "$(xcrun --sdk iphonesimulator --show-sdk-path)" \
            --triple arm64-apple-ios16.0-simulator

(swift build on its own builds for macOS and stops at the App Attest API — this is expected, the SDK is iOS-only.)


7. App Attest flow (brief)

AppAttestManager attests the device on the first request that goes to the backend:

  1. First request: generateKeyattestKey → header Type: attest (the server records keyId / pubkey / counter).
  2. Every subsequent request: generateAssertion → header Type: assert + KeyId (the server checks that the counter strictly increases).

The server returns an X-Next-Attestation-Nonce header in its response, so the app can skip a separate challenge GET (GET /v3/attestation/challenge) and go faster.


8. Backend endpoints (used by the SDK)

All map to the Go server's internal/httpapi:

Endpoint Purpose
GET /v3/attestation/challenge App Attest nonce
POST /v3/enrollment/init · /complete distributed keygen + certificate
GET /v3/mobile/session/{id} · /v3/mobile/pending/{doc} session details / pending poll
POST /v3/mobile/session/{id}/sign/{commit,prove,finish} 2-of-2 threshold ECDSA (3 rounds)