helpers.js (23429B)
1 // Useful constants for working with COSE key objects 2 const cose_kty = 1; 3 const cose_kty_ec2 = 2; 4 const cose_alg = 3; 5 const cose_alg_ECDSA_w_SHA256 = -7; 6 const cose_alg_ECDSA_w_SHA512 = -36; 7 const cose_crv = -1; 8 const cose_crv_P256 = 1; 9 const cose_crv_x = -2; 10 const cose_crv_y = -3; 11 12 /** 13 * These are the default arguments that will be passed to navigator.credentials.create() 14 * unless modified by a specific test case 15 */ 16 var createCredentialDefaultArgs = { 17 options: { 18 publicKey: { 19 // Relying Party: 20 rp: { 21 name: "Acme", 22 }, 23 24 // User: 25 user: { 26 id: new Uint8Array(16), // Won't survive the copy, must be rebuilt 27 name: "john.p.smith@example.com", 28 displayName: "John P. Smith", 29 }, 30 31 pubKeyCredParams: [{ 32 type: "public-key", 33 alg: cose_alg_ECDSA_w_SHA256, 34 }], 35 36 authenticatorSelection: { 37 requireResidentKey: false, 38 }, 39 40 timeout: 60000, // 1 minute 41 excludeCredentials: [] // No excludeList 42 } 43 } 44 }; 45 46 /** 47 * These are the default arguments that will be passed to navigator.credentials.get() 48 * unless modified by a specific test case 49 */ 50 var getCredentialDefaultArgs = { 51 options: { 52 publicKey: { 53 timeout: 60000 54 // allowCredentials: [newCredential] 55 } 56 } 57 }; 58 59 function createCredential(opts) { 60 opts = opts || {}; 61 62 // set the default options 63 var createArgs = cloneObject(createCredentialDefaultArgs); 64 let challengeBytes = new Uint8Array(16); 65 window.crypto.getRandomValues(challengeBytes); 66 createArgs.options.publicKey.challenge = challengeBytes; 67 createArgs.options.publicKey.user.id = new Uint8Array(16); 68 69 // change the defaults with any options that were passed in 70 extendObject(createArgs, opts); 71 72 // create the credential, return the Promise 73 return navigator.credentials.create(createArgs.options); 74 } 75 76 function assertCredential(credential) { 77 var options = cloneObject(getCredentialDefaultArgs); 78 let challengeBytes = new Uint8Array(16); 79 window.crypto.getRandomValues(challengeBytes); 80 options.challenge = challengeBytes; 81 options.allowCredentials = [{type: 'public-key', id: credential.rawId}]; 82 return navigator.credentials.get({publicKey: options}); 83 } 84 85 function createRandomString(len) { 86 var text = ""; 87 var possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; 88 for(var i = 0; i < len; i++) { 89 text += possible.charAt(Math.floor(Math.random() * possible.length)); 90 } 91 return text; 92 } 93 94 95 function ab2str(buf) { 96 return String.fromCharCode.apply(null, new Uint8Array(buf)); 97 } 98 99 // Useful constants for working with attestation data 100 const authenticator_data_user_present = 0x01; 101 const authenticator_data_user_verified = 0x04; 102 const authenticator_data_attested_cred_data = 0x40; 103 const authenticator_data_extension_data = 0x80; 104 105 function parseAuthenticatorData(buf) { 106 if (buf.byteLength < 37) { 107 throw new TypeError ("parseAuthenticatorData: buffer must be at least 37 bytes"); 108 } 109 110 printHex ("authnrData", buf); 111 112 var authnrData = new DataView(buf); 113 var authnrDataObj = {}; 114 authnrDataObj.length = buf.byteLength; 115 116 authnrDataObj.rpIdHash = new Uint8Array (buf.slice (0,32)); 117 authnrDataObj.rawFlags = authnrData.getUint8(32); 118 authnrDataObj.counter = authnrData.getUint32(33, false); 119 authnrDataObj.rawCounter = []; 120 authnrDataObj.rawCounter[0] = authnrData.getUint8(33); 121 authnrDataObj.rawCounter[1] = authnrData.getUint8(34); 122 authnrDataObj.rawCounter[2] = authnrData.getUint8(35); 123 authnrDataObj.rawCounter[3] = authnrData.getUint8(36); 124 authnrDataObj.flags = {}; 125 126 authnrDataObj.flags.userPresent = (authnrDataObj.rawFlags&authenticator_data_user_present)?true:false; 127 authnrDataObj.flags.userVerified = (authnrDataObj.rawFlags&authenticator_data_user_verified)?true:false; 128 authnrDataObj.flags.attestedCredentialData = (authnrDataObj.rawFlags&authenticator_data_attested_cred_data)?true:false; 129 authnrDataObj.flags.extensionData = (authnrDataObj.rawFlags&authenticator_data_extension_data)?true:false; 130 131 return authnrDataObj; 132 } 133 134 /** 135 * TestCase 136 * 137 * A generic template for test cases 138 * Is intended to be overloaded with subclasses that override testObject, testFunction and argOrder 139 * The testObject is the default arguments for the testFunction 140 * The default testObject can be modified with the modify() method, making it easy to create new tests based on the default 141 * The testFunction is the target of the test and is called by the doIt() method. doIt() applies the testObject as arguments via toArgs() 142 * toArgs() uses argOrder to make sure the resulting array is in the right order of the arguments for the testFunction 143 */ 144 class TestCase { 145 constructor() { 146 this.testFunction = function() { 147 throw new Error("Test Function not implemented"); 148 }; 149 this.testObject = {}; 150 this.argOrder = []; 151 this.ctx = null; 152 } 153 154 /** 155 * toObject 156 * 157 * return a copy of the testObject 158 */ 159 toObject() { 160 return JSON.parse(JSON.stringify(this.testObject)); // cheap clone 161 } 162 163 /** 164 * toArgs 165 * 166 * converts test object to an array that is ordered in the same way as the arguments to the test function 167 */ 168 toArgs() { 169 var ret = []; 170 // XXX, TODO: this won't necessarily produce the args in the right order 171 for (let idx of this.argOrder) { 172 ret.push(this.testObject[idx]); 173 } 174 return ret; 175 } 176 177 /** 178 * modify 179 * 180 * update the internal object by a path / value combination 181 * e.g. : 182 * modify ("foo.bar", 3) 183 * accepts three types of args: 184 * "foo.bar", 3 185 * {path: "foo.bar", value: 3} 186 * [{path: "foo.bar", value: 3}, ...] 187 */ 188 modify(arg1, arg2) { 189 var mods; 190 191 // check for the two argument scenario 192 if (typeof arg1 === "string" && arg2 !== undefined) { 193 mods = { 194 path: arg1, 195 value: arg2 196 }; 197 } else { 198 mods = arg1; 199 } 200 201 // accept a single modification object instead of an array 202 if (!Array.isArray(mods) && typeof mods === "object") { 203 mods = [mods]; 204 } 205 206 // iterate through each of the desired modifications, and call recursiveSetObject on them 207 for (let idx in mods) { 208 var mod = mods[idx]; 209 let paths = mod.path.split("."); 210 recursiveSetObject(this.testObject, paths, mod.value); 211 } 212 213 // iterates through nested `obj` using the `pathArray`, creating the path if it doesn't exist 214 // when the final leaf of the path is found, it is assigned the specified value 215 function recursiveSetObject(obj, pathArray, value) { 216 var currPath = pathArray.shift(); 217 if (typeof obj[currPath] !== "object") { 218 obj[currPath] = {}; 219 } 220 if (pathArray.length > 0) { 221 return recursiveSetObject(obj[currPath], pathArray, value); 222 } 223 obj[currPath] = value; 224 } 225 226 return this; 227 } 228 229 /** 230 * actually runs the test function with the supplied arguments 231 */ 232 doIt() { 233 if (typeof this.testFunction !== "function") { 234 throw new Error("Test function not found"); 235 } 236 237 return this.testFunction.call(this.ctx, ...this.toArgs()); 238 } 239 240 /** 241 * run the test function with the top-level properties of the test object applied as arguments 242 * expects the test to pass, and then validates the results 243 */ 244 testPasses(desc) { 245 return this.doIt() 246 .then((ret) => { 247 // check the result 248 this.validateRet(ret); 249 return ret; 250 }); 251 } 252 253 /** 254 * run the test function with the top-level properties of the test object applied as arguments 255 * expects the test to fail 256 */ 257 testFails(t, testDesc, expectedErr) { 258 if (typeof expectedErr == "string") { 259 return promise_rejects_dom(t, expectedErr, this.doIt(), "Expected bad parameters to fail"); 260 } 261 262 return promise_rejects_js(t, expectedErr, this.doIt(), "Expected bad parameters to fail"); 263 } 264 265 /** 266 * Runs the test that's implemented by the class by calling the doIt() function 267 * @param {String} desc A description of the test being run 268 * @param [Error|String] expectedErr A string matching an error type, such as "SecurityError" or an object with a .name value that is an error type string 269 */ 270 runTest(desc, expectedErr) { 271 promise_test((t) => { 272 return Promise.resolve().then(() => { 273 return this.testSetup(); 274 }).then(() => { 275 if (expectedErr === undefined) { 276 return this.testPasses(desc); 277 } else { 278 return this.testFails(t, desc, expectedErr); 279 } 280 }).then((res) => { 281 return this.testTeardown(res); 282 }) 283 }, desc) 284 } 285 286 /** 287 * called before runTest 288 * virtual method expected to be overridden by child class if needed 289 */ 290 testSetup() { 291 if (this.beforeTestFn) { 292 this.beforeTestFn.call(this); 293 } 294 295 return Promise.resolve(); 296 } 297 298 /** 299 * Adds a callback function that gets called in the TestCase context 300 * and within the testing process. 301 */ 302 beforeTest(fn) { 303 if (typeof fn !== "function") { 304 throw new Error ("Tried to call non-function before test"); 305 } 306 307 this.beforeTestFn = fn; 308 309 return this; 310 } 311 312 /** 313 * called after runTest 314 * virtual method expected to be overridden by child class if needed 315 */ 316 testTeardown(res) { 317 if (this.afterTestFn) { 318 this.afterTestFn.call(this, res); 319 } 320 321 return Promise.resolve(); 322 } 323 324 /** 325 * Adds a callback function that gets called in the TestCase context 326 * and within the testing process. Good for validating results. 327 */ 328 afterTest(fn) { 329 if (typeof fn !== "function") { 330 throw new Error ("Tried to call non-function after test"); 331 } 332 333 this.afterTestFn = fn; 334 335 return this; 336 } 337 338 /** 339 * validates the value returned from the test function 340 * virtual method expected to be overridden by child class 341 */ 342 validateRet() { 343 throw new Error("Not implemented"); 344 } 345 } 346 347 function cloneObject(o) { 348 return JSON.parse(JSON.stringify(o)); 349 } 350 351 function extendObject(dst, src) { 352 Object.keys(src).forEach(function(key) { 353 if (isSimpleObject(src[key]) && !isAbortSignal(src[key])) { 354 dst[key] ||= {}; 355 extendObject(dst[key], src[key]); 356 } else { 357 dst[key] = src[key]; 358 } 359 }); 360 } 361 362 function isSimpleObject(o) { 363 return (typeof o === "object" && 364 !Array.isArray(o) && 365 !(o instanceof ArrayBuffer) && 366 !(o instanceof Uint8Array)); 367 } 368 369 function isAbortSignal(o) { 370 return (o instanceof AbortSignal); 371 } 372 373 /** 374 * CreateCredentialTest 375 * 376 * tests the WebAuthn navigator.credentials.create() interface 377 */ 378 class CreateCredentialsTest extends TestCase { 379 constructor() { 380 // initialize the parent class 381 super(); 382 383 // the function to be tested 384 this.testFunction = navigator.credentials.create; 385 // the context to call the test function with (i.e. - the 'this' object for the function) 386 this.ctx = navigator.credentials; 387 388 // the default object to pass to makeCredential, to be modified with modify() for various tests 389 let challengeBytes = new Uint8Array(16); 390 window.crypto.getRandomValues(challengeBytes); 391 this.testObject = cloneObject(createCredentialDefaultArgs); 392 // cloneObject can't clone the BufferSource in user.id, so let's recreate it. 393 this.testObject.options.publicKey.user.id = new Uint8Array(16); 394 this.testObject.options.publicKey.challenge = challengeBytes; 395 396 // how to order the properties of testObject when passing them to makeCredential 397 this.argOrder = [ 398 "options" 399 ]; 400 401 // enable the constructor to modify the default testObject 402 // would prefer to do this in the super class, but have to call super() before using `this.*` 403 if (arguments.length) this.modify(...arguments); 404 } 405 406 validateRet(ret) { 407 validatePublicKeyCredential(ret); 408 validateAuthenticatorAttestationResponse(ret.response); 409 } 410 } 411 412 /** 413 * GetCredentialsTest 414 * 415 * tests the WebAuthn navigator.credentials.get() interface 416 */ 417 class GetCredentialsTest extends TestCase { 418 constructor(...args) { 419 // initialize the parent class 420 super(); 421 422 // the function to be tested 423 this.testFunction = navigator.credentials.get; 424 // the context to call the test function with (i.e. - the 'this' object for the function) 425 this.ctx = navigator.credentials; 426 427 // default arguments 428 let challengeBytes = new Uint8Array(16); 429 window.crypto.getRandomValues(challengeBytes); 430 this.testObject = cloneObject(getCredentialDefaultArgs); 431 this.testObject.options.publicKey.challenge = challengeBytes; 432 433 // how to order the properties of testObject when passing them to makeCredential 434 this.argOrder = [ 435 "options" 436 ]; 437 438 this.credentialPromiseList = []; 439 440 // set to true to pass an empty allowCredentials list to credentials.get 441 this.isResidentKeyTest = false; 442 443 // enable the constructor to modify the default testObject 444 // would prefer to do this in the super class, but have to call super() before using `this.*` 445 if (arguments.length) { 446 if (args.cred instanceof Promise) this.credPromise = args.cred; 447 else if (typeof args.cred === "object") this.credPromise = Promise.resolve(args.cred); 448 delete args.cred; 449 this.modify(...arguments); 450 } 451 } 452 453 addCredential(arg) { 454 // if a Promise was passed in, add it to the list 455 if (arg instanceof Promise) { 456 this.credentialPromiseList.push(arg); 457 return this; 458 } 459 460 // if a credential object was passed in, convert it to a Promise for consistency 461 if (typeof arg === "object") { 462 this.credentialPromiseList.push(Promise.resolve(arg)); 463 return this; 464 } 465 466 // if no credential specified then create one 467 var p = createCredential(); 468 this.credentialPromiseList.push(p); 469 470 return this; 471 } 472 473 testSetup(desc) { 474 if (!this.credentialPromiseList.length) { 475 throw new Error("Attempting list without defining credential to test"); 476 } 477 478 return Promise.all(this.credentialPromiseList) 479 .then((credList) => { 480 var idList = credList.map((cred) => { 481 return { 482 id: cred.rawId, 483 transports: ["usb", "nfc", "ble"], 484 type: "public-key" 485 }; 486 }); 487 if (!this.isResidentKeyTest) { 488 this.testObject.options.publicKey.allowCredentials = idList; 489 } 490 // return super.test(desc); 491 }) 492 .catch((err) => { 493 throw Error(err); 494 }); 495 } 496 497 validateRet(ret) { 498 validatePublicKeyCredential(ret); 499 validateAuthenticatorAssertionResponse(ret.response); 500 } 501 502 setIsResidentKeyTest(isResidentKeyTest) { 503 this.isResidentKeyTest = isResidentKeyTest; 504 return this; 505 } 506 } 507 508 /** 509 * converts a uint8array to base64 url-safe encoding 510 * based on similar function in resources/utils.js 511 */ 512 function base64urlEncode(array) { 513 let string = String.fromCharCode.apply(null, array); 514 let result = btoa(string); 515 return result.replace(/=+$/g, '').replace(/\+/g, "-").replace(/\//g, "_"); 516 } 517 /** 518 * runs assertions against a PublicKeyCredential object to ensure it is properly formatted 519 */ 520 function validatePublicKeyCredential(cred) { 521 // class 522 assert_class_string(cred, "PublicKeyCredential", "Expected return to be instance of 'PublicKeyCredential' class"); 523 // id 524 assert_idl_attribute(cred, "id", "should return PublicKeyCredential with id attribute"); 525 assert_readonly(cred, "id", "should return PublicKeyCredential with readonly id attribute"); 526 // rawId 527 assert_idl_attribute(cred, "rawId", "should return PublicKeyCredential with rawId attribute"); 528 assert_readonly(cred, "rawId", "should return PublicKeyCredential with readonly rawId attribute"); 529 assert_equals(cred.id, base64urlEncode(new Uint8Array(cred.rawId)), "should return PublicKeyCredential with id attribute set to base64 encoding of rawId attribute"); 530 531 // type 532 assert_idl_attribute(cred, "type", "should return PublicKeyCredential with type attribute"); 533 assert_equals(cred.type, "public-key", "should return PublicKeyCredential with type 'public-key'"); 534 } 535 536 /** 537 * runs assertions against a AuthenticatorAttestationResponse object to ensure it is properly formatted 538 */ 539 function validateAuthenticatorAttestationResponse(attr) { 540 // class 541 assert_class_string(attr, "AuthenticatorAttestationResponse", "Expected credentials.create() to return instance of 'AuthenticatorAttestationResponse' class"); 542 543 // clientDataJSON 544 assert_idl_attribute(attr, "clientDataJSON", "credentials.create() should return AuthenticatorAttestationResponse with clientDataJSON attribute"); 545 assert_readonly(attr, "clientDataJSON", "credentials.create() should return AuthenticatorAttestationResponse with readonly clientDataJSON attribute"); 546 // TODO: clientDataJSON() and make sure fields are correct 547 548 // attestationObject 549 assert_idl_attribute(attr, "attestationObject", "credentials.create() should return AuthenticatorAttestationResponse with attestationObject attribute"); 550 assert_readonly(attr, "attestationObject", "credentials.create() should return AuthenticatorAttestationResponse with readonly attestationObject attribute"); 551 // TODO: parseAuthenticatorData() and make sure flags are correct 552 } 553 554 /** 555 * runs assertions against a AuthenticatorAssertionResponse object to ensure it is properly formatted 556 */ 557 function validateAuthenticatorAssertionResponse(assert) { 558 // class 559 assert_class_string(assert, "AuthenticatorAssertionResponse", "Expected credentials.create() to return instance of 'AuthenticatorAssertionResponse' class"); 560 561 // clientDataJSON 562 assert_idl_attribute(assert, "clientDataJSON", "credentials.get() should return AuthenticatorAssertionResponse with clientDataJSON attribute"); 563 assert_readonly(assert, "clientDataJSON", "credentials.get() should return AuthenticatorAssertionResponse with readonly clientDataJSON attribute"); 564 // TODO: clientDataJSON() and make sure fields are correct 565 566 // signature 567 assert_idl_attribute(assert, "signature", "credentials.get() should return AuthenticatorAssertionResponse with signature attribute"); 568 assert_readonly(assert, "signature", "credentials.get() should return AuthenticatorAssertionResponse with readonly signature attribute"); 569 570 // authenticatorData 571 assert_idl_attribute(assert, "authenticatorData", "credentials.get() should return AuthenticatorAssertionResponse with authenticatorData attribute"); 572 assert_readonly(assert, "authenticatorData", "credentials.get() should return AuthenticatorAssertionResponse with readonly authenticatorData attribute"); 573 // TODO: parseAuthenticatorData() and make sure flags are correct 574 } 575 576 function defaultAuthenticatorArgs() { 577 return { 578 protocol: 'ctap1/u2f', 579 transport: 'usb', 580 hasResidentKey: false, 581 hasUserVerification: false, 582 isUserVerified: false, 583 }; 584 } 585 586 function standardSetup(cb, options = {}) { 587 // Setup an automated testing environment if available. 588 let authenticatorArgs = Object.assign(defaultAuthenticatorArgs(), options); 589 window.test_driver.add_virtual_authenticator(authenticatorArgs) 590 .then(authenticator => { 591 cb(); 592 // XXX add a subtest to clean up the virtual authenticator since 593 // testharness does not support waiting for promises on cleanup. 594 promise_test( 595 () => 596 window.test_driver.remove_virtual_authenticator(authenticator), 597 'Clean up the test environment'); 598 }) 599 .catch(error => { 600 if (error !== 601 'error: Action add_virtual_authenticator not implemented') { 602 throw error; 603 } 604 // The protocol is not available. Continue manually. 605 cb(); 606 }); 607 } 608 609 // virtualAuthenticatorPromiseTest runs |testCb| in a promise_test with a 610 // virtual authenticator set up before and destroyed after the test, if the 611 // virtual testing API is available. In manual tests, setup and teardown is 612 // skipped. 613 function virtualAuthenticatorPromiseTest( 614 testCb, options = {}, name = 'Virtual Authenticator Test') { 615 let authenticatorArgs = Object.assign(defaultAuthenticatorArgs(), options); 616 promise_test(async t => { 617 let authenticator; 618 try { 619 authenticator = 620 await window.test_driver.add_virtual_authenticator(authenticatorArgs); 621 t.add_cleanup( 622 () => window.test_driver.remove_virtual_authenticator(authenticator)); 623 } catch (error) { 624 if (error !== 'error: Action add_virtual_authenticator not implemented') { 625 throw error; 626 } 627 } 628 return testCb(t, authenticator); 629 }, name); 630 } 631 632 function bytesEqual(a, b) { 633 if (a instanceof ArrayBuffer) { 634 a = new Uint8Array(a); 635 } 636 if (b instanceof ArrayBuffer) { 637 b = new Uint8Array(b); 638 } 639 if (a.byteLength != b.byteLength) { 640 return false; 641 } 642 for (let i = 0; i < a.byteLength; i++) { 643 if (a[i] != b[i]) { 644 return false; 645 } 646 } 647 return true; 648 } 649 650 // Compares two PublicKeyCredentialUserEntity objects. 651 function userEntityEquals(a, b) { 652 return bytesEqual(a.id, b.id) && a.name == b.name && a.displayName == b.displayName; 653 } 654 655 // Asserts that `actual` and `expected`, which are both JSON types, are equal. 656 // The object key order is ignored for comparison. 657 function assertJsonEquals(actual, expected, optMsg) { 658 // Returns a copy of `jsonObj`, which must be a JSON type, with object keys 659 // recursively sorted in lexicographic order; or simply `jsonObj` if it is not 660 // an instance of Object. 661 function deepSortKeys(jsonObj) { 662 if (jsonObj instanceof Array) { 663 return Array.from(jsonObj, (x) => { return deepSortKeys(x); }) 664 } 665 if (typeof jsonObj !== 'object' || jsonObj === null || 666 jsonObj.__proto__.constructor !== Object || 667 Object.keys(jsonObj).length === 0) { 668 return jsonObj; 669 } 670 return Object.keys(jsonObj).sort().reduce((acc, key) => { 671 acc[key] = deepSortKeys(jsonObj[key]); 672 return acc; 673 }, {}); 674 } 675 676 assert_equals( 677 JSON.stringify(deepSortKeys(actual)), 678 JSON.stringify(deepSortKeys(expected)), optMsg); 679 }