├── .github ├── dependabot.yml └── workflows │ ├── lint.yml │ ├── static-analysis.yml │ └── test.yml ├── CAcerts └── yubico.pem ├── CHANGELOG.md ├── LICENSE ├── README.md ├── assets └── u2f-api.js ├── composer.json ├── phpcs.xml ├── phpstan-baseline.neon ├── phpstan.neon ├── phpunit.xml └── src ├── AppIdTrait.php ├── AttestationCertificate.php ├── AttestationCertificateInterface.php ├── Challenge.php ├── ChallengeProvider.php ├── ChallengeProviderInterface.php ├── ChallengeTrait.php ├── ClientData.php ├── ClientError.php ├── ClientErrorException.php ├── ECPublicKey.php ├── InvalidDataException.php ├── KeyHandleInterface.php ├── KeyHandleTrait.php ├── LoginResponseInterface.php ├── PublicKeyInterface.php ├── RegisterRequest.php ├── RegisterResponse.php ├── Registration.php ├── RegistrationInterface.php ├── RegistrationResponseInterface.php ├── ResponseTrait.php ├── SecurityException.php ├── Server.php ├── SignRequest.php ├── SignResponse.php ├── VersionTrait.php ├── WebAuthn ├── AuthenticatorData.php ├── LoginResponse.php └── RegistrationResponse.php └── functions.php /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "composer" 9 | directory: "/" 10 | schedule: 11 | interval: "daily" 12 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | # Run on all PRs 9 | 10 | env: 11 | CI: "true" 12 | 13 | jobs: 14 | phpcs: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Check out code 18 | uses: actions/checkout@v2 19 | 20 | - name: Setup PHP 21 | uses: shivammathur/setup-php@v2 22 | 23 | - name: Cache Composer packages 24 | id: composer-cache 25 | uses: actions/cache@v2 26 | with: 27 | path: vendor 28 | key: ${{ runner.os }}-php-${{ hashFiles('**/composer.json') }} 29 | restore-keys: | 30 | ${{ runner.os }}-php- 31 | 32 | - name: Install dependencies 33 | run: composer update 34 | --no-ansi 35 | --no-interaction 36 | --no-progress 37 | --no-suggest 38 | --prefer-dist 39 | 40 | - name: PHPCS 41 | run: composer phpcs 42 | -------------------------------------------------------------------------------- /.github/workflows/static-analysis.yml: -------------------------------------------------------------------------------- 1 | name: Static analysis 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | # Run on all PRs 9 | 10 | env: 11 | CI: "true" 12 | 13 | jobs: 14 | phpstan: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Check out code 18 | uses: actions/checkout@v2 19 | 20 | - name: Setup PHP 21 | uses: shivammathur/setup-php@v2 22 | 23 | - name: Cache Composer packages 24 | id: composer-cache 25 | uses: actions/cache@v2 26 | with: 27 | path: vendor 28 | key: ${{ runner.os }}-php-${{ hashFiles('**/composer.json') }} 29 | restore-keys: | 30 | ${{ runner.os }}-php- 31 | 32 | - name: Install dependencies 33 | run: composer update 34 | --no-ansi 35 | --no-interaction 36 | --no-progress 37 | --no-suggest 38 | --prefer-dist 39 | 40 | - name: PHPStan 41 | run: composer phpstan 42 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | # Run on all PRs 9 | 10 | env: 11 | CI: "true" 12 | 13 | jobs: 14 | phpunit: 15 | runs-on: ubuntu-latest 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | dependencies: 20 | - 'high' 21 | - 'low' 22 | php: 23 | - '7.2' 24 | - '7.3' 25 | - '7.4' 26 | - '8.0' 27 | - '8.1' 28 | exclude: 29 | - php: '8.1' 30 | dependencies: 'low' 31 | 32 | steps: 33 | - name: Check out code 34 | uses: actions/checkout@v2 35 | 36 | - name: Setup PHP 37 | uses: shivammathur/setup-php@v2 38 | with: 39 | coverage: pcov 40 | ini-values: zend.assertions=1, assert.exception=1, error_reporting=-1 41 | php-version: ${{ matrix.php }} 42 | 43 | - name: Cache Composer packages 44 | id: composer-cache 45 | uses: actions/cache@v2 46 | with: 47 | path: vendor 48 | key: ${{ runner.os }}-php-${{ matrix.dependencies }}-${{ matrix.php }}-${{ hashFiles('**/composer.json') }} 49 | restore-keys: | 50 | ${{ runner.os }}-php-${{ matrix.dependencies }}-${{ matrix.php }}-${{ hashFiles('**/composer.json') }} 51 | ${{ runner.os }}-php-${{ matrix.dependencies }}-${{ matrix.php }}- 52 | ${{ runner.os }}-php-${{ matrix.dependencies }}- 53 | ${{ runner.os }}-php- 54 | 55 | - name: Install highest dependencies 56 | if: ${{ matrix.dependencies == 'high' }} 57 | run: composer update 58 | --no-ansi 59 | --no-interaction 60 | --no-progress 61 | --no-suggest 62 | --prefer-dist 63 | 64 | - name: Install lowest dependencies 65 | if: ${{ matrix.dependencies == 'low' }} 66 | run: composer update 67 | --no-ansi 68 | --no-interaction 69 | --no-progress 70 | --no-suggest 71 | --prefer-dist 72 | --prefer-lowest 73 | 74 | - name: PHPUnit 75 | run: vendor/bin/phpunit 76 | --coverage-clover coverage.xml 77 | 78 | - name: Submit code coverage 79 | if: ${{ always() }} 80 | uses: codecov/codecov-action@v2 81 | -------------------------------------------------------------------------------- /CAcerts/yubico.pem: -------------------------------------------------------------------------------- 1 | Yubico U2F Device Attestation CA 2 | ================================ 3 | 4 | Last Update: 2014-09-01 5 | 6 | Yubico manufacturer U2F devices that contains device attestation 7 | certificates signed by a set of Yubico CAs. This file contains the CA 8 | certificates that Relying Parties (RP) need to configure their 9 | software with to be able to verify U2F device certificates. 10 | 11 | This file has been signed with OpenPGP and you should verify the 12 | signature and the authenticity of the public key before trusting the 13 | content. The signature is located next to the file: 14 | 15 | https://developers.yubico.com/u2f/yubico-u2f-ca-certs.txt 16 | https://developers.yubico.com/u2f/yubico-u2f-ca-certs.txt.sig 17 | 18 | We will update this file from time to time when we publish more CA 19 | certificates. 20 | 21 | Name: Yubico U2F Root CA Serial 457200631 22 | Issued: 2014-08-01 23 | 24 | -----BEGIN CERTIFICATE----- 25 | MIIDHjCCAgagAwIBAgIEG0BT9zANBgkqhkiG9w0BAQsFADAuMSwwKgYDVQQDEyNZ 26 | dWJpY28gVTJGIFJvb3QgQ0EgU2VyaWFsIDQ1NzIwMDYzMTAgFw0xNDA4MDEwMDAw 27 | MDBaGA8yMDUwMDkwNDAwMDAwMFowLjEsMCoGA1UEAxMjWXViaWNvIFUyRiBSb290 28 | IENBIFNlcmlhbCA0NTcyMDA2MzEwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK 29 | AoIBAQC/jwYuhBVlqaiYWEMsrWFisgJ+PtM91eSrpI4TK7U53mwCIawSDHy8vUmk 30 | 5N2KAj9abvT9NP5SMS1hQi3usxoYGonXQgfO6ZXyUA9a+KAkqdFnBnlyugSeCOep 31 | 8EdZFfsaRFtMjkwz5Gcz2Py4vIYvCdMHPtwaz0bVuzneueIEz6TnQjE63Rdt2zbw 32 | nebwTG5ZybeWSwbzy+BJ34ZHcUhPAY89yJQXuE0IzMZFcEBbPNRbWECRKgjq//qT 33 | 9nmDOFVlSRCt2wiqPSzluwn+v+suQEBsUjTGMEd25tKXXTkNW21wIWbxeSyUoTXw 34 | LvGS6xlwQSgNpk2qXYwf8iXg7VWZAgMBAAGjQjBAMB0GA1UdDgQWBBQgIvz0bNGJ 35 | hjgpToksyKpP9xv9oDAPBgNVHRMECDAGAQH/AgEAMA4GA1UdDwEB/wQEAwIBBjAN 36 | BgkqhkiG9w0BAQsFAAOCAQEAjvjuOMDSa+JXFCLyBKsycXtBVZsJ4Ue3LbaEsPY4 37 | MYN/hIQ5ZM5p7EjfcnMG4CtYkNsfNHc0AhBLdq45rnT87q/6O3vUEtNMafbhU6kt 38 | hX7Y+9XFN9NpmYxr+ekVY5xOxi8h9JDIgoMP4VB1uS0aunL1IGqrNooL9mmFnL2k 39 | LVVee6/VR6C5+KSTCMCWppMuJIZII2v9o4dkoZ8Y7QRjQlLfYzd3qGtKbw7xaF1U 40 | sG/5xUb/Btwb2X2g4InpiB/yt/3CpQXpiWX/K4mBvUKiGn05ZsqeY1gx4g0xLBqc 41 | U9psmyPzK+Vsgw2jeRQ5JlKDyqE0hebfC1tvFu0CCrJFcw== 42 | -----END CERTIFICATE----- 43 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [1.3.0] - Unreleased 8 | 9 | ### Added 10 | - Challenge class 11 | - ChallengeProviderInterface (will replace ChallengeProvider) 12 | - Server::generateChallenge(): ChallengeProviderInterface (now public; signature changed from previous private implementation) 13 | - Server::validateLogin(ChallengeProviderInterface, LoginResponseInterface, RegistrationInterface[]): RegistrationInterface (will replace Server::setRegistrations + Server::setSignRequests + Server::authenticate) 14 | - Server::validateRegistration(ChallengeProviderInterface, RegistrationResponseInterface): RegistrationInterface (will replace Server::setRegisterRequest + Server::register) 15 | 16 | ### Changed 17 | - Server's constructor now can take `string $appId` as a parameter 18 | 19 | ### Deprecated 20 | - ChallengeProvider 21 | - Server::authenticate(LoginResponseInterface) 22 | - Server::register(RegistrationResponseInterface) 23 | - Server::setAppId(string) 24 | - Server::setRegisterRequest(RegisterRequest) 25 | - Server::setRegistrations(RegistrationInterface[]) 26 | - Server::setSignRequests(SignRequest[]) 27 | 28 | ## [1.2.0] - 2021-10-26 29 | ### Added 30 | Support for WebAuthn protocols and APIs 31 | 32 | - WebAuthn\RegistrationResponse 33 | - WebAuthn\LoginResponse 34 | 35 | ## [1.1.0] - 2021-10-25 36 | ### Added 37 | - AttestationCertificate 38 | - AttestationCertificateInterface 39 | - ECPublicKey 40 | - KeyHandleInterface 41 | - LoginResponseInterface 42 | - PublicKeyInterface 43 | - RegistrationInterface 44 | - RegistrationResponseInterface 45 | 46 | ### Changed 47 | - Type information improved throughout 48 | - RegisterResponse implements RegistrationResponseInterface 49 | - Registration implements RegistrationInterface 50 | - SignResponse implements LoginResponseInterface- 51 | 52 | ## [1.0.1] - 2019-06-07 53 | ### Changed 54 | - Handle missing `cid_pubkey` field in response client data 55 | 56 | ## [1.0.0] - 2018-04-29 57 | Initial Release 58 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Eric Stern 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Important: This repository is archived 2 | 3 | This repository has been replaced by [firehed/webauthn-php](https://github.com/Firehed/webauthn-php), and is no longer being maintained. The replacement no longer supports the long-deprecated U2F protocol, which allows for a more modern and flexible API. It DOES support U2F hardware keys, but only through the WebAuthn protocols. 4 | 5 | # U2F 6 | 7 | A PHP implementation of the FIDO U2F authentication standard. 8 | Now also for Web Authentication! 9 | 10 | [![Lint](https://github.com/Firehed/u2f-php/actions/workflows/lint.yml/badge.svg)](https://github.com/Firehed/u2f-php/actions/workflows/lint.yml) 11 | [![Static analysis](https://github.com/Firehed/u2f-php/actions/workflows/static-analysis.yml/badge.svg)](https://github.com/Firehed/u2f-php/actions/workflows/static-analysis.yml) 12 | [![Test](https://github.com/Firehed/u2f-php/actions/workflows/test.yml/badge.svg)](https://github.com/Firehed/u2f-php/actions/workflows/test.yml) 13 | [![codecov](https://codecov.io/gh/Firehed/u2f-php/branch/master/graph/badge.svg?token=8VxRoJxmNL)](https://codecov.io/gh/Firehed/u2f-php) 14 | 15 | ## Introduction 16 | 17 | Web Authenication (commonly called WebAuthn) is a set of technologies to securely authenticate users in web applications. 18 | It is most commonly used as a second factor - either biometrics or a hardware device - to supplement password logins. 19 | It allows websites to replace the need for a companion app (such as Google Authenticator) or communication protocols (e.g. SMS) with a hardware-based second factor. 20 | 21 | This library has its roots in the U2F (universal second factor) protocol that WebAuthn evolved from, and supports both standards. 22 | Note that browsers are starting to drop support for the original U2F protocols in favor of WebAuthn; consequently, this library will do the same in the next major version. 23 | 24 | This library is designed to allow easy integration of the U2F protocol to an existing user authentication scheme. 25 | It handles the parsing and validating all of the raw message formats, and translates them into standard PHP objects. 26 | 27 | Note that use of the word "key" throughout this document should be interpreted to mean "FIDO U2F Token". 28 | These are often USB "keys" but can also be NFC or Bluetooth devices. 29 | 30 | There are two main operations that you will need to understand for a successful integration: registration and authentication. 31 | Registration is the act of associating a key that the end-user is physically in posession of with their existing account; authentication is where that key is used to cryptographically sign a message from your application to verify posession of said key. 32 | 33 | Additional resources: 34 | 35 | * [W3 Spec](https://www.w3.org/TR/webauthn-2) 36 | * [MDN](https://developer.mozilla.org/en-US/docs/Web/API/Web_Authentication_API) 37 | 38 | ## Demo 39 | 40 | You may try all of this at https://u2f.ericstern.com, and see the corresponding code at https://github.com/Firehed/u2f-php-examples 41 | 42 | The example code is only designed to show how the APIs interact with each other, and intentionally leaves out best practices such as use of routers and dependency inversion containers to keep the examples as simple as possible. 43 | See its README for more information. 44 | 45 | ## Installation 46 | 47 | `composer require firehed/u2f` 48 | 49 | Note: you **must not** be using the deprecated `mbstring.func_overload` functionality, which can completely break working on binary data. 50 | The library will immediately throw an exception if you have it enabled. 51 | 52 | ## Usage 53 | 54 | Usage will be described in three parts: setup, registration, and authentication. 55 | The code in setup should be used before both registration and authentication. 56 | 57 | The API is designed to "fail loudly"; that is, failures will throw an exception, ensuring that return values are always the result of a successful operation. 58 | This reduces the need for complex error checking and handling during use, since the whole thing can be simply wrapped in a `try/catch` block and assume that everything went well if no exceptions are caught. 59 | 60 | This guide covers the modern Web Authentication ("WebAuthn") usage and data formats. 61 | More information on the legacy U2F protocols are available in versions of this README from v1.1.0 and earlier. 62 | 63 | ### Setup 64 | 65 | All operations are performed by the U2F Server class, so it needs to be instanciated and configured: 66 | 67 | ```php 68 | use Firehed\U2F\Server; 69 | $server = new Server('u2f.example.com'); 70 | $server->setTrustedCAs(glob('path/to/certs/*.pem')); 71 | ``` 72 | 73 | The trusted CAs are whitelisted vendors, and must be an array of absolute paths to PEM-formatted CA certs (as strings). 74 | Some provider certificates are provided in the `CACerts/` directory in the repository root; in a deployed project, these should be available via `$PROJECT_ROOT/vendor/firehed/u2f/CACerts/*.pem`. 75 | 76 | You may also choose to disable CA verification, by calling `->disableCAVerification()` instead of `setTrustedCAs()`. 77 | This removes trust in the hardware vendors, but ensures that as new vendors issue tokens, they will be forward-compatible with your website. 78 | 79 | The URI provided to the constructor must be the HTTPS domain component of your website. 80 | See [FIDO U2F AppID and Facet Specification](https://fidoalliance.org/specs/fido-u2f-v1.0-nfc-bt-amendment-20150514/fido-appid-and-facets.html#appid-example-1) for additional information. 81 | 82 | ### Registration 83 | 84 | Registering a token to a user's account is a two-step process: generating a challenge, and verifying the response to that challenge. 85 | 86 | #### Generating the challenge 87 | 88 | Start by generating a challenge. 89 | You will need to store this temporarily (e.g. in a session), then send it to the user: 90 | 91 | ```php 92 | $challenge = $server->generateChallenge(); 93 | $_SESSION['registration_challenge'] = $challenge; 94 | 95 | header('Content-type: application/json'); 96 | echo json_encode($challenge); 97 | ``` 98 | 99 | #### Client-side registration 100 | Create a [`PublicKeyCredentialCreationOptions`](https://www.w3.org/TR/webauthn/#dictdef-publickeycredentialcreationoptions) data structure, and provide it to the WebAuthn API: 101 | 102 | ```js 103 | const userId = "some value from your application" 104 | const challenge = "challenge string from above" 105 | const options = { 106 | rp: { 107 | name: "Example Site", 108 | }, 109 | user: { 110 | id: Uint8Array.from(userId, c => c.charCodeAt(0)), 111 | name: "user@example.com", 112 | displayName: "User Name", 113 | }, 114 | challenge: Uint8Array.from(challenge, c => c.charCodeAt(0)), 115 | pubKeyCredParams: [{alg: -7, type: "public-key"}], 116 | timeout: 60000, // 60 seconds 117 | authenticatorSelection: { 118 | authenticatorAttachment: "cross-platform", 119 | userVerification: "preferred", 120 | }, 121 | attestation: "direct" 122 | } 123 | 124 | // If the user completes registration, this value will hold the data to POST to your application 125 | const credential = await navigator.credentials.create({ 126 | publicKey: options 127 | }) 128 | 129 | // Format the user's `credential` and POST it to your application: 130 | 131 | const dataToSend = { 132 | rawId: new Uint8Array(credential.rawId), 133 | type: credential.type, 134 | response: { 135 | attestationObject: new Uint8Array(credential.response.attestationObject), 136 | clientDataJSON: new Uint8Array(credential.response.clientDataJSON), 137 | }, 138 | } 139 | 140 | // Pseudocode: 141 | // POST will send JSON.stringify(dataToSend) with an application/json Content-type header 142 | const response = POST('/verifyRegisterChallenge.php', dataToSend) 143 | ``` 144 | 145 | #### Parse and verify the response 146 | 147 | Using the previously-generated registration request, ask the server to verify the POSTed data. 148 | If verification succeeds, you will have a Registration object to associate with the user: 149 | 150 | ```php 151 | // You should validate that the inbound request has an 'application/json' Content-type header 152 | $rawPostBody = trim(file_get_contents('php://input')); 153 | $data = json_decode($rawPostBody, true); 154 | $response = \Firehed\U2F\WebAuthn\RegistrationResponse::fromDecodedJson($data); 155 | 156 | $challenge = $_SESSION['registration_challenge']; 157 | $registration = $server->validateRegistration($challenge, $response); 158 | ``` 159 | 160 | #### Persist the `$registration` 161 | 162 | Registrations SHOULD be persisted as a one-to-many relationship with the user, since a user may own multiple keys and may want to associate all of them with their account (e.g. a backup key is kept on a spouse's keychain). 163 | It is RECOMMENDED to use `(user_id, key_handle)` as a unique composite identifier. 164 | 165 | ```sql 166 | -- A schema with roughly this format is ideal 167 | CREATE TABLE token_registrations ( 168 | id INTEGER PRIMARY KEY, 169 | user_id INTEGER, 170 | counter INTEGER, 171 | key_handle TEXT, 172 | public_key TEXT, 173 | attestation_certificate TEXT, 174 | FOREIGN KEY (user_id) REFERENCES users(id), 175 | UNIQUE(user_id, key_handle) 176 | ) 177 | ``` 178 | ```php 179 | // This assumes you are connecting to your database with PDO 180 | $query = <<prepare($query); 196 | // Note: you may want to base64- or hex-encode the binary values below. 197 | // Doing so is entirely optional. 198 | $stmt->execute([ 199 | ':user_id' => $_SESSION['user_id'], 200 | ':counter' => $registration->getCounter(), 201 | ':key_handle' => $registration->getKeyHandleBinary(), 202 | ':public_key' => $registration->getPublicKey()->getBinary(), 203 | ':attestation_certificate' => $registration->getAttestationCertificate()->getBinary(), 204 | ]); 205 | ``` 206 | 207 | After doing this, you should add a flag of some kind on the user to indicate that 2FA is enabled, and ensure that they have authenticated with their second factor. 208 | Since this is entirely application-specific, it won't be covered here. 209 | 210 | ### Authentication 211 | 212 | Authentication is a similar process as registration: generate challenges to sign for each of the user's registrations, and validate the response when received. 213 | 214 | #### Generating the challenge 215 | 216 | Start by generating sign requests. 217 | Like with registration, you will need to store them temporarily for verification. 218 | After doing so, send them to the user: 219 | 220 | ```php 221 | $registrations = $user->getU2FRegistrations(); // this must be an array of Registration objects 222 | 223 | $challenge = $server->generateChallenge(); 224 | $_SESSION['login_challenge'] = $challenge; 225 | 226 | // WebAuthn expects a single challenge for all key handles, and the Server generates the requests accordingly. 227 | header('Content-type: application/json'); 228 | echo json_encode([ 229 | 'challenge' => $challenge, 230 | 'key_handles' => array_map(function (\Firehed\U2F\RegistrationInterface $reg) { 231 | return $reg->getKeyHandleWeb(); 232 | }, $registrations), 233 | ]); 234 | ``` 235 | 236 | #### Client-side authentication 237 | 238 | Create a [`PublicKeyCredentialRequestOptions`](https://www.w3.org/TR/webauthn/#assertion-options) data structure, and provide it to the WebAuthn API: 239 | 240 | ```js 241 | // This is a basic decoder for the above `getKeyHandleWeb()` format 242 | const fromBase64Web = s => atob(s.replace(/\-/g,'+').replace(/_/g,'/')) 243 | 244 | // postedData is the decoded JSON from the above snippet 245 | const challenge = postedData.challenge 246 | const keyHandles = postedData.key_handles 247 | const options = { 248 | challenge: Uint8Array.from(challenge, c => c.charCodeAt(0)), 249 | allowCredentials: keyHandles.map(kh => ({ 250 | id: Uint8Array.from(fromBase64Web(kh), c => c.charCodeAt(0)), 251 | type: 'public-key', 252 | transports: ['usb', 'ble', 'nfc'], 253 | })), 254 | timeout: 60000, 255 | } 256 | // If the user authenticates, this value will hold the data to POST to your application 257 | const assertion = await navigator.credentials.get({ 258 | publicKey: options 259 | }); 260 | 261 | // Format the user's `assertion` and POST it to your application: 262 | 263 | const dataToSend = { 264 | rawId: new Uint8Array(assertion.rawId), 265 | type: assertion.type, 266 | response: { 267 | authenticatorData: new Uint8Array(assertion.response.authenticatorData), 268 | clientDataJSON: new Uint8Array(assertion.response.clientDataJSON), 269 | signature: new Uint8Array(assertion.response.signature), 270 | }, 271 | } 272 | 273 | // Pseudocode, same as above 274 | const response = await POST('/verifyLoginChallenge.php', dataToSend) 275 | ``` 276 | #### Parse and verify the response 277 | 278 | Parse the POSTed data into a `LoginResponseInterface`: 279 | ```php 280 | // You should validate that the inbound request has an 'application/json' Content-type header 281 | $rawPostBody = trim(file_get_contents('php://input')); 282 | $data = json_decode($rawPostBody, true); 283 | $response = \Firehed\U2F\WebAuthn\LoginResponse::fromDecodedJson($data); 284 | 285 | $registrations = $user->getU2FRegistrations(); // Registration[] 286 | $registration = $server->validateLogin( 287 | $_SESSION['login_challenge'], 288 | $response, 289 | $registrations 290 | ); 291 | ``` 292 | 293 | #### Persist the updated `$registration` 294 | If no exception is thrown, `$registration` will be a Registration object with an updated `counter`; you MUST persist this updated counter to wherever the registrations are stored. 295 | Failure to do so **is insecure** and exposes your application to token cloning attacks. 296 | 297 | ```php 298 | // Again, assumes a PDO connection 299 | $query = <<prepare($query); 306 | $stmt->execute([ 307 | ':counter' => $registration->getCounter(), 308 | ':user_id' => $_SESSION['user_id'], 309 | ':key_handle' => $registration->getKeyHandleBinary(), // if you are storing base64- or hex- encoded above, do so here as well 310 | ]); 311 | ``` 312 | 313 | If you reach this point, the user has succcessfully authenticated with their second factor. 314 | Update their session to indicate this, and allow them to proceeed. 315 | 316 | ## Tests 317 | 318 | All tests are in the `tests/` directory and can be run with `vendor/bin/phpunit`. 319 | 320 | ## License 321 | 322 | MIT 323 | -------------------------------------------------------------------------------- /assets/u2f-api.js: -------------------------------------------------------------------------------- 1 | // Copyright 2014-2015 Google Inc. All rights reserved. 2 | // 3 | // Use of this source code is governed by a BSD-style 4 | // license that can be found in the LICENSE file or at 5 | // https://developers.google.com/open-source/licenses/bsd 6 | 7 | /** 8 | * @fileoverview The U2F api. 9 | */ 10 | 11 | 'use strict'; 12 | 13 | /** Namespace for the U2F api. 14 | * @type {Object} 15 | */ 16 | var u2f = u2f || {}; 17 | 18 | /** 19 | * The U2F extension id 20 | * @type {string} 21 | * @const 22 | */ 23 | u2f.EXTENSION_ID = 'kmendfapggjehodndflmmgagdbamhnfd'; 24 | 25 | /** 26 | * Message types for messsages to/from the extension 27 | * @const 28 | * @enum {string} 29 | */ 30 | u2f.MessageTypes = { 31 | 'U2F_REGISTER_REQUEST': 'u2f_register_request', 32 | 'U2F_SIGN_REQUEST': 'u2f_sign_request', 33 | 'U2F_REGISTER_RESPONSE': 'u2f_register_response', 34 | 'U2F_SIGN_RESPONSE': 'u2f_sign_response' 35 | }; 36 | 37 | /** 38 | * Response status codes 39 | * @const 40 | * @enum {number} 41 | */ 42 | u2f.ErrorCodes = { 43 | 'OK': 0, 44 | 'OTHER_ERROR': 1, 45 | 'BAD_REQUEST': 2, 46 | 'CONFIGURATION_UNSUPPORTED': 3, 47 | 'DEVICE_INELIGIBLE': 4, 48 | 'TIMEOUT': 5 49 | }; 50 | 51 | /** 52 | * A message type for registration requests 53 | * @typedef {{ 54 | * type: u2f.MessageTypes, 55 | * signRequests: Array, 56 | * registerRequests: ?Array, 57 | * timeoutSeconds: ?number, 58 | * requestId: ?number 59 | * }} 60 | */ 61 | u2f.Request; 62 | 63 | /** 64 | * A message for registration responses 65 | * @typedef {{ 66 | * type: u2f.MessageTypes, 67 | * responseData: (u2f.Error | u2f.RegisterResponse | u2f.SignResponse), 68 | * requestId: ?number 69 | * }} 70 | */ 71 | u2f.Response; 72 | 73 | /** 74 | * An error object for responses 75 | * @typedef {{ 76 | * errorCode: u2f.ErrorCodes, 77 | * errorMessage: ?string 78 | * }} 79 | */ 80 | u2f.Error; 81 | 82 | /** 83 | * Data object for a single sign request. 84 | * @typedef {{ 85 | * version: string, 86 | * challenge: string, 87 | * keyHandle: string, 88 | * appId: string 89 | * }} 90 | */ 91 | u2f.SignRequest; 92 | 93 | /** 94 | * Data object for a sign response. 95 | * @typedef {{ 96 | * keyHandle: string, 97 | * signatureData: string, 98 | * clientData: string 99 | * }} 100 | */ 101 | u2f.SignResponse; 102 | 103 | /** 104 | * Data object for a registration request. 105 | * @typedef {{ 106 | * version: string, 107 | * challenge: string, 108 | * appId: string 109 | * }} 110 | */ 111 | u2f.RegisterRequest; 112 | 113 | /** 114 | * Data object for a registration response. 115 | * @typedef {{ 116 | * registrationData: string, 117 | * clientData: string 118 | * }} 119 | */ 120 | u2f.RegisterResponse; 121 | 122 | 123 | // Low level MessagePort API support 124 | 125 | /** 126 | * Sets up a MessagePort to the U2F extension using the 127 | * available mechanisms. 128 | * @param {function((MessagePort|u2f.WrappedChromeRuntimePort_))} callback 129 | */ 130 | u2f.getMessagePort = function(callback) { 131 | if (typeof chrome != 'undefined' && chrome.runtime) { 132 | // The actual message here does not matter, but we need to get a reply 133 | // for the callback to run. Thus, send an empty signature request 134 | // in order to get a failure response. 135 | var msg = { 136 | type: u2f.MessageTypes.U2F_SIGN_REQUEST, 137 | signRequests: [] 138 | }; 139 | chrome.runtime.sendMessage(u2f.EXTENSION_ID, msg, function() { 140 | if (!chrome.runtime.lastError) { 141 | // We are on a whitelisted origin and can talk directly 142 | // with the extension. 143 | u2f.getChromeRuntimePort_(callback); 144 | } else { 145 | // chrome.runtime was available, but we couldn't message 146 | // the extension directly, use iframe 147 | u2f.getIframePort_(callback); 148 | } 149 | }); 150 | } else if (u2f.isAndroidChrome_()) { 151 | u2f.getAuthenticatorPort_(callback); 152 | } else { 153 | // chrome.runtime was not available at all, which is normal 154 | // when this origin doesn't have access to any extensions. 155 | u2f.getIframePort_(callback); 156 | } 157 | }; 158 | 159 | /** 160 | * Detect chrome running on android based on the browser's useragent. 161 | * @private 162 | */ 163 | u2f.isAndroidChrome_ = function() { 164 | var userAgent = navigator.userAgent; 165 | return userAgent.indexOf('Chrome') != -1 && 166 | userAgent.indexOf('Android') != -1; 167 | }; 168 | 169 | /** 170 | * Connects directly to the extension via chrome.runtime.connect 171 | * @param {function(u2f.WrappedChromeRuntimePort_)} callback 172 | * @private 173 | */ 174 | u2f.getChromeRuntimePort_ = function(callback) { 175 | var port = chrome.runtime.connect(u2f.EXTENSION_ID, 176 | {'includeTlsChannelId': true}); 177 | setTimeout(function() { 178 | callback(new u2f.WrappedChromeRuntimePort_(port)); 179 | }, 0); 180 | }; 181 | 182 | /** 183 | * Return a 'port' abstraction to the Authenticator app. 184 | * @param {function(u2f.WrappedAuthenticatorPort_)} callback 185 | * @private 186 | */ 187 | u2f.getAuthenticatorPort_ = function(callback) { 188 | setTimeout(function() { 189 | callback(new u2f.WrappedAuthenticatorPort_()); 190 | }, 0); 191 | }; 192 | 193 | /** 194 | * A wrapper for chrome.runtime.Port that is compatible with MessagePort. 195 | * @param {Port} port 196 | * @constructor 197 | * @private 198 | */ 199 | u2f.WrappedChromeRuntimePort_ = function(port) { 200 | this.port_ = port; 201 | }; 202 | 203 | /** 204 | * Format a return a sign request. 205 | * @param {Array} signRequests 206 | * @param {number} timeoutSeconds 207 | * @param {number} reqId 208 | * @return {Object} 209 | */ 210 | u2f.WrappedChromeRuntimePort_.prototype.formatSignRequest_ = 211 | function(signRequests, timeoutSeconds, reqId) { 212 | return { 213 | type: u2f.MessageTypes.U2F_SIGN_REQUEST, 214 | signRequests: signRequests, 215 | timeoutSeconds: timeoutSeconds, 216 | requestId: reqId 217 | }; 218 | }; 219 | 220 | /** 221 | * Format a return a register request. 222 | * @param {Array} signRequests 223 | * @param {Array} signRequests 224 | * @param {number} timeoutSeconds 225 | * @param {number} reqId 226 | * @return {Object} 227 | */ 228 | u2f.WrappedChromeRuntimePort_.prototype.formatRegisterRequest_ = 229 | function(signRequests, registerRequests, timeoutSeconds, reqId) { 230 | return { 231 | type: u2f.MessageTypes.U2F_REGISTER_REQUEST, 232 | signRequests: signRequests, 233 | registerRequests: registerRequests, 234 | timeoutSeconds: timeoutSeconds, 235 | requestId: reqId 236 | }; 237 | }; 238 | 239 | /** 240 | * Posts a message on the underlying channel. 241 | * @param {Object} message 242 | */ 243 | u2f.WrappedChromeRuntimePort_.prototype.postMessage = function(message) { 244 | this.port_.postMessage(message); 245 | }; 246 | 247 | /** 248 | * Emulates the HTML 5 addEventListener interface. Works only for the 249 | * onmessage event, which is hooked up to the chrome.runtime.Port.onMessage. 250 | * @param {string} eventName 251 | * @param {function({data: Object})} handler 252 | */ 253 | u2f.WrappedChromeRuntimePort_.prototype.addEventListener = 254 | function(eventName, handler) { 255 | var name = eventName.toLowerCase(); 256 | if (name == 'message' || name == 'onmessage') { 257 | this.port_.onMessage.addListener(function(message) { 258 | // Emulate a minimal MessageEvent object 259 | handler({'data': message}); 260 | }); 261 | } else { 262 | console.error('WrappedChromeRuntimePort only supports onMessage'); 263 | } 264 | }; 265 | 266 | /** 267 | * Wrap the Authenticator app with a MessagePort interface. 268 | * @constructor 269 | * @private 270 | */ 271 | u2f.WrappedAuthenticatorPort_ = function() { 272 | this.requestId_ = -1; 273 | this.requestObject_ = null; 274 | } 275 | 276 | /** 277 | * Launch the Authenticator intent. 278 | * @param {Object} message 279 | */ 280 | u2f.WrappedAuthenticatorPort_.prototype.postMessage = function(message) { 281 | var intentLocation = /** @type {string} */ (message); 282 | document.location = intentLocation; 283 | }; 284 | 285 | /** 286 | * Emulates the HTML 5 addEventListener interface. 287 | * @param {string} eventName 288 | * @param {function({data: Object})} handler 289 | */ 290 | u2f.WrappedAuthenticatorPort_.prototype.addEventListener = 291 | function(eventName, handler) { 292 | var name = eventName.toLowerCase(); 293 | if (name == 'message') { 294 | var self = this; 295 | /* Register a callback to that executes when 296 | * chrome injects the response. */ 297 | window.addEventListener( 298 | 'message', self.onRequestUpdate_.bind(self, handler), false); 299 | } else { 300 | console.error('WrappedAuthenticatorPort only supports message'); 301 | } 302 | }; 303 | 304 | /** 305 | * Callback invoked when a response is received from the Authenticator. 306 | * @param function({data: Object}) callback 307 | * @param {Object} message message Object 308 | */ 309 | u2f.WrappedAuthenticatorPort_.prototype.onRequestUpdate_ = 310 | function(callback, message) { 311 | var messageObject = JSON.parse(message.data); 312 | var intentUrl = messageObject['intentURL']; 313 | 314 | var errorCode = messageObject['errorCode']; 315 | var responseObject = null; 316 | if (messageObject.hasOwnProperty('data')) { 317 | responseObject = /** @type {Object} */ ( 318 | JSON.parse(messageObject['data'])); 319 | responseObject['requestId'] = this.requestId_; 320 | } 321 | 322 | /* Sign responses from the authenticator do not conform to U2F, 323 | * convert to U2F here. */ 324 | responseObject = this.doResponseFixups_(responseObject); 325 | callback({'data': responseObject}); 326 | }; 327 | 328 | /** 329 | * Fixup the response provided by the Authenticator to conform with 330 | * the U2F spec. 331 | * @param {Object} responseData 332 | * @return {Object} the U2F compliant response object 333 | */ 334 | u2f.WrappedAuthenticatorPort_.prototype.doResponseFixups_ = 335 | function(responseObject) { 336 | if (responseObject.hasOwnProperty('responseData')) { 337 | return responseObject; 338 | } else if (this.requestObject_['type'] != u2f.MessageTypes.U2F_SIGN_REQUEST) { 339 | // Only sign responses require fixups. If this is not a response 340 | // to a sign request, then an internal error has occurred. 341 | return { 342 | 'type': u2f.MessageTypes.U2F_REGISTER_RESPONSE, 343 | 'responseData': { 344 | 'errorCode': u2f.ErrorCodes.OTHER_ERROR, 345 | 'errorMessage': 'Internal error: invalid response from Authenticator' 346 | } 347 | }; 348 | } 349 | 350 | /* Non-conformant sign response, do fixups. */ 351 | var encodedChallengeObject = responseObject['challenge']; 352 | if (typeof encodedChallengeObject !== 'undefined') { 353 | var challengeObject = JSON.parse(atob(encodedChallengeObject)); 354 | var serverChallenge = challengeObject['challenge']; 355 | var challengesList = this.requestObject_['signData']; 356 | var requestChallengeObject = null; 357 | for (var i = 0; i < challengesList.length; i++) { 358 | var challengeObject = challengesList[i]; 359 | if (challengeObject['keyHandle'] == responseObject['keyHandle']) { 360 | requestChallengeObject = challengeObject; 361 | break; 362 | } 363 | } 364 | } 365 | var responseData = { 366 | 'errorCode': responseObject['resultCode'], 367 | 'keyHandle': responseObject['keyHandle'], 368 | 'signatureData': responseObject['signature'], 369 | 'clientData': encodedChallengeObject 370 | }; 371 | return { 372 | 'type': u2f.MessageTypes.U2F_SIGN_RESPONSE, 373 | 'responseData': responseData, 374 | 'requestId': responseObject['requestId'] 375 | } 376 | }; 377 | 378 | /** 379 | * Base URL for intents to Authenticator. 380 | * @const 381 | * @private 382 | */ 383 | u2f.WrappedAuthenticatorPort_.INTENT_URL_BASE_ = 384 | 'intent:#Intent;action=com.google.android.apps.authenticator.AUTHENTICATE'; 385 | 386 | /** 387 | * Format a return a sign request. 388 | * @param {Array} signRequests 389 | * @param {number} timeoutSeconds (ignored for now) 390 | * @param {number} reqId 391 | * @return {string} 392 | */ 393 | u2f.WrappedAuthenticatorPort_.prototype.formatSignRequest_ = 394 | function(signRequests, timeoutSeconds, reqId) { 395 | if (!signRequests || signRequests.length == 0) { 396 | return null; 397 | } 398 | /* TODO(fixme): stash away requestId, as the authenticator app does 399 | * not return it for sign responses. */ 400 | this.requestId_ = reqId; 401 | /* TODO(fixme): stash away the signRequests, to deal with the legacy 402 | * response format returned by the Authenticator app. */ 403 | this.requestObject_ = { 404 | 'type': u2f.MessageTypes.U2F_SIGN_REQUEST, 405 | 'signData': signRequests, 406 | 'requestId': reqId, 407 | 'timeout': timeoutSeconds 408 | }; 409 | 410 | var appId = signRequests[0]['appId']; 411 | var intentUrl = 412 | u2f.WrappedAuthenticatorPort_.INTENT_URL_BASE_ + 413 | ';S.appId=' + encodeURIComponent(appId) + 414 | ';S.eventId=' + reqId + 415 | ';S.challenges=' + 416 | encodeURIComponent( 417 | JSON.stringify(this.getBrowserDataList_(signRequests))) + ';end'; 418 | return intentUrl; 419 | }; 420 | 421 | /** 422 | * Get the browser data objects from the challenge list 423 | * @param {Array} challenges list of challenges 424 | * @return {Array} list of browser data objects 425 | * @private 426 | */ 427 | u2f.WrappedAuthenticatorPort_ 428 | .prototype.getBrowserDataList_ = function(challenges) { 429 | return challenges 430 | .map(function(challenge) { 431 | var browserData = { 432 | 'typ': 'navigator.id.getAssertion', 433 | 'challenge': challenge['challenge'] 434 | }; 435 | var challengeObject = { 436 | 'challenge' : browserData, 437 | 'keyHandle' : challenge['keyHandle'] 438 | }; 439 | return challengeObject; 440 | }); 441 | }; 442 | 443 | /** 444 | * Format a return a register request. 445 | * @param {Array} signRequests 446 | * @param {Array} enrollChallenges 447 | * @param {number} timeoutSeconds (ignored for now) 448 | * @param {number} reqId 449 | * @return {Object} 450 | */ 451 | u2f.WrappedAuthenticatorPort_.prototype.formatRegisterRequest_ = 452 | function(signRequests, enrollChallenges, timeoutSeconds, reqId) { 453 | if (!enrollChallenges || enrollChallenges.length == 0) { 454 | return null; 455 | } 456 | // Assume the appId is the same for all enroll challenges. 457 | var appId = enrollChallenges[0]['appId']; 458 | var registerRequests = []; 459 | for (var i = 0; i < enrollChallenges.length; i++) { 460 | var registerRequest = { 461 | 'challenge': enrollChallenges[i]['challenge'], 462 | 'version': enrollChallenges[i]['version'] 463 | }; 464 | if (enrollChallenges[i]['appId'] != appId) { 465 | // Only include the appId when it differs from the first appId. 466 | registerRequest['appId'] = enrollChallenges[i]['appId']; 467 | } 468 | registerRequests.push(registerRequest); 469 | } 470 | var registeredKeys = []; 471 | if (signRequests) { 472 | for (i = 0; i < signRequests.length; i++) { 473 | var key = { 474 | 'keyHandle': signRequests[i]['keyHandle'], 475 | 'version': signRequests[i]['version'] 476 | }; 477 | // Only include the appId when it differs from the appId that's 478 | // being registered now. 479 | if (signRequests[i]['appId'] != appId) { 480 | key['appId'] = signRequests[i]['appId']; 481 | } 482 | registeredKeys.push(key); 483 | } 484 | } 485 | var request = { 486 | 'type': u2f.MessageTypes.U2F_REGISTER_REQUEST, 487 | 'appId': appId, 488 | 'registerRequests': registerRequests, 489 | 'registeredKeys': registeredKeys, 490 | 'requestId': reqId, 491 | 'timeoutSeconds': timeoutSeconds 492 | }; 493 | var intentUrl = 494 | u2f.WrappedAuthenticatorPort_.INTENT_URL_BASE_ + 495 | ';S.request=' + encodeURIComponent(JSON.stringify(request)) + 496 | ';end'; 497 | /* TODO(fixme): stash away requestId, this is is not necessary for 498 | * register requests, but here to keep parity with sign. 499 | */ 500 | this.requestId_ = reqId; 501 | return intentUrl; 502 | }; 503 | 504 | 505 | /** 506 | * Sets up an embedded trampoline iframe, sourced from the extension. 507 | * @param {function(MessagePort)} callback 508 | * @private 509 | */ 510 | u2f.getIframePort_ = function(callback) { 511 | // Create the iframe 512 | var iframeOrigin = 'chrome-extension://' + u2f.EXTENSION_ID; 513 | var iframe = document.createElement('iframe'); 514 | iframe.src = iframeOrigin + '/u2f-comms.html'; 515 | iframe.setAttribute('style', 'display:none'); 516 | document.body.appendChild(iframe); 517 | 518 | var channel = new MessageChannel(); 519 | var ready = function(message) { 520 | if (message.data == 'ready') { 521 | channel.port1.removeEventListener('message', ready); 522 | callback(channel.port1); 523 | } else { 524 | console.error('First event on iframe port was not "ready"'); 525 | } 526 | }; 527 | channel.port1.addEventListener('message', ready); 528 | channel.port1.start(); 529 | 530 | iframe.addEventListener('load', function() { 531 | // Deliver the port to the iframe and initialize 532 | iframe.contentWindow.postMessage('init', iframeOrigin, [channel.port2]); 533 | }); 534 | }; 535 | 536 | 537 | // High-level JS API 538 | 539 | /** 540 | * Default extension response timeout in seconds. 541 | * @const 542 | */ 543 | u2f.EXTENSION_TIMEOUT_SEC = 30; 544 | 545 | /** 546 | * A singleton instance for a MessagePort to the extension. 547 | * @type {MessagePort|u2f.WrappedChromeRuntimePort_} 548 | * @private 549 | */ 550 | u2f.port_ = null; 551 | 552 | /** 553 | * Callbacks waiting for a port 554 | * @type {Array} 555 | * @private 556 | */ 557 | u2f.waitingForPort_ = []; 558 | 559 | /** 560 | * A counter for requestIds. 561 | * @type {number} 562 | * @private 563 | */ 564 | u2f.reqCounter_ = 0; 565 | 566 | /** 567 | * A map from requestIds to client callbacks 568 | * @type {Object.} 570 | * @private 571 | */ 572 | u2f.callbackMap_ = {}; 573 | 574 | /** 575 | * Creates or retrieves the MessagePort singleton to use. 576 | * @param {function((MessagePort|u2f.WrappedChromeRuntimePort_))} callback 577 | * @private 578 | */ 579 | u2f.getPortSingleton_ = function(callback) { 580 | if (u2f.port_) { 581 | callback(u2f.port_); 582 | } else { 583 | if (u2f.waitingForPort_.length == 0) { 584 | u2f.getMessagePort(function(port) { 585 | u2f.port_ = port; 586 | u2f.port_.addEventListener('message', 587 | /** @type {function(Event)} */ (u2f.responseHandler_)); 588 | 589 | // Careful, here be async callbacks. Maybe. 590 | while (u2f.waitingForPort_.length) 591 | u2f.waitingForPort_.shift()(u2f.port_); 592 | }); 593 | } 594 | u2f.waitingForPort_.push(callback); 595 | } 596 | }; 597 | 598 | /** 599 | * Handles response messages from the extension. 600 | * @param {MessageEvent.} message 601 | * @private 602 | */ 603 | u2f.responseHandler_ = function(message) { 604 | var response = message.data; 605 | var reqId = response['requestId']; 606 | if (!reqId || !u2f.callbackMap_[reqId]) { 607 | console.error('Unknown or missing requestId in response.'); 608 | return; 609 | } 610 | var cb = u2f.callbackMap_[reqId]; 611 | delete u2f.callbackMap_[reqId]; 612 | cb(response['responseData']); 613 | }; 614 | 615 | /** 616 | * Dispatches an array of sign requests to available U2F tokens. 617 | * @param {Array} signRequests 618 | * @param {function((u2f.Error|u2f.SignResponse))} callback 619 | * @param {number=} opt_timeoutSeconds 620 | */ 621 | u2f.sign = function(signRequests, callback, opt_timeoutSeconds) { 622 | u2f.getPortSingleton_(function(port) { 623 | var reqId = ++u2f.reqCounter_; 624 | u2f.callbackMap_[reqId] = callback; 625 | var timeoutSeconds = (typeof opt_timeoutSeconds !== 'undefined' ? 626 | opt_timeoutSeconds : u2f.EXTENSION_TIMEOUT_SEC); 627 | var req = port.formatSignRequest_(signRequests, timeoutSeconds, reqId); 628 | port.postMessage(req); 629 | }); 630 | }; 631 | 632 | /** 633 | * Dispatches register requests to available U2F tokens. An array of sign 634 | * requests identifies already registered tokens. 635 | * @param {Array} registerRequests 636 | * @param {Array} signRequests 637 | * @param {function((u2f.Error|u2f.RegisterResponse))} callback 638 | * @param {number=} opt_timeoutSeconds 639 | */ 640 | u2f.register = function(registerRequests, signRequests, 641 | callback, opt_timeoutSeconds) { 642 | u2f.getPortSingleton_(function(port) { 643 | var reqId = ++u2f.reqCounter_; 644 | u2f.callbackMap_[reqId] = callback; 645 | var timeoutSeconds = (typeof opt_timeoutSeconds !== 'undefined' ? 646 | opt_timeoutSeconds : u2f.EXTENSION_TIMEOUT_SEC); 647 | var req = port.formatRegisterRequest_( 648 | signRequests, registerRequests, timeoutSeconds, reqId); 649 | port.postMessage(req); 650 | }); 651 | }; -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "firehed/u2f", 3 | "description": "A library providing U2F authentication", 4 | "license": "MIT", 5 | "keywords": [ 6 | "auth", 7 | "authentication", 8 | "mfa", 9 | "security", 10 | "u2f", 11 | "webauthn", 12 | "web authentication", 13 | "webauthentication", 14 | "yubico", 15 | "yubikey" 16 | ], 17 | "homepage": "https://github.com/Firehed/u2f-php", 18 | "require": { 19 | "php": ">=7.2", 20 | "firehed/cbor": "^0.1" 21 | }, 22 | "require-dev": { 23 | "phpstan/phpstan": "^0.12", 24 | "phpunit/phpunit": "^8.5 || ^9.0", 25 | "squizlabs/php_codesniffer": "^3.2", 26 | "phpstan/phpstan-phpunit": "^0.12" 27 | }, 28 | "autoload": { 29 | "psr-4": { 30 | "Firehed\\U2F\\": "src/" 31 | }, 32 | "files": [ 33 | "src/functions.php" 34 | ] 35 | }, 36 | "autoload-dev": { 37 | "psr-4": { 38 | "Firehed\\U2F\\": "tests/" 39 | } 40 | }, 41 | "authors": [ 42 | { 43 | "name": "Eric Stern", 44 | "email": "eric@ericstern.com" 45 | } 46 | ], 47 | "scripts": { 48 | "test": [ 49 | "@phpunit", 50 | "@phpstan", 51 | "@phpcs" 52 | ], 53 | "coverage": "phpunit --coverage-html build; open build/index.html", 54 | "autofix": "phpcbf src lib tests db", 55 | "phpunit": "phpunit", 56 | "phpstan": "phpstan analyse --no-progress", 57 | "phpcs": "phpcs" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /phpcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | src 4 | tests 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /phpstan-baseline.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | ignoreErrors: 3 | - 4 | message: "#^Property Firehed\\\\U2F\\\\ClientData\\:\\:\\$cid_pubkey is never read, only written\\.$#" 5 | count: 1 6 | path: src/ClientData.php 7 | 8 | - 9 | message: "#^Property Firehed\\\\U2F\\\\ClientData\\:\\:\\$typ is never read, only written\\.$#" 10 | count: 1 11 | path: src/ClientData.php 12 | 13 | - 14 | message: "#^Cannot access offset 1 on array\\|false\\.$#" 15 | count: 2 16 | path: src/WebAuthn/AuthenticatorData.php 17 | 18 | - 19 | message: "#^Property Firehed\\\\U2F\\\\WebAuthn\\\\AuthenticatorData\\:\\:\\$extensions is unused\\.$#" 20 | count: 1 21 | path: src/WebAuthn/AuthenticatorData.php 22 | 23 | - 24 | message: "#^Offset \\-2 does not exist on array\\(1 \\=\\> int, \\-1 \\=\\> int, \\?\\-2 \\=\\> string, \\?\\-3 \\=\\> string, \\?\\-4 \\=\\> string, 3 \\=\\> \\-7\\)\\.$#" 25 | count: 1 26 | path: src/WebAuthn/RegistrationResponse.php 27 | 28 | - 29 | message: "#^Offset \\-3 does not exist on array\\(1 \\=\\> int, \\-1 \\=\\> int, \\?\\-2 \\=\\> string, \\?\\-3 \\=\\> string, \\?\\-4 \\=\\> string, 3 \\=\\> \\-7\\)\\.$#" 30 | count: 1 31 | path: src/WebAuthn/RegistrationResponse.php 32 | 33 | - 34 | message: "#^Offset 3 does not exist on array\\(1 \\=\\> int, \\?3 \\=\\> int, \\-1 \\=\\> int, \\?\\-2 \\=\\> string, \\?\\-3 \\=\\> string, \\?\\-4 \\=\\> string\\)\\.$#" 35 | count: 1 36 | path: src/WebAuthn/RegistrationResponse.php 37 | 38 | - 39 | message: "#^Method Firehed\\\\U2F\\\\FunctionsTest\\:\\:vectors\\(\\) should return array\\ but returns array\\(array\\('', ''\\), array\\('f', 'Zg'\\), array\\('fo', 'Zm8'\\), array\\('foo', 'Zm9v'\\), array\\('foob', 'Zm9vYg'\\), array\\('fooba', 'Zm9vYmE'\\), array\\('foobar', 'Zm9vYmFy'\\), array\\(string\\|false, 'AA_BB\\-cc'\\)\\)\\.$#" 40 | count: 1 41 | path: tests/FunctionsTest.php 42 | 43 | - 44 | message: "#^Call to an undefined static method object\\:\\:fromJson\\(\\)\\.$#" 45 | count: 4 46 | path: tests/ResponseTraitTest.php 47 | 48 | - 49 | message: "#^Method class@anonymous/tests/ResponseTraitTest\\.php\\:16\\:\\:parseResponse\\(\\) has parameter \\$response with no value type specified in iterable type array\\.$#" 50 | count: 1 51 | path: tests/ResponseTraitTest.php 52 | 53 | - 54 | message: "#^Offset \\-2 does not exist on array\\(1 \\=\\> int, \\-1 \\=\\> 1, 3 \\=\\> \\-7, \\?\\-2 \\=\\> string, \\?\\-3 \\=\\> string, \\?\\-4 \\=\\> string\\)\\.$#" 55 | count: 1 56 | path: tests/WebAuthn/AuthenticatorDataTest.php 57 | 58 | - 59 | message: "#^Offset \\-3 does not exist on array\\(1 \\=\\> int, \\-1 \\=\\> 1, 3 \\=\\> \\-7, \\?\\-3 \\=\\> string, \\?\\-4 \\=\\> string, \\-2 \\=\\> string\\)\\.$#" 60 | count: 1 61 | path: tests/WebAuthn/AuthenticatorDataTest.php 62 | 63 | - 64 | message: "#^Offset 3 does not exist on array\\(1 \\=\\> int, \\?3 \\=\\> int, \\-1 \\=\\> int, \\?\\-2 \\=\\> string, \\?\\-3 \\=\\> string, \\?\\-4 \\=\\> string\\)\\.$#" 65 | count: 1 66 | path: tests/WebAuthn/AuthenticatorDataTest.php 67 | 68 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | includes: 2 | - phpstan-baseline.neon 3 | - vendor/phpstan/phpstan-phpunit/extension.neon 4 | - vendor/phpstan/phpstan/conf/bleedingEdge.neon 5 | parameters: 6 | level: max 7 | paths: 8 | - src 9 | - tests 10 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 10 | 11 | 12 | tests 13 | 14 | 15 | 16 | 17 | 18 | src 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /src/AppIdTrait.php: -------------------------------------------------------------------------------- 1 | appId; 14 | } 15 | 16 | public function setAppId(string $appId): self 17 | { 18 | $this->appId = $appId; 19 | return $this; 20 | } 21 | 22 | /** 23 | * @return string The raw SHA-256 hash of the App ID 24 | */ 25 | public function getApplicationParameter(): string 26 | { 27 | return hash('sha256', $this->appId, true); 28 | } 29 | 30 | /** 31 | * @return string The raw SHA-256 hash of the Relying Party ID 32 | */ 33 | public function getRpIdHash(): string 34 | { 35 | return hash('sha256', $this->appId, true); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/AttestationCertificate.php: -------------------------------------------------------------------------------- 1 | binary = $binary; 14 | } 15 | 16 | public function getBinary(): string 17 | { 18 | return $this->binary; 19 | } 20 | 21 | public function getPemFormatted(): string 22 | { 23 | $data = base64_encode($this->binary); 24 | $pem = "-----BEGIN CERTIFICATE-----\r\n"; 25 | $pem .= chunk_split($data, 64); 26 | $pem .= "-----END CERTIFICATE-----"; 27 | return $pem; 28 | } 29 | 30 | /** @return array{binary: string} */ 31 | public function __debugInfo(): array 32 | { 33 | return ['binary' => '0x' . bin2hex($this->binary)]; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/AttestationCertificateInterface.php: -------------------------------------------------------------------------------- 1 | challenge = $challenge; 17 | } 18 | 19 | public function getChallenge(): string 20 | { 21 | return $this->challenge; 22 | } 23 | 24 | public function jsonSerialize(): string 25 | { 26 | return $this->challenge; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/ChallengeProvider.php: -------------------------------------------------------------------------------- 1 | challenge; 14 | } 15 | 16 | public function setChallenge(string $challenge): self 17 | { 18 | // TODO: make immutable 19 | $this->challenge = $challenge; 20 | return $this; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/ClientData.php: -------------------------------------------------------------------------------- 1 | setType($ret->validateKey('typ', $data)); 32 | $ret->setChallenge($ret->validateKey('challenge', $data)); 33 | $ret->origin = $ret->validateKey('origin', $data); 34 | // This field is optional 35 | if (isset($data['cid_pubkey'])) { 36 | $ret->cid_pubkey = $data['cid_pubkey']; 37 | } 38 | $ret->originalJson = $json; 39 | return $ret; 40 | } 41 | 42 | public function getApplicationParameter(): string 43 | { 44 | return hash('sha256', $this->origin, true); 45 | } 46 | 47 | /** 48 | * Checks the 'typ' field against the allowed types in the U2F spec (sec. 49 | * 7.1) 50 | * @param string $type the 'typ' value 51 | * @return $this 52 | * @throws InvalidDataException if a non-conforming value is provided 53 | */ 54 | private function setType(string $type): self 55 | { 56 | switch ($type) { 57 | case 'navigator.id.getAssertion': // fall through 58 | case 'navigator.id.finishEnrollment': 59 | break; 60 | default: 61 | throw new IDE(IDE::MALFORMED_DATA, 'typ'); 62 | } 63 | $this->typ = $type; 64 | return $this; 65 | } 66 | 67 | /** 68 | * Checks for the presence of $key in $data. Returns the value if found, 69 | * throws an InvalidDataException if missing 70 | * @param string $key The array key to check 71 | * @param array $data The array to check in 72 | * @return string The data, if present 73 | * @throws InvalidDataException if not prsent 74 | */ 75 | private function validateKey(string $key, array $data): string 76 | { 77 | if (!array_key_exists($key, $data)) { 78 | throw new IDE(IDE::MISSING_KEY, $key); 79 | } 80 | return $data[$key]; 81 | } 82 | 83 | // Returns the SHA256 hash of this object per the raw message formats spec 84 | public function getChallengeParameter(): string 85 | { 86 | return hash('sha256', $this->originalJson, true); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/ClientError.php: -------------------------------------------------------------------------------- 1 | 'Success. Not used in errors but reserved', 16 | self::OTHER_ERROR => 'An error otherwise not enumerated here', 17 | self::BAD_REQUEST => 'The request cannot be processed', 18 | self::CONFIGURATION_UNSUPPORTED => 'Client configuration is not supported', 19 | self::DEVICE_INELIGIBLE => 'The presented device is not eligible for this request', 20 | self::TIMEOUT => 'Timeout reached before request could be satisfied', 21 | ]; 22 | } 23 | -------------------------------------------------------------------------------- /src/ClientErrorException.php: -------------------------------------------------------------------------------- 1 | binary = $key; 27 | } 28 | 29 | /** 30 | * @return string The decoded public key. 31 | */ 32 | public function getBinary(): string 33 | { 34 | return $this->binary; 35 | } 36 | 37 | // Prepends the pubkey format headers and builds a pem file from the raw 38 | // public key component 39 | public function getPemFormatted(): string 40 | { 41 | // Described in RFC 5480 42 | // Just use an OID calculator to figure out *that* encoding 43 | $der = hex2bin( 44 | '3059' // SEQUENCE, length 89 45 | .'3013' // SEQUENCE, length 19 46 | .'0607' // OID, length 7 47 | .'2a8648ce3d0201' // 1.2.840.10045.2.1 = EC Public Key 48 | .'0608' // OID, length 8 49 | .'2a8648ce3d030107' // 1.2.840.10045.3.1.7 = P-256 Curve 50 | .'0342' // BIT STRING, length 66 51 | .'00' // prepend with NUL - pubkey will follow 52 | ); 53 | $der .= $this->binary; 54 | 55 | $pem = "-----BEGIN PUBLIC KEY-----\r\n"; 56 | $pem .= chunk_split(base64_encode($der), 64); 57 | $pem .= "-----END PUBLIC KEY-----"; 58 | return $pem; 59 | } 60 | 61 | /** @return array{x: string, y: string} */ 62 | public function __debugInfo(): array 63 | { 64 | return [ 65 | 'x' => '0x' . bin2hex(substr($this->binary, 1, 32)), 66 | 'y' => '0x' . bin2hex(substr($this->binary, 33)), 67 | ]; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/InvalidDataException.php: -------------------------------------------------------------------------------- 1 | 'Missing key %s', 16 | self::MALFORMED_DATA => 'Invalid data found in %s', 17 | self::PUBLIC_KEY_LENGTH => 'Public key length invalid, must be %s bytes', 18 | ]; 19 | 20 | public function __construct(int $code, string ...$args) 21 | { 22 | $format = self::MESSAGES[$code] ?? 'Default message'; 23 | 24 | $message = sprintf($format, ...$args); 25 | parent::__construct($message, $code); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/KeyHandleInterface.php: -------------------------------------------------------------------------------- 1 | keyHandle; 14 | } 15 | // B64-websafe value 16 | public function getKeyHandleWeb(): string 17 | { 18 | return toBase64Web($this->getKeyHandleBinary()); 19 | } 20 | // Binary value 21 | public function setKeyHandle(string $keyHandle): self 22 | { 23 | // TODO: make immutable 24 | $this->keyHandle = $keyHandle; 25 | return $this; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/LoginResponseInterface.php: -------------------------------------------------------------------------------- 1 | $this->version, 24 | "challenge" => $this->getChallenge(), 25 | "appId" => $this->getAppId(), 26 | ]; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/RegisterResponse.php: -------------------------------------------------------------------------------- 1 | validateKeyInArray('registrationData', $response); 30 | // Binary string as defined by 31 | // U2F 1.0 Raw Message Format Sec. 4.3 32 | // https://fidoalliance.org/specs/fido-u2f-v1.0-nfc-bt-amendment-20150514/fido-u2f-raw-message-formats.html#registration-response-message-success 33 | $regData = fromBase64Web($response['registrationData']); 34 | 35 | // Basic fixed length check 36 | if (strlen($regData) < 67) { 37 | throw new IDE( 38 | IDE::MALFORMED_DATA, 39 | 'registrationData is missing information' 40 | ); 41 | } 42 | 43 | $offset = 0; // Number of bytes read so far (think fread/fseek) 44 | 45 | $reserved = ord($regData[$offset]); 46 | if ($reserved !== 5) { 47 | throw new IDE( 48 | IDE::MALFORMED_DATA, 49 | 'reserved byte' 50 | ); 51 | } 52 | $offset += 1; 53 | 54 | $this->pubKey = new ECPublicKey(substr($regData, $offset, 65)); 55 | $offset += 65; 56 | 57 | $keyHandleLength = ord($regData[$offset]); 58 | $offset += 1; 59 | 60 | // Dynamic length check through key handle 61 | if (strlen($regData) < $offset+$keyHandleLength) { 62 | throw new IDE( 63 | IDE::MALFORMED_DATA, 64 | 'key handle length' 65 | ); 66 | } 67 | $this->setKeyHandle(substr($regData, $offset, $keyHandleLength)); 68 | $offset += $keyHandleLength; 69 | 70 | // (Notes are 0-indexed) 71 | // If byte 0 & 0x1F = 0x10, it's a sequence where the next byte 72 | // determines length (if not, this is not the start of a certificate) 73 | // 74 | // If the length byte (byte 1) & 0x80 = 0x80, then the following 75 | // (byte 1 ^ 0x80) bytes are the remaining length of the sequence. If 76 | // not, then the legnth byte alone is correct. I.e. > 128 low 7 bits 77 | // are the byte count for length; <=127 then it is the length. 78 | // 79 | // https://msdn.microsoft.com/en-us/library/bb648645(v=vs.85).aspx 80 | $remain = substr($regData, $offset); 81 | $b0 = ord($remain[0]); 82 | if (($b0 & 0x1F) != 0x10) { 83 | throw new IDE( 84 | IDE::MALFORMED_DATA, 85 | 'starting byte of attestation certificate' 86 | ); 87 | } 88 | $length = ord($remain[1]); 89 | if (($length & 0x80) == 0x80) { 90 | $needed = $length ^ 0x80; 91 | if ($needed > 4) { 92 | // This would be a >4GB cert, reject it out of hand 93 | throw new IDE( 94 | IDE::MALFORMED_DATA, 95 | 'certificate length' 96 | ); 97 | } 98 | $bytes = 0; 99 | // Start 2 bytes in, for SEQUENCE and its LENGTH 100 | for ($i = 2; $i < $needed+2; $i++) { 101 | $bytes <<= 8; // shift running total left 8 bytes 102 | $byte = ord($remain[$i]); // grab next byte 103 | $bytes |= $byte; // OR in that byte 104 | } 105 | $length = $bytes + $needed + 2; 106 | } 107 | // Sanity check the length against the remainder of the registration 108 | // data, in case a malformed cert was provided to trigger an overflow 109 | // during parsing 110 | if ($length + $offset > strlen($regData)) { 111 | throw new IDE( 112 | IDE::MALFORMED_DATA, 113 | 'certificate and sigature length' 114 | ); 115 | } 116 | $cert = new AttestationCertificate(substr($regData, $offset, $length)); 117 | $this->cert = $cert; 118 | $offset += $length; 119 | 120 | // All remaining data is the signature 121 | $this->setSignature(substr($regData, $offset)); 122 | 123 | return $this; 124 | } 125 | 126 | public function getSignedData(): string 127 | { 128 | // https://fidoalliance.org/specs/fido-u2f-v1.0-nfc-bt-amendment-20150514/fido-u2f-raw-message-formats.html#fig-authentication-request-message 129 | return sprintf( 130 | '%s%s%s%s%s', 131 | chr(0), 132 | $this->clientData->getApplicationParameter(), 133 | $this->clientData->getChallengeParameter(), 134 | $this->getKeyHandleBinary(), 135 | $this->pubKey->getBinary() 136 | ); 137 | } 138 | 139 | public function getRpIdHash(): string 140 | { 141 | return $this->clientData->getApplicationParameter(); 142 | } 143 | 144 | public function getAttestationCertificate(): AttestationCertificateInterface 145 | { 146 | return $this->cert; 147 | } 148 | 149 | public function getChallenge(): string 150 | { 151 | return $this->clientData->getChallenge(); 152 | } 153 | 154 | public function getPublicKey(): PublicKeyInterface 155 | { 156 | return $this->pubKey; 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /src/Registration.php: -------------------------------------------------------------------------------- 1 | cert; 24 | } 25 | 26 | public function getCounter(): int 27 | { 28 | return $this->counter; 29 | } 30 | 31 | public function setAttestationCertificate(AttestationCertificateInterface $cert): self 32 | { 33 | $this->cert = $cert; 34 | return $this; 35 | } 36 | 37 | public function setCounter(int $counter): self 38 | { 39 | if ($counter < 0) { 40 | throw new OutOfBoundsException('Counter may not be negative'); 41 | } 42 | $this->counter = $counter; 43 | return $this; 44 | } 45 | 46 | public function getPublicKey(): PublicKeyInterface 47 | { 48 | return $this->publicKey; 49 | } 50 | 51 | public function setPublicKey(PublicKeyInterface $publicKey): self 52 | { 53 | $this->publicKey = $publicKey; 54 | return $this; 55 | } 56 | 57 | /** 58 | * @return array{ 59 | * cert: AttestationCertificateInterface, 60 | * counter: int, 61 | * publicKey: PublicKeyInterface, 62 | * keyHandle: string, 63 | * } 64 | */ 65 | public function __debugInfo(): array 66 | { 67 | $hex = function (string $binary): string { 68 | return '0x' . bin2hex($binary); 69 | }; 70 | 71 | return [ 72 | 'cert' => $this->cert, 73 | 'counter' => $this->counter, 74 | 'publicKey' => $this->publicKey, 75 | 'keyHandle' => $hex($this->keyHandle), 76 | ]; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/RegistrationInterface.php: -------------------------------------------------------------------------------- 1 | signature; 21 | } 22 | 23 | protected function setSignature(string $signature): self 24 | { 25 | $this->signature = $signature; 26 | return $this; 27 | } 28 | 29 | public static function fromJson(string $json): self 30 | { 31 | $data = json_decode($json, true); 32 | if (json_last_error() !== \JSON_ERROR_NONE) { 33 | throw new IDE(IDE::MALFORMED_DATA, 'JSON'); 34 | } 35 | if (isset($data['errorCode'])) { 36 | throw new ClientErrorException($data['errorCode']); 37 | } 38 | 39 | $ret = new self; 40 | $ret->validateKeyInArray('clientData', $data); 41 | $ret->clientData = ClientData::fromJson(fromBase64Web($data['clientData'])); 42 | return $ret->parseResponse($data); 43 | } 44 | 45 | abstract protected function parseResponse(array $response): self; 46 | 47 | /** @param array $data */ 48 | private function validateKeyInArray(string $key, array $data): bool 49 | { 50 | if (!isset($data[$key])) { 51 | throw new IDE(IDE::MISSING_KEY, $key); 52 | } 53 | if (!is_string($data[$key])) { 54 | throw new IDE(IDE::MALFORMED_DATA, $key); 55 | } 56 | return true; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/SecurityException.php: -------------------------------------------------------------------------------- 1 | 'Signature verification failed', 19 | self::COUNTER_USED => 20 | 'Response counter value is too low, indicating a possible replay '. 21 | 'attack or cloned token. It is also possible but unlikely that '. 22 | 'the token\'s internal counter wrapped around. This token should '. 23 | 'be invalidated or flagged for review.', 24 | self::CHALLENGE_MISMATCH => 'Response challenge does not match request', 25 | self::KEY_HANDLE_UNRECOGNIZED => 'Key handle has not been registered', 26 | self::NO_TRUSTED_CA => 'The attestation certificate was not signed by any trusted Certificate Authority', 27 | self::WRONG_RELYING_PARTY => 'Relying party invalid for this server', 28 | ]; 29 | 30 | public function __construct(int $code) 31 | { 32 | parent::__construct(self::MESSAGES[$code] ?? '', $code); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Server.php: -------------------------------------------------------------------------------- 1 | appId = $appId; 70 | $overload = ini_get('mbstring.func_overload'); 71 | // @codeCoverageIgnoreStart 72 | if ($overload > 0) { 73 | throw new RuntimeException( 74 | 'The deprecated "mbstring.func_overload" directive must be disabled' 75 | ); 76 | } 77 | // @codeCoverageIgnoreEnd 78 | } 79 | 80 | /** 81 | * This method authenticates a `LoginResponseInterface` against outstanding 82 | * registrations and a known challenge. If the response's signature 83 | * validates and the counter hasn't done anything strange, the registration 84 | * will be returned with an updated counter value, which *must* be 85 | * persisted for the next authentication. If any verification component 86 | * fails, a `SE` will be thrown. 87 | * 88 | * @param RegistrationInterface[] $registrations 89 | * @return RegistrationInterface if authentication succeeds 90 | * @throws SE if authentication fails 91 | * @throws BadMethodCallException if a precondition is not met 92 | */ 93 | public function validateLogin( 94 | ChallengeProviderInterface $challenge, 95 | LoginResponseInterface $response, 96 | array $registrations 97 | ): RegistrationInterface { 98 | // Search for the registration to use based on the Key Handle 99 | $registration = $this->findObjectWithKeyHandle( 100 | $registrations, 101 | $response->getKeyHandleBinary() 102 | ); 103 | if ($registration === null) { 104 | // This would suggest either some sort of forgery attempt or 105 | // a hilariously-broken token responding to handles it doesn't 106 | // support and not returning a DEVICE_INELIGIBLE client error. 107 | throw new SE(SE::KEY_HANDLE_UNRECOGNIZED); 108 | } 109 | 110 | // If the challenge in the (signed) response ClientData doesn't 111 | // match the one in the signing request, the client signed the 112 | // wrong thing. This could possibly be an attempt at a replay 113 | // attack. 114 | $this->validateChallenge($challenge, $response); 115 | 116 | $pem = $registration->getPublicKey()->getPemFormatted(); 117 | 118 | $toVerify = $response->getSignedData(); 119 | 120 | // Signature must validate against 121 | $sig_check = openssl_verify( 122 | $toVerify, 123 | $response->getSignature(), 124 | $pem, 125 | \OPENSSL_ALGO_SHA256 126 | ); 127 | if ($sig_check !== 1) { 128 | // We could not validate the signature using the 129 | // previously-verified public key on file for this registration. 130 | // This is most likely malicious, since there's either 131 | // a non-spec-compliant device or the device doesn't have access to 132 | // the embedded private key that was used to sign the original 133 | // registration request. 134 | throw new SE(SE::SIGNATURE_INVALID); 135 | } 136 | if ($response->getCounter() <= $registration->getCounter()) { 137 | // Tokens are required to keep a counter of authentications 138 | // performed, and this value is included in the signed response. 139 | // Entering this block means one of two things: 140 | // 1) The device counter rolled over its integer limit (very, very 141 | // unlikely), or 142 | // 2) A message was compromised and this is an attempt at a replay 143 | // attack, or 144 | // 3) The private key on the device was somehow compromised, but 145 | // the counter is unknown to the attacker. 146 | // 3a) The attacker started low and we caught them, or 147 | // 3b) The attacker started high, got in, updated the counter on 148 | // file, and the device owner just tried to reauthenticate 149 | // In either case, this indicates the device was somehow 150 | // compromised. The user should be alerted and the device should 151 | // no longer be trusted for authentication. However, the 152 | // registration assicated with the device should only be 153 | // disabled and not deleted, since it should not be allowed to 154 | // be re-added to the user's account. 155 | throw new SE(SE::COUNTER_USED); 156 | } 157 | // It's reasonable to check that the gap between these values is 158 | // relatively small, to handle the case where an attacker is able to 159 | // compromise a token's private key and performs a single 160 | // authentication with an arbitrarily-high counter to avoid this 161 | // rollback detection. Such a scenario would still trigger the above 162 | // error when the legitimate token-holder attempts to use their token 163 | // again. There's no perfect way to handle this since 164 | 165 | return (new Registration()) 166 | ->setAttestationCertificate($registration->getAttestationCertificate()) 167 | ->setKeyHandle($registration->getKeyHandleBinary()) 168 | ->setPublicKey($registration->getPublicKey()) 169 | ->setCounter($response->getCounter()); 170 | } 171 | 172 | /** 173 | * @deprecated This is being replaced by validateLogin 174 | * 175 | * This method authenticates a `LoginResponseInterface` against outstanding 176 | * registrations and their corresponding `SignRequest`s. If the response's 177 | * signature validates and the counter hasn't done anything strange, the 178 | * registration will be returned with an updated counter value, which *must* 179 | * be persisted for the next authentication. If any verification component 180 | * fails, a `SE` will be thrown. 181 | * 182 | * @param LoginResponseInterface $response the parsed response from the user 183 | * @return RegistrationInterface if authentication succeeds 184 | * @throws SE if authentication fails 185 | * @throws BadMethodCallException if a precondition is not met 186 | */ 187 | public function authenticate(LoginResponseInterface $response): RegistrationInterface 188 | { 189 | if (!$this->registrations) { 190 | throw new BadMethodCallException( 191 | 'Before calling authenticate(), provide objects implementing'. 192 | 'RegistrationInterface with setRegistrations()' 193 | ); 194 | } 195 | if (!$this->signRequests) { 196 | throw new BadMethodCallException( 197 | 'Before calling authenticate(), provide `SignRequest`s with '. 198 | 'setSignRequests()' 199 | ); 200 | } 201 | 202 | // Search for the Signing Request to use based on the Key Handle 203 | $request = $this->findObjectWithKeyHandle( 204 | $this->signRequests, 205 | $response->getKeyHandleBinary() 206 | ); 207 | if (!$request) { 208 | // Similar to above, there is a bizarre mismatch between the known 209 | // possible sign requests and the key handle determined above. This 210 | // would probably be caused by a logic error causing bogus sign 211 | // requests to be passed to this method. 212 | throw new SE(SE::KEY_HANDLE_UNRECOGNIZED); 213 | } 214 | 215 | return $this->validateLogin($request, $response, $this->registrations); 216 | } 217 | 218 | /** 219 | * This method authenticates a RegistrationResponseInterface against its 220 | * corresponding RegisterRequest by verifying the certificate and signature. 221 | * If valid, it returns a registration; if not, a SE will be thrown and 222 | * attempt to register the key must be aborted. 223 | * 224 | * @param RegistrationResponseInterface $response The response to verify 225 | * @return RegistrationInterface if the response is proven authentic 226 | * @throws SE if the response cannot be proven authentic 227 | * @throws BadMethodCallException if a precondition is not met 228 | */ 229 | public function validateRegistration( 230 | ChallengeProviderInterface $request, 231 | RegistrationResponseInterface $response 232 | ): RegistrationInterface { 233 | $this->validateChallenge($request, $response); 234 | // Check the Application Parameter 235 | $this->validateRelyingParty($response->getRpIdHash()); 236 | 237 | if ($this->verifyCA) { 238 | $this->verifyAttestationCertAgainstTrustedCAs($response); 239 | } 240 | 241 | // Signature must validate against device issuer's public key 242 | $pem = $response->getAttestationCertificate()->getPemFormatted(); 243 | $sig_check = openssl_verify( 244 | $response->getSignedData(), 245 | $response->getSignature(), 246 | $pem, 247 | \OPENSSL_ALGO_SHA256 248 | ); 249 | if ($sig_check !== 1) { 250 | throw new SE(SE::SIGNATURE_INVALID); 251 | } 252 | 253 | return (new Registration()) 254 | ->setAttestationCertificate($response->getAttestationCertificate()) 255 | ->setCounter(0) // The response does not include this 256 | ->setKeyHandle($response->getKeyHandleBinary()) 257 | ->setPublicKey($response->getPublicKey()); 258 | } 259 | 260 | /** 261 | * @deprecated This is being replaced with validateRegistration() 262 | * 263 | * This method authenticates a RegistrationResponseInterface against its 264 | * corresponding RegisterRequest by verifying the certificate and signature. 265 | * If valid, it returns a registration; if not, a SE will be thrown and 266 | * attempt to register the key must be aborted. 267 | * 268 | * @param RegistrationResponseInterface $response The response to verify 269 | * @return RegistrationInterface if the response is proven authentic 270 | * @throws SE if the response cannot be proven authentic 271 | * @throws BadMethodCallException if a precondition is not met 272 | */ 273 | public function register(RegistrationResponseInterface $response): RegistrationInterface 274 | { 275 | if ($this->registerRequest === null) { 276 | throw new BadMethodCallException( 277 | 'Before calling register(), provide a RegisterRequest '. 278 | 'with setRegisterRequest()' 279 | ); 280 | } 281 | return $this->validateRegistration($this->registerRequest, $response); 282 | } 283 | 284 | /** 285 | * Disables verification of the Attestation Certificate against the list of 286 | * CA certificates. This lowers overall security, at the benefit of being 287 | * able to use devices that haven't been explicitly whitelisted. 288 | * 289 | * This method or setTrustedCAs() must be called before register() or 290 | * a SecurityException will always be thrown. 291 | * 292 | * @return self 293 | */ 294 | public function disableCAVerification(): self 295 | { 296 | $this->verifyCA = false; 297 | return $this; 298 | } 299 | 300 | /** 301 | * Provides a list of CA certificates for device issuer verification during 302 | * registration. 303 | * 304 | * This method or disableCAVerification must be called before register() or 305 | * a SecurityException will always be thrown. 306 | * 307 | * @param string[] $CAs A list of file paths to device issuer CA certs 308 | * @return self 309 | */ 310 | public function setTrustedCAs(array $CAs): self 311 | { 312 | $this->verifyCA = true; 313 | $this->trustedCAs = $CAs; 314 | return $this; 315 | } 316 | 317 | /** 318 | * @deprecated 319 | * 320 | * Provide the previously-generated RegisterRequest to be used when 321 | * verifying a RegisterResponse during register() 322 | * 323 | * @param RegisterRequest $request 324 | * @return self 325 | */ 326 | public function setRegisterRequest(RegisterRequest $request): self 327 | { 328 | $this->registerRequest = $request; 329 | return $this; 330 | } 331 | 332 | /** 333 | * @deprecated 334 | * 335 | * Provide a user's existing registration to be used during 336 | * authentication 337 | * 338 | * @param RegistrationInterface[] $registrations 339 | * @return self 340 | */ 341 | public function setRegistrations(array $registrations): self 342 | { 343 | array_map(function (RegistrationInterface $r) { 344 | }, $registrations); // type check 345 | $this->registrations = $registrations; 346 | return $this; 347 | } 348 | 349 | /** 350 | * @deprecated 351 | * 352 | * Provide the previously-generated SignRequests, corresponing to the 353 | * existing Registrations, of of which should be signed and will be 354 | * verified during authenticate() 355 | * 356 | * @param SignRequest[] $signRequests 357 | * @return self 358 | */ 359 | public function setSignRequests(array $signRequests): self 360 | { 361 | array_map(function (SignRequest $s) { 362 | }, $signRequests); // type check 363 | $this->signRequests = $signRequests; 364 | return $this; 365 | } 366 | 367 | /** 368 | * Creates a new RegisterRequest to be sent to the authenticated user to be 369 | * used by the `u2f.register` API. 370 | * 371 | * @return RegisterRequest 372 | */ 373 | public function generateRegisterRequest(): RegisterRequest 374 | { 375 | return (new RegisterRequest()) 376 | ->setAppId($this->getAppId()) 377 | ->setChallenge($this->generateChallenge()->getChallenge()); 378 | } 379 | 380 | /** 381 | * Creates a new SignRequest for an existing registration for an 382 | * authenticating user, used by the `u2f.sign` API. 383 | * 384 | * @param RegistrationInterface $reg one of the user's existing Registrations 385 | * @return SignRequest 386 | */ 387 | public function generateSignRequest(RegistrationInterface $reg): SignRequest 388 | { 389 | return (new SignRequest()) 390 | ->setAppId($this->getAppId()) 391 | ->setChallenge($this->generateChallenge()->getChallenge()) 392 | ->setKeyHandle($reg->getKeyHandleBinary()); 393 | } 394 | 395 | /** 396 | * Wraps generateSignRequest for multiple registrations. Using this API 397 | * ensures that all sign requests share a single challenge, which greatly 398 | * simplifies compatibility with WebAuthn 399 | * 400 | * @param RegistrationInterface[] $registrations 401 | * @return SignRequest[] 402 | */ 403 | public function generateSignRequests(array $registrations): array 404 | { 405 | $challenge = $this->generateChallenge()->getChallenge(); 406 | $requests = array_map([$this, 'generateSignRequest'], $registrations); 407 | $requestsWithSameChallenge = array_map(function (SignRequest $req) use ($challenge) { 408 | return $req->setChallenge($challenge); 409 | }, $requests); 410 | return array_values($requestsWithSameChallenge); 411 | } 412 | 413 | /** 414 | * @deprecated 415 | * 416 | * Re-implements the trait's version solely for deprecation warnings 417 | */ 418 | public function setAppId(string $appId): self 419 | { 420 | $this->appId = $appId; 421 | return $this; 422 | } 423 | 424 | /** 425 | * Searches through the provided array of objects, and looks for a matching 426 | * key handle value. If one is found, it is returned; if not, this returns 427 | * null. 428 | * 429 | * @template T of KeyHandleInterface 430 | * 431 | * @param T[] $objects haystack to search 432 | * @param string $keyHandle key handle to find in haystack 433 | * 434 | * @return ?T element from haystack if match found, otherwise null 435 | */ 436 | private function findObjectWithKeyHandle( 437 | array $objects, 438 | string $keyHandle 439 | ) { 440 | foreach ($objects as $object) { 441 | if (hash_equals($object->getKeyHandleBinary(), $keyHandle)) { 442 | return $object; 443 | } 444 | } 445 | return null; 446 | } 447 | 448 | /** 449 | * Generates a random challenge and returns it base64-web-encoded 450 | */ 451 | public function generateChallenge(): ChallengeProviderInterface 452 | { 453 | // FIDO Alliance spec suggests a minimum of 8 random bytes 454 | return new Challenge(toBase64Web(\random_bytes(16))); 455 | } 456 | 457 | private function validateRelyingParty(string $rpIdHash): void 458 | { 459 | // Note: this is a bit delicate at the moment, since different 460 | // protocols have different rules around the handling of Relying Party 461 | // verification. Expect this to be revised. 462 | if (!hash_equals($this->getRpIdHash(), $rpIdHash)) { 463 | throw new SE(SE::WRONG_RELYING_PARTY); 464 | } 465 | } 466 | /** 467 | * Compares the Challenge value from a known source against the 468 | * user-provided value. A mismatch will throw a SE. Future 469 | * versions may also enforce a timing window. 470 | * 471 | * @param ChallengeProviderInterface $from source of known challenge 472 | * @param ChallengeProviderInterface $to user-provided value 473 | * @throws SE on failure 474 | */ 475 | private function validateChallenge(ChallengeProviderInterface $from, ChallengeProviderInterface $to): void 476 | { 477 | // Note: strictly speaking, this shouldn't even be targetable as 478 | // a timing attack. However, this opts to be proactive, and also 479 | // ensures that no weird PHP-isms in string handling cause mismatched 480 | // values to validate. 481 | if (!hash_equals($from->getChallenge(), $to->getChallenge())) { 482 | throw new SE(SE::CHALLENGE_MISMATCH); 483 | } 484 | } 485 | 486 | /** 487 | * Asserts that the attestation cert provided by the registration is issued 488 | * by the set of trusted CAs. 489 | * 490 | * @param RegistrationResponseInterface $response The response to validate 491 | * @throws SecurityException upon failure 492 | * @return void 493 | */ 494 | private function verifyAttestationCertAgainstTrustedCAs(RegistrationResponseInterface $response): void 495 | { 496 | $pem = $response->getAttestationCertificate()->getPemFormatted(); 497 | 498 | $result = openssl_x509_checkpurpose( 499 | $pem, 500 | \X509_PURPOSE_ANY, 501 | $this->trustedCAs 502 | ); 503 | if ($result !== true) { 504 | throw new SE(SE::NO_TRUSTED_CA); 505 | } 506 | } 507 | } 508 | -------------------------------------------------------------------------------- /src/SignRequest.php: -------------------------------------------------------------------------------- 1 | $this->version, 26 | "challenge" => $this->getChallenge(), 27 | "keyHandle" => $this->getKeyHandleWeb(), 28 | "appId" => $this->getAppId(), 29 | ]; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/SignResponse.php: -------------------------------------------------------------------------------- 1 | counter; 22 | } 23 | 24 | public function getUserPresenceByte(): int 25 | { 26 | return $this->user_presence; 27 | } 28 | 29 | public function getSignedData(): string 30 | { 31 | // U2F Spec: 32 | // https://fidoalliance.org/specs/fido-u2f-v1.0-nfc-bt-amendment-20150514/fido-u2f-raw-message-formats.html#authentication-response-message-success 33 | return sprintf( 34 | '%s%s%s%s', 35 | $this->clientData->getApplicationParameter(), 36 | chr($this->getUserPresenceByte()), 37 | pack('N', $this->getCounter()), 38 | // Note: Spec says this should be from the request, but that's not 39 | // actually available via the JS API. Because we assert the 40 | // challenge *value* from the Client Data matches the trusted one 41 | // from the SignRequest and that value is included in the Challenge 42 | // Parameter, this is safe unless/until SHA-256 is broken. 43 | $this->clientData->getChallengeParameter() 44 | ); 45 | } 46 | 47 | /** 48 | * @param array{ 49 | * keyHandle: string, 50 | * clientData: string, 51 | * signatureData: string, 52 | * } $response 53 | */ 54 | protected function parseResponse(array $response): self 55 | { 56 | $this->validateKeyInArray('keyHandle', $response); 57 | $this->setKeyHandle(fromBase64Web($response['keyHandle'])); 58 | 59 | $this->validateKeyInArray('signatureData', $response); 60 | // Binary string as defined by 61 | // U2F 1.0 Raw Message Format Sec. 5.4 62 | // https://fidoalliance.org/specs/fido-u2f-v1.0-nfc-bt-amendment-20150514/fido-u2f-raw-message-formats.html#authentication-response-message-success 63 | $sig_raw = fromBase64Web($response['signatureData']); 64 | 65 | if (strlen($sig_raw) < 6) { 66 | throw new IDE(IDE::MALFORMED_DATA, 'signatureData'); 67 | } 68 | $decoded = unpack('cpresence/Ncounter/a*signature', $sig_raw); 69 | assert($decoded !== false); 70 | $this->user_presence = $decoded['presence']; 71 | $this->counter = $decoded['counter']; 72 | $this->setSignature($decoded['signature']); 73 | return $this; 74 | } 75 | 76 | public function getChallenge(): string 77 | { 78 | return $this->clientData->getChallenge(); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/VersionTrait.php: -------------------------------------------------------------------------------- 1 | version; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/WebAuthn/AuthenticatorData.php: -------------------------------------------------------------------------------- 1 | = 37); 52 | 53 | $rpIdHash = substr($bytes, 0, 32); 54 | $flags = ord(substr($bytes, 32, 1)); 55 | $UP = ($flags & 0x01) === 0x01; // bit 0 56 | $UV = ($flags & 0x04) === 0x04; // bit 2 57 | $AT = ($flags & 0x40) === 0x40; // bit 6 58 | $ED = ($flags & 0x80) === 0x80; // bit 7 59 | $signCount = unpack('N', substr($bytes, 33, 4))[1]; 60 | 61 | $authData = new AuthenticatorData(); 62 | $authData->isUserPresent = $UP; 63 | $authData->isUserVerified = $UV; 64 | $authData->rpIdHash = $rpIdHash; 65 | $authData->signCount = $signCount; 66 | 67 | $restOfBytes = substr($bytes, 37); 68 | $restOfBytesLength = strlen($restOfBytes); 69 | if ($AT) { 70 | assert($restOfBytesLength >= 18); 71 | 72 | $aaguid = substr($restOfBytes, 0, 16); 73 | $credentialIdLength = unpack('n', substr($restOfBytes, 16, 2))[1]; 74 | assert($restOfBytesLength >= (18 + $credentialIdLength)); 75 | $credentialId = substr($restOfBytes, 18, $credentialIdLength); 76 | 77 | $rawCredentialPublicKey = substr($restOfBytes, 18 + $credentialIdLength); 78 | 79 | $decoder = new Decoder(); 80 | $credentialPublicKey = $decoder->decode($rawCredentialPublicKey); 81 | 82 | $authData->ACD = [ 83 | 'aaguid' => $aaguid, 84 | 'credentialId' => $credentialId, 85 | 'credentialPublicKey' => $credentialPublicKey, 86 | ]; 87 | // var_dump($decoder->getNumberOfBytesRead()); 88 | // cut rest of bytes down based on that ^ ? 89 | } 90 | if ($ED) { 91 | // @codeCoverageIgnoreStart 92 | throw new BadMethodCallException('Not implemented yet'); 93 | // @codeCoverageIgnoreEnd 94 | } 95 | 96 | return $authData; 97 | } 98 | 99 | /** @return ?AttestedCredentialData */ 100 | public function getAttestedCredentialData(): ?array 101 | { 102 | return $this->ACD; 103 | } 104 | 105 | public function getRpIdHash(): string 106 | { 107 | return $this->rpIdHash; 108 | } 109 | 110 | public function getSignCount(): int 111 | { 112 | return $this->signCount; 113 | } 114 | 115 | public function isUserPresent(): bool 116 | { 117 | return $this->isUserPresent; 118 | } 119 | 120 | /** 121 | * @return array{ 122 | * isUserPresent: bool, 123 | * isUserVerified: bool, 124 | * rpIdHash: string, 125 | * signCount: int, 126 | * ACD?: array{ 127 | * aaguid: string, 128 | * credentialId: string, 129 | * credentialPublicKey: array{ 130 | * kty: int, 131 | * alg: ?int, 132 | * crv: int, 133 | * x: string, 134 | * y: string, 135 | * d: string, 136 | * }, 137 | * }, 138 | * } 139 | */ 140 | public function __debugInfo(): array 141 | { 142 | $hex = function ($str) { 143 | return '0x' . bin2hex($str); 144 | }; 145 | $data = [ 146 | 'isUserPresent' => $this->isUserPresent, 147 | 'isUserVerified' => $this->isUserVerified, 148 | 'rpIdHash' => $hex($this->rpIdHash), 149 | 'signCount' => $this->signCount, 150 | ]; 151 | 152 | if ($this->ACD) { 153 | // See RFC8152 section 7 (COSE key parameters) 154 | $pk = [ 155 | 'kty' => $this->ACD['credentialPublicKey'][1], // MUST be 'EC2' (sec 13 tbl 21) 156 | // kid = 2 157 | 'alg' => $this->ACD['credentialPublicKey'][3] ?? null, 158 | // key_ops = 4 // must include sign (1)/verify(2) if present, depending on usage 159 | // Base IV = 5 160 | 161 | // this would be 'k' if 'kty'===4(Symmetric) 162 | 'crv' => $this->ACD['credentialPublicKey'][-1], // (13.1 tbl 22) 163 | 'x' => $hex($this->ACD['credentialPublicKey'][-2] ?? ''), // (13.1.1 tbl 23/13.2 tbl 24) 164 | 'y' => $hex($this->ACD['credentialPublicKey'][-3] ?? ''), // (13.1.1 tbl 23) 165 | 'd' => $hex($this->ACD['credentialPublicKey'][-4] ?? ''), // (13.2 tbl 24) 166 | 167 | ]; 168 | $acd = [ 169 | 'aaguid' => $hex($this->ACD['aaguid']), 170 | 'credentialId' => $hex($this->ACD['credentialId']), 171 | 'credentialPublicKey' => $pk, 172 | ]; 173 | $data['ACD'] = $acd; 174 | } 175 | return $data; 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /src/WebAuthn/LoginResponse.php: -------------------------------------------------------------------------------- 1 | isUserPresent()); 85 | 86 | // 7.2.5 87 | $jsonText = self::byteArrayToBinaryString($data['response']['clientDataJSON']); 88 | // 7.2.6 89 | $clientData = json_decode($jsonText, true); 90 | assert(is_array($clientData)); 91 | // 7.2.7 92 | assert($clientData['type'] === 'webauthn.get'); 93 | 94 | $response = new LoginResponse(); 95 | $response->authenticatorData = $authData; 96 | $response->authenticatorDataBytes = $authDataBytes; 97 | $response->challenge = fromBase64Web($clientData['challenge']); 98 | $response->clientDataJson = $jsonText; 99 | $response->keyHandle = self::byteArrayToBinaryString($data['rawId']); 100 | $response->signature = self::byteArrayToBinaryString($data['response']['signature']); 101 | return $response; 102 | } 103 | 104 | /** 105 | * @param int[] $bytes 106 | */ 107 | private static function byteArrayToBinaryString(array $bytes): string 108 | { 109 | return implode('', array_map('chr', $bytes)); 110 | } 111 | 112 | public function getChallenge(): string 113 | { 114 | return $this->challenge; 115 | } 116 | 117 | public function getCounter(): int 118 | { 119 | return $this->authenticatorData->getSignCount(); 120 | } 121 | 122 | public function getKeyHandleBinary(): string 123 | { 124 | return $this->keyHandle; 125 | } 126 | 127 | public function getSignature(): string 128 | { 129 | return $this->signature; 130 | } 131 | 132 | /** 133 | * @see WebAuthn 7.2.16 https://w3c.github.io/webauthn/#verifying-assertion 134 | */ 135 | public function getSignedData(): string 136 | { 137 | return sprintf( 138 | '%s%s', 139 | $this->authenticatorDataBytes, 140 | hash('sha256', $this->clientDataJson, true) 141 | ); 142 | } 143 | 144 | /** 145 | * @return array{ 146 | * clientDataJson: string, 147 | * challenge: string, 148 | * keyHandle: string, 149 | * signature: string, 150 | * } 151 | */ 152 | public function __debugInfo(): array 153 | { 154 | $hex = function (string $binary) { 155 | return '0x' . bin2hex($binary); 156 | }; 157 | return [ 158 | 'clientDataJson' => $this->clientDataJson, 159 | 'challenge' => $hex($this->challenge), 160 | 'keyHandle' => $hex($this->keyHandle), 161 | 'signature' => $hex($this->signature), 162 | ]; 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /src/WebAuthn/RegistrationResponse.php: -------------------------------------------------------------------------------- 1 | decode( 91 | self::byteArrayToBinaryString($data['response']['attestationObject']) 92 | ); 93 | 94 | $aoFmt = $attestationObject['fmt']; 95 | $aoAuthData = $attestationObject['authData']; 96 | $aoAttStmt = $attestationObject['attStmt']; 97 | 98 | $authData = AuthenticatorData::parse($aoAuthData); 99 | // 7.1.9 (validate rpIdHash) happens in server 100 | // 7.1.10 101 | assert($authData->isUserPresent()); 102 | // 7.1.11 skip? TODO 103 | // 7.1.12 skip? TODO 104 | // 7.1.13 105 | // For now, we're only going to support the FIDO-U2F format. In the 106 | // future this can become switching logic based on the format to get 107 | // the relevant data 108 | assert($aoFmt === 'fido-u2f'); 109 | assert(isset($aoAttStmt['sig'])); 110 | assert(isset($aoAttStmt['x5c'])); 111 | assert(is_array($aoAttStmt['x5c']) && count($aoAttStmt['x5c']) === 1); 112 | $attestationCert = new AttestationCertificate($aoAttStmt['x5c'][0]); 113 | 114 | $credentialData = $authData->getAttestedCredentialData(); 115 | assert($credentialData !== null); 116 | $publicKey = $credentialData['credentialPublicKey']; 117 | assert($publicKey[3] === -7); // ES256 (8.6.2) 118 | 119 | $publicKeyU2F = sprintf( 120 | '%s%s%s', 121 | "\x04", 122 | $publicKey[-2], 123 | $publicKey[-3] 124 | ); // 8.6.4 125 | $publicKey = new ECPublicKey($publicKeyU2F); 126 | 127 | $response = new RegistrationResponse(); 128 | $response->challenge = fromBase64Web($clientData['challenge']); 129 | $response->clientDataJson = $jsonText; 130 | $response->rpIdHash = $authData->getRpIdHash(); 131 | $response->signature = $aoAttStmt['sig']; 132 | $response->signedData = sprintf( 133 | '%s%s%s%s%s', 134 | "\x00", 135 | $authData->getRpIdHash(), 136 | $clientDataHash, 137 | $credentialData['credentialId'], 138 | $publicKeyU2F 139 | ); // 8.6.5 140 | $response->keyHandle = $credentialData['credentialId']; 141 | $response->publicKey = $publicKey; 142 | $response->attestationCert = $attestationCert; 143 | return $response; 144 | // 7.1.14 (perform verification of attestation statement) is done in 145 | // the server 146 | // 7.1.15 (get valid roots) is done in the server 147 | // 7.1.16 (cehck attestation cert) is done in the server 148 | // 7.1.17 (check credentialId is unregistered) is done in the app 149 | } 150 | 151 | public function getAttestationCertificate(): AttestationCertificateInterface 152 | { 153 | return $this->attestationCert; 154 | } 155 | 156 | public function getChallenge(): string 157 | { 158 | return $this->challenge; 159 | } 160 | 161 | public function getSignedData(): string 162 | { 163 | return $this->signedData; 164 | } 165 | 166 | public function getSignature(): string 167 | { 168 | return $this->signature; 169 | } 170 | 171 | public function getPublicKey(): PublicKeyInterface 172 | { 173 | return $this->publicKey; 174 | } 175 | 176 | public function getKeyHandleBinary(): string 177 | { 178 | return $this->keyHandle; 179 | } 180 | 181 | /** 182 | * @param int[] $bytes 183 | */ 184 | private static function byteArrayToBinaryString(array $bytes): string 185 | { 186 | return implode('', array_map('chr', $bytes)); 187 | } 188 | 189 | public function getRpIdHash(): string 190 | { 191 | return $this->rpIdHash; 192 | } 193 | 194 | /** 195 | * @return array{ 196 | * attestationCert: AttestationCertificateInterface, 197 | * clientDataJson: string, 198 | * challenge: string, 199 | * keyHandle: string, 200 | * publicKey: PublicKeyInterface, 201 | * rpIdHash: string, 202 | * signature: string, 203 | * signedData: string, 204 | * } 205 | */ 206 | public function __debugInfo(): array 207 | { 208 | $hex = function (string $binary) { 209 | return '0x' . bin2hex($binary); 210 | }; 211 | return [ 212 | 'attestationCert' => $this->attestationCert, 213 | 'clientDataJson' => $this->clientDataJson, 214 | 'challenge' => $hex($this->challenge), 215 | 'keyHandle' => $hex($this->keyHandle), 216 | 'publicKey' => $this->publicKey, 217 | 'rpIdHash' => $hex($this->rpIdHash), 218 | 'signature' => $hex($this->signature), 219 | 'signedData' => $hex($this->signedData), 220 | ]; 221 | } 222 | } 223 | -------------------------------------------------------------------------------- /src/functions.php: -------------------------------------------------------------------------------- 1 |