├── README.adoc ├── examples ├── README.adoc ├── tutorial │ ├── README.adoc │ └── README ├── README ├── config.php └── simple │ ├── index.php │ └── u2f-api.js ├── composer.json ├── README ├── COPYING └── src ├── Exceptions.php └── Client.php /README.adoc: -------------------------------------------------------------------------------- 1 | README -------------------------------------------------------------------------------- /examples/README.adoc: -------------------------------------------------------------------------------- 1 | README -------------------------------------------------------------------------------- /examples/tutorial/README.adoc: -------------------------------------------------------------------------------- 1 | README -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "yubico/u2fval-client", 3 | "description": "Client library for communicating with a U2FVAL server.", 4 | "license": "BSD-2-Clause", 5 | "require": { 6 | "lib-curl": "*" 7 | }, 8 | "autoload": { 9 | "classmap": ["src/"] 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /examples/tutorial/README: -------------------------------------------------------------------------------- 1 | == U2FVAL Tutorial 2 | This tutorial will show an example of how to add U2F capability to an existing 3 | username/password based site. To follow along, start by cloning the repository 4 | and checking out the first tutorial git tag: tutorial-1. At each step check out 5 | the contents of this README and take a look at the source code changes from the 6 | previous step by running: 7 | 8 | git diff HEAD~1 9 | 10 | Once ready, move on to the next step: 11 | 12 | git checkout tutorial-N 13 | 14 | ...where N is replaced by the step number, 1-5. 15 | -------------------------------------------------------------------------------- /README: -------------------------------------------------------------------------------- 1 | == u2fval-client 2 | Client library for communicating with a U2FVAL server. This is intended to be 3 | used for PHP applications which wish to implement 2FA using U2F. 4 | 5 | === License 6 | The project is licensed under a BSD license. See the file COPYING for 7 | exact wording. For any copyright year range specified as YYYY-ZZZZ in 8 | this package note that the range specifies every single year in that 9 | closed interval. 10 | 11 | === Dependencies 12 | You require CURL for PHP to use this library. 13 | To setup when composer is available, run: 14 | 15 | $ composer.phar install 16 | -------------------------------------------------------------------------------- /examples/README: -------------------------------------------------------------------------------- 1 | == Example 2 | A passwordless sample server allowing a user to register and authenticate U2F 3 | devices with a U2FVAL server. 4 | 5 | === Setup 6 | To include the required files 7 | 8 | require_once(dirname(__FILE__)."/../src/Client.php"); 9 | require_once(dirname(__FILE__)."/../src/Exceptions.php"); 10 | if(!function_exists("curl_init")) die("You require CURL for PHP"); 11 | 12 | Or install via Composer using 13 | 14 | $ composer.phar install 15 | 16 | You will also need to configure the endpoint and authentication to use for the 17 | U2FVAL server. Edit config.php and fill in the required values. 18 | -------------------------------------------------------------------------------- /examples/config.php: -------------------------------------------------------------------------------- 1 | 26 | -------------------------------------------------------------------------------- /COPYING: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 Yubico AB 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or 5 | without modification, are permitted provided that the following 6 | conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | 2. Redistributions in binary form must reproduce the above 11 | copyright notice, this list of conditions and the following 12 | disclaimer in the documentation and/or other materials provided 13 | with the distribution. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 16 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 17 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS 18 | FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE 19 | COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, 20 | INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 21 | BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 22 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 24 | LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN 25 | ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 26 | POSSIBILITY OF SUCH DAMAGE. 27 | -------------------------------------------------------------------------------- /examples/simple/index.php: -------------------------------------------------------------------------------- 1 | 7 |
8 |
12 | 13 | '; 14 | exit; 15 | } else { // Register or Login 16 | $user = $_GET["username"]; 17 | $isregister = ($_GET["action"] == "register"); 18 | if(empty($_GET["data"])) { // Ask user to press yubikey to register or login 19 | $data = $isregister ? $u2fval->register_begin($user) : $u2fval->auth_begin($user); 20 | echo ' 21 | 22 | 45 | Please wait... 46 |

Back to Main'; 47 | exit; 48 | } else { // Process yubikey data 49 | try { 50 | $data = $isregister ? $u2fval->register_complete($user, $_GET['data']) : $u2fval->auth_complete($user, $_GET["data"]); 51 | echo '

Success

'.json_encode($data); 52 | } catch(U2fVal\U2fValException $exception) { 53 | echo '

Error

'.$exception->getMessage(); 54 | } 55 | echo '

Back to Main'; 56 | exit; 57 | } 58 | } 59 | ?> 60 | -------------------------------------------------------------------------------- /src/Exceptions.php: -------------------------------------------------------------------------------- 1 | errorData = $errorData; 46 | } 47 | 48 | public function getData() { 49 | return $this->errorData; 50 | } 51 | 52 | public static function fromJson($response) { 53 | switch($response['errorCode']) { 54 | case 10: 55 | return new BadInputException($response['errorMessage'], $response['errorCode'], $response['errorData']); 56 | case 11: 57 | return new NoEligableDevicesException($response['errorMessage'], $response['errorCode'], $response['errorData']); 58 | case 12: 59 | return new DeviceCompromisedException($response['errorMessage'], $response['errorCode'], $response['errorData']); 60 | default: 61 | return new U2fValException($response['errorMessage'], $response['errorCode'], $response['errorData']); 62 | } 63 | } 64 | } 65 | 66 | class BadInputException extends U2fValException {} 67 | class NoEligableDevicesException extends U2fValException { 68 | public function hasDevices() { 69 | return !empty($this->errorData); 70 | } 71 | } 72 | class DeviceCompromisedException extends U2fValException {} 73 | 74 | ?> 75 | -------------------------------------------------------------------------------- /src/Client.php: -------------------------------------------------------------------------------- 1 | endpoint = $endpoint; 42 | $this->auth = $auth; 43 | } 44 | 45 | public static function withApiToken($endpoint, $apiToken) { 46 | return new Client($endpoint, new ApiTokenAuth($apiToken)); 47 | } 48 | 49 | public static function withHttpAuth($endpoint, $username, $password, $type=CURLAUTH_DIGEST) { 50 | return new Client($endpoint, new HttpAuth($username, $password, $type)); 51 | } 52 | 53 | public static function withNoAuth($endpoint) { 54 | return new Client($endpoint, new NoAuth()); 55 | } 56 | 57 | private static function add_props($data, $props) { 58 | if(is_string($data)) { 59 | $data = json_decode($data, true); 60 | } 61 | if($props !== NULL) { 62 | $data['properties'] = $props; 63 | } 64 | return $data; 65 | } 66 | 67 | private function curl_begin($path, & $headers) { 68 | $ch = curl_init($this->endpoint . $path); 69 | curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); 70 | curl_setopt($ch, CURLOPT_TIMEOUT, 10); 71 | $this->auth->authenticate($ch, $headers); 72 | return $ch; 73 | } 74 | 75 | private function curl_complete($ch, & $headers) { 76 | curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); 77 | $res = curl_exec($ch); 78 | if($res === false) { 79 | curl_close($ch); 80 | throw new ServerUnreachableException('Server unreachable'); 81 | } 82 | $res = json_decode($res, true); 83 | 84 | $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); 85 | curl_close($ch); 86 | if($status >= 400) { 87 | if(isset($res['errorCode'])) { 88 | throw U2fValException::fromJson($res); 89 | } else if($status == 401) { 90 | throw new BadAuthException("Not Authorized", $status); 91 | } else if($status == 404) { 92 | throw new U2fValClientException("Not Found", $status); 93 | } else { 94 | throw new U2fValClientException("Unknown error", $status); 95 | } 96 | } 97 | return $res; 98 | } 99 | 100 | private function curl_send($path, $data=null) { 101 | if(!function_exists('curl_init')) { 102 | trigger_error('cURL not installed', E_USER_ERROR); 103 | } 104 | 105 | $headers = array(); 106 | $ch = $this->curl_begin($path, $headers); 107 | if($data) { 108 | if(!is_string($data)) { 109 | $data = json_encode($data); 110 | } 111 | curl_setopt($ch, CURLOPT_POST, 1); 112 | $headers[] = 'Content-Type: application/json'; 113 | $headers[] = 'Content-Length: ' . strlen($data); 114 | curl_setopt($ch, CURLOPT_POSTFIELDS, $data); 115 | } 116 | return $this->curl_complete($ch, $headers); 117 | } 118 | 119 | private function curl_delete($path) { 120 | if(!function_exists('curl_init')) { 121 | trigger_error('cURL not installed', E_USER_ERROR); 122 | } 123 | 124 | $headers = array(); 125 | $ch = $this->curl_begin($path, $headers); 126 | curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "DELETE"); 127 | return $this->curl_complete($ch, $headers); 128 | } 129 | 130 | public function get_trusted_facets() { 131 | return json_encode($this->curl_send('')); 132 | } 133 | 134 | public function list_devices($username) { 135 | return $this->curl_send($username . '/'); 136 | } 137 | 138 | public function register_begin($username) { 139 | return json_encode($this->curl_send($username . '/register')); 140 | } 141 | 142 | public function register_complete($username, $registerResponse, $properties=NULL) { 143 | $registerData = array('registerResponse' => json_decode($registerResponse, true)); 144 | return $this->curl_send($username . '/register', self::add_props($registerData, $properties)); 145 | } 146 | 147 | public function unregister($username, $handle) { 148 | return $this->curl_delete($username . "/" . $handle); 149 | } 150 | 151 | public function auth_begin($username) { 152 | return json_encode($this->curl_send($username . '/authenticate')); 153 | } 154 | 155 | public function auth_complete($username, $authenticateResponse, $properties=NULL) { 156 | $authData = array('authenticateResponse' => json_decode($authenticateResponse, true)); 157 | return $this->curl_send($username . '/authenticate', self::add_props($authData, $properties)); 158 | } 159 | } 160 | 161 | class NoAuth { 162 | public function authenticate($ch, & $headers) {} 163 | } 164 | 165 | class ApiTokenAuth { 166 | public function __construct($apiToken) { 167 | $this->apiToken = $apiToken; 168 | } 169 | 170 | public function authenticate($ch, & $headers) { 171 | $headers[] = 'Authorization: Bearer ' . $this->apiToken; 172 | } 173 | } 174 | 175 | class HttpAuth { 176 | public function __construct($username, $password, $authtype=CURLAUTH_DIGEST) { 177 | $this->username = $username; 178 | $this->password = $password; 179 | $this->authtype = $authtype; 180 | } 181 | 182 | public function authenticate($ch, & $headers) { 183 | curl_setopt($ch, CURLOPT_USERPWD, $this->username . ':' . $this->password); 184 | curl_setopt($ch, CURLOPT_HTTPAUTH, $this->authtype); 185 | } 186 | } 187 | 188 | ?> 189 | -------------------------------------------------------------------------------- /examples/simple/u2f-api.js: -------------------------------------------------------------------------------- 1 | // Copyright 2014 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 { 151 | // chrome.runtime was not available at all, which is normal 152 | // when this origin doesn't have access to any extensions. 153 | u2f.getIframePort_(callback); 154 | } 155 | }; 156 | 157 | /** 158 | * Connects directly to the extension via chrome.runtime.connect 159 | * @param {function(u2f.WrappedChromeRuntimePort_)} callback 160 | * @private 161 | */ 162 | u2f.getChromeRuntimePort_ = function(callback) { 163 | var port = chrome.runtime.connect(u2f.EXTENSION_ID, 164 | {'includeTlsChannelId': true}); 165 | setTimeout(function() { 166 | callback(new u2f.WrappedChromeRuntimePort_(port)); 167 | }, 0); 168 | }; 169 | 170 | /** 171 | * A wrapper for chrome.runtime.Port that is compatible with MessagePort. 172 | * @param {Port} port 173 | * @constructor 174 | * @private 175 | */ 176 | u2f.WrappedChromeRuntimePort_ = function(port) { 177 | this.port_ = port; 178 | }; 179 | 180 | /** 181 | * Posts a message on the underlying channel. 182 | * @param {Object} message 183 | */ 184 | u2f.WrappedChromeRuntimePort_.prototype.postMessage = function(message) { 185 | this.port_.postMessage(message); 186 | }; 187 | 188 | /** 189 | * Emulates the HTML 5 addEventListener interface. Works only for the 190 | * onmessage event, which is hooked up to the chrome.runtime.Port.onMessage. 191 | * @param {string} eventName 192 | * @param {function({data: Object})} handler 193 | */ 194 | u2f.WrappedChromeRuntimePort_.prototype.addEventListener = 195 | function(eventName, handler) { 196 | var name = eventName.toLowerCase(); 197 | if (name == 'message' || name == 'onmessage') { 198 | this.port_.onMessage.addListener(function(message) { 199 | // Emulate a minimal MessageEvent object 200 | handler({'data': message}); 201 | }); 202 | } else { 203 | console.error('WrappedChromeRuntimePort only supports onMessage'); 204 | } 205 | }; 206 | 207 | /** 208 | * Sets up an embedded trampoline iframe, sourced from the extension. 209 | * @param {function(MessagePort)} callback 210 | * @private 211 | */ 212 | u2f.getIframePort_ = function(callback) { 213 | // Create the iframe 214 | var iframeOrigin = 'chrome-extension://' + u2f.EXTENSION_ID; 215 | var iframe = document.createElement('iframe'); 216 | iframe.src = iframeOrigin + '/u2f-comms.html'; 217 | iframe.setAttribute('style', 'display:none'); 218 | document.body.appendChild(iframe); 219 | 220 | var channel = new MessageChannel(); 221 | var ready = function(message) { 222 | if (message.data == 'ready') { 223 | channel.port1.removeEventListener('message', ready); 224 | callback(channel.port1); 225 | } else { 226 | console.error('First event on iframe port was not "ready"'); 227 | } 228 | }; 229 | channel.port1.addEventListener('message', ready); 230 | channel.port1.start(); 231 | 232 | iframe.addEventListener('load', function() { 233 | // Deliver the port to the iframe and initialize 234 | iframe.contentWindow.postMessage('init', iframeOrigin, [channel.port2]); 235 | }); 236 | }; 237 | 238 | 239 | // High-level JS API 240 | 241 | /** 242 | * Default extension response timeout in seconds. 243 | * @const 244 | */ 245 | u2f.EXTENSION_TIMEOUT_SEC = 30; 246 | 247 | /** 248 | * A singleton instance for a MessagePort to the extension. 249 | * @type {MessagePort|u2f.WrappedChromeRuntimePort_} 250 | * @private 251 | */ 252 | u2f.port_ = null; 253 | 254 | /** 255 | * Callbacks waiting for a port 256 | * @type {Array.} 257 | * @private 258 | */ 259 | u2f.waitingForPort_ = []; 260 | 261 | /** 262 | * A counter for requestIds. 263 | * @type {number} 264 | * @private 265 | */ 266 | u2f.reqCounter_ = 0; 267 | 268 | /** 269 | * A map from requestIds to client callbacks 270 | * @type {Object.} 272 | * @private 273 | */ 274 | u2f.callbackMap_ = {}; 275 | 276 | /** 277 | * Creates or retrieves the MessagePort singleton to use. 278 | * @param {function((MessagePort|u2f.WrappedChromeRuntimePort_))} callback 279 | * @private 280 | */ 281 | u2f.getPortSingleton_ = function(callback) { 282 | if (u2f.port_) { 283 | callback(u2f.port_); 284 | } else { 285 | if (u2f.waitingForPort_.length == 0) { 286 | u2f.getMessagePort(function(port) { 287 | u2f.port_ = port; 288 | u2f.port_.addEventListener('message', 289 | /** @type {function(Event)} */ (u2f.responseHandler_)); 290 | 291 | // Careful, here be async callbacks. Maybe. 292 | while (u2f.waitingForPort_.length) 293 | u2f.waitingForPort_.shift()(u2f.port_); 294 | }); 295 | } 296 | u2f.waitingForPort_.push(callback); 297 | } 298 | }; 299 | 300 | /** 301 | * Handles response messages from the extension. 302 | * @param {MessageEvent.} message 303 | * @private 304 | */ 305 | u2f.responseHandler_ = function(message) { 306 | var response = message.data; 307 | var reqId = response['requestId']; 308 | if (!reqId || !u2f.callbackMap_[reqId]) { 309 | console.error('Unknown or missing requestId in response.'); 310 | return; 311 | } 312 | var cb = u2f.callbackMap_[reqId]; 313 | delete u2f.callbackMap_[reqId]; 314 | cb(response['responseData']); 315 | }; 316 | 317 | /** 318 | * Dispatches an array of sign requests to available U2F tokens. 319 | * @param {Array.} signRequests 320 | * @param {function((u2f.Error|u2f.SignResponse))} callback 321 | * @param {number=} opt_timeoutSeconds 322 | */ 323 | u2f.sign = function(signRequests, callback, opt_timeoutSeconds) { 324 | u2f.getPortSingleton_(function(port) { 325 | var reqId = ++u2f.reqCounter_; 326 | u2f.callbackMap_[reqId] = callback; 327 | var req = { 328 | type: u2f.MessageTypes.U2F_SIGN_REQUEST, 329 | signRequests: signRequests, 330 | timeoutSeconds: (typeof opt_timeoutSeconds !== 'undefined' ? 331 | opt_timeoutSeconds : u2f.EXTENSION_TIMEOUT_SEC), 332 | requestId: reqId 333 | }; 334 | port.postMessage(req); 335 | }); 336 | }; 337 | 338 | /** 339 | * Dispatches register requests to available U2F tokens. An array of sign 340 | * requests identifies already registered tokens. 341 | * @param {Array.} registerRequests 342 | * @param {Array.} signRequests 343 | * @param {function((u2f.Error|u2f.RegisterResponse))} callback 344 | * @param {number=} opt_timeoutSeconds 345 | */ 346 | u2f.register = function(registerRequests, signRequests, 347 | callback, opt_timeoutSeconds) { 348 | u2f.getPortSingleton_(function(port) { 349 | var reqId = ++u2f.reqCounter_; 350 | u2f.callbackMap_[reqId] = callback; 351 | var req = { 352 | type: u2f.MessageTypes.U2F_REGISTER_REQUEST, 353 | signRequests: signRequests, 354 | registerRequests: registerRequests, 355 | timeoutSeconds: (typeof opt_timeoutSeconds !== 'undefined' ? 356 | opt_timeoutSeconds : u2f.EXTENSION_TIMEOUT_SEC), 357 | requestId: reqId 358 | }; 359 | port.postMessage(req); 360 | }); 361 | }; 362 | --------------------------------------------------------------------------------