├── README.md └── benchmarks ├── .gitignore ├── README.md ├── arkg.py ├── bench-arkg.py ├── bench-software.py ├── bench-yubikey.py ├── recovery_extension.py └── tox.ini /README.md: -------------------------------------------------------------------------------- 1 | _NOTE: This is a draft and **not implementation ready**. Security analysis: https://eprint.iacr.org/2020/1004 (published at https://sigsac.org/ccs/CCS2020/)._ 2 | 3 | Authors: Emil Lundberg, Dain Nilsson 4 | 5 | # Introduction 6 | 7 | Web Authentication solves many problems in secure online authentication, but 8 | also introduces some new challenges. One of the greatest challenges is loss of 9 | an authenticator - what can the user do to prevent being locked out of their 10 | account if they lose an authenticator? 11 | 12 | The current workaround to this problem is to have the user register more than 13 | one authenticator, for example a roaming USB authenticator and a platform 14 | authenticator integrated into a smartphone. That way the user can still use the 15 | other authenticator to log in if they lose one of the two. 16 | 17 | However, this approach has drawbacks. What we would like to enable is for the 18 | user to have a separate _backup authenticator_ which they could leave in a safe 19 | place and not keep with them day-to-day. This is not really feasible with the 20 | aforementioned workaround, since the user would have to register the backup 21 | authenticator with each new RP where they register their daily-use 22 | authenticator. This effectively means that the user must keep the backup 23 | authenticator with them, or in an easily accessible location, to not risk 24 | forgetting to register the backup authenticator, which largely defeats the 25 | purpose of the backup authenticator. 26 | 27 | Under the restriction that we don't want to share any secrets or private keys 28 | between authenticators, one simple way to solve this would be to import a public 29 | key from the backup authenticator to the primary authenticator, so that the 30 | primary authenticator can also register that public key with each RP. Then the 31 | backup authenticator can later prove possession of the private key and recover 32 | access to the account. This has a big drawback, however: a static public key 33 | would be easily correlatable between RPs or accounts, undermining much of the 34 | privacy protections in WebAuthn. 35 | 36 | In this document we propose a key agreement scheme which allows a pair of 37 | authenticators to agree on an EC key pair in such a way that the primary 38 | authenticator can generate nondeterministic public keys, but only the backup 39 | authenticator can derive the corresponding private keys. We present the scheme 40 | in the context of a practical application as a WebAuthn extension for account 41 | recovery. This enables the use case of storing the backup authenticator in a 42 | secure location, while maintaining WebAuthn's privacy protection of 43 | non-correlatable public keys. 44 | 45 | 46 | # Terminology 47 | 48 | The following terms are used throughout this document: 49 | 50 | - `LEFT(X, n)` is the first `n` bytes of the byte array `X`. 51 | - `DROP_LEFT(X, n)` is the byte array `X` without the first `n` bytes. 52 | - CTAP2_ERR_XXX represents some not yet specified error code. 53 | 54 | 55 | # The key agreement scheme 56 | 57 | The scheme has three participants: 58 | 59 | - PA: the _primary authenticator_ 60 | - BA: the _backup authenticator_ 61 | - RP: the WebAuthn _Relying Party_ 62 | 63 | The goal is that PA will generate public keys for RP to store. At a later 64 | time, BA will request the public keys from RP and derive the corresponding 65 | private keys without further communication with PA. 66 | 67 | The scheme is divided into three stages ordered in this forward sequence: 68 | 69 | - In stage 1, only PA and BA may communicate. 70 | 71 | ``` 72 | PA <-> BA 73 | 74 | 75 | 76 | RP 77 | ``` 78 | 79 | This corresponds to the initial setup done to pair the primary authenticator 80 | with the backup authenticator. 81 | 82 | - In stage 2, only PA and RP may communicate. 83 | 84 | ``` 85 | PA BA 86 | ^ 87 | | 88 | v 89 | RP 90 | ``` 91 | 92 | This corresponds to using the primary authenticator for day-to-day 93 | authentication while the backup authenticator is stored away in a safe place. 94 | 95 | - In stage 3, only BA and RP may communicate. 96 | 97 | ``` 98 | PA BA 99 | ^ 100 | | 101 | | 102 | RP <---+ 103 | ``` 104 | 105 | This corresponds to the primary authenticator being lost and no longer 106 | available, and the backup authenticator having been retrieved from storage. 107 | 108 | 109 | ## Stage 1: Setup 110 | 111 | This procedure is performed once to set up the parameters for the key agreement 112 | scheme. 113 | 114 | 1. PA and BA agree on a choice of two key derivation functions `KDF1` and 115 | `KDF2`, and one message authentication code (MAC) function `MAC`. `KDF1` 116 | outputs integers and `KDF2` outputs values suitable as key inputs for `MAC`. 117 | 2. BA generates a new P-256 EC key pair with private key `s` and public key 118 | `S`. 119 | 3. BA sends `S` to PA. 120 | 4. RP chooses a unique public identifier `rp_id`. This is effectively a 121 | protocol constant and implicitly available to all parties at all times. 122 | 123 | 124 | ## Stage 2: Public key creation 125 | 126 | The following steps are performed by PA, the primary authenticator. 127 | 128 | 1. Generate an ephemeral EC P-256 key pair: `e`, `E`. 129 | 130 | 2. Let `cred_key = KDF1(ECDH(e, S))` and `mac_key = KDF2(ECDH(e, S))`. 131 | 132 | 3. If `cred_key >= n`, where `n` is the order of the P-256 curve, start 133 | over from 1. 134 | 135 | 4. Calculate `P = (cred_key * G) + S`, where * and + are EC point 136 | multiplication and addition, and `G` is the generator of the P-256 curve. 137 | 138 | 5. If `P` is the point at infinity, start over from 1. 139 | 140 | 6. Let `credential_id = E || LEFT(MAC(mac_key, E || rp_id), 16)`. 141 | 142 | 7. Send the pair `(P, credential_id)` to RP for storage. 143 | 144 | 145 | ## Stage 3: Private key derivation 146 | 147 | The following steps are performed by BA, the backup authenticator. 148 | 149 | 1. Retrieve a set of `credential_id`s from RP. Perform the following steps 150 | for each `credential_id`. 151 | 152 | 1. Let `E = LEFT(credential_id, 65)`. Verify that `E` is not the point at 153 | infinity. 154 | 155 | 1. Let `cred_key = KDF1(ECDH(s, E))` and `mac_key = KDF2(ECDH(s, E))`. 156 | 157 | 1. Verify that `credential_id == E || LEFT(MAC(mac_key, E || rp_id), 16)`. If 158 | not, this `credential_id` was generated for a different backup authenticator 159 | than BA or a different relying party than RP, and is not processed further. 160 | 161 | 1. Calculate `p = cred_key + s mod n`, 162 | where `n` is the order of the P-256 curve. 163 | 164 | 1. The private key is `p`, which BA can now use to create a signature. 165 | 166 | As a result of these procedures, BA will have derived `p` such that 167 | 168 | p * G = (cred_key + s) * G = 169 | = cred_key * G + s * G = 170 | = cred_key * G + S = P. 171 | 172 | 173 | ## Protocol weaknesses 174 | 175 | Although it was shown by Frymann et al. [1] that derived public keys `P` are 176 | unlinkable, it was brought to our attention by Wilson [2] that a weakness exists 177 | in this generic protocol: a user who has multiple accounts can be tricked by a 178 | malicous RP into revealing their ownership of both accounts. 179 | 180 | The attack proceeds as follows: 181 | 182 | 1. The user performs the procedure defined above in "public key creation" twice, 183 | resulting in two distinct public keys `P1` and `P2`, with respective 184 | credential ID `credential_id1` and `credential_id2`. The user registers 185 | (`P1`, `credential_id1`) as a recovery key for account `A1` at the RP, and 186 | registers (`P2`, `credential_id2`) as a recovery key for account `A2` at the 187 | same RP. 188 | 189 | 1. The user initiates the recovery procedure for account `A1`, expecting the RP 190 | to respond with an authentication challenge with `credential_id1` to be 191 | signed by `P1`. 192 | 193 | 1. The RP instead responds with an authentication challenge with 194 | `credential_id2`. Since this is also a valid credential ID for the same RP 195 | ID, the user's backup authenticator successfully produces an authentication 196 | signature signed by `P2`. 197 | 198 | 1. Since `P2` is registered to account `A2`, the RP can conclude that the user 199 | most likely owns both `A1` and `A2`. 200 | 201 | This account linking attack is possible in the WebAuthn protocol without the 202 | `recovery` extension proposed below, so this weakness does not introduce any new 203 | weakness when used in the WebAuthn context. However, this weakness should be 204 | taken into account should this protocol be applied in other contexts where it 205 | would introduce a new weakness. In that case, this weakness could possibly be 206 | mitigated by including some form of account identifier in the MAC embedded in 207 | the `credential_id`; this way the client and authenticator could cooperate to 208 | detect if the RP responds with `credential_id`s for a different account than the 209 | user requested. See [2] for additional detail. 210 | 211 | - [1]: Frymann et al., "Asynchronous Remote Key Generation: An Analysis of 212 | Yubico's Proposal for W3C WebAuthn". Proceedings of the 2020 ACM SIGSAC 213 | Conference on Computer and Communications Security, 2020. 214 | https://doi.org/10.1145/3372297.3417292 215 | - [2]: Wilson, Spencer MacLaren, "Post-Quantum Account Recovery for Passwordless 216 | Authentication". Master Thesis, University of Waterloo, 2023. 217 | https://uwspace.uwaterloo.ca/handle/10012/19316 218 | 219 | 220 | # Application: WebAuthn extension 221 | 222 | This section proposes an application of the above key agreement scheme as a 223 | WebAuthn extension for recovery credentials. The second subsection proposes the 224 | CTAP2 commands used to export and import the recovery seed. 225 | 226 | 227 | ## Recovery Credentials Extension (`recovery`) 228 | 229 | This extension allows for recovery credentials to be registered with an RP, 230 | which can be used for account recovery in the case of a lost or destroyed 231 | _primary authenticator_. This is done by associating one or more _backup 232 | authenticators_ with the primary authenticator, after which the latter can 233 | provide additional credentials for account recovery to the RP without involving 234 | the backup authenticators. 235 | 236 | In summary, the extension works like this: 237 | 238 | 1. The primary authenticator first generates public keys and credential IDs for 239 | recovery credentials. These are stored by the RP and associated with the 240 | primary authenticator's credential, the _primary credential_. These are 241 | delivered through the authenticator data, and therefore signed by the 242 | primary credential. 243 | 244 | 1. After losing the primary authenticator, account recovery can be done by 245 | creating a new credential with the backup authenticator. The backup 246 | authenticator receives the recovery credential IDs from the RP, and can use 247 | one of them to derive the private key corresponding to the recovery public 248 | key. The backup authenticator uses this private key to sign the new 249 | credential public key, thus creating a signature chain from the primary 250 | credential to the new credential. 251 | 252 | 1. Upon verifying the recovery signature, the RP invalidates the primary 253 | credential and all recovery credentials associated with it, and replaces it 254 | with the new credential. The backup authenticator is thus "promoted" and 255 | replaces the primary authenticator. 256 | 257 | In order for the RP to detect when recovery credentials can be registered, or 258 | need to be updated, the primary authenticator keeps a _recovery credentials 259 | state counter_ defined as follows. Let `state` be initialized to 0. Performing a 260 | device reset resets `state` to 0. When the set of registered backup 261 | authenticators for the device changes (e.g., on adding or removing a backup 262 | authenticator, including adding the first backup authenticator) `state` is 263 | incremented by one. 264 | 265 | NOTE: The choice to make registration of recovery credentials explicit is 266 | deliberate, in an attempt to ensure that the user deliberately intends to do so 267 | and understands the implications. 268 | 269 | The authenticator operations are governed by an `alg` parameter, 270 | an unsigned 8-bit integer identifying the key agreement scheme to be used. 271 | Credential IDs for recovery credentials are always on the form 272 | `alg || `, 273 | where the format and meaning of `` 274 | depends on the value of `alg`. 275 | This allows for new key agreement schemes to be added in the future 276 | without changes to the WebAuthn-facing interface; 277 | clients and RPs are automatically compatible with any new key agreement schemes. 278 | Currently the only valid value for `alg` is `alg=0`. 279 | 280 | 281 | ### Extension identifier 282 | 283 | `recovery` 284 | 285 | 286 | ### Operation applicability 287 | 288 | Registration and Authentication 289 | 290 | 291 | ### Client extension input 292 | 293 | partial dictionary AuthenticationExtensionsClientInputs { 294 | RecoveryExtensionInput recovery; 295 | } 296 | 297 | dictionary RecoveryExtensionInput { 298 | required RecoveryExtensionAction action; 299 | sequence allowCredentials; 300 | } 301 | 302 | enum RecoveryExtensionAction { 303 | "state", 304 | "generate", 305 | "recover" 306 | } 307 | 308 | The values of `action` have the following meanings. `X` indicates that the value 309 | is applicable for the given WebAuthn operation: 310 | 311 | | Value | create() | get() | Description | 312 | | :------: | :--------: | :-----: | ------------- | 313 | | state | X | X | Get the _recovery credentials state counter_ value from the primary authenticator. | 314 | | generate | | X | Regenerate recovery credentials from the primary authenticator. | 315 | | recover | X | | Get a recovery signature from a backup authenticator, to replace the primary credential with a new one. | 316 | 317 | 318 | ### Client extension processing 319 | 320 | None required, except creating the authenticator extension input from the client 321 | extension input. 322 | 323 | If the client implements support for this extension, then when `action` is 324 | `"generate"`, the client SHOULD notify the user of the number of recovery 325 | credentials in the response. 326 | 327 | 328 | ### Client extension output 329 | None. 330 | 331 | 332 | ### Authenticator extension input 333 | 334 | The client extension input encoded as a CBOR map. 335 | 336 | 337 | ### Authenticator extension processing 338 | 339 | If `action` is 340 | 341 | - `"state"`, 342 | 343 | 1. Let `state` be the current value of the _recovery credentials state 344 | counter_. 345 | 346 | 2. Set the extension output to the CBOR encoding of `{"action": "state", 347 | "state": state}`. 348 | 349 | 350 | - `"generate"`, 351 | 352 | 1. If the current operation is not an `authenticatorGetAssertion` operation, 353 | return CTAP2_ERR_XXX. 354 | 355 | 2. Let `creds` be an empty list. 356 | 357 | 3. For each recovery seed tuple `(alg, aaguid, S)` 358 | stored in this authenticator: 359 | 360 | 1. If `alg` equals 361 | 362 | - 0: 363 | 364 | 1. Generate an ephemeral EC P-256 key pair: `e, E`. `E` MUST NOT be 365 | the point at infinity. 366 | 367 | 1. Let `ikm = ECDH(e, S)`. Let `ikm_x` be the X coordinate of `ikm`, 368 | encoded as a byte string of length 32 as described in 369 | [SEC 1][sec1], section 2.3.7. 370 | 371 | 1. Let `credKey` be the 32 bytes of output keying material from [HKDF-SHA-256][hkdf] 372 | with the arguments: 373 | 374 | - `salt`: Not set. 375 | - `IKM`: `ikm_x`. 376 | - `info`: The string `webauthn.recovery.cred_key` encoded as a UTF-8 byte string. 377 | - `L`: 32. 378 | 379 | Parse `credKey` as a big-endian unsigned 256-bit number. 380 | 381 | 1. Let `macKey` be the 32 bytes of output keying material from [HKDF-SHA-256][hkdf] 382 | with the arguments: 383 | 384 | - `salt`: Not set. 385 | - `IKM`: `ikm_x`. 386 | - `info`: The string `webauthn.recovery.mac_key` encoded as a UTF-8 byte string. 387 | - `L`: 32. 388 | 389 | 1. If `credKey >= n`, where `n` is the order of the P-256 curve, 390 | start over from 1. 391 | 392 | 1. Let `P = (credKey * G) + S`, where * and + are EC point 393 | multiplication and addition, and `G` is the generator of the P-256 394 | curve. 395 | 396 | 1. If `P` is the point at infinity, start over from 1. 397 | 398 | 1. Let `rpIdHash` be the SHA-256 hash of `rpId`. 399 | 400 | 1. Let `E_enc` be `E` encoded as described in [SEC 1][sec1], section 401 | 2.3.3, without point compression. 402 | 403 | 1. Set `credentialId = alg || E_enc || LEFT(HMAC-SHA-256(macKey, alg || E_enc || 404 | rpIdHash), 16)`. 405 | 406 | - anything else: 407 | 408 | 1. Return CTAP2_ERR_XXX. 409 | 410 | Note: This should never happen, since the _Import recovery seed_ 411 | operation should never store a recovery seed with an unknown 412 | `alg` value. 413 | 414 | 2. Let `attCredData` be a new [attested credential data][att-cred-data] 415 | structure with the following member values: 416 | 417 | - **aaguid**: `aaguid`. 418 | - **credentialIdLength**: The byte length of `credentialId`. 419 | - **credentialId**: `credentialId`. 420 | - **credentialPublicKey**: `P`. 421 | 422 | 3. Add `attCredData` to `creds`. 423 | 424 | 4. Let `state` be the current value of the _recovery credentials state 425 | counter_. 426 | 427 | 5. Set the extension output to the CBOR encoding of `{"action": "generate", 428 | "state": state, "creds": creds}`. 429 | 430 | 431 | - `"recover"`, 432 | 433 | 1. If the current operation is not an `authenticatorMakeCredential` 434 | operation, return CTAP2_ERR_XXX. 435 | 436 | 2. If the recovery seed key pair `s, S` has not been initialized, return 437 | CTAP2_ERR_XXX. 438 | 439 | 3. For each `cred` in `allowCredentials`: 440 | 441 | 1. Let `alg = LEFT(cred.id, 1)`. 442 | 443 | 2. If `alg` equals 444 | 445 | - 0: 446 | 447 | 1. Let `E_enc = LEFT(DROP_LEFT(cred.id, 1), 65)`. 448 | 449 | 1. Let `E` be the P-256 public key decoded from the uncompressed point 450 | `E_enc` as described in [SEC 1][sec1], section 2.3.4. If invalid, 451 | return CTAP2_ERR_XXX. 452 | 453 | 1. If `E` is the point at infinity, return CTAP2_ERR_XXX. 454 | 455 | 1. Let `ikm = ECDH(s, E)`. Let `ikm_x` be the X coordinate of `ikm`, 456 | encoded as a byte string of length 32 as described in 457 | [SEC 1][sec1], section 2.3.7. 458 | 459 | 1. Let `credKey` be the 32 bytes of output keying material from [HKDF-SHA-256][hkdf] 460 | with the arguments: 461 | 462 | - `salt`: Not set. 463 | - `IKM`: `ikm_x`. 464 | - `info`: The string `webauthn.recovery.cred_key` encoded as a UTF-8 byte string. 465 | - `L`: 32. 466 | 467 | Parse `credKey` as a big-endian unsigned 256-bit number. 468 | 469 | 1. Let `macKey` be the 32 bytes of output keying material from [HKDF-SHA-256][hkdf] 470 | with the arguments: 471 | 472 | - `salt`: Not set. 473 | - `IKM`: `ikm_x`. 474 | - `info`: The string `webauthn.recovery.mac_key` encoded as a UTF-8 byte string. 475 | - `L`: 32. 476 | 477 | 1. Let `rpIdHash` be the SHA-256 hash of `rp.id`. 478 | 479 | 1. If `cred.id` is not exactly equal to `alg || E || LEFT(HMAC-SHA-256(macKey, alg 480 | || E || rpIdHash), 16)`, _continue_. 481 | 482 | 1. Let `p = credKey + s (mod n)`, where `n` is the order of the P-256 483 | curve. 484 | 485 | 1. Let `authenticatorDataWithoutExtensions` be the [authenticator 486 | data][authdata] that will be returned from this registration operation, 487 | but without the `extensions` part. The `ED` flag in 488 | `authenticatorDataWithoutExtensions` MUST be set to 1 even though 489 | `authenticatorDataWithoutExtensions` does not include extension data. 490 | 491 | 1. Let `sig` be a signature over `authenticatorDataWithoutExtensions || 492 | clientDataHash` using `p`. `sig` is DER encoded as described in [RFC 493 | 3279][rfc3279]. 494 | 495 | - anything else: 496 | 497 | 1. _Continue_. 498 | 499 | 9. Let `state` be the current value of the _recovery credentials state 500 | counter_. 501 | 502 | 10. Set the extension output to the CBOR encoding of `{"action": 503 | "recover", "credId": cred.id, "sig": sig, "state": state}` and end 504 | extension processing. 505 | 506 | 4. Return an error code equivalent to ERR_XXX. 507 | 508 | - anything else, 509 | 510 | 1. Return CTAP2_ERR_XXX. 511 | 512 | 513 | ### Authenticator extension output 514 | 515 | A CBOR map with contents as defined above. 516 | 517 | dictionary RecoveryExtensionOutput { 518 | required RecoveryExtensionAction action; 519 | required int state; 520 | sequence creds; 521 | ArrayBuffer credId; 522 | ArrayBuffer sig; 523 | } 524 | 525 | 526 | ### Recovery credential considerations 527 | 528 | - The RP MUST be very explicit in notifying the user when recovery credentials 529 | are registered, and how many, to avoid any credentials being registered 530 | without the user's knowledge. If possible, the client SHOULD also display the 531 | number of backup authenticators associated with the primary authenticator. 532 | 533 | - The RP SHOULD clearly display information about registered recovery 534 | credentials, just as it does with standard credentials. For example, the RP 535 | MAY use the AAGUIDs of recovery credentials to indicate the (alleged) model of 536 | the corresponding managing authenticator. 537 | 538 | - All security and privacy considerations for standard credentials also apply to 539 | recovery credentials. 540 | 541 | - Although recovery credentials are issued by the primary authenticator, they 542 | can only ever be used by the backup authenticator. 543 | 544 | - Recovery credentials are scoped to a specific RP ID, and the RP SHOULD also 545 | associate each recovery credential with a specific primary credential. 546 | 547 | - Recovery credentials can only be used in registration ceremonies where the 548 | recovery extension is present, with `action == "recover"`. 549 | 550 | - A primary authenticator MAY refuse to import a recovery seed without a trusted 551 | attestation signature, to reduce the risk that an RP rejects the recovery 552 | credential that would later be generated by the backup authenticator. 553 | 554 | - Recovery credentials cannot be used as resident credentials, since they by 555 | definition cannot be stored in the backup authenticator. 556 | 557 | 558 | ## Authenticator operations 559 | 560 | The CTAP2 command `authenticatorRecovery` is added. It is not exposed via any 561 | browser API. 562 | 563 | 564 | ### authenticatorRecovery (0x0D) 565 | 566 | This command is used to export a recovery seed from a backup authenticator and 567 | then to import the seed to another authenticator, so that the latter can issue 568 | recovery credentials on behalf of the backup authenticator. 569 | 570 | It takes the following input parameters: 571 | 572 | | Parameter name | Data type | Required? | Definition 573 | | --- | ---- | ---- | ----------- 574 | | subCommand (0x01) | Unsigned integer | Required | Identifier for the subcommand to execute. 575 | | allowAlgs (0x02) | Array of unsigned integers | Optional | Required if subCommand = exportSeed (0x02). List of acceptable key agreement schemes for seed export. 576 | | seed (0x03) | RecoverySeed | Optional | Required if subCommand = importSeed (0x03). Recovery seed to import. 577 | | pinUvAuthProtocol (0x04) | Unsigned integer | Required | PIN/UV protocol version chosen by the client. 578 | | pinUvAuthParam (0x05) | Byte array | Required | First 16 bytes of HMAC-SHA-256 of contents using pinUvAuthToken 579 | 580 | The list of sub commands for recovery seeds is: 581 | 582 | | subCommand Name | subCommand Number 583 | | --- | ---- 584 | | getAllowAlgs | 0x01 585 | | exportSeed | 0x02 586 | | importSeed | 0x03 587 | 588 | The RecoverySeed type is a CBOR map with the following structure: 589 | 590 | | Member name | Data type | Required? | Definition 591 | | --- | ---- | ---- | ----------- 592 | | alg (0x01) | Unsigned integer | Required | Identifier for the key agreement scheme. 593 | | aaguid (0x02) | Byte array | Required | AAGUID of the authenticator that exported the `payload`. 594 | | x5c (0x03) | Array of byte arrays | Required | Sequence of DER encoded X.509 attestation certificates 595 | | sig (0x04) | Byte array | Required | DER encoded ECDSA signature. 596 | | S_enc (0xFF) | Byte array | Optional | Required if alg = 0x00. P-256 public key encoded as described in [SEC 1][sec1], section 2.3.4, without point compression. 597 | 598 | On success, authenticator returns the following structure in its response: 599 | 600 | | Parameter name | Data type | Required? | Definition 601 | | --- | ---- | ---- | ----------- 602 | | allowAlgs (0x02) | Array of unsigned integers | Optional | List of key agreement schemes the authenticator supports. 603 | | seed (0x03) | RecoverySeed | Optional | Recovery seed to be imported by another authenticator. 604 | 605 | 606 | #### Feature detection 607 | 608 | TODO 609 | 610 | 611 | #### Get supported key agreement schemes (subCommand 0x01) 612 | 613 | Used by the platform to get a suitable value for the allowAlgs (0x02) parameter 614 | of the exportSeed (0x02) subcommand. 615 | 616 | Following operations are performed to get the list of recovery key agreement 617 | schemes an authenticator supports: 618 | 619 | - Platform sends authenticatorRecovery command with following parameters: 620 | - subCommand (0x01): getAllowAlgs (0x01) 621 | - Authenticator returns authenticatorRecovery response with following 622 | parameters: 623 | - allowAlgs (0x02): An array containing the integer 0 as the only element. 624 | 625 | 626 | #### Export Recovery Seed (subCommand 0x02) 627 | 628 | Exports a seed which can be imported into other authenticators, enabling them to 629 | register credentials on behalf of the exporting authenticator, for the purpose 630 | of account recovery. 631 | 632 | 633 | Following operations are performed to get a recovery seed: 634 | 635 | - Platform gets pinUvAuthToken from the authenticator. 636 | - Platform sends authenticatorRecovery command with following parameters: 637 | - subCommand (0x01): exportSeed (0x02) 638 | - allowAlgs (0x02): Output from getAllowAlgs (0x01) subcommand on a different 639 | authenticator 640 | 641 | - pinUvAuthProtocol (0x04): Pin Protocol used. Currently this is 0x01. 642 | - pinUvAuthParam (0x05): `LEFT(HMAC-SHA-256(pinUvAuthToken, exportSeed 643 | (0x02)), 16)`. 644 | 645 | - Authenticator verifies pinUvAuthParam by generating 646 | `LEFT(HMAC-SHA-256(pinUvAuthToken, exportSeed (0x02)), 16)` and matching 647 | against input pinUvAuthParam parameter. 648 | - If pinUvAuthParam verification fails, authenticator returns 649 | CTAP2_ERR_PIN_AUTH_INVALID error. 650 | - If authenticator sees 3 consecutive mismatches, it returns 651 | CTAP2_ERR_PIN_AUTH_BLOCKED indicating that power recycle is needed for 652 | further operations. This is done so that malware running on the platform 653 | should not be able to block the device without user interaction. 654 | 655 | - Authenticator performs following steps: 656 | 657 | 1. For each `alg` in `allowAlgs`: 658 | 659 | 1. If `alg` equals: 660 | 661 | - 0: 662 | 663 | 1. If the recovery functionality is uninitialized, generate a new EC 664 | P-256 key pair and store it as `s, S`. The `authenticatorReset` 665 | command MUST erase `s` and `S`. 666 | 667 | 1. Let `S_enc` be `S` encoded as described in [SEC 1][sec1], section 668 | 2.3.3, without point compression. 669 | 670 | 1. Let `sig` be a signature over the data `alg || aaguid || S_enc` 671 | using the authenticator's attestation key and the SHA-256 hash 672 | algorithm. `sig` is DER encoded as described in [RFC 673 | 3279][rfc3279]. 674 | 675 | 1. Authenticator returns authenticatorRecovery response with 676 | following parameters: 677 | 678 | - seed (0x03): RecoverySeed structure with following parameters: 679 | - alg (0x01): `alg` 680 | - aaguid (0x02): Authenticator's AAGUID 681 | - x5c (0x03): Authenticator's attestation certificate chain as 682 | DER encoded X.509 certificates, with leaf attestation 683 | certificate as the first element 684 | - sig (0x04): `sig` as computed above 685 | - S_enc (0xFF): `S_enc` as computed above 686 | 687 | - anything else: 688 | 689 | 1. _Continue_. 690 | 691 | 2. Return CTAP2_ERR_XXX. 692 | 693 | 694 | #### Import Recovery Seed (subCommand 0x03) 695 | 696 | Imports a recovery seed, enabling this authenticator to issue recovery 697 | credentials on behalf of a backup authenticator. Multiple recovery seeds can be 698 | imported into an authenticator, limited by storage space. Resetting the 699 | authenticator removes all stored recovery seeds, and resets the `state` counter 700 | to 0. 701 | 702 | Following operations are performed to get a recovery seed: 703 | 704 | - Platform gets pinUvAuthToken from the authenticator. 705 | - Platform sends authenticatorRecovery command with following parameters: 706 | - subCommand (0x01): importSeed (0x03) 707 | - seed (0x03): Output from exportSeed (0x02) subcommand on a different 708 | authenticator, containing following parameters: 709 | - alg (0x01): Identifier for key agreement scheme 710 | - aaguid (0x02): AAGUID of the authenticator that exported the seed 711 | - x5c (0x03): Attestation certificate chain of the authenticator that 712 | exported the seed 713 | - sig (0x04): Attestation signature over the seed contents 714 | - S_enc (0xFF): Required if alg = 0x00. EC public key encoded without point compression. 715 | 716 | - pinUvAuthProtocol (0x04): Pin Protocol used. Currently this is 0x01. 717 | - pinUvAuthParam (0x05): `LEFT(HMAC-SHA-256(pinUvAuthToken, importSeed 718 | (0x03)), 16)`. 719 | 720 | - Authenticator verifies pinUvAuthParam by generating 721 | `LEFT(HMAC-SHA-256(pinUvAuthToken, importSeed (0x03)), 16)` and matching 722 | against input pinUvAuthParam parameter. 723 | - If pinUvAuthParam verification fails, authenticator returns 724 | CTAP2_ERR_PIN_AUTH_INVALID error. 725 | - If authenticator sees 3 consecutive mismatches, it returns 726 | CTAP2_ERR_PIN_AUTH_BLOCKED indicating that power recycle is needed for 727 | further operations. This is done so that malware running on the platform 728 | should not be able to block the device without user interaction. 729 | 730 | - Authenticator performs following steps: 731 | 732 | 1. If the authenticator has no storage space available to import a recovery 733 | seed, return CTAP2_ERR_XXX. 734 | 735 | 1. If `alg` equals: 736 | 737 | - 0: 738 | 739 | 1. Let `S` be the P-256 public key decoded from the uncompressed point 740 | `S_enc` as described in [SEC 1][sec1], section 2.3.4. If invalid, 741 | return CTAP2_ERR_XXX. 742 | 743 | 1. Let `attestation_cert` be the first element of `x5c`. 744 | 745 | 1. Extract the public key from `attestation_cert` and use it to verify 746 | the signature `sig` against the signed data `alg || aaguid || 747 | S_enc`. If invalid, return CTAP2_ERR_XXX. 748 | 749 | 1. If `attestation_cert` contains an extension with OID 750 | 1.3.6.1.4.1.45724.1.1.4 (`id-fido-gen-ce-aaguid`), verify that the 751 | value of this extension equals `aaguid`. 752 | 753 | 1. OPTIONALLY, perform this sub-step: 754 | 1. Using a vendor-specific store of trusted attestation CA 755 | certificates, verify the signature chain `x5c`. If invalid or 756 | untrusted, OPTIONALLY return CTAP2_ERR_XXX. 757 | 758 | 1. Store `(alg, aaguid, S)` internally. 759 | 760 | - anything else: 761 | 762 | 1. Return CTAP2_ERR_XXX. 763 | 764 | 1. Increment the `state` counter by one (the counter's initial value is 0). 765 | 766 | 1. Return CTAP2_OK. 767 | 768 | 769 | ## RP operations 770 | 771 | An RP supporting this extension SHOULD include the extension with `action: 772 | "state"` whenever performing a registration or authentication ceremony. There 773 | are two cases where the response indicates that the RP SHOULD initiate recovery 774 | credential registration (`action: "generate"`), which are: 775 | 776 | - Upon successful `create()`, if `state` > 0. 777 | - Upon successful `get()`, if `state` > `old_state`, where `old_state` is the 778 | previous value for `state` that the RP has seen for the used credential. 779 | 780 | The following operations assume that each user account contains a 781 | `recoveryStates` field, which is a map with credential IDs as keys. 782 | `recoveryStates` is initialized to an empty map. 783 | 784 | 785 | ### Detecting changes to recovery seeds 786 | 787 | To detect when the user's authenticator has updated its recovery seed settings, 788 | the RP SHOULD add the following steps to all registration and authentication 789 | ceremonies: 790 | 791 | 1. When initiating any `create()` or `get()` operation, set the extension 792 | `"recovery": {"action": "state"}`. 793 | 794 | 1. Let `pkc` be the PublicKeyCredential response from the client. 795 | 796 | 1. In step 14 of the RP Operation to [Register a New 797 | Credential][rp-reg-ext-processing], or 15 of the RP Operation to [Verify an 798 | Authentication Assertion][rp-auth-ext-processing], perform the following 799 | steps: 800 | 801 | 1. Let `extOutput` be the recovery extension output, or null if not 802 | present. For `create()` ceremonies, this is `extOutput = 803 | pkc.response.attestationObject["authData"].extensions["recovery"]`; for 804 | `get()` ceremonies it is `extOutput = 805 | pkc.response.authenticatorData.extensions["recovery"]`. 806 | 807 | 1. If `extOutput` is not null: 808 | 809 | 1. If `extOutput.action` does not equal `"state"`, or `extOutput.state` 810 | is not present, abort this extension processing and OPTIONALLY show 811 | a user-visible warning. 812 | 813 | 1. If `extOutput.state > 0`: 814 | 815 | 1. Let `recoveryState = recoveryStates[pkc.id]`, or null if not 816 | present. 817 | 818 | 1. If `recoveryState` is null or `extOutput.state > 819 | recoveryState.state`: 820 | 821 | 1. If the ceremony finishes successfully, prompt the user that 822 | their recovery credentials need to be updated and ask to 823 | initiate a _Registering recovery credentials_ ceremony as 824 | described below. It is RECOMMENDED to set `allowCredentials` 825 | to contain only `pkc.id` in this authentication ceremony. 826 | 827 | 4. Continue with the remaining steps of the standard registration or 828 | authentication ceremony. 829 | 830 | 831 | ### Registering recovery credentials 832 | 833 | To register new recovery credentials for a given primary credential, or replace 834 | the existing recovery credentials with updated ones, the RP performs the 835 | following procedure: 836 | 837 | 1. Initiate a `get()` operation and set the extension `"recovery": {"action": 838 | "generate"}`. 839 | 840 | If this ceremony was triggered as described in _Detecting changes to 841 | recovery seeds_, it is RECOMMENDED to set `allowCredentials` to contain only 842 | the credential that was used in that preceding ceremony. 843 | 844 | 1. Let `pkc` be the PublicKeyCredential response from the client. If the 845 | operation fails, abort the ceremony with an error. 846 | 847 | 1. In step 15 of the RP Operation to [Verify an Authentication 848 | Assertion][rp-auth-ext-processing], perform the following steps: 849 | 850 | 1. Let `extOutput = pkc.response.authenticatorData.extensions["recovery"]`, 851 | or null if not present. 852 | 853 | 1. If `extOutput` is null, `extOutput.action` does not equal `"generate"`, 854 | `extOutput.state` is not present, or `extOutput.creds` is not present, 855 | abort the ceremony with an error. 856 | 857 | 1. Let `acceptedCreds` be a new empty list. 858 | 859 | 1. Let `rejectedCreds` be a new empty list. 860 | 861 | 1. For each `cred` in `extOutput.creds`: 862 | 863 | 1. If `cred.aaguid` identifies an authenticator model accepted by the 864 | RP's policy, add `cred` to `acceptedCreds`. Otherwise, add `cred` to 865 | `rejectedCreds`. 866 | 867 | 1. Set `recoveryStates[pkc.id] = (extOutput.state, acceptedCreds)`. 868 | 869 | 1. Show the user a confirmation message containing the length of 870 | `acceptedCreds`. 871 | 872 | 1. If `rejectedCreds` is not empty, show the user a warning message. The 873 | warning message SHOULD contain the length of `rejectedCreds` and, if 874 | possible, descriptions of the AAGUIDs that were rejected. 875 | 876 | 1. Continue with the remaining steps of the standard authentication ceremony. 877 | 878 | 879 | ### Using a recovery credential to replace a lost primary credential 880 | 881 | To authenticate the user with a recovery credential and create a new primary 882 | credential, the RP performs the following procedure: 883 | 884 | 1. Identify the user, for example by asking for a username. 885 | 886 | 1. Let `allowCredentials` be a new empty list. 887 | 888 | 1. For each `(state, creds)` value in the `recoveryStates` map stored in the 889 | user's account: 890 | 891 | 1. For each `cred` in `creds`: 892 | 893 | 1. Let `credDesc` be a PublicKeyCredentialDescriptor structure with the 894 | following member values: 895 | 896 | - **type**: `"public-key"`. 897 | - **id**: `cred.credentialId`. 898 | 899 | 1. Add `credDesc` to `allowCredentials`. 900 | 901 | 1. If `allowCredentials` is empty, abort this procedure with an error. 902 | 903 | 1. Initiate a `create()` operation with the extension input: 904 | 905 | "recovery": { 906 | "action": "recover", 907 | "allowCredentials": 908 | } 909 | 910 | 1. Let `pkc` be the PublicKeyCredential response from the client. If the 911 | operation fails, abort the ceremony with an error. 912 | 913 | 1. In step 14 of the RP Operation to [Register a New 914 | Credential][rp-reg-ext-processing], perform the following steps: 915 | 916 | 1. Let `extOutput = pkc.response.authenticatorData.extensions["recovery"]`, 917 | or null if not present. 918 | 919 | 1. If `extOutput` is null, `extOutput.action` does not equal `"recover"`, 920 | `extOutput.state` is not present, `extOutput.credId` is not present, or 921 | `extOutput.sig` is not present, abort the ceremony with an error. 922 | 923 | 1. Let `revokedCredId` be null. 924 | 925 | 1. For each `primaryCredId` in the keys of `recoveryStates`: 926 | 927 | 1. Let `(state, creds) = recoveryCreds[primaryCredId]`. 928 | 929 | 1. For each `cred` in `creds`: 930 | 931 | 1. If `cred.credentialId` equals `extOutput.credId`: 932 | 933 | 1. Verify that `credentialId` equals the `id` member of some 934 | element of `allowCredentials`. 935 | 936 | 1. Let `publicKey` be the decoded public key `cred.credentialPublicKey`. 937 | 938 | 1. Let `authenticatorDataWithoutExtensions` be 939 | `pkc.response.authenticatorData`, but without the `extensions` part. The 940 | `ED` flag in `authenticatorDataWithoutExtensions` MUST be set to 1 even 941 | though `authenticatorDataWithoutExtensions` does not include the 942 | extension outputs. 943 | 944 | 1. Using `publicKey`, verify that `extOutput.sig` is a valid 945 | signature over `authenticatorDataWithoutExtensions || clientDataHash`. 946 | If the signature is invalid, fail the registration ceremony. 947 | 948 | 1. Set `revokedCredId = primaryCredId`. 949 | 950 | 1. _Break._ 951 | 952 | 1. If `revokedCredId` is not null, _break_. 953 | 954 | 1. If `revokedCredId` is null, abort the ceremony with an error. 955 | 956 | 1. Continue with the remaining steps of the standard registration ceremony. 957 | This means a new credential has now been registered using the backup 958 | authenticator. 959 | 960 | 1. Invalidate the credential identified by `revokedCredId` and all recovery 961 | credentials associated with it (i.e., delete 962 | `recoveryStates[revokedCredId]`). This step and the registration of the new 963 | credential SHOULD be performed as an atomic operation. 964 | 965 | 1. It is RECOMMENDED to send the user an e-mail or similar notification about 966 | this change to their account. 967 | 968 | 1. If `extOutput.state` is greater than 0, the RP SHOULD initiate 969 | recovery credential registration (`action = "generate"`) for the newly 970 | registered credential. 971 | 972 | When identifying the user and building the `allowCredentials` list, please 973 | consider the [risk of privacy leak via Credential IDs][privacy-cons]. 974 | 975 | As an alternative to proceeding to register a new credential for the backup 976 | authenticator, the RP MAY choose to not replace the lost credential with the new 977 | one, and instead disable 2FA or provide some other means for the user to access 978 | their account. In either case, the associated primary credential SHOULD be 979 | revoked and no longer usable. 980 | 981 | 982 | [att-cred-data]: https://w3c.github.io/webauthn/#attested-credential-data 983 | [authdata]: https://w3c.github.io/webauthn/#authenticator-data 984 | [ctap2-canon]: https://fidoalliance.org/specs/fido-v2.0-ps-20190130/fido-client-to-authenticator-protocol-v2.0-ps-20190130.html#ctap2-canonical-cbor-encoding-form 985 | [hkdf]: https://tools.ietf.org/html/rfc5869 986 | [privacy-cons]: https://www.w3.org/TR/2019/WD-webauthn-2-20191126/#sctn-credential-id-privacy-leak 987 | [rfc3279]: https://tools.ietf.org/html/rfc3279.html 988 | [rp-auth-ext-processing]: https://w3c.github.io/webauthn/#sctn-verifying-assertion 989 | [rp-reg-ext-processing]: https://w3c.github.io/webauthn/#sctn-registering-a-new-credential 990 | [sec1]: http://www.secg.org/sec1-v2.pdf 991 | -------------------------------------------------------------------------------- /benchmarks/.gitignore: -------------------------------------------------------------------------------- 1 | .tox/ 2 | -------------------------------------------------------------------------------- /benchmarks/README.md: -------------------------------------------------------------------------------- 1 | # Runtime benchmarks 2 | 3 | This directory contains some runtime benchmarks for the authenticator parts of the recovery extension. 4 | 5 | The benchmarks are implemented in single-threaded Python, 6 | using the [fastecdsa][fastecdsa] library for all elliptic curve arithmetic and cryptography. 7 | 8 | To run the benchmark on the software implementation, just run [tox][tox]: 9 | 10 | ``` 11 | $ tox 12 | ``` 13 | 14 | You can also specify the number of iterations to run per benchmark (default is 100): 15 | 16 | ``` 17 | $ tox -- 10 18 | ``` 19 | 20 | There is also a benchmark running the standard authenticatorGetAssertion 21 | against a connected CTAP2 authenticator. To run it, invoke tox as: 22 | 23 | ``` 24 | $ tox -e yubikey 25 | ``` 26 | 27 | This can also take an additional argument specifying the number of iterations: 28 | 29 | ``` 30 | $ tox -e yubikey -- 100 31 | ``` 32 | 33 | 34 | [fastecdsa]: https://pypi.org/project/fastecdsa/ 35 | [tox]: https://tox.readthedocs.io/ 36 | -------------------------------------------------------------------------------- /benchmarks/arkg.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # Prototype implementation of ARKG 3 | 4 | import fastecdsa 5 | import fastecdsa.keys 6 | import hashlib 7 | import os 8 | 9 | from cryptography.hazmat.backends import default_backend 10 | from cryptography.hazmat.primitives import hashes 11 | from cryptography.hazmat.primitives.hmac import HMAC 12 | from cryptography.hazmat.primitives.kdf.hkdf import HKDF 13 | from fastecdsa import ecdsa 14 | from fastecdsa.curve import P256 15 | from fastecdsa.encoding.sec1 import SEC1Encoder 16 | 17 | 18 | N = P256.q 19 | 20 | 21 | def encode_pub(pubkey): 22 | return fastecdsa.keys.export_key(pubkey, encoder=SEC1Encoder) 23 | 24 | 25 | def hmac(key, data): 26 | hmac = HMAC(key, hashes.SHA256(), default_backend()) 27 | hmac.update(data) 28 | return hmac.finalize() 29 | 30 | 31 | def hkdf(ikm, length=64): 32 | hkdf = HKDF( 33 | algorithm=hashes.SHA256(), 34 | length=length, 35 | salt=None, 36 | info=None, 37 | backend=default_backend(), 38 | ) 39 | return hkdf.derive(ikm) 40 | 41 | 42 | class NoCredentialAvailable(Exception): 43 | pass 44 | 45 | 46 | class InvalidMac(Exception): 47 | pass 48 | 49 | 50 | def derive_pk(seed_pub, aux): 51 | def pack_cred(eph_pub, aux, mac_key): 52 | compressed_pubkey = encode_pub(eph_pub) 53 | 54 | full_mac = hmac(mac_key, compressed_pubkey + aux) 55 | mac = full_mac[0:16] 56 | 57 | return (eph_pub, aux, mac) 58 | 59 | eph_pri, eph_pub = fastecdsa.keys.gen_keypair(P256) 60 | 61 | ecdh_point = seed_pub * eph_pri 62 | ikm_x = fastecdsa.encoding.util.int_to_bytes(ecdh_point.x) 63 | okm = hkdf(ikm_x, length=64) 64 | cred_key = okm[0:32] 65 | cred_key_int = int.from_bytes(cred_key, 'big', signed=False) 66 | 67 | assert cred_key_int < N, "cred_key >= N: " + str(cred_key_int) 68 | 69 | mac_key = okm[32:64] 70 | 71 | cred_pub = (cred_key_int * P256.G) + seed_pub 72 | assert cred_pub != P256.G.IDENTITY_ELEMENT 73 | 74 | cred = pack_cred(eph_pub, aux, mac_key) 75 | return cred_pub, cred 76 | 77 | 78 | def derive_sk(seed_pri, cred): 79 | eph_pub, aux, input_mac = cred 80 | eph_pub_enc = encode_pub(eph_pub) 81 | 82 | ecdh_point = seed_pri * eph_pub 83 | ikm_x = fastecdsa.encoding.util.int_to_bytes(ecdh_point.x) 84 | okm = hkdf(ikm_x, length=64) 85 | cred_key = okm[0:32] 86 | cred_key_int = int.from_bytes(cred_key, 'big', signed=False) 87 | 88 | mac_key = okm[32:64] 89 | full_mac = hmac(mac_key, eph_pub_enc + aux) 90 | mac = full_mac[0:16] 91 | 92 | if mac != input_mac: 93 | raise InvalidMac() 94 | 95 | assert cred_key_int < N, "cred_key >= N: " + str(cred_key_int) 96 | 97 | cred_pri = cred_key_int + seed_pri % N 98 | return cred_pri 99 | 100 | class Authenticator: 101 | 102 | def __init__(self): 103 | self._seed_pri = None 104 | self._seed_pub = None 105 | self._imported_seeds = [] 106 | self._credentials = {} 107 | 108 | def _initialize_recovery_seed(self): 109 | self._seed_pri, self._seed_pub = fastecdsa.keys.gen_keypair(P256) 110 | 111 | def make_credential(self, challenge): 112 | '''Abstraction of the basic WebAuthn registration operation.''' 113 | 114 | cred = os.urandom(32) 115 | pri, pub = fastecdsa.keys.gen_keypair(P256) 116 | self._credentials[cred] = pri 117 | sig = ecdsa.sign( 118 | challenge, 119 | pri, 120 | hashfunc=hashlib.sha256 121 | ) 122 | return pub, cred, sig 123 | 124 | def get_assertion(self, challenge, creds): 125 | '''Abstraction of the basic WebAuthn authentication operation.''' 126 | 127 | for cred in creds: 128 | if cred in self._credentials: 129 | sig = ecdsa.sign( 130 | challenge, 131 | self._credentials[cred], 132 | hashfunc=hashlib.sha256 133 | ) 134 | return cred, sig 135 | 136 | raise NoCredentialAvailable() 137 | 138 | def export_recovery_seed(self): 139 | '''Export a backup seed public key for importing into another authenticator.''' 140 | 141 | if self._seed_pri is None: 142 | self._initialize_recovery_seed() 143 | 144 | return self._seed_pub 145 | 146 | def import_recovery_seed(self, S): 147 | '''Import a backup seed public key to derive backup keys from.''' 148 | 149 | self._imported_seeds.append(S) 150 | 151 | def derivepk_all(self, aux): 152 | '''Perform DerivePK with all imported seed public keys.''' 153 | return [ 154 | derive_pk(seed, aux) 155 | for seed in self._imported_seeds] 156 | 157 | 158 | def make_credential_derivepk(self, challenge, aux): 159 | '''Create a credential keypair, derive all backup public keys, 160 | and sign the challenge and backup public keys with the credential private key.''' 161 | 162 | backup_creds = self.derivepk_all(aux) 163 | 164 | signed_data = challenge 165 | for cred in backup_creds: 166 | signed_data += encode_pub(cred[0]) 167 | 168 | cred = os.urandom(32) 169 | pri, pub = fastecdsa.keys.gen_keypair(P256) 170 | self._credentials[cred] = pri 171 | sig = ecdsa.sign( 172 | signed_data, 173 | pri, 174 | hashfunc=hashlib.sha256 175 | ) 176 | return pub, cred, sig, backup_creds 177 | 178 | def derivepk_all(self, aux): 179 | '''Perform DerivePK with all imported seed public keys.''' 180 | return [ 181 | derive_pk(seed, aux) 182 | for seed in self._imported_seeds] 183 | 184 | def derivesk_find(self, creds): 185 | ''' 186 | Find a cred in creds that matches this authenticator's backup seed private key, 187 | derive the private key and return it. 188 | ''' 189 | for cred in creds: 190 | try: 191 | pri_key = derive_sk(self._seed_pri, cred) 192 | return cred, pri_key 193 | except InvalidMac: 194 | pass 195 | 196 | raise NoCredentialAvailable() 197 | 198 | 199 | def derivesk_authenticate(self, challenge, creds): 200 | ''' 201 | Find a cred in creds that matches this authenticator's backup seed private key, 202 | derive the private key, and sign the challenge with it. 203 | ''' 204 | chosen_cred, pri_key = self.derivesk_find(creds) 205 | sig = ecdsa.sign( 206 | challenge, 207 | pri_key, 208 | hashfunc=hashlib.sha256 209 | ) 210 | return chosen_cred, sig 211 | 212 | 213 | def verify_correctness(): 214 | def separate_registration(): 215 | primary_authnr = Authenticator() 216 | backup_authnr = Authenticator() 217 | 218 | # Create a credential with the primary authenticator 219 | challenge = os.urandom(32) 220 | pub, cred, sig = primary_authnr.make_credential(challenge) 221 | assert ecdsa.verify(sig, challenge, pub) 222 | standard_creds = [cred] 223 | 224 | # Authenticate with the primary authenticator 225 | challenge = os.urandom(32) 226 | cred, sig = primary_authnr.get_assertion(challenge, standard_creds) 227 | assert ecdsa.verify(sig, challenge, pub) 228 | assert cred in standard_creds 229 | 230 | # Transfer recovery seed from backup authenticator to primary 231 | primary_authnr.import_recovery_seed(backup_authnr.export_recovery_seed()) 232 | 233 | # Create a backup public key with the primary authenticator 234 | aux = os.urandom(32) 235 | backup_creds = primary_authnr.derivepk_all(aux) 236 | backup_pub, backup_cred = backup_creds[0] 237 | 238 | # Perform recovery registration with backup authenticator 239 | challenge = os.urandom(32) 240 | chosen_cred, sig = backup_authnr.derivesk_authenticate(challenge, [backup_cred]) 241 | assert ecdsa.verify(sig, challenge, backup_pub) 242 | assert chosen_cred == backup_cred 243 | assert chosen_cred[1] == aux 244 | 245 | 246 | def simultaneous_registration(): 247 | primary_authnr = Authenticator() 248 | backup_authnr = Authenticator() 249 | 250 | # Transfer recovery seed from backup authenticator to primary 251 | primary_authnr.import_recovery_seed(backup_authnr.export_recovery_seed()) 252 | 253 | # Create a credential and backup public key with the primary authenticator 254 | challenge = os.urandom(32) 255 | aux = os.urandom(32) 256 | pub, cred, sig, backup_creds = primary_authnr.make_credential_derivepk(challenge, aux) 257 | signed_data = challenge 258 | for c in backup_creds: 259 | signed_data += encode_pub(c[0]) 260 | assert ecdsa.verify(sig, signed_data, pub) 261 | standard_creds = [cred] 262 | backup_pub, backup_cred = backup_creds[0] 263 | 264 | # Authenticate with the primary authenticator 265 | challenge = os.urandom(32) 266 | cred, sig = primary_authnr.get_assertion(challenge, standard_creds) 267 | assert ecdsa.verify(sig, challenge, pub) 268 | assert cred in standard_creds 269 | 270 | # Perform recovery registration with backup authenticator 271 | challenge = os.urandom(32) 272 | chosen_cred, sig = backup_authnr.derivesk_authenticate(challenge, [backup_cred]) 273 | assert ecdsa.verify(sig, challenge, backup_pub) 274 | assert chosen_cred == backup_cred 275 | assert chosen_cred[1] == aux 276 | 277 | separate_registration() 278 | simultaneous_registration() 279 | 280 | 281 | verify_correctness() 282 | -------------------------------------------------------------------------------- /benchmarks/bench-arkg.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import sys 4 | import os 5 | import timeit 6 | import fastecdsa.keys 7 | 8 | from fastecdsa import ecdsa 9 | from fastecdsa.encoding.der import DEREncoder 10 | from fido2 import cbor, ctap2 11 | 12 | from arkg import Authenticator, encode_pub, verify_correctness 13 | 14 | 15 | RP_ID = 'example.org' 16 | ORIGIN = f'https://{RP_ID}' 17 | 18 | 19 | def basic_make_credential(authenticator, clientDataJSON_hash): 20 | authenticator.authenticator_make_credential(cbor.encode({ 21 | 0x01: clientDataJSON_hash, 22 | 0x02: {'id': RP_ID}, 23 | 0x06: {}, 24 | })) 25 | 26 | 27 | def basic_get_assertion(authenticator, clientDataJSON_hash, allowList): 28 | authenticator.authenticator_get_assertion(cbor.encode({ 29 | 0x01: RP_ID, 30 | 0x02: clientDataJSON_hash, 31 | 0x03: allowList, 32 | 0x04: {}, 33 | })) 34 | 35 | 36 | def recovery_make_credential(authenticator, clientDataJSON_hash, recovery_allowCredentials): 37 | attObj_bytes = authenticator.authenticator_make_credential(cbor.encode({ 38 | 0x01: clientDataJSON_hash, 39 | 0x02: {'id': RP_ID}, 40 | 0x06: { 41 | 'recovery': { 42 | 'action': 'recover', 43 | 'allowCredentials': recovery_allowCredentials, 44 | }, 45 | }, 46 | })) 47 | return attObj_bytes 48 | 49 | 50 | def generate_backups_get_assertion(authenticator, clientDataJSON_hash, allowList): 51 | authenticator_response = authenticator.authenticator_get_assertion(cbor.encode({ 52 | 0x01: RP_ID, 53 | 0x02: clientDataJSON_hash, 54 | 0x03: allowList, 55 | 0x04: { 56 | 'recovery': { 57 | 'action': 'generate', 58 | } 59 | }, 60 | })) 61 | return authenticator_response 62 | 63 | 64 | def bench_basic_makecredential(iterations=100, repeats=10): 65 | iterations = int(iterations) 66 | 67 | challenge = os.urandom(32) 68 | authnr = Authenticator() 69 | return iterations, repeats, timeit.repeat( 70 | stmt=lambda: authnr.make_credential(challenge), 71 | number=iterations, 72 | repeat=repeats, 73 | ) 74 | 75 | 76 | def bench_basic_getassertion(iterations=100, repeats=10): 77 | iterations = int(iterations) 78 | 79 | challenge = os.urandom(32) 80 | authnr = Authenticator() 81 | 82 | pub, cred, sig = authnr.make_credential(challenge) 83 | assert ecdsa.verify(sig, challenge, pub) 84 | creds = [cred] 85 | 86 | return iterations, repeats, timeit.repeat( 87 | stmt=lambda: authnr.get_assertion(challenge, creds), 88 | number=iterations, 89 | repeat=repeats, 90 | ) 91 | 92 | 93 | def bench_derivepk_all(iterations=100, repeats=10, num_seeds=1): 94 | iterations = int(iterations) 95 | 96 | primary_authnr = Authenticator() 97 | for i in range(num_seeds): 98 | primary_authnr.import_recovery_seed(Authenticator().export_recovery_seed()) 99 | 100 | aux = os.urandom(32) 101 | return iterations, repeats, timeit.repeat( 102 | stmt=lambda: primary_authnr.derivepk_all(aux), 103 | number=iterations, 104 | repeat=repeats, 105 | ) 106 | 107 | 108 | def bench_makecredential_derivepk(iterations=100, repeats=10, num_seeds=1): 109 | iterations = int(iterations) 110 | 111 | primary_authnr = Authenticator() 112 | for i in range(num_seeds): 113 | primary_authnr.import_recovery_seed(Authenticator().export_recovery_seed()) 114 | 115 | challenge = os.urandom(32) 116 | aux = os.urandom(32) 117 | return iterations, repeats, timeit.repeat( 118 | stmt=lambda: primary_authnr.make_credential_derivepk(challenge, aux), 119 | number=iterations, 120 | repeat=repeats, 121 | ) 122 | 123 | 124 | def bench_derivesk_find(iterations=100, repeats=10, num_seeds=1): 125 | iterations = int(iterations) 126 | 127 | primary_authnr = Authenticator() 128 | for i in range(num_seeds - 1): 129 | primary_authnr.import_recovery_seed(Authenticator().export_recovery_seed()) 130 | 131 | backup_authnr = Authenticator() 132 | if num_seeds > 0: 133 | primary_authnr.import_recovery_seed(backup_authnr.export_recovery_seed()) 134 | 135 | aux = os.urandom(32) 136 | creds = primary_authnr.derivepk_all(aux) 137 | 138 | derivesk_creds = [cred[1] for cred in creds] 139 | 140 | return iterations, repeats, timeit.repeat( 141 | stmt=lambda: backup_authnr.derivesk_find(derivesk_creds), 142 | number=iterations, 143 | repeat=repeats, 144 | ) 145 | 146 | 147 | def bench_derivesk_authenticate(iterations=100, repeats=10, num_seeds=1): 148 | iterations = int(iterations) 149 | 150 | primary_authnr = Authenticator() 151 | for i in range(num_seeds - 1): 152 | primary_authnr.import_recovery_seed(Authenticator().export_recovery_seed()) 153 | 154 | backup_authnr = Authenticator() 155 | if num_seeds > 0: 156 | primary_authnr.import_recovery_seed(backup_authnr.export_recovery_seed()) 157 | 158 | aux = os.urandom(32) 159 | creds = primary_authnr.derivepk_all(aux) 160 | 161 | challenge = os.urandom(32) 162 | derivesk_creds = [cred[1] for cred in creds] 163 | 164 | return iterations, repeats, timeit.repeat( 165 | stmt=lambda: backup_authnr.derivesk_authenticate(challenge, derivesk_creds), 166 | number=iterations, 167 | repeat=repeats, 168 | ) 169 | 170 | 171 | def main(repeats=100): 172 | verify_correctness() 173 | 174 | iterations = 1 175 | 176 | def print_times(iterations_repeats_and_times): 177 | its, repeats, times = iterations_repeats_and_times 178 | its_tot = its * repeats 179 | time_tot = sum(times) 180 | min_avg = min(t / its for t in times) 181 | max_avg = max(t / its for t in times) 182 | print(f'Iterations: {repeats}') 183 | print(f'Min: {min_avg * 1000} ms/op') 184 | print(f'Average: {time_tot * 1000 / its_tot} ms/op') 185 | print(f'Max: {max_avg * 1000} ms/op') 186 | 187 | print('=== Benchmark: Normal credential registration ===') 188 | print_times(bench_basic_makecredential(iterations=iterations, repeats=repeats)) 189 | 190 | def bench_dpka(num_seeds): 191 | print('\n') 192 | print(f'=== Benchmark: DerivePK ({num_seeds} seeds) ===') 193 | print_times(bench_derivepk_all(iterations=iterations, repeats=repeats, num_seeds=num_seeds)) 194 | 195 | for num in [0, 1, 2, 5, 10]: 196 | bench_dpka(num) 197 | 198 | def bench_mcdpk(num_seeds): 199 | print('\n') 200 | print(f'=== Benchmark: Make credential, DerivePK, and sign ({num_seeds} seeds) ===') 201 | print_times(bench_makecredential_derivepk(iterations=iterations, repeats=repeats, num_seeds=num_seeds)) 202 | 203 | for num in [0, 1, 2, 5, 10]: 204 | bench_mcdpk(num) 205 | 206 | 207 | print('\n') 208 | 209 | print('=== Benchmark: Normal authentication ===') 210 | print_times(bench_basic_getassertion(iterations=iterations, repeats=repeats)) 211 | 212 | def bench_dskf(num_seeds): 213 | print('\n') 214 | print(f'=== Benchmark: DeriveSK ({num_seeds} seeds) ===') 215 | print_times(bench_derivesk_find(iterations=iterations, repeats=repeats, num_seeds=num_seeds)) 216 | 217 | for num in [1, 2, 5, 10]: 218 | bench_dskf(num) 219 | 220 | def bench_dska(num_seeds): 221 | print('\n') 222 | print(f'=== Benchmark: DeriveSK and authenticate ({num_seeds} seeds) ===') 223 | print_times(bench_derivesk_authenticate(iterations=iterations, repeats=repeats, num_seeds=num_seeds)) 224 | 225 | for num in [1, 2, 5, 10]: 226 | bench_dska(num) 227 | 228 | 229 | if __name__ == '__main__': 230 | if len(sys.argv) > 1: 231 | main(int(sys.argv[1])) 232 | else: 233 | main() 234 | -------------------------------------------------------------------------------- /benchmarks/bench-software.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import sys 4 | import os 5 | import timeit 6 | import fastecdsa.keys 7 | 8 | from fastecdsa import ecdsa 9 | from fastecdsa.encoding.der import DEREncoder 10 | from fido2 import cbor, ctap2 11 | 12 | from recovery_extension import Authenticator, cose_key_to_point 13 | 14 | 15 | RP_ID = 'example.org' 16 | ORIGIN = f'https://{RP_ID}' 17 | 18 | 19 | def basic_make_credential(authenticator, clientDataJSON_hash): 20 | authenticator.authenticator_make_credential(cbor.encode({ 21 | 0x01: clientDataJSON_hash, 22 | 0x02: {'id': RP_ID}, 23 | 0x06: {}, 24 | })) 25 | 26 | 27 | def basic_get_assertion(authenticator, clientDataJSON_hash, allowList): 28 | authenticator.authenticator_get_assertion(cbor.encode({ 29 | 0x01: RP_ID, 30 | 0x02: clientDataJSON_hash, 31 | 0x03: allowList, 32 | 0x04: {}, 33 | })) 34 | 35 | 36 | def recovery_make_credential(authenticator, clientDataJSON_hash, recovery_allowCredentials): 37 | attObj_bytes = authenticator.authenticator_make_credential(cbor.encode({ 38 | 0x01: clientDataJSON_hash, 39 | 0x02: {'id': RP_ID}, 40 | 0x06: { 41 | 'recovery': { 42 | 'action': 'recover', 43 | 'allowCredentials': recovery_allowCredentials, 44 | }, 45 | }, 46 | })) 47 | return attObj_bytes 48 | 49 | 50 | def generate_backups_get_assertion(authenticator, clientDataJSON_hash, allowList): 51 | authenticator_response = authenticator.authenticator_get_assertion(cbor.encode({ 52 | 0x01: RP_ID, 53 | 0x02: clientDataJSON_hash, 54 | 0x03: allowList, 55 | 0x04: { 56 | 'recovery': { 57 | 'action': 'generate', 58 | } 59 | }, 60 | })) 61 | return authenticator_response 62 | 63 | 64 | def bench_basic_makecredential(iterations=100, repeats=10): 65 | iterations = int(iterations) 66 | 67 | clientDataJSON_hash = os.urandom(32) 68 | authnr = Authenticator() 69 | return iterations, repeats, timeit.repeat( 70 | stmt=lambda: basic_make_credential(authnr, clientDataJSON_hash), 71 | number=iterations, 72 | repeat=repeats, 73 | ) 74 | 75 | 76 | def bench_basic_getassertion(iterations=100, repeats=10): 77 | iterations = int(iterations) 78 | 79 | clientDataJSON_hash = os.urandom(32) 80 | authnr = Authenticator() 81 | basic_make_credential(authnr, clientDataJSON_hash) 82 | allowList = [{'type': 'public-key', 'id': i} for i in authnr._credentials.keys()] 83 | 84 | return iterations, repeats, timeit.repeat( 85 | stmt=lambda: basic_get_assertion(authnr, clientDataJSON_hash, allowList), 86 | number=iterations, 87 | repeat=repeats, 88 | ) 89 | 90 | 91 | def bench_recovery_makecredential(iterations=100, repeats=10, num_seeds=1): 92 | iterations = int(iterations) 93 | 94 | clientDataJSON_hash = os.urandom(32) 95 | primary_authnr = Authenticator() 96 | backup_authnr = Authenticator() 97 | basic_make_credential(primary_authnr, clientDataJSON_hash) 98 | allowList = [{'type': 'public-key', 'id': i} for i in primary_authnr._credentials.keys()] 99 | 100 | for i in range(num_seeds - 1): 101 | primary_authnr.import_recovery_seed(Authenticator().export_recovery_seed([0])) 102 | primary_authnr.import_recovery_seed(backup_authnr.export_recovery_seed([0])) 103 | 104 | recovery_allowCredentials = [ 105 | {'id': ctap2.AttestedCredentialData(cred).credential_id, 'type': 'public-key'} 106 | for cred in ctap2.AuthenticatorData( 107 | cbor.decode( 108 | generate_backups_get_assertion(primary_authnr, clientDataJSON_hash, allowList) 109 | )[2] 110 | ).extensions['recovery']['creds'] 111 | ] 112 | 113 | return iterations, repeats, timeit.repeat( 114 | stmt=lambda: recovery_make_credential(backup_authnr, clientDataJSON_hash, recovery_allowCredentials), 115 | number=iterations, 116 | repeat=repeats, 117 | ) 118 | 119 | 120 | def bench_generate_backups_getassertion(iterations=100, repeats=10, num_seeds=1): 121 | iterations = int(iterations) 122 | 123 | clientDataJSON_hash = os.urandom(32) 124 | primary_authnr = Authenticator() 125 | basic_make_credential(primary_authnr, clientDataJSON_hash) 126 | allowList = [{'type': 'public-key', 'id': i} for i in primary_authnr._credentials.keys()] 127 | 128 | for i in range(num_seeds): 129 | primary_authnr.import_recovery_seed(Authenticator().export_recovery_seed([0])) 130 | 131 | return iterations, repeats, timeit.repeat( 132 | stmt=lambda: generate_backups_get_assertion(primary_authnr, clientDataJSON_hash, allowList), 133 | number=iterations, 134 | repeat=repeats, 135 | ) 136 | 137 | 138 | def verify_correctness(): 139 | clientDataJSON_hash = os.urandom(32) 140 | primary_authnr = Authenticator() 141 | backup_authnr = Authenticator() 142 | 143 | # Create a credential with the primary authenticator 144 | basic_make_credential(primary_authnr, clientDataJSON_hash) 145 | allowList = [{'type': 'public-key', 'id': i} for i in primary_authnr._credentials.keys()] 146 | 147 | # Transfer recovery seed from backup authenticator to primary 148 | primary_authnr.import_recovery_seed(backup_authnr.export_recovery_seed([0])) 149 | 150 | # Generate a recovery credential with the primary authenticator 151 | recovery_creds = ctap2.AuthenticatorData( 152 | cbor.decode( 153 | generate_backups_get_assertion(primary_authnr, clientDataJSON_hash, allowList) 154 | )[2] 155 | ).extensions['recovery']['creds'] 156 | recovery_pubkey = cose_key_to_point( 157 | ctap2.AttestedCredentialData(recovery_creds[0]).public_key 158 | ) 159 | recovery_allowCredentials = [ 160 | {'id': ctap2.AttestedCredentialData(cred).credential_id, 'type': 'public-key'} 161 | for cred in recovery_creds 162 | ] 163 | 164 | # Perform recovery registration with backup authenticator 165 | attObj_bytes = recovery_make_credential(backup_authnr, clientDataJSON_hash, recovery_allowCredentials) 166 | att_obj = ctap2.AttestationObject(attObj_bytes) 167 | 168 | # Verify that backup authenticator returns the correct recovery credential ID 169 | recovery_cred_id = att_obj.auth_data.extensions['recovery']['credId'] 170 | assert recovery_cred_id == ctap2.AttestedCredentialData(recovery_creds[0]).credential_id 171 | 172 | # Verify that backup authenticator returns a valid recovery signature 173 | auth_data_without_extensions = att_obj.auth_data[:37 + len(att_obj.auth_data.credential_data)] 174 | recovery_sig = att_obj.auth_data.extensions['recovery']['sig'] 175 | assert ecdsa.verify( 176 | DEREncoder.decode_signature(recovery_sig), 177 | auth_data_without_extensions + clientDataJSON_hash, 178 | recovery_pubkey 179 | ) 180 | 181 | 182 | def main(repeats=100): 183 | verify_correctness() 184 | 185 | iterations = 1 186 | 187 | def print_times(iterations_repeats_and_times): 188 | its, repeats, times = iterations_repeats_and_times 189 | its_tot = its * repeats 190 | time_tot = sum(times) 191 | min_avg = min(t / its for t in times) 192 | max_avg = max(t / its for t in times) 193 | print(f'Iterations: {repeats}') 194 | print(f'Min: {min_avg * 1000} ms/op') 195 | print(f'Average: {time_tot * 1000 / its_tot} ms/op') 196 | print(f'Max: {max_avg * 1000} ms/op') 197 | 198 | print('=== Benchmark: Normal credential registration ===') 199 | print_times(bench_basic_makecredential(iterations=iterations, repeats=repeats)) 200 | 201 | print('\n') 202 | 203 | print('=== Benchmark: Normal authentication ===') 204 | print_times(bench_basic_getassertion(iterations=iterations, repeats=repeats)) 205 | 206 | def bench_gen_auth(num_seeds): 207 | print('\n') 208 | print(f'=== Benchmark: Authentication with recovery credential generation ({num_seeds} seeds) ===') 209 | print_times(bench_generate_backups_getassertion(iterations=iterations, repeats=repeats, num_seeds=num_seeds)) 210 | 211 | bench_gen_auth(0) 212 | bench_gen_auth(1) 213 | bench_gen_auth(2) 214 | bench_gen_auth(3) 215 | bench_gen_auth(4) 216 | bench_gen_auth(5) 217 | bench_gen_auth(10) 218 | 219 | def bench_rec_register(num_seeds): 220 | print('\n') 221 | print(f'=== Benchmark: Registration with recovery signature ({num_seeds} seeds) ===') 222 | print_times(bench_recovery_makecredential(iterations=iterations, repeats=repeats, num_seeds=num_seeds)) 223 | 224 | bench_rec_register(0) 225 | bench_rec_register(1) 226 | bench_rec_register(2) 227 | bench_rec_register(3) 228 | bench_rec_register(4) 229 | bench_rec_register(5) 230 | bench_rec_register(10) 231 | 232 | 233 | if __name__ == '__main__': 234 | if len(sys.argv) > 1: 235 | main(int(sys.argv[1])) 236 | else: 237 | main() 238 | -------------------------------------------------------------------------------- /benchmarks/bench-yubikey.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os 4 | import sys 5 | import timeit 6 | 7 | from fido2.ctap2 import CTAP2 8 | from fido2.hid import CtapHidDevice 9 | 10 | 11 | RP_ID = 'example.org' 12 | ORIGIN = f'https://{RP_ID}' 13 | USER_ID = os.urandom(32) 14 | 15 | 16 | def create_ctap2_handle(): 17 | for dev in CtapHidDevice.list_devices(): 18 | return CTAP2(dev) 19 | print("No FIDO device found") 20 | 21 | 22 | def make_credential(ctap2: CTAP2, clientDataJSON_hash): 23 | att_obj = ctap2.make_credential( 24 | clientDataJSON_hash, 25 | {'id': RP_ID, 'name': 'Test RP'}, 26 | {'id': USER_ID, 'name': 'test@example.org', 'displayName': 'Test'}, 27 | [{'alg': -7, 'type': 'public-key'}], 28 | ) 29 | 30 | return {'id': att_obj.auth_data.credential_data.credential_id, 'type': 'public-key'} 31 | 32 | 33 | def get_assertion(ctap2: CTAP2, clientDataJSON_hash, allowList): 34 | return ctap2.get_assertions( 35 | RP_ID, 36 | clientDataJSON_hash, 37 | allowList, 38 | options={'up': False}, 39 | ) 40 | 41 | 42 | def main(repeats=10): 43 | ctap2 = create_ctap2_handle() 44 | 45 | print("Please touch the authenticator to begin the benchmark...") 46 | clientDataJSON_hash = os.urandom(32) 47 | allowList = [make_credential(ctap2, clientDataJSON_hash)] 48 | 49 | iterations = 1 50 | 51 | def print_times(iterations_repeats_and_times): 52 | its, repeats, times = iterations_repeats_and_times 53 | its_tot = its * repeats 54 | time_tot = sum(times) 55 | min_avg = min(t / its for t in times) 56 | max_avg = max(t / its for t in times) 57 | print(f'Iterations: {repeats}') 58 | print(f'Min: {min_avg * 1000} ms/op') 59 | print(f'Average: {time_tot * 1000 / its_tot} ms/op') 60 | print(f'Max: {max_avg * 1000} ms/op') 61 | 62 | print('=== Benchmark: Authentication with YubiKey ===') 63 | times = timeit.repeat( 64 | stmt=lambda: get_assertion(ctap2, clientDataJSON_hash, allowList), 65 | number=iterations, 66 | repeat=repeats, 67 | ) 68 | print_times((iterations, repeats, times)) 69 | 70 | 71 | if __name__ == '__main__': 72 | if len(sys.argv) > 1: 73 | main(int(sys.argv[1])) 74 | else: 75 | main() 76 | -------------------------------------------------------------------------------- /benchmarks/recovery_extension.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # Prototype implementation of https://gist.github.com/emlun/4c3efd99a727c7037fdb86ffd43c020d # noqa: E501 3 | 4 | import base64 5 | import binascii 6 | import datetime 7 | import fastecdsa 8 | import hashlib 9 | import fastecdsa.keys 10 | import json 11 | import secrets 12 | import struct 13 | 14 | from collections import namedtuple 15 | from cryptography.hazmat.backends import default_backend 16 | from cryptography.hazmat.primitives import hashes 17 | from cryptography.hazmat.primitives.hmac import HMAC 18 | from cryptography.hazmat.primitives.kdf.hkdf import HKDF 19 | from fastecdsa import ecdsa 20 | from fastecdsa.curve import P256 21 | from fastecdsa.encoding.der import DEREncoder 22 | from fastecdsa.encoding.sec1 import SEC1Encoder 23 | from fastecdsa.point import Point 24 | from fido2 import cbor, cose, ctap2 25 | 26 | BackupSeed = namedtuple('BackupSeed', ['alg', 'aaguid', 'pubkey']) 27 | 28 | 29 | N = P256.q 30 | 31 | 32 | def encode_pub(pubkey): 33 | return SEC1Encoder().encode_public_key(pubkey, compressed=False) 34 | 35 | 36 | def sha256(data): 37 | hash = hashes.Hash(hashes.SHA256(), default_backend()) 38 | hash.update(data) 39 | return hash.finalize() 40 | 41 | 42 | def hmac(key, data): 43 | hmac = HMAC(key, hashes.SHA256(), default_backend()) 44 | hmac.update(data) 45 | return hmac.finalize() 46 | 47 | 48 | def hkdf(ikm, info, length=32): 49 | hkdf = HKDF( 50 | algorithm=hashes.SHA256(), 51 | length=length, 52 | salt=None, 53 | info=info, 54 | backend=default_backend(), 55 | ) 56 | return hkdf.derive(ikm) 57 | 58 | 59 | def b64d(data): 60 | data_bytes = data.encode('utf-8') if isinstance(data, str) else data 61 | pad_length = (4 - (len(data_bytes) % 4)) % 4 62 | return base64.urlsafe_b64decode(data_bytes + (b'=' * pad_length)) 63 | 64 | 65 | def point_to_cose_key(point): 66 | return { 67 | 1: 2, 68 | 3: -7, 69 | -1: 1, 70 | -2: fastecdsa.encoding.util.int_to_bytes(point.x), 71 | -3: fastecdsa.encoding.util.int_to_bytes(point.y), 72 | } 73 | 74 | 75 | def cose_key_to_point(cose): 76 | return SEC1Encoder.decode_public_key( 77 | b'\x04' + cose[-2] + cose[-3], 78 | P256 79 | ) 80 | 81 | 82 | def pack_attested_credential_data(aaguid, cred_id, cred_pub): 83 | cose_pubkey = point_to_cose_key(cred_pub) 84 | cbor_pubkey = cbor.encode(cose_pubkey) 85 | return struct.pack(f'>16sH{len(cred_id)}s{len(cbor_pubkey)}s', 86 | aaguid, 87 | len(cred_id), 88 | cred_id, 89 | cbor_pubkey) 90 | 91 | 92 | class InvalidState(Exception): 93 | pass 94 | 95 | 96 | class NoCredentialAvailable(Exception): 97 | pass 98 | 99 | 100 | class RpIdMismatch(Exception): 101 | pass 102 | 103 | 104 | class UnknownAction(Exception): 105 | pass 106 | 107 | 108 | class UnknownKeyAgreementScheme(Exception): 109 | pass 110 | 111 | 112 | class Authenticator: 113 | 114 | def __init__(self): 115 | self._aaguid = binascii.a2b_hex('00112233445566778899aabbccddeeff') 116 | self._recovery_seed_pri_key = None 117 | self._seeds = [] 118 | self._state = 0 119 | self._credentials = {} 120 | 121 | self._attestation_key = fastecdsa.keys.gen_private_key(P256) 122 | 123 | def _initialize_recovery_seed(self): 124 | self._recovery_seed_pri_key = fastecdsa.keys.gen_private_key(P256) 125 | 126 | def export_recovery_seed(self, allow_algs): 127 | for alg in allow_algs: 128 | if alg == 0: 129 | if self._recovery_seed_pri_key is None: 130 | self._initialize_recovery_seed() 131 | 132 | S = fastecdsa.keys.get_public_key(self._recovery_seed_pri_key, P256) 133 | S_enc = encode_pub(S) 134 | 135 | signed_data = struct.pack('>B16s65s', alg, self._aaguid, S_enc) 136 | sig = DEREncoder.encode_signature( 137 | *ecdsa.sign( 138 | signed_data, 139 | self._attestation_key, 140 | hashfunc=hashlib.sha256 141 | ) 142 | ) 143 | payload = { 144 | 1: alg, 145 | 2: [], 146 | 3: self._aaguid, 147 | 4: sig, 148 | -1: S_enc, 149 | } 150 | return cbor.encode(payload) 151 | 152 | raise UnknownKeyAgreementScheme(allow_algs) 153 | 154 | def import_recovery_seed(self, exported_seed): 155 | payload = cbor.decode(exported_seed) 156 | alg = payload[1] 157 | aaguid = payload[3] 158 | sig = payload[4] 159 | 160 | assert isinstance(alg, int) 161 | assert isinstance(aaguid, bytes) 162 | assert isinstance(sig, bytes) 163 | 164 | if alg == 0: 165 | S_enc = payload[-1] 166 | assert isinstance(S_enc, bytes) 167 | S = SEC1Encoder().decode_public_key(S_enc, P256) 168 | 169 | self._seeds.append(BackupSeed(alg, aaguid, S)) 170 | 171 | else: 172 | raise UnknownKeyAgreementScheme(alg) 173 | 174 | self._state += 1 175 | 176 | def authenticator_make_credential(self, args_cbor): 177 | args = cbor.decode(args_cbor) 178 | clientDataJSON_hash = args[0x01] 179 | rp_id = args[0x02]['id'] 180 | extension_inputs = args[0x06] 181 | rp_id_hash = sha256(rp_id.encode('utf-8')) 182 | flags = 0b11000001 # ED, AT, UP 183 | sign_count = 0 184 | 185 | credential_id = secrets.token_bytes(32) 186 | (credential_private_key, credential_public_key) = fastecdsa.keys.gen_keypair(P256) 187 | 188 | self._credentials[credential_id] = credential_private_key 189 | 190 | attested_cred_data = pack_attested_credential_data( 191 | self._aaguid, 192 | credential_id, 193 | credential_public_key, 194 | ) 195 | 196 | authData_without_extensions = struct.pack( 197 | f'>32sBL{len(attested_cred_data)}s', 198 | rp_id_hash, 199 | flags, 200 | sign_count, 201 | attested_cred_data, 202 | ) 203 | 204 | extensions = {} 205 | 206 | if "recovery" in extension_inputs: 207 | extensions["recovery"] = self.process_recovery_extension( 208 | rp_id, 209 | authData_without_extensions, 210 | clientDataJSON_hash, 211 | extension_inputs["recovery"], 212 | ) 213 | 214 | authData = authData_without_extensions + cbor.encode(extensions) 215 | attStmt = { 216 | 0x01: 'packed', 217 | 0x02: authData, 218 | 0x03: { 219 | 'alg': -7, 220 | 'sig': DEREncoder.encode_signature( 221 | *ecdsa.sign( 222 | authData + clientDataJSON_hash, 223 | credential_private_key, 224 | hashfunc=hashlib.sha256 225 | ) 226 | ), 227 | }, 228 | } 229 | return cbor.encode(attStmt) 230 | 231 | def authenticator_get_assertion(self, args_cbor): 232 | args = cbor.decode(args_cbor) 233 | rp_id = args[0x01] 234 | clientDataJSON_hash = args[0x02] 235 | extension_inputs = args[0x04] 236 | rp_id_hash = sha256(rp_id.encode('utf-8')) 237 | flags = 0b10000001 # ED, UP 238 | sign_count = 0 239 | 240 | extensions = {} 241 | 242 | authData_without_extensions = struct.pack( 243 | f'>32sBL', 244 | rp_id_hash, 245 | flags, 246 | sign_count, 247 | ) 248 | 249 | if "recovery" in extension_inputs: 250 | extensions["recovery"] = self.process_recovery_extension( 251 | rp_id, 252 | authData_without_extensions, 253 | clientDataJSON_hash, 254 | extension_inputs["recovery"] 255 | ) 256 | 257 | authData = authData_without_extensions + cbor.encode(extensions) 258 | 259 | sig = None 260 | for cred_descriptor in args[0x03]: 261 | if cred_descriptor['id'] in self._credentials: 262 | sig = DEREncoder.encode_signature( 263 | *ecdsa.sign( 264 | authData + clientDataJSON_hash, 265 | self._credentials[cred_descriptor['id']], 266 | hashfunc=hashlib.sha256 267 | ) 268 | ) 269 | break 270 | if sig is None: 271 | raise NoCredentialAvailable() 272 | 273 | return cbor.encode({ 274 | 0x01: cred_descriptor, 275 | 0x02: authData, 276 | 0x03: sig, 277 | }) 278 | 279 | def process_recovery_extension( 280 | self, 281 | rp_id, 282 | authData_without_extensions, 283 | clientDataHash, 284 | extension_input, 285 | ): 286 | action = extension_input['action'] 287 | if action == 'state': 288 | return self._get_state_counter() 289 | elif action == 'generate': 290 | return self._generate_backup_credentials(rp_id) 291 | elif action == 'recover': 292 | return self._generate_recovery_signature( 293 | rp_id, 294 | authData_without_extensions, 295 | clientDataHash, 296 | extension_input['allowCredentials'] 297 | ) 298 | else: 299 | return UnknownAction() 300 | 301 | def _get_state_counter(self): 302 | return { 303 | 'action': 'state', 304 | 'state': self._state, 305 | } 306 | 307 | def _generate_backup_credentials(self, rp_id): 308 | creds = [self._generate_backup_credential(seed, rp_id) 309 | for seed in self._seeds] 310 | ext_output = { 311 | 'action': 'generate', 312 | 'state': self._state, 313 | 'creds': creds, 314 | } 315 | return ext_output 316 | 317 | def _generate_backup_credential(self, backup_seed, rp_id): 318 | if backup_seed.alg != 0: 319 | raise UnknownKeyAgreementScheme(backup_seed.alg) 320 | 321 | def pack_cred_id(eph_pub, rp_id, mac_key): 322 | uncompressed_pubkey = encode_pub(eph_pub) 323 | rp_id_hash = sha256(rp_id.encode('utf-8')) 324 | 325 | full_mac = hmac(mac_key, 326 | struct.pack('>B65s32s', 327 | backup_seed.alg, 328 | uncompressed_pubkey, 329 | rp_id_hash)) 330 | mac = full_mac[0:16] 331 | 332 | return struct.pack('>B65s16s', 333 | backup_seed.alg, 334 | uncompressed_pubkey, 335 | mac) 336 | 337 | seed_pub = backup_seed.pubkey 338 | eph_pri, eph_pub = fastecdsa.keys.gen_keypair(P256) 339 | 340 | ecdh_point = seed_pub * eph_pri 341 | ikm_x = fastecdsa.encoding.util.int_to_bytes(ecdh_point.x) 342 | cred_key = hkdf(ikm_x, "webauthn.recovery.cred_key".encode('utf-8'), length=32) 343 | cred_key_int = int.from_bytes(cred_key, 'big', signed=False) 344 | 345 | assert cred_key_int < N, "cred_key >= N: " + str(cred_key_int) 346 | 347 | mac_key = hkdf(ikm_x, "webauthn.recovery.mac_key".encode('utf-8'), length=32) 348 | 349 | cred_pub = (cred_key_int * P256.G) + seed_pub 350 | assert cred_pub != P256.G.IDENTITY_ELEMENT 351 | 352 | cred_id = pack_cred_id(eph_pub, rp_id, mac_key) 353 | cred_data = pack_attested_credential_data(backup_seed.aaguid, cred_id, cred_pub) 354 | return cred_data 355 | 356 | def _generate_recovery_signature( 357 | self, 358 | rp_id, 359 | authData_without_extensions, 360 | clientDataHash, 361 | allow_credentials, 362 | ): 363 | if self._recovery_seed_pri_key is None: 364 | return InvalidState() 365 | 366 | for cred in allow_credentials: 367 | cred_id = cred['id'] 368 | alg = cred_id[0] 369 | 370 | if alg == 0: 371 | try: 372 | cred_pri = self._derive_private_key( 373 | self._recovery_seed_pri_key, 374 | cred_id, rp_id) 375 | sig = DEREncoder.encode_signature( 376 | *ecdsa.sign( 377 | authData_without_extensions + clientDataHash, 378 | cred_pri, 379 | hashfunc=hashlib.sha256 380 | ) 381 | ) 382 | except RpIdMismatch: 383 | continue 384 | else: 385 | continue 386 | 387 | extension_output = { 388 | 'action': 'recover', 389 | 'credId': cred_id, 390 | 'sig': sig, 391 | 'state': self._state, 392 | } 393 | return extension_output 394 | 395 | raise NoCredentialAvailable() 396 | 397 | def _derive_private_key(self, seed_pri, cred_id, rp_id): 398 | alg = cred_id[0] 399 | if alg != 0: 400 | raise UnknownKeyAgreementScheme(alg) 401 | 402 | eph_pub_enc = cred_id[1:][:65] 403 | eph_pub = SEC1Encoder.decode_public_key(eph_pub_enc, P256) 404 | 405 | ecdh_point = seed_pri * eph_pub 406 | ikm_x = fastecdsa.encoding.util.int_to_bytes(ecdh_point.x) 407 | cred_key = hkdf(ikm_x, binascii.a2b_hex('776562617574686e2e7265636f766572792e637265645f6b6579'), length=32) 408 | cred_key_int = int.from_bytes(cred_key, 'big', signed=False) 409 | 410 | mac_key = hkdf(ikm_x, binascii.a2b_hex('776562617574686e2e7265636f766572792e6d61635f6b6579'), length=32) 411 | full_mac = hmac(mac_key, 412 | struct.pack('>B65s32s', 413 | alg, 414 | eph_pub_enc, 415 | sha256(rp_id.encode('utf-8')))) 416 | mac = full_mac[0:16] 417 | 418 | recon_cred_id = struct.pack('>B65s16s', alg, eph_pub_enc, mac) 419 | if cred_id != recon_cred_id: 420 | raise RpIdMismatch() 421 | 422 | assert cred_key_int < N, "cred_key >= N: " + str(cred_key_int) 423 | 424 | cred_pri = cred_key_int + seed_pri % N 425 | return cred_pri 426 | 427 | 428 | class RelyingParty: 429 | 430 | def __init__(self): 431 | self._recovery_credentials = {} 432 | 433 | def get_recovery_cred_descriptors(self): 434 | return [ 435 | {'type': 'public-key', 'id': id} 436 | for id in self._recovery_credentials.keys() 437 | ] 438 | 439 | def makecredential_process_recovery_extension( 440 | self, 441 | authData, 442 | clientDataHash, 443 | extension_output, 444 | ): 445 | action = extension_output['action'] 446 | 447 | if action == 'state': 448 | pass 449 | elif action == 'recover': 450 | authData_without_extensions = authData 451 | pubkey_cose = self._recovery_credentials[ 452 | extension_output['credId'] 453 | ].public_key 454 | pubkey = cose_key_to_point(pubkey_cose) 455 | assert ecdsa.verify( 456 | DEREncoder.decode_signature(extension_output['sig']), 457 | authData_without_extensions + clientDataHash, 458 | pubkey, 459 | hashfunc=hashlib.sha256 460 | ) 461 | else: 462 | raise UnknownAction() 463 | 464 | def getassertion_process_recovery_extension(self, extension_output): 465 | action = extension_output['action'] 466 | 467 | if action == 'state': 468 | pass 469 | elif action == 'generate': 470 | backup_cred_data = extension_output['creds'] 471 | self._recovery_credentials.update({ 472 | cred.credential_id: cred 473 | for cred in 474 | (ctap2.AttestedCredentialData(cd) for cd in backup_cred_data) 475 | }) 476 | else: 477 | raise UnknownAction() 478 | 479 | 480 | def ctap2_to_webauthn_attestation_object(attObj_cbor): 481 | attObj = cbor.decode(attObj_cbor) 482 | return cbor.encode({ 483 | 'fmt': attObj[0x01], 484 | 'authData': attObj[0x02], 485 | 'attStmt': attObj[0x03], 486 | }) 487 | 488 | 489 | def create_credential(authenticator, request_json): 490 | request = json.loads(request_json) 491 | pkcco = request['publicKeyCredentialCreationOptions'] 492 | collectedClientData = { 493 | 'type': 'webauthn.create', 494 | 'challenge': pkcco['challenge'], 495 | 'origin': 'https://localhost:8443', 496 | } 497 | clientDataJSON = json.dumps(collectedClientData, indent=None).encode('utf-8') 498 | clientDataJSON_hash = sha256(clientDataJSON) 499 | 500 | if 'extensions' in pkcco and 'recovery' in pkcco['extensions'] and 'allowCredentials' in pkcco['extensions']['recovery']: 501 | for cred in pkcco['extensions']['recovery']['allowCredentials']: 502 | cred['id'] = b64d(cred['id']) 503 | 504 | attObj_bytes = authenticator.authenticator_make_credential(cbor.encode({ 505 | 0x01: clientDataJSON_hash, 506 | 0x02: pkcco['rp'], 507 | 0x06: pkcco['extensions'], 508 | })) 509 | attObj = ctap2.AttestationObject(attObj_bytes) 510 | credential_id = attObj.auth_data.credential_data.credential_id 511 | credential = { 512 | 'type': 'public-key', 513 | 'id': base64.urlsafe_b64encode(credential_id).decode('utf-8'), 514 | 'response': { 515 | 'attestationObject': base64.urlsafe_b64encode(ctap2_to_webauthn_attestation_object(attObj_bytes)).decode('utf-8'), 516 | 'clientDataJSON': base64.urlsafe_b64encode(clientDataJSON).decode('utf-8'), 517 | }, 518 | 'clientExtensionResults': {}, 519 | } 520 | print(json.dumps(credential, indent=2)) 521 | 522 | 523 | def get_assertion(authenticator, request_json): 524 | request = json.loads(request_json) 525 | pkcro = request['publicKeyCredentialRequestOptions'] 526 | collectedClientData = { 527 | 'type': 'webauthn.get', 528 | 'challenge': pkcro['challenge'], 529 | 'origin': 'https://localhost:8443', 530 | } 531 | clientDataJSON = json.dumps(collectedClientData, indent=None).encode('utf-8') 532 | clientDataJSON_hash = sha256(clientDataJSON) 533 | authenticator_response = cbor.decode(authenticator.authenticator_get_assertion(cbor.encode({ 534 | 0x01: pkcro['rpId'], 535 | 0x02: clientDataJSON_hash, 536 | 0x03: [{'id': base64.urlsafe_b64decode(c['id']), 'type': 'public-key'} for c in pkcro['allowCredentials']], 537 | 0x04: pkcro['extensions'], 538 | }))) 539 | authenticatorData = authenticator_response[0x02] 540 | sig = authenticator_response[0x03] 541 | credential = { 542 | 'type': 'public-key', 543 | 'id': base64.urlsafe_b64encode(authenticator_response[0x01]['id']).decode('utf-8'), 544 | 'response': { 545 | 'authenticatorData': base64.urlsafe_b64encode(authenticatorData).decode('utf-8'), 546 | 'clientDataJSON': base64.urlsafe_b64encode(clientDataJSON).decode('utf-8'), 547 | 'signature': base64.urlsafe_b64encode(sig).decode('utf-8'), 548 | }, 549 | 'clientExtensionResults': {}, 550 | } 551 | print(json.dumps(credential, indent=2)) 552 | 553 | 554 | if __name__ == '__main__': 555 | main_authnr = Authenticator() 556 | backup_authnr = Authenticator() 557 | rp = RelyingParty() 558 | 559 | main_authnr.import_recovery_seed( 560 | backup_authnr.export_recovery_seed([0])) 561 | 562 | gen_ext_output = main_authnr.process_recovery_extension( 563 | 'yubico.com', 564 | b'', 565 | b'', 566 | {'action': 'generate'}) 567 | rp.getassertion_process_recovery_extension(gen_ext_output) 568 | 569 | authData = b'Hej hej' 570 | clientDataHash = sha256(b'Herp le derp') 571 | recover_ext_output = backup_authnr.process_recovery_extension( 572 | 'yubico.com', 573 | authData, 574 | clientDataHash, 575 | { 576 | 'action': 'recover', 577 | 'allowCredentials': rp.get_recovery_cred_descriptors(), 578 | }) 579 | rp.makecredential_process_recovery_extension( 580 | authData, 581 | clientDataHash, 582 | recover_ext_output) 583 | -------------------------------------------------------------------------------- /benchmarks/tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py 3 | skipsdist = true 4 | 5 | [testenv] 6 | # Run benchmark on software implementation 7 | # Invoke as `tox`, `tox -e py27` etc. 8 | deps = 9 | fastecdsa 10 | fido2 11 | commands = 12 | python bench-software.py {posargs} 13 | setenv = 14 | PYTHONUNBUFFERED=1 15 | 16 | [testenv:arkg] 17 | # Run benchmark on abstract ARKG scheme 18 | # Invoke as `tox -e arkg` 19 | deps = 20 | {[testenv]deps} 21 | commands = 22 | python bench-arkg.py {posargs} 23 | setenv = 24 | {[testenv]setenv} 25 | 26 | [testenv:yubikey] 27 | # Run benchmark on YubiKey 28 | # Invoke as `tox -e yubikey` 29 | deps = 30 | {[testenv]deps} 31 | commands = 32 | python bench-yubikey.py {posargs} 33 | setenv = 34 | {[testenv]setenv} 35 | --------------------------------------------------------------------------------