PingBinding Module
The PingBinding Module provides device binding and signing capabilities for native applications.
Cryptographic Algorithm
The PingBinding Module uses ES256 (ECDSA with P-256 curve and SHA-256) for all signing operations. This algorithm is compatible with iOS Secure Enclave, providing hardware-backed security for private keys.
Installation
The PingBinding Module is available via Swift Package Manager and CocoaPods.
Swift Package Manager
Add the following dependency to your Package.swift file:
.package(url: "https://github.com/ForgeRock/ping-ios-sdk.git", from: "2.0.0")
Then, add PingBinding to your target’s dependencies:
.target(
name: "YourTarget",
dependencies: [
.product(name: "PingBinding", package: "ping-ios-sdk")
]
)
Alternatively, in Xcode:
- Go to File > Add Package Dependencies…
- Enter the repository URL:
https://github.com/ForgeRock/ping-ios-sdk.git - Select the version (2.0.0 or later)
- Add the
PingBindinglibrary to your target
CocoaPods
Add the following to your Podfile:
pod 'PingBinding', '~> 2.0.0'
Then run:
pod install
Dependencies
PingBinding has the following dependencies which will be automatically installed:
| Dependency | Version | Description |
|---|---|---|
PingOrchestrate |
~> 2.0.0 | Core orchestration framework |
PingJourneyPlugin |
~> 2.0.0 | Journey-based authentication flow management |
PingCommons |
~> 2.0.0 | Common utilities including JWT signing |
PingStorage |
~> 2.0.0 | Secure storage capabilities |
PingLogger |
~> 2.0.0 | Logging framework |
These dependencies provide the foundation for device binding operations, including secure key storage, JWT signing, and authentication flow management.
Migration from Legacy SDK
The PingBinding Module includes automatic migration capabilities to seamlessly upgrade from legacy SDK (forgerock-ios-sdk) to the new storage format. This ensures that existing device bindings and user keys are preserved when upgrading your application.
What Gets Migrated
The migration process handles:
- User Key Metadata: Migrates all user key information including user IDs, usernames, key identifiers (kid), and authentication types from each individual legacy keychain entry
- Key References: Preserves references to cryptographic keys stored in the iOS keychain
- Authentication Types: Maintains the configured authentication method (biometric, application PIN, none, etc.)
- Creation Timestamps: Preserves the original key creation date from legacy storage
Note: The legacy SDK stored each user key as a separate keychain entry with the service identifier com.forgerock.ios.devicebinding.keychainservice. The migration queries all entries with this identifier and migrates them to the new consolidated storage format.
Automatic Migration
Migration is automatically triggered in the following scenarios:
- When the Journey framework registers callbacks internally, OR
- When you manually call
BindingModule.registerCallbacks(), OR - When performing bind or sign operations - Migration is checked before each operation to ensure legacy keys are available
The migration runs in the background and does not block your application. If no legacy data is found, the migration is silently skipped.
Migration During Bind and Sign Operations
The SDK automatically checks for and migrates legacy data before performing bind or sign operations. This ensures:
- Seamless Upgrade: When signing with a device after upgrading your SDK, legacy keys are automatically migrated and used
- No Manual Intervention: Users don’t need to re-bind their devices after an SDK upgrade
- Backward Compatibility: Existing device bindings continue to work without interruption
// Legacy keys are automatically checked and migrated during bind
try await callback.bind()
// Legacy keys are automatically checked and migrated during sign
// If legacy keys exist for the user, they're migrated and used for signing
let result = await callback.sign()
Migration Process
The migration follows these steps:
- Check for Legacy Data: Verifies if data exists in the legacy keychain location (
com.forgerock.ios.devicebinding.keychainservice) - Read User Keys: Retrieves all user key metadata from the legacy storage. Each key was stored individually as a JSON entry with its key ID as the account name.
- Transform Data: Converts legacy field names (
id,userName,createdAttimestamp) to new format (keyTag,username,createdAtDate) - Migrate to New Storage: Saves keys to the new storage format, avoiding duplicates
- Cleanup: Removes all legacy keychain entries after successful migration (optional)
Legacy Storage Format
The legacy SDK stored each device binding key as an individual keychain entry with the following structure:
- Service Identifier:
com.forgerock.ios.devicebinding.keychainservice - Account Name: The unique key identifier (keyTag)
- Data Format: JSON string with fields:
json { "id": "key-identifier", "userId": "user-id", "userName": "username", "kid": "key-id", "authType": "BIOMETRIC_ONLY", "createdAt": 1763384307.7923698 }
The migration automatically handles this format and converts it to the new storage structure where:
id→keyTaguserName→usernamecreatedAt(Unix timestamp) →createdAt(Date object)
Manual Migration
If you need more control over the migration process, you can trigger it manually:
import PingBinding
import PingLogger
// Basic migration
Task {
do {
try await BindingMigration.migrate()
print("Migration completed successfully")
} catch MigrationError.noLegacyDataFound {
print("No legacy data to migrate")
} catch {
print("Migration failed: \(error)")
}
}
Conditional Migration
You can check if migration is needed before performing it:
import PingBinding
Task {
// Check if legacy data exists
if await BindingMigration.isMigrationNeeded() {
print("Legacy data found, migration will be performed")
// Migrate without throwing errors if no data exists
let migrated = await BindingMigration.migrateIfNeeded()
if migrated {
print("Migration completed successfully")
}
} else {
print("No legacy data to migrate")
}
}
The migrateIfNeeded() method is particularly useful as it:
- Silently handles errors and logs them without throwing exceptions
Advanced Migration Options
You can customize the migration behavior with additional parameters:
import PingBinding
Task {
try await BindingMigration.migrate(
accessGroup: "com.myapp.shared", // Keychain access group if configured
logger: Logger.standard, // Logger for debugging
cleanupLegacyData: true, // Remove legacy data after migration
storageConfig: nil // Custom storage configuration (optional)
)
}
Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
accessGroup |
String? |
nil |
The keychain access group that was configured in the legacy SDK. Only needed if your app uses keychain access groups for sharing data between app extensions or app groups. |
logger |
Logger? |
nil |
Optional logger for debugging and monitoring migration progress. Provides detailed information about each legacy entry processed. |
cleanupLegacyData |
Bool |
true |
Whether to delete all legacy keychain entries after successful migration. Set to false if you want to preserve the legacy data for rollback purposes. |
storageConfig |
UserKeyStorageConfig? |
nil |
Custom storage configuration for the new storage location. If not provided, uses the default configuration. |
Migration Error Handling
The migration can throw the following errors:
| Error | Description |
|---|---|
MigrationError.noLegacyDataFound |
No legacy data exists to migrate. This is normal for new installations or apps that have already been migrated. |
MigrationError.invalidLegacyData |
Legacy data is corrupted or in an unexpected format. This can occur if individual legacy keychain entries cannot be decoded as valid JSON or are missing required fields. |
MigrationError.failedToReadLegacyKeys |
Unable to read legacy keychain data due to keychain access errors. |
MigrationError.failedToSaveKeys |
Unable to save migrated keys to new storage. |
MigrationError.failedToDeleteLegacyData |
Unable to delete legacy data after migration (migration still succeeded). |
MigrationError.alreadyMigrated |
Migration has already been completed in a previous run. |
Note: If some legacy entries fail to decode but others succeed, the migration will complete successfully with the valid keys and log warnings about failed entries. Only if all entries fail will invalidLegacyData be thrown.
Logging Migration Progress
To monitor the migration process, provide a logger:
import PingBinding
import PingLogger
// Set logger for automatic migration via BindingModule
BindingModule.setLogger(Logger.standard)
// Or for manual migration
Task {
try await BindingMigration.migrate(logger: Logger.standard)
}
The migration logs include:
- Migration start and completion
- Number of legacy keychain entries found
- Individual key decoding progress with JSON preview
- Number of keys successfully migrated vs. failed
- Duplicate key detection
- Cleanup status (all legacy entries deleted)
- Any errors or warnings encountered
Example log output:
[INFO] Found 3 legacy keychain entries
[INFO] Decoding legacy key from JSON: {"userName":"user123","id":"key-abc"...
[INFO] Successfully decoded legacy key for user: id=user123,ou=user,o=alpha
[INFO] Successfully decoded 3 user keys from legacy storage (failed: 0)
[INFO] Successfully deleted all legacy keychain entries
Migration Guarantees
The migration process is:
- Idempotent: Can be run multiple times safely without duplicating data
- Non-blocking: Runs asynchronously in the background
- Safe: Does not modify or delete legacy data until migration succeeds
- Duplicate-aware: Checks for existing keys before migrating to prevent duplicates
Testing Migration
For testing purposes, you can reset the migration state:
// Only use in test environments
await BindingMigration.resetMigrationState()
Warning: This should only be used in test code. Resetting the migration state in production code may cause the migration to run multiple times.
Usage
Callback Registration
PingBinding callbacks (DeviceBindingCallback and DeviceSigningVerifierCallback) are automatically registered when you use the Journey framework. The callbacks are registered when Journey.createJourney() is called to initialize a Journey.
No explicit registration is required in your application code when using Journey flows.
Manual Registration (Optional)
If you need to use PingBinding callbacks outside of the Journey framework, you can manually register them:
import PingBinding
@main
struct MyApp: App {
init() {
// Only needed if NOT using Journey framework
BindingModule.registerCallbacks()
}
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
Binding a Device
To bind a device, you’ll receive a DeviceBindingCallback from the AIC authentication flow. You can then call the bind() method on the callback to handle the binding process.
import PingBinding
import PingJourney
func handleDeviceBinding(callback: DeviceBindingCallback, onNext: @escaping () -> Void) {
Task {
do {
try await callback.bind()
print("Device bound successfully")
} catch {
print("Device binding failed: \(error.localizedDescription)")
viewModel.error = error
}
// Continue to the next node
onNext()
}
}
Signing a Transaction
To sign a transaction, you’ll receive a DeviceSigningVerifierCallback. Call the sign() method on the callback to sign the data.
import PingBinding
import PingJourney
func handleDeviceSigning(callback: DeviceSigningVerifierCallback, onNext: @escaping () -> Void) {
Task {
let result = await callback.sign()
switch result {
case .success:
print("Signing successful")
case .failure(let error):
print("Signing failed: \(error.localizedDescription)")
}
// Continue to the next node
onNext()
}
}
Advanced Usage & Customization
The SDK allows for customization of the device authentication process, particularly for handling Application PIN authentication with a custom user interface.
Using a Custom PIN Collector
By default, if Application PIN authentication is required, the SDK presents a system alert to collect the PIN. You can override this behavior by providing a custom implementation of the PinCollector protocol. This allows you to present your own UI for PIN entry.
Here is a step-by-step guide to implementing a custom PIN collector:
Step 1: Create a Custom UI for PIN Collection
First, create a view that will serve as your PIN entry screen. This example uses SwiftUI to create a simple view that collects a 4-digit PIN.
// In your application, e.g., PinCollectorView.swift
import SwiftUI
import PingBinding
struct PinCollectorView: View {
let prompt: Prompt
let completion: (String?) -> Void
@State private var pin: String = ""
var body: some View {
VStack(spacing: 20) {
Text(prompt.title)
.font(.title)
Text(prompt.description)
.font(.subheadline)
TextField("4-digit PIN", text: $pin)
.keyboardType(.numberPad)
.padding()
HStack {
Button("Cancel") { completion(nil) }
Button("Submit") { completion(pin) }
.disabled(pin.count != 4)
}
}
.padding()
}
}
Step 2: Implement the PinCollector Protocol
Next, create a class that conforms to the PinCollector protocol. This class is responsible for presenting your custom UI and returning the collected PIN via the completion handler.
// In your application, e.g., CustomPinCollector.swift
import UIKit
import SwiftUI
import PingBinding
class CustomPinCollector: PinCollector {
func collectPin(prompt: Prompt, completion: @escaping @Sendable (String?) -> Void) {
DispatchQueue.main.async {
guard let topVC = UIApplication.shared.windows.first?.rootViewController else {
completion(nil)
return
}
let pinView = PinCollectorView(prompt: prompt) { pin in
topVC.dismiss(animated: true) {
completion(pin)
}
}
let hostingController = UIHostingController(rootView: pinView)
topVC.present(hostingController, animated: true)
}
}
}
Step 3: Use the Custom Collector During Binding and Signing
Finally, when you handle the DeviceBindingCallback or DeviceSigningVerifierCallback, you can provide a custom PIN collector through the configuration.
For Device Binding:
// In your view that handles the DeviceBindingCallback
import PingBinding
func handleDeviceBinding(callback: DeviceBindingCallback, onNext: @escaping () -> Void) {
Task {
let result = await callback.bind { config in
// Customize the PIN collector for application PIN authentication
config.pinCollector = CustomPinCollector()
}
// Handle result...
onNext()
}
}
For Device Signing:
// In your view that handles the DeviceSigningVerifierCallback
import PingBinding
func handleDeviceSigning(callback: DeviceSigningVerifierCallback, onNext: @escaping () -> Void) {
Task {
let result = await callback.sign { config in
// Customize the PIN collector for application PIN authentication
config.pinCollector = CustomPinCollector()
}
// Handle result...
onNext()
}
}
Advanced Authenticator Configuration
You can further customize authenticators by providing configuration objects:
AppPinAuthenticator Configuration:
import PingBinding
let result = await callback.bind { config in
// Create a custom AppPinConfig
let appPinConfig = AppPinConfig(
logger: myCustomLogger,
prompt: Prompt(title: "Enter PIN", subtitle: "Security", description: "Enter your 4-digit PIN"),
pinRetry: 5,
keyTag: "my-custom-key-tag",
keySizeInBits: 256, // P-256 curve for ES256 algorithm
pinCollector: CustomPinCollector()
)
// Use the custom authenticator with the config
config.deviceAuthenticator = AppPinAuthenticator(config: appPinConfig)
}
Note: The keySizeInBits parameter should always be set to 256 for compatibility with iOS Secure Enclave. This is the default value and typically doesn’t need to be specified.
BiometricAuthenticator Configuration:
import PingBinding
let result = await callback.bind { config in
// Create a custom BiometricAuthenticatorConfig
let biometricConfig = BiometricAuthenticatorConfig(
logger: myCustomLogger,
keyTag: "my-biometric-key-tag",
keySizeInBits: 256 // P-256 curve for ES256 algorithm
)
// Set the authenticator config - the appropriate authenticator (BiometricOnlyAuthenticator
// or BiometricDeviceCredentialAuthenticator) will be used based on the callback type
config.authenticatorConfig = biometricConfig
}
Note: The keySizeInBits parameter should always be set to 256 for compatibility with iOS Secure Enclave. This is the default value and typically doesn’t need to be specified.
Custom User Key Selection
When signing with a device and the callback doesn’t specify a userId, or when multiple keys are available for the same user, the SDK needs to determine which key to use. By default, the SDK presents a system alert (UIAlertController with action sheet) for the user to choose. You can customize this behavior by implementing the UserKeySelector protocol.
This is particularly useful when:
- Multiple users have bound their devices on the same physical device
- You want to provide a branded UI for key selection
- You need to display additional context about each key
Step 1: Create a Custom UI for Key Selection
First, create a SwiftUI view that will display the available keys and allow the user to select one:
// In your application, e.g., UserKeySelectorView.swift
import SwiftUI
import PingBinding
struct UserKeySelectorView: View {
let userKeys: [UserKey]
let prompt: Prompt
let completion: (UserKey?) -> Void
var body: some View {
NavigationView {
VStack(spacing: 20) {
if !prompt.description.isEmpty {
Text(prompt.description)
.font(.body)
.multilineTextAlignment(.center)
.padding()
}
List(userKeys, id: \.id) { userKey in
Button(action: {
completion(userKey)
}) {
VStack(alignment: .leading, spacing: 4) {
if !userKey.username.isEmpty {
Text(userKey.username)
.font(.headline)
}
if !userKey.userId.isEmpty {
Text("User ID: \(userKey.userId)")
.font(.caption)
.foregroundColor(.secondary)
}
Text("Auth: \(userKey.authType.rawValue)")
.font(.caption)
.foregroundColor(.secondary)
}
.padding(.vertical, 4)
}
}
}
.navigationTitle(prompt.title.isEmpty ? "Select Device Key" : prompt.title)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
completion(nil)
}
}
}
}
}
}
Step 2: Implement the UserKeySelector Protocol
Create a class that conforms to the UserKeySelector protocol. This class is responsible for presenting your custom UI and returning the selected key:
// In your application, e.g., CustomUserKeySelector.swift
import UIKit
import SwiftUI
import PingBinding
class CustomUserKeySelector: UserKeySelector {
func selectKey(userKeys: [UserKey], prompt: Prompt) async -> UserKey? {
return await withCheckedContinuation { continuation in
DispatchQueue.main.async {
guard let topVC = self.getTopViewController() else {
continuation.resume(returning: nil)
return
}
let selectorView = UserKeySelectorView(
userKeys: userKeys,
prompt: prompt
) { selectedKey in
topVC.dismiss(animated: true) {
continuation.resume(returning: selectedKey)
}
}
let hostingController = UIHostingController(rootView: selectorView)
hostingController.modalPresentationStyle = .formSheet
topVC.present(hostingController, animated: true)
}
}
}
private func getTopViewController() -> UIViewController? {
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = windowScene.windows.first(where: { $0.isKeyWindow }),
let rootViewController = window.rootViewController else {
return nil
}
var topViewController = rootViewController
while let presentedViewController = topViewController.presentedViewController {
topViewController = presentedViewController
}
return topViewController
}
}
Step 3: Use the Custom Selector During Signing
When handling the DeviceSigningVerifierCallback, provide your custom key selector through the configuration:
import PingBinding
func handleDeviceSigning(callback: DeviceSigningVerifierCallback, onNext: @escaping () -> Void) {
Task {
let result = await callback.sign { config in
// Use custom UI for selecting from multiple device keys
config.userKeySelector = CustomUserKeySelector()
}
switch result {
case .success:
print("Signing successful")
case .failure(let error):
print("Signing failed: \(error.localizedDescription)")
}
onNext()
}
}
Note: The default DefaultUserKeySelector presents a system alert with an action sheet showing the available keys. You only need to implement a custom selector if you want a different UI experience.
License
The PingBinding SDK is licensed under the Apache License, Version 2.0. © Copyright 2025-2026 Ping Identity Corporation. All Rights Reserved
View on GitHub