tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

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 }