Skip to content

Commit 363977a

Browse files
hperlclaude
andcommitted
docs: document the 30-key limit and treat the nonce as opaque
Per review: state the MaxKeysPerIdentity limit of 30 on the overview and both platform guides, and stop specifying the nonce length everywhere so clients do not hard-code it — the size may change (post-quantum algorithms, newly discovered attacks). The transport-key and pin_secret sizes stay: they are fixed by the pinned HPKE suite and the wire contract. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent f25269f commit 363977a

3 files changed

Lines changed: 18 additions & 15 deletions

File tree

src/components/Shared/kratos/passwordless/deviceauthn/android.mdx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -132,8 +132,8 @@ all options; an empty list disables the check.
132132
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
133133
that the application keeps track of the keys it created — including the mapping between the local key alias and the
134134
server-assigned `client_key_id` — to know which keys can be used on this device, compared to keys that belong to the same
135-
identity but reside on other devices. Note that there is a maximum number of keys that can be created for an identity, and
136-
there is no point to create multiple keys for the same user on the same device, even though the server allows it.
135+
identity but reside on other devices. Note that an identity can hold at most 30 device keys, and there is no point to create
136+
multiple keys for the same user on the same device, even though the server allows it.
137137

138138
1. To use a key to step-up the AAL,
139139
[complete the login flow](https://www.ory.com/docs/reference/api#tag/frontend/operation/updateLoginFlow) with this payload:
@@ -405,7 +405,8 @@ object DeviceAuthnPin {
405405
private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
406406
407407
/// The flow's hidden deviceauthn_nonce node value:
408-
/// base64(JSON {"nonce": "<base64 of 32 raw bytes>"}) → raw nonce bytes.
408+
/// base64(JSON {"nonce": "<base64>"}) → raw nonce bytes. Treat the nonce as
409+
/// opaque bytes; do not assume a fixed length.
409410
fun decodeNonce(nodeValue: String): ByteArray {
410411
val json = String(Base64.decode(nodeValue, Base64.DEFAULT), Charsets.UTF_8)
411412
return Base64.decode(JSONObject(json).getString("nonce"), Base64.DEFAULT)

src/components/Shared/kratos/passwordless/deviceauthn/index.mdx

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,8 @@ Since this is a strategy, it supports all the same hooks as the other strategies
4444
- With `first_factor` enabled, a key protected by an app PIN or platform biometrics is a complete passwordless login granting AAL2
4545
— see [How it works](#how-it-works) and [Configuration](#configuration).
4646
- 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.
47-
- A device may have multiple keys, to support multiple user accounts on the same device.
47+
- A device may have multiple keys, to support multiple user accounts on the same device. An identity can enroll at most 30 device
48+
keys.
4849
- Only these platforms are currently supported, because they offer native APIs, strong hardware, and trust guarantees:
4950
- iOS: 14.0+
5051
- iPadOS: 14.0+
@@ -193,8 +194,9 @@ hardware-binding guarantees that this strategy relies on.
193194
## Protocol reference
194195

195196
All flows are native (API) flows. The flow's UI contains a hidden `deviceauthn_nonce` node; its value is the base64 encoding of
196-
the JSON `{"nonce":"<base64 of 32 raw bytes>"}`. Decode twice to obtain the raw nonce bytes. The nonce is single-use and bound to
197-
the flow.
197+
the JSON `{"nonce":"<base64>"}`. Decode twice to obtain the raw nonce bytes. The nonce is single-use and bound to the flow. Treat
198+
it as opaque bytes: do not hard-code its length, which may change in the future — decode whatever the flow provides and feed it to
199+
the operations below as-is.
198200

199201
### Enrollment
200202

@@ -255,7 +257,7 @@ Runs in a settings flow under a privileged session.
255257
challenge = SHA256(nonce ‖ t_pub)
256258
```
257259

258-
The raw 32-byte nonce concatenated with the raw 32-byte transport public key, in that order, hashed once. This binds the
260+
The raw nonce bytes concatenated with the raw 32-byte transport public key, in that order, hashed once. This binds the
259261
transport key to the attested device: an intermediary that swaps `t_pub` invalidates the attestation.
260262

261263
:::warning
@@ -443,10 +445,9 @@ possession of the enrolled key:
443445
}
444446
```
445447

446-
- `signature` covers the challenge `nonce ‖ t_pub` — the raw 64-byte concatenation of the settings-flow nonce and the fresh
447-
transport public key, **not hashed by the caller**. Android signs it with `SHA256withECDSA` as usual; iOS passes it as
448-
`clientDataHash` to `generateAssertion`. This binding ensures a session-level attacker cannot rotate the secret to a transport
449-
key they control.
448+
- `signature` covers the challenge `nonce ‖ t_pub` — the raw concatenation of the settings-flow nonce and the fresh transport
449+
public key, **not hashed by the caller**. Android signs it with `SHA256withECDSA` as usual; iOS passes it as `clientDataHash` to
450+
`generateAssertion`. This binding ensures a session-level attacker cannot rotate the secret to a transport key they control.
450451
- Only PIN keys can be rotated.
451452
- Effects: fresh secret (delivered via the same one-time `continue_with`), failure counter reset, `locked` state cleared.
452453

@@ -548,7 +549,7 @@ rotated, and delaying sensitive operations after either event.
548549
nonce. PIN enrollment binds the transport key: use `SHA256(nonce ‖ transport_public_key)` as the challenge. The bare nonce is
549550
correct only for second-factor device binding and biometric enrollment.
550551
- **`The rotation signature is invalid.`** — the rotation signature must cover the raw concatenation
551-
`nonce ‖ transport_public_key` (64 bytes, not hashed by the caller, transport key generated fresh for this rotation).
552+
`nonce ‖ transport_public_key` (not hashed by the caller, transport key generated fresh for this rotation).
552553
- **Login always fails with the same generic error** — by design the server returns one identical error for an unknown key, a bad
553554
signature, a wrong PIN, and a locked key. Track wrong-PIN retries locally; after `pin_max_attempts` consecutive failures assume
554555
the key is locked and offer recovery.

src/components/Shared/kratos/passwordless/deviceauthn/ios.mdx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -154,8 +154,8 @@ all options; an empty list disables the check.
154154
155155
Once a key is created, the application must store both identifiers — the App Attest `keyId` (needed to sign) and the
156156
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
157-
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
158-
multiple keys for the same user on the same device, even though the server allows it.
157+
key exists. Note that an identity can hold at most 30 device keys, and there is no point to create multiple keys for the same
158+
user on the same device, even though the server allows it.
159159

160160
1. To use a key to step-up the AAL,
161161
[complete the login flow](https://www.ory.com/docs/reference/api#tag/frontend/operation/updateLoginFlow) with this payload:
@@ -352,7 +352,8 @@ enum DeviceAuthnPinError: Error {
352352
}
353353
354354
/// Decodes the value of the flow's hidden `deviceauthn_nonce` UI node:
355-
/// base64(JSON {"nonce": "<base64 of 32 raw bytes>"}) → raw nonce bytes.
355+
/// base64(JSON {"nonce": "<base64>"}) → raw nonce bytes. Treat the nonce as
356+
/// opaque bytes; do not assume a fixed length.
356357
func decodeNonce(nodeValue: String) -> Data? {
357358
guard let json = Data(base64Encoded: nodeValue),
358359
let obj = try? JSONSerialization.jsonObject(with: json) as? [String: String],

0 commit comments

Comments
 (0)