Binding keys to a device in iOS
PingOne Advanced Identity Cloud PingAM iOS
The Device Binding module provides secure device registration and authentication capabilities for iOS applications.
It enables applications to bind cryptographic keys to a device and restrict access to those keys, using biometrics, a PIN, and other authentication methods.
Before you begin
You need to create an authentication journey in your server using the appropriate nodes to enable device binding.
The nodes you can use for device binding Journeys include the follows:
- Device Binding node
-
Allows users to register one or more devices to their account. A user can bind multiple devices, and each device can be bound to multiple users.
The client receives a
DeviceBindingCallbackwhen reaching this node in a journey. - Device Signing Verifier node
-
Verifies possession of a registered bound device.
The node requires the client device to sign a challenge string using the private key that corresponds to the public key stored on the server during initial binding.
The client receives a
DeviceSigningVerifierCallbackwhen reaching this node in a journey. - Device Binding Storage node
-
Optionally persists collected device binding data to a user’s profile in the identity store.
By default, the Device Binding node stores device data in the user’s profile. You can choose instead to store the device data in transient state, perhaps to run a custom script to extract additional context.
In this case, you can use a Device Binding Storage node to store the data in the user’s profile.
This node runs entirely server-side, and doesn’t send a callback to the client.
Securing access to the keys
The Device Binding module supports four distinct methods for accessing the private key, each offering different levels of security and user experience.
You specify which authentication type your client uses in the configuration of the Device Binding node. To change the authentication type to access the keys, you’ll need to rebind the client device
-
Biometric Only
-
Biometric with Fallback
-
Application PIN
-
No Authentication
| Type name |
|
| Description |
Requires strict biometric authentication with no fallback options |
| Security level |
High |
| User experience |
Streamlined for devices with reliable biometric sensors |
| Behavior |
|
| Use cases |
Financial applications, enterprise security, medical applications |
| Device requirements |
Must have functional biometric sensors and enrolled biometric data |
| Type name |
|
| Description |
Prefers biometric authentication but allows fallback to device credentials |
| Security level |
Medium to High |
| User experience |
Flexible with multiple authentication options |
| Behavior |
|
| Use cases |
Consumer applications, general-purpose authentication, accessibility-focused apps |
| Device requirements |
|
| Type name |
|
| Description |
Requires a custom PIN that the application manages entirely |
| Security level |
Medium |
| User experience |
Consistent across all devices regardless of hardware capabilities |
| Behavior |
|
| Use cases |
|
| Device requirements |
None - works on all devices |
| Type name |
|
| Description |
No user authentication required to access cryptographic keys |
| Security Level |
Low |
| User Experience |
Seamless with no authentication prompts |
| Behavior |
|
| Use cases |
|
| Security Considerations |
Anyone with device access can use the cryptographic keys |
| Device Requirements |
None |
Installing modules
To install the Device Binding module for iOS, use Swift Package Manager (SPM) or Cocoapods to add the dependency to your project.
-
SPM (Swift Package Manager)
-
CocoaPods
You can install packages by using SPM (Swift Package Manager) on the iOS project.
-
In Xcode, in the Project Navigator, right-click your project, and then click Add Package Dependencies….
-
In the Search or Enter Package URL field, enter the URL of the repo containing the Orchestration SDK for iOS,
https://github.com/ForgeRock/ping-ios-sdk.git. -
In Add to Project, select the name of your project, and then click Add Package.
Xcode shows a dialog containing the libraries available for iOS.
-
Select the
PingBindinglibrary, and in the Add to Target column select the name of your project. -
Repeat the previous step for any other packages you want to use in your project.
-
Click Add Package.
Xcode displays the chosen packages and any prerequisites they might have in the Package Dependencies pane of the Project Navigator.
-
If you don’t already have CocoaPods, install the latest version.
-
If you don’t already have a Podfile, in a terminal window, run the following command to create a new Podfile:
pod init
-
Add the following lines to your Podfile:
pod 'PingBinding'
-
Run the following command to install pods:
pod install
Binding keys to a device
To bind keys to a device, the Binding Module performs the following tasks:
-
Validation: Checks device support for authentication type
-
Cleanup: Removes existing keys for the user
-
Key generation: Creates new cryptographic key pair
-
Authentication: Verifies user identity
-
JWT Signing: Creates signed proof-of-possession
-
Storage: Saves user key meta data
Use the bind() method to bind keys to the device as follows:
import PingBinding
import PingJourney
private func handleDeviceBinding() {
Task {
let result = await callback.bind()
switch result {
case .success(let json):
print("Device binding success: \(json)")
case .failure(let error):
if let deviceBindingStatus = error as? DeviceBindingStatus {
print("Device binding failed: \(deviceBindingStatus.errorMessage)")
} else {
print("Device binding failed: \(error.localizedDescription)")
}
}
onNext()
}
}
Verifying bound keys on a device
To verify that a device possesses a bound key, the Binding Module performs the following tasks:
-
Validation: Validates custom claims
-
Key Lookup: Finds appropriate user key
-
Authentication: Verifies user identity
-
Challenge signing: Signs server challenge
-
JWT creation: Creates verification JWT
Use the DeviceSigningVerifierCallback.sign() method to verify possession of bound keys as follows:
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()
}
}
Adding custom claims when signing using bound keys
When signing a server-provided challenge to verify possession of a bound key, you can add custom data to the resulting JSON Web Token (JWT). The server can access and use this data for context, or for auditing purposes.
Add a claims attribute to the configuration, including the key-value pairs you want to add to the JWT:
let result = await callback.sign { config in
// Use custom claims
config.claims = [
"amount": "100.00",
"recipient": "babs@example.com",
"currency": "USD",
]
}
Configuring authenticator parameters
You can configure a number of device binding parameters, such as the algorithm used, and the validity time.
import PingBinding
let result = await callback.bind { config in
// Specify custom app PIN auth parameters
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",
pinCollector: CustomPinCollector()
)
// Use the custom authenticator with the config
config.deviceAuthenticator = AppPinAuthenticator(config: appPinConfig)
}
import PingBinding
let result = await callback.bind { config in
// Specify custom biometric auth parameters
let biometricConfig = BiometricAuthenticatorConfig(
logger: myCustomLogger,
keyTag: "my-biometric-key-tag",
)
// Set the authenticator config - uses the appropriate authenticator (BiometricOnlyAuthenticator
// or BiometricDeviceCredentialAuthenticator) based on the callback type
config.authenticatorConfig = biometricConfig
}
Replacing the default system UI
The PingBinding module uses system dialogs and alerts by default in the following situations:
- Application PIN entry
-
If the authentication journey specifies the
APPLICATION_PINauthentication method to access the secure keys, the module uses a system alert to obtain the PIN from the user. - Key selection
-
If there are multiple keys available on the device that could be used to fulfil the request, the module uses
UIAlertControllerwith an action sheet to display the suitable keys to the user to choose the correct one.
You can implement your own custom user interface for both of these situations.
Creating a custom UI for PIN entry
To implement a custom UI for accepting PIN codes from the user, complete the following steps:
Step 1. Create a Custom UI for PIN Collection
You need to create a view that serves as your PIN entry screen.
The following 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
To allow the PingBinding module to present your custom UI and return the collected PIN code, implement the PinCollector protocol.
// 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 for binding and signing
To use your new UI for binding and signing, specify the implementation class in the configuration of the sign() and bind() methods.
// 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()
}
}
// 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()
}
}
Creating a custom UI for key selection
Implementing a custom UI for displaying available key pairs to the user to select the correct one is useful in the following situations:
-
Multiple users have bound keys on the same physical device
-
You want to provide a branded UI for key selection
-
You need to display additional context about each key to help with selection
To implement a custom UI for displaying available key pairs, complete the following steps:
. . .
Step 1. Create a custom UI for key selection
You need to create a view that displays the available keys and allow the user to select one.
The following example uses SwiftUI to create a view that allows key selection:
// 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
To allow the PingBinding module to present your custom UI and return the selected key, implement the UserKeySelector protocol.
// 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 key selector for signing
To use your new UI signing, specify the implementation class in the configuration of the sign() and bind() methods.
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()
}
}
Handling errors
If the Device Binding module returns failure when you call bind() or sign(), you can get the details of the error and take the appropriate action.
| Error | Description | Remediation |
|---|---|---|
|
The device lacks required capabilities, or the user hasn’t enrolled. |
Retry with alternative authentication requirements that don’t require biometrics. |
|
No keys are available for signing. Either the device hasn’t been registered, or the user has removed the authentication methods that protected the private key. |
Redirect the user to bind a new key to the device. |
|
Operation exceeded timeout. |
Allow retry with a longer timeout. |
|
Reserved claim names used in custom claims parameter. You can’t add custom claims that match the standard required claims in a JWT, such as |
Remove or rename the claims listed in the error so they do not clash. |
|
The user aborted the operation. For example the user clicked Cancel rather than provide their fingerprint. |
Handle gracefully, and don’t show error. The user chose not to continue the authentication flow. |
|
The user provided invalid credentials. For example, the user entered an incorrect PIN number. |
Allow retry and prompt for the correct credentials. |
The following example shows how to handle some of these exceptions:
let result = await callback.sign()
switch result {
case .success(let json):
print("Device signing success: \(json)")
case .failure(let error):
if let deviceBindingStatus = error as? DeviceBindingStatus {
print("Device signing failed: \(deviceBindingStatus.errorMessage)")
} else {
print("Device signing failed: \(error.localizedDescription)")
}
}
onNext()