From 761400e67af6f9a8aed73d3dc830bad6b218314a Mon Sep 17 00:00:00 2001 From: Henning Perl Date: Fri, 10 Jul 2026 17:58:05 +0200 Subject: [PATCH 01/18] docs: add device PIN and biometrics page skeleton Co-Authored-By: Claude Fable 5 --- .../passwordless/09_deviceauthn-pin.mdx | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 docs/kratos/passwordless/09_deviceauthn-pin.mdx diff --git a/docs/kratos/passwordless/09_deviceauthn-pin.mdx b/docs/kratos/passwordless/09_deviceauthn-pin.mdx new file mode 100644 index 0000000000..a595658972 --- /dev/null +++ b/docs/kratos/passwordless/09_deviceauthn-pin.mdx @@ -0,0 +1,112 @@ +--- +id: deviceauthn-pin +title: Device authentication with PIN and biometrics +sidebar_label: Device PIN & biometrics +--- + +import Mermaid from "@site/src/theme/Mermaid" + +Device authentication with PIN and biometrics turns an enrolled device into a complete passwordless login: an app-level 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. + +This builds on [device binding](08_deviceauthn.mdx), which covers the underlying strategy, enrollment as a second factor, and +platform requirements. This page covers the first-factor mode. + +Key properties: + +- 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. + +Availability: Ory Network and Ory Enterprise License. Platform support matches device binding: iOS 14.0+ and Android SDK 24+, +native apps only (API flows). + +## 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. + +## Configuration + +```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 [device binding](08_deviceauthn.mdx#relaxed-attestation-for-testing) and only takes effect in development environments. From f4ad4adbd62c47abb7a6fe2456dfc282ba91bfe1 Mon Sep 17 00:00:00 2001 From: Henning Perl Date: Fri, 10 Jul 2026 18:02:26 +0200 Subject: [PATCH 02/18] docs: add deviceauthn PIN protocol reference Co-Authored-By: Claude Fable 5 --- .../passwordless/09_deviceauthn-pin.mdx | 184 ++++++++++++++++++ 1 file changed, 184 insertions(+) diff --git a/docs/kratos/passwordless/09_deviceauthn-pin.mdx b/docs/kratos/passwordless/09_deviceauthn-pin.mdx index a595658972..368ce6b1af 100644 --- a/docs/kratos/passwordless/09_deviceauthn-pin.mdx +++ b/docs/kratos/passwordless/09_deviceauthn-pin.mdx @@ -110,3 +110,187 @@ selfservice: On Ory Network all of these are available through the project configuration. Relaxed attestation for emulator testing is described in [device binding](08_deviceauthn.mdx#relaxed-attestation-for-testing) and only takes effect in development environments. + +## 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. + +### 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: PUT /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). + +### 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: PUT /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 [device binding](08_deviceauthn.mdx). + +### 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. From e8d9457a2880e59c2fe4b10a54c14a52d25992b6 Mon Sep 17 00:00:00 2001 From: Henning Perl Date: Fri, 10 Jul 2026 18:07:51 +0200 Subject: [PATCH 03/18] docs: add deviceauthn PIN client implementation requirements Co-Authored-By: Claude Fable 5 --- .../passwordless/09_deviceauthn-pin.mdx | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/docs/kratos/passwordless/09_deviceauthn-pin.mdx b/docs/kratos/passwordless/09_deviceauthn-pin.mdx index 368ce6b1af..70f2b5e2a8 100644 --- a/docs/kratos/passwordless/09_deviceauthn-pin.mdx +++ b/docs/kratos/passwordless/09_deviceauthn-pin.mdx @@ -294,3 +294,60 @@ possession of the enrolled key: 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. + +## 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; `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. From 2d3230145786dc07b0a4f4bd97d3f4c188e3e3f9 Mon Sep 17 00:00:00 2001 From: Henning Perl Date: Fri, 10 Jul 2026 18:14:55 +0200 Subject: [PATCH 04/18] docs: add deviceauthn PIN iOS guide Append the iOS guide to the device authentication PIN page: prerequisites, a full Swift reference implementation, biometric-key notes, and the PIN change / secret rotation flows. Co-Authored-By: Claude Fable 5 --- .../passwordless/09_deviceauthn-pin.mdx | 319 ++++++++++++++++++ 1 file changed, 319 insertions(+) diff --git a/docs/kratos/passwordless/09_deviceauthn-pin.mdx b/docs/kratos/passwordless/09_deviceauthn-pin.mdx index 70f2b5e2a8..e6c609b6ee 100644 --- a/docs/kratos/passwordless/09_deviceauthn-pin.mdx +++ b/docs/kratos/passwordless/09_deviceauthn-pin.mdx @@ -351,3 +351,322 @@ iterations, parallelism 4. | 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. + +## iOS guide + +This guide implements PIN enrollment, first-factor login, PIN change, and secret rotation on iOS. It builds on the App Attest +signing key from [device binding](08_deviceauthn.mdx) and adds the transport, sealing, and PIN layers this page describes. Read it +together with [Client implementation requirements](#client-implementation-requirements) — the comments in the code below are +normative and restate those rules inline. + +### Prerequisites + +- The App Attest entitlement `com.apple.developer.devicecheck.appattest-environment` set to `production` in your app's + entitlements. +- A real device. App Attest and the Secure Enclave are unavailable in the simulator, so PIN enrollment cannot run there. +- iOS 14 or newer. +- 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. + +### 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 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. + static func aesCtr(key: [UInt8], iv: [UInt8], data: [UInt8]) -> [UInt8] { + var cryptor: CCCryptorRef? + CCCryptorCreateWithMode( + CCOperation(kCCEncrypt), CCMode(kCCModeCTR), CCAlgorithm(kCCAlgorithmAES128), + CCPadding(ccNoPadding), iv, key, key.count, nil, 0, 0, 0, &cryptor) + defer { CCCryptorRelease(cryptor) } + var out = [UInt8](repeating: 0, count: data.count) + var moved = 0 + CCCryptorUpdate(cryptor, data, data.count, &out, out.count, &moved) + 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 } + } + var salt = [UInt8](repeating: 0, count: 16) + _ = SecRandomCopyBytes(kSecRandomDefault, salt.count, &salt) + var iv = [UInt8](repeating: 0, count: 16) + _ = SecRandomCopyBytes(kSecRandomDefault, iv.count, &iv) + + var pinKey = try argon2id(pin: pin, salt: salt, opsLimit: opsLimit, memLimit: memLimit) + defer { for i in pinKey.indices { pinKey[i] = 0 } } + let inner = 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 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: [UInt8], artifacts: PinArtifacts, sealingKey: SecKey) + async throws -> UpdateLoginFlowWithDeviceAuthnMethod +{ + var pin = pin + 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 + ) +} +``` + +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` (see +[PIN enrollment](#pin-enrollment)), 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 +[device binding](08_deviceauthn.mdx#how-to-implement-device-binding-in-your-iosipados-application). The PIN listing above calls +`DCAppAttestService` directly only to keep the challenge computation visible. On iOS, biometric first-factor login also requires +the `ios_biometric_first_factor` opt-in — see [Configuration](#configuration). + +### 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](#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. From 0de862e8eb77be49b43fd60b4d98c2d65d384068 Mon Sep 17 00:00:00 2001 From: Henning Perl Date: Fri, 10 Jul 2026 18:23:10 +0200 Subject: [PATCH 05/18] docs: add deviceauthn PIN Android guide Co-Authored-By: Claude Fable 5 --- .../passwordless/09_deviceauthn-pin.mdx | 352 ++++++++++++++++++ 1 file changed, 352 insertions(+) diff --git a/docs/kratos/passwordless/09_deviceauthn-pin.mdx b/docs/kratos/passwordless/09_deviceauthn-pin.mdx index e6c609b6ee..b9a0780b59 100644 --- a/docs/kratos/passwordless/09_deviceauthn-pin.mdx +++ b/docs/kratos/passwordless/09_deviceauthn-pin.mdx @@ -670,3 +670,355 @@ let updated = try PinVault.seal( replace the stored artifacts. The signing key and its `client_key_id` never change; only the sealed secret does. + +## Android guide + +This guide implements PIN enrollment, first-factor login, PIN change, and secret rotation on Android. It builds on the +hardware-attested signing key from [device binding](08_deviceauthn.mdx) and adds the transport, sealing, and PIN layers this page +describes. Read it together with [Client implementation requirements](#client-implementation-requirements) — the comments in the +code below are normative and restate those rules inline. + +### Prerequisites + +- 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. +- 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. + +### 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.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.spec.GCMParameterSpec +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec + +object DeviceAuthnPin { + private const val HPKE_INFO = "ory/deviceauthn/pin-secret/v1" + 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)) + 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) } + + /// 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 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, "ory/deviceauthn/pin-secret/v1".toByteArray()) + 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. Fail closed if neither is available — never a software key. + /// 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() + } + } + + 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](#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 [device binding](08_deviceauthn.mdx#how-to-implement-device-binding-in-your-android-application). The PIN +listing above creates the signing key **without** `setUserAuthenticationRequired` precisely because the PIN — not a platform +prompt — is its gate. + +### 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](#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 + +Each ceremony maps to one flow in the [Protocol reference](#protocol-reference); 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](#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](#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](#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 produces software-backed attestations, which the server rejects by default. To exercise PIN enrollment on an +emulator, enable relaxed attestation — a development-only setting. Relaxed-attestation keys expire after 30 days, so re-enroll +after that. See [Relaxed attestation for testing](08_deviceauthn.mdx#relaxed-attestation-for-testing). + +The emulator also has no StrongBox or TEE, so `createPinSigningKey` and `createSealingKey` land in the software keystore. That is +fine for wiring and flow testing, but it defeats the offline-guessing resistance the PIN path depends on: a real PIN enrollment +must land its keys in StrongBox or the TEE, never software. Verify the sealing path on a physical device before shipping. From 3284a5c6242569eeffb96f7ce10f67fe77cf5e5f Mon Sep 17 00:00:00 2001 From: Henning Perl Date: Fri, 10 Jul 2026 19:29:23 +0200 Subject: [PATCH 06/18] docs: fail closed when Android sealing key is not hardware-backed Co-Authored-By: Claude Fable 5 --- .../passwordless/09_deviceauthn-pin.mdx | 46 +++++++++++++++---- 1 file changed, 37 insertions(+), 9 deletions(-) diff --git a/docs/kratos/passwordless/09_deviceauthn-pin.mdx b/docs/kratos/passwordless/09_deviceauthn-pin.mdx index b9a0780b59..d98a4506b6 100644 --- a/docs/kratos/passwordless/09_deviceauthn-pin.mdx +++ b/docs/kratos/passwordless/09_deviceauthn-pin.mdx @@ -699,6 +699,7 @@ the PIN proof, and the login signature. The `PinCeremony` is a fresh object per ```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 @@ -721,6 +722,7 @@ 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 @@ -843,9 +845,10 @@ object PinVault { private val random = SecureRandom() /// Creates the sealing key: AES-256-GCM, StrongBox where available, TEE - /// otherwise. Fail closed if neither is available — never a software key. - /// No setUserAuthenticationRequired (the PIN is the gate); - /// setUnlockedDeviceRequired so a locked stolen device cannot unseal. + /// 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 { @@ -867,6 +870,27 @@ object PinVault { 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 = @@ -1015,10 +1039,14 @@ linked below. The code above zeroizes every PIN, derived key, and secret in a `f ### Testing in the emulator -The Android emulator produces software-backed attestations, which the server rejects by default. To exercise PIN enrollment on an -emulator, enable relaxed attestation — a development-only setting. Relaxed-attestation keys expire after 30 days, so re-enroll -after that. See [Relaxed attestation for testing](08_deviceauthn.mdx#relaxed-attestation-for-testing). +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](08_deviceauthn.mdx#relaxed-attestation-for-testing). -The emulator also has no StrongBox or TEE, so `createPinSigningKey` and `createSealingKey` land in the software keystore. That is -fine for wiring and flow testing, but it defeats the offline-guessing resistance the PIN path depends on: a real PIN enrollment -must land its keys in StrongBox or the TEE, never software. Verify the sealing path on a physical device before shipping. +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. From 4b54c27e2b32210d505ff9bff18bd81c37491708 Mon Sep 17 00:00:00 2001 From: Henning Perl Date: Fri, 10 Jul 2026 19:33:51 +0200 Subject: [PATCH 07/18] docs: add deviceauthn PIN recovery, troubleshooting, and security model --- .../passwordless/09_deviceauthn-pin.mdx | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/docs/kratos/passwordless/09_deviceauthn-pin.mdx b/docs/kratos/passwordless/09_deviceauthn-pin.mdx index d98a4506b6..fcbce0278d 100644 --- a/docs/kratos/passwordless/09_deviceauthn-pin.mdx +++ b/docs/kratos/passwordless/09_deviceauthn-pin.mdx @@ -1050,3 +1050,49 @@ Second, the sealing key also lands in the software keystore, so the reference `c 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. + +## 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](08_deviceauthn.mdx#relaxed-attestation-for-testing). + +## Security model + +| 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 5 attempts. | +| 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. From cfeb55ff51f7c1527780930e332238c471fd79ee Mon Sep 17 00:00:00 2001 From: Henning Perl Date: Fri, 10 Jul 2026 19:38:19 +0200 Subject: [PATCH 08/18] docs: link device binding page to first-factor PIN docs Remove the stale "second factor only" claims from the device binding page and cross-link the new first-factor PIN and biometrics page. Also fix a copy-paste error where the step-up step pointed at the settings flow instead of the login flow, and note the enrollment user_verification level. Co-Authored-By: Claude Fable 5 --- docs/kratos/passwordless/08_deviceauthn.mdx | 26 +++++++++++++-------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/docs/kratos/passwordless/08_deviceauthn.mdx b/docs/kratos/passwordless/08_deviceauthn.mdx index 0f0b8e9ebd..e031bd31f0 100644 --- a/docs/kratos/passwordless/08_deviceauthn.mdx +++ b/docs/kratos/passwordless/08_deviceauthn.mdx @@ -17,8 +17,8 @@ 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. +This page covers using device keys as a second factor (step-up). A key protected by an app PIN or platform biometrics can also act +as a complete passwordless first factor — see [Device authentication with PIN and biometrics](09_deviceauthn-pin.mdx). Since this is a strategy, it supports all the same hooks as the other strategies. @@ -28,6 +28,8 @@ Since this is a strategy, it supports all the same hooks as the other strategies - 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 [Device authentication with PIN and biometrics](09_deviceauthn-pin.mdx). - 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: @@ -64,8 +66,9 @@ the endpoints for native apps, to avoid having to pass cookies around manually. ``` 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. 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 [Device authentication with PIN and biometrics](09_deviceauthn-pin.mdx). 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, @@ -145,8 +148,7 @@ the endpoints for native apps, to avoid having to pass cookies around manually. 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: + [complete the login flow](https://www.ory.com/docs/reference/api#tag/frontend/operation/updateLoginFlow) with this payload: ```json { @@ -402,8 +404,9 @@ the endpoints for native apps, to avoid having to pass cookies around manually. 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. 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 [Device authentication with PIN and biometrics](09_deviceauthn-pin.mdx). 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, @@ -500,8 +503,7 @@ the endpoints for native apps, to avoid having to pass cookies around manually. 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: + [complete the login flow](https://www.ory.com/docs/reference/api#tag/frontend/operation/updateLoginFlow) with this payload: ```json { @@ -825,6 +827,10 @@ hardware-binding guarantees that this strategy relies on. 7. Erases the challenge value in the database to prevent re-use 8. 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 +[Device authentication with PIN and biometrics](09_deviceauthn-pin.mdx). + At this point the key is enrolled for the identity. Date: Fri, 10 Jul 2026 19:50:01 +0200 Subject: [PATCH 09/18] docs: register device PIN page in network and OEL sidebars Co-Authored-By: Claude Fable 5 --- sidebars-network.ts | 1 + sidebars-oel.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/sidebars-network.ts b/sidebars-network.ts index 180cda8fc3..7d11b9e49f 100644 --- a/sidebars-network.ts +++ b/sidebars-network.ts @@ -203,6 +203,7 @@ const networkSidebar = [ "kratos/passwordless/passkeys", "kratos/passwordless/passkeys-mobile", "kratos/passwordless/deviceauthn", + "kratos/passwordless/deviceauthn-pin", "kratos/organizations/organizations", "kratos/emails-sms/custom-email-templates", ], diff --git a/sidebars-oel.ts b/sidebars-oel.ts index 914904c89c..1d680c9c73 100644 --- a/sidebars-oel.ts +++ b/sidebars-oel.ts @@ -88,6 +88,7 @@ const oelSidebar = [ "kratos/guides/hosting-own-have-i-been-pwned-api", "kratos/guides/secret-key-rotation", "kratos/passwordless/deviceauthn", + "kratos/passwordless/deviceauthn-pin", { type: "category", label: "Troubleshooting", From 478580bdeb200dbc779ac7e722c4cd7296498b73 Mon Sep 17 00:00:00 2001 From: Henning Perl Date: Fri, 10 Jul 2026 20:04:57 +0200 Subject: [PATCH 10/18] docs: fix flow-submission verb and polish deviceauthn PIN page Co-Authored-By: Claude Fable 5 --- docs/kratos/passwordless/08_deviceauthn.mdx | 4 +-- .../passwordless/09_deviceauthn-pin.mdx | 29 +++++++++---------- 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/docs/kratos/passwordless/08_deviceauthn.mdx b/docs/kratos/passwordless/08_deviceauthn.mdx index e031bd31f0..313d4519c8 100644 --- a/docs/kratos/passwordless/08_deviceauthn.mdx +++ b/docs/kratos/passwordless/08_deviceauthn.mdx @@ -843,7 +843,7 @@ sequenceDiagram 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}} + C->>S: POST /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 `} @@ -872,7 +872,7 @@ sequenceDiagram 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} + 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} `} diff --git a/docs/kratos/passwordless/09_deviceauthn-pin.mdx b/docs/kratos/passwordless/09_deviceauthn-pin.mdx index fcbce0278d..8763f4e8ff 100644 --- a/docs/kratos/passwordless/09_deviceauthn-pin.mdx +++ b/docs/kratos/passwordless/09_deviceauthn-pin.mdx @@ -199,7 +199,7 @@ sequenceDiagram 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: PUT /self-service/settings?flow=… {add: {pin_protected, transport_public_key, 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 @@ -259,7 +259,7 @@ sequenceDiagram 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: PUT /self-service/login?flow=… {client_key_id, signature, pin_proof} + 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} `} @@ -596,10 +596,9 @@ func pinProof(pinSecret: [UInt8], clientKeyId: String, nonce: Data) -> Data { } /// First-factor login with a PIN key. -func loginWithPin(flowNonce: Data, pin: [UInt8], artifacts: PinArtifacts, sealingKey: SecKey) +func loginWithPin(flowNonce: Data, pin: inout [UInt8], artifacts: PinArtifacts, sealingKey: SecKey) async throws -> UpdateLoginFlowWithDeviceAuthnMethod { - var pin = pin 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) @@ -669,7 +668,7 @@ let updated = try PinVault.seal( 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. +The signing key and its `client_key_id` never change; only the sealed secret does. Only PIN keys can be rotated. ## Android guide @@ -728,7 +727,6 @@ import javax.crypto.spec.IvParameterSpec import javax.crypto.spec.SecretKeySpec object DeviceAuthnPin { - private const val HPKE_INFO = "ory/deviceauthn/pin-secret/v1" private const val PIN_PROOF_DOMAIN = "ory/deviceauthn/pin-proof/v1" private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } @@ -811,6 +809,7 @@ object DeviceAuthnPin { 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). @@ -820,7 +819,7 @@ class PinCeremony { /// 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, "ory/deviceauthn/pin-secret/v1".toByteArray()) + val ctx = hpke.setupBaseR(enc, transportKeyPair, hpkeInfo) return ctx.open(clientKeyId.toByteArray(), ciphertext) } } @@ -989,7 +988,7 @@ the `OryApi` from [device binding](08_deviceauthn.mdx#how-to-implement-device-bi listing above creates the signing key **without** `setUserAuthenticationRequired` precisely because the PIN — not a platform prompt — is its gate. -### Rotating the secret +### 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 @@ -1080,13 +1079,13 @@ rotated, and delaying sensitive operations after either event. ## Security model -| 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 5 attempts. | -| 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. | +| 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: From 0724946055e944eb2f05579f6c99e229906b4732 Mon Sep 17 00:00:00 2001 From: Henning Perl Date: Sat, 11 Jul 2026 15:58:50 +0200 Subject: [PATCH 11/18] docs: document server-assigned client_key_id on the device binding page The add-key examples still showed the client choosing and submitting client_key_id. The server now derives it as the lowercase-hex SHA-256 fingerprint of the key's public key (SubjectPublicKeyInfo, DER) and the add payload has no such field. Update the payloads, SDK snippets, reference steps, and diagrams accordingly, and align the availability wording with the new PIN page. Co-Authored-By: Claude Fable 5 --- docs/kratos/passwordless/08_deviceauthn.mdx | 86 +++++++++++++-------- 1 file changed, 52 insertions(+), 34 deletions(-) diff --git a/docs/kratos/passwordless/08_deviceauthn.mdx b/docs/kratos/passwordless/08_deviceauthn.mdx index 313d4519c8..426a5bc838 100644 --- a/docs/kratos/passwordless/08_deviceauthn.mdx +++ b/docs/kratos/passwordless/08_deviceauthn.mdx @@ -24,7 +24,10 @@ 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`. +- 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. @@ -78,7 +81,7 @@ the endpoints for native apps, to avoid having to pass cookies around manually. ```json { "delete": { - "client_key_id": "4fcXqFY9kg2unsTCM33GH8ayIWY6WdIGFWXMzhl9Vik=" + "client_key_id": "9c62918cbbef2e94c3a10238bd57ab196e3a2caae1a44f28b02f2d1a72b1e59c" }, "method": "deviceauthn" } @@ -111,9 +114,8 @@ the endpoints for native apps, to avoid having to pass cookies around manually. { "method": "deviceauthn", "add": { - "device_name": "iPhone (iPhone14,5)", - "attestation_ios": "...", - "client_key_id": "sBS5ZHWqsSRbV6OEvCfsg0+DWa3ERns6JyqRypqccrE=" + "device_name": "Pixel 9", + "certificate_chain_android": ["...", "...", "..."] } } ``` @@ -136,16 +138,25 @@ the endpoints for native apps, to avoid having to pass cookies around manually. 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, "") + + // 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) } ``` - 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. + 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: @@ -153,7 +164,7 @@ the endpoints for native apps, to avoid having to pass cookies around manually. ```json { "signature": "...", - "client_key_id": "sBS5ZHWqsSRbV6OEvCfsg0+DWa3ERns6JyqRypqccrE=", + "client_key_id": "9c62918cbbef2e94c3a10238bd57ab196e3a2caae1a44f28b02f2d1a72b1e59c", "method": "deviceauthn" } ``` @@ -161,7 +172,8 @@ the endpoints for native apps, to avoid having to pass cookies around manually. Or using the SDK: ```kotlin - val clientKeyId = "..." + 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") @@ -172,7 +184,7 @@ the endpoints for native apps, to avoid having to pass cookies around manually. updateMethod.method = "deviceauthn" updateMethod.signature = Api.create().launchBiometricSigner( context as FragmentActivity, - clientKeyId, + keyAlias, nonce, "Confirm", "Cancel" @@ -416,7 +428,7 @@ the endpoints for native apps, to avoid having to pass cookies around manually. ```json { "delete": { - "client_key_id": "4fcXqFY9kg2unsTCM33GH8ayIWY6WdIGFWXMzhl9Vik=" + "client_key_id": "9c62918cbbef2e94c3a10238bd57ab196e3a2caae1a44f28b02f2d1a72b1e59c" }, "method": "deviceauthn" } @@ -459,8 +471,7 @@ the endpoints for native apps, to avoid having to pass cookies around manually. "method": "deviceauthn", "add": { "device_name": "iPhone (iPhone14,5)", - "attestation_ios": "...", - "client_key_id": "sBS5ZHWqsSRbV6OEvCfsg0+DWa3ERns6JyqRypqccrE=" + "attestation_ios": "..." } } ``` @@ -468,15 +479,13 @@ the endpoints for native apps, to avoid having to pass cookies around manually. 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( + let (keyId, attestation) = try await OryApi().createKey( challengeB64: nonce ) @@ -485,7 +494,6 @@ the endpoints for native apps, to avoid having to pass cookies around manually. UpdateSettingsFlowWithDeviceAuthnMethod( add: UpdateSettingsFlowWithDeviceAuthnMethodAdd( attestationIos: attestation, - clientKeyId: clientKeyId, deviceName: deviceName, ), method: "deviceauthn" @@ -496,9 +504,16 @@ the endpoints for native apps, to avoid having to pass cookies around manually. 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 the key id somewhere, because there are no APIs to list keys or check if a + 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. @@ -508,7 +523,7 @@ the endpoints for native apps, to avoid having to pass cookies around manually. ```json { "signature": "...", - "client_key_id": "sBS5ZHWqsSRbV6OEvCfsg0+DWa3ERns6JyqRypqccrE=", + "client_key_id": "9c62918cbbef2e94c3a10238bd57ab196e3a2caae1a44f28b02f2d1a72b1e59c", "method": "deviceauthn" } ``` @@ -516,7 +531,8 @@ the endpoints for native apps, to avoid having to pass cookies around manually. Or using the SDK: ```swift - let clientKeyId = "..." + 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, @@ -526,7 +542,7 @@ the endpoints for native apps, to avoid having to pass cookies around manually. let nonce = extractNonceFromUiNodes(nodes: flow.ui.nodes) ?? "" let signature = try await OryApi().signWithKey( - keyId: clientKeyId, + keyId: keyId, challengeB64: nonce, ) @@ -584,7 +600,7 @@ public enum OryApiError: Error, LocalizedError { public class OryApi { public func createKey(challengeB64: String) - async throws -> (clientKeyId: String, attestation: Data) + async throws -> (keyId: String, attestation: Data) { if #available(iOS 14.0, *) { let service = DCAppAttestService.shared @@ -814,8 +830,8 @@ hardware-binding guarantees that this strategy relies on. 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) + 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 @@ -824,8 +840,10 @@ hardware-binding guarantees that this strategy relies on. 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 + 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 @@ -842,10 +860,10 @@ sequenceDiagram C->>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: POST /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 + 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} `} /> @@ -869,7 +887,7 @@ sequenceDiagram participant S as Kratos server C->>S: POST /self-service/login/api {aal: aal2, refresh: false} S-->>C: 200 login flow {nonce} - C->>H: sign(nonce, client_key_id) + 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} From abe2eac03099c7b42c605e6e7a37f7c60b97167a Mon Sep 17 00:00:00 2001 From: Henning Perl Date: Sat, 11 Jul 2026 16:28:44 +0200 Subject: [PATCH 12/18] docs: split device binding docs into platform subpages Co-Authored-By: Claude Fable 5 --- docs/kratos/mfa/01_overview.mdx | 2 +- docs/kratos/passwordless/08_deviceauthn.mdx | 1074 ----------------- .../passwordless/deviceauthn/android.mdx | 342 ++++++ .../passwordless/deviceauthn/flutter.mdx | 109 ++ .../kratos/passwordless/deviceauthn/index.mdx | 355 ++++++ docs/kratos/passwordless/deviceauthn/ios.mdx | 287 +++++ .../pin.mdx} | 33 +- sidebars-network.ts | 13 +- sidebars-oel.ts | 13 +- 9 files changed, 1132 insertions(+), 1096 deletions(-) delete mode 100644 docs/kratos/passwordless/08_deviceauthn.mdx create mode 100644 docs/kratos/passwordless/deviceauthn/android.mdx create mode 100644 docs/kratos/passwordless/deviceauthn/flutter.mdx create mode 100644 docs/kratos/passwordless/deviceauthn/index.mdx create mode 100644 docs/kratos/passwordless/deviceauthn/ios.mdx rename docs/kratos/passwordless/{09_deviceauthn-pin.mdx => deviceauthn/pin.mdx} (97%) diff --git a/docs/kratos/mfa/01_overview.mdx b/docs/kratos/mfa/01_overview.mdx index 86899a7ba0..f7bbf77e54 100644 --- a/docs/kratos/mfa/01_overview.mdx +++ b/docs/kratos/mfa/01_overview.mdx @@ -51,7 +51,7 @@ SMS for MFA sends a one-time password to the user's registered mobile phone numb ### Device binding 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. +[Device binding](../passwordless/deviceauthn/index.mdx) 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 426a5bc838..0000000000 --- a/docs/kratos/passwordless/08_deviceauthn.mdx +++ /dev/null @@ -1,1074 +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. - -This page covers using device keys as a second factor (step-up). A key protected by an app PIN or platform biometrics can also act -as a complete passwordless first factor — see [Device authentication with PIN and biometrics](09_deviceauthn-pin.mdx). - -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 [Device authentication with PIN and biometrics](09_deviceauthn-pin.mdx). -- 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. 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 [Device authentication with PIN and biometrics](09_deviceauthn-pin.mdx). -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) } - ``` - - 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)) - ) - } -} - -``` - -#### 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. 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 [Device authentication with PIN and biometrics](09_deviceauthn-pin.mdx). -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 - ) - } - } -} -``` - -## 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. 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 -[Device authentication with PIN and biometrics](09_deviceauthn-pin.mdx). - -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} -`} -/> - -### 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} -`} -/> - -### 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/kratos/passwordless/deviceauthn/android.mdx b/docs/kratos/passwordless/deviceauthn/android.mdx new file mode 100644 index 0000000000..8548d19765 --- /dev/null +++ b/docs/kratos/passwordless/deviceauthn/android.mdx @@ -0,0 +1,342 @@ +--- +id: android +title: Device binding on Android +sidebar_label: Android +--- + +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. 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 [Device authentication with PIN and biometrics](pin.mdx). +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) } + ``` + + 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)) + ) + } +} + +``` + +## 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](index.mdx#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. diff --git a/docs/kratos/passwordless/deviceauthn/flutter.mdx b/docs/kratos/passwordless/deviceauthn/flutter.mdx new file mode 100644 index 0000000000..16b96f45ae --- /dev/null +++ b/docs/kratos/passwordless/deviceauthn/flutter.mdx @@ -0,0 +1,109 @@ +--- +id: flutter +title: Device binding in Dart/Flutter +sidebar_label: Dart/Flutter +--- + +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/docs/kratos/passwordless/deviceauthn/index.mdx b/docs/kratos/passwordless/deviceauthn/index.mdx new file mode 100644 index 0000000000..79589ba42a --- /dev/null +++ b/docs/kratos/passwordless/deviceauthn/index.mdx @@ -0,0 +1,355 @@ +--- +id: index +title: Device binding +sidebar_label: Overview +slug: /kratos/passwordless/deviceauthn +--- + +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. + +This page covers using device keys as a second factor (step-up). A key protected by an app PIN or platform biometrics can also act +as a complete passwordless first factor — see [Device authentication with PIN and biometrics](pin.mdx). + +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 [Device authentication with PIN and biometrics](pin.mdx). +- 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 + +## 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. + +- [Device binding on Android](android.mdx) +- [Device binding on iOS](ios.mdx) +- [Device binding in Dart/Flutter](flutter.mdx) +- [Device authentication with PIN and biometrics](pin.mdx) + +## 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. 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 +[Device authentication with PIN and biometrics](pin.mdx). + +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} +`} +/> + +### 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} +`} +/> + +### 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/kratos/passwordless/deviceauthn/ios.mdx b/docs/kratos/passwordless/deviceauthn/ios.mdx new file mode 100644 index 0000000000..178a32f09e --- /dev/null +++ b/docs/kratos/passwordless/deviceauthn/ios.mdx @@ -0,0 +1,287 @@ +--- +id: ios +title: Device binding on iOS +sidebar_label: iOS +--- + +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. 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 [Device authentication with PIN and biometrics](pin.mdx). +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 + ) + } + } +} +``` diff --git a/docs/kratos/passwordless/09_deviceauthn-pin.mdx b/docs/kratos/passwordless/deviceauthn/pin.mdx similarity index 97% rename from docs/kratos/passwordless/09_deviceauthn-pin.mdx rename to docs/kratos/passwordless/deviceauthn/pin.mdx index 8763f4e8ff..1e4c854161 100644 --- a/docs/kratos/passwordless/09_deviceauthn-pin.mdx +++ b/docs/kratos/passwordless/deviceauthn/pin.mdx @@ -1,5 +1,5 @@ --- -id: deviceauthn-pin +id: pin title: Device authentication with PIN and biometrics sidebar_label: Device PIN & biometrics --- @@ -10,8 +10,8 @@ Device authentication with PIN and biometrics turns an enrolled device into a co 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. -This builds on [device binding](08_deviceauthn.mdx), which covers the underlying strategy, enrollment as a second factor, and -platform requirements. This page covers the first-factor mode. +This builds on [device binding](index.mdx), which covers the underlying strategy, enrollment as a second factor, and platform +requirements. This page covers the first-factor mode. Key properties: @@ -109,7 +109,7 @@ selfservice: [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 [device binding](08_deviceauthn.mdx#relaxed-attestation-for-testing) and only takes effect in development environments. +in [device binding](index.mdx#relaxed-attestation-for-testing) and only takes effect in development environments. ## Protocol reference @@ -269,7 +269,7 @@ sequenceDiagram 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 [device binding](08_deviceauthn.mdx). +alone, as described in [device binding](index.mdx). ### Rotating the PIN secret @@ -355,9 +355,9 @@ Never present a failed outer unseal as "wrong PIN" — it is structural and retr ## iOS guide This guide implements PIN enrollment, first-factor login, PIN change, and secret rotation on iOS. It builds on the App Attest -signing key from [device binding](08_deviceauthn.mdx) and adds the transport, sealing, and PIN layers this page describes. Read it -together with [Client implementation requirements](#client-implementation-requirements) — the comments in the code below are -normative and restate those rules inline. +signing key from [device binding](index.mdx) and adds the transport, sealing, and PIN layers this page describes. Read it together +with [Client implementation requirements](#client-implementation-requirements) — the comments in the code below are normative and +restate those rules inline. ### Prerequisites @@ -632,9 +632,9 @@ flow: - 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 -[device binding](08_deviceauthn.mdx#how-to-implement-device-binding-in-your-iosipados-application). The PIN listing above calls -`DCAppAttestService` directly only to keep the challenge computation visible. On iOS, biometric first-factor login also requires -the `ios_biometric_first_factor` opt-in — see [Configuration](#configuration). +[device binding](ios.mdx). The PIN listing above calls `DCAppAttestService` directly only to keep the challenge computation +visible. On iOS, biometric first-factor login also requires the `ios_biometric_first_factor` opt-in — see +[Configuration](#configuration). ### Changing the PIN and rotating the secret @@ -673,7 +673,7 @@ The signing key and its `client_key_id` never change; only the sealed secret doe ## Android guide This guide implements PIN enrollment, first-factor login, PIN change, and secret rotation on Android. It builds on the -hardware-attested signing key from [device binding](08_deviceauthn.mdx) and adds the transport, sealing, and PIN layers this page +hardware-attested signing key from [device binding](index.mdx) and adds the transport, sealing, and PIN layers this page describes. Read it together with [Client implementation requirements](#client-implementation-requirements) — the comments in the code below are normative and restate those rules inline. @@ -984,9 +984,8 @@ shows the fingerprint or face prompt when the key signs. Three differences from `pin_proof`. For the key-creation and `BiometricPrompt` signing scaffolding (the `createKeyPair` and `launchBiometricSigner` helpers), reuse -the `OryApi` from [device binding](08_deviceauthn.mdx#how-to-implement-device-binding-in-your-android-application). The PIN -listing above creates the signing key **without** `setUserAuthenticationRequired` precisely because the PIN — not a platform -prompt — is its gate. +the `OryApi` from [device binding](android.mdx). 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 @@ -1043,7 +1042,7 @@ 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](08_deviceauthn.mdx#relaxed-attestation-for-testing). +[Relaxed attestation for testing](index.mdx#relaxed-attestation-for-testing). 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, @@ -1075,7 +1074,7 @@ rotated, and delaying sensitive operations after either event. 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](08_deviceauthn.mdx#relaxed-attestation-for-testing). + as the setting is turned off. See [relaxed attestation](index.mdx#relaxed-attestation-for-testing). ## Security model diff --git a/sidebars-network.ts b/sidebars-network.ts index 7d11b9e49f..77368341cb 100644 --- a/sidebars-network.ts +++ b/sidebars-network.ts @@ -202,8 +202,17 @@ const networkSidebar = [ "kratos/passwordless/one-time-code", "kratos/passwordless/passkeys", "kratos/passwordless/passkeys-mobile", - "kratos/passwordless/deviceauthn", - "kratos/passwordless/deviceauthn-pin", + { + type: "category", + label: "Device binding", + items: [ + "kratos/passwordless/deviceauthn/index", + "kratos/passwordless/deviceauthn/android", + "kratos/passwordless/deviceauthn/ios", + "kratos/passwordless/deviceauthn/flutter", + "kratos/passwordless/deviceauthn/pin", + ], + }, "kratos/organizations/organizations", "kratos/emails-sms/custom-email-templates", ], diff --git a/sidebars-oel.ts b/sidebars-oel.ts index 1d680c9c73..9748c42ec8 100644 --- a/sidebars-oel.ts +++ b/sidebars-oel.ts @@ -87,8 +87,17 @@ const oelSidebar = [ "kratos/guides/https-tls", "kratos/guides/hosting-own-have-i-been-pwned-api", "kratos/guides/secret-key-rotation", - "kratos/passwordless/deviceauthn", - "kratos/passwordless/deviceauthn-pin", + { + type: "category", + label: "Device binding", + items: [ + "kratos/passwordless/deviceauthn/index", + "kratos/passwordless/deviceauthn/android", + "kratos/passwordless/deviceauthn/ios", + "kratos/passwordless/deviceauthn/flutter", + "kratos/passwordless/deviceauthn/pin", + ], + }, { type: "category", label: "Troubleshooting", From 12d2149d61f9da5d5d3d684a6c9bc8247f989237 Mon Sep 17 00:00:00 2001 From: Henning Perl Date: Sat, 11 Jul 2026 16:52:52 +0200 Subject: [PATCH 13/18] docs: fold PIN and biometrics content into the platform guides Dissolve the standalone "Device authentication with PIN and biometrics" page. Platform-agnostic content (how it works, configuration, protocol reference, client requirements, recovery, troubleshooting, security model) merges into the device binding overview; the iOS and Android reference implementations merge into the respective platform guides. Preserves the #pin-enrollment, #first-factor-login, #rotating-the-pin-secret, and #client-implementation-requirements anchors on the overview page. Co-Authored-By: Claude Fable 5 --- .../passwordless/deviceauthn/android.mdx | 395 +++++- .../kratos/passwordless/deviceauthn/index.mdx | 426 ++++++- docs/kratos/passwordless/deviceauthn/ios.mdx | 331 ++++- docs/kratos/passwordless/deviceauthn/pin.mdx | 1096 ----------------- sidebars-network.ts | 1 - sidebars-oel.ts | 1 - 6 files changed, 1128 insertions(+), 1122 deletions(-) delete mode 100644 docs/kratos/passwordless/deviceauthn/pin.mdx diff --git a/docs/kratos/passwordless/deviceauthn/android.mdx b/docs/kratos/passwordless/deviceauthn/android.mdx index 8548d19765..e5d4910366 100644 --- a/docs/kratos/passwordless/deviceauthn/android.mdx +++ b/docs/kratos/passwordless/deviceauthn/android.mdx @@ -10,6 +10,22 @@ 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 @@ -22,7 +38,7 @@ the endpoints for native apps, to avoid having to pass cookies around manually. 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 [Device authentication with PIN and biometrics](pin.mdx). + 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, @@ -319,10 +335,376 @@ internal class OryApi : Api { ``` -## Making it work in the Android emulator +## 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`. -Because the emulator produces software-based attestations, the server only accepts its keys when relaxed attestation is enabled. -See [Relaxed attestation for testing](index.mdx#relaxed-attestation-for-testing). +::: + +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 it together with [Client implementation requirements](index.mdx#client-implementation-requirements) — 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)) + 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) } + + /// 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](index.mdx#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](index.mdx#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 + +Each ceremony maps to one flow in the [Protocol reference](index.mdx#protocol-reference); 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](index.mdx#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](index.mdx#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](index.mdx#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](index.mdx#relaxed-attestation-for-testing). + +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. @@ -340,3 +722,8 @@ Then, start the application inside the emulated device. When the biometric promp 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/docs/kratos/passwordless/deviceauthn/index.mdx b/docs/kratos/passwordless/deviceauthn/index.mdx index 79589ba42a..9b06ca9b66 100644 --- a/docs/kratos/passwordless/deviceauthn/index.mdx +++ b/docs/kratos/passwordless/deviceauthn/index.mdx @@ -18,8 +18,24 @@ 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. -This page covers using device keys as a second factor (step-up). A key protected by an app PIN or platform biometrics can also act -as a complete passwordless first factor — see [Device authentication with PIN and biometrics](pin.mdx). +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. @@ -33,7 +49,7 @@ Since this is a strategy, it supports all the same hooks as the other strategies - 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 [Device authentication with PIN and biometrics](pin.mdx). + — 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: @@ -50,16 +66,105 @@ Since this is a strategy, it supports all the same hooks as the other strategies - 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](android.mdx) - [Device binding on iOS](ios.mdx) - [Device binding in Dart/Flutter](flutter.mdx) -- [Device authentication with PIN and biometrics](pin.mdx) + +## 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 @@ -92,18 +197,16 @@ hardware-binding guarantees that this strategy relies on. ::: -## Reference +## 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. This strategy implements the settings and login flow. This - is done so: - ```yaml - selfservice: - methods: - deviceauthn: - enabled: true - ``` +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. @@ -127,8 +230,7 @@ hardware-binding guarantees that this strategy relies on. 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 -[Device authentication with PIN and biometrics](pin.mdx). +first factor. PIN enrollment computes the attestation challenge differently — see [PIN enrollment](#pin-enrollment). At this point the key is enrolled for the identity. @@ -148,6 +250,104 @@ sequenceDiagram `} /> +### 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. @@ -177,6 +377,86 @@ sequenceDiagram `} /> +### 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 @@ -198,6 +478,95 @@ The settings flow contains all keys for the identity. This is used to present th 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; `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. @@ -317,16 +686,35 @@ The security of this design relies on a chain of trust anchored in hardware and 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 +### 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 +### Similarities - Core Cryptography: Both approaches are built on public-key cryptography (typically ECDSA), and use a challenge-response protocol -#### Key Differences +### Key Differences - Standard vs. proprietary: - WebAuthn/passkeys: An open, interoperable standard from the W3C and FIDO Alliance, designed to work across different websites, @@ -348,7 +736,7 @@ they share core cryptographic principles, their goals and scope are fundamentall - 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 +## 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 diff --git a/docs/kratos/passwordless/deviceauthn/ios.mdx b/docs/kratos/passwordless/deviceauthn/ios.mdx index 178a32f09e..1330bccc01 100644 --- a/docs/kratos/passwordless/deviceauthn/ios.mdx +++ b/docs/kratos/passwordless/deviceauthn/ios.mdx @@ -12,6 +12,25 @@ 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 @@ -30,7 +49,7 @@ the endpoints for native apps, to avoid having to pass cookies around manually. 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 [Device authentication with PIN and biometrics](pin.mdx). + 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, @@ -285,3 +304,313 @@ public class OryApi { } } ``` + +## 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 it together with [Client implementation requirements](index.mdx#client-implementation-requirements) — 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 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. + static func aesCtr(key: [UInt8], iv: [UInt8], data: [UInt8]) -> [UInt8] { + var cryptor: CCCryptorRef? + CCCryptorCreateWithMode( + CCOperation(kCCEncrypt), CCMode(kCCModeCTR), CCAlgorithm(kCCAlgorithmAES128), + CCPadding(ccNoPadding), iv, key, key.count, nil, 0, 0, 0, &cryptor) + defer { CCCryptorRelease(cryptor) } + var out = [UInt8](repeating: 0, count: data.count) + var moved = 0 + CCCryptorUpdate(cryptor, data, data.count, &out, out.count, &moved) + 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 } + } + var salt = [UInt8](repeating: 0, count: 16) + _ = SecRandomCopyBytes(kSecRandomDefault, salt.count, &salt) + var iv = [UInt8](repeating: 0, count: 16) + _ = SecRandomCopyBytes(kSecRandomDefault, iv.count, &iv) + + var pinKey = try argon2id(pin: pin, salt: salt, opsLimit: opsLimit, memLimit: memLimit) + defer { for i in pinKey.indices { pinKey[i] = 0 } } + let inner = 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 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 + ) +} +``` + +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` (see +[PIN enrollment](index.mdx#pin-enrollment)), 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. On iOS, biometric first-factor login also requires the `ios_biometric_first_factor` opt-in — see +[Configuration](index.mdx#configuration). + +## 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](index.mdx#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/docs/kratos/passwordless/deviceauthn/pin.mdx b/docs/kratos/passwordless/deviceauthn/pin.mdx deleted file mode 100644 index 1e4c854161..0000000000 --- a/docs/kratos/passwordless/deviceauthn/pin.mdx +++ /dev/null @@ -1,1096 +0,0 @@ ---- -id: pin -title: Device authentication with PIN and biometrics -sidebar_label: Device PIN & biometrics ---- - -import Mermaid from "@site/src/theme/Mermaid" - -Device authentication with PIN and biometrics turns an enrolled device into a complete passwordless login: an app-level 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. - -This builds on [device binding](index.mdx), which covers the underlying strategy, enrollment as a second factor, and platform -requirements. This page covers the first-factor mode. - -Key properties: - -- 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. - -Availability: Ory Network and Ory Enterprise License. Platform support matches device binding: iOS 14.0+ and Android SDK 24+, -native apps only (API flows). - -## 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. - -## Configuration - -```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 [device binding](index.mdx#relaxed-attestation-for-testing) and only takes effect in development environments. - -## 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. - -### 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). - -### 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 [device binding](index.mdx). - -### 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. - -## 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; `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. - -## iOS guide - -This guide implements PIN enrollment, first-factor login, PIN change, and secret rotation on iOS. It builds on the App Attest -signing key from [device binding](index.mdx) and adds the transport, sealing, and PIN layers this page describes. Read it together -with [Client implementation requirements](#client-implementation-requirements) — the comments in the code below are normative and -restate those rules inline. - -### Prerequisites - -- The App Attest entitlement `com.apple.developer.devicecheck.appattest-environment` set to `production` in your app's - entitlements. -- A real device. App Attest and the Secure Enclave are unavailable in the simulator, so PIN enrollment cannot run there. -- iOS 14 or newer. -- 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. - -### 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 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. - static func aesCtr(key: [UInt8], iv: [UInt8], data: [UInt8]) -> [UInt8] { - var cryptor: CCCryptorRef? - CCCryptorCreateWithMode( - CCOperation(kCCEncrypt), CCMode(kCCModeCTR), CCAlgorithm(kCCAlgorithmAES128), - CCPadding(ccNoPadding), iv, key, key.count, nil, 0, 0, 0, &cryptor) - defer { CCCryptorRelease(cryptor) } - var out = [UInt8](repeating: 0, count: data.count) - var moved = 0 - CCCryptorUpdate(cryptor, data, data.count, &out, out.count, &moved) - 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 } - } - var salt = [UInt8](repeating: 0, count: 16) - _ = SecRandomCopyBytes(kSecRandomDefault, salt.count, &salt) - var iv = [UInt8](repeating: 0, count: 16) - _ = SecRandomCopyBytes(kSecRandomDefault, iv.count, &iv) - - var pinKey = try argon2id(pin: pin, salt: salt, opsLimit: opsLimit, memLimit: memLimit) - defer { for i in pinKey.indices { pinKey[i] = 0 } } - let inner = 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 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 - ) -} -``` - -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` (see -[PIN enrollment](#pin-enrollment)), 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 -[device binding](ios.mdx). The PIN listing above calls `DCAppAttestService` directly only to keep the challenge computation -visible. On iOS, biometric first-factor login also requires the `ios_biometric_first_factor` opt-in — see -[Configuration](#configuration). - -### 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](#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. - -## Android guide - -This guide implements PIN enrollment, first-factor login, PIN change, and secret rotation on Android. It builds on the -hardware-attested signing key from [device binding](index.mdx) and adds the transport, sealing, and PIN layers this page -describes. Read it together with [Client implementation requirements](#client-implementation-requirements) — the comments in the -code below are normative and restate those rules inline. - -### Prerequisites - -- 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. -- 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. - -### 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)) - 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) } - - /// 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](#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 [device binding](android.mdx). 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](#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 - -Each ceremony maps to one flow in the [Protocol reference](#protocol-reference); 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](#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](#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](#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](index.mdx#relaxed-attestation-for-testing). - -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. - -## 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](index.mdx#relaxed-attestation-for-testing). - -## Security model - -| 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. diff --git a/sidebars-network.ts b/sidebars-network.ts index 77368341cb..847e2a9f1d 100644 --- a/sidebars-network.ts +++ b/sidebars-network.ts @@ -210,7 +210,6 @@ const networkSidebar = [ "kratos/passwordless/deviceauthn/android", "kratos/passwordless/deviceauthn/ios", "kratos/passwordless/deviceauthn/flutter", - "kratos/passwordless/deviceauthn/pin", ], }, "kratos/organizations/organizations", diff --git a/sidebars-oel.ts b/sidebars-oel.ts index 9748c42ec8..dbd7290b97 100644 --- a/sidebars-oel.ts +++ b/sidebars-oel.ts @@ -95,7 +95,6 @@ const oelSidebar = [ "kratos/passwordless/deviceauthn/android", "kratos/passwordless/deviceauthn/ios", "kratos/passwordless/deviceauthn/flutter", - "kratos/passwordless/deviceauthn/pin", ], }, { From 7a4fa6ac83ffe36838d9bfbd0c0e0735d6c40b91 Mon Sep 17 00:00:00 2001 From: Henning Perl Date: Sat, 11 Jul 2026 17:31:33 +0200 Subject: [PATCH 14/18] docs: rename device binding section to device authentication The section now covers both second-factor binding and first-factor PIN/biometric login, so the strategy's umbrella name fits better. Co-Authored-By: Claude Fable 5 --- docs/kratos/mfa/01_overview.mdx | 4 ++-- docs/kratos/passwordless/deviceauthn/android.mdx | 2 +- docs/kratos/passwordless/deviceauthn/flutter.mdx | 2 +- docs/kratos/passwordless/deviceauthn/index.mdx | 2 +- docs/kratos/passwordless/deviceauthn/ios.mdx | 2 +- sidebars-network.ts | 2 +- sidebars-oel.ts | 2 +- 7 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/kratos/mfa/01_overview.mdx b/docs/kratos/mfa/01_overview.mdx index f7bbf77e54..804c69bd93 100644 --- a/docs/kratos/mfa/01_overview.mdx +++ b/docs/kratos/mfa/01_overview.mdx @@ -48,10 +48,10 @@ 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/deviceauthn/index.mdx) documentation to learn more. +[Device authentication](../passwordless/deviceauthn/index.mdx) documentation to learn more. ### Email diff --git a/docs/kratos/passwordless/deviceauthn/android.mdx b/docs/kratos/passwordless/deviceauthn/android.mdx index e5d4910366..a30d61dec0 100644 --- a/docs/kratos/passwordless/deviceauthn/android.mdx +++ b/docs/kratos/passwordless/deviceauthn/android.mdx @@ -1,6 +1,6 @@ --- id: android -title: Device binding on Android +title: Device authentication on Android sidebar_label: Android --- diff --git a/docs/kratos/passwordless/deviceauthn/flutter.mdx b/docs/kratos/passwordless/deviceauthn/flutter.mdx index 16b96f45ae..241f96bdd3 100644 --- a/docs/kratos/passwordless/deviceauthn/flutter.mdx +++ b/docs/kratos/passwordless/deviceauthn/flutter.mdx @@ -1,6 +1,6 @@ --- id: flutter -title: Device binding in Dart/Flutter +title: Device authentication in Dart/Flutter sidebar_label: Dart/Flutter --- diff --git a/docs/kratos/passwordless/deviceauthn/index.mdx b/docs/kratos/passwordless/deviceauthn/index.mdx index 9b06ca9b66..2131d1f580 100644 --- a/docs/kratos/passwordless/deviceauthn/index.mdx +++ b/docs/kratos/passwordless/deviceauthn/index.mdx @@ -1,6 +1,6 @@ --- id: index -title: Device binding +title: Device authentication sidebar_label: Overview slug: /kratos/passwordless/deviceauthn --- diff --git a/docs/kratos/passwordless/deviceauthn/ios.mdx b/docs/kratos/passwordless/deviceauthn/ios.mdx index 1330bccc01..fb59b52a0e 100644 --- a/docs/kratos/passwordless/deviceauthn/ios.mdx +++ b/docs/kratos/passwordless/deviceauthn/ios.mdx @@ -1,6 +1,6 @@ --- id: ios -title: Device binding on iOS +title: Device authentication on iOS sidebar_label: iOS --- diff --git a/sidebars-network.ts b/sidebars-network.ts index 847e2a9f1d..88332db569 100644 --- a/sidebars-network.ts +++ b/sidebars-network.ts @@ -204,7 +204,7 @@ const networkSidebar = [ "kratos/passwordless/passkeys-mobile", { type: "category", - label: "Device binding", + label: "Device authentication", items: [ "kratos/passwordless/deviceauthn/index", "kratos/passwordless/deviceauthn/android", diff --git a/sidebars-oel.ts b/sidebars-oel.ts index dbd7290b97..8d07216a03 100644 --- a/sidebars-oel.ts +++ b/sidebars-oel.ts @@ -89,7 +89,7 @@ const oelSidebar = [ "kratos/guides/secret-key-rotation", { type: "category", - label: "Device binding", + label: "Device authentication", items: [ "kratos/passwordless/deviceauthn/index", "kratos/passwordless/deviceauthn/android", From 280a058e679dbdaa77c191d232751431fd41684e Mon Sep 17 00:00:00 2001 From: Henning Perl Date: Sat, 11 Jul 2026 17:49:59 +0200 Subject: [PATCH 15/18] docs: list device authentication in the network sidebar only The pages were listed in both the network and OEL sidebars; a doc can only display one sidebar, and the OEL one won the association, so navigating from the Ory Network sidebar into the section reshuffled the navigation to Ory Enterprise License. Sibling feature pages (passkeys, one-time codes) are network-only for the same reason. Co-Authored-By: Claude Fable 5 --- sidebars-oel.ts | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/sidebars-oel.ts b/sidebars-oel.ts index 8d07216a03..870e22f3a5 100644 --- a/sidebars-oel.ts +++ b/sidebars-oel.ts @@ -87,16 +87,6 @@ const oelSidebar = [ "kratos/guides/https-tls", "kratos/guides/hosting-own-have-i-been-pwned-api", "kratos/guides/secret-key-rotation", - { - type: "category", - label: "Device authentication", - items: [ - "kratos/passwordless/deviceauthn/index", - "kratos/passwordless/deviceauthn/android", - "kratos/passwordless/deviceauthn/ios", - "kratos/passwordless/deviceauthn/flutter", - ], - }, { type: "category", label: "Troubleshooting", From 8eca3671885c068e2fb31d325d0520f5473a65ac Mon Sep 17 00:00:00 2001 From: Henning Perl Date: Sun, 12 Jul 2026 19:01:34 +0200 Subject: [PATCH 16/18] docs: harden reference code per review Check CommonCrypto and SecRandomCopyBytes status codes in the Swift reference (structural failures throw instead of proceeding with zeroed or undefined buffers), decode the nonce JSON as UTF-8 and hex-encode the key fingerprint explicitly unsigned in the Kotlin reference, and state the API 28+ floor for setUnlockedDeviceRequired in the hard rules. Co-Authored-By: Claude Fable 5 --- .../passwordless/deviceauthn/android.mdx | 6 ++-- .../kratos/passwordless/deviceauthn/index.mdx | 3 +- docs/kratos/passwordless/deviceauthn/ios.mdx | 28 +++++++++++++------ 3 files changed, 25 insertions(+), 12 deletions(-) diff --git a/docs/kratos/passwordless/deviceauthn/android.mdx b/docs/kratos/passwordless/deviceauthn/android.mdx index a30d61dec0..33e10a5924 100644 --- a/docs/kratos/passwordless/deviceauthn/android.mdx +++ b/docs/kratos/passwordless/deviceauthn/android.mdx @@ -116,7 +116,7 @@ additionally needs: val spki = keyCertChain.first().publicKey.encoded val clientKeyId = MessageDigest.getInstance("SHA-256") .digest(spki) - .joinToString("") { "%02x".format(it) } + .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 @@ -394,7 +394,7 @@ object DeviceAuthnPin { /// 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)) + val json = String(Base64.decode(nodeValue, Base64.DEFAULT), Charsets.UTF_8) return Base64.decode(JSONObject(json).getString("nonce"), Base64.DEFAULT) } @@ -409,7 +409,7 @@ object DeviceAuthnPin { /// which is exactly PublicKey.getEncoded() on Android. fun clientKeyId(signingKeyAlias: String): String = sha256(keyStore.getCertificate(signingKeyAlias).publicKey.encoded) - .joinToString("") { "%02x".format(it) } + .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 diff --git a/docs/kratos/passwordless/deviceauthn/index.mdx b/docs/kratos/passwordless/deviceauthn/index.mdx index 2131d1f580..0145d8529e 100644 --- a/docs/kratos/passwordless/deviceauthn/index.mdx +++ b/docs/kratos/passwordless/deviceauthn/index.mdx @@ -509,7 +509,8 @@ After opening the one-time `pin_secret`: - **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; `kSecAttrAccessibleWhenUnlockedThisDeviceOnly` on iOS). + (`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. diff --git a/docs/kratos/passwordless/deviceauthn/ios.mdx b/docs/kratos/passwordless/deviceauthn/ios.mdx index fb59b52a0e..85df8f59bd 100644 --- a/docs/kratos/passwordless/deviceauthn/ios.mdx +++ b/docs/kratos/passwordless/deviceauthn/ios.mdx @@ -336,6 +336,7 @@ 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" } @@ -465,16 +466,24 @@ enum PinVault { /// 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. - static func aesCtr(key: [UInt8], iv: [UInt8], data: [UInt8]) -> [UInt8] { + /// 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? - CCCryptorCreateWithMode( + 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 - CCCryptorUpdate(cryptor, data, data.count, &out, out.count, &moved) + status = CCCryptorUpdate(cryptor, data, data.count, &out, out.count, &moved) + guard status == CCCryptorStatus(kCCSuccess) else { + throw DeviceAuthnPinError.cryptoFailure + } return out } @@ -490,14 +499,17 @@ enum PinVault { 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) - _ = SecRandomCopyBytes(kSecRandomDefault, salt.count, &salt) var iv = [UInt8](repeating: 0, count: 16) - _ = SecRandomCopyBytes(kSecRandomDefault, iv.count, &iv) + 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 = aesCtr(key: pinKey, iv: iv, data: pinSecret) + let inner = try aesCtr(key: pinKey, iv: iv, data: pinSecret) let publicKey = SecKeyCopyPublicKey(sealingKey)! var error: Unmanaged? @@ -528,7 +540,7 @@ enum PinVault { pin: pin, salt: [UInt8](artifacts.salt), opsLimit: artifacts.opsLimit, memLimit: artifacts.memLimit) defer { for i in pinKey.indices { pinKey[i] = 0 } } - return aesCtr(key: pinKey, iv: [UInt8](artifacts.iv), data: [UInt8](inner)) + return try aesCtr(key: pinKey, iv: [UInt8](artifacts.iv), data: [UInt8](inner)) } } From 859a0ccb69339812960977ba9d3e24664354ee10 Mon Sep 17 00:00:00 2001 From: Henning Perl Date: Mon, 13 Jul 2026 12:18:36 +0200 Subject: [PATCH 17/18] docs: migrate device authentication section to deployment structure Shared partials under src/components/Shared/kratos/passwordless/deviceauthn with Network and OEL shell pages and per-deployment sidebar entries, so the section ships migrated (#2674) and OEL keeps navigation context. The old public URL redirects to the Network page. Co-Authored-By: Claude Fable 5 --- docs/kratos/mfa/01_overview.mdx | 6 +- .../kratos/passwordless/deviceauthn/index.mdx | 744 -------------- .../passwordless/deviceauthn/android.mdx | 15 + .../passwordless/deviceauthn/flutter.mdx | 15 + .../kratos/passwordless/deviceauthn/index.mdx | 16 + .../kratos/passwordless/deviceauthn/ios.mdx | 15 + .../passwordless/deviceauthn/android.mdx | 15 + .../passwordless/deviceauthn/flutter.mdx | 15 + .../kratos/passwordless/deviceauthn/index.mdx | 16 + .../kratos/passwordless/deviceauthn/ios.mdx | 15 + sidebars-network.ts | 8 +- sidebars-oel.ts | 16 + .../passwordless/deviceauthn/android.mdx | 301 +++--- .../passwordless/deviceauthn/flutter.mdx | 13 +- .../kratos/passwordless/deviceauthn/index.mdx | 921 ++++++++++++++++++ .../kratos/passwordless/deviceauthn/ios.mdx | 209 ++-- vercel.json | 5 + 17 files changed, 1382 insertions(+), 963 deletions(-) delete mode 100644 docs/kratos/passwordless/deviceauthn/index.mdx create mode 100644 docs/network/kratos/passwordless/deviceauthn/android.mdx create mode 100644 docs/network/kratos/passwordless/deviceauthn/flutter.mdx create mode 100644 docs/network/kratos/passwordless/deviceauthn/index.mdx create mode 100644 docs/network/kratos/passwordless/deviceauthn/ios.mdx create mode 100644 docs/oel/kratos/passwordless/deviceauthn/android.mdx create mode 100644 docs/oel/kratos/passwordless/deviceauthn/flutter.mdx create mode 100644 docs/oel/kratos/passwordless/deviceauthn/index.mdx create mode 100644 docs/oel/kratos/passwordless/deviceauthn/ios.mdx rename {docs => src/components/Shared}/kratos/passwordless/deviceauthn/android.mdx (73%) rename {docs => src/components/Shared}/kratos/passwordless/deviceauthn/flutter.mdx (93%) create mode 100644 src/components/Shared/kratos/passwordless/deviceauthn/index.mdx rename {docs => src/components/Shared}/kratos/passwordless/deviceauthn/ios.mdx (79%) diff --git a/docs/kratos/mfa/01_overview.mdx b/docs/kratos/mfa/01_overview.mdx index 804c69bd93..da0c8c0847 100644 --- a/docs/kratos/mfa/01_overview.mdx +++ b/docs/kratos/mfa/01_overview.mdx @@ -50,8 +50,10 @@ SMS for MFA sends a one-time password to the user's registered mobile phone numb ### Device authentication -Passwordless authentication where the private key is hardware-resident on the user's device. Read the -[Device authentication](../passwordless/deviceauthn/index.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/deviceauthn/index.mdx b/docs/kratos/passwordless/deviceauthn/index.mdx deleted file mode 100644 index 0145d8529e..0000000000 --- a/docs/kratos/passwordless/deviceauthn/index.mdx +++ /dev/null @@ -1,744 +0,0 @@ ---- -id: index -title: Device authentication -sidebar_label: Overview -slug: /kratos/passwordless/deviceauthn ---- - -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](android.mdx) -- [Device binding on iOS](ios.mdx) -- [Device binding in Dart/Flutter](flutter.mdx) - -## 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/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 88332db569..b33c431f28 100644 --- a/sidebars-network.ts +++ b/sidebars-network.ts @@ -206,10 +206,10 @@ const networkSidebar = [ type: "category", label: "Device authentication", items: [ - "kratos/passwordless/deviceauthn/index", - "kratos/passwordless/deviceauthn/android", - "kratos/passwordless/deviceauthn/ios", - "kratos/passwordless/deviceauthn/flutter", + "network/kratos/passwordless/deviceauthn/index", + "network/kratos/passwordless/deviceauthn/android", + "network/kratos/passwordless/deviceauthn/ios", + "network/kratos/passwordless/deviceauthn/flutter", ], }, "kratos/organizations/organizations", diff --git a/sidebars-oel.ts b/sidebars-oel.ts index 870e22f3a5..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", diff --git a/docs/kratos/passwordless/deviceauthn/android.mdx b/src/components/Shared/kratos/passwordless/deviceauthn/android.mdx similarity index 73% rename from docs/kratos/passwordless/deviceauthn/android.mdx rename to src/components/Shared/kratos/passwordless/deviceauthn/android.mdx index 33e10a5924..948d83b090 100644 --- a/docs/kratos/passwordless/deviceauthn/android.mdx +++ b/src/components/Shared/kratos/passwordless/deviceauthn/android.mdx @@ -1,49 +1,55 @@ ---- -id: android -title: Device authentication on Android -sidebar_label: Android ---- +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. -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. +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. +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: +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. 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: + [complete the settings flow](https://www.ory.com/docs/reference/api#tag/frontend/operation/updateSettingsFlow) + with this payload: ```json { @@ -70,12 +76,12 @@ additionally needs: 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. + 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: + [complete the settings flow](https://www.ory.com/docs/reference/api#tag/frontend/operation/updateSettingsFlow) + with this payload: ```json { @@ -119,14 +125,18 @@ additionally needs: .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. + 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: + [complete the login flow](https://www.ory.com/docs/reference/api#tag/frontend/operation/updateLoginFlow) + with this payload: ```json { @@ -173,7 +183,8 @@ additionally needs: } ``` -There are two Keystore calls required: one to create the key and one to use it to sign: +There are two Keystore calls required: one to create the key and one to use it +to sign: ```kotlin package com.ory.sdk @@ -339,21 +350,30 @@ internal class OryApi : Api { :::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`. +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 it together with [Client implementation requirements](index.mdx#client-implementation-requirements) — the -comments in the code below are normative and restate those rules inline. +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. +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 @@ -630,28 +650,39 @@ object PinVault { ## 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](index.mdx#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. +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. +**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 @@ -662,68 +693,100 @@ val updated = PinVault.seal( ) // 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: +**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](index.mdx#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. +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 -Each ceremony maps to one flow in the [Protocol reference](index.mdx#protocol-reference); 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 +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](index.mdx#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](index.mdx#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`, +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](index.mdx#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. +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. +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. -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](index.mdx#relaxed-attestation-for-testing). +See Relaxed attestation +for testing to learn more. 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. 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 +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/docs/kratos/passwordless/deviceauthn/flutter.mdx b/src/components/Shared/kratos/passwordless/deviceauthn/flutter.mdx similarity index 93% rename from docs/kratos/passwordless/deviceauthn/flutter.mdx rename to src/components/Shared/kratos/passwordless/deviceauthn/flutter.mdx index 241f96bdd3..8d59b29759 100644 --- a/docs/kratos/passwordless/deviceauthn/flutter.mdx +++ b/src/components/Shared/kratos/passwordless/deviceauthn/flutter.mdx @@ -1,11 +1,5 @@ ---- -id: flutter -title: Device authentication in Dart/Flutter -sidebar_label: Dart/Flutter ---- - -Dart can call native APIs via message passing. Let's call a function called `generateKey` with the parameter -`{'alias': 'my_key_01'}`: +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 { @@ -30,7 +24,8 @@ try { } ``` -Since the call might block, it is marked async and a loading indicator is shown in the UI via the `_isLoading` field. +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: 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/docs/kratos/passwordless/deviceauthn/ios.mdx b/src/components/Shared/kratos/passwordless/deviceauthn/ios.mdx similarity index 79% rename from docs/kratos/passwordless/deviceauthn/ios.mdx rename to src/components/Shared/kratos/passwordless/deviceauthn/ios.mdx index 85df8f59bd..197e9a8692 100644 --- a/docs/kratos/passwordless/deviceauthn/ios.mdx +++ b/src/components/Shared/kratos/passwordless/deviceauthn/ios.mdx @@ -1,60 +1,65 @@ ---- -id: ios -title: Device authentication on iOS -sidebar_label: iOS ---- - -A notable difference with Android is that Apple's app attestation APIs require a network call to Apple's servers from a real -device. +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. +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`). +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. +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: +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: +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. + [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: + [complete the settings flow](https://www.ory.com/docs/reference/api#tag/frontend/operation/updateSettingsFlow) + with this payload: ```json { @@ -90,12 +95,12 @@ is the correct API. ) ``` - 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. + 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: + [complete the settings flow](https://www.ory.com/docs/reference/api#tag/frontend/operation/updateSettingsFlow) + with this payload: ```json { @@ -143,13 +148,16 @@ is the correct API. 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. + 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: + [complete the login flow](https://www.ory.com/docs/reference/api#tag/frontend/operation/updateLoginFlow) + with this payload: ```json { @@ -309,21 +317,29 @@ public class OryApi { :::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`. +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 it together with [Client implementation requirements](index.mdx#client-implementation-requirements) — the comments in the -code below are normative and restate those rules inline. +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. +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 @@ -571,33 +587,47 @@ func loginWithPin(flowNonce: Data, pin: inout [UInt8], artifacts: PinArtifacts, } ``` -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` (see -[PIN enrollment](index.mdx#pin-enrollment)), 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. +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. On iOS, biometric first-factor login also requires the `ios_biometric_first_factor` opt-in — see -[Configuration](index.mdx#configuration). +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. +**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 */ @@ -613,16 +643,25 @@ let updated = try PinVault.seal( // 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: +**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](index.mdx#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. +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": [ From 9eb3c36c2c0cb30252cbed64f632d1d753ed4d57 Mon Sep 17 00:00:00 2001 From: Henning Perl Date: Mon, 13 Jul 2026 13:00:25 +0200 Subject: [PATCH 18/18] docs: restore section anchors on device authentication cross-links SameDeploymentLink's dev-time existence check now strips URL fragments, so cross-page links can carry #anchors; re-add the section anchors on the android/ios links into the overview page. Co-Authored-By: Claude Fable 5 --- src/components/SameDeploymentLink.tsx | 2 +- .../passwordless/deviceauthn/android.mdx | 43 ++++++++++++------- .../kratos/passwordless/deviceauthn/ios.mdx | 22 ++++++---- 3 files changed, 43 insertions(+), 24 deletions(-) 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 index 948d83b090..49960a1469 100644 --- a/src/components/Shared/kratos/passwordless/deviceauthn/android.mdx +++ b/src/components/Shared/kratos/passwordless/deviceauthn/android.mdx @@ -362,9 +362,12 @@ 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. +- Read + + Client implementation requirements + + together with this section — the comments in the code below are normative and restate + those rules inline. ### Reference implementation @@ -663,7 +666,7 @@ signs. Three differences from the PIN flow: `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 ). @@ -705,7 +708,7 @@ same signing key. Start a settings flow under a privileged session, then: 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 ). @@ -720,13 +723,19 @@ 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. +- 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 +1. **Enroll** + (PIN enrollment) — `decodeNonce` the flow's `deviceauthn_nonce` node, `createSealingKey`, create a `PinCeremony`, then `createPinSigningKey(alias, nonce, transportPublicKey)` and submit the `add` @@ -736,12 +745,13 @@ transport key — keep that discipline when you wire these calls. 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 + (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 +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 @@ -756,8 +766,11 @@ 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 to learn more. +- See + + Relaxed attestation for testing + + for the details. To exercise the biometric prompt on the emulated device, register a fingerprint: diff --git a/src/components/Shared/kratos/passwordless/deviceauthn/ios.mdx b/src/components/Shared/kratos/passwordless/deviceauthn/ios.mdx index 197e9a8692..724a790246 100644 --- a/src/components/Shared/kratos/passwordless/deviceauthn/ios.mdx +++ b/src/components/Shared/kratos/passwordless/deviceauthn/ios.mdx @@ -329,9 +329,12 @@ 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. +- Read + + Client implementation requirements + + together with this section — the comments in the code below are normative and restate + those rules inline. ### Reference implementation @@ -587,7 +590,7 @@ func loginWithPin(flowNonce: Data, pin: inout [UInt8], artifacts: PinArtifacts, } ``` -See PIN +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 @@ -617,9 +620,12 @@ scaffolding, reuse the `OryApi` helper from the 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. +- 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 @@ -653,7 +659,7 @@ same signing key. Start a settings flow under a privileged session, then: unhashed. 3. Submit the `rotate_secret` payload with `client_key_id`, the fresh `transport_public_key`, and that signature (see - + Rotating the PIN secret ).