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": [