Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -487,8 +487,11 @@ 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 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
Expand Down
51 changes: 38 additions & 13 deletions src/components/Shared/kratos/passwordless/deviceauthn/ios.mdx
Original file line number Diff line number Diff line change
@@ -1,16 +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 that the emulator cannot be used.
This means the iOS Simulator cannot produce real attestations. For development and testing you can still exercise the flow on the
Simulator by enabling <SameDeploymentLink to="kratos/passwordless/deviceauthn#relaxed-attestation-for-testing">relaxed
attestation</SameDeploymentLink>, 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.
Expand Down Expand Up @@ -334,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
Expand Down Expand Up @@ -423,9 +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 — so
/// deleting the app purges them. 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
Expand Down Expand Up @@ -591,14 +607,23 @@ 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 — 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
Expand Down
Loading