From 638e3de6bf33ea8353b7be0cde706378a3c168c7 Mon Sep 17 00:00:00 2001 From: Henning Perl Date: Thu, 23 Jul 2026 11:09:41 +0200 Subject: [PATCH 1/3] docs: correct iOS device-authentication guide - The iOS Simulator can run device auth via relaxed attestation and the OryClientSimulator product, rather than being unusable outright. - App Attest keys cannot be biometric-gated through access control; the client gates signing with a LocalAuthentication check the server trusts. - Keychain items survive app deletion; kSecAttrAccessibleWhenUnlockedThisDeviceOnly protects backups and device migration, and a fresh install must clear stale keys. Co-Authored-By: Claude Opus 4.8 --- .../kratos/passwordless/deviceauthn/index.mdx | 6 ++-- .../kratos/passwordless/deviceauthn/ios.mdx | 30 ++++++++++++++----- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/src/components/Shared/kratos/passwordless/deviceauthn/index.mdx b/src/components/Shared/kratos/passwordless/deviceauthn/index.mdx index 7e2b4e324..4f12e9dd0 100644 --- a/src/components/Shared/kratos/passwordless/deviceauthn/index.mdx +++ b/src/components/Shared/kratos/passwordless/deviceauthn/index.mdx @@ -487,8 +487,10 @@ After opening the one-time `pin_secret`: 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. + with `kSecAttrAccessibleWhenUnlockedThisDeviceOnly` (never `UserDefaults`) so the blob stays off encrypted backups and device + migrations. Keychain items survive app deletion, so detect a reinstall — for example with a `UserDefaults` install marker, + which the system clears on deletion — and drop any stale artifacts before use. Android Keystore keys are purged on uninstall + automatically, along with their hardware-backed sealing key. 5. Wipe the PIN, `pinKey`, `pin_secret`, and the transport private key from memory. ### Hard rules diff --git a/src/components/Shared/kratos/passwordless/deviceauthn/ios.mdx b/src/components/Shared/kratos/passwordless/deviceauthn/ios.mdx index e78e2e95b..5016cd68d 100644 --- a/src/components/Shared/kratos/passwordless/deviceauthn/ios.mdx +++ b/src/components/Shared/kratos/passwordless/deviceauthn/ios.mdx @@ -1,7 +1,11 @@ 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. +This means the iOS Simulator cannot produce real attestations. For development and testing you can still run on the Simulator by +enabling relaxed attestation, +which accepts software-backed attestations. The Ory Swift SDK ships an `OryClientSimulator` product that generates such an +attestation on the Simulator; its code compiles to nothing in device builds, so it can never ship in production. Relaxed +attestation is refused once the project leaves the development environment. 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. @@ -424,8 +428,12 @@ func clientKeyId(fromUpdatedFlow flow: SettingsFlow) -> String? { } /// 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. +/// kSecAttrAccessibleWhenUnlockedThisDeviceOnly — never in UserDefaults. The +/// ThisDeviceOnly attribute keeps the blob off encrypted backups and device +/// migrations, so it cannot follow the sealed secret onto another device. +/// Keychain items survive app deletion, so detect a reinstall (for example with +/// a UserDefaults install marker, which the system does clear on deletion) and +/// clear any stale artifacts before use. None of these are secret on their own. struct PinArtifacts: Codable { let version: Int // format version of this recipe let clientKeyId: String @@ -591,14 +599,20 @@ 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: +Biometric (`platform`) keys skip the PIN machinery entirely — no transport key, no sealed secret, no `pin_proof`. Unlike a PIN +key, the client gates each signature behind a biometric check. App Attest signing is silent, and an App Attest key cannot be +bound to Face ID or Touch ID through its access control, because App Attest keys are system-managed and accept no access-control +flags. The app must therefore run the biometric check itself immediately before it signs. The server trusts the `platform` +declaration on iOS because it cannot verify biometric gating remotely, which makes the client-side gate the security-critical +part of this mode. 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`. +- Enroll with `"user_verification": "platform"` and no `pin_protected` or `transport_public_key`. There is no access-control flag + to set on the key; instead, gate signing with a `LocalAuthentication` check + (`LAContext.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics)`) that must succeed before every `generateAssertion` call, + at both enrollment and login. To approximate `.biometryCurrentSet` semantics — invalidating the key when the enrolled biometric + set changes — pin the context's `evaluatedPolicyDomainState` at enrollment and reject a login when it no longer matches. - 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 From 18caa6c8196eb375187ce4b4dcacd524ddd41ff3 Mon Sep 17 00:00:00 2001 From: Henning Perl Date: Fri, 24 Jul 2026 08:29:47 +0200 Subject: [PATCH 2/3] chore: format --- .../kratos/passwordless/deviceauthn/ios.mdx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/components/Shared/kratos/passwordless/deviceauthn/ios.mdx b/src/components/Shared/kratos/passwordless/deviceauthn/ios.mdx index 5016cd68d..597446561 100644 --- a/src/components/Shared/kratos/passwordless/deviceauthn/ios.mdx +++ b/src/components/Shared/kratos/passwordless/deviceauthn/ios.mdx @@ -2,10 +2,10 @@ A notable difference with Android is that Apple's app attestation APIs require a device. This means the iOS Simulator cannot produce real attestations. For development and testing you can still run on the Simulator by -enabling relaxed attestation, -which accepts software-backed attestations. The Ory Swift SDK ships an `OryClientSimulator` product that generates such an -attestation on the Simulator; its code compiles to nothing in device builds, so it can never ship in production. Relaxed -attestation is refused once the project leaves the development environment. +enabling relaxed +attestation, which accepts software-backed attestations. The Ory Swift SDK ships an `OryClientSimulator` +product that generates such an attestation on the Simulator; its code compiles to nothing in device builds, so it can never ship +in production. Relaxed attestation is refused once the project leaves the development environment. 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. @@ -600,11 +600,11 @@ 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`. Unlike a PIN -key, the client gates each signature behind a biometric check. App Attest signing is silent, and an App Attest key cannot be -bound to Face ID or Touch ID through its access control, because App Attest keys are system-managed and accept no access-control -flags. The app must therefore run the biometric check itself immediately before it signs. The server trusts the `platform` -declaration on iOS because it cannot verify biometric gating remotely, which makes the client-side gate the security-critical -part of this mode. Three differences from the PIN flow: +key, the client gates each signature behind a biometric check. App Attest signing is silent, and an App Attest key cannot be bound +to Face ID or Touch ID through its access control, because App Attest keys are system-managed and accept no access-control flags. +The app must therefore run the biometric check itself immediately before it signs. The server trusts the `platform` declaration on +iOS because it cannot verify biometric gating remotely, which makes the client-side gate the security-critical part of this mode. +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`. From c2e71e05e90c9ee88a0a372515bec8379a13e39a Mon Sep 17 00:00:00 2001 From: Henning Perl Date: Mon, 27 Jul 2026 11:05:37 +0200 Subject: [PATCH 3/3] docs: address review on iOS device-authentication guide - Reconcile the Simulator guidance: relaxed attestation exercises the flow, but App Attest and the Secure Enclave still require a real device. - Make reinstall detection an explicit instruction, and spell out the UserDefaults marker mechanism. - Show LAContext.evaluatePolicy as an instance call with localizedReason. - Recommend domainState.biometry.stateHash on iOS 18+, since evaluatedPolicyDomainState is deprecated there. - Rename "Ory Swift SDK" to "Ory client SDK for Swift". Co-Authored-By: Claude Opus 5 (1M context) --- .../kratos/passwordless/deviceauthn/index.mdx | 7 +-- .../kratos/passwordless/deviceauthn/ios.mdx | 51 +++++++++++-------- 2 files changed, 35 insertions(+), 23 deletions(-) diff --git a/src/components/Shared/kratos/passwordless/deviceauthn/index.mdx b/src/components/Shared/kratos/passwordless/deviceauthn/index.mdx index 4f12e9dd0..57350d029 100644 --- a/src/components/Shared/kratos/passwordless/deviceauthn/index.mdx +++ b/src/components/Shared/kratos/passwordless/deviceauthn/index.mdx @@ -488,9 +488,10 @@ After opening the one-time `pin_secret`: 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 the blob stays off encrypted backups and device - migrations. Keychain items survive app deletion, so detect a reinstall — for example with a `UserDefaults` install marker, - which the system clears on deletion — and drop any stale artifacts before use. Android Keystore keys are purged on uninstall - automatically, along with their hardware-backed sealing key. + migrations. Keychain items survive app deletion, so you must detect a reinstall yourself and delete the stale artifacts before + the app uses them: write a marker to `UserDefaults` at first launch, which the system clears on deletion, and treat a stored + blob without its marker as a reinstall. Android Keystore keys are purged on uninstall automatically, along with their + hardware-backed sealing key. 5. Wipe the PIN, `pinKey`, `pin_secret`, and the transport private key from memory. ### Hard rules diff --git a/src/components/Shared/kratos/passwordless/deviceauthn/ios.mdx b/src/components/Shared/kratos/passwordless/deviceauthn/ios.mdx index 597446561..e1363f567 100644 --- a/src/components/Shared/kratos/passwordless/deviceauthn/ios.mdx +++ b/src/components/Shared/kratos/passwordless/deviceauthn/ios.mdx @@ -1,20 +1,22 @@ 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 the iOS Simulator cannot produce real attestations. For development and testing you can still run on the Simulator by -enabling relaxed -attestation, which accepts software-backed attestations. The Ory Swift SDK ships an `OryClientSimulator` -product that generates such an attestation on the Simulator; its code compiles to nothing in device builds, so it can never ship -in production. Relaxed attestation is refused once the project leaves the development environment. +This means the iOS Simulator cannot produce real attestations. For development and testing you can still exercise the flow on the +Simulator by enabling relaxed +attestation, which accepts software-backed attestations. The Ory client SDK for Swift ships an +`OryClientSimulator` product that generates such an attestation on the Simulator; its code compiles to nothing in device builds, +so it can never ship in production. Relaxed attestation is refused once the project leaves the development environment. It does +not, however, make the hardware-backed flows themselves work in the Simulator — see [Prerequisites](#prerequisites) below. 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 [second-factor guide](#second-factor-device-binding) below runs on a real device with iOS 14 or newer, because App Attest and +the Secure Enclave are unavailable in the Simulator. Relaxed attestation stands in for App Attest during development, but nothing +stands in for the Secure Enclave, so the PIN path — whose sealing key must be hardware-backed — can only be exercised end to end +on a device. 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. @@ -338,7 +340,7 @@ signing key from the [second-factor guide](#second-factor-device-binding) above 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. +The `SettingsFlow` and `UpdateLoginFlowWithDeviceAuthnMethod` types are models from the Ory client SDK for Swift. ```swift import CommonCrypto @@ -427,13 +429,19 @@ func clientKeyId(fromUpdatedFlow flow: SettingsFlow) -> String? { .max { $0.1 < $1.1 }?.0 } -/// The local artifacts persisted after sealing. Store in the Keychain with -/// kSecAttrAccessibleWhenUnlockedThisDeviceOnly — never in UserDefaults. The -/// ThisDeviceOnly attribute keeps the blob off encrypted backups and device -/// migrations, so it cannot follow the sealed secret onto another device. -/// Keychain items survive app deletion, so detect a reinstall (for example with -/// a UserDefaults install marker, which the system does clear on deletion) and -/// clear any stale artifacts before use. None of these are secret on their own. +/// The local artifacts persisted after sealing. Store the whole struct in the +/// Keychain with kSecAttrAccessibleWhenUnlockedThisDeviceOnly, never in +/// UserDefaults. The ThisDeviceOnly attribute keeps the blob out of encrypted +/// backups and device migrations, so the sealed secret cannot follow the user +/// onto another device. +/// +/// Keychain items survive app deletion, so you must detect a reinstall yourself +/// and delete the stale artifacts before the app uses them. The system does +/// clear UserDefaults on deletion, so write a marker there at first launch: if +/// the Keychain holds a PinArtifacts but the marker is gone, the app was +/// reinstalled. +/// +/// None of these fields are secret on their own. struct PinArtifacts: Codable { let version: Int // format version of this recipe let clientKeyId: String @@ -609,10 +617,13 @@ 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`. - Enroll with `"user_verification": "platform"` and no `pin_protected` or `transport_public_key`. There is no access-control flag - to set on the key; instead, gate signing with a `LocalAuthentication` check - (`LAContext.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics)`) that must succeed before every `generateAssertion` call, - at both enrollment and login. To approximate `.biometryCurrentSet` semantics — invalidating the key when the enrolled biometric - set changes — pin the context's `evaluatedPolicyDomainState` at enrollment and reject a login when it no longer matches. + to set on the key; instead, gate signing with a `LocalAuthentication` check — call + `evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason:)` on an `LAContext` instance — that must succeed + before every `generateAssertion` call, at both enrollment and login. To approximate `.biometryCurrentSet` semantics — + invalidating the key when the enrolled biometric set changes — record the biometric domain state at enrollment and reject a + login when it no longer matches. Read it from `context.domainState.biometry.stateHash` on iOS 18 and newer, or from the + deprecated `evaluatedPolicyDomainState` on iOS 14–17. Treat a mismatch as "re-enroll", not "account lost": the legacy value can + also change across a major OS upgrade even when the enrolled biometrics did not. - 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