diff --git a/docs/kratos/mfa/01_overview.mdx b/docs/kratos/mfa/01_overview.mdx index 86899a7ba0..da0c8c0847 100644 --- a/docs/kratos/mfa/01_overview.mdx +++ b/docs/kratos/mfa/01_overview.mdx @@ -48,10 +48,12 @@ authentication method. They can be used to complete the second factor when users SMS for MFA sends a one-time password to the user's registered mobile phone number via text message. Read the [Code via SMS](../../../docs/kratos/mfa/mfa-via-sms) documentation to learn more. -### Device binding +### Device authentication -Passwordless authentication where the private key is hardware-resident on the user's device. Read the -[Device binding](../passwordless/08_deviceauthn.mdx) documentation to learn more. +Passwordless authentication where the private key is hardware-resident on the user's device. + +Read the Device authentication documentation to +learn more. ### Email diff --git a/docs/kratos/passwordless/08_deviceauthn.mdx b/docs/kratos/passwordless/08_deviceauthn.mdx deleted file mode 100644 index 0f0b8e9ebd..0000000000 --- a/docs/kratos/passwordless/08_deviceauthn.mdx +++ /dev/null @@ -1,1050 +0,0 @@ ---- -id: deviceauthn -title: Device binding -sidebar_label: Device binding ---- - -import Mermaid from "@site/src/theme/Mermaid" - -Device Authentication (also known as 'DeviceAuthn', or device binding) is a way for a user to authenticate with a hardware -resident private key. - -Since the key cannot leave the device, once the key has been added to the identity, it gives a high assurance that the user is who -they say they are, and is using a trusted, known device, without needing to remember something like a password. - -This is very similar to passkeys with one crucial difference: passkeys are usually synced in the cloud among many devices, whereas -a DeviceAuthn key cannot leave the hardware where it was created. - -Using this approach, the system can restrict the use of an application on specific, whitelisted devices. - -Currently, this authentication strategy can only be used as a second factor. It may change in the future. That is because there is -no way to do recovery, since the private key is never readable in clear and cannot be extracted out of the hardware. - -Since this is a strategy, it supports all the same hooks as the other strategies. - -## Short summary - -- This is implemented in the OEL version with the strategy `DeviceAuthn`, in spirit similar to `WebAuthn`. -- The settings flow is used to manage keys (create, delete). -- The login flow is used to step-up the AAL. Hardware-backed keys (TEE) satisfy AAL2, while keys stored in a dedicated security - chip (StrongBox) may eventually be categorized as AAL3. -- Using the admin API, it is possible to delete all keys for a device on behalf of the user in case of theft or loss. -- A device may have multiple keys, to support multiple user accounts on the same device. -- Only these platforms are currently supported, because they offer native APIs, strong hardware, and trust guarantees: - - iOS: 14.0+ - - iPadOS: 14.0+ - - tvOS: 15.0+ (untested) - - visionOS 1.0+ (untested) - - Android SDK 24.0+. Older versions are unlikely to be supported. - -## Acronyms - -- TPM: Trusted Platform Module -- TEE: Trusted Execution Environment -- CA: Certificate Authority -- AAL: Authenticator Assurance Level - -## Guides - -### How to implement Device Binding in your Android application - -We recommend using the Ory Java SDK to communicate with Kratos, although this is not required. Code snippets here use this SDK, -and are written in Kotlin. - -Since Device Binding only is supported on native devices (not in the browser), all corresponding API calls should be done using -the endpoints for native apps, to avoid having to pass cookies around manually. - -1. Ensure that the `DeviceAuthn` strategy is enabled in the Kratos configuration. This strategy implements the settings and login - flow. This is done so: - ```yaml - selfservice: - methods: - deviceauthn: - enabled: true - ``` -1. Implement a runtime check for the Android version. If is lower than 24, Device Binding may not be used, and a fallback should - be found, for example using passkeys. -1. Device Binding is (currently) only a second factor, the UI should only show existing Device Binding keys and related buttons - (e.g. to add a key) if the user is currently logged in. This can be confirmed with a `whoami` call. -1. Create a [settings flow for native apps](https://www.ory.com/docs/reference/api#tag/frontend/operation/updateSettingsFlow). The - response contains the list of existing Device Binding keys. -1. To delete an existing key, - [complete the settings flow](https://www.ory.com/docs/reference/api#tag/frontend/operation/updateSettingsFlow) with this - payload: - - ```json - { - "delete": { - "client_key_id": "4fcXqFY9kg2unsTCM33GH8ayIWY6WdIGFWXMzhl9Vik=" - }, - "method": "deviceauthn" - } - ``` - - Or using the SDK: - - ```kotlin - val clientKeyIdToDelete = "..." - - val apiClient = Configuration.getDefaultApiClient() - val apiInstance = FrontendApi(apiClient) - val body = UpdateSettingsFlowBody() - val method = UpdateSettingsFlowWithDeviceAuthnMethod() - method.method = "deviceauthn" - method.delete = UpdateSettingsFlowWithDeviceAuthnMethodDelete() - method.delete!!.clientKeyId = clientKeyIdToDelete - body.actualInstance = method - val updatedFlow = apiInstance.updateSettingsFlow(settingsFlow?.id, body, sessionToken, "") - ``` - - Once the key has been deleted server-side, it is fine (although not required) to also delete it on the device using the - KeyStore API. - -1. To add a new key, - [complete the settings flow](https://www.ory.com/docs/reference/api#tag/frontend/operation/updateSettingsFlow) with this - payload: - - ```json - { - "method": "deviceauthn", - "add": { - "device_name": "iPhone (iPhone14,5)", - "attestation_ios": "...", - "client_key_id": "sBS5ZHWqsSRbV6OEvCfsg0+DWa3ERns6JyqRypqccrE=" - } - } - ``` - - Or using the SDK: - - ```kotlin - val apiClient = Configuration.getDefaultApiClient() - val withStrongbox = false // Better: Detect the presence of StrongBox at runtime. - - val keyAlias = UUID.randomUUID().toString() - val nonce = extractNonceFromUiNodes(settingsFlow?.ui?.nodes ?: emptyList()) - if (nonce == null) { - throw Exception("No nonce found in UI. Is DeviceAuthn enabled server-side?") - } - val keyCertChain = Api.create().createKeyPair(keyAlias, nonce, withStrongbox) - val apiInstance = FrontendApi(apiClient) - val body = UpdateSettingsFlowBody() - val method = UpdateSettingsFlowWithDeviceAuthnMethod() - method.method = "deviceauthn" - method.add = UpdateSettingsFlowWithDeviceAuthnMethodAdd() - method.add!!.deviceName = "My work phone" - method.add!!.clientKeyId = keyAlias - method.add!!.certificateChainAndroid = keyCertChain.map { it.encoded }.toList() - body.actualInstance = method - val updatedFlow = apiInstance.updateSettingsFlow(settingsFlow?.id, body, sessionToken, "") - ``` - - Once a key is created, the KeyStore APIs can be used to list all keys, query a key using its id, etc. However we recommend that - the application keeps track of what keys were created to know which ones can be used on the device, compared to which keys - belong to the same identity but reside on other devices. Note that there is a maximum number of keys that can be created for an - identity, and there is no point to create multiple keys for the same user on the same device, even though the server allows it. - -1. To use a key to step-up the AAL, - [complete the settings flow](https://www.ory.com/docs/reference/api#tag/frontend/operation/updateSettingsFlow) with this - payload: - - ```json - { - "signature": "...", - "client_key_id": "sBS5ZHWqsSRbV6OEvCfsg0+DWa3ERns6JyqRypqccrE=", - "method": "deviceauthn" - } - ``` - - Or using the SDK: - - ```kotlin - val clientKeyId = "..." - val nonce = extractNonceFromUiNodes(flow?.ui?.nodes ?: emptyList()) - if (nonce == null) { - throw Exception("No nonce found in UI") - } - - val updateMethod = UpdateLoginFlowWithDeviceAuthnMethod() - updateMethod.clientKeyId = clientKeyId - updateMethod.method = "deviceauthn" - updateMethod.signature = Api.create().launchBiometricSigner( - context as FragmentActivity, - clientKeyId, - nonce, - "Confirm", - "Cancel" - ) - - val updateBody = UpdateLoginFlowBody() - updateBody.actualInstance = updateMethod - - val apiClient = Configuration.getDefaultApiClient() - - withContext(Dispatchers.IO) { - val apiInstance = FrontendApi(apiClient) - val res = apiInstance.updateLoginFlow( - /* flow = */ flow.id, - /* updateLoginFlowBody = */ updateBody, - /* xSessionToken = */ sessionToken, - /* cookie = */ "" - ) - } - ``` - -There are two Keystore calls required: one to create the key and one to use it to sign: - -```kotlin -package com.ory.sdk - -import android.os.Build -import android.security.keystore.KeyGenParameterSpec -import android.security.keystore.KeyProperties -import android.util.Log -import androidx.biometric.BiometricPrompt -import androidx.core.content.ContextCompat -import androidx.fragment.app.FragmentActivity -import kotlinx.coroutines.suspendCancellableCoroutine -import java.security.KeyPairGenerator -import java.security.KeyStore -import java.security.PrivateKey -import java.security.Signature -import java.security.cert.Certificate -import kotlin.coroutines.resume -import kotlin.coroutines.resumeWithException - -private const val TAG = "com.ory.sdk" - -public interface Api { - public companion object { - @JvmStatic - public fun create(): Api { - return OryApi() - } - } - - public fun createKeyPair( - keyAlias: String, - challenge: ByteArray, - withStrongBox: Boolean - ): List - - public suspend fun launchBiometricSigner( - activity: FragmentActivity, - keyAlias: String, - challenge: ByteArray, - title: String, - negativeButtonText: String, - ): ByteArray -} - -internal class OryApi : Api { - private val keyStore: KeyStore by lazy { - KeyStore.getInstance("AndroidKeyStore").apply { - load(null) - } - } - - private fun getCertificateChain(keyAlias: String): List { - return keyStore.getCertificateChain(keyAlias).toList() - } - - - override fun createKeyPair( - keyAlias: String, - challenge: ByteArray, - withStrongBox: Boolean, - ): List { - val kpg: KeyPairGenerator = KeyPairGenerator.getInstance( - KeyProperties.KEY_ALGORITHM_EC, - "AndroidKeyStore" - ) - - val parameterSpec: KeyGenParameterSpec = KeyGenParameterSpec.Builder( - keyAlias, - KeyProperties.PURPOSE_SIGN - ).run { - setDigests(KeyProperties.DIGEST_SHA256) - if (Build.VERSION.SDK_INT >= 24) { - setAttestationChallenge(challenge) - } - - if (Build.VERSION.SDK_INT >= 28) { - setIsStrongBoxBacked(withStrongBox) - } - // Require biometric/PIN for every single use. - setUserAuthenticationRequired(true) - // TODO: Should we use: setInvalidatedByBiometricEnrollment(true) ? - build() - } - - kpg.initialize(parameterSpec) - kpg.generateKeyPair() - Log.i(TAG, "created keypair: alias=$keyAlias") - - return getCertificateChain(keyAlias) - } - - - /** - * Provides an uninitialized Signature object for the App to use in BiometricPrompt. - */ - private fun getSignatureObject(keyAlias: String): Signature { - val privateKey = keyStore.getKey(keyAlias, null) as? PrivateKey - - return Signature.getInstance("SHA256withECDSA").apply { - initSign(privateKey) - } - } - - override suspend fun launchBiometricSigner( - activity: FragmentActivity, - keyAlias: String, - challenge: ByteArray, - title: String, - negativeButtonText: String, - ): ByteArray = suspendCancellableCoroutine { continuation -> - val executor = ContextCompat.getMainExecutor(activity) - - val biometricPrompt = BiometricPrompt( - activity, executor, - object : BiometricPrompt.AuthenticationCallback() { - override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { - try { - val signature = result.cryptoObject?.signature - if (signature != null) { - signature.update(challenge) - continuation.resume(signature.sign()) - } else { - continuation.resumeWithException(Exception("Signature object is null")) - } - } catch (e: Exception) { - continuation.resumeWithException(e) - } - } - - override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { - // Wrap the error in a custom Exception or handle specific error codes - continuation.resumeWithException(Exception(errString.toString())) - } - - override fun onAuthenticationFailed() { - // Note: onAuthenticationFailed is called for finger-read errors - // but doesn't dismiss the prompt; we usually wait for Error or Success. - } - } - ) - - val promptInfo = BiometricPrompt.PromptInfo.Builder() - .setTitle(title) - .setNegativeButtonText(negativeButtonText) - .build() - - // Cancel the biometric prompt if the coroutine is canceled - continuation.invokeOnCancellation { - biometricPrompt.cancelAuthentication() - } - - biometricPrompt.authenticate( - promptInfo, - BiometricPrompt.CryptoObject(getSignatureObject(keyAlias)) - ) - } -} - -``` - -#### Making it work in the Android emulator - -Because the emulator produces software-based attestations, the server only accepts its keys when relaxed attestation is enabled. -See [Relaxed attestation for testing](#relaxed-attestation-for-testing). - -1. Create an emulated device in the Android emulator with an Android version which is at least 24. -1. Start the emulated device. -1. Inside the emulated device, go to 'Settings > Security & Location > Screen Lock' and set a device PIN (this is required for - biometrics). -1. Inside the emulated device, go to 'Settings > Security & Location > Fingerprints' and add a fingerprint. A biometric prompt - will appear on the screen of the emulated device. -1. In the 'Extended Controls' for the emulated device (not inside the device, but in Android Studio), go to the 'Fingerprints' - section and click on 'Touch sensor' to pass the biometrics prompt of the device. This simulates placing your finger on the - sensor. - -At this point the fingerprint is registered for the emulated device. The process must be repeated for each emulated device. - -Then, start the application inside the emulated device. When the biometric prompt appears, repeat step 5. to pass the biometric -prompt. There are several fingerprints available, so it is possible to test the case of using a registered fingerprint, and the -case of using an unknown fingerprint. To test the case of no fingerprint registered, remove the registered fingerprint in the -'Settings' of the emulated device. - -### How to implement Device Binding in your iOS/iPadOS application - -A notable difference with Android is that Apple's app attestation APIs require a network call to Apple's servers from a real -device. - -This means that the emulator cannot be used. - -Since Device Binding only is supported on native devices (not in the browser), all corresponding API calls should be done using -the endpoints for native apps, to avoid having to pass cookies around manually. - -1. Ensure that the `DeviceAuthn` strategy is enabled in the Kratos configuration. This strategy implements the settings and login - flow. This is done so: - ```yaml - selfservice: - methods: - deviceauthn: - enabled: true - ``` -1. In XCode, add a permission so that the application is allowed to use FaceID. In - `Target settings > Info > Custom iOS Target Properties`, add: - - Key: `Privacy - Face ID Usage Description` - - Type: `String` - - Value: `This app uses FaceID to authenticate signing operations.` -1. Implement a runtime check for the OS version. If is lower than the - [documented ones](https://developer.apple.com/documentation/devicecheck/dcappattestservice), Device Binding may not be used, - and a fallback should be found, for example using passkeys. -1. Device Binding is (currently) only a second factor, the UI should only show existing Device Binding keys and related buttons - (e.g. to add a key) if the user is currently logged in. This can be confirmed with a `whoami` call. -1. Create a [settings flow for native apps](https://www.ory.com/docs/reference/api#tag/frontend/operation/updateSettingsFlow). The - response contains the list of existing Device Binding keys. -1. To delete an existing key, - [complete the settings flow](https://www.ory.com/docs/reference/api#tag/frontend/operation/updateSettingsFlow) with this - payload: - - ```json - { - "delete": { - "client_key_id": "4fcXqFY9kg2unsTCM33GH8ayIWY6WdIGFWXMzhl9Vik=" - }, - "method": "deviceauthn" - } - ``` - - Or using the SDK: - - ```swift - let clientKeyId = "..." - - let flow = try await FrontendAPI.createNativeSettingsFlow( - xSessionToken: sessionToken - ) - - let body: UpdateSettingsFlowBody = - .typeUpdateSettingsFlowWithDeviceAuthnMethod( - UpdateSettingsFlowWithDeviceAuthnMethod( - delete: UpdateSettingsFlowWithDeviceAuthnMethodDelete( - clientKeyId: clientKeyId, - ), - method: "deviceauthn" - ) - ) - let finalFlow = try await FrontendAPI.updateSettingsFlow( - flow: flow.id, - updateSettingsFlowBody: body, - xSessionToken: sessionToken - ) - ``` - - Once the key has been deleted server-side, it is fine (although not required) to also delete it on the device using the - KeyStore API. - -1. To add a new key, - [complete the settings flow](https://www.ory.com/docs/reference/api#tag/frontend/operation/updateSettingsFlow) with this - payload: - - ```json - { - "method": "deviceauthn", - "add": { - "device_name": "iPhone (iPhone14,5)", - "attestation_ios": "...", - "client_key_id": "sBS5ZHWqsSRbV6OEvCfsg0+DWa3ERns6JyqRypqccrE=" - } - } - ``` - - Or using the SDK: - - ```swift - let clientKeyId = "..." - - let flow = try await FrontendAPI.createNativeSettingsFlow( - xSessionToken: sessionToken - ) - - let nonce = extractNonceFromUiNodes(nodes: flow.ui.nodes) ?? "" - let deviceName = "My work phone" - let (clientKeyId, attestation) = try await OryApi().createKey( - challengeB64: nonce - ) - - let body: UpdateSettingsFlowBody = - .typeUpdateSettingsFlowWithDeviceAuthnMethod( - UpdateSettingsFlowWithDeviceAuthnMethod( - add: UpdateSettingsFlowWithDeviceAuthnMethodAdd( - attestationIos: attestation, - clientKeyId: clientKeyId, - deviceName: deviceName, - ), - method: "deviceauthn" - ) - ) - let finalFlow = try await FrontendAPI.updateSettingsFlow( - flow: flow.id, - updateSettingsFlowBody: body, - xSessionToken: sessionToken - ) - ``` - - Once a key is created, the application must store the key id somewhere, because there are no APIs to list keys or check if a - key exists. Note that there is a maximum number of keys that can be created for an identity, and there is no point to create - multiple keys for the same user on the same device, even though the server allows it. - -1. To use a key to step-up the AAL, - [complete the settings flow](https://www.ory.com/docs/reference/api#tag/frontend/operation/updateSettingsFlow) with this - payload: - - ```json - { - "signature": "...", - "client_key_id": "sBS5ZHWqsSRbV6OEvCfsg0+DWa3ERns6JyqRypqccrE=", - "method": "deviceauthn" - } - ``` - - Or using the SDK: - - ```swift - let clientKeyId = "..." - - let flow = try await FrontendAPI.createNativeLoginFlow( - refresh: false, - aal: AuthenticatorAssuranceLevel.aal2.rawValue, - xSessionToken: sessionToken - ) - let nonce = extractNonceFromUiNodes(nodes: flow.ui.nodes) ?? "" - - let signature = try await OryApi().signWithKey( - keyId: clientKeyId, - challengeB64: nonce, - ) - - let body = - UpdateLoginFlowBody - .typeUpdateLoginFlowWithDeviceAuthnMethod( - UpdateLoginFlowWithDeviceAuthnMethod( - clientKeyId: clientKeyId, - method: "deviceauthn", - signature: signature, - ) - ) - - let finalFlow = try await FrontendAPI.updateLoginFlow( - flow: flow.id, - updateLoginFlowBody: body, - xSessionToken: sessionToken - ) - ``` - -There are two required App Attest calls to create a key and use it to sign: - -```swift -import CryptoKit -import DeviceCheck -import Foundation -import LocalAuthentication -import OSLog -import Security - -public enum OryApiError: Error, LocalizedError { - case secureEnclaveError(String, OSStatus?) - case appAttestationNotSupported - case appAttestationError(String) - case biometricAuthenticationFailed(String?) - case biometricAuthenticationCancelled - - public var errorDescription: String? { - switch self { - case .secureEnclaveError(let message, let status): - let statusString = status != nil ? " (Status: \(status!))" : "" - return "Secure Enclave Error: \(message)\(statusString)" - case .appAttestationNotSupported: - return "App Attestation is not supported on this device." - case .appAttestationError(let message): - return "App Attestation Error: \(message)" - case .biometricAuthenticationFailed(let message): - return - "Biometric authentication failed: \(message ?? "Unknown error")" - case .biometricAuthenticationCancelled: - return "Biometric authentication canceled by user." - } - } -} - -public class OryApi { - public func createKey(challengeB64: String) - async throws -> (clientKeyId: String, attestation: Data) - { - if #available(iOS 14.0, *) { - let service = DCAppAttestService.shared - guard service.isSupported else { - throw OryApiError.appAttestationNotSupported - } - - let keyId: String - do { - keyId = try await service.generateKey() - } catch { - let errorMessage = - "Failed to generate key: \(error.localizedDescription)" - throw OryApiError.appAttestationError(errorMessage) - } - - let challenge = Data(base64Encoded: challengeB64)! - let attestation = try await service.attestKey( - keyId, - clientDataHash: challenge - ) - - return (keyId, attestation) - } else { - // Fallback for older iOS versions - throw OryApiError.secureEnclaveError( - "iOS 14.0 or newer is required for App Attestation.", - nil - ) - } - } - - public func signWithKey(keyId: String, challengeB64: String) - async throws -> Data - { - if #available(iOS 14.0, watchOS 14.0, *) { - let context = LAContext() - let reason = "Authenticate to sign in" - do { - try await context.evaluatePolicy( - .deviceOwnerAuthenticationWithBiometrics, - localizedReason: reason - ) - } catch let error as LAError { - switch error.code { - case .userCancel, .appCancel, .systemCancel, .userFallback: - throw OryApiError.biometricAuthenticationCancelled - default: - throw OryApiError.biometricAuthenticationFailed( - error.localizedDescription - ) - } - } catch { - throw OryApiError.biometricAuthenticationFailed( - error.localizedDescription - ) - } - - let challenge = Data(base64Encoded: challengeB64)! - let assertion = try await DCAppAttestService.shared - .generateAssertion(keyId, clientDataHash: challenge) - - return assertion - } else { - throw OryApiError.secureEnclaveError( - "iOS 14.0 or newer is required for App Attestation.", - nil - ) - } - } -} -``` - -## How to implement Device Binding in your Dart/Flutter application - -Dart can call native APIs via message passing. Let's call a function called `generateKey` with the parameter -`{'alias': 'my_key_01'}`: - -```dart -Future _generateKey() async { -setState(() => _isLoading = true); - -try { - // Calling the native method - final String result = await platform.invokeMethod('generateKey', { - 'alias': 'my_key_01', - }); - - setState(() { - _keyStoreResult = result; - _isLoading = false; - }); -} on PlatformException catch (e) { - setState(() { - _keyStoreResult = "Failed to generate key: '${e.message}'."; - _isLoading = false; - }); -} -} -``` - -Since the call might block, it is marked async and a loading indicator is shown in the UI via the `_isLoading` field. - -Now to the platform code, for example for Android: - -```kotlin -class MainActivity: FlutterActivity() { - private val CHANNEL = "com.example.secure/keystore" - - override fun configureFlutterEngine(flutterEngine: FlutterEngine) { - super.configureFlutterEngine(flutterEngine) - - MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result -> - if (call.method == "generateKey") { - val alias = call.argument("alias") ?: "default_alias" - try { - val keyStoreResult = [..] // Call the KeyStore here. - - // Send the result back to Flutter. - result.success(keyStoreResult) - } catch (e: Exception) { - // If generation fails (e.g., hardware issues), send an error - result.error("KEY_GEN_FAIL", e.localizedMessage, null) - } - } else { - result.notImplemented() - } - } - } -} -``` - -And for iOS: - -```swift -import UIKit -import Flutter - -@main -@objc class AppDelegate: FlutterAppDelegate { - override func application( - _ application: UIApplication, - didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? - ) -> Bool { - - // 1. Standard plugin registration for things like path_provider, etc. - GeneratedPluginRegistrant.register(with: self) - - // 2. Create a registrar for our custom "inline" plugin - // The name "SecureKeystorePlugin" can be anything unique. - let registrar = self.registrar(forPlugin: "SecureKeystorePlugin") - - // 3. Setup the channel using the registrar's messenger - let channel = FlutterMethodChannel( - name: "com.example.secure/keystore", - binaryMessenger: registrar!.messenger() - ) - - // 4. Handle the method calls - channel.setMethodCallHandler({ - (call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in - - if call.method == "generateResidentKey" { - let alias = (call.arguments as? [String: Any])?["alias"] as? String ?? "unknown" - - // Just for the example, get the iOS version. - result("iOS \(version)") - } else { - result(FlutterMethodNotImplemented) - } - }) - - return super.application(application, didFinishLaunchingWithOptions: launchOptions) - } -} -``` - -And the Flutter code gets this result back: `iOS 26.2.1` (for example). - -## Relaxed attestation for testing - -For testing purposes, you can relax the enrollment checks so that software-based attestations (such as those produced by the -Android emulator) are accepted. This relaxes the checks for software roots, expired certificates, and software security level. Add -`config.insecure_allow_relaxed_attestation` to the strategy configuration: - -```yaml -selfservice: - methods: - deviceauthn: - enabled: true - config: - insecure_allow_relaxed_attestation: true -``` - -On Ory Network, this is exposed as a toggle in the Console under **MFA → Device Authentication**, and is only available on -development projects. - -Keep the following in mind when using relaxed attestation: - -- Keys enrolled while relaxed attestation is enabled carry a 30-day expiry. Hardware-attested keys are unaffected. -- Relaxed keys are refused at login as soon as the setting is disabled, the key expires, or (on Ory Network) the project is no - longer in the `development` environment. - -:::warning - -Relaxed attestation is intended for development and testing only. Never enable it for production traffic, as it removes the -hardware-binding guarantees that this strategy relies on. - -::: - -## Reference - -### Enrollment - -1. The `DeviceAuthn` strategy is enabled in the Kratos configuration. This strategy implements the settings and login flow. This - is done so: - ```yaml - selfservice: - methods: - deviceauthn: - enabled: true - ``` -2. The client creates a new settings flow and the existing keys for the identity are in the response. The settings flow has a - field `nonce` which contains a random nonce. This is the server challenge. This value is opaque and should not be assigned - meaning. It may be a random string, or a hash of something. The important part is that it is not guessable by an attacker. -3. The client generates a private-public Elliptic Curve (EC) key pair in the TEE/TPM of the device using the server challenge, - using native mobile APIs. -4. The client completes the settings flow to enroll a new key by sending these fields: - 1. device name (human readable, picked by the user, for example `My work phone`) - 2. client key id - 3. certificate chain, which contains the signature of the server challenge, and the public key (in the leaf certificate) -5. The server: - 1. Checks that the certificate chain is valid, using Google and Apple root CAs - 2. Checks the certificate revocation lists to ensure no root/intermediate CA in the chain has been revoked - 3. Checks that the challenge sent is the same as the challenge in the database (stored in the settings flow) - 4. Checks that the key is indeed in the TEE/TPM based on the device attestation information. A key in software is rejected. A - key in the TPM (e.g. Strongbox) may warrant a higher AAL e.g. AAL3 in the future. - 5. Checks that the device is not emulated, modified in some way, etc based on the device attestation information - 6. Records the public key in the database - 7. Erases the challenge value in the database to prevent re-use - 8. Replies with 200 - -At this point the key is enrolled for the identity. - ->S: POST /self-service/settings/api (xSessionToken) - S-->>C: 200 settings flow {nonce, existing_keys} - C->>H: generateKey(nonce) - H-->>C: {client_key_id, cert_chain} - C->>S: PUT /self-service/settings?flow=... {method: deviceauthn, add: {device_name, client_key_id, cert_chain or attestation_ios}} - Note over S: Verify cert chain vs Apple/Google root CAs
Check CRLs
Match challenge to stored nonce
Reject software/emulated keys
Store pubkey, erase challenge - S-->>C: 200 updated settings flow -`} -/> - -### Proof of device enrollment - -1. When the user creates the login flow with the DeviceAuthn strategy, the client receives a server challenge. -2. Using the private key in the hardware of the device, the client signs the server challenge using ECDSA. The signature is only - emitted after a biometric/PIN prompt has been passed. The client then sends the signature to the server using the login flow - update endpoint. -3. The server: - 1. Checks that the signature is valid using the recorded public key in the database - 1. Checks that no CA in the certificate chain (when the device has been enrolled) has been revoked - 1. Erases the challenge value in the database to prevent re-use. - 1. Replies with 200 with a fresh session token and a higher AAL e.g. AAL2 or AAL3 - ->S: POST /self-service/login/api {aal: aal2, refresh: false} - S-->>C: 200 login flow {nonce} - C->>H: sign(nonce, client_key_id) - Note right of H: biometric/PIN prompt
private key never leaves hardware - H-->>C: ECDSA signature - C->>S: PUT /self-service/login?flow=... {method: deviceauthn,
client_key_id, signature} - Note over S: Verify signature with stored pubkey
Check no CA in chain is revoked
Erase challenge - S-->>C: 200 {session_token, aal: aal2} -`} -/> - -### Key Revocation - -- The user can revoke a key themselves (e.g. because the device is stolen, lost, broken, etc) using the settings flow. This action - can be done from any device (e.g. from the browser), as it is the case for other methods e.g. WebAuthn. -- An admin using the admin API can revoke all keys on a device on behalf of the user. This is useful when the user only owns one - device which is the one that should be revoked (e.g. one mobile phone) and which has been lost/stolen - -Revocation is done by removing the key from the database. - -### Device list - -The settings flow contains all keys for the identity. This is used to present the list of keys (including device name) in the UI. - -### Key lifecycle on the device - -- Creation: When the device enrollment process is started for the user -- Deletion: - - When the app is uninstalled or when the phone is reset, the mobile OSes automatically remove all keys for the app. This means - that if the device was enrolled, the public key subsists server-side but the private key does not exist anymore, so no one can - sign any challenge for this public key. This database entry is thus useless, but poses no security risks. - -### Cryptography - -The security of this design relies on a chain of trust anchored in hardware and standard cryptographic primitives. - -- Asymmetric Cryptography: ECDSA with P-256 is used for the device key pair. This is a modern, efficient, and widely supported - standard for digital signatures. It is less computationally expensive than RSA. -- Hardware-Backed Keys: Private keys are generated and stored as non-exportable within the device's Secure Enclave (iOS) or - Trusted Execution Environment (TEE)/StrongBox (Android). They cannot be accessed by the OS or any application, providing strong - protection against extraction. As much as the APIs allow it, the keys are marked as requiring user authentication (the phone is - unlocked) and a biometrics/PIN prompt. -- Hashing: SHA-256 is used for generating nonces and hashing challenges, providing standard collision resistance. -- Certificate Chains: X.509 certificates are used to establish the chain of trust. The device's attestation is signed by a key - that is, in turn, certified by a platform authority (Apple or Google), ensuring the attestation's authenticity. -- No configurability: Intentionally, for simplicity, performance, auditability, and to avoid downgrade attacks, all cryptographic - primitives are fixed. - -### Attack Surface and Mitigations - -- Man-in-the-Middle (MitM) Attack - - Threat: An attacker intercepts and tries to modify the communication between the client and server. - - Mitigation: All communication occurs over TLS, encrypting the channel. More importantly, the core payloads (attestation and - login signatures) are themselves digitally signed using the hardware-bound key. Any tampering would invalidate the signature, - causing the server to reject the request. -- Replay Attacks - - Threat: An attacker captures a valid attestation or login payload and "replays" it to the server at a later time to gain - access. - - Mitigation: The server generates a unique, single-use cryptographic challenge for every new enrollment or login attempt. This - challenge is embedded in the certificate chain. The server verifies that the challenge in the payload is the exact one it - issued for that specific session and reject any duplicates or expired challenges. -- Emulation & Software-Based Attacks - - Threat: An attacker attempts to enroll a software-based "device" (e.g., an emulator, a script) by faking an attestation. - - Mitigation: This is the central problem that hardware attestation solves. The server verifies the entire certificate chain of - the attestation object up to a trusted root CA (Apple or Google). Only genuine hardware can obtain a valid certificate chain. - The server also inspects attestation flags (e.g., Android's `attestationSecurityLevel`) to explicitly reject any keys that are - not certified as hardware-backed. -- Physical Attacks & Key Extraction - - Threat: An attacker with physical possession of the device attempts to extract the private signing key from memory. - - Mitigation: Keys are generated as non-exportable inside the hardware security module (Secure Enclave/TEE). This is a physical - countermeasure that makes it computationally infeasible to extract key material, even with advanced hardware probing - techniques. -- Compromised OS (Rooting/Jailbreaking) - - Threat: An attacker gains root access to the device's operating system. - - Mitigation: The attestation object contains signals about the integrity of the operating system. Android's attestation - includes `VerifiedBootState`, which indicates if the bootloader is locked and the OS is unmodified. The server can enforce a - policy to only accept attestations from devices in a secure state. -- Cross-App/Cross-Site Attacks - - Threat: An attacker tricks a user into generating an attestation for a malicious app that is then used to attack the service. - - Mitigation: The attestation object includes an identifier for the application that requested it. On iOS, the `authData` - contains the `rpIdHash` (a hash of the App ID). The server can verify that this hash matches its own app's identifier to - ensure the attestation originated from the legitimate, code-signed application. -- Malicious App Key Theft/Usage - - Threat: A different, malicious app installed on the same device attempts to access and use the private key generated by the - legitimate app to impersonate the user. - - Mitigation: This is prevented by the fundamental application sandbox security model of both iOS and Android. Keys generated in - the hardware-backed key store are cryptographically bound to the application identifier that created them. The operating - system and the secure hardware enforce this separation, making it impossible for "App B" to access, request, or use a key - generated by "App A". -- Malware and Keyloggers on a Compromised Device - - Threat: Malware, such as a keylogger, screen scraper, or accessibility service exploit, is active on the user's device and - attempts to intercept credentials. - - Mitigation: This design is highly resistant to such attacks. The entire flow is passwordless, meaning there is no "typeable" - secret for a keylogger to capture. The core secret (the private key) never leaves the secure hardware. The user authorizes its - use via a biometric prompt, which is managed by a privileged part of the OS, isolated from the application space where malware - would reside. A keylogger can neither intercept the biometric data nor the signing operation itself. -- Device Backup, Restore, and Cloning - - Threat: An attacker steals a user's cloud backup (e.g., iCloud or Google One) and restores it to a new device they control, - hoping to clone the trusted device and its keys. - - Mitigation: This is mitigated by the non-exportable property of hardware-backed keys. While application data and metadata may - be backed up and restored, the actual private key material never leaves the Secure Enclave or TEE. When the app is restored on - a new device, the reference to the old key will be invalid, effectively breaking the binding and forcing the user to perform a - new enrollment. Furthermore, resetting the device automatically erases all keys in the TEE/TPM. -- Biometric System Bypass - - Threat: An attacker with physical possession of the device attempts to bypass biometric authentication (e.g., using a lifted - fingerprint, high-resolution photo, or 3D mask). - - Mitigation: The design relies on the platform-level biometric security. Since the hardware key is only unlocked for signing - after the hardware confirms a match, the attacker must defeat the hardware manufacturer's physical anti-spoofing technologies. -- Server-Side Compromise (Database Leak) - - Threat: An attacker breaches the server and steals the database containing public keys and device IDs for all enrolled - devices. - - Mitigation: Because this is an asymmetric system, the public keys are useless for authentication without the corresponding - private keys. Even with a full database leak, the attacker cannot impersonate users because they cannot sign the login - challenges. -- Server-Side Compromise (CA Trust Anchor) - - Threat: An attacker gains enough server access to modify the list of trusted Root CAs, allowing them to accept attestations - from a rogue CA they control. - - Mitigation: The Root CA certificates for Apple and Google are hard-coded within the server-side application logic rather than - relying on the general OS trust store. This prevents an attacker from using a compromised system-wide trust store to validate - fraudulent device attestations. However, if the attacker can modify the server executable, all bets are off, because they can - modify the in-memory root CAs or bypass the validation logic entirely. -- UI Redressing / Overlay Attack (Android) - - Threat: A malicious app with the "Draw over other apps" permission creates a transparent overlay on top of your app. When the - user thinks they are clicking "Enroll Device" or approving a "Transaction Signing" prompt, they are actually clicking through - a malicious flow hidden beneath. - - Mitigation: - - iOS: Inherently protected by the OS (overlays are not permitted over other apps). - - Android: We use the `setFilterTouchesWhenObscured(true)` flag on sensitive UI components. This tells the Android OS to - discard touch events if the window is obscured by another visible window. See - [tapjacking](https://developer.android.com/privacy-and-security/risks/tapjacking). -- Dependency / Supply Chain Attack - - Threat: An attacker compromises the Mobile SDK or a dependency. They inject code that leaks the challenge, or subtly alters - device attestation. - - Mitigation: - - Minimized dependencies - - Automated dependency scanning - - Certificate pinning: The Ory server CA can be pinned in the mobile application/SDK to ensure the device is talking to the - legitimate server. - - TLS & URL whitelisting: Both Android and iOS allow for URL whitelisting to avoid attacker controlled servers from being - contacted. - - Signed Device Information: The TEE/TPM on the device signs the device information. Using Apple/Google root CAs, the server - checks that this information, e.g. the application id, has not been altered. -- Attestation Misbinding Attack - - Threat: The attack manages to leak the challenge meant for another user (e.g. due to a supply chain attack in the mobile app - code), they sign the challenge with the attacker device, and they submit that to the server before the legitimate user can, in - order to register the attacker device for the other user account. - - Mitigation: - - Challenge bound to the identity id: The challenge is bound to the identity in the database (stored in the same row). Since - the identity is detected from the session token, an attacker cannot tamper with the identity id (unless they steal the - session token, at which point they _are_ the user, from the server perspective). - -### Comparison with WebAuthn and Passkeys - -It is useful to compare this custom implementation with the FIDO WebAuthn standard and the user-facing concept of Passkeys. While -they share core cryptographic principles, their goals and scope are fundamentally different. - -#### Similarities - -- Core Cryptography: Both approaches are built on public-key cryptography (typically ECDSA), and use a challenge-response protocol - -#### Key Differences - -- Standard vs. proprietary: - - WebAuthn/passkeys: An open, interoperable standard from the W3C and FIDO Alliance, designed to work across different websites, - apps, browsers, and operating systems. - - This Design: A proprietary implementation tailored specifically for Ory's native application and server. It is not intended to - be interoperable with any other system. However the design is based on building blocks that are fully open and standardized: - PKI, TPM 2.0, ASN1, iOS & Android device attestation, etc. -- Goal: Device Binding vs. synced credentials: - - WebAuthn/passkeys: The primary goal is to create a convenient and portable user credential (a Passkey). Passkeys are often - syncable via a cloud service (like iCloud Keychain or Google Password Manager), allowing a user who enrolls on their phone to - seamlessly sign in on their laptop without re-enrolling. - - This design: The primary goal is strict device binding. We are proving that a specific, individual piece of hardware is - authorized. The key is explicitly non-exportable and bound to a single installation of an app on a single device. It - physically cannot be synced or used elsewhere. -- Role of attestation: - - WebAuthn/passkeys: Attestation is an optional feature. While a server can request it to verify the properties of an - authenticator, many services skip it in favor of a simpler user experience. The focus is on proving possession of the key, not - on scrutinizing the device itself. - - This design: Attestation is mandatory and central to the entire security model. The main purpose of the enrollment ceremony is - for the server to validate the device's hardware and software integrity. - -### Further reading - -- [Android](https://developer.android.com/privacy-and-security/security-key-attestation) -- iOS/iPadOS: [1](https://developer.apple.com/documentation/devicecheck/validating-apps-that-connect-to-your-server) and - [2](https://developer.apple.com/documentation/devicecheck/establishing-your-app-s-integrity) diff --git a/docs/network/kratos/passwordless/deviceauthn/android.mdx b/docs/network/kratos/passwordless/deviceauthn/android.mdx new file mode 100644 index 0000000000..8bacfdb41e --- /dev/null +++ b/docs/network/kratos/passwordless/deviceauthn/android.mdx @@ -0,0 +1,15 @@ +--- +id: android +title: Device authentication on Android +sidebar_label: Android +--- + + + + + +```mdx-code-block +import MyPartial from "@site/src/components/Shared/kratos/passwordless/deviceauthn/android.mdx" + + +``` diff --git a/docs/network/kratos/passwordless/deviceauthn/flutter.mdx b/docs/network/kratos/passwordless/deviceauthn/flutter.mdx new file mode 100644 index 0000000000..d1788bd8bb --- /dev/null +++ b/docs/network/kratos/passwordless/deviceauthn/flutter.mdx @@ -0,0 +1,15 @@ +--- +id: flutter +title: Device authentication in Dart/Flutter +sidebar_label: Dart/Flutter +--- + + + + + +```mdx-code-block +import MyPartial from "@site/src/components/Shared/kratos/passwordless/deviceauthn/flutter.mdx" + + +``` diff --git a/docs/network/kratos/passwordless/deviceauthn/index.mdx b/docs/network/kratos/passwordless/deviceauthn/index.mdx new file mode 100644 index 0000000000..c8311e5a72 --- /dev/null +++ b/docs/network/kratos/passwordless/deviceauthn/index.mdx @@ -0,0 +1,16 @@ +--- +id: index +title: Device authentication +sidebar_label: Overview +slug: /network/kratos/passwordless/deviceauthn +--- + + + + + +```mdx-code-block +import MyPartial from "@site/src/components/Shared/kratos/passwordless/deviceauthn/index.mdx" + + +``` diff --git a/docs/network/kratos/passwordless/deviceauthn/ios.mdx b/docs/network/kratos/passwordless/deviceauthn/ios.mdx new file mode 100644 index 0000000000..e9c64682cc --- /dev/null +++ b/docs/network/kratos/passwordless/deviceauthn/ios.mdx @@ -0,0 +1,15 @@ +--- +id: ios +title: Device authentication on iOS +sidebar_label: iOS +--- + + + + + +```mdx-code-block +import MyPartial from "@site/src/components/Shared/kratos/passwordless/deviceauthn/ios.mdx" + + +``` diff --git a/docs/oel/kratos/passwordless/deviceauthn/android.mdx b/docs/oel/kratos/passwordless/deviceauthn/android.mdx new file mode 100644 index 0000000000..8bacfdb41e --- /dev/null +++ b/docs/oel/kratos/passwordless/deviceauthn/android.mdx @@ -0,0 +1,15 @@ +--- +id: android +title: Device authentication on Android +sidebar_label: Android +--- + + + + + +```mdx-code-block +import MyPartial from "@site/src/components/Shared/kratos/passwordless/deviceauthn/android.mdx" + + +``` diff --git a/docs/oel/kratos/passwordless/deviceauthn/flutter.mdx b/docs/oel/kratos/passwordless/deviceauthn/flutter.mdx new file mode 100644 index 0000000000..d1788bd8bb --- /dev/null +++ b/docs/oel/kratos/passwordless/deviceauthn/flutter.mdx @@ -0,0 +1,15 @@ +--- +id: flutter +title: Device authentication in Dart/Flutter +sidebar_label: Dart/Flutter +--- + + + + + +```mdx-code-block +import MyPartial from "@site/src/components/Shared/kratos/passwordless/deviceauthn/flutter.mdx" + + +``` diff --git a/docs/oel/kratos/passwordless/deviceauthn/index.mdx b/docs/oel/kratos/passwordless/deviceauthn/index.mdx new file mode 100644 index 0000000000..9825f385b3 --- /dev/null +++ b/docs/oel/kratos/passwordless/deviceauthn/index.mdx @@ -0,0 +1,16 @@ +--- +id: index +title: Device authentication +sidebar_label: Overview +slug: /oel/kratos/passwordless/deviceauthn +--- + + + + + +```mdx-code-block +import MyPartial from "@site/src/components/Shared/kratos/passwordless/deviceauthn/index.mdx" + + +``` diff --git a/docs/oel/kratos/passwordless/deviceauthn/ios.mdx b/docs/oel/kratos/passwordless/deviceauthn/ios.mdx new file mode 100644 index 0000000000..e9c64682cc --- /dev/null +++ b/docs/oel/kratos/passwordless/deviceauthn/ios.mdx @@ -0,0 +1,15 @@ +--- +id: ios +title: Device authentication on iOS +sidebar_label: iOS +--- + + + + + +```mdx-code-block +import MyPartial from "@site/src/components/Shared/kratos/passwordless/deviceauthn/ios.mdx" + + +``` diff --git a/sidebars-network.ts b/sidebars-network.ts index 180cda8fc3..b33c431f28 100644 --- a/sidebars-network.ts +++ b/sidebars-network.ts @@ -202,7 +202,16 @@ const networkSidebar = [ "kratos/passwordless/one-time-code", "kratos/passwordless/passkeys", "kratos/passwordless/passkeys-mobile", - "kratos/passwordless/deviceauthn", + { + type: "category", + label: "Device authentication", + items: [ + "network/kratos/passwordless/deviceauthn/index", + "network/kratos/passwordless/deviceauthn/android", + "network/kratos/passwordless/deviceauthn/ios", + "network/kratos/passwordless/deviceauthn/flutter", + ], + }, "kratos/organizations/organizations", "kratos/emails-sms/custom-email-templates", ], diff --git a/sidebars-oel.ts b/sidebars-oel.ts index 914904c89c..edc7562a6b 100644 --- a/sidebars-oel.ts +++ b/sidebars-oel.ts @@ -72,6 +72,22 @@ const oelSidebar = [ "kratos/reference/configuration-editor", ], }, + { + type: "category", + label: "Authentication", + items: [ + { + type: "category", + label: "Device authentication", + items: [ + "oel/kratos/passwordless/deviceauthn/index", + "oel/kratos/passwordless/deviceauthn/android", + "oel/kratos/passwordless/deviceauthn/ios", + "oel/kratos/passwordless/deviceauthn/flutter", + ], + }, + ], + }, { type: "category", label: "Guides", @@ -87,7 +103,6 @@ const oelSidebar = [ "kratos/guides/https-tls", "kratos/guides/hosting-own-have-i-been-pwned-api", "kratos/guides/secret-key-rotation", - "kratos/passwordless/deviceauthn", { type: "category", label: "Troubleshooting", diff --git a/src/components/SameDeploymentLink.tsx b/src/components/SameDeploymentLink.tsx index e52d9cdda0..4e60c6fee9 100644 --- a/src/components/SameDeploymentLink.tsx +++ b/src/components/SameDeploymentLink.tsx @@ -61,7 +61,7 @@ export default function SameDeploymentLink({ plugin?.versions.find((v) => v.isLast) ?? plugin?.versions[0] if (!version?.docs?.length) return - const docId = stripLeadingSlashes(href) + const docId = stripLeadingSlashes(href).split("#")[0] const found = version.docs.some((d) => d.id === docId) if (!found) { const overrideMsg = overrideForCurrentDeployment diff --git a/src/components/Shared/kratos/passwordless/deviceauthn/android.mdx b/src/components/Shared/kratos/passwordless/deviceauthn/android.mdx new file mode 100644 index 0000000000..49960a1469 --- /dev/null +++ b/src/components/Shared/kratos/passwordless/deviceauthn/android.mdx @@ -0,0 +1,805 @@ +We recommend using the Ory Java SDK to communicate with Kratos, although this is +not required. Code snippets here use this SDK, and are written in Kotlin. + +Since Device Binding only is supported on native devices (not in the browser), +all corresponding API calls should be done using the endpoints for native apps, +to avoid having to pass cookies around manually. + +## Prerequisites + +The [second-factor guide](#second-factor-device-binding) below targets Android +SDK 24 (Android 7.0) or newer; the signing and sealing keys use StrongBox where +the device offers it (API 28+) and fall back to the TEE otherwise. The +first-factor PIN path additionally needs: + +- HPKE for the one-time transport channel: + [BouncyCastle](https://www.bouncycastle.org/) + (`org.bouncycastle:bcprov-jdk18on`). The suite is fixed to DHKEM(X25519, + HKDF-SHA256) / HKDF-SHA256 / AES-128-GCM with the `client_key_id` string as + AAD. Tink's hybrid encryption API cannot pass that AAD, so use BouncyCastle's + HPKE directly — do not substitute Tink. +- Argon2id for the PIN key derivation: + [`org.signal:argon2`](https://github.com/signalapp/Argon2) (used below) or a + libsodium binding. +- [AndroidX Biometric](https://developer.android.com/jetpack/androidx/releases/biometric) + (`androidx.biometric:biometric`) only for the biometric path — the PIN path + never prompts, so it does not need it. + +## Second-factor device binding + +1. Ensure that the `DeviceAuthn` strategy is enabled in the Kratos + configuration. This strategy implements the settings and login flow. This is + done so: + ```yaml + selfservice: + methods: + deviceauthn: + enabled: true + ``` +1. Implement a runtime check for the Android version. If is lower than 24, + Device Binding may not be used, and a fallback should be found, for example + using passkeys. +1. This guide covers the second-factor setup: the UI should only show existing + Device Binding keys and related buttons (e.g. to add a key) if the user is + currently logged in. This can be confirmed with a `whoami` call. For + first-factor PIN or biometric login, see + [First factor with PIN](#first-factor-with-pin). +1. Create a + [settings flow for native apps](https://www.ory.com/docs/reference/api#tag/frontend/operation/updateSettingsFlow). + The response contains the list of existing Device Binding keys. +1. To delete an existing key, + [complete the settings flow](https://www.ory.com/docs/reference/api#tag/frontend/operation/updateSettingsFlow) + with this payload: + + ```json + { + "delete": { + "client_key_id": "9c62918cbbef2e94c3a10238bd57ab196e3a2caae1a44f28b02f2d1a72b1e59c" + }, + "method": "deviceauthn" + } + ``` + + Or using the SDK: + + ```kotlin + val clientKeyIdToDelete = "..." + + val apiClient = Configuration.getDefaultApiClient() + val apiInstance = FrontendApi(apiClient) + val body = UpdateSettingsFlowBody() + val method = UpdateSettingsFlowWithDeviceAuthnMethod() + method.method = "deviceauthn" + method.delete = UpdateSettingsFlowWithDeviceAuthnMethodDelete() + method.delete!!.clientKeyId = clientKeyIdToDelete + body.actualInstance = method + val updatedFlow = apiInstance.updateSettingsFlow(settingsFlow?.id, body, sessionToken, "") + ``` + + Once the key has been deleted server-side, it is fine (although not required) + to also delete it on the device using the KeyStore API. + +1. To add a new key, + [complete the settings flow](https://www.ory.com/docs/reference/api#tag/frontend/operation/updateSettingsFlow) + with this payload: + + ```json + { + "method": "deviceauthn", + "add": { + "device_name": "Pixel 9", + "certificate_chain_android": ["...", "...", "..."] + } + } + ``` + + Or using the SDK: + + ```kotlin + val apiClient = Configuration.getDefaultApiClient() + val withStrongbox = false // Better: Detect the presence of StrongBox at runtime. + + val keyAlias = UUID.randomUUID().toString() + val nonce = extractNonceFromUiNodes(settingsFlow?.ui?.nodes ?: emptyList()) + if (nonce == null) { + throw Exception("No nonce found in UI. Is DeviceAuthn enabled server-side?") + } + val keyCertChain = Api.create().createKeyPair(keyAlias, nonce, withStrongbox) + val apiInstance = FrontendApi(apiClient) + val body = UpdateSettingsFlowBody() + val method = UpdateSettingsFlowWithDeviceAuthnMethod() + method.method = "deviceauthn" + method.add = UpdateSettingsFlowWithDeviceAuthnMethodAdd() + method.add!!.deviceName = "My work phone" + method.add!!.certificateChainAndroid = keyCertChain.map { it.encoded }.toList() + body.actualInstance = method + val updatedFlow = apiInstance.updateSettingsFlow(settingsFlow?.id, body, sessionToken, "") + + // The server assigns the key's client_key_id: the lowercase-hex SHA-256 of + // the public key in SubjectPublicKeyInfo (DER) form. Derive it locally and + // store it with the key alias; later login and delete calls address the key + // by this fingerprint. + val spki = keyCertChain.first().publicKey.encoded + val clientKeyId = MessageDigest.getInstance("SHA-256") + .digest(spki) + .joinToString("") { "%02x".format(it.toInt() and 0xff) } + ``` + + Once a key is created, the KeyStore APIs can be used to list all keys, query + a key using its alias, etc. However we recommend that the application keeps + track of the keys it created — including the mapping between the local key + alias and the server-assigned `client_key_id` — to know which keys can be + used on this device, compared to keys that belong to the same identity but + reside on other devices. Note that there is a maximum number of keys that can + be created for an identity, and there is no point to create multiple keys for + the same user on the same device, even though the server allows it. + +1. To use a key to step-up the AAL, + [complete the login flow](https://www.ory.com/docs/reference/api#tag/frontend/operation/updateLoginFlow) + with this payload: + + ```json + { + "signature": "...", + "client_key_id": "9c62918cbbef2e94c3a10238bd57ab196e3a2caae1a44f28b02f2d1a72b1e59c", + "method": "deviceauthn" + } + ``` + + Or using the SDK: + + ```kotlin + val keyAlias = "..." // The local KeyStore alias, used to sign. + val clientKeyId = "..." // The server-assigned key fingerprint. + val nonce = extractNonceFromUiNodes(flow?.ui?.nodes ?: emptyList()) + if (nonce == null) { + throw Exception("No nonce found in UI") + } + + val updateMethod = UpdateLoginFlowWithDeviceAuthnMethod() + updateMethod.clientKeyId = clientKeyId + updateMethod.method = "deviceauthn" + updateMethod.signature = Api.create().launchBiometricSigner( + context as FragmentActivity, + keyAlias, + nonce, + "Confirm", + "Cancel" + ) + + val updateBody = UpdateLoginFlowBody() + updateBody.actualInstance = updateMethod + + val apiClient = Configuration.getDefaultApiClient() + + withContext(Dispatchers.IO) { + val apiInstance = FrontendApi(apiClient) + val res = apiInstance.updateLoginFlow( + /* flow = */ flow.id, + /* updateLoginFlowBody = */ updateBody, + /* xSessionToken = */ sessionToken, + /* cookie = */ "" + ) + } + ``` + +There are two Keystore calls required: one to create the key and one to use it +to sign: + +```kotlin +package com.ory.sdk + +import android.os.Build +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Log +import androidx.biometric.BiometricPrompt +import androidx.core.content.ContextCompat +import androidx.fragment.app.FragmentActivity +import kotlinx.coroutines.suspendCancellableCoroutine +import java.security.KeyPairGenerator +import java.security.KeyStore +import java.security.PrivateKey +import java.security.Signature +import java.security.cert.Certificate +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +private const val TAG = "com.ory.sdk" + +public interface Api { + public companion object { + @JvmStatic + public fun create(): Api { + return OryApi() + } + } + + public fun createKeyPair( + keyAlias: String, + challenge: ByteArray, + withStrongBox: Boolean + ): List + + public suspend fun launchBiometricSigner( + activity: FragmentActivity, + keyAlias: String, + challenge: ByteArray, + title: String, + negativeButtonText: String, + ): ByteArray +} + +internal class OryApi : Api { + private val keyStore: KeyStore by lazy { + KeyStore.getInstance("AndroidKeyStore").apply { + load(null) + } + } + + private fun getCertificateChain(keyAlias: String): List { + return keyStore.getCertificateChain(keyAlias).toList() + } + + + override fun createKeyPair( + keyAlias: String, + challenge: ByteArray, + withStrongBox: Boolean, + ): List { + val kpg: KeyPairGenerator = KeyPairGenerator.getInstance( + KeyProperties.KEY_ALGORITHM_EC, + "AndroidKeyStore" + ) + + val parameterSpec: KeyGenParameterSpec = KeyGenParameterSpec.Builder( + keyAlias, + KeyProperties.PURPOSE_SIGN + ).run { + setDigests(KeyProperties.DIGEST_SHA256) + if (Build.VERSION.SDK_INT >= 24) { + setAttestationChallenge(challenge) + } + + if (Build.VERSION.SDK_INT >= 28) { + setIsStrongBoxBacked(withStrongBox) + } + // Require biometric/PIN for every single use. + setUserAuthenticationRequired(true) + // TODO: Should we use: setInvalidatedByBiometricEnrollment(true) ? + build() + } + + kpg.initialize(parameterSpec) + kpg.generateKeyPair() + Log.i(TAG, "created keypair: alias=$keyAlias") + + return getCertificateChain(keyAlias) + } + + + /** + * Provides an uninitialized Signature object for the App to use in BiometricPrompt. + */ + private fun getSignatureObject(keyAlias: String): Signature { + val privateKey = keyStore.getKey(keyAlias, null) as? PrivateKey + + return Signature.getInstance("SHA256withECDSA").apply { + initSign(privateKey) + } + } + + override suspend fun launchBiometricSigner( + activity: FragmentActivity, + keyAlias: String, + challenge: ByteArray, + title: String, + negativeButtonText: String, + ): ByteArray = suspendCancellableCoroutine { continuation -> + val executor = ContextCompat.getMainExecutor(activity) + + val biometricPrompt = BiometricPrompt( + activity, executor, + object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + try { + val signature = result.cryptoObject?.signature + if (signature != null) { + signature.update(challenge) + continuation.resume(signature.sign()) + } else { + continuation.resumeWithException(Exception("Signature object is null")) + } + } catch (e: Exception) { + continuation.resumeWithException(e) + } + } + + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + // Wrap the error in a custom Exception or handle specific error codes + continuation.resumeWithException(Exception(errString.toString())) + } + + override fun onAuthenticationFailed() { + // Note: onAuthenticationFailed is called for finger-read errors + // but doesn't dismiss the prompt; we usually wait for Error or Success. + } + } + ) + + val promptInfo = BiometricPrompt.PromptInfo.Builder() + .setTitle(title) + .setNegativeButtonText(negativeButtonText) + .build() + + // Cancel the biometric prompt if the coroutine is canceled + continuation.invokeOnCancellation { + biometricPrompt.cancelAuthentication() + } + + biometricPrompt.authenticate( + promptInfo, + BiometricPrompt.CryptoObject(getSignatureObject(keyAlias)) + ) + } +} + +``` + +## First factor with PIN + +:::warning + +The first-factor signing key is attested over +`SHA256(nonce ‖ transport_public_key)`, **not** the bare nonce used by the +second-factor guide above. Using the bare nonce here fails with +`Unable to validate the key attestation: wrong challenge`. + +::: + +This section implements PIN enrollment, first-factor login, PIN change, and +secret rotation on Android. It builds on the hardware-attested signing key from +the [second-factor guide](#second-factor-device-binding) above and adds the +transport, sealing, and PIN layers. + +- Read + + Client implementation requirements + + together with this section — the comments in the code below are normative and restate + those rules inline. + +### Reference implementation + +This listing is one complete recipe: nonce decoding, the enrollment and rotation +ceremony (transport key, attestation challenge, sealed secret), the PIN vault +(Android Keystore sealing key, Argon2id, AES-CTR, seal and unseal), the +`client_key_id` derivation, the PIN proof, and the login signature. The +`PinCeremony` is a fresh object per ceremony; the transport key never outlives +it. + +```kotlin +import android.os.Build +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyInfo +import android.security.keystore.KeyProperties +import android.security.keystore.StrongBoxUnavailableException +import android.util.Base64 +import org.bouncycastle.crypto.AsymmetricCipherKeyPair +import org.bouncycastle.crypto.hpke.HPKE +import org.bouncycastle.crypto.params.X25519PublicKeyParameters +import org.json.JSONObject +import org.signal.argon2.Argon2 +import org.signal.argon2.MemoryCost +import org.signal.argon2.Type +import org.signal.argon2.Version +import java.security.KeyPairGenerator +import java.security.KeyStore +import java.security.MessageDigest +import java.security.PrivateKey +import java.security.SecureRandom +import java.security.Signature +import java.security.cert.Certificate +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.Mac +import javax.crypto.SecretKey +import javax.crypto.SecretKeyFactory +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec + +object DeviceAuthnPin { + private const val PIN_PROOF_DOMAIN = "ory/deviceauthn/pin-proof/v1" + + private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + + /// The flow's hidden deviceauthn_nonce node value: + /// base64(JSON {"nonce": ""}) → raw nonce bytes. + fun decodeNonce(nodeValue: String): ByteArray { + val json = String(Base64.decode(nodeValue, Base64.DEFAULT), Charsets.UTF_8) + return Base64.decode(JSONObject(json).getString("nonce"), Base64.DEFAULT) + } + + fun sha256(vararg parts: ByteArray): ByteArray = + MessageDigest.getInstance("SHA-256").run { + parts.forEach { update(it) } + digest() + } + + /// client_key_id is the key's deterministic fingerprint: the lowercase-hex + /// SHA-256 of the device public key in SubjectPublicKeyInfo DER form — + /// which is exactly PublicKey.getEncoded() on Android. + fun clientKeyId(signingKeyAlias: String): String = + sha256(keyStore.getCertificate(signingKeyAlias).publicKey.encoded) + .joinToString("") { "%02x".format(it.toInt() and 0xff) } + + /// Creates the attested signing key for a PIN enrollment. The challenge + /// must be SHA256(nonce ‖ t_pub) — NOT the bare nonce (that is the + /// second-factor device-binding form). No setUserAuthenticationRequired: + /// the PIN is the gate, the key must sign without a platform prompt. + fun createPinSigningKey(alias: String, nonce: ByteArray, transportPublicKey: ByteArray): List { + val challenge = sha256(nonce, transportPublicKey) + val kpg = KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_EC, "AndroidKeyStore") + val spec = KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_SIGN).run { + setDigests(KeyProperties.DIGEST_SHA256) + setAttestationChallenge(challenge) + if (Build.VERSION.SDK_INT >= 28) setIsStrongBoxBacked(true) + build() + } + return try { + kpg.initialize(spec) + kpg.generateKeyPair() + keyStore.getCertificateChain(alias).toList() + } catch (e: StrongBoxUnavailableException) { + // TEE fallback is fine for the SIGNING key; software is not — the + // server rejects software attestations unless relaxed attestation + // is enabled for testing. + val teeSpec = KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_SIGN).run { + setDigests(KeyProperties.DIGEST_SHA256) + setAttestationChallenge(challenge) + build() + } + kpg.initialize(teeSpec) + kpg.generateKeyPair() + keyStore.getCertificateChain(alias).toList() + } + } + + /// Signs a login or rotation challenge. Login: the raw nonce. Rotation: + /// the raw concatenation nonce ‖ t_pub (not pre-hashed). + fun sign(alias: String, challenge: ByteArray): ByteArray = + Signature.getInstance("SHA256withECDSA").run { + initSign(keyStore.getKey(alias, null) as PrivateKey) + update(challenge) + sign() + } + + /// pin_proof = HMAC-SHA256(pin_secret, domain ‖ client_key_id ‖ nonce). + fun pinProof(pinSecret: ByteArray, clientKeyId: String, nonce: ByteArray): ByteArray = + Mac.getInstance("HmacSHA256").run { + init(SecretKeySpec(pinSecret, "HmacSHA256")) + update(PIN_PROOF_DOMAIN.toByteArray()) + update(clientKeyId.toByteArray()) + update(nonce) + doFinal() + } +} + +/// One PIN enrollment (or secret rotation) ceremony: holds the ephemeral HPKE +/// transport keypair. Create a fresh instance per ceremony; never persist or +/// reuse the transport key. +class PinCeremony { + // Suite (fixed, non-negotiable): DHKEM(X25519, HKDF-SHA256) / HKDF-SHA256 / AES-128-GCM. + private val hpke = HPKE(HPKE.mode_base, HPKE.kem_X25519_SHA256, HPKE.kdf_HKDF_SHA256, HPKE.aead_AES_GCM128) + private val hpkeInfo = "ory/deviceauthn/pin-secret/v1".toByteArray() + private val transportKeyPair: AsymmetricCipherKeyPair = hpke.generatePrivateKey() + + /// Raw 32 bytes for the transport_public_key payload field (base64-encode it). + val transportPublicKey: ByteArray = + (transportKeyPair.public as X25519PublicKeyParameters).encoded + + /// Opens the one-time sealed secret from the response's continue_with item. + /// AAD is the client_key_id string. + fun openSealedSecret(enc: ByteArray, ciphertext: ByteArray, clientKeyId: String): ByteArray { + val ctx = hpke.setupBaseR(enc, transportKeyPair, hpkeInfo) + return ctx.open(clientKeyId.toByteArray(), ciphertext) + } +} + +/// Local artifacts persisted after sealing. None are secret on their own. +/// Android Keystore keys (and app storage) are purged on uninstall. +data class PinArtifacts( + val version: Int, // format version of this recipe + val clientKeyId: String, + val signingKeyAlias: String, + val sealingKeyAlias: String, + val salt: ByteArray, // Argon2id salt — fresh on EVERY seal + val ctrIv: ByteArray, // AES-CTR IV — fresh on EVERY seal + val gcmIv: ByteArray, // outer-layer GCM IV + val memoryCostMiB: Int, // Argon2id parameters chosen at enrollment + val iterations: Int, + val sealed: ByteArray, // KeystoreGCM(AES-CTR(pinKey, pin_secret)) +) + +object PinVault { + private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + private val random = SecureRandom() + + /// Creates the sealing key: AES-256-GCM, StrongBox where available, TEE + /// otherwise. Fails closed: after generation the key is verified to be + /// hardware-backed (TEE or StrongBox); a software key is deleted and + /// enrollment refused. No setUserAuthenticationRequired (the PIN is the + /// gate); setUnlockedDeviceRequired so a locked stolen device cannot unseal. + fun createSealingKey(alias: String) { + fun spec(strongBox: Boolean) = + KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT).run { + setBlockModes(KeyProperties.BLOCK_MODE_GCM) + setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + setKeySize(256) + setRandomizedEncryptionRequired(false) // we manage the IV in the artifacts + if (Build.VERSION.SDK_INT >= 28) { + setUnlockedDeviceRequired(true) + setIsStrongBoxBacked(strongBox) + } + build() + } + val kg = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore") + try { + kg.init(spec(strongBox = true)) + kg.generateKey() + } catch (e: StrongBoxUnavailableException) { + kg.init(spec(strongBox = false)) + kg.generateKey() + } + requireHardwareBacked(alias) + } + + /// Throws unless the key lives in a TEE or StrongBox. The sealing key has + /// no server-side attestation backstop — this local check is the only + /// enforcement of the "hardware or refuse" rule. + private fun requireHardwareBacked(alias: String) { + val key = keyStore.getKey(alias, null) as SecretKey + val factory = SecretKeyFactory.getInstance(key.algorithm, "AndroidKeyStore") + val info = factory.getKeySpec(key, KeyInfo::class.java) as KeyInfo + val hardwareBacked = if (Build.VERSION.SDK_INT >= 31) { + info.securityLevel == KeyProperties.SECURITY_LEVEL_TRUSTED_ENVIRONMENT || + info.securityLevel == KeyProperties.SECURITY_LEVEL_STRONGBOX + } else { + @Suppress("DEPRECATION") + info.isInsideSecureHardware + } + if (!hardwareBacked) { + keyStore.deleteEntry(alias) + throw IllegalStateException("no hardware-backed keystore; refusing PIN enrollment") + } + } + + private fun argon2id(pin: ByteArray, salt: ByteArray, memoryCostMiB: Int, iterations: Int): ByteArray = + Argon2.Builder(Version.V13) + .type(Type.Argon2id) + .memoryCost(MemoryCost.MiB(memoryCostMiB)) + .parallelism(4) + .iterations(iterations) + .hashLength(32) + .build() + .hash(pin, salt) + .hash + + /// Unauthenticated AES-CTR — encrypt and decrypt are the same operation. + /// Deliberately no MAC, checksum, or format marker: a wrong PIN must yield + /// plausible garbage, never a locally detectable failure. + private fun ctr(key: ByteArray, iv: ByteArray, data: ByteArray): ByteArray = + Cipher.getInstance("AES/CTR/NoPadding").run { + init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), IvParameterSpec(iv)) + doFinal(data) + } + + /// Seals pin_secret under the PIN. Generates a FRESH salt and IV — reusing + /// either across seals leaks the secret via CTR keystream reuse. Zeroizes + /// the PIN, the derived key, and the secret before returning. + fun seal( + pinSecret: ByteArray, pin: ByteArray, clientKeyId: String, + signingKeyAlias: String, sealingKeyAlias: String, + memoryCostMiB: Int = 64, iterations: Int = 3, + ): PinArtifacts { + val salt = ByteArray(16).also(random::nextBytes) + val ctrIv = ByteArray(16).also(random::nextBytes) + val gcmIv = ByteArray(12).also(random::nextBytes) + val pinKey = argon2id(pin, salt, memoryCostMiB, iterations) + try { + val inner = ctr(pinKey, ctrIv, pinSecret) + val sealingKey = keyStore.getKey(sealingKeyAlias, null) as SecretKey + val sealed = Cipher.getInstance("AES/GCM/NoPadding").run { + init(Cipher.ENCRYPT_MODE, sealingKey, GCMParameterSpec(128, gcmIv)) + doFinal(inner) + } + return PinArtifacts( + version = 1, clientKeyId = clientKeyId, + signingKeyAlias = signingKeyAlias, sealingKeyAlias = sealingKeyAlias, + salt = salt, ctrIv = ctrIv, gcmIv = gcmIv, + memoryCostMiB = memoryCostMiB, iterations = iterations, sealed = sealed, + ) + } finally { + pinKey.fill(0) + pinSecret.fill(0) + pin.fill(0) + } + } + + /// Unseals with the entered PIN. ALWAYS returns 32 bytes: a wrong PIN + /// yields garbage that only the server can falsify. An exception here is + /// structural (missing key/blob) and must route to re-enrollment — never + /// present it as "wrong PIN". + fun unseal(artifacts: PinArtifacts, pin: ByteArray): ByteArray { + try { + val sealingKey = keyStore.getKey(artifacts.sealingKeyAlias, null) as SecretKey + val inner = Cipher.getInstance("AES/GCM/NoPadding").run { + init(Cipher.DECRYPT_MODE, sealingKey, GCMParameterSpec(128, artifacts.gcmIv)) + doFinal(artifacts.sealed) // outer tag covers integrity of the blob, not the PIN + } + val pinKey = argon2id(pin, artifacts.salt, artifacts.memoryCostMiB, artifacts.iterations) + try { + return ctr(pinKey, artifacts.ctrIv, inner) + } finally { + pinKey.fill(0) + } + } finally { + pin.fill(0) + } + } +} +``` + +## Biometric keys + +Biometric (`platform`) keys skip the PIN machinery entirely — no transport key, +no sealed secret, no `pin_proof`. Android Keystore gates the signing key itself: +create it with `setUserAuthenticationRequired(true)` and sign through a +`BiometricPrompt`, which shows the fingerprint or face prompt when the key +signs. Three differences from the PIN flow: + +- The attestation challenge is the **bare nonce**, not `SHA256(nonce ‖ t_pub)`. + Pass the raw nonce bytes straight to `setAttestationChallenge`. +- Create the signing key **with** `setUserAuthenticationRequired(true)` and + enroll it with `"user_verification": "platform"` and no `pin_protected` or + `transport_public_key`. The server cross-checks that declaration against the + attestation, so a `platform` key really is biometric-gated — which is why + Android biometric keys can be a first factor without an opt-in (see + + Biometric enrollment + + ). +- At login, submit only `client_key_id` and `signature` — the `BiometricPrompt` + assertion over the bare nonce — and omit `pin_proof`. + +For the key-creation and `BiometricPrompt` signing scaffolding (the +`createKeyPair` and `launchBiometricSigner` helpers), reuse the `OryApi` from +the [second-factor guide](#second-factor-device-binding). The PIN listing above +creates the signing key **without** `setUserAuthenticationRequired` precisely +because the PIN — not a platform prompt — is its gate. + +## Changing the PIN and rotating the secret + +**Changing the PIN is purely local** — no server call. Unseal the secret with +the old PIN, then `PinVault.seal` it again with the new PIN. `seal` generates a +fresh salt and IV, so the whole stored blob changes, while the `pin_secret`, +`client_key_id`, and signing key stay the same. The server never learns that the +PIN changed. + +```kotlin +val oldSecret = PinVault.unseal(artifacts, oldPin) // oldPin is zeroized by unseal +val updated = PinVault.seal( + pinSecret = oldSecret, pin = newPin, clientKeyId = artifacts.clientKeyId, + signingKeyAlias = artifacts.signingKeyAlias, sealingKeyAlias = artifacts.sealingKeyAlias, + memoryCostMiB = artifacts.memoryCostMiB, iterations = artifacts.iterations, +) // seal zeroizes oldSecret and newPin; persist `updated` in place of the old artifacts. +``` + +**Rotating the secret needs the server.** It is the recovery path for a +forgotten PIN or a locked key: the server issues a fresh `pin_secret` for the +same signing key. Start a settings flow under a privileged session, then: + +1. Create a fresh `PinCeremony` — a new ephemeral transport key. +2. Sign the rotation challenge with the enrolled key: + `sign(alias, nonce + transportPublicKey)` over the raw `nonce ‖ t_pub` + concatenation, **not pre-hashed** (contrast login, which signs the bare + nonce). This binding stops a session-level attacker from rotating the secret + to a transport key they control. +3. Submit the `rotate_secret` payload with `client_key_id`, the fresh + `transport_public_key`, and that signature (see + + Rotating the PIN secret + + ). +4. Open the new secret from the response's `continue_with` with + `openSealedSecret`, exactly as at enrollment. +5. Capture the user's PIN — a new one if they forgot the old — and + `PinVault.seal` the new secret with a fresh salt and IV, then replace the + stored artifacts. + +The signing key and its `client_key_id` never change; only the sealed secret +does. Only PIN keys can be rotated. + +## Putting it together + +- See + + Protocol reference + + for the flow each ceremony maps to; the JSON request and response bodies live there, + linked below. + +The code above zeroizes every PIN, derived key, and secret in a `finally` block +and never persists the transport key — keep that discipline when you wire these +calls. + +1. **Enroll** + (PIN + enrollment) — `decodeNonce` the flow's + `deviceauthn_nonce` node, `createSealingKey`, create a `PinCeremony`, then + `createPinSigningKey(alias, nonce, transportPublicKey)` and submit the `add` + payload with `transport_public_key` and the returned chain as + `certificate_chain_android`. On the response, `openSealedSecret` on the + `continue_with` item, derive the fingerprint with `clientKeyId(alias)`, + capture the PIN, `PinVault.seal`, and persist the `PinArtifacts`. Let the + `PinCeremony` go out of scope so its transport key is destroyed. +2. **Log in** + (First-factor + login) — `decodeNonce`, `PinVault.unseal` with the + entered PIN, `pinProof(pinSecret, clientKeyId, nonce)`, and + `sign(alias, nonce)` over the raw nonce, then submit `client_key_id`, + `signature`, and `pin_proof`. +3. **Rotate** + (Rotating + the PIN secret) — a fresh `PinCeremony`, + `sign(alias, nonce + transportPublicKey)` over the unhashed concatenation, + submit the `rotate_secret` payload, then open and re-seal exactly as at + enrollment. + +## Testing in the emulator + +The Android emulator has no StrongBox or TEE, so exercising the PIN flow on one +needs two development-only relaxations — never in a release build. + +First, the emulator produces software-backed attestations, which the server +rejects by default. Enable relaxed attestation server-side to accept the signing +key. Relaxed-attestation keys expire after 30 days, so re-enroll after that. + +- See + + Relaxed attestation for testing + + for the details. + +To exercise the biometric prompt on the emulated device, register a fingerprint: + +1. Create an emulated device in the Android emulator with an Android version + which is at least 24. +1. Start the emulated device. +1. Inside the emulated device, go to 'Settings > Security & Location > Screen + Lock' and set a device PIN (this is required for biometrics). +1. Inside the emulated device, go to 'Settings > Security & Location > + Fingerprints' and add a fingerprint. A biometric prompt will appear on the + screen of the emulated device. +1. In the 'Extended Controls' for the emulated device (not inside the device, + but in Android Studio), go to the 'Fingerprints' section and click on 'Touch + sensor' to pass the biometrics prompt of the device. This simulates placing + your finger on the sensor. + +At this point the fingerprint is registered for the emulated device. The process +must be repeated for each emulated device. + +Then, start the application inside the emulated device. When the biometric +prompt appears, repeat step 5. to pass the biometric prompt. There are several +fingerprints available, so it is possible to test the case of using a registered +fingerprint, and the case of using an unknown fingerprint. To test the case of +no fingerprint registered, remove the registered fingerprint in the 'Settings' +of the emulated device. + +Second, the sealing key also lands in the software keystore, so the reference +`createSealingKey` refuses enrollment by design — its `requireHardwareBacked` +check throws. Temporarily bypass that check in a debug/test build to wire and +flow-test the PIN path, but never in a release build: the offline-guessing +resistance rests entirely on a hardware sealing key. Verify the sealing path on +a physical device before shipping. diff --git a/src/components/Shared/kratos/passwordless/deviceauthn/flutter.mdx b/src/components/Shared/kratos/passwordless/deviceauthn/flutter.mdx new file mode 100644 index 0000000000..8d59b29759 --- /dev/null +++ b/src/components/Shared/kratos/passwordless/deviceauthn/flutter.mdx @@ -0,0 +1,104 @@ +Dart can call native APIs via message passing. Let's call a function called +`generateKey` with the parameter `{'alias': 'my_key_01'}`: + +```dart +Future _generateKey() async { +setState(() => _isLoading = true); + +try { + // Calling the native method + final String result = await platform.invokeMethod('generateKey', { + 'alias': 'my_key_01', + }); + + setState(() { + _keyStoreResult = result; + _isLoading = false; + }); +} on PlatformException catch (e) { + setState(() { + _keyStoreResult = "Failed to generate key: '${e.message}'."; + _isLoading = false; + }); +} +} +``` + +Since the call might block, it is marked async and a loading indicator is shown +in the UI via the `_isLoading` field. + +Now to the platform code, for example for Android: + +```kotlin +class MainActivity: FlutterActivity() { + private val CHANNEL = "com.example.secure/keystore" + + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result -> + if (call.method == "generateKey") { + val alias = call.argument("alias") ?: "default_alias" + try { + val keyStoreResult = [..] // Call the KeyStore here. + + // Send the result back to Flutter. + result.success(keyStoreResult) + } catch (e: Exception) { + // If generation fails (e.g., hardware issues), send an error + result.error("KEY_GEN_FAIL", e.localizedMessage, null) + } + } else { + result.notImplemented() + } + } + } +} +``` + +And for iOS: + +```swift +import UIKit +import Flutter + +@main +@objc class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + + // 1. Standard plugin registration for things like path_provider, etc. + GeneratedPluginRegistrant.register(with: self) + + // 2. Create a registrar for our custom "inline" plugin + // The name "SecureKeystorePlugin" can be anything unique. + let registrar = self.registrar(forPlugin: "SecureKeystorePlugin") + + // 3. Setup the channel using the registrar's messenger + let channel = FlutterMethodChannel( + name: "com.example.secure/keystore", + binaryMessenger: registrar!.messenger() + ) + + // 4. Handle the method calls + channel.setMethodCallHandler({ + (call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in + + if call.method == "generateResidentKey" { + let alias = (call.arguments as? [String: Any])?["alias"] as? String ?? "unknown" + + // Just for the example, get the iOS version. + result("iOS \(version)") + } else { + result(FlutterMethodNotImplemented) + } + }) + + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} +``` + +And the Flutter code gets this result back: `iOS 26.2.1` (for example). diff --git a/src/components/Shared/kratos/passwordless/deviceauthn/index.mdx b/src/components/Shared/kratos/passwordless/deviceauthn/index.mdx new file mode 100644 index 0000000000..44212ed911 --- /dev/null +++ b/src/components/Shared/kratos/passwordless/deviceauthn/index.mdx @@ -0,0 +1,921 @@ +import Mermaid from "@site/src/theme/Mermaid" + +Device Authentication (also known as 'DeviceAuthn', or device binding) is a way +for a user to authenticate with a hardware resident private key. + +Since the key cannot leave the device, once the key has been added to the +identity, it gives a high assurance that the user is who they say they are, and +is using a trusted, known device, without needing to remember something like a +password. + +This is very similar to passkeys with one crucial difference: passkeys are +usually synced in the cloud among many devices, whereas a DeviceAuthn key cannot +leave the hardware where it was created. + +Using this approach, the system can restrict the use of an application on +specific, whitelisted devices. + +The strategy supports two modes: + +- **Second factor (step-up):** an enrolled device key satisfies a higher + authenticator assurance level (AAL2 or AAL3) after the user has already signed + in with another method. +- **First factor (passwordless):** with `first_factor` enabled, a device key + protected by an app PIN or platform biometrics (Face ID, fingerprint), + combined with the device's hardware-resident key, signs the user in with a + single request and grants an AAL2 session — no password, no one-time code. + +Key properties of the PIN and biometric first factor: + +- The PIN never leaves the device and is never stored anywhere — not on the + device, not on the server. It unlocks a secret on the device; the server + verifies a cryptographic proof derived from that secret. +- The server is the only place a PIN guess can be tested. A stolen device, a + stolen backup, or a full database dump cannot be used to guess the PIN + offline. +- Wrong PIN attempts are counted server-side. After `pin_max_attempts` + consecutive failures (default 5), the key is locked and its secret destroyed. +- The secret is delivered to the device exactly once at enrollment, end-to-end + encrypted (HPKE) — TLS-terminating intermediaries and request logs only ever + see ciphertext. + +Since this is a strategy, it supports all the same hooks as the other +strategies. + +## Short summary + +- Available on Ory Network and with the Ory Enterprise License; implemented by + the `deviceauthn` strategy, in spirit similar to `WebAuthn`. +- Every key is addressed by its `client_key_id` — a server-assigned fingerprint: + the lowercase-hex SHA-256 of the key's public key in SubjectPublicKeyInfo + (DER) form. Clients don't choose it; they derive it locally or read it from + the settings flow. +- The settings flow is used to manage keys (create, delete). +- The login flow is used to step-up the AAL. Hardware-backed keys (TEE) satisfy + AAL2, while keys stored in a dedicated security chip (StrongBox) may + eventually be categorized as AAL3. +- With `first_factor` enabled, a key protected by an app PIN or platform + biometrics is a complete passwordless login granting AAL2 — see + [How it works](#how-it-works) and [Configuration](#configuration). +- Using the admin API, it is possible to delete all keys for a device on behalf + of the user in case of theft or loss. +- A device may have multiple keys, to support multiple user accounts on the same + device. +- Only these platforms are currently supported, because they offer native APIs, + strong hardware, and trust guarantees: + - iOS: 14.0+ + - iPadOS: 14.0+ + - tvOS: 15.0+ (untested) + - visionOS 1.0+ (untested) + - Android SDK 24.0+. Older versions are unlikely to be supported. + +## Acronyms + +- TPM: Trusted Platform Module +- TEE: Trusted Execution Environment +- CA: Certificate Authority +- AAL: Authenticator Assurance Level + +## How it works + +### Three keys + +A PIN-protected enrollment involves three distinct keys: + +| Key | Purpose | Lifetime | Known to the server | +| ---------------------- | ------------------------------------------------------- | -------------------------------------- | ------------------- | +| Device signing key | Signs the login challenge — the possession factor | Persistent, hardware-resident | Public half only | +| Sealing key | Wraps the stored PIN secret on the device | Persistent, hardware-resident, local | Never | +| Transport key (X25519) | Receives the one-time `pin_secret` at enrollment (HPKE) | Ephemeral — destroyed after enrollment | Public half only | + +### User verification levels + +Every key records a `user_verification` level at enrollment. It is fixed at +enrollment and not negotiable at login: + +| `user_verification` | How the user is verified | First factor | Second factor (step-up) | +| ------------------- | ---------------------------------------- | ------------ | ------------------------ | +| `pin` | App PIN, proven to the server per login | Yes | Yes (PIN proof required) | +| `platform` | Platform biometrics gate the signing key | Yes¹ | Yes | +| `none` | Not verified | No | Yes | + +¹ On iOS, biometric-only first factor requires the `ios_biometric_first_factor` +opt-in — see [Configuration](#configuration). + +### The PIN mechanism + +At enrollment the server generates a random 32-byte `pin_secret`, stores it +encrypted, and sends it to the device exactly once, sealed to the enrollment's +transport key. The device never stores it in the clear: it derives a key from +the user's PIN (Argon2id), encrypts the secret with it (unauthenticated +AES-CTR), and wraps the result under a non-exportable hardware key. + +At login the device reverses the wrapping with the entered PIN and proves +possession of the secret with an HMAC over the login challenge. A wrong PIN +yields 32 bytes of garbage — indistinguishable from the real secret on the +device — so the resulting proof is wrong and the server counts the failure. +Nothing on the device (or in a stolen backup) can test whether a PIN guess is +correct. + +Biometric keys need none of this machinery: the platform gates the signing key +itself and shows the biometric prompt when the key signs. + +### Assurance level + +A successful first-factor login records possession (the device signature) plus +knowledge (PIN) or inherence (biometrics) and grants an AAL2 session in a single +submission. This matches NIST SP 800-63B's multi-factor cryptographic +authenticator model: the PIN acts as an activation secret whose verification +failures the server rate-limits. + +## Platform guides + +Device binding is implemented with native platform APIs. We recommend using the +Ory SDK to communicate with Kratos, although this is not required. Since device +binding is only supported on native devices (not in the browser), all +corresponding API calls should be done using the endpoints for native apps, to +avoid having to pass cookies around manually. + +Each platform guide covers the full journey: second-factor device binding, the +PIN and biometric first factor, and key recovery. + +- + Device binding on Android + +- + Device binding on iOS + +- + Device binding in Dart/Flutter + + +## Configuration + +Enable the `deviceauthn` strategy and configure its behavior in the Kratos +configuration. This is the canonical configuration example — enabling the +strategy with no `config` block gives you second-factor device binding; the +`config` keys below add the first-factor PIN and biometric modes. + +```yaml +selfservice: + methods: + deviceauthn: + enabled: true + config: + # Allow deviceauthn keys with user_verification "pin" or "platform" + # to act as a complete first factor. Default: false. + first_factor: true + + # Consecutive wrong-PIN attempts before a key is locked and its + # secret destroyed. Default 5, hard ceiling 10. + pin_max_attempts: 5 + + # Allow iOS biometric ("platform") keys as the sole first factor. + # Default false: App Attest cannot prove that a Secure Enclave key is + # biometric-gated, so this is an explicit opt-in. + ios_biometric_first_factor: false + + # Bind enrollments (and iOS logins) to your apps. Empty lists disable + # the check — configure both in production. + ios_app_ids: + - "TEAMID.com.example.app" + android_app_ids: + - "0123…ef" # lowercase-hex SHA-256 of your app signing certificate +``` + +- `first_factor` — enables the first-factor login path and its UI nodes. Without + it, all keys are step-up only. +- `pin_max_attempts` — server-side lockout limit. Values above 10 are clamped + (NIST SP 800-63B ceiling). +- `ios_biometric_first_factor` — on Android, the attestation proves that a + `platform` key requires user authentication; on iOS it cannot, so iOS + `platform` keys are step-up only unless you opt in. PIN keys are unaffected. +- `ios_app_ids` / `android_app_ids` — allow-lists checked against the + attestation. Use the Apple App ID (`.`) and the SHA-256 + digest of the Android app signing certificate (package names are forgeable). +- PIN length and complexity are client-side concerns — see + [Client implementation requirements](#client-implementation-requirements). + +On Ory Network all of these are available through the project configuration. +Relaxed attestation for emulator testing is described in +[Relaxed attestation for testing](#relaxed-attestation-for-testing) and only +takes effect in development environments. + +## Relaxed attestation for testing + +For testing purposes, you can relax the enrollment checks so that software-based +attestations (such as those produced by the Android emulator) are accepted. This +relaxes the checks for software roots, expired certificates, and software +security level. Add `config.insecure_allow_relaxed_attestation` to the strategy +configuration: + +```yaml +selfservice: + methods: + deviceauthn: + enabled: true + config: + insecure_allow_relaxed_attestation: true +``` + +On Ory Network, this is exposed as a toggle in the Console under **MFA → Device +Authentication**, and is only available on development projects. + +Keep the following in mind when using relaxed attestation: + +- Keys enrolled while relaxed attestation is enabled carry a 30-day expiry. + Hardware-attested keys are unaffected. +- Relaxed keys are refused at login as soon as the setting is disabled, the key + expires, or (on Ory Network) the project is no longer in the `development` + environment. + +:::warning + +Relaxed attestation is intended for development and testing only. Never enable +it for production traffic, as it removes the hardware-binding guarantees that +this strategy relies on. + +::: + +## Protocol reference + +All flows are native (API) flows. The flow's UI contains a hidden +`deviceauthn_nonce` node; its value is the base64 encoding of the JSON +`{"nonce":""}`. Decode twice to obtain the raw nonce +bytes. The nonce is single-use and bound to the flow. + +### Enrollment + +1. The `DeviceAuthn` strategy is enabled in the Kratos configuration — see + [Configuration](#configuration). This strategy implements the settings and + login flow. +2. The client creates a new settings flow and the existing keys for the identity + are in the response. The settings flow has a field `nonce` which contains a + random nonce. This is the server challenge. This value is opaque and should + not be assigned meaning. It may be a random string, or a hash of something. + The important part is that it is not guessable by an attacker. +3. The client generates a private-public Elliptic Curve (EC) key pair in the + TEE/TPM of the device using the server challenge, using native mobile APIs. +4. The client completes the settings flow to enroll a new key by sending these + fields: + 1. device name (human readable, picked by the user, for example + `My work phone`) + 2. certificate chain (Android) or attestation (iOS), which contains the + signature of the server challenge, and the public key (in the leaf + certificate) +5. The server: + 1. Checks that the certificate chain is valid, using Google and Apple root + CAs + 2. Checks the certificate revocation lists to ensure no root/intermediate CA + in the chain has been revoked + 3. Checks that the challenge sent is the same as the challenge in the + database (stored in the settings flow) + 4. Checks that the key is indeed in the TEE/TPM based on the device + attestation information. A key in software is rejected. A key in the TPM + (e.g. Strongbox) may warrant a higher AAL e.g. AAL3 in the future. + 5. Checks that the device is not emulated, modified in some way, etc based on + the device attestation information + 6. Records the public key in the database + 7. Assigns the key's `client_key_id`: the lowercase-hex SHA-256 fingerprint + of the public key in SubjectPublicKeyInfo (DER) form. The device can + recompute it locally. Keys enrolled before server-assigned IDs keep their + original client-chosen value. + 8. Erases the challenge value in the database to prevent re-use + 9. Replies with 200 + +Enrollment also records a `user_verification` level (`none`, `platform`, or +`pin`) that determines whether the key can act as a first factor. PIN enrollment +computes the attestation challenge differently — see +[PIN enrollment](#pin-enrollment). + +At this point the key is enrolled for the identity. + +>S: POST /self-service/settings/api (xSessionToken) + S-->>C: 200 settings flow {nonce, existing_keys} + C->>H: generateKey(nonce) + H-->>C: {public key, cert_chain or attestation} + C->>S: POST /self-service/settings?flow=... {method: deviceauthn, add: {device_name, cert_chain or attestation_ios}} + Note over S: Verify cert chain vs Apple/Google root CAs
Check CRLs
Match challenge to stored nonce
Reject software/emulated keys
Store pubkey, assign client_key_id, erase challenge + S-->>C: 200 updated settings flow {client_key_id} +`} +/> + +### PIN enrollment + +Runs in a settings flow under a privileged session. + +1. Generate an ephemeral X25519 keypair — the transport key (`t_priv`, `t_pub`). + It must exist **before** attestation, because its public half is baked into + the attestation challenge. +2. Compute the attestation challenge: + + ```text + challenge = SHA256(nonce ‖ t_pub) + ``` + + The raw 32-byte nonce concatenated with the raw 32-byte transport public key, + in that order, hashed once. This binds the transport key to the attested + device: an intermediary that swaps `t_pub` invalidates the attestation. + + :::warning + + Do **not** use the bare nonce as the challenge — that is the second-factor + device-binding form. Using it here fails with + `Unable to validate the key attestation: wrong challenge`. + + ::: + +3. Create and attest the device signing key with that challenge. For PIN keys, + create the key **without** platform user-verification gating — the PIN is the + gate. On iOS pass the digest as `clientDataHash` to `attestKey`; on Android + pass it to `setAttestationChallenge`. +4. Complete the settings flow: + + ```json + { + "method": "deviceauthn", + "add": { + "device_name": "My work phone", + "version": 1, + "pin_protected": true, + "transport_public_key": "", + "attestation_ios": "" + } + } + ``` + + Android submits `certificate_chain_android` (the attestation chain, leaf + first) instead of `attestation_ios`. `user_verification` may be omitted: + `pin_protected: true` implies `"pin"`. + +5. The server verifies the attestation, recomputes the challenge from the nonce + it issued and the submitted `transport_public_key`, assigns the key's + `client_key_id`, generates the `pin_secret`, stores it encrypted, and returns + it — exactly once — HPKE-sealed in the response's `continue_with`: + + ```json + { + "continue_with": [ + { + "action": "show_pin_entry_ui", + "data": { + "enc": "", + "ciphertext": "" + } + } + ] + } + ``` + + The HPKE parameters are fixed and non-negotiable: DHKEM(X25519, HKDF-SHA256), + HKDF-SHA256, AES-128-GCM, with `info = "ory/deviceauthn/pin-secret/v1"` and + the `client_key_id` string as AAD. + +6. `client_key_id` is the key's deterministic fingerprint — the lowercase-hex + SHA-256 of the device public key in PKIX, ASN.1 DER (SubjectPublicKeyInfo) + form. It is **not** included in `continue_with`: derive it locally (trivial + on Android) or read it from the updated flow's `deviceauthn_remove` node for + the new key (see the platform guides). +7. The client opens the sealed secret with `t_priv`, captures the user's PIN, + seals the secret per the + [client requirements](#client-implementation-requirements), and destroys the + transport keypair. The key is immediately usable (`confirmed`). + +>S: POST /self-service/settings/api + S-->>App: settings flow {deviceauthn_nonce} + App->>App: generate transport keypair (t_priv, t_pub) + App->>H: create + attest signing key
challenge = SHA256(nonce ‖ t_pub) + H-->>App: attestation + App->>S: POST /self-service/settings?flow=… {add: {pin_protected, transport_public_key, attestation}} + Note over S: verify attestation, recompute challenge,
assign client_key_id, mint pin_secret,
store encrypted + S-->>App: 200 + continue_with {enc, ciphertext} — one-time + App->>App: pin_secret = HPKE.Open(t_priv, enc, ciphertext)
AAD = client_key_id + App->>App: capture PIN, seal secret, wipe PIN + t_priv +`} +/> + +### Biometric enrollment + +Same settings flow, three differences: the challenge is the **bare nonce**; the +signing key is created **with** platform user-verification gating +(`setUserAuthenticationRequired` on Android, `.biometryCurrentSet` access +control on iOS); and the payload declares `"user_verification": "platform"` with +no `pin_protected` and no `transport_public_key`. There is no secret and no +`continue_with`. On Android the server cross-checks the declaration against the +attestation; on iOS it is trusted at enrollment (which is why first-factor use +is opt-in there). + +### Proof of device enrollment + +1. When the user creates the login flow with the DeviceAuthn strategy, the + client receives a server challenge. +2. Using the private key in the hardware of the device, the client signs the + server challenge using ECDSA. The signature is only emitted after a + biometric/PIN prompt has been passed. The client then sends the signature to + the server using the login flow update endpoint. +3. The server: + 1. Checks that the signature is valid using the recorded public key in the + database + 1. Checks that no CA in the certificate chain (when the device has been + enrolled) has been revoked + 1. Erases the challenge value in the database to prevent re-use. + 1. Replies with 200 with a fresh session token and a higher AAL e.g. AAL2 or + AAL3 + +>S: POST /self-service/login/api {aal: aal2, refresh: false} + S-->>C: 200 login flow {nonce} + C->>H: sign(nonce) with the enrolled key + Note right of H: biometric/PIN prompt
private key never leaves hardware + H-->>C: ECDSA signature + C->>S: POST /self-service/login?flow=... {method: deviceauthn,
client_key_id, signature} + Note over S: Verify signature with stored pubkey
Check no CA in chain is revoked
Erase challenge + S-->>C: 200 {session_token, aal: aal2} +`} +/> + +### First-factor login + +Requires `first_factor: true`. The client creates a native login flow and +submits: + +```json +{ + "method": "deviceauthn", + "client_key_id": "", + "signature": "", + "pin_proof": "" +} +``` + +- `signature` — the device key over the raw nonce. Android: an ASN.1/DER ECDSA + signature over the SHA-256 of the nonce (`Signature("SHA256withECDSA")` fed + the nonce bytes). iOS: the CBOR App Attest assertion from + `generateAssertion(keyId, clientDataHash: nonce)`. +- `pin_proof` — PIN keys only: + + ```text + pin_proof = HMAC-SHA256(key: pin_secret, + msg: "ory/deviceauthn/pin-proof/v1" ‖ client_key_id ‖ nonce) + ``` + + The domain string and `client_key_id` as UTF-8 bytes, the nonce raw, + concatenated without separators. + +The server resolves the identity from `client_key_id`, verifies the signature, +and — for PIN keys — verifies the proof. Success grants an AAL2 session in this +single submission. + +Every rejection (unknown key, ineligible key, bad signature, wrong PIN, locked +key) returns the same error, so enrollment status can't be probed. The lockout +counter moves only on a valid signature with a wrong proof — the combination +that proves the physical device made a wrong-PIN attempt. At the limit the key +state becomes `locked` and the stored secret is destroyed. A correct proof +resets the counter. + +>S: POST /self-service/login/api + S-->>App: login flow {deviceauthn_nonce} + App->>App: unseal pin_secret with entered PIN
(wrong PIN → garbage, no local error) + App->>H: sign nonce with device key + H-->>App: signature + App->>S: POST /self-service/login?flow=… {client_key_id, signature, pin_proof} + Note over S: resolve identity, verify signature,
verify proof, count failures + S-->>App: 200 {session_token, aal2} +`} +/> + +### Step-up with a PIN key + +At AAL2 step-up, a PIN key must send `pin_proof` alongside the signature — the +device signature alone does not bypass the PIN. A `pin_proof` submitted for a +non-PIN key is rejected as a downgrade attempt. Biometric and `none` keys step +up with the signature alone, as described in +[Proof of device enrollment](#proof-of-device-enrollment). + +### Rotating the PIN secret + +Re-issues a fresh `pin_secret` for an existing PIN key — the recovery path for a +forgotten PIN or a locked key. The device signing key is unchanged; no +re-attestation happens. Runs in a settings flow under a privileged session and +additionally requires proof of possession of the enrolled key: + +```json +{ + "method": "deviceauthn", + "rotate_secret": { + "client_key_id": "", + "transport_public_key": "", + "signature": "" + } +} +``` + +- `signature` covers the challenge `nonce ‖ t_pub` — the raw 64-byte + concatenation of the settings-flow nonce and the fresh transport public key, + **not hashed by the caller**. Android signs it with `SHA256withECDSA` as + usual; iOS passes it as `clientDataHash` to `generateAssertion`. This binding + ensures a session-level attacker cannot rotate the secret to a transport key + they control. +- Only PIN keys can be rotated. +- Effects: fresh secret (delivered via the same one-time `continue_with`), + failure counter reset, `locked` state cleared. + +### Key Revocation + +- The user can revoke a key themselves (e.g. because the device is stolen, lost, + broken, etc) using the settings flow. This action can be done from any device + (e.g. from the browser), as it is the case for other methods e.g. WebAuthn. +- An admin using the admin API can revoke all keys on a device on behalf of the + user. This is useful when the user only owns one device which is the one that + should be revoked (e.g. one mobile phone) and which has been lost/stolen + +Revocation is done by removing the key from the database. + +### Device list + +The settings flow contains all keys for the identity. This is used to present +the list of keys (including device name) in the UI. + +### Key lifecycle on the device + +- Creation: When the device enrollment process is started for the user +- Deletion: + - When the app is uninstalled or when the phone is reset, the mobile OSes + automatically remove all keys for the app. This means that if the device was + enrolled, the public key subsists server-side but the private key does not + exist anymore, so no one can sign any challenge for this public key. This + database entry is thus useless, but poses no security risks. + +## Client implementation requirements + +The security of the PIN path depends on the client following this recipe +exactly. Each rule exists to preserve one invariant: **the server's rate-limited +proof check is the only place a PIN guess can be tested.** + +### Sealing the secret + +After opening the one-time `pin_secret`: + +1. Derive a key from the PIN: `pinKey = Argon2id(PIN, salt)` with a **fresh + random salt**. +2. Inner layer: encrypt the secret with **unauthenticated AES-CTR** under + `pinKey`, with a **fresh random IV**. CTR is deliberate: a wrong PIN yields + plausible garbage, never a locally detectable failure. +3. Outer layer: seal the result under a **non-exportable hardware key** created + without user-verification gating — an AES-256-GCM Android Keystore key + (StrongBox where available), or an iOS Secure Enclave P-256 key used via + ECIES. +4. Store the sealed blob together with the salt, KDF parameters, IV, a format + version, and the key alias. On iOS use the Keychain with + `kSecAttrAccessibleWhenUnlockedThisDeviceOnly` (never `UserDefaults`), so + uninstalling the app purges it. Android Keystore keys are purged on uninstall + automatically. +5. Wipe the PIN, `pinKey`, `pin_secret`, and the transport private key from + memory. + +### Hard rules + +- **Hardware sealing key or refuse.** If no StrongBox/TEE/Secure Enclave key is + available, refuse PIN enrollment. Never fall back to a software key — the + offline-guessing resistance rests entirely on this. +- **No local PIN-correctness signal.** Never wrap the secret in anything that + reveals whether a PIN guess was right: no MAC or AEAD tag on the inner layer, + no checksum, magic bytes, length or format marker, and no "did it decrypt + sensibly" heuristics. Any such artifact is an offline PIN-testing oracle. +- **Fresh salt and IV on every seal** — at enrollment, on PIN change, and after + secret rotation. Reusing either leaks the secret through CTR keystream reuse. +- **Zeroize.** Fresh PIN entry for every authentication; never cache the PIN, + `pinKey`, or `pin_secret`. Hold them in mutable byte buffers (`ByteArray`, + `[UInt8]`, `Data`) — never in immutable strings — and overwrite with zeros + after use. +- **Require an unlocked device.** Create the sealing key so it is usable only + while the device is unlocked (`setUnlockedDeviceRequired(true)` on Android — + available on API 28+, older levels cannot enforce this gate; + `kSecAttrAccessibleWhenUnlockedThisDeviceOnly` on iOS). +- **Separate keys.** The signing key and the sealing key are distinct hardware + keys; the transport key is ephemeral and destroyed after enrollment. + +### PIN policy + +The server never sees the PIN, so PIN policy is enforced by your app, not by +Ory: require at least 6 digits (recommended; 4 is the absolute floor) and reject +common values (repeated or sequential digits, `123456`, birthdate shapes). +Server-side lockout bounds the damage of a weak PIN; it does not make weak PINs +safe. + +### Argon2id calibration + +Mobile hardware varies widely. Ship pre-tuned parameter tiers, benchmark once on +first launch to pick a tier, and store the chosen parameters with the sealed +blob so unsealing always uses the enrollment-time values. As a starting point: +64 MiB memory, 3 iterations, parallelism 4. + +### Error routing + +| Symptom | Meaning | Action | +| ------------------------------------------------ | ---------------------------- | --------------------------------------- | +| Server rejects login; local unseal succeeded | Wrong PIN (or key locked) | Let the user retry; count local retries | +| Repeated rejections despite correct-looking PIN | Key locked server-side | Recover: fallback login + rotate secret | +| Outer unseal fails / sealing key or blob missing | Local state lost (reinstall) | Re-enroll the key | + +Never present a failed outer unseal as "wrong PIN" — it is structural and +retrying cannot fix it. + +## Recovery and lockout + +| Situation | Path | +| ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- | +| User wants to change the PIN (knows it) | Client-only: unseal with the old PIN, re-seal with the new one (fresh salt and IV). No server call. | +| PIN forgotten, or sealed secret lost (reinstall) | Log in with another method, then [rotate the secret](#rotating-the-pin-secret). The device key and its attestation are kept. | +| Key locked after too many wrong PINs | Same: another login method, then rotate (clears the lock) — or delete and re-enroll the key. | +| Device lost or stolen | Delete the key from a session on another device, or via the admin identity APIs. | + +A locked key is refused for both first-factor login and step-up. Locking +destroys the stored secret, so a lock can never be silently undone — recovery +always issues a fresh secret. Consider notifying users whenever a key is +enrolled or its secret rotated, and delaying sensitive operations after either +event. + +## Troubleshooting + +- **`Unable to validate the key attestation: wrong challenge` at PIN + enrollment** — the attestation was created over the bare nonce. PIN enrollment + binds the transport key: use `SHA256(nonce ‖ transport_public_key)` as the + challenge. The bare nonce is correct only for second-factor device binding and + biometric enrollment. +- **`The rotation signature is invalid.`** — the rotation signature must cover + the raw concatenation `nonce ‖ transport_public_key` (64 bytes, not hashed by + the caller, transport key generated fresh for this rotation). +- **Login always fails with the same generic error** — by design the server + returns one identical error for an unknown key, a bad signature, a wrong PIN, + and a locked key. Track wrong-PIN retries locally; after `pin_max_attempts` + consecutive failures assume the key is locked and offer recovery. +- **Keys enrolled before user verification existed** — legacy keys cannot log in + and must be re-enrolled. +- **Relaxed-attestation keys stop working** — keys enrolled with relaxed + attestation expire after 30 days and are refused as soon as the setting is + turned off. See [relaxed attestation](#relaxed-attestation-for-testing). + +## Security + +This section covers the cryptographic design, the second-factor attack surface, +and the security model of the PIN and biometric first factor. + +### Cryptography + +The security of this design relies on a chain of trust anchored in hardware and +standard cryptographic primitives. + +- Asymmetric Cryptography: ECDSA with P-256 is used for the device key pair. + This is a modern, efficient, and widely supported standard for digital + signatures. It is less computationally expensive than RSA. +- Hardware-Backed Keys: Private keys are generated and stored as non-exportable + within the device's Secure Enclave (iOS) or Trusted Execution Environment + (TEE)/StrongBox (Android). They cannot be accessed by the OS or any + application, providing strong protection against extraction. As much as the + APIs allow it, the keys are marked as requiring user authentication (the phone + is unlocked) and a biometrics/PIN prompt. +- Hashing: SHA-256 is used for generating nonces and hashing challenges, + providing standard collision resistance. +- Certificate Chains: X.509 certificates are used to establish the chain of + trust. The device's attestation is signed by a key that is, in turn, certified + by a platform authority (Apple or Google), ensuring the attestation's + authenticity. +- No configurability: Intentionally, for simplicity, performance, auditability, + and to avoid downgrade attacks, all cryptographic primitives are fixed. + +### Attack Surface and Mitigations + +- Man-in-the-Middle (MitM) Attack + - Threat: An attacker intercepts and tries to modify the communication between + the client and server. + - Mitigation: All communication occurs over TLS, encrypting the channel. More + importantly, the core payloads (attestation and login signatures) are + themselves digitally signed using the hardware-bound key. Any tampering + would invalidate the signature, causing the server to reject the request. +- Replay Attacks + - Threat: An attacker captures a valid attestation or login payload and + "replays" it to the server at a later time to gain access. + - Mitigation: The server generates a unique, single-use cryptographic + challenge for every new enrollment or login attempt. This challenge is + embedded in the certificate chain. The server verifies that the challenge in + the payload is the exact one it issued for that specific session and reject + any duplicates or expired challenges. +- Emulation & Software-Based Attacks + - Threat: An attacker attempts to enroll a software-based "device" (e.g., an + emulator, a script) by faking an attestation. + - Mitigation: This is the central problem that hardware attestation solves. + The server verifies the entire certificate chain of the attestation object + up to a trusted root CA (Apple or Google). Only genuine hardware can obtain + a valid certificate chain. The server also inspects attestation flags (e.g., + Android's `attestationSecurityLevel`) to explicitly reject any keys that are + not certified as hardware-backed. +- Physical Attacks & Key Extraction + - Threat: An attacker with physical possession of the device attempts to + extract the private signing key from memory. + - Mitigation: Keys are generated as non-exportable inside the hardware + security module (Secure Enclave/TEE). This is a physical countermeasure that + makes it computationally infeasible to extract key material, even with + advanced hardware probing techniques. +- Compromised OS (Rooting/Jailbreaking) + - Threat: An attacker gains root access to the device's operating system. + - Mitigation: The attestation object contains signals about the integrity of + the operating system. Android's attestation includes `VerifiedBootState`, + which indicates if the bootloader is locked and the OS is unmodified. The + server can enforce a policy to only accept attestations from devices in a + secure state. +- Cross-App/Cross-Site Attacks + - Threat: An attacker tricks a user into generating an attestation for a + malicious app that is then used to attack the service. + - Mitigation: The attestation object includes an identifier for the + application that requested it. On iOS, the `authData` contains the + `rpIdHash` (a hash of the App ID). The server can verify that this hash + matches its own app's identifier to ensure the attestation originated from + the legitimate, code-signed application. +- Malicious App Key Theft/Usage + - Threat: A different, malicious app installed on the same device attempts to + access and use the private key generated by the legitimate app to + impersonate the user. + - Mitigation: This is prevented by the fundamental application sandbox + security model of both iOS and Android. Keys generated in the + hardware-backed key store are cryptographically bound to the application + identifier that created them. The operating system and the secure hardware + enforce this separation, making it impossible for "App B" to access, + request, or use a key generated by "App A". +- Malware and Keyloggers on a Compromised Device + - Threat: Malware, such as a keylogger, screen scraper, or accessibility + service exploit, is active on the user's device and attempts to intercept + credentials. + - Mitigation: This design is highly resistant to such attacks. The entire flow + is passwordless, meaning there is no "typeable" secret for a keylogger to + capture. The core secret (the private key) never leaves the secure hardware. + The user authorizes its use via a biometric prompt, which is managed by a + privileged part of the OS, isolated from the application space where malware + would reside. A keylogger can neither intercept the biometric data nor the + signing operation itself. +- Device Backup, Restore, and Cloning + - Threat: An attacker steals a user's cloud backup (e.g., iCloud or Google + One) and restores it to a new device they control, hoping to clone the + trusted device and its keys. + - Mitigation: This is mitigated by the non-exportable property of + hardware-backed keys. While application data and metadata may be backed up + and restored, the actual private key material never leaves the Secure + Enclave or TEE. When the app is restored on a new device, the reference to + the old key will be invalid, effectively breaking the binding and forcing + the user to perform a new enrollment. Furthermore, resetting the device + automatically erases all keys in the TEE/TPM. +- Biometric System Bypass + - Threat: An attacker with physical possession of the device attempts to + bypass biometric authentication (e.g., using a lifted fingerprint, + high-resolution photo, or 3D mask). + - Mitigation: The design relies on the platform-level biometric security. + Since the hardware key is only unlocked for signing after the hardware + confirms a match, the attacker must defeat the hardware manufacturer's + physical anti-spoofing technologies. +- Server-Side Compromise (Database Leak) + - Threat: An attacker breaches the server and steals the database containing + public keys and device IDs for all enrolled devices. + - Mitigation: Because this is an asymmetric system, the public keys are + useless for authentication without the corresponding private keys. Even with + a full database leak, the attacker cannot impersonate users because they + cannot sign the login challenges. +- Server-Side Compromise (CA Trust Anchor) + - Threat: An attacker gains enough server access to modify the list of trusted + Root CAs, allowing them to accept attestations from a rogue CA they control. + - Mitigation: The Root CA certificates for Apple and Google are hard-coded + within the server-side application logic rather than relying on the general + OS trust store. This prevents an attacker from using a compromised + system-wide trust store to validate fraudulent device attestations. However, + if the attacker can modify the server executable, all bets are off, because + they can modify the in-memory root CAs or bypass the validation logic + entirely. +- UI Redressing / Overlay Attack (Android) + - Threat: A malicious app with the "Draw over other apps" permission creates a + transparent overlay on top of your app. When the user thinks they are + clicking "Enroll Device" or approving a "Transaction Signing" prompt, they + are actually clicking through a malicious flow hidden beneath. + - Mitigation: + - iOS: Inherently protected by the OS (overlays are not permitted over other + apps). + - Android: We use the `setFilterTouchesWhenObscured(true)` flag on sensitive + UI components. This tells the Android OS to discard touch events if the + window is obscured by another visible window. See + [tapjacking](https://developer.android.com/privacy-and-security/risks/tapjacking). +- Dependency / Supply Chain Attack + - Threat: An attacker compromises the Mobile SDK or a dependency. They inject + code that leaks the challenge, or subtly alters device attestation. + - Mitigation: + - Minimized dependencies + - Automated dependency scanning + - Certificate pinning: The Ory server CA can be pinned in the mobile + application/SDK to ensure the device is talking to the legitimate server. + - TLS & URL whitelisting: Both Android and iOS allow for URL whitelisting to + avoid attacker controlled servers from being contacted. + - Signed Device Information: The TEE/TPM on the device signs the device + information. Using Apple/Google root CAs, the server checks that this + information, e.g. the application id, has not been altered. +- Attestation Misbinding Attack + - Threat: The attack manages to leak the challenge meant for another user + (e.g. due to a supply chain attack in the mobile app code), they sign the + challenge with the attacker device, and they submit that to the server + before the legitimate user can, in order to register the attacker device for + the other user account. + - Mitigation: + - Challenge bound to the identity id: The challenge is bound to the identity + in the database (stored in the same row). Since the identity is detected + from the session token, an attacker cannot tamper with the identity id + (unless they steal the session token, at which point they _are_ the user, + from the server perspective). + +### Security model for PIN and biometric first factor + +| Attacker has | Outcome | +| ---------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| Stolen, locked device | The sealing key requires an unlocked device; the sealed secret is hardware-bound and PIN guesses can't be tested. | +| Exfiltrated backup or sealed blob | Opaque — the outer layer is sealed by a non-exportable hardware key that never leaves the original device. | +| Code execution on the unlocked device | Can unseal, but a wrong PIN yields garbage; the only oracle is the server, which locks the key after `pin_max_attempts` attempts (default 5). | +| Ory database dump (even with the encryption secrets) | The `pin_secret` — but login still requires the device's hardware key, and the PIN itself is not derivable. | +| TLS-terminating intermediary, request logs | Only HPKE ciphertext of the one-time secret delivery; the PIN never transits at all. | + +Stated limitations: + +- On iOS, biometric gating of a `platform` key cannot be proven by App Attest — + it is trusted at enrollment. This is why iOS biometric-only first factor is an + explicit opt-in (`ios_biometric_first_factor`). +- PIN strength cannot be enforced server-side; the server never sees the PIN. + Enforce policy in your app and rely on the lockout to bound weak-PIN damage. +- Locking a key destroys its secret. An attacker with deep device compromise can + deliberately burn the attempt budget to force a recovery; recovery paths + require a different login method. + +## Comparison with WebAuthn and Passkeys + +It is useful to compare this custom implementation with the FIDO WebAuthn +standard and the user-facing concept of Passkeys. While they share core +cryptographic principles, their goals and scope are fundamentally different. + +### Similarities + +- Core Cryptography: Both approaches are built on public-key cryptography + (typically ECDSA), and use a challenge-response protocol + +### Key Differences + +- Standard vs. proprietary: + - WebAuthn/passkeys: An open, interoperable standard from the W3C and FIDO + Alliance, designed to work across different websites, apps, browsers, and + operating systems. + - This Design: A proprietary implementation tailored specifically for Ory's + native application and server. It is not intended to be interoperable with + any other system. However the design is based on building blocks that are + fully open and standardized: PKI, TPM 2.0, ASN1, iOS & Android device + attestation, etc. +- Goal: Device Binding vs. synced credentials: + - WebAuthn/passkeys: The primary goal is to create a convenient and portable + user credential (a Passkey). Passkeys are often syncable via a cloud service + (like iCloud Keychain or Google Password Manager), allowing a user who + enrolls on their phone to seamlessly sign in on their laptop without + re-enrolling. + - This design: The primary goal is strict device binding. We are proving that + a specific, individual piece of hardware is authorized. The key is + explicitly non-exportable and bound to a single installation of an app on a + single device. It physically cannot be synced or used elsewhere. +- Role of attestation: + - WebAuthn/passkeys: Attestation is an optional feature. While a server can + request it to verify the properties of an authenticator, many services skip + it in favor of a simpler user experience. The focus is on proving possession + of the key, not on scrutinizing the device itself. + - This design: Attestation is mandatory and central to the entire security + model. The main purpose of the enrollment ceremony is for the server to + validate the device's hardware and software integrity. + +## Further reading + +- [Android](https://developer.android.com/privacy-and-security/security-key-attestation) +- iOS/iPadOS: + [1](https://developer.apple.com/documentation/devicecheck/validating-apps-that-connect-to-your-server) + and + [2](https://developer.apple.com/documentation/devicecheck/establishing-your-app-s-integrity) diff --git a/src/components/Shared/kratos/passwordless/deviceauthn/ios.mdx b/src/components/Shared/kratos/passwordless/deviceauthn/ios.mdx new file mode 100644 index 0000000000..724a790246 --- /dev/null +++ b/src/components/Shared/kratos/passwordless/deviceauthn/ios.mdx @@ -0,0 +1,673 @@ +A notable difference with Android is that Apple's app attestation APIs require a +network call to Apple's servers from a real device. + +This means that the emulator cannot be used. + +Since Device Binding only is supported on native devices (not in the browser), +all corresponding API calls should be done using the endpoints for native apps, +to avoid having to pass cookies around manually. + +## Prerequisites + +The [second-factor guide](#second-factor-device-binding) below runs on a real +device (App Attest and the Secure Enclave are unavailable in the simulator, so +device binding cannot run there) with iOS 14 or newer. The first-factor PIN path +has the same base requirements and additionally needs: + +- The App Attest entitlement + `com.apple.developer.devicecheck.appattest-environment` set to `production` in + your app's entitlements. +- HPKE for the one-time transport channel: use `HPKE` from CryptoKit on iOS 17+, + or the [swift-crypto](https://github.com/apple/swift-crypto) package on iOS + 14–16. Do **not** use `SecKey` ECIES for transport — the wire contract fixes + the HPKE suite to DHKEM(X25519, HKDF-SHA256) / HKDF-SHA256 / AES-128-GCM, + which `SecKey` cannot produce. +- [swift-sodium](https://github.com/jedisct1/swift-sodium) for Argon2id + (`Sodium().pwHash`). +- CommonCrypto for the unauthenticated AES-CTR inner layer. + +The Secure Enclave sealing key uses `SecKey` ECIES (`SecKeyCreateEncryptedData`) +— this is separate from transport, and there it is the correct API. + +## Second-factor device binding + +1. Ensure that the `DeviceAuthn` strategy is enabled in the Kratos + configuration. This strategy implements the settings and login flow. This is + done so: + ```yaml + selfservice: + methods: + deviceauthn: + enabled: true + ``` +1. In XCode, add a permission so that the application is allowed to use FaceID. + In `Target settings > Info > Custom iOS Target Properties`, add: + - Key: `Privacy - Face ID Usage Description` + - Type: `String` + - Value: `This app uses FaceID to authenticate signing operations.` +1. Implement a runtime check for the OS version. If is lower than the + [documented ones](https://developer.apple.com/documentation/devicecheck/dcappattestservice), + Device Binding may not be used, and a fallback should be found, for example + using passkeys. +1. This guide covers the second-factor setup: the UI should only show existing + Device Binding keys and related buttons (e.g. to add a key) if the user is + currently logged in. This can be confirmed with a `whoami` call. For + first-factor PIN or biometric login, see + [First factor with PIN](#first-factor-with-pin). +1. Create a + [settings flow for native apps](https://www.ory.com/docs/reference/api#tag/frontend/operation/updateSettingsFlow). + The response contains the list of existing Device Binding keys. +1. To delete an existing key, + [complete the settings flow](https://www.ory.com/docs/reference/api#tag/frontend/operation/updateSettingsFlow) + with this payload: + + ```json + { + "delete": { + "client_key_id": "9c62918cbbef2e94c3a10238bd57ab196e3a2caae1a44f28b02f2d1a72b1e59c" + }, + "method": "deviceauthn" + } + ``` + + Or using the SDK: + + ```swift + let clientKeyId = "..." + + let flow = try await FrontendAPI.createNativeSettingsFlow( + xSessionToken: sessionToken + ) + + let body: UpdateSettingsFlowBody = + .typeUpdateSettingsFlowWithDeviceAuthnMethod( + UpdateSettingsFlowWithDeviceAuthnMethod( + delete: UpdateSettingsFlowWithDeviceAuthnMethodDelete( + clientKeyId: clientKeyId, + ), + method: "deviceauthn" + ) + ) + let finalFlow = try await FrontendAPI.updateSettingsFlow( + flow: flow.id, + updateSettingsFlowBody: body, + xSessionToken: sessionToken + ) + ``` + + Once the key has been deleted server-side, it is fine (although not required) + to also delete it on the device using the KeyStore API. + +1. To add a new key, + [complete the settings flow](https://www.ory.com/docs/reference/api#tag/frontend/operation/updateSettingsFlow) + with this payload: + + ```json + { + "method": "deviceauthn", + "add": { + "device_name": "iPhone (iPhone14,5)", + "attestation_ios": "..." + } + } + ``` + + Or using the SDK: + + ```swift + let flow = try await FrontendAPI.createNativeSettingsFlow( + xSessionToken: sessionToken + ) + + let nonce = extractNonceFromUiNodes(nodes: flow.ui.nodes) ?? "" + let deviceName = "My work phone" + let (keyId, attestation) = try await OryApi().createKey( + challengeB64: nonce + ) + + let body: UpdateSettingsFlowBody = + .typeUpdateSettingsFlowWithDeviceAuthnMethod( + UpdateSettingsFlowWithDeviceAuthnMethod( + add: UpdateSettingsFlowWithDeviceAuthnMethodAdd( + attestationIos: attestation, + deviceName: deviceName, + ), + method: "deviceauthn" + ) + ) + let finalFlow = try await FrontendAPI.updateSettingsFlow( + flow: flow.id, + updateSettingsFlowBody: body, + xSessionToken: sessionToken + ) + + // The server assigns the key's client_key_id — the lowercase-hex SHA-256 + // fingerprint of the public key. Read it from the updated flow (the value + // of the new key's `deviceauthn_remove` node) and store it together with + // the App Attest keyId: signing uses keyId, API calls use clientKeyId. + let clientKeyId = extractClientKeyIdFromUiNodes(nodes: finalFlow.ui.nodes) + ``` + + Once a key is created, the application must store both identifiers — the App + Attest `keyId` (needed to sign) and the server-assigned `client_key_id` + (needed to address the key in API calls) — because there are no APIs to list + keys or check if a key exists. Note that there is a maximum number of keys + that can be created for an identity, and there is no point to create multiple + keys for the same user on the same device, even though the server allows it. + +1. To use a key to step-up the AAL, + [complete the login flow](https://www.ory.com/docs/reference/api#tag/frontend/operation/updateLoginFlow) + with this payload: + + ```json + { + "signature": "...", + "client_key_id": "9c62918cbbef2e94c3a10238bd57ab196e3a2caae1a44f28b02f2d1a72b1e59c", + "method": "deviceauthn" + } + ``` + + Or using the SDK: + + ```swift + let keyId = "..." // The App Attest key id, used to sign. + let clientKeyId = "..." // The server-assigned key fingerprint. + + let flow = try await FrontendAPI.createNativeLoginFlow( + refresh: false, + aal: AuthenticatorAssuranceLevel.aal2.rawValue, + xSessionToken: sessionToken + ) + let nonce = extractNonceFromUiNodes(nodes: flow.ui.nodes) ?? "" + + let signature = try await OryApi().signWithKey( + keyId: keyId, + challengeB64: nonce, + ) + + let body = + UpdateLoginFlowBody + .typeUpdateLoginFlowWithDeviceAuthnMethod( + UpdateLoginFlowWithDeviceAuthnMethod( + clientKeyId: clientKeyId, + method: "deviceauthn", + signature: signature, + ) + ) + + let finalFlow = try await FrontendAPI.updateLoginFlow( + flow: flow.id, + updateLoginFlowBody: body, + xSessionToken: sessionToken + ) + ``` + +There are two required App Attest calls to create a key and use it to sign: + +```swift +import CryptoKit +import DeviceCheck +import Foundation +import LocalAuthentication +import OSLog +import Security + +public enum OryApiError: Error, LocalizedError { + case secureEnclaveError(String, OSStatus?) + case appAttestationNotSupported + case appAttestationError(String) + case biometricAuthenticationFailed(String?) + case biometricAuthenticationCancelled + + public var errorDescription: String? { + switch self { + case .secureEnclaveError(let message, let status): + let statusString = status != nil ? " (Status: \(status!))" : "" + return "Secure Enclave Error: \(message)\(statusString)" + case .appAttestationNotSupported: + return "App Attestation is not supported on this device." + case .appAttestationError(let message): + return "App Attestation Error: \(message)" + case .biometricAuthenticationFailed(let message): + return + "Biometric authentication failed: \(message ?? "Unknown error")" + case .biometricAuthenticationCancelled: + return "Biometric authentication canceled by user." + } + } +} + +public class OryApi { + public func createKey(challengeB64: String) + async throws -> (keyId: String, attestation: Data) + { + if #available(iOS 14.0, *) { + let service = DCAppAttestService.shared + guard service.isSupported else { + throw OryApiError.appAttestationNotSupported + } + + let keyId: String + do { + keyId = try await service.generateKey() + } catch { + let errorMessage = + "Failed to generate key: \(error.localizedDescription)" + throw OryApiError.appAttestationError(errorMessage) + } + + let challenge = Data(base64Encoded: challengeB64)! + let attestation = try await service.attestKey( + keyId, + clientDataHash: challenge + ) + + return (keyId, attestation) + } else { + // Fallback for older iOS versions + throw OryApiError.secureEnclaveError( + "iOS 14.0 or newer is required for App Attestation.", + nil + ) + } + } + + public func signWithKey(keyId: String, challengeB64: String) + async throws -> Data + { + if #available(iOS 14.0, watchOS 14.0, *) { + let context = LAContext() + let reason = "Authenticate to sign in" + do { + try await context.evaluatePolicy( + .deviceOwnerAuthenticationWithBiometrics, + localizedReason: reason + ) + } catch let error as LAError { + switch error.code { + case .userCancel, .appCancel, .systemCancel, .userFallback: + throw OryApiError.biometricAuthenticationCancelled + default: + throw OryApiError.biometricAuthenticationFailed( + error.localizedDescription + ) + } + } catch { + throw OryApiError.biometricAuthenticationFailed( + error.localizedDescription + ) + } + + let challenge = Data(base64Encoded: challengeB64)! + let assertion = try await DCAppAttestService.shared + .generateAssertion(keyId, clientDataHash: challenge) + + return assertion + } else { + throw OryApiError.secureEnclaveError( + "iOS 14.0 or newer is required for App Attestation.", + nil + ) + } + } +} +``` + +## First factor with PIN + +:::warning + +The first-factor signing key is attested over +`SHA256(nonce ‖ transport_public_key)`, **not** the bare nonce used by the +second-factor guide above. Using the bare nonce here fails with +`Unable to validate the key attestation: wrong challenge`. + +::: + +This section implements PIN enrollment, first-factor login, PIN change, and +secret rotation on iOS. It builds on the App Attest signing key from the +[second-factor guide](#second-factor-device-binding) above and adds the +transport, sealing, and PIN layers. + +- Read + + Client implementation requirements + + together with this section — the comments in the code below are normative and restate + those rules inline. + +### Reference implementation + +This listing is one complete recipe: nonce decoding, the enrollment and rotation +ceremony (transport key, attestation, sealed secret), the PIN vault (Secure +Enclave sealing key, Argon2id, AES-CTR, seal and unseal), the PIN proof, and +first-factor login. The `SettingsFlow` and +`UpdateLoginFlowWithDeviceAuthnMethod` types are Ory Swift SDK models. + +```swift +import CommonCrypto +import CryptoKit +import DeviceCheck +import Foundation +import Sodium + +enum DeviceAuthnPinError: Error { + case attestationUnsupported + case secureEnclaveUnavailable // never fall back to software — refuse PIN enrollment + case kdfFailed + case cryptoFailure // CommonCrypto/SecRandom failure — structural, never PIN-related + case localStateMissing // sealed blob or sealing key gone → re-enroll, not "wrong PIN" +} + +/// Decodes the value of the flow's hidden `deviceauthn_nonce` UI node: +/// base64(JSON {"nonce": ""}) → raw nonce bytes. +func decodeNonce(nodeValue: String) -> Data? { + guard let json = Data(base64Encoded: nodeValue), + let obj = try? JSONSerialization.jsonObject(with: json) as? [String: String], + let nonceB64 = obj["nonce"] + else { return nil } + return Data(base64Encoded: nonceB64) +} + +/// One PIN enrollment (or secret rotation) ceremony. Holds the ephemeral HPKE +/// transport keypair; create a fresh instance per ceremony and let it go out of +/// scope afterwards — the transport key must never be reused or persisted. +final class PinCeremony { + private let transportPrivateKey = Curve25519.KeyAgreement.PrivateKey() + private(set) var appAttestKeyId: String? + + /// Raw 32 bytes for the `transport_public_key` payload field (base64-encode it). + var transportPublicKey: Data { transportPrivateKey.publicKey.rawRepresentation } + + /// Creates and attests the device signing key for a PIN enrollment. + /// The challenge binds the transport key: SHA256(nonce ‖ t_pub) — NOT the + /// bare nonce (the bare nonce is the second-factor device-binding form). + func createPinAttestation(nonce: Data) async throws -> Data { + let service = DCAppAttestService.shared + guard service.isSupported else { throw DeviceAuthnPinError.attestationUnsupported } + let keyId = try await service.generateKey() + appAttestKeyId = keyId + let challenge = Data(SHA256.hash(data: nonce + transportPublicKey)) + return try await service.attestKey(keyId, clientDataHash: challenge) + } + + /// Signs the rotate-secret challenge with an existing key: the raw + /// concatenation nonce ‖ t_pub, NOT pre-hashed. + func signRotationChallenge(nonce: Data, appAttestKeyId: String) async throws -> Data { + try await DCAppAttestService.shared.generateAssertion( + appAttestKeyId, clientDataHash: nonce + transportPublicKey) + } + + /// Opens the one-time sealed secret from the response's continue_with item + /// {"action": "show_pin_entry_ui", "data": {"enc", "ciphertext"}}. + /// Suite (fixed): DHKEM(X25519, HKDF-SHA256) / HKDF-SHA256 / AES-128-GCM. + /// AAD is the client_key_id string. + func openSealedSecret(enc: Data, ciphertext: Data, clientKeyId: String) throws -> Data { + var recipient = try HPKE.Recipient( + privateKey: transportPrivateKey, + ciphersuite: .Curve25519_SHA256_AES_GCM_128, + info: Data("ory/deviceauthn/pin-secret/v1".utf8), + encapsulatedKey: enc + ) + return try recipient.open(ciphertext, authenticating: Data(clientKeyId.utf8)) + } +} + +/// Reads the new key's client_key_id from the updated settings flow: the +/// deviceauthn_remove node whose meta context has the newest created_at. +/// (client_key_id is the lowercase-hex SHA-256 of the device public key in +/// SubjectPublicKeyInfo DER form — the server derives it, and it is NOT part +/// of continue_with.) +func clientKeyId(fromUpdatedFlow flow: SettingsFlow) -> String? { + flow.ui.nodes + .filter { $0.group == "deviceauthn" && $0.attributes.name == "deviceauthn_remove" } + .compactMap { node -> (String, String)? in + guard let value = node.attributes.value, + let createdAt = node.meta.label?.context?["created_at"] as? String + else { return nil } + return (value, createdAt) + } + .max { $0.1 < $1.1 }?.0 +} + +/// The local artifacts persisted after sealing. Store in the Keychain with +/// kSecAttrAccessibleWhenUnlockedThisDeviceOnly — never in UserDefaults — so +/// deleting the app purges them. None of these are secret on their own. +struct PinArtifacts: Codable { + let version: Int // format version of this recipe + let clientKeyId: String + let appAttestKeyId: String + let salt: Data // Argon2id salt — fresh on EVERY seal + let iv: Data // AES-CTR IV — fresh on EVERY seal + let opsLimit: Int // Argon2id parameters chosen at enrollment + let memLimit: Int + let sealingKeyTag: String + let sealed: Data // ECIES(SE key, AES-CTR(pinKey, pin_secret)) +} + +enum PinVault { + /// Creates the Secure Enclave sealing key. Fails closed: if the Secure + /// Enclave is unavailable, PIN enrollment must be refused — never use a + /// software key. No .userPresence/.biometryCurrentSet flag: the PIN is the + /// gate, the key must not prompt. + static func createSealingKey(tag: String) throws -> SecKey { + let access = SecAccessControlCreateWithFlags( + nil, + kSecAttrAccessibleWhenUnlockedThisDeviceOnly, + [.privateKeyUsage], + nil + )! + let attributes: [String: Any] = [ + kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom, + kSecAttrKeySizeInBits as String: 256, + kSecAttrTokenID as String: kSecAttrTokenIDSecureEnclave, + kSecPrivateKeyAttrs as String: [ + kSecAttrIsPermanent as String: true, + kSecAttrApplicationTag as String: Data(tag.utf8), + kSecAttrAccessControl as String: access, + ], + ] + var error: Unmanaged? + guard let key = SecKeyCreateRandomKey(attributes as CFDictionary, &error) else { + throw DeviceAuthnPinError.secureEnclaveUnavailable + } + return key + } + + static func argon2id(pin: [UInt8], salt: [UInt8], opsLimit: Int, memLimit: Int) throws -> [UInt8] { + guard + let key = Sodium().pwHash.hash( + outputLength: 32, passwd: pin, salt: salt, + opsLimit: opsLimit, memLimit: memLimit, alg: .Argon2ID13) + else { throw DeviceAuthnPinError.kdfFailed } + return key + } + + /// Unauthenticated AES-CTR — encrypt and decrypt are the same operation. + /// Deliberately no MAC, checksum, or format marker: a wrong PIN must yield + /// plausible garbage, never a locally detectable failure. A thrown error is + /// structural — key and IV sizes are fixed, so the status can never depend + /// on the PIN — and must not be presented as "wrong PIN". + static func aesCtr(key: [UInt8], iv: [UInt8], data: [UInt8]) throws -> [UInt8] { + var cryptor: CCCryptorRef? + var status = CCCryptorCreateWithMode( + CCOperation(kCCEncrypt), CCMode(kCCModeCTR), CCAlgorithm(kCCAlgorithmAES128), + CCPadding(ccNoPadding), iv, key, key.count, nil, 0, 0, 0, &cryptor) + guard status == CCCryptorStatus(kCCSuccess), let cryptor else { + throw DeviceAuthnPinError.cryptoFailure + } + defer { CCCryptorRelease(cryptor) } + var out = [UInt8](repeating: 0, count: data.count) + var moved = 0 + status = CCCryptorUpdate(cryptor, data, data.count, &out, out.count, &moved) + guard status == CCCryptorStatus(kCCSuccess) else { + throw DeviceAuthnPinError.cryptoFailure + } + return out + } + + /// Seals pin_secret under the PIN. Generates a FRESH salt and IV — reusing + /// either across seals leaks the secret via CTR keystream reuse. + static func seal( + pinSecret: inout [UInt8], pin: inout [UInt8], sealingKey: SecKey, + clientKeyId: String, appAttestKeyId: String, sealingKeyTag: String, + opsLimit: Int, memLimit: Int + ) throws -> PinArtifacts { + defer { + // Zeroize: the PIN and the secret must not outlive the ceremony. + for i in pinSecret.indices { pinSecret[i] = 0 } + for i in pin.indices { pin[i] = 0 } + } + // A failed random source must abort the seal: an all-zero salt or IV + // would break the fresh-salt-and-IV rule above. + var salt = [UInt8](repeating: 0, count: 16) + var iv = [UInt8](repeating: 0, count: 16) + guard SecRandomCopyBytes(kSecRandomDefault, salt.count, &salt) == errSecSuccess, + SecRandomCopyBytes(kSecRandomDefault, iv.count, &iv) == errSecSuccess + else { throw DeviceAuthnPinError.cryptoFailure } + + var pinKey = try argon2id(pin: pin, salt: salt, opsLimit: opsLimit, memLimit: memLimit) + defer { for i in pinKey.indices { pinKey[i] = 0 } } + let inner = try aesCtr(key: pinKey, iv: iv, data: pinSecret) + + let publicKey = SecKeyCopyPublicKey(sealingKey)! + var error: Unmanaged? + guard + let sealed = SecKeyCreateEncryptedData( + publicKey, .eciesEncryptionCofactorVariableIVX963SHA256AESGCM, + Data(inner) as CFData, &error) as Data? + else { throw DeviceAuthnPinError.secureEnclaveUnavailable } + + return PinArtifacts( + version: 1, clientKeyId: clientKeyId, appAttestKeyId: appAttestKeyId, + salt: Data(salt), iv: Data(iv), opsLimit: opsLimit, memLimit: memLimit, + sealingKeyTag: sealingKeyTag, sealed: sealed) + } + + /// Unseals with the entered PIN. ALWAYS returns 32 bytes: a wrong PIN + /// yields garbage that only the server can falsify. A failure here is + /// structural (missing key/blob) and must route to re-enrollment. + static func unseal(artifacts: PinArtifacts, pin: inout [UInt8], sealingKey: SecKey) throws -> [UInt8] { + defer { for i in pin.indices { pin[i] = 0 } } + var error: Unmanaged? + guard + let inner = SecKeyCreateDecryptedData( + sealingKey, .eciesEncryptionCofactorVariableIVX963SHA256AESGCM, + artifacts.sealed as CFData, &error) as Data? + else { throw DeviceAuthnPinError.localStateMissing } + var pinKey = try argon2id( + pin: pin, salt: [UInt8](artifacts.salt), + opsLimit: artifacts.opsLimit, memLimit: artifacts.memLimit) + defer { for i in pinKey.indices { pinKey[i] = 0 } } + return try aesCtr(key: pinKey, iv: [UInt8](artifacts.iv), data: [UInt8](inner)) + } +} + +/// pin_proof = HMAC-SHA256(pin_secret, "ory/deviceauthn/pin-proof/v1" ‖ client_key_id ‖ nonce). +func pinProof(pinSecret: [UInt8], clientKeyId: String, nonce: Data) -> Data { + var message = Data("ory/deviceauthn/pin-proof/v1".utf8) + message.append(Data(clientKeyId.utf8)) + message.append(nonce) + return Data(HMAC.authenticationCode(for: message, using: SymmetricKey(data: pinSecret))) +} + +/// First-factor login with a PIN key. +func loginWithPin(flowNonce: Data, pin: inout [UInt8], artifacts: PinArtifacts, sealingKey: SecKey) + async throws -> UpdateLoginFlowWithDeviceAuthnMethod +{ + var pinSecret = try PinVault.unseal(artifacts: artifacts, pin: &pin, sealingKey: sealingKey) + defer { for i in pinSecret.indices { pinSecret[i] = 0 } } + let proof = pinProof(pinSecret: pinSecret, clientKeyId: artifacts.clientKeyId, nonce: flowNonce) + // The login signature covers the RAW nonce (no transport key at login). + let assertion = try await DCAppAttestService.shared.generateAssertion( + artifacts.appAttestKeyId, clientDataHash: flowNonce) + return UpdateLoginFlowWithDeviceAuthnMethod( + clientKeyId: artifacts.clientKeyId, + method: "deviceauthn", + pinProof: proof, + signature: assertion + ) +} +``` + +See PIN +enrollment for the payload reference. To wire the +enrollment ceremony end to end: decode the flow nonce with `decodeNonce`, create +a `PinCeremony`, `createPinAttestation`, submit the `add` payload with +`transport_public_key` and `attestation_ios`, then `openSealedSecret` on the +returned `continue_with`, capture the PIN, `PinVault.seal`, and persist the +`PinArtifacts`. Let the `PinCeremony` go out of scope so its transport key is +destroyed. + +## Biometric keys + +Biometric (`platform`) keys skip the PIN machinery entirely — no transport key, +no sealed secret, no `pin_proof`. The Secure Enclave gates the signing key +itself and shows the Face ID or Touch ID prompt when the key signs. Three +differences from the PIN flow: + +- The attestation challenge is the **bare nonce**, not `SHA256(nonce ‖ t_pub)`. + Pass the raw nonce bytes straight to `attestKey` as `clientDataHash`. +- Create the signing key with `.biometryCurrentSet` in its access control, and + enroll it with `"user_verification": "platform"` and no `pin_protected` or + `transport_public_key`. +- At login, submit only `client_key_id` and `signature` (the assertion over the + bare nonce) — omit `pin_proof`. + +For the App Attest `generateKey` / `attestKey` / `generateAssertion` +scaffolding, reuse the `OryApi` helper from the +[second-factor guide](#second-factor-device-binding). The PIN listing above +calls `DCAppAttestService` directly only to keep the challenge computation +visible. + +- See the + + configuration reference + + for the `ios_biometric_first_factor` opt-in that biometric first-factor login on + iOS also requires. + +## Changing the PIN and rotating the secret + +**Changing the PIN is a purely local operation** — no server call. Unseal the +secret with the old PIN, then seal it again with the new PIN. `PinVault.seal` +generates a fresh salt and IV, so the whole stored blob changes, but the +`pin_secret`, `client_key_id`, and signing key are unchanged. The server never +learns that the PIN changed. + +```swift +var oldPin: [UInt8] = /* entered old PIN */ +var pinSecret = try PinVault.unseal(artifacts: artifacts, pin: &oldPin, sealingKey: sealingKey) +defer { for i in pinSecret.indices { pinSecret[i] = 0 } } + +var newPin: [UInt8] = /* entered new PIN */ +let updated = try PinVault.seal( + pinSecret: &pinSecret, pin: &newPin, sealingKey: sealingKey, + clientKeyId: artifacts.clientKeyId, appAttestKeyId: artifacts.appAttestKeyId, + sealingKeyTag: artifacts.sealingKeyTag, + opsLimit: artifacts.opsLimit, memLimit: artifacts.memLimit) +// Persist `updated` in place of the old artifacts. +``` + +**Rotating the secret needs the server.** It is the recovery path for a +forgotten PIN or a locked key: the server issues a fresh `pin_secret` for the +same signing key. Start a settings flow under a privileged session, then: + +1. Create a fresh `PinCeremony` — a new ephemeral transport key. +2. Call `signRotationChallenge(nonce:appAttestKeyId:)` with the flow nonce and + the key's App Attest key id. It signs the raw `nonce ‖ t_pub` concatenation, + unhashed. +3. Submit the `rotate_secret` payload with `client_key_id`, the fresh + `transport_public_key`, and that signature (see + + Rotating the PIN secret + + ). +4. Open the new secret from the response's `continue_with` with + `openSealedSecret`, exactly as at enrollment. +5. Capture the user's PIN — a new one if they forgot the old — and + `PinVault.seal` the new secret with a fresh salt and IV, then replace the + stored artifacts. + +The signing key and its `client_key_id` never change; only the sealed secret +does. Only PIN keys can be rotated. diff --git a/vercel.json b/vercel.json index d81ed8efa2..59f801c788 100644 --- a/vercel.json +++ b/vercel.json @@ -1911,6 +1911,11 @@ "source": "/docs/network/keto/quickstarts/quickstart", "destination": "/docs/network/keto/overview", "permanent": true + }, + { + "source": "/docs/kratos/passwordless/deviceauthn", + "destination": "/docs/network/kratos/passwordless/deviceauthn", + "permanent": true } ], "headers": [