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
platformsinPackage.swift. (The maineIDMongoliaapp 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:
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
.userPresenceaccess 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-Tokenon every/v3/mobile/*request (pending/activity polling is needed before the PIN) — alsoThisDeviceOnly.
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:
- First request:
generateKey→attestKey→ headerType: attest(the server records keyId / pubkey / counter). - Every subsequent request:
generateAssertion→ headerType: 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) |
9. Links¶
- SDK source code:
ios/ios-sdk/Sources/GeregeSmartID/ - SDK README:
ios/ios-sdk/README.md - App + build instructions:
ios/README.md - Identifiers:
docs/IDENTIFIERS.md· 2-cert:docs/EID_2CERT_MILESTONE.md - RP integration (server side):
docs/RP_INTEGRATION.md