diff --git a/Libraries/LibWeb/CredentialManagement/Credential.h b/Libraries/LibWeb/CredentialManagement/Credential.h index bf4385d2b54de..8c89f97867066 100644 --- a/Libraries/LibWeb/CredentialManagement/Credential.h +++ b/Libraries/LibWeb/CredentialManagement/Credential.h @@ -13,6 +13,8 @@ namespace Web::CredentialManagement { +typedef GC::Function>(JS::Object const&)> CreateCredentialAlgorithm; + class Credential : public Bindings::PlatformObject { WEB_PLATFORM_OBJECT(Credential, Bindings::PlatformObject); GC_DECLARE_ALLOCATOR(Credential); @@ -31,6 +33,12 @@ class Credential : public Bindings::PlatformObject { virtual String type() = 0; + // https://w3c.github.io/webappsec-credential-management/#algorithm-create-cred + virtual JS::ThrowCompletionOr, GC::Ref>> internal_create(URL::Origin const&, CredentialCreationOptions const&, bool) + { + return Empty {}; + } + protected: explicit Credential(JS::Realm&); virtual void initialize(JS::Realm&) override; diff --git a/Libraries/LibWeb/CredentialManagement/CredentialsContainer.cpp b/Libraries/LibWeb/CredentialManagement/CredentialsContainer.cpp index 604ad09f1ce66..9d5fe0faa6c45 100644 --- a/Libraries/LibWeb/CredentialManagement/CredentialsContainer.cpp +++ b/Libraries/LibWeb/CredentialManagement/CredentialsContainer.cpp @@ -5,6 +5,9 @@ */ #include +#include +#include +#include namespace Web::CredentialManagement { @@ -27,9 +30,207 @@ JS::ThrowCompletionOr> CredentialsContainer::store(Cred return WebIDL::create_rejected_promise(realm(), JS::PrimitiveString::create(realm().vm(), "Not implemented"sv)); } -JS::ThrowCompletionOr> CredentialsContainer::create(CredentialCreationOptions const&) +// https://w3c.github.io/webappsec-credential-management/#algorithm-same-origin-with-ancestors +static bool is_same_origin_with_its_ancestors(HTML::EnvironmentSettingsObject& settings) { - return WebIDL::create_rejected_promise(realm(), JS::PrimitiveString::create(realm().vm(), "Not implemented"sv)); + auto& global = settings.global_object(); + + // 1. FIXME: If settings’s relevant global object has no associated Document, return false. + // 2. Let document be settings’ relevant global object's associated Document. + auto& document = verify_cast(global).associated_document(); + + // 3. If document has no browsing context, return false. + if (!document.browsing_context()) + return false; + + // 4. Let origin be settings’ origin. + auto origin = settings.origin(); + + // 5. Let navigable be document’s node navigable. + auto navigable = document.navigable(); + + // 6. While navigable has a non-null parent: + while (navigable->parent()) { + // 1. Set navigable to navigable’s parent. + navigable = navigable->parent(); + + // 2. If navigable’s active document's origin is not same origin with origin, return false. + if (!origin.is_same_origin(navigable->active_document()->origin())) + return false; + } + + // 7. Return true. + return true; +} + +// https://w3c.github.io/webappsec-credential-management/#credentialrequestoptions-relevant-credential-interface-objects +template +static Vector> relevant_credential_interface_objects(OptionsType const& options) +{ + // 1. Let settings be the current settings object. + auto& settings = HTML::current_principal_settings_object(); + (void)settings; + + // 2. Let relevant interface objects be an empty set. + Vector> interfaces; + + // 3. For each optionKey → optionValue of options: + // NOTE: We cannot iterate like the spec says. + // 1. Let credentialInterfaceObject be the Appropriate Interface Object (on settings’ global object) whose Options Member Identifier is optionKey. + // 2. Assert: credentialInterfaceObject’s [[type]] slot equals the Credential Type whose Options Member Identifier is optionKey. + // 3. Append credentialInterfaceObject to relevant interface objects. + +#define APPEND_CREDENTIAL_INTERFACE_OBJECT(key, type_) \ + if (options.key.has_value()) { \ + auto credential_interface_object = type_::create(settings.realm()); \ + VERIFY(credential_interface_object->type() == #key); \ + interfaces.append(move(credential_interface_object)); \ + } + + // https://w3c.github.io/webappsec-credential-management/#credential-type-registry-appropriate-interface-object + APPEND_CREDENTIAL_INTERFACE_OBJECT(password, PasswordCredential); + APPEND_CREDENTIAL_INTERFACE_OBJECT(federated, FederatedCredential); + // TODO: digital + // TODO: identity + // TODO: otp + // TODO: publicKey + + // 4. Return relevant interface objects. + return interfaces; +} + +// https://w3c.github.io/webappsec-credential-management/#algorithm-create +JS::ThrowCompletionOr> CredentialsContainer::create(CredentialCreationOptions const& options) +{ + // 1. Let settings be the current settings object. + auto& settings = HTML::current_principal_settings_object(); + + // 2. Assert: settings is a secure context. + VERIFY(HTML::is_secure_context(settings)); + + // 3. Let global be settings’ global object. + auto& global = settings.global_object(); + + // 4. Let document be the relevant global object's associated Document. + auto& document = verify_cast(global).associated_document(); + + // 5. If document is not fully active, then return a promise rejected with an "InvalidStateError" DOMException. + if (!document.is_fully_active()) + return WebIDL::create_rejected_promise_from_exception(realm(), WebIDL::InvalidStateError::create(realm(), "Document is not fully active"_string)); + + // 6. Let sameOriginWithAncestors be true if the current settings object is same-origin with its ancestors, and false otherwise. + auto same_origin_with_ancestors = is_same_origin_with_its_ancestors(settings); + (void)same_origin_with_ancestors; // FIXME + + // 7. Let interfaces be the set of options’ relevant credential interface objects. + auto interfaces = relevant_credential_interface_objects(options); + + // 8. Return a promise rejected with NotSupportedError if any of the following statements are true: + // FIXME: 1. global does not have an associated Document. + // 2. interfaces’ size is greater than 1. + if (interfaces.size() > 1) + return WebIDL::create_rejected_promise_from_exception(realm(), WebIDL::NotSupportedError::create(realm(), "Too many crendetial types"_string)); + + // 9. For each interface in interfaces: + for (auto& interface : interfaces) { + // 1. Let permission be the interface’s [[type]] Create Permissions Policy. + // 2. If permission is null, continue. + // 3. If document is not allowed to use permission, return a promise rejected with a "NotAllowedError" DOMException. + + // https://w3c.github.io/webappsec-credential-management/#credential-type-registry-create-permissions-policy + if (interface->type() == "public-key") { + // TODO: https://w3c.github.io/webauthn/#publickey-credentials-create-feature + VERIFY_NOT_REACHED(); + } + } + + // 10. If options.signal is aborted, then return a promise rejected with options.signal’s abort reason. + if (options.signal && options.signal->aborted()) + return WebIDL::create_rejected_promise(realm(), options.signal->reason()); + + // NOTE: The spec does not mention this check + if (interfaces.size() < 1) + return WebIDL::create_rejected_promise_from_exception(realm(), WebIDL::NotSupportedError::create(realm(), "No crendetial types"_string)); + + // 11. Let type be interfaces[0]'s [[type]]. + auto type = interfaces[0]->type(); + + // 12. If settings’ active credential types contains type, return a promise rejected with a "NotAllowedError" DOMException. + if (settings.active_credential_types().contains_slow(type)) + return WebIDL::create_rejected_promise_from_exception(realm(), WebIDL::NotAllowedError::create(realm(), "Credential type is not allowed"_string)); + + // 13. Append type to settings’ active credential types. + settings.active_credential_types().append(type); + + // 14. Let origin be settings’s origin. + auto origin = settings.origin(); + + // 15. Let p be a new promise. + auto promise = WebIDL::create_promise(realm()); + + // 16. Run the following steps in parallel: + Platform::EventLoopPlugin::the().deferred_invoke(GC::create_function(realm().heap(), [this, promise = GC::Root(promise), &global, &document, interfaces = move(interfaces), &origin, &options, same_origin_with_ancestors] { + HTML::TemporaryExecutionContext execution_context { realm(), HTML::TemporaryExecutionContext::CallbacksEnabled::Yes }; + + // 1. Let r be the result of executing interfaces[0]'s [[Create]](origin, options, sameOriginWithAncestors) + // internal method on origin, options, and sameOriginWithAncestors. + auto maybe_r = interfaces[0]->internal_create(origin, options, same_origin_with_ancestors); + // If that threw an exception: + if (maybe_r.is_error()) { + // 1. Let e be the thrown exception. + auto e = maybe_r.error_value(); + // 2. Queue a task on global’s DOM manipulation task source to run the following substeps: + queue_global_task(HTML::Task::Source::DOMManipulation, global, GC::create_function(document.heap(), [&] { + // 1. Reject p with e. + WebIDL::reject_promise(realm(), *promise, e); + })); + // 3. Terminate these substeps. + return; + } + + auto r = maybe_r.release_value(); + + // 2. If r is a Credential or null, resolve p with r, and terminate these substeps. + if (r.has()) { + WebIDL::resolve_promise(realm(), *promise, JS::js_null()); + return; + } + if (r.has>()) { + auto& credential = r.get>(); + WebIDL::resolve_promise(realm(), *promise, credential); + return; + } + + // 3. Assert: r is an algorithm (as defined in §2.2.1.4 [[Create]] internal method). + VERIFY(r.has>()); + + // 4. Queue a task on global’s DOM manipulation task source to run the following substeps: + queue_global_task(HTML::Task::Source::DOMManipulation, global, GC::create_function(document.heap(), [&] { + auto& r_algo = r.get>(); + + // 1. Resolve p with the result of promise-calling r given global. + auto maybe_result = r_algo->function()(global); + if (maybe_result.is_error()) { + WebIDL::reject_promise(realm(), *promise, maybe_result.error_value()); + return; + } + + auto& result = maybe_result.value(); + WebIDL::resolve_promise(realm(), *promise, result); + })); + })); + + // 17. React to p: + auto on_completion = GC::create_function(realm().heap(), [&settings, &type](JS::Value) -> WebIDL::ExceptionOr { + // 1. Remove type from settings’ active credential types. + settings.active_credential_types().remove_first_matching([&](auto& v) { return v == type; }); + + return JS::js_undefined(); + }); + WebIDL::react_to_promise(*promise, on_completion, on_completion); + + // 18. Return p. + return promise; } JS::ThrowCompletionOr> CredentialsContainer::prevent_silent_access() diff --git a/Libraries/LibWeb/CredentialManagement/FederatedCredential.h b/Libraries/LibWeb/CredentialManagement/FederatedCredential.h index 71df9996aa45a..6808769e18680 100644 --- a/Libraries/LibWeb/CredentialManagement/FederatedCredential.h +++ b/Libraries/LibWeb/CredentialManagement/FederatedCredential.h @@ -28,6 +28,8 @@ class FederatedCredential final : public Credential { String type() override { return "federated"_string; } + //JS::ThrowCompletionOr, CreateCredentialAlgorithm>> internal_create(const URL::Origin&, CredentialCreationOptions const&, bool) override; + private: explicit FederatedCredential(JS::Realm&); virtual void initialize(JS::Realm&) override; diff --git a/Libraries/LibWeb/CredentialManagement/PasswordCredential.h b/Libraries/LibWeb/CredentialManagement/PasswordCredential.h index feb27cad52a6e..e3af466c86398 100644 --- a/Libraries/LibWeb/CredentialManagement/PasswordCredential.h +++ b/Libraries/LibWeb/CredentialManagement/PasswordCredential.h @@ -29,6 +29,8 @@ class PasswordCredential final : public Credential { String type() override { return "password"_string; } + //JS::ThrowCompletionOr, CreateCredentialAlgorithm>> internal_create(const URL::Origin&, CredentialCreationOptions const&, bool) override; + private: explicit PasswordCredential(JS::Realm&); virtual void initialize(JS::Realm&) override; diff --git a/Libraries/LibWeb/HTML/Scripting/Environments.cpp b/Libraries/LibWeb/HTML/Scripting/Environments.cpp index 217711cc0b71b..194f2d2147894 100644 --- a/Libraries/LibWeb/HTML/Scripting/Environments.cpp +++ b/Libraries/LibWeb/HTML/Scripting/Environments.cpp @@ -582,4 +582,10 @@ GC::Ref EnvironmentSettingsObject::storage_manager() return *m_storage_manager; } +// https://w3c.github.io/webappsec-credential-management/#active-credential-types +Vector EnvironmentSettingsObject::active_credential_types() const +{ + return m_active_credential_types; +} + } diff --git a/Libraries/LibWeb/HTML/Scripting/Environments.h b/Libraries/LibWeb/HTML/Scripting/Environments.h index d7adc8d6c74e1..c67e4533575ed 100644 --- a/Libraries/LibWeb/HTML/Scripting/Environments.h +++ b/Libraries/LibWeb/HTML/Scripting/Environments.h @@ -102,6 +102,8 @@ struct EnvironmentSettingsObject : public Environment { GC::Ref storage_manager(); + Vector active_credential_types() const; + [[nodiscard]] bool discarded() const { return m_discarded; } void set_discarded(bool b) { m_discarded = b; } @@ -124,6 +126,9 @@ struct EnvironmentSettingsObject : public Environment { // Each environment settings object has an associated StorageManager object. GC::Ptr m_storage_manager; + // https://w3c.github.io/webappsec-credential-management/#active-credential-types + Vector m_active_credential_types; + // https://w3c.github.io/ServiceWorker/#service-worker-client-discarded-flag // A service worker client has an associated discarded flag. It is initially unset. bool m_discarded { false }; diff --git a/Tests/LibWeb/Text/expected/wpt-import/webauthn/createcredential-badargs-rp.https.txt b/Tests/LibWeb/Text/expected/wpt-import/webauthn/createcredential-badargs-rp.https.txt new file mode 100644 index 0000000000000..241ab3f0b9ccb --- /dev/null +++ b/Tests/LibWeb/Text/expected/wpt-import/webauthn/createcredential-badargs-rp.https.txt @@ -0,0 +1,18 @@ +Harness status: OK + +Found 12 tests + +1 Pass +11 Fail +Fail Bad rp: rp missing +Fail Bad rp: rp null +Fail Bad rp: rp is string +Fail Bad rp: rp is empty object +Fail Bad rp: id is null +Fail Bad rp: id is empty String +Fail Bad rp: id is invalid domain (has space) +Fail Bad rp: id is invalid domain (starts with dash) +Fail Bad rp: id is invalid domain (starts with number) +Fail Bad rp id: id is host + port +Fail rp missing name +Pass Clean up the test environment \ No newline at end of file diff --git a/Tests/LibWeb/Text/input/wpt-import/resources/testdriver-vendor.js b/Tests/LibWeb/Text/input/wpt-import/resources/testdriver-vendor.js index 54b62143917ae..65963ba1ad0c0 100644 --- a/Tests/LibWeb/Text/input/wpt-import/resources/testdriver-vendor.js +++ b/Tests/LibWeb/Text/input/wpt-import/resources/testdriver-vendor.js @@ -20,3 +20,11 @@ window.test_driver_internal.get_computed_label = async function(element) { window.test_driver_internal.get_computed_role = async function(element) { return await window.internals.getComputedRole(element); }; + +window.test_driver_internal.add_virtual_authenticator = function(config, context=null) { + return Promise.resolve("hello") +} + +window.test_driver_internal.remove_virtual_authenticator = function(config, context=null) { + return Promise.resolve("hello") +} diff --git a/Tests/LibWeb/Text/input/wpt-import/webauthn/createcredential-badargs-rp.https.html b/Tests/LibWeb/Text/input/wpt-import/webauthn/createcredential-badargs-rp.https.html new file mode 100644 index 0000000000000..f65711fce366c --- /dev/null +++ b/Tests/LibWeb/Text/input/wpt-import/webauthn/createcredential-badargs-rp.https.html @@ -0,0 +1,42 @@ + + +WebAuthn navigator.credentials.create() rp Tests + + + + + + + + + + diff --git a/Tests/LibWeb/Text/input/wpt-import/webauthn/helpers.js b/Tests/LibWeb/Text/input/wpt-import/webauthn/helpers.js new file mode 100644 index 0000000000000..a45b686ca775c --- /dev/null +++ b/Tests/LibWeb/Text/input/wpt-import/webauthn/helpers.js @@ -0,0 +1,679 @@ +// Useful constants for working with COSE key objects +const cose_kty = 1; +const cose_kty_ec2 = 2; +const cose_alg = 3; +const cose_alg_ECDSA_w_SHA256 = -7; +const cose_alg_ECDSA_w_SHA512 = -36; +const cose_crv = -1; +const cose_crv_P256 = 1; +const cose_crv_x = -2; +const cose_crv_y = -3; + +/** + * These are the default arguments that will be passed to navigator.credentials.create() + * unless modified by a specific test case + */ +var createCredentialDefaultArgs = { + options: { + publicKey: { + // Relying Party: + rp: { + name: "Acme", + }, + + // User: + user: { + id: new Uint8Array(16), // Won't survive the copy, must be rebuilt + name: "john.p.smith@example.com", + displayName: "John P. Smith", + }, + + pubKeyCredParams: [{ + type: "public-key", + alg: cose_alg_ECDSA_w_SHA256, + }], + + authenticatorSelection: { + requireResidentKey: false, + }, + + timeout: 60000, // 1 minute + excludeCredentials: [] // No excludeList + } + } +}; + +/** + * These are the default arguments that will be passed to navigator.credentials.get() + * unless modified by a specific test case + */ +var getCredentialDefaultArgs = { + options: { + publicKey: { + timeout: 60000 + // allowCredentials: [newCredential] + } + } +}; + +function createCredential(opts) { + opts = opts || {}; + + // set the default options + var createArgs = cloneObject(createCredentialDefaultArgs); + let challengeBytes = new Uint8Array(16); + window.crypto.getRandomValues(challengeBytes); + createArgs.options.publicKey.challenge = challengeBytes; + createArgs.options.publicKey.user.id = new Uint8Array(16); + + // change the defaults with any options that were passed in + extendObject(createArgs, opts); + + // create the credential, return the Promise + return navigator.credentials.create(createArgs.options); +} + +function assertCredential(credential) { + var options = cloneObject(getCredentialDefaultArgs); + let challengeBytes = new Uint8Array(16); + window.crypto.getRandomValues(challengeBytes); + options.challenge = challengeBytes; + options.allowCredentials = [{type: 'public-key', id: credential.rawId}]; + return navigator.credentials.get({publicKey: options}); +} + +function createRandomString(len) { + var text = ""; + var possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + for(var i = 0; i < len; i++) { + text += possible.charAt(Math.floor(Math.random() * possible.length)); + } + return text; +} + + +function ab2str(buf) { + return String.fromCharCode.apply(null, new Uint8Array(buf)); +} + +// Useful constants for working with attestation data +const authenticator_data_user_present = 0x01; +const authenticator_data_user_verified = 0x04; +const authenticator_data_attested_cred_data = 0x40; +const authenticator_data_extension_data = 0x80; + +function parseAuthenticatorData(buf) { + if (buf.byteLength < 37) { + throw new TypeError ("parseAuthenticatorData: buffer must be at least 37 bytes"); + } + + printHex ("authnrData", buf); + + var authnrData = new DataView(buf); + var authnrDataObj = {}; + authnrDataObj.length = buf.byteLength; + + authnrDataObj.rpIdHash = new Uint8Array (buf.slice (0,32)); + authnrDataObj.rawFlags = authnrData.getUint8(32); + authnrDataObj.counter = authnrData.getUint32(33, false); + authnrDataObj.rawCounter = []; + authnrDataObj.rawCounter[0] = authnrData.getUint8(33); + authnrDataObj.rawCounter[1] = authnrData.getUint8(34); + authnrDataObj.rawCounter[2] = authnrData.getUint8(35); + authnrDataObj.rawCounter[3] = authnrData.getUint8(36); + authnrDataObj.flags = {}; + + authnrDataObj.flags.userPresent = (authnrDataObj.rawFlags&authenticator_data_user_present)?true:false; + authnrDataObj.flags.userVerified = (authnrDataObj.rawFlags&authenticator_data_user_verified)?true:false; + authnrDataObj.flags.attestedCredentialData = (authnrDataObj.rawFlags&authenticator_data_attested_cred_data)?true:false; + authnrDataObj.flags.extensionData = (authnrDataObj.rawFlags&authenticator_data_extension_data)?true:false; + + return authnrDataObj; +} + +/** + * TestCase + * + * A generic template for test cases + * Is intended to be overloaded with subclasses that override testObject, testFunction and argOrder + * The testObject is the default arguments for the testFunction + * The default testObject can be modified with the modify() method, making it easy to create new tests based on the default + * The testFunction is the target of the test and is called by the doIt() method. doIt() applies the testObject as arguments via toArgs() + * toArgs() uses argOrder to make sure the resulting array is in the right order of the arguments for the testFunction + */ +class TestCase { + constructor() { + this.testFunction = function() { + throw new Error("Test Function not implemented"); + }; + this.testObject = {}; + this.argOrder = []; + this.ctx = null; + } + + /** + * toObject + * + * return a copy of the testObject + */ + toObject() { + return JSON.parse(JSON.stringify(this.testObject)); // cheap clone + } + + /** + * toArgs + * + * converts test object to an array that is ordered in the same way as the arguments to the test function + */ + toArgs() { + var ret = []; + // XXX, TODO: this won't necessarily produce the args in the right order + for (let idx of this.argOrder) { + ret.push(this.testObject[idx]); + } + return ret; + } + + /** + * modify + * + * update the internal object by a path / value combination + * e.g. : + * modify ("foo.bar", 3) + * accepts three types of args: + * "foo.bar", 3 + * {path: "foo.bar", value: 3} + * [{path: "foo.bar", value: 3}, ...] + */ + modify(arg1, arg2) { + var mods; + + // check for the two argument scenario + if (typeof arg1 === "string" && arg2 !== undefined) { + mods = { + path: arg1, + value: arg2 + }; + } else { + mods = arg1; + } + + // accept a single modification object instead of an array + if (!Array.isArray(mods) && typeof mods === "object") { + mods = [mods]; + } + + // iterate through each of the desired modifications, and call recursiveSetObject on them + for (let idx in mods) { + var mod = mods[idx]; + let paths = mod.path.split("."); + recursiveSetObject(this.testObject, paths, mod.value); + } + + // iterates through nested `obj` using the `pathArray`, creating the path if it doesn't exist + // when the final leaf of the path is found, it is assigned the specified value + function recursiveSetObject(obj, pathArray, value) { + var currPath = pathArray.shift(); + if (typeof obj[currPath] !== "object") { + obj[currPath] = {}; + } + if (pathArray.length > 0) { + return recursiveSetObject(obj[currPath], pathArray, value); + } + obj[currPath] = value; + } + + return this; + } + + /** + * actually runs the test function with the supplied arguments + */ + doIt() { + if (typeof this.testFunction !== "function") { + throw new Error("Test function not found"); + } + + return this.testFunction.call(this.ctx, ...this.toArgs()); + } + + /** + * run the test function with the top-level properties of the test object applied as arguments + * expects the test to pass, and then validates the results + */ + testPasses(desc) { + return this.doIt() + .then((ret) => { + // check the result + this.validateRet(ret); + return ret; + }); + } + + /** + * run the test function with the top-level properties of the test object applied as arguments + * expects the test to fail + */ + testFails(t, testDesc, expectedErr) { + if (typeof expectedErr == "string") { + return promise_rejects_dom(t, expectedErr, this.doIt(), "Expected bad parameters to fail"); + } + + return promise_rejects_js(t, expectedErr, this.doIt(), "Expected bad parameters to fail"); + } + + /** + * Runs the test that's implemented by the class by calling the doIt() function + * @param {String} desc A description of the test being run + * @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 + */ + runTest(desc, expectedErr) { + promise_test((t) => { + return Promise.resolve().then(() => { + return this.testSetup(); + }).then(() => { + if (expectedErr === undefined) { + return this.testPasses(desc); + } else { + return this.testFails(t, desc, expectedErr); + } + }).then((res) => { + return this.testTeardown(res); + }) + }, desc) + } + + /** + * called before runTest + * virtual method expected to be overridden by child class if needed + */ + testSetup() { + if (this.beforeTestFn) { + this.beforeTestFn.call(this); + } + + return Promise.resolve(); + } + + /** + * Adds a callback function that gets called in the TestCase context + * and within the testing process. + */ + beforeTest(fn) { + if (typeof fn !== "function") { + throw new Error ("Tried to call non-function before test"); + } + + this.beforeTestFn = fn; + + return this; + } + + /** + * called after runTest + * virtual method expected to be overridden by child class if needed + */ + testTeardown(res) { + if (this.afterTestFn) { + this.afterTestFn.call(this, res); + } + + return Promise.resolve(); + } + + /** + * Adds a callback function that gets called in the TestCase context + * and within the testing process. Good for validating results. + */ + afterTest(fn) { + if (typeof fn !== "function") { + throw new Error ("Tried to call non-function after test"); + } + + this.afterTestFn = fn; + + return this; + } + + /** + * validates the value returned from the test function + * virtual method expected to be overridden by child class + */ + validateRet() { + throw new Error("Not implemented"); + } +} + +function cloneObject(o) { + return JSON.parse(JSON.stringify(o)); +} + +function extendObject(dst, src) { + Object.keys(src).forEach(function(key) { + if (isSimpleObject(src[key]) && !isAbortSignal(src[key])) { + dst[key] ||= {}; + extendObject(dst[key], src[key]); + } else { + dst[key] = src[key]; + } + }); +} + +function isSimpleObject(o) { + return (typeof o === "object" && + !Array.isArray(o) && + !(o instanceof ArrayBuffer) && + !(o instanceof Uint8Array)); +} + +function isAbortSignal(o) { + return (o instanceof AbortSignal); +} + +/** + * CreateCredentialTest + * + * tests the WebAuthn navigator.credentials.create() interface + */ +class CreateCredentialsTest extends TestCase { + constructor() { + // initialize the parent class + super(); + + // the function to be tested + this.testFunction = navigator.credentials.create; + // the context to call the test function with (i.e. - the 'this' object for the function) + this.ctx = navigator.credentials; + + // the default object to pass to makeCredential, to be modified with modify() for various tests + let challengeBytes = new Uint8Array(16); + window.crypto.getRandomValues(challengeBytes); + this.testObject = cloneObject(createCredentialDefaultArgs); + // cloneObject can't clone the BufferSource in user.id, so let's recreate it. + this.testObject.options.publicKey.user.id = new Uint8Array(16); + this.testObject.options.publicKey.challenge = challengeBytes; + + // how to order the properties of testObject when passing them to makeCredential + this.argOrder = [ + "options" + ]; + + // enable the constructor to modify the default testObject + // would prefer to do this in the super class, but have to call super() before using `this.*` + if (arguments.length) this.modify(...arguments); + } + + validateRet(ret) { + validatePublicKeyCredential(ret); + validateAuthenticatorAttestationResponse(ret.response); + } +} + +/** + * GetCredentialsTest + * + * tests the WebAuthn navigator.credentials.get() interface + */ +class GetCredentialsTest extends TestCase { + constructor(...args) { + // initialize the parent class + super(); + + // the function to be tested + this.testFunction = navigator.credentials.get; + // the context to call the test function with (i.e. - the 'this' object for the function) + this.ctx = navigator.credentials; + + // default arguments + let challengeBytes = new Uint8Array(16); + window.crypto.getRandomValues(challengeBytes); + this.testObject = cloneObject(getCredentialDefaultArgs); + this.testObject.options.publicKey.challenge = challengeBytes; + + // how to order the properties of testObject when passing them to makeCredential + this.argOrder = [ + "options" + ]; + + this.credentialPromiseList = []; + + // set to true to pass an empty allowCredentials list to credentials.get + this.isResidentKeyTest = false; + + // enable the constructor to modify the default testObject + // would prefer to do this in the super class, but have to call super() before using `this.*` + if (arguments.length) { + if (args.cred instanceof Promise) this.credPromise = args.cred; + else if (typeof args.cred === "object") this.credPromise = Promise.resolve(args.cred); + delete args.cred; + this.modify(...arguments); + } + } + + addCredential(arg) { + // if a Promise was passed in, add it to the list + if (arg instanceof Promise) { + this.credentialPromiseList.push(arg); + return this; + } + + // if a credential object was passed in, convert it to a Promise for consistency + if (typeof arg === "object") { + this.credentialPromiseList.push(Promise.resolve(arg)); + return this; + } + + // if no credential specified then create one + var p = createCredential(); + this.credentialPromiseList.push(p); + + return this; + } + + testSetup(desc) { + if (!this.credentialPromiseList.length) { + throw new Error("Attempting list without defining credential to test"); + } + + return Promise.all(this.credentialPromiseList) + .then((credList) => { + var idList = credList.map((cred) => { + return { + id: cred.rawId, + transports: ["usb", "nfc", "ble"], + type: "public-key" + }; + }); + if (!this.isResidentKeyTest) { + this.testObject.options.publicKey.allowCredentials = idList; + } + // return super.test(desc); + }) + .catch((err) => { + throw Error(err); + }); + } + + validateRet(ret) { + validatePublicKeyCredential(ret); + validateAuthenticatorAssertionResponse(ret.response); + } + + setIsResidentKeyTest(isResidentKeyTest) { + this.isResidentKeyTest = isResidentKeyTest; + return this; + } +} + +/** + * converts a uint8array to base64 url-safe encoding + * based on similar function in resources/utils.js + */ +function base64urlEncode(array) { + let string = String.fromCharCode.apply(null, array); + let result = btoa(string); + return result.replace(/=+$/g, '').replace(/\+/g, "-").replace(/\//g, "_"); +} +/** + * runs assertions against a PublicKeyCredential object to ensure it is properly formatted + */ +function validatePublicKeyCredential(cred) { + // class + assert_class_string(cred, "PublicKeyCredential", "Expected return to be instance of 'PublicKeyCredential' class"); + // id + assert_idl_attribute(cred, "id", "should return PublicKeyCredential with id attribute"); + assert_readonly(cred, "id", "should return PublicKeyCredential with readonly id attribute"); + // rawId + assert_idl_attribute(cred, "rawId", "should return PublicKeyCredential with rawId attribute"); + assert_readonly(cred, "rawId", "should return PublicKeyCredential with readonly rawId attribute"); + assert_equals(cred.id, base64urlEncode(new Uint8Array(cred.rawId)), "should return PublicKeyCredential with id attribute set to base64 encoding of rawId attribute"); + + // type + assert_idl_attribute(cred, "type", "should return PublicKeyCredential with type attribute"); + assert_equals(cred.type, "public-key", "should return PublicKeyCredential with type 'public-key'"); +} + +/** + * runs assertions against a AuthenticatorAttestationResponse object to ensure it is properly formatted + */ +function validateAuthenticatorAttestationResponse(attr) { + // class + assert_class_string(attr, "AuthenticatorAttestationResponse", "Expected credentials.create() to return instance of 'AuthenticatorAttestationResponse' class"); + + // clientDataJSON + assert_idl_attribute(attr, "clientDataJSON", "credentials.create() should return AuthenticatorAttestationResponse with clientDataJSON attribute"); + assert_readonly(attr, "clientDataJSON", "credentials.create() should return AuthenticatorAttestationResponse with readonly clientDataJSON attribute"); + // TODO: clientDataJSON() and make sure fields are correct + + // attestationObject + assert_idl_attribute(attr, "attestationObject", "credentials.create() should return AuthenticatorAttestationResponse with attestationObject attribute"); + assert_readonly(attr, "attestationObject", "credentials.create() should return AuthenticatorAttestationResponse with readonly attestationObject attribute"); + // TODO: parseAuthenticatorData() and make sure flags are correct +} + +/** + * runs assertions against a AuthenticatorAssertionResponse object to ensure it is properly formatted + */ +function validateAuthenticatorAssertionResponse(assert) { + // class + assert_class_string(assert, "AuthenticatorAssertionResponse", "Expected credentials.create() to return instance of 'AuthenticatorAssertionResponse' class"); + + // clientDataJSON + assert_idl_attribute(assert, "clientDataJSON", "credentials.get() should return AuthenticatorAssertionResponse with clientDataJSON attribute"); + assert_readonly(assert, "clientDataJSON", "credentials.get() should return AuthenticatorAssertionResponse with readonly clientDataJSON attribute"); + // TODO: clientDataJSON() and make sure fields are correct + + // signature + assert_idl_attribute(assert, "signature", "credentials.get() should return AuthenticatorAssertionResponse with signature attribute"); + assert_readonly(assert, "signature", "credentials.get() should return AuthenticatorAssertionResponse with readonly signature attribute"); + + // authenticatorData + assert_idl_attribute(assert, "authenticatorData", "credentials.get() should return AuthenticatorAssertionResponse with authenticatorData attribute"); + assert_readonly(assert, "authenticatorData", "credentials.get() should return AuthenticatorAssertionResponse with readonly authenticatorData attribute"); + // TODO: parseAuthenticatorData() and make sure flags are correct +} + +function defaultAuthenticatorArgs() { + return { + protocol: 'ctap1/u2f', + transport: 'usb', + hasResidentKey: false, + hasUserVerification: false, + isUserVerified: false, + }; +} + +function standardSetup(cb, options = {}) { + // Setup an automated testing environment if available. + let authenticatorArgs = Object.assign(defaultAuthenticatorArgs(), options); + window.test_driver.add_virtual_authenticator(authenticatorArgs) + .then(authenticator => { + cb(); + // XXX add a subtest to clean up the virtual authenticator since + // testharness does not support waiting for promises on cleanup. + promise_test( + () => + window.test_driver.remove_virtual_authenticator(authenticator), + 'Clean up the test environment'); + }) + .catch(error => { + if (error !== + 'error: Action add_virtual_authenticator not implemented') { + throw error; + } + // The protocol is not available. Continue manually. + cb(); + }); +} + +// virtualAuthenticatorPromiseTest runs |testCb| in a promise_test with a +// virtual authenticator set up before and destroyed after the test, if the +// virtual testing API is available. In manual tests, setup and teardown is +// skipped. +function virtualAuthenticatorPromiseTest( + testCb, options = {}, name = 'Virtual Authenticator Test') { + let authenticatorArgs = Object.assign(defaultAuthenticatorArgs(), options); + promise_test(async t => { + let authenticator; + try { + authenticator = + await window.test_driver.add_virtual_authenticator(authenticatorArgs); + t.add_cleanup( + () => window.test_driver.remove_virtual_authenticator(authenticator)); + } catch (error) { + if (error !== 'error: Action add_virtual_authenticator not implemented') { + throw error; + } + } + return testCb(t, authenticator); + }, name); +} + +function bytesEqual(a, b) { + if (a instanceof ArrayBuffer) { + a = new Uint8Array(a); + } + if (b instanceof ArrayBuffer) { + b = new Uint8Array(b); + } + if (a.byteLength != b.byteLength) { + return false; + } + for (let i = 0; i < a.byteLength; i++) { + if (a[i] != b[i]) { + return false; + } + } + return true; +} + +// Compares two PublicKeyCredentialUserEntity objects. +function userEntityEquals(a, b) { + return bytesEqual(a.id, b.id) && a.name == b.name && a.displayName == b.displayName; +} + +// Asserts that `actual` and `expected`, which are both JSON types, are equal. +// The object key order is ignored for comparison. +function assertJsonEquals(actual, expected, optMsg) { + // Returns a copy of `jsonObj`, which must be a JSON type, with object keys + // recursively sorted in lexicographic order; or simply `jsonObj` if it is not + // an instance of Object. + function deepSortKeys(jsonObj) { + if (jsonObj instanceof Array) { + return Array.from(jsonObj, (x) => { return deepSortKeys(x); }) + } + if (typeof jsonObj !== 'object' || jsonObj === null || + jsonObj.__proto__.constructor !== Object || + Object.keys(jsonObj).length === 0) { + return jsonObj; + } + return Object.keys(jsonObj).sort().reduce((acc, key) => { + acc[key] = deepSortKeys(jsonObj[key]); + return acc; + }, {}); + } + + assert_equals( + JSON.stringify(deepSortKeys(actual)), + JSON.stringify(deepSortKeys(expected)), optMsg); +}