diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
commit | 26a029d407be480d791972afb5975cf62c9360a6 (patch) | |
tree | f435a8308119effd964b339f76abb83a57c29483 /services/fxaccounts | |
parent | Initial commit. (diff) | |
download | firefox-upstream/124.0.1.tar.xz firefox-upstream/124.0.1.zip |
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to '')
47 files changed, 22538 insertions, 0 deletions
diff --git a/services/fxaccounts/Credentials.sys.mjs b/services/fxaccounts/Credentials.sys.mjs new file mode 100644 index 0000000000..30c88fafdc --- /dev/null +++ b/services/fxaccounts/Credentials.sys.mjs @@ -0,0 +1,134 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * This module implements client-side key stretching for use in Firefox + * Accounts account creation and login. + * + * See https://github.com/mozilla/fxa-auth-server/wiki/onepw-protocol + */ + +import { Log } from "resource://gre/modules/Log.sys.mjs"; + +import { CryptoUtils } from "resource://services-crypto/utils.sys.mjs"; + +import { CommonUtils } from "resource://services-common/utils.sys.mjs"; + +const PROTOCOL_VERSION = "identity.mozilla.com/picl/v1/"; +const PBKDF2_ROUNDS = 1000; +const STRETCHED_PW_LENGTH_BYTES = 32; +const HKDF_SALT = CommonUtils.hexToBytes("00"); +const HKDF_LENGTH = 32; + +// loglevel preference should be one of: "FATAL", "ERROR", "WARN", "INFO", +// "CONFIG", "DEBUG", "TRACE" or "ALL". We will be logging error messages by +// default. +const PREF_LOG_LEVEL = "identity.fxaccounts.loglevel"; +let LOG_LEVEL = Log.Level.Error; +try { + LOG_LEVEL = + Services.prefs.getPrefType(PREF_LOG_LEVEL) == + Ci.nsIPrefBranch.PREF_STRING && + Services.prefs.getStringPref(PREF_LOG_LEVEL); +} catch (e) {} + +var log = Log.repository.getLogger("Identity.FxAccounts"); +log.level = LOG_LEVEL; +log.addAppender(new Log.ConsoleAppender(new Log.BasicFormatter())); + +export var Credentials = Object.freeze({ + /** + * Make constants accessible to tests + */ + constants: { + PROTOCOL_VERSION, + PBKDF2_ROUNDS, + STRETCHED_PW_LENGTH_BYTES, + HKDF_SALT, + HKDF_LENGTH, + }, + + /** + * KW function from https://github.com/mozilla/fxa-auth-server/wiki/onepw-protocol + * + * keyWord derivation for use as a salt. + * + * + * @param {String} context String for use in generating salt + * + * @return {bitArray} the salt + * + * Note that PROTOCOL_VERSION does not refer in any way to the version of the + * Firefox Accounts API. + */ + keyWord(context) { + return CommonUtils.stringToBytes(PROTOCOL_VERSION + context); + }, + + /** + * KWE function from https://github.com/mozilla/fxa-auth-server/wiki/onepw-protocol + * + * keyWord extended with a name and an email. + * + * @param {String} name The name of the salt + * @param {String} email The email of the user. + * + * @return {bitArray} the salt combination with the namespace + * + * Note that PROTOCOL_VERSION does not refer in any way to the version of the + * Firefox Accounts API. + */ + keyWordExtended(name, email) { + return CommonUtils.stringToBytes(PROTOCOL_VERSION + name + ":" + email); + }, + + setup(emailInput, passwordInput, options = {}) { + return new Promise(resolve => { + log.debug("setup credentials for " + emailInput); + + let hkdfSalt = options.hkdfSalt || HKDF_SALT; + let hkdfLength = options.hkdfLength || HKDF_LENGTH; + let stretchedPWLength = + options.stretchedPassLength || STRETCHED_PW_LENGTH_BYTES; + let pbkdf2Rounds = options.pbkdf2Rounds || PBKDF2_ROUNDS; + + let result = {}; + + let password = CommonUtils.encodeUTF8(passwordInput); + let salt = this.keyWordExtended("quickStretch", emailInput); + + let runnable = async () => { + let start = Date.now(); + let quickStretchedPW = await CryptoUtils.pbkdf2Generate( + password, + salt, + pbkdf2Rounds, + stretchedPWLength + ); + + result.quickStretchedPW = quickStretchedPW; + + result.authPW = await CryptoUtils.hkdfLegacy( + quickStretchedPW, + hkdfSalt, + this.keyWord("authPW"), + hkdfLength + ); + + result.unwrapBKey = await CryptoUtils.hkdfLegacy( + quickStretchedPW, + hkdfSalt, + this.keyWord("unwrapBkey"), + hkdfLength + ); + + log.debug("Credentials set up after " + (Date.now() - start) + " ms"); + resolve(result); + }; + + Services.tm.dispatchToMainThread(runnable); + log.debug("Dispatched thread for credentials setup crypto work"); + }); + }, +}); diff --git a/services/fxaccounts/FxAccounts.sys.mjs b/services/fxaccounts/FxAccounts.sys.mjs new file mode 100644 index 0000000000..18169c6b2d --- /dev/null +++ b/services/fxaccounts/FxAccounts.sys.mjs @@ -0,0 +1,1657 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { CryptoUtils } from "resource://services-crypto/utils.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; +import { clearTimeout, setTimeout } from "resource://gre/modules/Timer.sys.mjs"; + +import { FxAccountsStorageManager } from "resource://gre/modules/FxAccountsStorage.sys.mjs"; + +import { + ERRNO_INVALID_AUTH_TOKEN, + ERROR_AUTH_ERROR, + ERROR_INVALID_PARAMETER, + ERROR_NO_ACCOUNT, + ERROR_TO_GENERAL_ERROR_CLASS, + ERROR_UNKNOWN, + ERROR_UNVERIFIED_ACCOUNT, + FXA_PWDMGR_PLAINTEXT_FIELDS, + FXA_PWDMGR_REAUTH_ALLOWLIST, + FXA_PWDMGR_SECURE_FIELDS, + FX_OAUTH_CLIENT_ID, + ON_ACCOUNT_STATE_CHANGE_NOTIFICATION, + ONLOGIN_NOTIFICATION, + ONLOGOUT_NOTIFICATION, + ON_PRELOGOUT_NOTIFICATION, + ONVERIFIED_NOTIFICATION, + ON_DEVICE_DISCONNECTED_NOTIFICATION, + POLL_SESSION, + PREF_ACCOUNT_ROOT, + PREF_LAST_FXA_USER, + SERVER_ERRNO_TO_ERROR, + log, + logPII, + logManager, +} from "resource://gre/modules/FxAccountsCommon.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + FxAccountsClient: "resource://gre/modules/FxAccountsClient.sys.mjs", + FxAccountsCommands: "resource://gre/modules/FxAccountsCommands.sys.mjs", + FxAccountsConfig: "resource://gre/modules/FxAccountsConfig.sys.mjs", + FxAccountsDevice: "resource://gre/modules/FxAccountsDevice.sys.mjs", + FxAccountsKeys: "resource://gre/modules/FxAccountsKeys.sys.mjs", + FxAccountsOAuth: "resource://gre/modules/FxAccountsOAuth.sys.mjs", + FxAccountsProfile: "resource://gre/modules/FxAccountsProfile.sys.mjs", + FxAccountsTelemetry: "resource://gre/modules/FxAccountsTelemetry.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "mpLocked", () => { + return ChromeUtils.importESModule("resource://services-sync/util.sys.mjs") + .Utils.mpLocked; +}); + +ChromeUtils.defineLazyGetter(lazy, "ensureMPUnlocked", () => { + return ChromeUtils.importESModule("resource://services-sync/util.sys.mjs") + .Utils.ensureMPUnlocked; +}); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "FXA_ENABLED", + "identity.fxaccounts.enabled", + true +); + +// An AccountState object holds all state related to one specific account. +// It is considered "private" to the FxAccounts modules. +// Only one AccountState is ever "current" in the FxAccountsInternal object - +// whenever a user logs out or logs in, the current AccountState is discarded, +// making it impossible for the wrong state or state data to be accidentally +// used. +// In addition, it has some promise-related helpers to ensure that if an +// attempt is made to resolve a promise on a "stale" state (eg, if an +// operation starts, but a different user logs in before the operation +// completes), the promise will be rejected. +// It is intended to be used thusly: +// somePromiseBasedFunction: function() { +// let currentState = this.currentAccountState; +// return someOtherPromiseFunction().then( +// data => currentState.resolve(data) +// ); +// } +// If the state has changed between the function being called and the promise +// being resolved, the .resolve() call will actually be rejected. +export function AccountState(storageManager) { + this.storageManager = storageManager; + this.inFlightTokenRequests = new Map(); + this.promiseInitialized = this.storageManager + .getAccountData() + .then(data => { + this.oauthTokens = data && data.oauthTokens ? data.oauthTokens : {}; + }) + .catch(err => { + log.error("Failed to initialize the storage manager", err); + // Things are going to fall apart, but not much we can do about it here. + }); +} + +AccountState.prototype = { + oauthTokens: null, + whenVerifiedDeferred: null, + whenKeysReadyDeferred: null, + + // If the storage manager has been nuked then we are no longer current. + get isCurrent() { + return this.storageManager != null; + }, + + abort() { + if (this.whenVerifiedDeferred) { + this.whenVerifiedDeferred.reject( + new Error("Verification aborted; Another user signing in") + ); + this.whenVerifiedDeferred = null; + } + if (this.whenKeysReadyDeferred) { + this.whenKeysReadyDeferred.reject( + new Error("Key fetching aborted; Another user signing in") + ); + this.whenKeysReadyDeferred = null; + } + this.inFlightTokenRequests.clear(); + return this.signOut(); + }, + + // Clobber all cached data and write that empty data to storage. + async signOut() { + this.cert = null; + this.keyPair = null; + this.oauthTokens = null; + this.inFlightTokenRequests.clear(); + + // Avoid finalizing the storageManager multiple times (ie, .signOut() + // followed by .abort()) + if (!this.storageManager) { + return; + } + const storageManager = this.storageManager; + this.storageManager = null; + + await storageManager.deleteAccountData(); + await storageManager.finalize(); + }, + + // Get user account data. Optionally specify explicit field names to fetch + // (and note that if you require an in-memory field you *must* specify the + // field name(s).) + getUserAccountData(fieldNames = null) { + if (!this.isCurrent) { + return Promise.reject(new Error("Another user has signed in")); + } + return this.storageManager.getAccountData(fieldNames).then(result => { + return this.resolve(result); + }); + }, + + async updateUserAccountData(updatedFields) { + if ("uid" in updatedFields) { + const existing = await this.getUserAccountData(["uid"]); + if (existing.uid != updatedFields.uid) { + throw new Error( + "The specified credentials aren't for the current user" + ); + } + // We need to nuke uid as storage will complain if we try and + // update it (even when the value is the same) + updatedFields = Cu.cloneInto(updatedFields, {}); // clone it first + delete updatedFields.uid; + } + if (!this.isCurrent) { + return Promise.reject(new Error("Another user has signed in")); + } + return this.storageManager.updateAccountData(updatedFields); + }, + + resolve(result) { + if (!this.isCurrent) { + log.info( + "An accountState promise was resolved, but was actually rejected" + + " due to a different user being signed in. Originally resolved" + + " with", + result + ); + return Promise.reject(new Error("A different user signed in")); + } + return Promise.resolve(result); + }, + + reject(error) { + // It could be argued that we should just let it reject with the original + // error - but this runs the risk of the error being (eg) a 401, which + // might cause the consumer to attempt some remediation and cause other + // problems. + if (!this.isCurrent) { + log.info( + "An accountState promise was rejected, but we are ignoring that " + + "reason and rejecting it due to a different user being signed in. " + + "Originally rejected with", + error + ); + return Promise.reject(new Error("A different user signed in")); + } + return Promise.reject(error); + }, + + // Abstractions for storage of cached tokens - these are all sync, and don't + // handle revocation etc - it's just storage (and the storage itself is async, + // but we don't return the storage promises, so it *looks* sync) + // These functions are sync simply so we can handle "token races" - when there + // are multiple in-flight requests for the same scope, we can detect this + // and revoke the redundant token. + + // A preamble for the cache helpers... + _cachePreamble() { + if (!this.isCurrent) { + throw new Error("Another user has signed in"); + } + }, + + // Set a cached token. |tokenData| must have a 'token' element, but may also + // have additional fields. + // The 'get' functions below return the entire |tokenData| value. + setCachedToken(scopeArray, tokenData) { + this._cachePreamble(); + if (!tokenData.token) { + throw new Error("No token"); + } + let key = getScopeKey(scopeArray); + this.oauthTokens[key] = tokenData; + // And a background save... + this._persistCachedTokens(); + }, + + // Return data for a cached token or null (or throws on bad state etc) + getCachedToken(scopeArray) { + this._cachePreamble(); + let key = getScopeKey(scopeArray); + let result = this.oauthTokens[key]; + if (result) { + // later we might want to check an expiry date - but we currently + // have no such concept, so just return it. + log.trace("getCachedToken returning cached token"); + return result; + } + return null; + }, + + // Remove a cached token from the cache. Does *not* revoke it from anywhere. + // Returns the entire token entry if found, null otherwise. + removeCachedToken(token) { + this._cachePreamble(); + let data = this.oauthTokens; + for (let [key, tokenValue] of Object.entries(data)) { + if (tokenValue.token == token) { + delete data[key]; + // And a background save... + this._persistCachedTokens(); + return tokenValue; + } + } + return null; + }, + + // A hook-point for tests. Returns a promise that's ignored in most cases + // (notable exceptions are tests and when we explicitly are saving the entire + // set of user data.) + _persistCachedTokens() { + this._cachePreamble(); + return this.updateUserAccountData({ oauthTokens: this.oauthTokens }).catch( + err => { + log.error("Failed to update cached tokens", err); + } + ); + }, +}; + +/* Given an array of scopes, make a string key by normalizing. */ +function getScopeKey(scopeArray) { + let normalizedScopes = scopeArray.map(item => item.toLowerCase()); + return normalizedScopes.sort().join("|"); +} + +function getPropertyDescriptor(obj, prop) { + return ( + Object.getOwnPropertyDescriptor(obj, prop) || + getPropertyDescriptor(Object.getPrototypeOf(obj), prop) + ); +} + +/** + * Copies properties from a given object to another object. + * + * @param from (object) + * The object we read property descriptors from. + * @param to (object) + * The object that we set property descriptors on. + * @param thisObj (object) + * The object that will be used to .bind() all function properties we find to. + * @param keys ([...]) + * The names of all properties to be copied. + */ +function copyObjectProperties(from, to, thisObj, keys) { + for (let prop of keys) { + // Look for the prop in the prototype chain. + let desc = getPropertyDescriptor(from, prop); + + if (typeof desc.value == "function") { + desc.value = desc.value.bind(thisObj); + } + + if (desc.get) { + desc.get = desc.get.bind(thisObj); + } + + if (desc.set) { + desc.set = desc.set.bind(thisObj); + } + + Object.defineProperty(to, prop, desc); + } +} + +/** + * The public API. + * + * TODO - *all* non-underscore stuff here should have sphinx docstrings so + * that docs magically appear on https://firefox-source-docs.mozilla.org/ + * (although |./mach doc| is broken on windows (bug 1232403) and on Linux for + * markh (some obscure npm issue he gave up on) - so later...) + */ +export class FxAccounts { + constructor(mocks = null) { + this._internal = new FxAccountsInternal(); + if (mocks) { + // it's slightly unfortunate that we need to mock the main "internal" object + // before calling initialize, primarily so a mock `newAccountState` is in + // place before initialize calls it, but we need to initialize the + // "sub-object" mocks after. This can probably be fixed, but whatever... + copyObjectProperties( + mocks, + this._internal, + this._internal, + Object.keys(mocks).filter(key => !["device", "commands"].includes(key)) + ); + } + this._internal.initialize(); + // allow mocking our "sub-objects" too. + if (mocks) { + for (let subobject of [ + "currentAccountState", + "keys", + "fxaPushService", + "device", + "commands", + ]) { + if (typeof mocks[subobject] == "object") { + copyObjectProperties( + mocks[subobject], + this._internal[subobject], + this._internal[subobject], + Object.keys(mocks[subobject]) + ); + } + } + } + } + + get commands() { + return this._internal.commands; + } + + static get config() { + return lazy.FxAccountsConfig; + } + + get device() { + return this._internal.device; + } + + get keys() { + return this._internal.keys; + } + + get telemetry() { + return this._internal.telemetry; + } + + _withCurrentAccountState(func) { + return this._internal.withCurrentAccountState(func); + } + + _withVerifiedAccountState(func) { + return this._internal.withVerifiedAccountState(func); + } + + _withSessionToken(func, mustBeVerified = true) { + return this._internal.withSessionToken(func, mustBeVerified); + } + + /** + * Returns an array listing all the OAuth clients connected to the + * authenticated user's account. This includes browsers and web sessions - no + * filtering is done of the set returned by the FxA server. + * + * @typedef {Object} AttachedClient + * @property {String} id - OAuth `client_id` of the client. + * @property {Number} lastAccessedDaysAgo - How many days ago the client last + * accessed the FxA server APIs. + * + * @returns {Array.<AttachedClient>} A list of attached clients. + */ + async listAttachedOAuthClients() { + // We expose last accessed times in 'days ago' + const ONE_DAY = 24 * 60 * 60 * 1000; + + return this._withSessionToken(async sessionToken => { + const response = await this._internal.fxAccountsClient.attachedClients( + sessionToken + ); + const attachedClients = response.body; + const timestamp = response.headers["x-timestamp"]; + const now = + timestamp !== undefined + ? new Date(parseInt(timestamp, 10)) + : Date.now(); + return attachedClients.map(client => { + const daysAgo = client.lastAccessTime + ? Math.max(Math.floor((now - client.lastAccessTime) / ONE_DAY), 0) + : null; + return { + id: client.clientId, + lastAccessedDaysAgo: daysAgo, + }; + }); + }); + } + + /** + * Get an OAuth token for the user. + * + * @param options + * { + * scope: (string/array) the oauth scope(s) being requested. As a + * convenience, you may pass a string if only one scope is + * required, or an array of strings if multiple are needed. + * ttl: (number) OAuth token TTL in seconds. + * } + * + * @return Promise.<string | Error> + * The promise resolves the oauth token as a string or rejects with + * an error object ({error: ERROR, details: {}}) of the following: + * INVALID_PARAMETER + * NO_ACCOUNT + * UNVERIFIED_ACCOUNT + * NETWORK_ERROR + * AUTH_ERROR + * UNKNOWN_ERROR + */ + async getOAuthToken(options = {}) { + try { + return await this._internal.getOAuthToken(options); + } catch (err) { + throw this._internal._errorToErrorClass(err); + } + } + + /** + * Remove an OAuth token from the token cache. Callers should call this + * after they determine a token is invalid, so a new token will be fetched + * on the next call to getOAuthToken(). + * + * @param options + * { + * token: (string) A previously fetched token. + * } + * @return Promise.<undefined> This function will always resolve, even if + * an unknown token is passed. + */ + removeCachedOAuthToken(options) { + return this._internal.removeCachedOAuthToken(options); + } + + /** + * Get details about the user currently signed in to Firefox Accounts. + * + * @return Promise + * The promise resolves to the credentials object of the signed-in user: + * { + * email: String: The user's email address + * uid: String: The user's unique id + * verified: Boolean: email verification status + * displayName: String or null if not known. + * avatar: URL of the avatar for the user. May be the default + * avatar, or null in edge-cases (eg, if there's an account + * issue, etc + * avatarDefault: boolean - whether `avatar` is specific to the user + * or the default avatar. + * } + * + * or null if no user is signed in. This function never fails except + * in pathological cases (eg, file-system errors, etc) + */ + getSignedInUser() { + // Note we don't return the session token, but use it to see if we + // should fetch the profile. + const ACCT_DATA_FIELDS = ["email", "uid", "verified", "sessionToken"]; + const PROFILE_FIELDS = ["displayName", "avatar", "avatarDefault"]; + return this._withCurrentAccountState(async currentState => { + const data = await currentState.getUserAccountData(ACCT_DATA_FIELDS); + if (!data) { + return null; + } + if (!lazy.FXA_ENABLED) { + await this.signOut(); + return null; + } + if (!this._internal.isUserEmailVerified(data)) { + // If the email is not verified, start polling for verification, + // but return null right away. We don't want to return a promise + // that might not be fulfilled for a long time. + this._internal.startVerifiedCheck(data); + } + + let profileData = null; + if (data.sessionToken) { + delete data.sessionToken; + try { + profileData = await this._internal.profile.getProfile(); + } catch (error) { + log.error("Could not retrieve profile data", error); + } + } + for (let field of PROFILE_FIELDS) { + data[field] = profileData ? profileData[field] : null; + } + // and email is a special case - if we have profile data we prefer the + // email from that, as the email we stored for the account itself might + // not have been updated if the email changed since the user signed in. + if (profileData && profileData.email) { + data.email = profileData.email; + } + return data; + }); + } + + /** + * Checks the status of the account. Resolves with Promise<boolean>, where + * true indicates the account status is OK and false indicates there's some + * issue with the account - either that there's no user currently signed in, + * the entire account has been deleted (in which case there will be no user + * signed in after this call returns), or that the user must reauthenticate (in + * which case `this.hasLocalSession()` will return `false` after this call + * returns). + * + * Typically used when some external code which uses, for example, oauth tokens + * received a 401 error using the token, or that this external code has some + * other reason to believe the account status may be bad. Note that this will + * be called automatically in many cases - for example, if calls to fetch the + * profile, or fetch keys, etc return a 401, there's no need to call this + * function. + * + * Because this hits the server, you should only call this method when you have + * good reason to believe the session very recently became invalid (eg, because + * you saw an auth related exception from a remote service.) + */ + checkAccountStatus() { + // Note that we don't use _withCurrentAccountState here because that will + // cause an exception to be thrown if we end up signing out due to the + // account not existing, which isn't what we want here. + let state = this._internal.currentAccountState; + return this._internal.checkAccountStatus(state); + } + + /** + * Checks if we have a valid local session state for the current account. + * + * @return Promise + * Resolves with a boolean, with true indicating that we appear to + * have a valid local session, or false if we need to reauthenticate + * with the content server to obtain one. + * Note that this only checks local state, although typically that's + * OK, because we drop the local session information whenever we detect + * we are in this state. However, see checkAccountStatus() for a way to + * check the account and session status with the server, which can be + * considered the canonical, albiet expensive, way to determine the + * status of the account. + */ + hasLocalSession() { + return this._withCurrentAccountState(async state => { + let data = await state.getUserAccountData(["sessionToken"]); + return !!(data && data.sessionToken); + }); + } + + /** Returns a promise that resolves to true if we can currently connect (ie, + * sign in, or re-connect after a password change) to a Firefox Account. + * If this returns false, the caller can assume that some UI was shown + * which tells the user why we could not connect. + * + * Currently, the primary password being locked is the only reason why + * this returns false, and in this scenario, the primary password unlock + * dialog will have been shown. + * + * This currently doesn't need to return a promise, but does so that + * future enhancements, such as other explanatory UI which requires + * async can work without modification of the call-sites. + */ + static canConnectAccount() { + return Promise.resolve(!lazy.mpLocked() || lazy.ensureMPUnlocked()); + } + + /** + * Send a message to a set of devices in the same account + * + * @param deviceIds: (null/string/array) The device IDs to send the message to. + * If null, will be sent to all devices. + * + * @param excludedIds: (null/string/array) If deviceIds is null, this may + * list device IDs which should not receive the message. + * + * @param payload: (object) The payload, which will be JSON.stringified. + * + * @param TTL: How long the message should be retained before it is discarded. + */ + // XXX - used only by sync to tell other devices that the clients collection + // has changed so they should sync asap. The API here is somewhat vague (ie, + // "an object"), but to be useful across devices, the payload really needs + // formalizing. We should try and do something better here. + notifyDevices(deviceIds, excludedIds, payload, TTL) { + return this._internal.notifyDevices(deviceIds, excludedIds, payload, TTL); + } + + /** + * Resend the verification email for the currently signed-in user. + * + */ + resendVerificationEmail() { + return this._withSessionToken((token, currentState) => { + this._internal.startPollEmailStatus(currentState, token, "start"); + return this._internal.fxAccountsClient.resendVerificationEmail(token); + }, false); + } + + async signOut(localOnly) { + // Note that we do not use _withCurrentAccountState here, otherwise we + // end up with an exception due to the user signing out before the call is + // complete - but that's the entire point of this method :) + return this._internal.signOut(localOnly); + } + + // XXX - we should consider killing this - the only reason it is public is + // so that sync can change it when it notices the device name being changed, + // and that could probably be replaced with a pref observer. + updateDeviceRegistration() { + return this._withCurrentAccountState(_ => { + return this._internal.updateDeviceRegistration(); + }); + } + + // we should try and kill this too. + whenVerified(data) { + return this._withCurrentAccountState(_ => { + return this._internal.whenVerified(data); + }); + } + + /** + * Generate a log file for the FxA action that just completed + * and refresh the input & output streams. + */ + async flushLogFile() { + const logType = await logManager.resetFileLog(); + if (logType == logManager.ERROR_LOG_WRITTEN) { + console.error( + "FxA encountered an error - see about:sync-log for the log file." + ); + } + Services.obs.notifyObservers(null, "service:log-manager:flush-log-file"); + } +} + +var FxAccountsInternal = function () {}; + +/** + * The internal API's prototype. + */ +FxAccountsInternal.prototype = { + // Make a local copy of this constant so we can mock it in testing + POLL_SESSION, + + // The timeout (in ms) we use to poll for a verified mail for the first + // VERIFICATION_POLL_START_SLOWDOWN_THRESHOLD minutes if the user has + // logged-in in this session. + VERIFICATION_POLL_TIMEOUT_INITIAL: 60000, // 1 minute. + // All the other cases (> 5 min, on restart etc). + VERIFICATION_POLL_TIMEOUT_SUBSEQUENT: 5 * 60000, // 5 minutes. + // After X minutes, the polling will slow down to _SUBSEQUENT if we have + // logged-in in this session. + VERIFICATION_POLL_START_SLOWDOWN_THRESHOLD: 5, + + _fxAccountsClient: null, + + // All significant initialization should be done in this initialize() method + // to help with our mocking story. + initialize() { + ChromeUtils.defineLazyGetter(this, "fxaPushService", function () { + return Cc["@mozilla.org/fxaccounts/push;1"].getService( + Ci.nsISupports + ).wrappedJSObject; + }); + + this.keys = new lazy.FxAccountsKeys(this); + + if (!this.observerPreloads) { + // A registry of promise-returning functions that `notifyObservers` should + // call before sending notifications. Primarily used so parts of Firefox + // which have yet to load for performance reasons can be force-loaded, and + // thus not miss notifications. + this.observerPreloads = [ + // Sync + () => { + let { Weave } = ChromeUtils.importESModule( + "resource://services-sync/main.sys.mjs" + ); + return Weave.Service.promiseInitialized; + }, + ]; + } + + this.currentTimer = null; + // This object holds details about, and storage for, the current user. It + // is replaced when a different user signs in. Instead of using it directly, + // you should try and use `withCurrentAccountState`. + this.currentAccountState = this.newAccountState(); + }, + + async withCurrentAccountState(func) { + const state = this.currentAccountState; + let result; + try { + result = await func(state); + } catch (ex) { + return state.reject(ex); + } + return state.resolve(result); + }, + + async withVerifiedAccountState(func) { + return this.withCurrentAccountState(async state => { + let data = await state.getUserAccountData(); + if (!data) { + // No signed-in user + throw this._error(ERROR_NO_ACCOUNT); + } + + if (!this.isUserEmailVerified(data)) { + // Signed-in user has not verified email + throw this._error(ERROR_UNVERIFIED_ACCOUNT); + } + return func(state); + }); + }, + + async withSessionToken(func, mustBeVerified = true) { + const state = this.currentAccountState; + let data = await state.getUserAccountData(); + if (!data) { + // No signed-in user + throw this._error(ERROR_NO_ACCOUNT); + } + + if (mustBeVerified && !this.isUserEmailVerified(data)) { + // Signed-in user has not verified email + throw this._error(ERROR_UNVERIFIED_ACCOUNT); + } + + if (!data.sessionToken) { + throw this._error(ERROR_AUTH_ERROR, "no session token"); + } + try { + // Anyone who needs the session token is going to send it to the server, + // so there's a chance we'll see an auth related error - so handle that + // here rather than requiring each caller to remember to. + let result = await func(data.sessionToken, state); + return state.resolve(result); + } catch (err) { + return this._handleTokenError(err); + } + }, + + get fxAccountsClient() { + if (!this._fxAccountsClient) { + this._fxAccountsClient = new lazy.FxAccountsClient(); + } + return this._fxAccountsClient; + }, + + // The profile object used to fetch the actual user profile. + _profile: null, + get profile() { + if (!this._profile) { + let profileServerUrl = Services.urlFormatter.formatURLPref( + "identity.fxaccounts.remote.profile.uri" + ); + this._profile = new lazy.FxAccountsProfile({ + fxa: this, + profileServerUrl, + }); + } + return this._profile; + }, + + _commands: null, + get commands() { + if (!this._commands) { + this._commands = new lazy.FxAccountsCommands(this); + } + return this._commands; + }, + + _device: null, + get device() { + if (!this._device) { + this._device = new lazy.FxAccountsDevice(this); + } + return this._device; + }, + + _oauth: null, + get oauth() { + if (!this._oauth) { + this._oauth = new lazy.FxAccountsOAuth(this.fxAccountsClient); + } + return this._oauth; + }, + + _telemetry: null, + get telemetry() { + if (!this._telemetry) { + this._telemetry = new lazy.FxAccountsTelemetry(this); + } + return this._telemetry; + }, + + beginOAuthFlow(scopes) { + return this.oauth.beginOAuthFlow(scopes); + }, + + completeOAuthFlow(sessionToken, code, state) { + return this.oauth.completeOAuthFlow(sessionToken, code, state); + }, + + setScopedKeys(scopedKeys) { + return this.keys.setScopedKeys(scopedKeys); + }, + + // A hook-point for tests who may want a mocked AccountState or mocked storage. + newAccountState(credentials) { + let storage = new FxAccountsStorageManager(); + storage.initialize(credentials); + return new AccountState(storage); + }, + + notifyDevices(deviceIds, excludedIds, payload, TTL) { + if (typeof deviceIds == "string") { + deviceIds = [deviceIds]; + } + return this.withSessionToken(sessionToken => { + return this.fxAccountsClient.notifyDevices( + sessionToken, + deviceIds, + excludedIds, + payload, + TTL + ); + }); + }, + + /** + * Return the current time in milliseconds as an integer. Allows tests to + * manipulate the date to simulate token expiration. + */ + now() { + return this.fxAccountsClient.now(); + }, + + /** + * Return clock offset in milliseconds, as reported by the fxAccountsClient. + * This can be overridden for testing. + * + * The offset is the number of milliseconds that must be added to the client + * clock to make it equal to the server clock. For example, if the client is + * five minutes ahead of the server, the localtimeOffsetMsec will be -300000. + */ + get localtimeOffsetMsec() { + return this.fxAccountsClient.localtimeOffsetMsec; + }, + + /** + * Ask the server whether the user's email has been verified + */ + checkEmailStatus: function checkEmailStatus(sessionToken, options = {}) { + if (!sessionToken) { + return Promise.reject( + new Error("checkEmailStatus called without a session token") + ); + } + return this.fxAccountsClient + .recoveryEmailStatus(sessionToken, options) + .catch(error => this._handleTokenError(error)); + }, + + // set() makes sure that polling is happening, if necessary. + // get() does not wait for verification, and returns an object even if + // unverified. The caller of get() must check .verified . + // The "fxaccounts:onverified" event will fire only when the verified + // state goes from false to true, so callers must register their observer + // and then call get(). In particular, it will not fire when the account + // was found to be verified in a previous boot: if our stored state says + // the account is verified, the event will never fire. So callers must do: + // register notification observer (go) + // userdata = get() + // if (userdata.verified()) {go()} + + /** + * Set the current user signed in to Firefox Accounts. + * + * @param credentials + * The credentials object obtained by logging in or creating + * an account on the FxA server: + * { + * authAt: The time (seconds since epoch) that this record was + * authenticated + * email: The users email address + * keyFetchToken: a keyFetchToken which has not yet been used + * sessionToken: Session for the FxA server + * uid: The user's unique id + * unwrapBKey: used to unwrap kB, derived locally from the + * password (not revealed to the FxA server) + * verified: true/false + * } + * @return Promise + * The promise resolves to null when the data is saved + * successfully and is rejected on error. + */ + async setSignedInUser(credentials) { + if (!lazy.FXA_ENABLED) { + throw new Error("Cannot call setSignedInUser when FxA is disabled."); + } + for (const pref of Services.prefs.getChildList(PREF_ACCOUNT_ROOT)) { + Services.prefs.clearUserPref(pref); + } + log.debug("setSignedInUser - aborting any existing flows"); + const signedInUser = await this.currentAccountState.getUserAccountData(); + if (signedInUser) { + await this._signOutServer( + signedInUser.sessionToken, + signedInUser.oauthTokens + ); + } + await this.abortExistingFlow(); + let currentAccountState = (this.currentAccountState = this.newAccountState( + Cu.cloneInto(credentials, {}) // Pass a clone of the credentials object. + )); + // This promise waits for storage, but not for verification. + // We're telling the caller that this is durable now (although is that + // really something we should commit to? Why not let the write happen in + // the background? Already does for updateAccountData ;) + await currentAccountState.promiseInitialized; + // Starting point for polling if new user + if (!this.isUserEmailVerified(credentials)) { + this.startVerifiedCheck(credentials); + } + await this.notifyObservers(ONLOGIN_NOTIFICATION); + await this.updateDeviceRegistration(); + return currentAccountState.resolve(); + }, + + /** + * Update account data for the currently signed in user. + * + * @param credentials + * The credentials object containing the fields to be updated. + * This object must contain the |uid| field and it must + * match the currently signed in user. + */ + updateUserAccountData(credentials) { + log.debug( + "updateUserAccountData called with fields", + Object.keys(credentials) + ); + if (logPII()) { + log.debug("updateUserAccountData called with data", credentials); + } + let currentAccountState = this.currentAccountState; + return currentAccountState.promiseInitialized.then(() => { + if (!credentials.uid) { + throw new Error("The specified credentials have no uid"); + } + return currentAccountState.updateUserAccountData(credentials); + }); + }, + + /* + * Reset state such that any previous flow is canceled. + */ + abortExistingFlow() { + if (this.currentTimer) { + log.debug("Polling aborted; Another user signing in"); + clearTimeout(this.currentTimer); + this.currentTimer = 0; + } + if (this._profile) { + this._profile.tearDown(); + this._profile = null; + } + if (this._commands) { + this._commands = null; + } + if (this._device) { + this._device.reset(); + } + // We "abort" the accountState and assume our caller is about to throw it + // away and replace it with a new one. + return this.currentAccountState.abort(); + }, + + async checkVerificationStatus() { + log.trace("checkVerificationStatus"); + let state = this.currentAccountState; + let data = await state.getUserAccountData(); + if (!data) { + log.trace("checkVerificationStatus - no user data"); + return null; + } + + // Always check the verification status, even if the local state indicates + // we're already verified. If the user changed their password, the check + // will fail, and we'll enter the reauth state. + log.trace("checkVerificationStatus - forcing verification status check"); + return this.startPollEmailStatus(state, data.sessionToken, "push"); + }, + + /** Destroyes an OAuth Token by sending a request to the FxA server + * @param { Object } tokenData: The token's data, with `tokenData.token` being the token itself + **/ + destroyOAuthToken(tokenData) { + return this.fxAccountsClient.oauthDestroy( + FX_OAUTH_CLIENT_ID, + tokenData.token + ); + }, + + _destroyAllOAuthTokens(tokenInfos) { + if (!tokenInfos) { + return Promise.resolve(); + } + // let's just destroy them all in parallel... + let promises = []; + for (let tokenInfo of Object.values(tokenInfos)) { + promises.push(this.destroyOAuthToken(tokenInfo)); + } + return Promise.all(promises); + }, + + async signOut(localOnly) { + let sessionToken; + let tokensToRevoke; + const data = await this.currentAccountState.getUserAccountData(); + // Save the sessionToken, tokens before resetting them in _signOutLocal(). + if (data) { + sessionToken = data.sessionToken; + tokensToRevoke = data.oauthTokens; + } + await this.notifyObservers(ON_PRELOGOUT_NOTIFICATION); + await this._signOutLocal(); + if (!localOnly) { + // Do this in the background so *any* slow request won't + // block the local sign out. + Services.tm.dispatchToMainThread(async () => { + await this._signOutServer(sessionToken, tokensToRevoke); + lazy.FxAccountsConfig.resetConfigURLs(); + this.notifyObservers("testhelper-fxa-signout-complete"); + }); + } else { + // We want to do this either way -- but if we're signing out remotely we + // need to wait until we destroy the oauth tokens if we want that to succeed. + lazy.FxAccountsConfig.resetConfigURLs(); + } + return this.notifyObservers(ONLOGOUT_NOTIFICATION); + }, + + async _signOutLocal() { + for (const pref of Services.prefs.getChildList(PREF_ACCOUNT_ROOT)) { + Services.prefs.clearUserPref(pref); + } + await this.currentAccountState.signOut(); + // this "aborts" this.currentAccountState but doesn't make a new one. + await this.abortExistingFlow(); + this.currentAccountState = this.newAccountState(); + return this.currentAccountState.promiseInitialized; + }, + + async _signOutServer(sessionToken, tokensToRevoke) { + log.debug("Unsubscribing from FxA push."); + try { + await this.fxaPushService.unsubscribe(); + } catch (err) { + log.error("Could not unsubscribe from push.", err); + } + if (sessionToken) { + log.debug("Destroying session and device."); + try { + await this.fxAccountsClient.signOut(sessionToken, { service: "sync" }); + } catch (err) { + log.error("Error during remote sign out of Firefox Accounts", err); + } + } else { + log.warn("Missing session token; skipping remote sign out"); + } + log.debug("Destroying all OAuth tokens."); + try { + await this._destroyAllOAuthTokens(tokensToRevoke); + } catch (err) { + log.error("Error during destruction of oauth tokens during signout", err); + } + }, + + getUserAccountData(fieldNames = null) { + return this.currentAccountState.getUserAccountData(fieldNames); + }, + + isUserEmailVerified: function isUserEmailVerified(data) { + return !!(data && data.verified); + }, + + /** + * Setup for and if necessary do email verification polling. + */ + loadAndPoll() { + let currentState = this.currentAccountState; + return currentState.getUserAccountData().then(data => { + if (data) { + if (!this.isUserEmailVerified(data)) { + this.startPollEmailStatus( + currentState, + data.sessionToken, + "browser-startup" + ); + } + } + return data; + }); + }, + + startVerifiedCheck(data) { + log.debug("startVerifiedCheck", data && data.verified); + if (logPII()) { + log.debug("startVerifiedCheck with user data", data); + } + + // Get us to the verified state. This returns a promise that will fire when + // verification is complete. + + // The callers of startVerifiedCheck never consume a returned promise (ie, + // this is simply kicking off a background fetch) so we must add a rejection + // handler to avoid runtime warnings about the rejection not being handled. + this.whenVerified(data).catch(err => + log.info("startVerifiedCheck promise was rejected: " + err) + ); + }, + + whenVerified(data) { + let currentState = this.currentAccountState; + if (data.verified) { + log.debug("already verified"); + return currentState.resolve(data); + } + if (!currentState.whenVerifiedDeferred) { + log.debug("whenVerified promise starts polling for verified email"); + this.startPollEmailStatus(currentState, data.sessionToken, "start"); + } + return currentState.whenVerifiedDeferred.promise.then(result => + currentState.resolve(result) + ); + }, + + async notifyObservers(topic, data) { + for (let f of this.observerPreloads) { + try { + await f(); + } catch (O_o) {} + } + log.debug("Notifying observers of " + topic); + Services.obs.notifyObservers(null, topic, data); + }, + + startPollEmailStatus(currentState, sessionToken, why) { + log.debug("entering startPollEmailStatus: " + why); + // If we were already polling, stop and start again. This could happen + // if the user requested the verification email to be resent while we + // were already polling for receipt of an earlier email. + if (this.currentTimer) { + log.debug( + "startPollEmailStatus starting while existing timer is running" + ); + clearTimeout(this.currentTimer); + this.currentTimer = null; + } + + this.pollStartDate = Date.now(); + if (!currentState.whenVerifiedDeferred) { + currentState.whenVerifiedDeferred = Promise.withResolvers(); + // This deferred might not end up with any handlers (eg, if sync + // is yet to start up.) This might cause "A promise chain failed to + // handle a rejection" messages, so add an error handler directly + // on the promise to log the error. + currentState.whenVerifiedDeferred.promise.then( + () => { + log.info("the user became verified"); + // We are now ready for business. This should only be invoked once + // per setSignedInUser(), regardless of whether we've rebooted since + // setSignedInUser() was called. + this.notifyObservers(ONVERIFIED_NOTIFICATION); + }, + err => { + log.info("the wait for user verification was stopped: " + err); + } + ); + } + return this.pollEmailStatus(currentState, sessionToken, why); + }, + + // We return a promise for testing only. Other callers can ignore this, + // since verification polling continues in the background. + async pollEmailStatus(currentState, sessionToken, why) { + log.debug("entering pollEmailStatus: " + why); + let nextPollMs; + try { + const response = await this.checkEmailStatus(sessionToken, { + reason: why, + }); + log.debug("checkEmailStatus -> " + JSON.stringify(response)); + if (response && response.verified) { + await this.onPollEmailSuccess(currentState); + return; + } + } catch (error) { + if (error && error.code && error.code == 401) { + let error = new Error("Verification status check failed"); + this._rejectWhenVerified(currentState, error); + return; + } + if (error && error.retryAfter) { + // If the server told us to back off, back off the requested amount. + nextPollMs = (error.retryAfter + 3) * 1000; + log.warn( + `the server rejected our email status check and told us to try again in ${nextPollMs}ms` + ); + } else { + log.error(`checkEmailStatus failed to poll`, error); + } + } + if (why == "push") { + return; + } + let pollDuration = Date.now() - this.pollStartDate; + // Polling session expired. + if (pollDuration >= this.POLL_SESSION) { + if (currentState.whenVerifiedDeferred) { + let error = new Error("User email verification timed out."); + this._rejectWhenVerified(currentState, error); + } + log.debug("polling session exceeded, giving up"); + return; + } + // Poll email status again after a short delay. + if (nextPollMs === undefined) { + let currentMinute = Math.ceil(pollDuration / 60000); + nextPollMs = + why == "start" && + currentMinute < this.VERIFICATION_POLL_START_SLOWDOWN_THRESHOLD + ? this.VERIFICATION_POLL_TIMEOUT_INITIAL + : this.VERIFICATION_POLL_TIMEOUT_SUBSEQUENT; + } + this._scheduleNextPollEmailStatus( + currentState, + sessionToken, + nextPollMs, + why + ); + }, + + // Easy-to-mock testable method + _scheduleNextPollEmailStatus(currentState, sessionToken, nextPollMs, why) { + log.debug("polling with timeout = " + nextPollMs); + this.currentTimer = setTimeout(() => { + this.pollEmailStatus(currentState, sessionToken, why); + }, nextPollMs); + }, + + async onPollEmailSuccess(currentState) { + try { + await currentState.updateUserAccountData({ verified: true }); + const accountData = await currentState.getUserAccountData(); + this._setLastUserPref(accountData.email); + // Now that the user is verified, we can proceed to fetch keys + if (currentState.whenVerifiedDeferred) { + currentState.whenVerifiedDeferred.resolve(accountData); + delete currentState.whenVerifiedDeferred; + } + } catch (e) { + log.error(e); + } + }, + + _rejectWhenVerified(currentState, error) { + currentState.whenVerifiedDeferred.reject(error); + delete currentState.whenVerifiedDeferred; + }, + + /** + * Does the actual fetch of an oauth token for getOAuthToken() + * using the account session token. + * + * It's split out into a separate method so that we can easily + * stash in-flight calls in a cache. + * + * @param {String} scopeString + * @param {Number} ttl + * @returns {Promise<string>} + * @private + */ + async _doTokenFetchWithSessionToken(sessionToken, scopeString, ttl) { + const result = await this.fxAccountsClient.accessTokenWithSessionToken( + sessionToken, + FX_OAUTH_CLIENT_ID, + scopeString, + ttl + ); + return result.access_token; + }, + + getOAuthToken(options = {}) { + log.debug("getOAuthToken enter"); + let scope = options.scope; + if (typeof scope === "string") { + scope = [scope]; + } + + if (!scope || !scope.length) { + return Promise.reject( + this._error( + ERROR_INVALID_PARAMETER, + "Missing or invalid 'scope' option" + ) + ); + } + + return this.withSessionToken(async (sessionToken, currentState) => { + // Early exit for a cached token. + let cached = currentState.getCachedToken(scope); + if (cached) { + log.debug("getOAuthToken returning a cached token"); + return cached.token; + } + + // Build the string we use in our "inflight" map and that we send to the + // server. Because it's used as a key in the map we sort the scopes. + let scopeString = scope.sort().join(" "); + + // We keep a map of in-flight requests to avoid multiple promise-based + // consumers concurrently requesting the same token. + let maybeInFlight = currentState.inFlightTokenRequests.get(scopeString); + if (maybeInFlight) { + log.debug("getOAuthToken has an in-flight request for this scope"); + return maybeInFlight; + } + + // We need to start a new fetch and stick the promise in our in-flight map + // and remove it when it resolves. + let promise = this._doTokenFetchWithSessionToken( + sessionToken, + scopeString, + options.ttl + ) + .then(token => { + // As a sanity check, ensure something else hasn't raced getting a token + // of the same scope. If something has we just make noise rather than + // taking any concrete action because it should never actually happen. + if (currentState.getCachedToken(scope)) { + log.error(`detected a race for oauth token with scope ${scope}`); + } + // If we got one, cache it. + if (token) { + let entry = { token }; + currentState.setCachedToken(scope, entry); + } + return token; + }) + .finally(() => { + // Remove ourself from the in-flight map. There's no need to check the + // result of .delete() to handle a signout race, because setCachedToken + // above will fail in that case and cause the entire call to fail. + currentState.inFlightTokenRequests.delete(scopeString); + }); + + currentState.inFlightTokenRequests.set(scopeString, promise); + return promise; + }); + }, + + /** + * Remove an OAuth token from the token cache + * and makes a network request to FxA server to destroy the token. + * + * @param options + * { + * token: (string) A previously fetched token. + * } + * @return Promise.<undefined> This function will always resolve, even if + * an unknown token is passed. + */ + removeCachedOAuthToken(options) { + if (!options.token || typeof options.token !== "string") { + throw this._error( + ERROR_INVALID_PARAMETER, + "Missing or invalid 'token' option" + ); + } + return this.withCurrentAccountState(currentState => { + let existing = currentState.removeCachedToken(options.token); + if (existing) { + // background destroy. + this.destroyOAuthToken(existing).catch(err => { + log.warn("FxA failed to revoke a cached token", err); + }); + } + }); + }, + + /** Sets the user to be verified in the account state, + * This prevents any polling for the user's verification state from the FxA server + **/ + setUserVerified() { + return this.withCurrentAccountState(async currentState => { + const userData = await currentState.getUserAccountData(); + if (!userData.verified) { + await currentState.updateAccountData({ verified: true }); + } + }); + }, + + async _getVerifiedAccountOrReject() { + let data = await this.currentAccountState.getUserAccountData(); + if (!data) { + // No signed-in user + throw this._error(ERROR_NO_ACCOUNT); + } + if (!this.isUserEmailVerified(data)) { + // Signed-in user has not verified email + throw this._error(ERROR_UNVERIFIED_ACCOUNT); + } + return data; + }, + + // _handle* methods used by push, used when the account/device status is + // changed on a different device. + async _handleAccountDestroyed(uid) { + let state = this.currentAccountState; + const accountData = await state.getUserAccountData(); + const localUid = accountData ? accountData.uid : null; + if (!localUid) { + log.info( + `Account destroyed push notification received, but we're already logged-out` + ); + return null; + } + if (uid == localUid) { + const data = JSON.stringify({ isLocalDevice: true }); + await this.notifyObservers(ON_DEVICE_DISCONNECTED_NOTIFICATION, data); + return this.signOut(true); + } + log.info( + `The destroyed account uid doesn't match with the local uid. ` + + `Local: ${localUid}, account uid destroyed: ${uid}` + ); + return null; + }, + + async _handleDeviceDisconnection(deviceId) { + let state = this.currentAccountState; + const accountData = await state.getUserAccountData(); + if (!accountData || !accountData.device) { + // Nothing we can do here. + return; + } + const localDeviceId = accountData.device.id; + const isLocalDevice = deviceId == localDeviceId; + if (isLocalDevice) { + this.signOut(true); + } + const data = JSON.stringify({ isLocalDevice }); + await this.notifyObservers(ON_DEVICE_DISCONNECTED_NOTIFICATION, data); + }, + + _setLastUserPref(newEmail) { + Services.prefs.setStringPref( + PREF_LAST_FXA_USER, + CryptoUtils.sha256Base64(newEmail) + ); + }, + + async _handleEmailUpdated(newEmail) { + this._setLastUserPref(newEmail); + await this.currentAccountState.updateUserAccountData({ email: newEmail }); + }, + + /* + * Coerce an error into one of the general error cases: + * NETWORK_ERROR + * AUTH_ERROR + * UNKNOWN_ERROR + * + * These errors will pass through: + * INVALID_PARAMETER + * NO_ACCOUNT + * UNVERIFIED_ACCOUNT + */ + _errorToErrorClass(aError) { + if (aError.errno) { + let error = SERVER_ERRNO_TO_ERROR[aError.errno]; + return this._error( + ERROR_TO_GENERAL_ERROR_CLASS[error] || ERROR_UNKNOWN, + aError + ); + } else if ( + aError.message && + (aError.message === "INVALID_PARAMETER" || + aError.message === "NO_ACCOUNT" || + aError.message === "UNVERIFIED_ACCOUNT" || + aError.message === "AUTH_ERROR") + ) { + return aError; + } + return this._error(ERROR_UNKNOWN, aError); + }, + + _error(aError, aDetails) { + log.error("FxA rejecting with error ${aError}, details: ${aDetails}", { + aError, + aDetails, + }); + let reason = new Error(aError); + if (aDetails) { + reason.details = aDetails; + } + return reason; + }, + + // Attempt to update the auth server with whatever device details are stored + // in the account data. Returns a promise that always resolves, never rejects. + // If the promise resolves to a value, that value is the device id. + updateDeviceRegistration() { + return this.device.updateDeviceRegistration(); + }, + + /** + * Delete all the persisted credentials we store for FxA. After calling + * this, the user will be forced to re-authenticate to continue. + * + * @return Promise resolves when the user data has been persisted + */ + dropCredentials(state) { + // Delete all fields except those required for the user to + // reauthenticate. + let updateData = {}; + let clearField = field => { + if (!FXA_PWDMGR_REAUTH_ALLOWLIST.has(field)) { + updateData[field] = null; + } + }; + FXA_PWDMGR_PLAINTEXT_FIELDS.forEach(clearField); + FXA_PWDMGR_SECURE_FIELDS.forEach(clearField); + + return state.updateUserAccountData(updateData); + }, + + async checkAccountStatus(state) { + log.info("checking account status..."); + let data = await state.getUserAccountData(["uid", "sessionToken"]); + if (!data) { + log.info("account status: no user"); + return false; + } + // If we have a session token, then check if that remains valid - if this + // works we know the account must also be OK. + if (data.sessionToken) { + if (await this.fxAccountsClient.sessionStatus(data.sessionToken)) { + log.info("account status: ok"); + return true; + } + } + let exists = await this.fxAccountsClient.accountStatus(data.uid); + if (!exists) { + // Delete all local account data. Since the account no longer + // exists, we can skip the remote calls. + log.info("account status: deleted"); + await this._handleAccountDestroyed(data.uid); + } else { + // Note that we may already have been in a "needs reauth" state (ie, if + // this function was called when we already had no session token), but + // that's OK - re-notifying etc should cause no harm. + log.info("account status: needs reauthentication"); + await this.dropCredentials(this.currentAccountState); + // Notify the account state has changed so the UI updates. + await this.notifyObservers(ON_ACCOUNT_STATE_CHANGE_NOTIFICATION); + } + return false; + }, + + async _handleTokenError(err) { + if (!err || err.code != 401 || err.errno != ERRNO_INVALID_AUTH_TOKEN) { + throw err; + } + log.warn("handling invalid token error", err); + // Note that we don't use `withCurrentAccountState` here as that will cause + // an error to be thrown if we sign out due to the account not existing. + let state = this.currentAccountState; + let ok = await this.checkAccountStatus(state); + if (ok) { + log.warn("invalid token error, but account state appears ok?"); + } + // always re-throw the error. + throw err; + }, +}; + +let fxAccountsSingleton = null; + +export function getFxAccountsSingleton() { + if (fxAccountsSingleton) { + return fxAccountsSingleton; + } + + fxAccountsSingleton = new FxAccounts(); + + // XXX Bug 947061 - We need a strategy for resuming email verification after + // browser restart + fxAccountsSingleton._internal.loadAndPoll(); + + return fxAccountsSingleton; +} + +// `AccountState` is exported for tests. diff --git a/services/fxaccounts/FxAccountsClient.sys.mjs b/services/fxaccounts/FxAccountsClient.sys.mjs new file mode 100644 index 0000000000..9dc80ff419 --- /dev/null +++ b/services/fxaccounts/FxAccountsClient.sys.mjs @@ -0,0 +1,839 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { CommonUtils } from "resource://services-common/utils.sys.mjs"; + +import { HawkClient } from "resource://services-common/hawkclient.sys.mjs"; +import { deriveHawkCredentials } from "resource://services-common/hawkrequest.sys.mjs"; +import { CryptoUtils } from "resource://services-crypto/utils.sys.mjs"; + +import { + ERRNO_ACCOUNT_DOES_NOT_EXIST, + ERRNO_INCORRECT_EMAIL_CASE, + ERRNO_INCORRECT_PASSWORD, + ERRNO_INVALID_AUTH_NONCE, + ERRNO_INVALID_AUTH_TIMESTAMP, + ERRNO_INVALID_AUTH_TOKEN, + log, +} from "resource://gre/modules/FxAccountsCommon.sys.mjs"; + +import { Credentials } from "resource://gre/modules/Credentials.sys.mjs"; + +const HOST_PREF = "identity.fxaccounts.auth.uri"; + +const SIGNIN = "/account/login"; +const SIGNUP = "/account/create"; +// Devices older than this many days will not appear in the devices list +const DEVICES_FILTER_DAYS = 21; + +export var FxAccountsClient = function ( + host = Services.prefs.getStringPref(HOST_PREF) +) { + this.host = host; + + // The FxA auth server expects requests to certain endpoints to be authorized + // using Hawk. + this.hawk = new HawkClient(host); + this.hawk.observerPrefix = "FxA:hawk"; + + // Manage server backoff state. C.f. + // https://github.com/mozilla/fxa-auth-server/blob/master/docs/api.md#backoff-protocol + this.backoffError = null; +}; + +FxAccountsClient.prototype = { + /** + * Return client clock offset, in milliseconds, as determined by hawk client. + * Provided because callers should not have to know about hawk + * implementation. + * + * The offset is the number of milliseconds that must be added to the client + * clock to make it equal to the server clock. For example, if the client is + * five minutes ahead of the server, the localtimeOffsetMsec will be -300000. + */ + get localtimeOffsetMsec() { + return this.hawk.localtimeOffsetMsec; + }, + + /* + * Return current time in milliseconds + * + * Not used by this module, but made available to the FxAccounts.jsm + * that uses this client. + */ + now() { + return this.hawk.now(); + }, + + /** + * Common code from signIn and signUp. + * + * @param path + * Request URL path. Can be /account/create or /account/login + * @param email + * The email address for the account (utf8) + * @param password + * The user's password + * @param [getKeys=false] + * If set to true the keyFetchToken will be retrieved + * @param [retryOK=true] + * If capitalization of the email is wrong and retryOK is set to true, + * we will retry with the suggested capitalization from the server + * @return Promise + * Returns a promise that resolves to an object: + * { + * authAt: authentication time for the session (seconds since epoch) + * email: the primary email for this account + * keyFetchToken: a key fetch token (hex) + * sessionToken: a session token (hex) + * uid: the user's unique ID (hex) + * unwrapBKey: used to unwrap kB, derived locally from the + * password (not revealed to the FxA server) + * verified (optional): flag indicating verification status of the + * email + * } + */ + _createSession(path, email, password, getKeys = false, retryOK = true) { + return Credentials.setup(email, password).then(creds => { + let data = { + authPW: CommonUtils.bytesAsHex(creds.authPW), + email, + }; + let keys = getKeys ? "?keys=true" : ""; + + return this._request(path + keys, "POST", null, data).then( + // Include the canonical capitalization of the email in the response so + // the caller can set its signed-in user state accordingly. + result => { + result.email = data.email; + result.unwrapBKey = CommonUtils.bytesAsHex(creds.unwrapBKey); + + return result; + }, + error => { + log.debug("Session creation failed", error); + // If the user entered an email with different capitalization from + // what's stored in the database (e.g., Greta.Garbo@gmail.COM as + // opposed to greta.garbo@gmail.com), the server will respond with a + // errno 120 (code 400) and the expected capitalization of the email. + // We retry with this email exactly once. If successful, we use the + // server's version of the email as the signed-in-user's email. This + // is necessary because the email also serves as salt; so we must be + // in agreement with the server on capitalization. + // + // API reference: + // https://github.com/mozilla/fxa-auth-server/blob/master/docs/api.md + if (ERRNO_INCORRECT_EMAIL_CASE === error.errno && retryOK) { + if (!error.email) { + log.error("Server returned errno 120 but did not provide email"); + throw error; + } + return this._createSession( + path, + error.email, + password, + getKeys, + false + ); + } + throw error; + } + ); + }); + }, + + /** + * Create a new Firefox Account and authenticate + * + * @param email + * The email address for the account (utf8) + * @param password + * The user's password + * @param [getKeys=false] + * If set to true the keyFetchToken will be retrieved + * @return Promise + * Returns a promise that resolves to an object: + * { + * uid: the user's unique ID (hex) + * sessionToken: a session token (hex) + * keyFetchToken: a key fetch token (hex), + * unwrapBKey: used to unwrap kB, derived locally from the + * password (not revealed to the FxA server) + * } + */ + signUp(email, password, getKeys = false) { + return this._createSession( + SIGNUP, + email, + password, + getKeys, + false /* no retry */ + ); + }, + + /** + * Authenticate and create a new session with the Firefox Account API server + * + * @param email + * The email address for the account (utf8) + * @param password + * The user's password + * @param [getKeys=false] + * If set to true the keyFetchToken will be retrieved + * @return Promise + * Returns a promise that resolves to an object: + * { + * authAt: authentication time for the session (seconds since epoch) + * email: the primary email for this account + * keyFetchToken: a key fetch token (hex) + * sessionToken: a session token (hex) + * uid: the user's unique ID (hex) + * unwrapBKey: used to unwrap kB, derived locally from the + * password (not revealed to the FxA server) + * verified: flag indicating verification status of the email + * } + */ + signIn: function signIn(email, password, getKeys = false) { + return this._createSession( + SIGNIN, + email, + password, + getKeys, + true /* retry */ + ); + }, + + /** + * Check the status of a session given a session token + * + * @param sessionTokenHex + * The session token encoded in hex + * @return Promise + * Resolves with a boolean indicating if the session is still valid + */ + async sessionStatus(sessionTokenHex) { + const credentials = await deriveHawkCredentials( + sessionTokenHex, + "sessionToken" + ); + return this._request("/session/status", "GET", credentials).then( + () => Promise.resolve(true), + error => { + if (isInvalidTokenError(error)) { + return Promise.resolve(false); + } + throw error; + } + ); + }, + + /** + * List all the clients connected to the authenticated user's account, + * including devices, OAuth clients, and web sessions. + * + * @param sessionTokenHex + * The session token encoded in hex + * @return Promise + */ + async attachedClients(sessionTokenHex) { + const credentials = await deriveHawkCredentials( + sessionTokenHex, + "sessionToken" + ); + return this._requestWithHeaders( + "/account/attached_clients", + "GET", + credentials + ); + }, + + /** + * Retrieves an OAuth authorization code. + * + * @param String sessionTokenHex + * The session token encoded in hex + * @param {Object} options + * @param options.client_id + * @param options.state + * @param options.scope + * @param options.access_type + * @param options.code_challenge_method + * @param options.code_challenge + * @param [options.keys_jwe] + * @returns {Promise<Object>} Object containing `code` and `state`. + */ + async oauthAuthorize(sessionTokenHex, options) { + const credentials = await deriveHawkCredentials( + sessionTokenHex, + "sessionToken" + ); + const body = { + client_id: options.client_id, + response_type: "code", + state: options.state, + scope: options.scope, + access_type: options.access_type, + code_challenge: options.code_challenge, + code_challenge_method: options.code_challenge_method, + }; + if (options.keys_jwe) { + body.keys_jwe = options.keys_jwe; + } + return this._request("/oauth/authorization", "POST", credentials, body); + }, + /** + * Exchanges an OAuth authorization code with a refresh token, access tokens and an optional JWE representing scoped keys + * Takes in the sessionToken to tie the device record associated with the session, with the device record associated with the refreshToken + * + * @param string sessionTokenHex: The session token encoded in hex + * @param String code: OAuth authorization code + * @param String verifier: OAuth PKCE verifier + * @param String clientId: OAuth client ID + * + * @returns { Object } object containing `refresh_token`, `access_token` and `keys_jwe` + **/ + async oauthToken(sessionTokenHex, code, verifier, clientId) { + const credentials = await deriveHawkCredentials( + sessionTokenHex, + "sessionToken" + ); + const body = { + grant_type: "authorization_code", + code, + client_id: clientId, + code_verifier: verifier, + }; + return this._request("/oauth/token", "POST", credentials, body); + }, + /** + * Destroy an OAuth access token or refresh token. + * + * @param String clientId + * @param String token The token to be revoked. + */ + async oauthDestroy(clientId, token) { + const body = { + client_id: clientId, + token, + }; + return this._request("/oauth/destroy", "POST", null, body); + }, + + /** + * Query for the information required to derive + * scoped encryption keys requested by the specified OAuth client. + * + * @param sessionTokenHex + * The session token encoded in hex + * @param clientId + * @param scope + * Space separated list of scopes + * @return Promise + */ + async getScopedKeyData(sessionTokenHex, clientId, scope) { + if (!clientId) { + throw new Error("Missing 'clientId' parameter"); + } + if (!scope) { + throw new Error("Missing 'scope' parameter"); + } + const params = { + client_id: clientId, + scope, + }; + const credentials = await deriveHawkCredentials( + sessionTokenHex, + "sessionToken" + ); + return this._request( + "/account/scoped-key-data", + "POST", + credentials, + params + ); + }, + + /** + * Destroy the current session with the Firefox Account API server and its + * associated device. + * + * @param sessionTokenHex + * The session token encoded in hex + * @return Promise + */ + async signOut(sessionTokenHex, options = {}) { + const credentials = await deriveHawkCredentials( + sessionTokenHex, + "sessionToken" + ); + let path = "/session/destroy"; + if (options.service) { + path += "?service=" + encodeURIComponent(options.service); + } + return this._request(path, "POST", credentials); + }, + + /** + * Check the verification status of the user's FxA email address + * + * @param sessionTokenHex + * The current session token encoded in hex + * @return Promise + */ + async recoveryEmailStatus(sessionTokenHex, options = {}) { + const credentials = await deriveHawkCredentials( + sessionTokenHex, + "sessionToken" + ); + let path = "/recovery_email/status"; + if (options.reason) { + path += "?reason=" + encodeURIComponent(options.reason); + } + + return this._request(path, "GET", credentials); + }, + + /** + * Resend the verification email for the user + * + * @param sessionTokenHex + * The current token encoded in hex + * @return Promise + */ + async resendVerificationEmail(sessionTokenHex) { + const credentials = await deriveHawkCredentials( + sessionTokenHex, + "sessionToken" + ); + return this._request("/recovery_email/resend_code", "POST", credentials); + }, + + /** + * Retrieve encryption keys + * + * @param keyFetchTokenHex + * A one-time use key fetch token encoded in hex + * @return Promise + * Returns a promise that resolves to an object: + * { + * kA: an encryption key for recevorable data (bytes) + * wrapKB: an encryption key that requires knowledge of the + * user's password (bytes) + * } + */ + async accountKeys(keyFetchTokenHex) { + let creds = await deriveHawkCredentials(keyFetchTokenHex, "keyFetchToken"); + let keyRequestKey = creds.extra.slice(0, 32); + let morecreds = await CryptoUtils.hkdfLegacy( + keyRequestKey, + undefined, + Credentials.keyWord("account/keys"), + 3 * 32 + ); + let respHMACKey = morecreds.slice(0, 32); + let respXORKey = morecreds.slice(32, 96); + + const resp = await this._request("/account/keys", "GET", creds); + if (!resp.bundle) { + throw new Error("failed to retrieve keys"); + } + + let bundle = CommonUtils.hexToBytes(resp.bundle); + let mac = bundle.slice(-32); + let key = CommonUtils.byteStringToArrayBuffer(respHMACKey); + // CryptoUtils.hmac takes ArrayBuffers as inputs for the key and data and + // returns an ArrayBuffer. + let bundleMAC = await CryptoUtils.hmac( + "SHA-256", + key, + CommonUtils.byteStringToArrayBuffer(bundle.slice(0, -32)) + ); + if (mac !== CommonUtils.arrayBufferToByteString(bundleMAC)) { + throw new Error("error unbundling encryption keys"); + } + + let keyAWrapB = CryptoUtils.xor(respXORKey, bundle.slice(0, 64)); + + return { + kA: keyAWrapB.slice(0, 32), + wrapKB: keyAWrapB.slice(32), + }; + }, + + /** + * Obtain an OAuth access token by authenticating using a session token. + * + * @param {String} sessionTokenHex + * The session token encoded in hex + * @param {String} clientId + * @param {String} scope + * List of space-separated scopes. + * @param {Number} ttl + * Token time to live. + * @return {Promise<Object>} Object containing an `access_token`. + */ + async accessTokenWithSessionToken(sessionTokenHex, clientId, scope, ttl) { + const credentials = await deriveHawkCredentials( + sessionTokenHex, + "sessionToken" + ); + const body = { + client_id: clientId, + grant_type: "fxa-credentials", + scope, + ttl, + }; + return this._request("/oauth/token", "POST", credentials, body); + }, + + /** + * Determine if an account exists + * + * @param email + * The email address to check + * @return Promise + * The promise resolves to true if the account exists, or false + * if it doesn't. The promise is rejected on other errors. + */ + accountExists(email) { + return this.signIn(email, "").then( + cantHappen => { + throw new Error("How did I sign in with an empty password?"); + }, + expectedError => { + switch (expectedError.errno) { + case ERRNO_ACCOUNT_DOES_NOT_EXIST: + return false; + case ERRNO_INCORRECT_PASSWORD: + return true; + default: + // not so expected, any more ... + throw expectedError; + } + } + ); + }, + + /** + * Given the uid of an existing account (not an arbitrary email), ask + * the server if it still exists via /account/status. + * + * Used for differentiating between password change and account deletion. + */ + accountStatus(uid) { + return this._request("/account/status?uid=" + uid, "GET").then( + result => { + return result.exists; + }, + error => { + log.error("accountStatus failed", error); + return Promise.reject(error); + } + ); + }, + + /** + * Register a new device + * + * @method registerDevice + * @param sessionTokenHex + * Session token obtained from signIn + * @param name + * Device name + * @param type + * Device type (mobile|desktop) + * @param [options] + * Extra device options + * @param [options.availableCommands] + * Available commands for this device + * @param [options.pushCallback] + * `pushCallback` push endpoint callback + * @param [options.pushPublicKey] + * `pushPublicKey` push public key (URLSafe Base64 string) + * @param [options.pushAuthKey] + * `pushAuthKey` push auth secret (URLSafe Base64 string) + * @return Promise + * Resolves to an object: + * { + * id: Device identifier + * createdAt: Creation time (milliseconds since epoch) + * name: Name of device + * type: Type of device (mobile|desktop) + * } + */ + async registerDevice(sessionTokenHex, name, type, options = {}) { + let path = "/account/device"; + + let creds = await deriveHawkCredentials(sessionTokenHex, "sessionToken"); + let body = { name, type }; + + if (options.pushCallback) { + body.pushCallback = options.pushCallback; + } + if (options.pushPublicKey && options.pushAuthKey) { + body.pushPublicKey = options.pushPublicKey; + body.pushAuthKey = options.pushAuthKey; + } + body.availableCommands = options.availableCommands; + + return this._request(path, "POST", creds, body); + }, + + /** + * Sends a message to other devices. Must conform with the push payload schema: + * https://github.com/mozilla/fxa-auth-server/blob/master/docs/pushpayloads.schema.json + * + * @method notifyDevice + * @param sessionTokenHex + * Session token obtained from signIn + * @param deviceIds + * Devices to send the message to. If null, will be sent to all devices. + * @param excludedIds + * Devices to exclude when sending to all devices (deviceIds must be null). + * @param payload + * Data to send with the message + * @return Promise + * Resolves to an empty object: + * {} + */ + async notifyDevices( + sessionTokenHex, + deviceIds, + excludedIds, + payload, + TTL = 0 + ) { + const credentials = await deriveHawkCredentials( + sessionTokenHex, + "sessionToken" + ); + if (deviceIds && excludedIds) { + throw new Error( + "You cannot specify excluded devices if deviceIds is set." + ); + } + const body = { + to: deviceIds || "all", + payload, + TTL, + }; + if (excludedIds) { + body.excluded = excludedIds; + } + return this._request("/account/devices/notify", "POST", credentials, body); + }, + + /** + * Retrieves pending commands for our device. + * + * @method getCommands + * @param sessionTokenHex - Session token obtained from signIn + * @param [index] - If specified, only messages received after the one who + * had that index will be retrieved. + * @param [limit] - Maximum number of messages to retrieve. + */ + async getCommands(sessionTokenHex, { index, limit }) { + const credentials = await deriveHawkCredentials( + sessionTokenHex, + "sessionToken" + ); + const params = new URLSearchParams(); + if (index != undefined) { + params.set("index", index); + } + if (limit != undefined) { + params.set("limit", limit); + } + const path = `/account/device/commands?${params.toString()}`; + return this._request(path, "GET", credentials); + }, + + /** + * Invokes a command on another device. + * + * @method invokeCommand + * @param sessionTokenHex - Session token obtained from signIn + * @param command - Name of the command to invoke + * @param target - Recipient device ID. + * @param payload + * @return Promise + * Resolves to the request's response, (which should be an empty object) + */ + async invokeCommand(sessionTokenHex, command, target, payload) { + const credentials = await deriveHawkCredentials( + sessionTokenHex, + "sessionToken" + ); + const body = { + command, + target, + payload, + }; + return this._request( + "/account/devices/invoke_command", + "POST", + credentials, + body + ); + }, + + /** + * Update the session or name for an existing device + * + * @method updateDevice + * @param sessionTokenHex + * Session token obtained from signIn + * @param id + * Device identifier + * @param name + * Device name + * @param [options] + * Extra device options + * @param [options.availableCommands] + * Available commands for this device + * @param [options.pushCallback] + * `pushCallback` push endpoint callback + * @param [options.pushPublicKey] + * `pushPublicKey` push public key (URLSafe Base64 string) + * @param [options.pushAuthKey] + * `pushAuthKey` push auth secret (URLSafe Base64 string) + * @return Promise + * Resolves to an object: + * { + * id: Device identifier + * name: Device name + * } + */ + async updateDevice(sessionTokenHex, id, name, options = {}) { + let path = "/account/device"; + + let creds = await deriveHawkCredentials(sessionTokenHex, "sessionToken"); + let body = { id, name }; + if (options.pushCallback) { + body.pushCallback = options.pushCallback; + } + if (options.pushPublicKey && options.pushAuthKey) { + body.pushPublicKey = options.pushPublicKey; + body.pushAuthKey = options.pushAuthKey; + } + body.availableCommands = options.availableCommands; + + return this._request(path, "POST", creds, body); + }, + + /** + * Get a list of currently registered devices that have been accessed + * in the last `DEVICES_FILTER_DAYS` days + * + * @method getDeviceList + * @param sessionTokenHex + * Session token obtained from signIn + * @return Promise + * Resolves to an array of objects: + * [ + * { + * id: Device id + * isCurrentDevice: Boolean indicating whether the item + * represents the current device + * name: Device name + * type: Device type (mobile|desktop) + * }, + * ... + * ] + */ + async getDeviceList(sessionTokenHex) { + let timestamp = Date.now() - 1000 * 60 * 60 * 24 * DEVICES_FILTER_DAYS; + let path = `/account/devices?filterIdleDevicesTimestamp=${timestamp}`; + let creds = await deriveHawkCredentials(sessionTokenHex, "sessionToken"); + return this._request(path, "GET", creds, {}); + }, + + _clearBackoff() { + this.backoffError = null; + }, + + /** + * A general method for sending raw API calls to the FxA auth server. + * All request bodies and responses are JSON. + * + * @param path + * API endpoint path + * @param method + * The HTTP request method + * @param credentials + * Hawk credentials + * @param jsonPayload + * A JSON payload + * @return Promise + * Returns a promise that resolves to the JSON response of the API call, + * or is rejected with an error. Error responses have the following properties: + * { + * "code": 400, // matches the HTTP status code + * "errno": 107, // stable application-level error number + * "error": "Bad Request", // string description of the error type + * "message": "the value of salt is not allowed to be undefined", + * "info": "https://docs.dev.lcip.og/errors/1234" // link to more info on the error + * } + */ + async _requestWithHeaders(path, method, credentials, jsonPayload) { + // We were asked to back off. + if (this.backoffError) { + log.debug("Received new request during backoff, re-rejecting."); + throw this.backoffError; + } + let response; + try { + response = await this.hawk.request( + path, + method, + credentials, + jsonPayload + ); + } catch (error) { + log.error(`error ${method}ing ${path}`, error); + if (error.retryAfter) { + log.debug("Received backoff response; caching error as flag."); + this.backoffError = error; + // Schedule clearing of cached-error-as-flag. + CommonUtils.namedTimer( + this._clearBackoff, + error.retryAfter * 1000, + this, + "fxaBackoffTimer" + ); + } + throw error; + } + try { + return { body: JSON.parse(response.body), headers: response.headers }; + } catch (error) { + log.error("json parse error on response: " + response.body); + // eslint-disable-next-line no-throw-literal + throw { error }; + } + }, + + async _request(path, method, credentials, jsonPayload) { + const response = await this._requestWithHeaders( + path, + method, + credentials, + jsonPayload + ); + return response.body; + }, +}; + +function isInvalidTokenError(error) { + if (error.code != 401) { + return false; + } + switch (error.errno) { + case ERRNO_INVALID_AUTH_TOKEN: + case ERRNO_INVALID_AUTH_TIMESTAMP: + case ERRNO_INVALID_AUTH_NONCE: + return true; + } + return false; +} diff --git a/services/fxaccounts/FxAccountsCommands.sys.mjs b/services/fxaccounts/FxAccountsCommands.sys.mjs new file mode 100644 index 0000000000..40fcc7f925 --- /dev/null +++ b/services/fxaccounts/FxAccountsCommands.sys.mjs @@ -0,0 +1,467 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { + COMMAND_SENDTAB, + COMMAND_SENDTAB_TAIL, + SCOPE_OLD_SYNC, + log, +} from "resource://gre/modules/FxAccountsCommon.sys.mjs"; + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +import { Observers } from "resource://services-common/observers.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + BulkKeyBundle: "resource://services-sync/keys.sys.mjs", + CryptoWrapper: "resource://services-sync/record.sys.mjs", + PushCrypto: "resource://gre/modules/PushCrypto.sys.mjs", +}); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "INVALID_SHAREABLE_SCHEMES", + "services.sync.engine.tabs.filteredSchemes", + "", + null, + val => { + return new Set(val.split("|")); + } +); + +export class FxAccountsCommands { + constructor(fxAccountsInternal) { + this._fxai = fxAccountsInternal; + this.sendTab = new SendTab(this, fxAccountsInternal); + this._invokeRateLimitExpiry = 0; + } + + async availableCommands() { + const encryptedSendTabKeys = await this.sendTab.getEncryptedSendTabKeys(); + if (!encryptedSendTabKeys) { + // This will happen if the account is not verified yet. + return {}; + } + return { + [COMMAND_SENDTAB]: encryptedSendTabKeys, + }; + } + + async invoke(command, device, payload) { + const { sessionToken } = await this._fxai.getUserAccountData([ + "sessionToken", + ]); + const client = this._fxai.fxAccountsClient; + const now = Date.now(); + if (now < this._invokeRateLimitExpiry) { + const remaining = (this._invokeRateLimitExpiry - now) / 1000; + throw new Error( + `Invoke for ${command} is rate-limited for ${remaining} seconds.` + ); + } + try { + let info = await client.invokeCommand( + sessionToken, + command, + device.id, + payload + ); + if (!info.enqueued || !info.notified) { + // We want an error log here to help diagnose users who report failure. + log.error("Sending was only partially successful", info); + } else { + log.info("Successfully sent", info); + } + } catch (err) { + if (err.code && err.code === 429 && err.retryAfter) { + this._invokeRateLimitExpiry = Date.now() + err.retryAfter * 1000; + } + throw err; + } + log.info(`Payload sent to device ${device.id}.`); + } + + /** + * Poll and handle device commands for the current device. + * This method can be called either in response to a Push message, + * or by itself as a "commands recovery" mechanism. + * + * @param {Number} notifiedIndex "Command received" push messages include + * the index of the command that triggered the message. We use it as a + * hint when we have no "last command index" stored. + */ + async pollDeviceCommands(notifiedIndex = 0) { + // Whether the call to `pollDeviceCommands` was initiated by a Push message from the FxA + // servers in response to a message being received or simply scheduled in order + // to fetch missed messages. + log.info(`Polling device commands.`); + await this._fxai.withCurrentAccountState(async state => { + const { device } = await state.getUserAccountData(["device"]); + if (!device) { + throw new Error("No device registration."); + } + // We increment lastCommandIndex by 1 because the server response includes the current index. + // If we don't have a `lastCommandIndex` stored, we fall back on the index from the push message we just got. + const lastCommandIndex = device.lastCommandIndex + 1 || notifiedIndex; + // We have already received this message before. + if (notifiedIndex > 0 && notifiedIndex < lastCommandIndex) { + return; + } + const { index, messages } = await this._fetchDeviceCommands( + lastCommandIndex + ); + if (messages.length) { + await state.updateUserAccountData({ + device: { ...device, lastCommandIndex: index }, + }); + log.info(`Handling ${messages.length} messages`); + await this._handleCommands(messages, notifiedIndex); + } + }); + return true; + } + + async _fetchDeviceCommands(index, limit = null) { + const userData = await this._fxai.getUserAccountData(); + if (!userData) { + throw new Error("No user."); + } + const { sessionToken } = userData; + if (!sessionToken) { + throw new Error("No session token."); + } + const client = this._fxai.fxAccountsClient; + const opts = { index }; + if (limit != null) { + opts.limit = limit; + } + return client.getCommands(sessionToken, opts); + } + + _getReason(notifiedIndex, messageIndex) { + // The returned reason value represents an explanation for why the command associated with the + // message of the given `messageIndex` is being handled. If `notifiedIndex` is zero the command + // is a part of a fallback polling process initiated by "Sync Now" ["poll"]. If `notifiedIndex` is + // greater than `messageIndex` this is a push command that was previously missed ["push-missed"], + // otherwise we assume this is a push command with no missed messages ["push"]. + if (notifiedIndex == 0) { + return "poll"; + } else if (notifiedIndex > messageIndex) { + return "push-missed"; + } + // Note: The returned reason might be "push" in the case where a user sends multiple tabs + // in quick succession. We are not attempting to distinguish this from other push cases at + // present. + return "push"; + } + + async _handleCommands(messages, notifiedIndex) { + try { + await this._fxai.device.refreshDeviceList(); + } catch (e) { + log.warn("Error refreshing device list", e); + } + // We debounce multiple incoming tabs so we show a single notification. + const tabsReceived = []; + for (const { index, data } of messages) { + const { command, payload, sender: senderId } = data; + const reason = this._getReason(notifiedIndex, index); + const sender = + senderId && this._fxai.device.recentDeviceList + ? this._fxai.device.recentDeviceList.find(d => d.id == senderId) + : null; + if (!sender) { + log.warn( + "Incoming command is from an unknown device (maybe disconnected?)" + ); + } + switch (command) { + case COMMAND_SENDTAB: + try { + const { title, uri } = await this.sendTab.handle( + senderId, + payload, + reason + ); + log.info( + `Tab received with FxA commands: "${ + title || "<no title>" + }" from ${sender ? sender.name : "Unknown device"}.` + ); + // URLs are PII, so only logged at trace. + log.trace(`Tab received URL: ${uri}`); + // This should eventually be rare to hit as all platforms will be using the same + // scheme filter list, but we have this here in the case other platforms + // haven't caught up and/or trying to send invalid uris using older versions + const scheme = Services.io.newURI(uri).scheme; + if (lazy.INVALID_SHAREABLE_SCHEMES.has(scheme)) { + throw new Error("Invalid scheme found for received URI."); + } + tabsReceived.push({ title, uri, sender }); + } catch (e) { + log.error(`Error while handling incoming Send Tab payload.`, e); + } + break; + default: + log.info(`Unknown command: ${command}.`); + } + } + if (tabsReceived.length) { + this._notifyFxATabsReceived(tabsReceived); + } + } + + _notifyFxATabsReceived(tabsReceived) { + Observers.notify("fxaccounts:commands:open-uri", tabsReceived); + } +} + +/** + * Send Tab is built on top of FxA commands. + * + * Devices exchange keys wrapped in the oldsync key between themselves (getEncryptedSendTabKeys) + * during the device registration flow. The FxA server can theoretically never + * retrieve the send tab keys since it doesn't know the oldsync key. + * + * Note about the keys: + * The server has the `pushPublicKey`. The FxA server encrypt the send-tab payload again using the + * push keys - after the client has encrypted the payload using the send-tab keys. + * The push keys are different from the send-tab keys. The FxA server uses + * the push keys to deliver the tabs using same mechanism we use for web-push. + * However, clients use the send-tab keys for end-to-end encryption. + */ +export class SendTab { + constructor(commands, fxAccountsInternal) { + this._commands = commands; + this._fxai = fxAccountsInternal; + } + /** + * @param {Device[]} to - Device objects (typically returned by fxAccounts.getDevicesList()). + * @param {Object} tab + * @param {string} tab.url + * @param {string} tab.title + * @returns A report object, in the shape of + * {succeded: [Device], error: [{device: Device, error: Exception}]} + */ + async send(to, tab) { + log.info(`Sending a tab to ${to.length} devices.`); + const flowID = this._fxai.telemetry.generateFlowID(); + const encoder = new TextEncoder(); + const data = { entries: [{ title: tab.title, url: tab.url }] }; + const report = { + succeeded: [], + failed: [], + }; + for (let device of to) { + try { + const streamID = this._fxai.telemetry.generateFlowID(); + const targetData = Object.assign({ flowID, streamID }, data); + const bytes = encoder.encode(JSON.stringify(targetData)); + const encrypted = await this._encrypt(bytes, device); + // FxA expects an object as the payload, but we only have a single encrypted string; wrap it. + // If you add any plaintext items to this payload, please carefully consider the privacy implications + // of revealing that data to the FxA server. + const payload = { encrypted }; + await this._commands.invoke(COMMAND_SENDTAB, device, payload); + this._fxai.telemetry.recordEvent( + "command-sent", + COMMAND_SENDTAB_TAIL, + this._fxai.telemetry.sanitizeDeviceId(device.id), + { flowID, streamID } + ); + report.succeeded.push(device); + } catch (error) { + log.error("Error while invoking a send tab command.", error); + report.failed.push({ device, error }); + } + } + return report; + } + + // Returns true if the target device is compatible with FxA Commands Send tab. + isDeviceCompatible(device) { + return ( + device.availableCommands && device.availableCommands[COMMAND_SENDTAB] + ); + } + + // Handle incoming send tab payload, called by FxAccountsCommands. + async handle(senderID, { encrypted }, reason) { + const bytes = await this._decrypt(encrypted); + const decoder = new TextDecoder("utf8"); + const data = JSON.parse(decoder.decode(bytes)); + const { flowID, streamID, entries } = data; + const current = data.hasOwnProperty("current") + ? data.current + : entries.length - 1; + const { title, url: uri } = entries[current]; + // `flowID` and `streamID` are in the top-level of the JSON, `entries` is + // an array of "tabs" with `current` being what index is the one we care + // about, or the last one if not specified. + this._fxai.telemetry.recordEvent( + "command-received", + COMMAND_SENDTAB_TAIL, + this._fxai.telemetry.sanitizeDeviceId(senderID), + { flowID, streamID, reason } + ); + + return { + title, + uri, + }; + } + + async _encrypt(bytes, device) { + let bundle = device.availableCommands[COMMAND_SENDTAB]; + if (!bundle) { + throw new Error(`Device ${device.id} does not have send tab keys.`); + } + const oldsyncKey = await this._fxai.keys.getKeyForScope(SCOPE_OLD_SYNC); + // Older clients expect this to be hex, due to pre-JWK sync key ids :-( + const ourKid = this._fxai.keys.kidAsHex(oldsyncKey); + const { kid: theirKid } = JSON.parse( + device.availableCommands[COMMAND_SENDTAB] + ); + if (theirKid != ourKid) { + throw new Error("Target Send Tab key ID is different from ours"); + } + const json = JSON.parse(bundle); + const wrapper = new lazy.CryptoWrapper(); + wrapper.deserialize({ payload: json }); + const syncKeyBundle = lazy.BulkKeyBundle.fromJWK(oldsyncKey); + let { publicKey, authSecret } = await wrapper.decrypt(syncKeyBundle); + authSecret = urlsafeBase64Decode(authSecret); + publicKey = urlsafeBase64Decode(publicKey); + + const { ciphertext: encrypted } = await lazy.PushCrypto.encrypt( + bytes, + publicKey, + authSecret + ); + return urlsafeBase64Encode(encrypted); + } + + async _getPersistedSendTabKeys() { + const { device } = await this._fxai.getUserAccountData(["device"]); + return device && device.sendTabKeys; + } + + async _decrypt(ciphertext) { + let { privateKey, publicKey, authSecret } = + await this._getPersistedSendTabKeys(); + publicKey = urlsafeBase64Decode(publicKey); + authSecret = urlsafeBase64Decode(authSecret); + ciphertext = new Uint8Array(urlsafeBase64Decode(ciphertext)); + return lazy.PushCrypto.decrypt( + privateKey, + publicKey, + authSecret, + // The only Push encoding we support. + { encoding: "aes128gcm" }, + ciphertext + ); + } + + async _generateAndPersistSendTabKeys() { + let [publicKey, privateKey] = await lazy.PushCrypto.generateKeys(); + publicKey = urlsafeBase64Encode(publicKey); + let authSecret = lazy.PushCrypto.generateAuthenticationSecret(); + authSecret = urlsafeBase64Encode(authSecret); + const sendTabKeys = { + publicKey, + privateKey, + authSecret, + }; + await this._fxai.withCurrentAccountState(async state => { + const { device } = await state.getUserAccountData(["device"]); + await state.updateUserAccountData({ + device: { + ...device, + sendTabKeys, + }, + }); + }); + return sendTabKeys; + } + + async _getPersistedEncryptedSendTabKey() { + const { encryptedSendTabKeys } = await this._fxai.getUserAccountData([ + "encryptedSendTabKeys", + ]); + return encryptedSendTabKeys; + } + + async _generateAndPersistEncryptedSendTabKey() { + let sendTabKeys = await this._getPersistedSendTabKeys(); + if (!sendTabKeys) { + log.info("Could not find sendtab keys, generating them"); + sendTabKeys = await this._generateAndPersistSendTabKeys(); + } + // Strip the private key from the bundle to encrypt. + const keyToEncrypt = { + publicKey: sendTabKeys.publicKey, + authSecret: sendTabKeys.authSecret, + }; + if (!(await this._fxai.keys.canGetKeyForScope(SCOPE_OLD_SYNC))) { + log.info("Can't fetch keys, so unable to determine sendtab keys"); + return null; + } + let oldsyncKey; + try { + oldsyncKey = await this._fxai.keys.getKeyForScope(SCOPE_OLD_SYNC); + } catch (ex) { + log.warn("Failed to fetch keys, so unable to determine sendtab keys", ex); + return null; + } + const wrapper = new lazy.CryptoWrapper(); + wrapper.cleartext = keyToEncrypt; + const keyBundle = lazy.BulkKeyBundle.fromJWK(oldsyncKey); + await wrapper.encrypt(keyBundle); + const encryptedSendTabKeys = JSON.stringify({ + // This is expected in hex, due to pre-JWK sync key ids :-( + kid: this._fxai.keys.kidAsHex(oldsyncKey), + IV: wrapper.IV, + hmac: wrapper.hmac, + ciphertext: wrapper.ciphertext, + }); + await this._fxai.withCurrentAccountState(async state => { + await state.updateUserAccountData({ + encryptedSendTabKeys, + }); + }); + return encryptedSendTabKeys; + } + + async getEncryptedSendTabKeys() { + let encryptedSendTabKeys = await this._getPersistedEncryptedSendTabKey(); + const sendTabKeys = await this._getPersistedSendTabKeys(); + if (!encryptedSendTabKeys || !sendTabKeys) { + log.info("Generating and persisting encrypted sendtab keys"); + // `_generateAndPersistEncryptedKeys` requires the sync key + // which cannot be accessed if the login manager is locked + // (i.e when the primary password is locked) or if the sync keys + // aren't accessible (account isn't verified) + // so this function could fail to retrieve the keys + // however, device registration will trigger when the account + // is verified, so it's OK + // Note that it's okay to persist those keys, because they are + // already persisted in plaintext and the encrypted bundle + // does not include the sync-key (the sync key is used to encrypt + // it though) + encryptedSendTabKeys = + await this._generateAndPersistEncryptedSendTabKey(); + } + return encryptedSendTabKeys; + } +} + +function urlsafeBase64Encode(buffer) { + return ChromeUtils.base64URLEncode(new Uint8Array(buffer), { pad: false }); +} + +function urlsafeBase64Decode(str) { + return ChromeUtils.base64URLDecode(str, { padding: "reject" }); +} diff --git a/services/fxaccounts/FxAccountsCommon.sys.mjs b/services/fxaccounts/FxAccountsCommon.sys.mjs new file mode 100644 index 0000000000..2688fc3c0a --- /dev/null +++ b/services/fxaccounts/FxAccountsCommon.sys.mjs @@ -0,0 +1,393 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { Log } from "resource://gre/modules/Log.sys.mjs"; +import { LogManager } from "resource://services-common/logmanager.sys.mjs"; + +// loglevel should be one of "Fatal", "Error", "Warn", "Info", "Config", +// "Debug", "Trace" or "All". If none is specified, "Debug" will be used by +// default. Note "Debug" is usually appropriate so that when this log is +// included in the Sync file logs we get verbose output. +const PREF_LOG_LEVEL = "identity.fxaccounts.loglevel"; + +// A pref that can be set so "sensitive" information (eg, personally +// identifiable info, credentials, etc) will be logged. +const PREF_LOG_SENSITIVE_DETAILS = "identity.fxaccounts.log.sensitive"; + +export let log = Log.repository.getLogger("FirefoxAccounts"); +log.manageLevelFromPref(PREF_LOG_LEVEL); + +let logs = [ + "Sync", + "Services.Common", + "FirefoxAccounts", + "Hawk", + "browserwindow.syncui", + "BookmarkSyncUtils", + "addons.xpi", +]; + +// For legacy reasons, the log manager still thinks it's part of sync. +export let logManager = new LogManager("services.sync.", logs, "sync"); + +// A boolean to indicate if personally identifiable information (or anything +// else sensitive, such as credentials) should be logged. +export let logPII = () => + Services.prefs.getBoolPref(PREF_LOG_SENSITIVE_DETAILS, false); + +export let FXACCOUNTS_PERMISSION = "firefox-accounts"; + +export let DATA_FORMAT_VERSION = 1; +export let DEFAULT_STORAGE_FILENAME = "signedInUser.json"; + +export let OAUTH_TOKEN_FOR_SYNC_LIFETIME_SECONDS = 3600 * 6; // 6 hours + +// After we start polling for account verification, we stop polling when this +// many milliseconds have elapsed. +export let POLL_SESSION = 1000 * 60 * 20; // 20 minutes + +// Observer notifications. +export let ONLOGIN_NOTIFICATION = "fxaccounts:onlogin"; +export let ONVERIFIED_NOTIFICATION = "fxaccounts:onverified"; +export let ONLOGOUT_NOTIFICATION = "fxaccounts:onlogout"; +export let ON_PRELOGOUT_NOTIFICATION = "fxaccounts:on_pre_logout"; +// Internal to services/fxaccounts only +export let ON_DEVICE_CONNECTED_NOTIFICATION = "fxaccounts:device_connected"; +export let ON_DEVICE_DISCONNECTED_NOTIFICATION = + "fxaccounts:device_disconnected"; +export let ON_PROFILE_UPDATED_NOTIFICATION = "fxaccounts:profile_updated"; // Push +export let ON_PASSWORD_CHANGED_NOTIFICATION = "fxaccounts:password_changed"; +export let ON_PASSWORD_RESET_NOTIFICATION = "fxaccounts:password_reset"; +export let ON_ACCOUNT_DESTROYED_NOTIFICATION = "fxaccounts:account_destroyed"; +export let ON_COLLECTION_CHANGED_NOTIFICATION = "sync:collection_changed"; +export let ON_VERIFY_LOGIN_NOTIFICATION = "fxaccounts:verify_login"; +export let ON_COMMAND_RECEIVED_NOTIFICATION = "fxaccounts:command_received"; + +export let FXA_PUSH_SCOPE_ACCOUNT_UPDATE = "chrome://fxa-device-update"; + +export let ON_PROFILE_CHANGE_NOTIFICATION = "fxaccounts:profilechange"; // WebChannel +export let ON_ACCOUNT_STATE_CHANGE_NOTIFICATION = "fxaccounts:statechange"; +export let ON_NEW_DEVICE_ID = "fxaccounts:new_device_id"; +export let ON_DEVICELIST_UPDATED = "fxaccounts:devicelist_updated"; + +// The common prefix for all commands. +export let COMMAND_PREFIX = "https://identity.mozilla.com/cmd/"; + +// The commands we support - only the _TAIL values are recorded in telemetry. +export let COMMAND_SENDTAB_TAIL = "open-uri"; +export let COMMAND_SENDTAB = COMMAND_PREFIX + COMMAND_SENDTAB_TAIL; + +// OAuth +export let FX_OAUTH_CLIENT_ID = "5882386c6d801776"; +export let SCOPE_PROFILE = "profile"; +export let SCOPE_PROFILE_WRITE = "profile:write"; +export let SCOPE_OLD_SYNC = "https://identity.mozilla.com/apps/oldsync"; + +// This scope was previously used to calculate a telemetry tracking identifier for +// the account, but that system has since been decommissioned. It's here entirely +// so that we can remove the corresponding key from storage if present. We should +// be safe to remove it after some sensible period of time has elapsed to allow +// most clients to update; ref Bug 1697596. +export let DEPRECATED_SCOPE_ECOSYSTEM_TELEMETRY = + "https://identity.mozilla.com/ids/ecosystem_telemetry"; + +// OAuth metadata for other Firefox-related services that we might need to know about +// in order to provide an enhanced user experience. +export let FX_MONITOR_OAUTH_CLIENT_ID = "802d56ef2a9af9fa"; +export let FX_RELAY_OAUTH_CLIENT_ID = "9ebfe2c2f9ea3c58"; +export let VPN_OAUTH_CLIENT_ID = "e6eb0d1e856335fc"; + +// UI Requests. +export let UI_REQUEST_SIGN_IN_FLOW = "signInFlow"; +export let UI_REQUEST_REFRESH_AUTH = "refreshAuthentication"; + +// Firefox Accounts WebChannel ID +export let WEBCHANNEL_ID = "account_updates"; + +// WebChannel commands +export let COMMAND_PAIR_HEARTBEAT = "fxaccounts:pair_heartbeat"; +export let COMMAND_PAIR_SUPP_METADATA = "fxaccounts:pair_supplicant_metadata"; +export let COMMAND_PAIR_AUTHORIZE = "fxaccounts:pair_authorize"; +export let COMMAND_PAIR_DECLINE = "fxaccounts:pair_decline"; +export let COMMAND_PAIR_COMPLETE = "fxaccounts:pair_complete"; + +export let COMMAND_PROFILE_CHANGE = "profile:change"; +export let COMMAND_CAN_LINK_ACCOUNT = "fxaccounts:can_link_account"; +export let COMMAND_LOGIN = "fxaccounts:login"; +export let COMMAND_OAUTH = "fxaccounts:oauth_login"; +export let COMMAND_LOGOUT = "fxaccounts:logout"; +export let COMMAND_DELETE = "fxaccounts:delete"; +export let COMMAND_SYNC_PREFERENCES = "fxaccounts:sync_preferences"; +export let COMMAND_CHANGE_PASSWORD = "fxaccounts:change_password"; +export let COMMAND_FXA_STATUS = "fxaccounts:fxa_status"; +export let COMMAND_PAIR_PREFERENCES = "fxaccounts:pair_preferences"; +export let COMMAND_FIREFOX_VIEW = "fxaccounts:firefox_view"; + +// The pref branch where any prefs which relate to a specific account should +// be stored. This branch will be reset on account signout and signin. +export let PREF_ACCOUNT_ROOT = "identity.fxaccounts.account."; + +export let PREF_LAST_FXA_USER = "identity.fxaccounts.lastSignedInUserHash"; +export let PREF_REMOTE_PAIRING_URI = "identity.fxaccounts.remote.pairing.uri"; + +// Server errno. +// From https://github.com/mozilla/fxa-auth-server/blob/master/docs/api.md#response-format +export let ERRNO_ACCOUNT_ALREADY_EXISTS = 101; +export let ERRNO_ACCOUNT_DOES_NOT_EXIST = 102; +export let ERRNO_INCORRECT_PASSWORD = 103; +export let ERRNO_UNVERIFIED_ACCOUNT = 104; +export let ERRNO_INVALID_VERIFICATION_CODE = 105; +export let ERRNO_NOT_VALID_JSON_BODY = 106; +export let ERRNO_INVALID_BODY_PARAMETERS = 107; +export let ERRNO_MISSING_BODY_PARAMETERS = 108; +export let ERRNO_INVALID_REQUEST_SIGNATURE = 109; +export let ERRNO_INVALID_AUTH_TOKEN = 110; +export let ERRNO_INVALID_AUTH_TIMESTAMP = 111; +export let ERRNO_MISSING_CONTENT_LENGTH = 112; +export let ERRNO_REQUEST_BODY_TOO_LARGE = 113; +export let ERRNO_TOO_MANY_CLIENT_REQUESTS = 114; +export let ERRNO_INVALID_AUTH_NONCE = 115; +export let ERRNO_ENDPOINT_NO_LONGER_SUPPORTED = 116; +export let ERRNO_INCORRECT_LOGIN_METHOD = 117; +export let ERRNO_INCORRECT_KEY_RETRIEVAL_METHOD = 118; +export let ERRNO_INCORRECT_API_VERSION = 119; +export let ERRNO_INCORRECT_EMAIL_CASE = 120; +export let ERRNO_ACCOUNT_LOCKED = 121; +export let ERRNO_ACCOUNT_UNLOCKED = 122; +export let ERRNO_UNKNOWN_DEVICE = 123; +export let ERRNO_DEVICE_SESSION_CONFLICT = 124; +export let ERRNO_SERVICE_TEMP_UNAVAILABLE = 201; +export let ERRNO_PARSE = 997; +export let ERRNO_NETWORK = 998; +export let ERRNO_UNKNOWN_ERROR = 999; + +// Offset oauth server errnos so they don't conflict with auth server errnos +export let OAUTH_SERVER_ERRNO_OFFSET = 1000; + +// OAuth Server errno. +export let ERRNO_UNKNOWN_CLIENT_ID = 101 + OAUTH_SERVER_ERRNO_OFFSET; +export let ERRNO_INCORRECT_CLIENT_SECRET = 102 + OAUTH_SERVER_ERRNO_OFFSET; +export let ERRNO_INCORRECT_REDIRECT_URI = 103 + OAUTH_SERVER_ERRNO_OFFSET; +export let ERRNO_INVALID_FXA_ASSERTION = 104 + OAUTH_SERVER_ERRNO_OFFSET; +export let ERRNO_UNKNOWN_CODE = 105 + OAUTH_SERVER_ERRNO_OFFSET; +export let ERRNO_INCORRECT_CODE = 106 + OAUTH_SERVER_ERRNO_OFFSET; +export let ERRNO_EXPIRED_CODE = 107 + OAUTH_SERVER_ERRNO_OFFSET; +export let ERRNO_OAUTH_INVALID_TOKEN = 108 + OAUTH_SERVER_ERRNO_OFFSET; +export let ERRNO_INVALID_REQUEST_PARAM = 109 + OAUTH_SERVER_ERRNO_OFFSET; +export let ERRNO_INVALID_RESPONSE_TYPE = 110 + OAUTH_SERVER_ERRNO_OFFSET; +export let ERRNO_UNAUTHORIZED = 111 + OAUTH_SERVER_ERRNO_OFFSET; +export let ERRNO_FORBIDDEN = 112 + OAUTH_SERVER_ERRNO_OFFSET; +export let ERRNO_INVALID_CONTENT_TYPE = 113 + OAUTH_SERVER_ERRNO_OFFSET; + +// Errors. +export let ERROR_ACCOUNT_ALREADY_EXISTS = "ACCOUNT_ALREADY_EXISTS"; +export let ERROR_ACCOUNT_DOES_NOT_EXIST = "ACCOUNT_DOES_NOT_EXIST "; +export let ERROR_ACCOUNT_LOCKED = "ACCOUNT_LOCKED"; +export let ERROR_ACCOUNT_UNLOCKED = "ACCOUNT_UNLOCKED"; +export let ERROR_ALREADY_SIGNED_IN_USER = "ALREADY_SIGNED_IN_USER"; +export let ERROR_DEVICE_SESSION_CONFLICT = "DEVICE_SESSION_CONFLICT"; +export let ERROR_ENDPOINT_NO_LONGER_SUPPORTED = "ENDPOINT_NO_LONGER_SUPPORTED"; +export let ERROR_INCORRECT_API_VERSION = "INCORRECT_API_VERSION"; +export let ERROR_INCORRECT_EMAIL_CASE = "INCORRECT_EMAIL_CASE"; +export let ERROR_INCORRECT_KEY_RETRIEVAL_METHOD = + "INCORRECT_KEY_RETRIEVAL_METHOD"; +export let ERROR_INCORRECT_LOGIN_METHOD = "INCORRECT_LOGIN_METHOD"; +export let ERROR_INVALID_EMAIL = "INVALID_EMAIL"; +export let ERROR_INVALID_AUDIENCE = "INVALID_AUDIENCE"; +export let ERROR_INVALID_AUTH_TOKEN = "INVALID_AUTH_TOKEN"; +export let ERROR_INVALID_AUTH_TIMESTAMP = "INVALID_AUTH_TIMESTAMP"; +export let ERROR_INVALID_AUTH_NONCE = "INVALID_AUTH_NONCE"; +export let ERROR_INVALID_BODY_PARAMETERS = "INVALID_BODY_PARAMETERS"; +export let ERROR_INVALID_PASSWORD = "INVALID_PASSWORD"; +export let ERROR_INVALID_VERIFICATION_CODE = "INVALID_VERIFICATION_CODE"; +export let ERROR_INVALID_REFRESH_AUTH_VALUE = "INVALID_REFRESH_AUTH_VALUE"; +export let ERROR_INVALID_REQUEST_SIGNATURE = "INVALID_REQUEST_SIGNATURE"; +export let ERROR_INTERNAL_INVALID_USER = "INTERNAL_ERROR_INVALID_USER"; +export let ERROR_MISSING_BODY_PARAMETERS = "MISSING_BODY_PARAMETERS"; +export let ERROR_MISSING_CONTENT_LENGTH = "MISSING_CONTENT_LENGTH"; +export let ERROR_NO_TOKEN_SESSION = "NO_TOKEN_SESSION"; +export let ERROR_NO_SILENT_REFRESH_AUTH = "NO_SILENT_REFRESH_AUTH"; +export let ERROR_NOT_VALID_JSON_BODY = "NOT_VALID_JSON_BODY"; +export let ERROR_OFFLINE = "OFFLINE"; +export let ERROR_PERMISSION_DENIED = "PERMISSION_DENIED"; +export let ERROR_REQUEST_BODY_TOO_LARGE = "REQUEST_BODY_TOO_LARGE"; +export let ERROR_SERVER_ERROR = "SERVER_ERROR"; +export let ERROR_SYNC_DISABLED = "SYNC_DISABLED"; +export let ERROR_TOO_MANY_CLIENT_REQUESTS = "TOO_MANY_CLIENT_REQUESTS"; +export let ERROR_SERVICE_TEMP_UNAVAILABLE = "SERVICE_TEMPORARY_UNAVAILABLE"; +export let ERROR_UI_ERROR = "UI_ERROR"; +export let ERROR_UI_REQUEST = "UI_REQUEST"; +export let ERROR_PARSE = "PARSE_ERROR"; +export let ERROR_NETWORK = "NETWORK_ERROR"; +export let ERROR_UNKNOWN = "UNKNOWN_ERROR"; +export let ERROR_UNKNOWN_DEVICE = "UNKNOWN_DEVICE"; +export let ERROR_UNVERIFIED_ACCOUNT = "UNVERIFIED_ACCOUNT"; + +// OAuth errors. +export let ERROR_UNKNOWN_CLIENT_ID = "UNKNOWN_CLIENT_ID"; +export let ERROR_INCORRECT_CLIENT_SECRET = "INCORRECT_CLIENT_SECRET"; +export let ERROR_INCORRECT_REDIRECT_URI = "INCORRECT_REDIRECT_URI"; +export let ERROR_INVALID_FXA_ASSERTION = "INVALID_FXA_ASSERTION"; +export let ERROR_UNKNOWN_CODE = "UNKNOWN_CODE"; +export let ERROR_INCORRECT_CODE = "INCORRECT_CODE"; +export let ERROR_EXPIRED_CODE = "EXPIRED_CODE"; +export let ERROR_OAUTH_INVALID_TOKEN = "OAUTH_INVALID_TOKEN"; +export let ERROR_INVALID_REQUEST_PARAM = "INVALID_REQUEST_PARAM"; +export let ERROR_INVALID_RESPONSE_TYPE = "INVALID_RESPONSE_TYPE"; +export let ERROR_UNAUTHORIZED = "UNAUTHORIZED"; +export let ERROR_FORBIDDEN = "FORBIDDEN"; +export let ERROR_INVALID_CONTENT_TYPE = "INVALID_CONTENT_TYPE"; + +// Additional generic error classes for external consumers +export let ERROR_NO_ACCOUNT = "NO_ACCOUNT"; +export let ERROR_AUTH_ERROR = "AUTH_ERROR"; +export let ERROR_INVALID_PARAMETER = "INVALID_PARAMETER"; + +// Status code errors +export let ERROR_CODE_METHOD_NOT_ALLOWED = 405; +export let ERROR_MSG_METHOD_NOT_ALLOWED = "METHOD_NOT_ALLOWED"; + +// FxAccounts has the ability to "split" the credentials between a plain-text +// JSON file in the profile dir and in the login manager. +// In order to prevent new fields accidentally ending up in the "wrong" place, +// all fields stored are listed here. + +// The fields we save in the plaintext JSON. +// See bug 1013064 comments 23-25 for why the sessionToken is "safe" +export let FXA_PWDMGR_PLAINTEXT_FIELDS = new Set([ + "email", + "verified", + "authAt", + "sessionToken", + "uid", + "oauthTokens", + "profile", + "device", + "profileCache", + "encryptedSendTabKeys", +]); + +// Fields we store in secure storage if it exists. +export let FXA_PWDMGR_SECURE_FIELDS = new Set([ + "keyFetchToken", + "unwrapBKey", + "scopedKeys", +]); + +// An allowlist of fields that remain in storage when the user needs to +// reauthenticate. All other fields will be removed. +export let FXA_PWDMGR_REAUTH_ALLOWLIST = new Set([ + "email", + "uid", + "profile", + "device", + "verified", +]); + +// The pseudo-host we use in the login manager +export let FXA_PWDMGR_HOST = "chrome://FirefoxAccounts"; +// The realm we use in the login manager. +export let FXA_PWDMGR_REALM = "Firefox Accounts credentials"; + +// Error matching. +export let SERVER_ERRNO_TO_ERROR = { + [ERRNO_ACCOUNT_ALREADY_EXISTS]: ERROR_ACCOUNT_ALREADY_EXISTS, + [ERRNO_ACCOUNT_DOES_NOT_EXIST]: ERROR_ACCOUNT_DOES_NOT_EXIST, + [ERRNO_INCORRECT_PASSWORD]: ERROR_INVALID_PASSWORD, + [ERRNO_UNVERIFIED_ACCOUNT]: ERROR_UNVERIFIED_ACCOUNT, + [ERRNO_INVALID_VERIFICATION_CODE]: ERROR_INVALID_VERIFICATION_CODE, + [ERRNO_NOT_VALID_JSON_BODY]: ERROR_NOT_VALID_JSON_BODY, + [ERRNO_INVALID_BODY_PARAMETERS]: ERROR_INVALID_BODY_PARAMETERS, + [ERRNO_MISSING_BODY_PARAMETERS]: ERROR_MISSING_BODY_PARAMETERS, + [ERRNO_INVALID_REQUEST_SIGNATURE]: ERROR_INVALID_REQUEST_SIGNATURE, + [ERRNO_INVALID_AUTH_TOKEN]: ERROR_INVALID_AUTH_TOKEN, + [ERRNO_INVALID_AUTH_TIMESTAMP]: ERROR_INVALID_AUTH_TIMESTAMP, + [ERRNO_MISSING_CONTENT_LENGTH]: ERROR_MISSING_CONTENT_LENGTH, + [ERRNO_REQUEST_BODY_TOO_LARGE]: ERROR_REQUEST_BODY_TOO_LARGE, + [ERRNO_TOO_MANY_CLIENT_REQUESTS]: ERROR_TOO_MANY_CLIENT_REQUESTS, + [ERRNO_INVALID_AUTH_NONCE]: ERROR_INVALID_AUTH_NONCE, + [ERRNO_ENDPOINT_NO_LONGER_SUPPORTED]: ERROR_ENDPOINT_NO_LONGER_SUPPORTED, + [ERRNO_INCORRECT_LOGIN_METHOD]: ERROR_INCORRECT_LOGIN_METHOD, + [ERRNO_INCORRECT_KEY_RETRIEVAL_METHOD]: ERROR_INCORRECT_KEY_RETRIEVAL_METHOD, + [ERRNO_INCORRECT_API_VERSION]: ERROR_INCORRECT_API_VERSION, + [ERRNO_INCORRECT_EMAIL_CASE]: ERROR_INCORRECT_EMAIL_CASE, + [ERRNO_ACCOUNT_LOCKED]: ERROR_ACCOUNT_LOCKED, + [ERRNO_ACCOUNT_UNLOCKED]: ERROR_ACCOUNT_UNLOCKED, + [ERRNO_UNKNOWN_DEVICE]: ERROR_UNKNOWN_DEVICE, + [ERRNO_DEVICE_SESSION_CONFLICT]: ERROR_DEVICE_SESSION_CONFLICT, + [ERRNO_SERVICE_TEMP_UNAVAILABLE]: ERROR_SERVICE_TEMP_UNAVAILABLE, + [ERRNO_UNKNOWN_ERROR]: ERROR_UNKNOWN, + [ERRNO_NETWORK]: ERROR_NETWORK, + // oauth + [ERRNO_UNKNOWN_CLIENT_ID]: ERROR_UNKNOWN_CLIENT_ID, + [ERRNO_INCORRECT_CLIENT_SECRET]: ERROR_INCORRECT_CLIENT_SECRET, + [ERRNO_INCORRECT_REDIRECT_URI]: ERROR_INCORRECT_REDIRECT_URI, + [ERRNO_INVALID_FXA_ASSERTION]: ERROR_INVALID_FXA_ASSERTION, + [ERRNO_UNKNOWN_CODE]: ERROR_UNKNOWN_CODE, + [ERRNO_INCORRECT_CODE]: ERROR_INCORRECT_CODE, + [ERRNO_EXPIRED_CODE]: ERROR_EXPIRED_CODE, + [ERRNO_OAUTH_INVALID_TOKEN]: ERROR_OAUTH_INVALID_TOKEN, + [ERRNO_INVALID_REQUEST_PARAM]: ERROR_INVALID_REQUEST_PARAM, + [ERRNO_INVALID_RESPONSE_TYPE]: ERROR_INVALID_RESPONSE_TYPE, + [ERRNO_UNAUTHORIZED]: ERROR_UNAUTHORIZED, + [ERRNO_FORBIDDEN]: ERROR_FORBIDDEN, + [ERRNO_INVALID_CONTENT_TYPE]: ERROR_INVALID_CONTENT_TYPE, +}; + +// Map internal errors to more generic error classes for consumers +export let ERROR_TO_GENERAL_ERROR_CLASS = { + [ERROR_ACCOUNT_ALREADY_EXISTS]: ERROR_AUTH_ERROR, + [ERROR_ACCOUNT_DOES_NOT_EXIST]: ERROR_AUTH_ERROR, + [ERROR_ACCOUNT_LOCKED]: ERROR_AUTH_ERROR, + [ERROR_ACCOUNT_UNLOCKED]: ERROR_AUTH_ERROR, + [ERROR_ALREADY_SIGNED_IN_USER]: ERROR_AUTH_ERROR, + [ERROR_DEVICE_SESSION_CONFLICT]: ERROR_AUTH_ERROR, + [ERROR_ENDPOINT_NO_LONGER_SUPPORTED]: ERROR_AUTH_ERROR, + [ERROR_INCORRECT_API_VERSION]: ERROR_AUTH_ERROR, + [ERROR_INCORRECT_EMAIL_CASE]: ERROR_AUTH_ERROR, + [ERROR_INCORRECT_KEY_RETRIEVAL_METHOD]: ERROR_AUTH_ERROR, + [ERROR_INCORRECT_LOGIN_METHOD]: ERROR_AUTH_ERROR, + [ERROR_INVALID_EMAIL]: ERROR_AUTH_ERROR, + [ERROR_INVALID_AUDIENCE]: ERROR_AUTH_ERROR, + [ERROR_INVALID_AUTH_TOKEN]: ERROR_AUTH_ERROR, + [ERROR_INVALID_AUTH_TIMESTAMP]: ERROR_AUTH_ERROR, + [ERROR_INVALID_AUTH_NONCE]: ERROR_AUTH_ERROR, + [ERROR_INVALID_BODY_PARAMETERS]: ERROR_AUTH_ERROR, + [ERROR_INVALID_PASSWORD]: ERROR_AUTH_ERROR, + [ERROR_INVALID_VERIFICATION_CODE]: ERROR_AUTH_ERROR, + [ERROR_INVALID_REFRESH_AUTH_VALUE]: ERROR_AUTH_ERROR, + [ERROR_INVALID_REQUEST_SIGNATURE]: ERROR_AUTH_ERROR, + [ERROR_INTERNAL_INVALID_USER]: ERROR_AUTH_ERROR, + [ERROR_MISSING_BODY_PARAMETERS]: ERROR_AUTH_ERROR, + [ERROR_MISSING_CONTENT_LENGTH]: ERROR_AUTH_ERROR, + [ERROR_NO_TOKEN_SESSION]: ERROR_AUTH_ERROR, + [ERROR_NO_SILENT_REFRESH_AUTH]: ERROR_AUTH_ERROR, + [ERROR_NOT_VALID_JSON_BODY]: ERROR_AUTH_ERROR, + [ERROR_PERMISSION_DENIED]: ERROR_AUTH_ERROR, + [ERROR_REQUEST_BODY_TOO_LARGE]: ERROR_AUTH_ERROR, + [ERROR_UNKNOWN_DEVICE]: ERROR_AUTH_ERROR, + [ERROR_UNVERIFIED_ACCOUNT]: ERROR_AUTH_ERROR, + [ERROR_UI_ERROR]: ERROR_AUTH_ERROR, + [ERROR_UI_REQUEST]: ERROR_AUTH_ERROR, + [ERROR_OFFLINE]: ERROR_NETWORK, + [ERROR_SERVER_ERROR]: ERROR_NETWORK, + [ERROR_TOO_MANY_CLIENT_REQUESTS]: ERROR_NETWORK, + [ERROR_SERVICE_TEMP_UNAVAILABLE]: ERROR_NETWORK, + [ERROR_PARSE]: ERROR_NETWORK, + [ERROR_NETWORK]: ERROR_NETWORK, + + // oauth + [ERROR_INCORRECT_CLIENT_SECRET]: ERROR_AUTH_ERROR, + [ERROR_INCORRECT_REDIRECT_URI]: ERROR_AUTH_ERROR, + [ERROR_INVALID_FXA_ASSERTION]: ERROR_AUTH_ERROR, + [ERROR_UNKNOWN_CODE]: ERROR_AUTH_ERROR, + [ERROR_INCORRECT_CODE]: ERROR_AUTH_ERROR, + [ERROR_EXPIRED_CODE]: ERROR_AUTH_ERROR, + [ERROR_OAUTH_INVALID_TOKEN]: ERROR_AUTH_ERROR, + [ERROR_INVALID_REQUEST_PARAM]: ERROR_AUTH_ERROR, + [ERROR_INVALID_RESPONSE_TYPE]: ERROR_AUTH_ERROR, + [ERROR_UNAUTHORIZED]: ERROR_AUTH_ERROR, + [ERROR_FORBIDDEN]: ERROR_AUTH_ERROR, + [ERROR_INVALID_CONTENT_TYPE]: ERROR_AUTH_ERROR, +}; diff --git a/services/fxaccounts/FxAccountsConfig.sys.mjs b/services/fxaccounts/FxAccountsConfig.sys.mjs new file mode 100644 index 0000000000..cf26704a50 --- /dev/null +++ b/services/fxaccounts/FxAccountsConfig.sys.mjs @@ -0,0 +1,360 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { RESTRequest } from "resource://services-common/rest.sys.mjs"; + +import { + log, + SCOPE_OLD_SYNC, + SCOPE_PROFILE, +} from "resource://gre/modules/FxAccountsCommon.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineLazyGetter(lazy, "fxAccounts", () => { + return ChromeUtils.importESModule( + "resource://gre/modules/FxAccounts.sys.mjs" + ).getFxAccountsSingleton(); +}); + +ChromeUtils.defineESModuleGetters(lazy, { + EnsureFxAccountsWebChannel: + "resource://gre/modules/FxAccountsWebChannel.sys.mjs", +}); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "ROOT_URL", + "identity.fxaccounts.remote.root" +); +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "CONTEXT_PARAM", + "identity.fxaccounts.contextParam" +); +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "REQUIRES_HTTPS", + "identity.fxaccounts.allowHttp", + false, + null, + val => !val +); + +const CONFIG_PREFS = [ + "identity.fxaccounts.remote.root", + "identity.fxaccounts.auth.uri", + "identity.fxaccounts.remote.oauth.uri", + "identity.fxaccounts.remote.profile.uri", + "identity.fxaccounts.remote.pairing.uri", + "identity.sync.tokenserver.uri", +]; +const SYNC_PARAM = "sync"; + +export var FxAccountsConfig = { + async promiseEmailURI(email, entrypoint, extraParams = {}) { + const authParams = await this._getAuthParams(); + return this._buildURL("", { + extraParams: { + entrypoint, + email, + ...authParams, + ...extraParams, + }, + }); + }, + + async promiseConnectAccountURI(entrypoint, extraParams = {}) { + const authParams = await this._getAuthParams(); + return this._buildURL("", { + extraParams: { + entrypoint, + action: "email", + ...authParams, + ...extraParams, + }, + }); + }, + + async promiseForceSigninURI(entrypoint, extraParams = {}) { + const authParams = await this._getAuthParams(); + return this._buildURL("force_auth", { + extraParams: { entrypoint, ...authParams, ...extraParams }, + addAccountIdentifiers: true, + }); + }, + + async promiseManageURI(entrypoint, extraParams = {}) { + return this._buildURL("settings", { + extraParams: { entrypoint, ...extraParams }, + addAccountIdentifiers: true, + }); + }, + + async promiseChangeAvatarURI(entrypoint, extraParams = {}) { + return this._buildURL("settings/avatar/change", { + extraParams: { entrypoint, ...extraParams }, + addAccountIdentifiers: true, + }); + }, + + async promiseManageDevicesURI(entrypoint, extraParams = {}) { + return this._buildURL("settings/clients", { + extraParams: { entrypoint, ...extraParams }, + addAccountIdentifiers: true, + }); + }, + + async promiseConnectDeviceURI(entrypoint, extraParams = {}) { + return this._buildURL("connect_another_device", { + extraParams: { entrypoint, service: SYNC_PARAM, ...extraParams }, + addAccountIdentifiers: true, + }); + }, + + async promisePairingURI(extraParams = {}) { + return this._buildURL("pair", { + extraParams, + includeDefaultParams: false, + }); + }, + + async promiseOAuthURI(extraParams = {}) { + return this._buildURL("oauth", { + extraParams, + includeDefaultParams: false, + }); + }, + + async promiseMetricsFlowURI(entrypoint, extraParams = {}) { + return this._buildURL("metrics-flow", { + extraParams: { entrypoint, ...extraParams }, + includeDefaultParams: false, + }); + }, + + get defaultParams() { + return { context: lazy.CONTEXT_PARAM }; + }, + + /** + * @param path should be parsable by the URL constructor first parameter. + * @param {bool} [options.includeDefaultParams] If true include the default search params. + * @param {Object.<string, string>} [options.extraParams] Additionnal search params. + * @param {bool} [options.addAccountIdentifiers] if true we add the current logged-in user uid and email to the search params. + */ + async _buildURL( + path, + { + includeDefaultParams = true, + extraParams = {}, + addAccountIdentifiers = false, + } + ) { + await this.ensureConfigured(); + const url = new URL(path, lazy.ROOT_URL); + if (lazy.REQUIRES_HTTPS && url.protocol != "https:") { + throw new Error("Firefox Accounts server must use HTTPS"); + } + const params = { + ...(includeDefaultParams ? this.defaultParams : null), + ...extraParams, + }; + for (let [k, v] of Object.entries(params)) { + url.searchParams.append(k, v); + } + if (addAccountIdentifiers) { + const accountData = await this.getSignedInUser(); + if (!accountData) { + return null; + } + url.searchParams.append("uid", accountData.uid); + url.searchParams.append("email", accountData.email); + } + return url.href; + }, + + async _buildURLFromString(href, extraParams = {}) { + const url = new URL(href); + for (let [k, v] of Object.entries(extraParams)) { + url.searchParams.append(k, v); + } + return url.href; + }, + + resetConfigURLs() { + let autoconfigURL = this.getAutoConfigURL(); + if (!autoconfigURL) { + return; + } + // They have the autoconfig uri pref set, so we clear all the prefs that we + // will have initialized, which will leave them pointing at production. + for (let pref of CONFIG_PREFS) { + Services.prefs.clearUserPref(pref); + } + // Reset the webchannel. + lazy.EnsureFxAccountsWebChannel(); + }, + + getAutoConfigURL() { + let pref = Services.prefs.getStringPref( + "identity.fxaccounts.autoconfig.uri", + "" + ); + if (!pref) { + // no pref / empty pref means we don't bother here. + return ""; + } + let rootURL = Services.urlFormatter.formatURL(pref); + if (rootURL.endsWith("/")) { + rootURL = rootURL.slice(0, -1); + } + return rootURL; + }, + + async ensureConfigured() { + let isSignedIn = !!(await this.getSignedInUser()); + if (!isSignedIn) { + await this.updateConfigURLs(); + } + }, + + // Returns true if this user is using the FxA "production" systems, false + // if using any other configuration, including self-hosting or the FxA + // non-production systems such as "dev" or "staging". + // It's typically used as a proxy for "is this likely to be a self-hosted + // user?", but it's named this way to make the implementation less + // surprising. As a result, it's fairly conservative and would prefer to have + // a false-negative than a false-position as it determines things which users + // might consider sensitive (notably, telemetry). + // Note also that while it's possible to self-host just sync and not FxA, we + // don't make that distinction - that's a self-hoster from the POV of this + // function. + isProductionConfig() { + // Specifically, if the autoconfig URLs, or *any* of the URLs that + // we consider configurable are modified, we assume self-hosted. + if (this.getAutoConfigURL()) { + return false; + } + for (let pref of CONFIG_PREFS) { + if (Services.prefs.prefHasUserValue(pref)) { + return false; + } + } + return true; + }, + + // Read expected client configuration from the fxa auth server + // (from `identity.fxaccounts.autoconfig.uri`/.well-known/fxa-client-configuration) + // and replace all the relevant our prefs with the information found there. + // This is only done before sign-in and sign-up, and even then only if the + // `identity.fxaccounts.autoconfig.uri` preference is set. + async updateConfigURLs() { + let rootURL = this.getAutoConfigURL(); + if (!rootURL) { + return; + } + const config = await this.fetchConfigDocument(rootURL); + try { + // Update the prefs directly specified by the config. + let authServerBase = config.auth_server_base_url; + if (!authServerBase.endsWith("/v1")) { + authServerBase += "/v1"; + } + Services.prefs.setStringPref( + "identity.fxaccounts.auth.uri", + authServerBase + ); + Services.prefs.setStringPref( + "identity.fxaccounts.remote.oauth.uri", + config.oauth_server_base_url + "/v1" + ); + // At the time of landing this, our servers didn't yet answer with pairing_server_base_uri. + // Remove this condition check once Firefox 68 is stable. + if (config.pairing_server_base_uri) { + Services.prefs.setStringPref( + "identity.fxaccounts.remote.pairing.uri", + config.pairing_server_base_uri + ); + } + Services.prefs.setStringPref( + "identity.fxaccounts.remote.profile.uri", + config.profile_server_base_url + "/v1" + ); + Services.prefs.setStringPref( + "identity.sync.tokenserver.uri", + config.sync_tokenserver_base_url + "/1.0/sync/1.5" + ); + Services.prefs.setStringPref("identity.fxaccounts.remote.root", rootURL); + + // Ensure the webchannel is pointed at the correct uri + lazy.EnsureFxAccountsWebChannel(); + } catch (e) { + log.error( + "Failed to initialize configuration preferences from autoconfig object", + e + ); + throw e; + } + }, + + // Read expected client configuration from the fxa auth server + // (or from the provided rootURL, if present) and return it as an object. + async fetchConfigDocument(rootURL = null) { + if (!rootURL) { + rootURL = lazy.ROOT_URL; + } + let configURL = rootURL + "/.well-known/fxa-client-configuration"; + let request = new RESTRequest(configURL); + request.setHeader("Accept", "application/json"); + + // Catch and rethrow the error inline. + let resp = await request.get().catch(e => { + log.error(`Failed to get configuration object from "${configURL}"`, e); + throw e; + }); + if (!resp.success) { + // Note: 'resp.body' is included with the error log below as we are not concerned + // that the body will contain PII, but if that changes it should be excluded. + log.error( + `Received HTTP response code ${resp.status} from configuration object request: + ${resp.body}` + ); + throw new Error( + `HTTP status ${resp.status} from configuration object request` + ); + } + log.debug("Got successful configuration response", resp.body); + try { + return JSON.parse(resp.body); + } catch (e) { + log.error( + `Failed to parse configuration preferences from ${configURL}`, + e + ); + throw e; + } + }, + + // For test purposes, returns a Promise. + getSignedInUser() { + return lazy.fxAccounts.getSignedInUser(); + }, + + _isOAuthFlow() { + return Services.prefs.getBoolPref( + "identity.fxaccounts.oauth.enabled", + false + ); + }, + + async _getAuthParams() { + if (this._isOAuthFlow()) { + const scopes = [SCOPE_OLD_SYNC, SCOPE_PROFILE]; + return lazy.fxAccounts._internal.beginOAuthFlow(scopes); + } + return { service: SYNC_PARAM }; + }, +}; diff --git a/services/fxaccounts/FxAccountsDevice.sys.mjs b/services/fxaccounts/FxAccountsDevice.sys.mjs new file mode 100644 index 0000000000..6b2089739c --- /dev/null +++ b/services/fxaccounts/FxAccountsDevice.sys.mjs @@ -0,0 +1,640 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +import { + log, + ERRNO_DEVICE_SESSION_CONFLICT, + ERRNO_UNKNOWN_DEVICE, + ON_NEW_DEVICE_ID, + ON_DEVICELIST_UPDATED, + ON_DEVICE_CONNECTED_NOTIFICATION, + ON_DEVICE_DISCONNECTED_NOTIFICATION, + ONVERIFIED_NOTIFICATION, + PREF_ACCOUNT_ROOT, +} from "resource://gre/modules/FxAccountsCommon.sys.mjs"; + +import { DEVICE_TYPE_DESKTOP } from "resource://services-sync/constants.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + CommonUtils: "resource://services-common/utils.sys.mjs", +}); + +const PREF_LOCAL_DEVICE_NAME = PREF_ACCOUNT_ROOT + "device.name"; +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "pref_localDeviceName", + PREF_LOCAL_DEVICE_NAME, + "" +); + +const PREF_DEPRECATED_DEVICE_NAME = "services.sync.client.name"; + +// Sanitizes all characters which the FxA server considers invalid, replacing +// them with the unicode replacement character. +// At time of writing, FxA has a regex DISPLAY_SAFE_UNICODE_WITH_NON_BMP, which +// the regex below is based on. +const INVALID_NAME_CHARS = + // eslint-disable-next-line no-control-regex + /[\u0000-\u001F\u007F\u0080-\u009F\u2028-\u2029\uE000-\uF8FF\uFFF9-\uFFFC\uFFFE-\uFFFF]/g; +const MAX_NAME_LEN = 255; +const REPLACEMENT_CHAR = "\uFFFD"; + +function sanitizeDeviceName(name) { + return name + .substr(0, MAX_NAME_LEN) + .replace(INVALID_NAME_CHARS, REPLACEMENT_CHAR); +} + +// Everything to do with FxA devices. +export class FxAccountsDevice { + constructor(fxai) { + this._fxai = fxai; + this._deviceListCache = null; + this._fetchAndCacheDeviceListPromise = null; + + // The current version of the device registration, we use this to re-register + // devices after we update what we send on device registration. + this.DEVICE_REGISTRATION_VERSION = 2; + + // This is to avoid multiple sequential syncs ending up calling + // this expensive endpoint multiple times in a row. + this.TIME_BETWEEN_FXA_DEVICES_FETCH_MS = 1 * 60 * 1000; // 1 minute + + // Invalidate our cached device list when a device is connected or disconnected. + Services.obs.addObserver(this, ON_DEVICE_CONNECTED_NOTIFICATION, true); + Services.obs.addObserver(this, ON_DEVICE_DISCONNECTED_NOTIFICATION, true); + // A user becoming verified probably means we need to re-register the device + // because we are now able to get the sendtab keys. + Services.obs.addObserver(this, ONVERIFIED_NOTIFICATION, true); + } + + async getLocalId() { + return this._withCurrentAccountState(currentState => { + // It turns out _updateDeviceRegistrationIfNecessary() does exactly what we + // need. + return this._updateDeviceRegistrationIfNecessary(currentState); + }); + } + + // Generate a client name if we don't have a useful one yet + getDefaultLocalName() { + let user = Services.env.get("USER") || Services.env.get("USERNAME"); + // Note that we used to fall back to the "services.sync.username" pref here, + // but that's no longer suitable in a world where sync might not be + // configured. However, we almost never *actually* fell back to that, and + // doing so sanely here would mean making this function async, which we don't + // really want to do yet. + + // A little hack for people using the the moz-build environment on Windows + // which sets USER to the literal "%USERNAME%" (yes, really) + if (user == "%USERNAME%" && Services.env.get("USERNAME")) { + user = Services.env.get("USERNAME"); + } + + // The DNS service may fail to provide a hostname in edge-cases we don't + // fully understand - bug 1391488. + let hostname; + try { + // hostname of the system, usually assigned by the user or admin + hostname = Services.dns.myHostName; + } catch (ex) { + console.error(ex); + } + let system = + // 'device' is defined on unix systems + Services.sysinfo.get("device") || + hostname || + // fall back on ua info string + Cc["@mozilla.org/network/protocol;1?name=http"].getService( + Ci.nsIHttpProtocolHandler + ).oscpu; + + const l10n = new Localization( + ["services/accounts.ftl", "branding/brand.ftl"], + true + ); + return sanitizeDeviceName( + l10n.formatValueSync("account-client-name", { user, system }) + ); + } + + getLocalName() { + // We used to store this in services.sync.client.name, but now store it + // under an fxa-specific location. + let deprecated_value = Services.prefs.getStringPref( + PREF_DEPRECATED_DEVICE_NAME, + "" + ); + if (deprecated_value) { + Services.prefs.setStringPref(PREF_LOCAL_DEVICE_NAME, deprecated_value); + Services.prefs.clearUserPref(PREF_DEPRECATED_DEVICE_NAME); + } + let name = lazy.pref_localDeviceName; + if (!name) { + name = this.getDefaultLocalName(); + Services.prefs.setStringPref(PREF_LOCAL_DEVICE_NAME, name); + } + // We need to sanitize here because some names were generated before we + // started sanitizing. + return sanitizeDeviceName(name); + } + + setLocalName(newName) { + Services.prefs.clearUserPref(PREF_DEPRECATED_DEVICE_NAME); + Services.prefs.setStringPref( + PREF_LOCAL_DEVICE_NAME, + sanitizeDeviceName(newName) + ); + // Update the registration in the background. + this.updateDeviceRegistration().catch(error => { + log.warn("failed to update fxa device registration", error); + }); + } + + getLocalType() { + return DEVICE_TYPE_DESKTOP; + } + + /** + * Returns the most recently fetched device list, or `null` if the list + * hasn't been fetched yet. This is synchronous, so that consumers like + * Send Tab can render the device list right away, without waiting for + * it to refresh. + * + * @type {?Array} + */ + get recentDeviceList() { + return this._deviceListCache ? this._deviceListCache.devices : null; + } + + /** + * Refreshes the device list. After this function returns, consumers can + * access the new list using the `recentDeviceList` getter. Note that + * multiple concurrent calls to `refreshDeviceList` will only refresh the + * list once. + * + * @param {Boolean} [options.ignoreCached] + * If `true`, forces a refresh, even if the cached device list is + * still fresh. Defaults to `false`. + * @return {Promise<Boolean>} + * `true` if the list was refreshed, `false` if the cached list is + * fresh. Rejects if an error occurs refreshing the list or device + * push registration. + */ + async refreshDeviceList({ ignoreCached = false } = {}) { + // If we're already refreshing the list in the background, let that finish. + if (this._fetchAndCacheDeviceListPromise) { + log.info("Already fetching device list, return existing promise"); + return this._fetchAndCacheDeviceListPromise; + } + + // If the cache is fresh enough, don't refresh it again. + if (!ignoreCached && this._deviceListCache) { + const ageOfCache = this._fxai.now() - this._deviceListCache.lastFetch; + if (ageOfCache < this.TIME_BETWEEN_FXA_DEVICES_FETCH_MS) { + log.info("Device list cache is fresh, re-using it"); + return false; + } + } + + log.info("fetching updated device list"); + this._fetchAndCacheDeviceListPromise = (async () => { + try { + const devices = await this._withVerifiedAccountState( + async currentState => { + const accountData = await currentState.getUserAccountData([ + "sessionToken", + "device", + ]); + const devices = await this._fxai.fxAccountsClient.getDeviceList( + accountData.sessionToken + ); + log.info( + `Got new device list: ${devices.map(d => d.id).join(", ")}` + ); + + await this._refreshRemoteDevice(currentState, accountData, devices); + return devices; + } + ); + log.info("updating the cache"); + // Be careful to only update the cache once the above has resolved, so + // we know that the current account state didn't change underneath us. + this._deviceListCache = { + lastFetch: this._fxai.now(), + devices, + }; + Services.obs.notifyObservers(null, ON_DEVICELIST_UPDATED); + return true; + } finally { + this._fetchAndCacheDeviceListPromise = null; + } + })(); + return this._fetchAndCacheDeviceListPromise; + } + + async _refreshRemoteDevice(currentState, accountData, remoteDevices) { + // Check if our push registration previously succeeded and is still + // good (although background device registration means it's possible + // we'll be fetching the device list before we've actually + // registered ourself!) + // (For a missing subscription we check for an explicit 'null' - + // both to help tests and as a safety valve - missing might mean + // "no push available" for self-hosters or similar?) + const ourDevice = remoteDevices.find(device => device.isCurrentDevice); + const subscription = await this._fxai.fxaPushService.getSubscription(); + if ( + ourDevice && + (ourDevice.pushCallback === null || // fxa server doesn't know our subscription. + ourDevice.pushEndpointExpired || // fxa server thinks it has expired. + !subscription || // we don't have a local subscription. + subscription.isExpired() || // our local subscription is expired. + ourDevice.pushCallback != subscription.endpoint) // we don't agree with fxa. + ) { + log.warn(`Our push endpoint needs resubscription`); + await this._fxai.fxaPushService.unsubscribe(); + await this._registerOrUpdateDevice(currentState, accountData); + // and there's a reasonable chance there are commands waiting. + await this._fxai.commands.pollDeviceCommands(); + } else if ( + ourDevice && + (await this._checkRemoteCommandsUpdateNeeded(ourDevice.availableCommands)) + ) { + log.warn(`Our commands need to be updated on the server`); + await this._registerOrUpdateDevice(currentState, accountData); + } else { + log.trace(`Our push subscription looks OK`); + } + } + + async updateDeviceRegistration() { + return this._withCurrentAccountState(async currentState => { + const signedInUser = await currentState.getUserAccountData([ + "sessionToken", + "device", + ]); + if (signedInUser) { + await this._registerOrUpdateDevice(currentState, signedInUser); + } + }); + } + + async updateDeviceRegistrationIfNecessary() { + return this._withCurrentAccountState(currentState => { + return this._updateDeviceRegistrationIfNecessary(currentState); + }); + } + + reset() { + this._deviceListCache = null; + this._fetchAndCacheDeviceListPromise = null; + } + + /** + * Here begin our internal helper methods. + * + * Many of these methods take the current account state as first argument, + * in order to avoid racing our state updates with e.g. the uer signing + * out while we're in the middle of an update. If this does happen, the + * resulting promise will be rejected rather than persisting stale state. + * + */ + + _withCurrentAccountState(func) { + return this._fxai.withCurrentAccountState(async currentState => { + try { + return await func(currentState); + } catch (err) { + // `_handleTokenError` always throws, this syntax keeps the linter happy. + // TODO: probably `_handleTokenError` could be done by `_fxai.withCurrentAccountState` + // internally rather than us having to remember to do it here. + throw await this._fxai._handleTokenError(err); + } + }); + } + + _withVerifiedAccountState(func) { + return this._fxai.withVerifiedAccountState(async currentState => { + try { + return await func(currentState); + } catch (err) { + // `_handleTokenError` always throws, this syntax keeps the linter happy. + throw await this._fxai._handleTokenError(err); + } + }); + } + + async _checkDeviceUpdateNeeded(device) { + // There is no device registered or the device registration is outdated. + // Either way, we should register the device with FxA + // before returning the id to the caller. + const availableCommandsKeys = Object.keys( + await this._fxai.commands.availableCommands() + ).sort(); + return ( + !device || + !device.registrationVersion || + device.registrationVersion < this.DEVICE_REGISTRATION_VERSION || + !device.registeredCommandsKeys || + !lazy.CommonUtils.arrayEqual( + device.registeredCommandsKeys, + availableCommandsKeys + ) + ); + } + + async _checkRemoteCommandsUpdateNeeded(remoteAvailableCommands) { + if (!remoteAvailableCommands) { + return true; + } + const remoteAvailableCommandsKeys = Object.keys( + remoteAvailableCommands + ).sort(); + const localAvailableCommands = + await this._fxai.commands.availableCommands(); + const localAvailableCommandsKeys = Object.keys( + localAvailableCommands + ).sort(); + + if ( + !lazy.CommonUtils.arrayEqual( + localAvailableCommandsKeys, + remoteAvailableCommandsKeys + ) + ) { + return true; + } + + for (const key of localAvailableCommandsKeys) { + if (remoteAvailableCommands[key] !== localAvailableCommands[key]) { + return true; + } + } + return false; + } + + async _updateDeviceRegistrationIfNecessary(currentState) { + let data = await currentState.getUserAccountData([ + "sessionToken", + "device", + ]); + if (!data) { + // Can't register a device without a signed-in user. + return null; + } + const { device } = data; + if (await this._checkDeviceUpdateNeeded(device)) { + return this._registerOrUpdateDevice(currentState, data); + } + // Return the device ID we already had. + return device.id; + } + + // If you change what we send to the FxA servers during device registration, + // you'll have to bump the DEVICE_REGISTRATION_VERSION number to force older + // devices to re-register when Firefox updates. + async _registerOrUpdateDevice(currentState, signedInUser) { + // This method has the side-effect of setting some account-related prefs + // (e.g. for caching the device name) so it's important we don't execute it + // if the signed-in state has changed. + if (!currentState.isCurrent) { + throw new Error( + "_registerOrUpdateDevice called after a different user has signed in" + ); + } + + const { sessionToken, device: currentDevice } = signedInUser; + if (!sessionToken) { + throw new Error("_registerOrUpdateDevice called without a session token"); + } + + try { + const subscription = + await this._fxai.fxaPushService.registerPushEndpoint(); + const deviceName = this.getLocalName(); + let deviceOptions = {}; + + // if we were able to obtain a subscription + if (subscription && subscription.endpoint) { + deviceOptions.pushCallback = subscription.endpoint; + let publicKey = subscription.getKey("p256dh"); + let authKey = subscription.getKey("auth"); + if (publicKey && authKey) { + deviceOptions.pushPublicKey = urlsafeBase64Encode(publicKey); + deviceOptions.pushAuthKey = urlsafeBase64Encode(authKey); + } + } + deviceOptions.availableCommands = + await this._fxai.commands.availableCommands(); + const availableCommandsKeys = Object.keys( + deviceOptions.availableCommands + ).sort(); + log.info("registering with available commands", availableCommandsKeys); + + let device; + let is_existing = currentDevice && currentDevice.id; + if (is_existing) { + log.debug("updating existing device details"); + device = await this._fxai.fxAccountsClient.updateDevice( + sessionToken, + currentDevice.id, + deviceName, + deviceOptions + ); + } else { + log.debug("registering new device details"); + device = await this._fxai.fxAccountsClient.registerDevice( + sessionToken, + deviceName, + this.getLocalType(), + deviceOptions + ); + } + + // Get the freshest device props before updating them. + let { device: deviceProps } = await currentState.getUserAccountData([ + "device", + ]); + await currentState.updateUserAccountData({ + device: { + ...deviceProps, // Copy the other properties (e.g. handledCommands). + id: device.id, + registrationVersion: this.DEVICE_REGISTRATION_VERSION, + registeredCommandsKeys: availableCommandsKeys, + }, + }); + // Must send the notification after we've written the storage. + if (!is_existing) { + Services.obs.notifyObservers(null, ON_NEW_DEVICE_ID); + } + return device.id; + } catch (error) { + return this._handleDeviceError(currentState, error, sessionToken); + } + } + + async _handleDeviceError(currentState, error, sessionToken) { + try { + if (error.code === 400) { + if (error.errno === ERRNO_UNKNOWN_DEVICE) { + return this._recoverFromUnknownDevice(currentState); + } + + if (error.errno === ERRNO_DEVICE_SESSION_CONFLICT) { + return this._recoverFromDeviceSessionConflict( + currentState, + error, + sessionToken + ); + } + } + + // `_handleTokenError` always throws, this syntax keeps the linter happy. + // Note that the re-thrown error is immediately caught, logged and ignored + // by the containing scope here, which is why we have to `_handleTokenError` + // ourselves rather than letting it bubble up for handling by the caller. + throw await this._fxai._handleTokenError(error); + } catch (error) { + await this._logErrorAndResetDeviceRegistrationVersion( + currentState, + error + ); + return null; + } + } + + async _recoverFromUnknownDevice(currentState) { + // FxA did not recognise the device id. Handle it by clearing the device + // id on the account data. At next sync or next sign-in, registration is + // retried and should succeed. + log.warn("unknown device id, clearing the local device data"); + try { + await currentState.updateUserAccountData({ + device: null, + encryptedSendTabKeys: null, + }); + } catch (error) { + await this._logErrorAndResetDeviceRegistrationVersion( + currentState, + error + ); + } + return null; + } + + async _recoverFromDeviceSessionConflict(currentState, error, sessionToken) { + // FxA has already associated this session with a different device id. + // Perhaps we were beaten in a race to register. Handle the conflict: + // 1. Fetch the list of devices for the current user from FxA. + // 2. Look for ourselves in the list. + // 3. If we find a match, set the correct device id and device registration + // version on the account data and return the correct device id. At next + // sync or next sign-in, registration is retried and should succeed. + // 4. If we don't find a match, log the original error. + log.warn( + "device session conflict, attempting to ascertain the correct device id" + ); + try { + const devices = await this._fxai.fxAccountsClient.getDeviceList( + sessionToken + ); + const matchingDevices = devices.filter(device => device.isCurrentDevice); + const length = matchingDevices.length; + if (length === 1) { + const deviceId = matchingDevices[0].id; + await currentState.updateUserAccountData({ + device: { + id: deviceId, + registrationVersion: null, + }, + encryptedSendTabKeys: null, + }); + return deviceId; + } + if (length > 1) { + log.error( + "insane server state, " + length + " devices for this session" + ); + } + await this._logErrorAndResetDeviceRegistrationVersion( + currentState, + error + ); + } catch (secondError) { + log.error("failed to recover from device-session conflict", secondError); + await this._logErrorAndResetDeviceRegistrationVersion( + currentState, + error + ); + } + return null; + } + + async _logErrorAndResetDeviceRegistrationVersion(currentState, error) { + // Device registration should never cause other operations to fail. + // If we've reached this point, just log the error and reset the device + // on the account data. At next sync or next sign-in, + // registration will be retried. + log.error("device registration failed", error); + try { + await currentState.updateUserAccountData({ + device: null, + encryptedSendTabKeys: null, + }); + } catch (secondError) { + log.error( + "failed to reset the device registration version, device registration won't be retried", + secondError + ); + } + } + + // Kick off a background refresh when a device is connected or disconnected. + observe(subject, topic, data) { + switch (topic) { + case ON_DEVICE_CONNECTED_NOTIFICATION: + this.refreshDeviceList({ ignoreCached: true }).catch(error => { + log.warn( + "failed to refresh devices after connecting a new device", + error + ); + }); + break; + case ON_DEVICE_DISCONNECTED_NOTIFICATION: + let json = JSON.parse(data); + if (!json.isLocalDevice) { + // If we're the device being disconnected, don't bother fetching a new + // list, since our session token is now invalid. + this.refreshDeviceList({ ignoreCached: true }).catch(error => { + log.warn( + "failed to refresh devices after disconnecting a device", + error + ); + }); + } + break; + case ONVERIFIED_NOTIFICATION: + this.updateDeviceRegistrationIfNecessary().catch(error => { + log.warn( + "updateDeviceRegistrationIfNecessary failed after verification", + error + ); + }); + break; + } + } +} + +FxAccountsDevice.prototype.QueryInterface = ChromeUtils.generateQI([ + "nsIObserver", + "nsISupportsWeakReference", +]); + +function urlsafeBase64Encode(buffer) { + return ChromeUtils.base64URLEncode(new Uint8Array(buffer), { pad: false }); +} diff --git a/services/fxaccounts/FxAccountsKeys.sys.mjs b/services/fxaccounts/FxAccountsKeys.sys.mjs new file mode 100644 index 0000000000..ad19df31be --- /dev/null +++ b/services/fxaccounts/FxAccountsKeys.sys.mjs @@ -0,0 +1,649 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { CommonUtils } from "resource://services-common/utils.sys.mjs"; + +import { CryptoUtils } from "resource://services-crypto/utils.sys.mjs"; + +import { + SCOPE_OLD_SYNC, + DEPRECATED_SCOPE_ECOSYSTEM_TELEMETRY, + FX_OAUTH_CLIENT_ID, + log, + logPII, +} from "resource://gre/modules/FxAccountsCommon.sys.mjs"; + +// The following top-level fields have since been deprecated and exist here purely +// to be removed from the account state when seen. After a reasonable period of time +// has passed, where users have been migrated away from those keys they should be safe to be removed +const DEPRECATED_DERIVED_KEYS_NAMES = [ + "kSync", + "kXCS", + "kExtSync", + "kExtKbHash", + "ecosystemUserId", + "ecosystemAnonId", +]; + +// This scope and its associated key material were used by the old Kinto webextension +// storage backend, but has since been decommissioned. It's here entirely so that we +// remove the corresponding key from storage if present. We should be safe to remove it +// after some sensible period of time has elapsed to allow most clients to update. +const DEPRECATED_SCOPE_WEBEXT_SYNC = "sync:addon_storage"; + +// These are the scopes that correspond to new storage for the `LEGACY_DERIVED_KEYS_NAMES`. +// We will, if necessary, migrate storage for those keys so that it's associated with +// these scopes. +const LEGACY_DERIVED_KEY_SCOPES = [SCOPE_OLD_SYNC]; + +// These are scopes that we used to store, but are no longer using, +// and hence should be deleted from storage if present. +const DEPRECATED_KEY_SCOPES = [ + DEPRECATED_SCOPE_ECOSYSTEM_TELEMETRY, + DEPRECATED_SCOPE_WEBEXT_SYNC, +]; + +/** + * Utilities for working with key material linked to the user's account. + * + * Each Firefox Account has 32 bytes of root key material called `kB` which is + * linked to the user's password, and which is used to derive purpose-specific + * subkeys for things like encrypting the user's sync data. This class provides + * the interface for working with such key material. + * + * Most recent FxA clients obtain appropriate key material directly as part of + * their sign-in flow, using a special extension of the OAuth2.0 protocol to + * securely deliver the derived keys without revealing `kB`. Keys obtained in + * in this way are called "scoped keys" since each corresponds to a particular + * OAuth scope, and this class provides a `getKeyForScope` method that is the + * preferred method for consumers to work with such keys. + * + * However, since the FxA integration in Firefox Desktop pre-dates the use of + * OAuth2.0, we also have a lot of code for fetching keys via an older flow. + * This flow uses a special `keyFetchToken` to obtain `kB` and then derive various + * sub-keys from it. Consumers should consider this an internal implementation + * detail of the `FxAccountsKeys` class and should prefer `getKeyForScope` where + * possible. We intend to remove support for Firefox ever directly handling `kB` + * at some point in the future. + */ +export class FxAccountsKeys { + constructor(fxAccountsInternal) { + this._fxai = fxAccountsInternal; + } + + /** + * Checks if we currently have the key for a given scope, or if we have enough to + * be able to successfully fetch and unwrap it for the signed-in-user. + * + * Unlike `getKeyForScope`, this will not hit the network to fetch wrapped keys if + * they aren't available locally. + */ + canGetKeyForScope(scope) { + return this._fxai.withCurrentAccountState(async currentState => { + let userData = await currentState.getUserAccountData(); + if (!userData) { + throw new Error("Can't possibly get keys; User is not signed in"); + } + if (!userData.verified) { + log.info("Can't get keys; user is not verified"); + return false; + } + + if (userData.scopedKeys && userData.scopedKeys.hasOwnProperty(scope)) { + return true; + } + + // If we have a `keyFetchToken` we can fetch `kB`. + if (userData.keyFetchToken) { + return true; + } + + log.info("Can't get keys; no key material or tokens available"); + return false; + }); + } + + /** + * Get the key for a specified OAuth scope. + * + * @param {String} scope The OAuth scope whose key should be returned + * + * @return Promise<JWK> + * If no key is available the promise resolves to `null`. + * If a key is available for the given scope, th promise resolves to a JWK with fields: + * { + * scope: The requested scope + * kid: Key identifier + * k: Derived key material + * kty: Always "oct" for scoped keys + * } + * + */ + async getKeyForScope(scope) { + const { scopedKeys } = await this._loadOrFetchKeys(); + if (!scopedKeys.hasOwnProperty(scope)) { + throw new Error(`Key not available for scope "${scope}"`); + } + return { + scope, + ...scopedKeys[scope], + }; + } + + /** + * Format a JWK kid as hex rather than base64. + * + * This is a backwards-compatibility helper for code that needs a raw key fingerprint + * for use as a key identifier, rather than the timestamp+fingerprint format used by + * FxA scoped keys. + * + * @param {Object} jwk The JWK from which to extract the `kid` field as hex. + */ + kidAsHex(jwk) { + // The kid format is "{timestamp}-{b64url(fingerprint)}", but we have to be careful + // because the fingerprint component may contain "-" as well, and we want to ensure + // the timestamp component was non-empty. + const idx = jwk.kid.indexOf("-") + 1; + if (idx <= 1) { + throw new Error(`Invalid kid: ${jwk.kid}`); + } + return CommonUtils.base64urlToHex(jwk.kid.slice(idx)); + } + + /** + * Fetch encryption keys for the signed-in-user from the FxA API server. + * + * Not for user consumption. Exists to cause the keys to be fetched. + * + * Returns user data so that it can be chained with other methods. + * + * @return Promise + * The promise resolves to the credentials object of the signed-in user: + * { + * email: The user's email address + * uid: The user's unique id + * sessionToken: Session for the FxA server + * scopedKeys: Object mapping OAuth scopes to corresponding derived keys + * verified: email verification status + * } + * @throws If there is no user signed in. + */ + async _loadOrFetchKeys() { + return this._fxai.withCurrentAccountState(async currentState => { + try { + let userData = await currentState.getUserAccountData(); + if (!userData) { + throw new Error("Can't get keys; User is not signed in"); + } + // If we have all the keys in latest storage location, we're good. + if (userData.scopedKeys) { + if ( + LEGACY_DERIVED_KEY_SCOPES.every(scope => + userData.scopedKeys.hasOwnProperty(scope) + ) && + !DEPRECATED_KEY_SCOPES.some(scope => + userData.scopedKeys.hasOwnProperty(scope) + ) && + !DEPRECATED_DERIVED_KEYS_NAMES.some(keyName => + userData.hasOwnProperty(keyName) + ) + ) { + return userData; + } + } + // If not, we've got work to do, and we debounce to avoid duplicating it. + if (!currentState.whenKeysReadyDeferred) { + currentState.whenKeysReadyDeferred = Promise.withResolvers(); + // N.B. we deliberately don't `await` here, and instead use the promise + // to resolve `whenKeysReadyDeferred` (which we then `await` below). + this._migrateOrFetchKeys(currentState, userData).then( + dataWithKeys => { + currentState.whenKeysReadyDeferred.resolve(dataWithKeys); + currentState.whenKeysReadyDeferred = null; + }, + err => { + currentState.whenKeysReadyDeferred.reject(err); + currentState.whenKeysReadyDeferred = null; + } + ); + } + return await currentState.whenKeysReadyDeferred.promise; + } catch (err) { + return this._fxai._handleTokenError(err); + } + }); + } + + /** + * Set externally derived scoped keys in internal storage + * @param { Object } scopedKeys: The scoped keys object derived by the oauth flow + * + * @return { Promise }: A promise that resolves if the keys were successfully stored, + * or rejects if we failed to persist the keys, or if the user is not signed in already + */ + async setScopedKeys(scopedKeys) { + return this._fxai.withCurrentAccountState(async currentState => { + const userData = await currentState.getUserAccountData(); + if (!userData) { + throw new Error("Cannot persist keys, no user signed in"); + } + await currentState.updateUserAccountData({ + scopedKeys, + }); + }); + } + + /** + * Key storage migration or fetching logic. + * + * This method contains the doing-expensive-operations part of the logic of + * _loadOrFetchKeys(), factored out into a separate method so we can debounce it. + * + */ + async _migrateOrFetchKeys(currentState, userData) { + // If the required scopes are present in `scopedKeys`, then we know that we've + // previously applied all earlier migrations + // so we are safe to delete deprecated fields that older migrations + // might have depended on. + if ( + userData.scopedKeys && + LEGACY_DERIVED_KEY_SCOPES.every(scope => + userData.scopedKeys.hasOwnProperty(scope) + ) + ) { + return this._removeDeprecatedKeys(currentState, userData); + } + + // Otherwise, we need to fetch from the network and unwrap. + if (!userData.sessionToken) { + throw new Error("No sessionToken"); + } + if (!userData.keyFetchToken) { + throw new Error("No keyFetchToken"); + } + return this._fetchAndUnwrapAndDeriveKeys( + currentState, + userData.sessionToken, + userData.keyFetchToken + ); + } + + /** + * Removes deprecated keys from storage and returns an + * updated user data object + */ + async _removeDeprecatedKeys(currentState, userData) { + // Bug 1838708: Delete any deprecated high level keys from storage + const keysToRemove = DEPRECATED_DERIVED_KEYS_NAMES.filter(keyName => + userData.hasOwnProperty(keyName) + ); + if (keysToRemove.length) { + const removedKeys = {}; + for (const keyName of keysToRemove) { + removedKeys[keyName] = null; + } + await currentState.updateUserAccountData({ + ...removedKeys, + }); + userData = await currentState.getUserAccountData(); + } + // Bug 1697596 - delete any deprecated scoped keys from storage. + const scopesToRemove = DEPRECATED_KEY_SCOPES.filter(scope => + userData.scopedKeys.hasOwnProperty(scope) + ); + if (scopesToRemove.length) { + const updatedScopedKeys = { + ...userData.scopedKeys, + }; + for (const scope of scopesToRemove) { + delete updatedScopedKeys[scope]; + } + await currentState.updateUserAccountData({ + scopedKeys: updatedScopedKeys, + }); + userData = await currentState.getUserAccountData(); + } + return userData; + } + + /** + * Fetch keys from the server, unwrap them, and derive required sub-keys. + * + * Once the user's email is verified, we can resquest the root key `kB` from the + * FxA server, unwrap it using the client-side secret `unwrapBKey`, and then + * derive all the sub-keys required for operation of the browser. + */ + async _fetchAndUnwrapAndDeriveKeys( + currentState, + sessionToken, + keyFetchToken + ) { + if (logPII()) { + log.debug( + `fetchAndUnwrapKeys: sessionToken: ${sessionToken}, keyFetchToken: ${keyFetchToken}` + ); + } + + // Sign out if we don't have the necessary tokens. + if (!sessionToken || !keyFetchToken) { + // this seems really bad and we should remove this - bug 1572313. + log.warn("improper _fetchAndUnwrapKeys() call: token missing"); + await this._fxai.signOut(); + return null; + } + + // Deriving OAuth scoped keys requires additional metadata from the server. + // We fetch this first, before fetching the actual key material, because the + // keyFetchToken is single-use and we don't want to do a potentially-fallible + // operation after consuming it. + const scopedKeysMetadata = await this._fetchScopedKeysMetadata( + sessionToken + ); + + // Fetch the wrapped keys. + // It would be nice to be able to fetch this in a single operation with fetching + // the metadata above, but that requires server-side changes in FxA. + let { wrapKB } = await this._fetchKeys(keyFetchToken); + + let data = await currentState.getUserAccountData(); + + // Sanity check that the user hasn't changed out from under us (which should + // be impossible given this is called within _withCurrentAccountState, but...) + if (data.keyFetchToken !== keyFetchToken) { + throw new Error("Signed in user changed while fetching keys!"); + } + + let kBbytes = CryptoUtils.xor( + CommonUtils.hexToBytes(data.unwrapBKey), + wrapKB + ); + + if (logPII()) { + log.debug("kBbytes: " + kBbytes); + } + + let updateData = { + ...(await this._deriveKeys(data.uid, kBbytes, scopedKeysMetadata)), + keyFetchToken: null, // null values cause the item to be removed. + unwrapBKey: null, + }; + + if (logPII()) { + log.debug(`Keys Obtained: ${updateData.scopedKeys}`); + } else { + log.debug( + "Keys Obtained: " + Object.keys(updateData.scopedKeys).join(", ") + ); + } + + // Just double-check that scoped keys are there now + if (!updateData.scopedKeys) { + throw new Error(`user data missing: scopedKeys`); + } + + await currentState.updateUserAccountData(updateData); + return currentState.getUserAccountData(); + } + + /** + * Fetch the wrapped root key `wrapKB` from the FxA server. + * + * This consumes the single-use `keyFetchToken`. + */ + _fetchKeys(keyFetchToken) { + let client = this._fxai.fxAccountsClient; + log.debug( + `Fetching keys with token ${!!keyFetchToken} from ${client.host}` + ); + if (logPII()) { + log.debug("fetchKeys - the token is " + keyFetchToken); + } + return client.accountKeys(keyFetchToken); + } + + /** + * Fetch additional metadata required for deriving scoped keys. + * + * This includes timestamps and a server-provided secret to mix in to + * the derived value in order to support key rotation. + */ + async _fetchScopedKeysMetadata(sessionToken) { + // Hard-coded list of scopes that we know about. + // This list will probably grow in future. + const scopes = [SCOPE_OLD_SYNC].join(" "); + const scopedKeysMetadata = + await this._fxai.fxAccountsClient.getScopedKeyData( + sessionToken, + FX_OAUTH_CLIENT_ID, + scopes + ); + // The server may decline us permission for some of those scopes, although it really shouldn't. + // We can live without them...except for the OLDSYNC scope, whose absence would be catastrophic. + if (!scopedKeysMetadata.hasOwnProperty(SCOPE_OLD_SYNC)) { + log.warn( + "The FxA server did not grant Firefox the `oldsync` scope; this is most unexpected!" + + ` scopes were: ${Object.keys(scopedKeysMetadata)}` + ); + throw new Error( + "The FxA server did not grant Firefox the `oldsync` scope" + ); + } + return scopedKeysMetadata; + } + + /** + * Derive purpose-specific keys from the root FxA key `kB`. + * + * Everything that uses an encryption key from FxA uses a purpose-specific derived + * key. For new uses this is derived in a structured way based on OAuth scopes, + * while for legacy uses (mainly Firefox Sync) it is derived in a more ad-hoc fashion. + * This method does all the derivations for the uses that we know about. + * + */ + async _deriveKeys(uid, kBbytes, scopedKeysMetadata) { + const scopedKeys = await this._deriveScopedKeys( + uid, + kBbytes, + scopedKeysMetadata + ); + return { + scopedKeys, + }; + } + + /** + * Derive various scoped keys from the root FxA key `kB`. + * + * The `scopedKeysMetadata` object is additional information fetched from the server that + * that gets mixed in to the key derivation, with each member of the object corresponding + * to an OAuth scope that keys its own scoped key. + * + * As a special case for backwards-compatibility, sync-related scopes get special + * treatment to use a legacy derivation algorithm. + * + */ + async _deriveScopedKeys(uid, kBbytes, scopedKeysMetadata) { + const scopedKeys = {}; + for (const scope in scopedKeysMetadata) { + if (LEGACY_DERIVED_KEY_SCOPES.includes(scope)) { + scopedKeys[scope] = await this._deriveLegacyScopedKey( + uid, + kBbytes, + scope, + scopedKeysMetadata[scope] + ); + } else { + scopedKeys[scope] = await this._deriveScopedKey( + uid, + kBbytes, + scope, + scopedKeysMetadata[scope] + ); + } + } + return scopedKeys; + } + + /** + * Derive a scoped key for an individual OAuth scope. + * + * The derivation here uses HKDF to combine: + * - the root key material kB + * - a unique identifier for this scoped key + * - a server-provided secret that allows for key rotation + * - the account uid as an additional salt + * + * It produces 32 bytes of (secret) key material along with a (potentially public) + * key identifier, formatted as a JWK. + * + * The full details are in the technical docs at + * https://docs.google.com/document/d/1IvQJFEBFz0PnL4uVlIvt8fBS_IPwSK-avK0BRIHucxQ/ + */ + async _deriveScopedKey(uid, kBbytes, scope, scopedKeyMetadata) { + kBbytes = CommonUtils.byteStringToArrayBuffer(kBbytes); + + const FINGERPRINT_LENGTH = 16; + const KEY_LENGTH = 32; + const VALID_UID = /^[0-9a-f]{32}$/i; + const VALID_ROTATION_SECRET = /^[0-9a-f]{64}$/i; + + // Engage paranoia mode for input data. + if (!VALID_UID.test(uid)) { + throw new Error("uid must be a 32-character hex string"); + } + if (kBbytes.length != 32) { + throw new Error("kBbytes must be exactly 32 bytes"); + } + if ( + typeof scopedKeyMetadata.identifier !== "string" || + scopedKeyMetadata.identifier.length < 10 + ) { + throw new Error("identifier must be a string of length >= 10"); + } + if (typeof scopedKeyMetadata.keyRotationTimestamp !== "number") { + throw new Error("keyRotationTimestamp must be a number"); + } + if (!VALID_ROTATION_SECRET.test(scopedKeyMetadata.keyRotationSecret)) { + throw new Error("keyRotationSecret must be a 64-character hex string"); + } + + // The server returns milliseconds, we want seconds as a string. + const keyRotationTimestamp = + "" + Math.round(scopedKeyMetadata.keyRotationTimestamp / 1000); + if (keyRotationTimestamp.length < 10) { + throw new Error("keyRotationTimestamp must round to a 10-digit number"); + } + + const keyRotationSecret = CommonUtils.hexToArrayBuffer( + scopedKeyMetadata.keyRotationSecret + ); + const salt = CommonUtils.hexToArrayBuffer(uid); + const context = new TextEncoder().encode( + "identity.mozilla.com/picl/v1/scoped_key\n" + scopedKeyMetadata.identifier + ); + + const inputKey = new Uint8Array(64); + inputKey.set(kBbytes, 0); + inputKey.set(keyRotationSecret, 32); + + const derivedKeyMaterial = await CryptoUtils.hkdf( + inputKey, + salt, + context, + FINGERPRINT_LENGTH + KEY_LENGTH + ); + const fingerprint = derivedKeyMaterial.slice(0, FINGERPRINT_LENGTH); + const key = derivedKeyMaterial.slice( + FINGERPRINT_LENGTH, + FINGERPRINT_LENGTH + KEY_LENGTH + ); + + return { + kid: + keyRotationTimestamp + + "-" + + ChromeUtils.base64URLEncode(fingerprint, { + pad: false, + }), + k: ChromeUtils.base64URLEncode(key, { + pad: false, + }), + kty: "oct", + }; + } + + /** + * Derive the scoped key for the one of our legacy sync-related scopes. + * + * These uses a different key-derivation algoritm that incorporates less server-provided + * data, for backwards-compatibility reasons. + * + */ + async _deriveLegacyScopedKey(uid, kBbytes, scope, scopedKeyMetadata) { + let kid, key; + if (scope == SCOPE_OLD_SYNC) { + kid = await this._deriveXClientState(kBbytes); + key = await this._deriveSyncKey(kBbytes); + } else { + throw new Error(`Unexpected legacy key-bearing scope: ${scope}`); + } + kid = CommonUtils.byteStringToArrayBuffer(kid); + key = CommonUtils.byteStringToArrayBuffer(key); + return this._formatLegacyScopedKey(kid, key, scope, scopedKeyMetadata); + } + + /** + * Format key material for a legacy scyne-related scope as a JWK. + * + * @param {ArrayBuffer} kid bytes of the key hash to use in the key identifier + * @param {ArrayBuffer} key bytes of the derived sync key + * @param {String} scope the scope with which this key is associated + * @param {Number} keyRotationTimestamp server-provided timestamp of last key rotation + * @returns {Object} key material formatted as a JWK object + */ + _formatLegacyScopedKey(kid, key, scope, { keyRotationTimestamp }) { + kid = ChromeUtils.base64URLEncode(kid, { + pad: false, + }); + key = ChromeUtils.base64URLEncode(key, { + pad: false, + }); + return { + kid: `${keyRotationTimestamp}-${kid}`, + k: key, + kty: "oct", + }; + } + + /** + * Derive the Sync Key given the byte string kB. + * + * @returns Promise<HKDF(kB, undefined, "identity.mozilla.com/picl/v1/oldsync", 64)> + */ + async _deriveSyncKey(kBbytes) { + return CryptoUtils.hkdfLegacy( + kBbytes, + undefined, + "identity.mozilla.com/picl/v1/oldsync", + 2 * 32 + ); + } + + /** + * Derive the X-Client-State header given the byte string kB. + * + * @returns Promise<SHA256(kB)[:16]> + */ + async _deriveXClientState(kBbytes) { + return this._sha256(kBbytes).slice(0, 16); + } + + _sha256(bytes) { + let hasher = Cc["@mozilla.org/security/hash;1"].createInstance( + Ci.nsICryptoHash + ); + hasher.init(hasher.SHA256); + return CryptoUtils.digestBytes(bytes, hasher); + } +} diff --git a/services/fxaccounts/FxAccountsOAuth.sys.mjs b/services/fxaccounts/FxAccountsOAuth.sys.mjs new file mode 100644 index 0000000000..1935decff2 --- /dev/null +++ b/services/fxaccounts/FxAccountsOAuth.sys.mjs @@ -0,0 +1,224 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + jwcrypto: "resource://services-crypto/jwcrypto.sys.mjs", +}); + +import { + FX_OAUTH_CLIENT_ID, + SCOPE_PROFILE, + SCOPE_PROFILE_WRITE, + SCOPE_OLD_SYNC, +} from "resource://gre/modules/FxAccountsCommon.sys.mjs"; + +const VALID_SCOPES = [SCOPE_PROFILE, SCOPE_PROFILE_WRITE, SCOPE_OLD_SYNC]; + +export const ERROR_INVALID_SCOPES = "INVALID_SCOPES"; +export const ERROR_INVALID_STATE = "INVALID_STATE"; +export const ERROR_SYNC_SCOPE_NOT_GRANTED = "ERROR_SYNC_SCOPE_NOT_GRANTED"; +export const ERROR_NO_KEYS_JWE = "ERROR_NO_KEYS_JWE"; +export const ERROR_OAUTH_FLOW_ABANDONED = "ERROR_OAUTH_FLOW_ABANDONED"; + +/** + * Handles all logic and state related to initializing, and completing OAuth flows + * with FxA + * It's possible to start multiple OAuth flow, but only one can be completed, and once one flow is completed + * all the other in-flight flows will be concluded, and attempting to complete those flows will result in errors. + */ +export class FxAccountsOAuth { + #flow; + #fxaClient; + /** + * Creates a new FxAccountsOAuth + * + * @param { Object } fxaClient: The fxa client used to send http request to the oauth server + */ + constructor(fxaClient) { + this.#flow = {}; + this.#fxaClient = fxaClient; + } + + /** + * Stores a flow in-memory + * @param { string } state: A base-64 URL-safe string represnting a random value created at the start of the flow + * @param { Object } value: The data needed to complete a flow, once the oauth code is available. + * in practice, `value` is: + * - `verifier`: A base=64 URL-safe string representing the PKCE code verifier + * - `key`: The private key need to decrypt the JWE we recieve from the auth server + * - `requestedScopes`: The scopes the caller requested, meant to be compared against the scopes the server authorized + */ + addFlow(state, value) { + this.#flow[state] = value; + } + + /** + * Clears all started flows + */ + clearAllFlows() { + this.#flow = {}; + } + + /* + * Gets a stored flow + * @param { string } state: The base-64 URL-safe state string that was created at the start of the flow + * @returns { Object }: The values initially stored when startign th eoauth flow + * in practice, the return value is: + * - `verifier`: A base=64 URL-safe string representing the PKCE code verifier + * - `key`: The private key need to decrypt the JWE we recieve from the auth server + * - ``requestedScopes`: The scopes the caller requested, meant to be compared against the scopes the server authorized + */ + getFlow(state) { + return this.#flow[state]; + } + + /* Returns the number of flows, used by tests + * + */ + numOfFlows() { + return Object.keys(this.#flow).length; + } + + /** + * Begins an OAuth flow, to be completed with a an OAuth code and state. + * + * This function stores needed information to complete the flow. You must call `completeOAuthFlow` + * on the same instance of `FxAccountsOAuth`, otherwise the completing of the oauth flow will fail. + * + * @param { string[] } scopes: The OAuth scopes the client should request from FxA + * + * @returns { Object }: Returns an object representing the query parameters that should be + * added to the FxA authorization URL to initialize an oAuth flow. + * In practice, the query parameters are: + * - `client_id`: The OAuth client ID for Firefox Desktop + * - `scope`: The scopes given by the caller, space seperated + * - `action`: This will always be `email` + * - `response_type`: This will always be `code` + * - `access_type`: This will always be `offline` + * - `state`: A URL-safe base-64 string randomly generated + * - `code_challenge`: A URL-safe base-64 string representing the PKCE challenge + * - `code_challenge_method`: This will always be `S256` + * For more informatio about PKCE, read https://datatracker.ietf.org/doc/html/rfc7636 + * - `keys_jwk`: A URL-safe base-64 representing a JWK to be used as a public key by the server + * to generate a JWE + */ + async beginOAuthFlow(scopes) { + if ( + !Array.isArray(scopes) || + scopes.some(scope => !VALID_SCOPES.includes(scope)) + ) { + throw new Error(ERROR_INVALID_SCOPES); + } + const queryParams = { + client_id: FX_OAUTH_CLIENT_ID, + action: "email", + response_type: "code", + access_type: "offline", + scope: scopes.join(" "), + }; + + // Generate a random, 16 byte value to represent a state that we verify + // once we complete the oauth flow, to ensure that we only conclude + // an oauth flow that we started + const state = new Uint8Array(16); + crypto.getRandomValues(state); + const stateB64 = ChromeUtils.base64URLEncode(state, { pad: false }); + queryParams.state = stateB64; + + // Generate a 43 byte code verifier for PKCE, in accordance with + // https://datatracker.ietf.org/doc/html/rfc7636#section-7.1 which recommends a + // 43-octet URL safe string + const codeVerifier = new Uint8Array(43); + crypto.getRandomValues(codeVerifier); + const codeVerifierB64 = ChromeUtils.base64URLEncode(codeVerifier, { + pad: false, + }); + const challenge = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(codeVerifierB64) + ); + const challengeB64 = ChromeUtils.base64URLEncode(challenge, { pad: false }); + queryParams.code_challenge = challengeB64; + queryParams.code_challenge_method = "S256"; + + // Generate a public, private key pair to be used during the oauth flow + // to encrypt scoped-keys as they roundtrip through the auth server + const ECDH_KEY = { name: "ECDH", namedCurve: "P-256" }; + const key = await crypto.subtle.generateKey(ECDH_KEY, true, ["deriveKey"]); + const publicKey = await crypto.subtle.exportKey("jwk", key.publicKey); + const privateKey = key.privateKey; + + // We encode the public key as URL-safe base64 to be included in the query parameters + const encodedPublicKey = ChromeUtils.base64URLEncode( + new TextEncoder().encode(JSON.stringify(publicKey)), + { pad: false } + ); + queryParams.keys_jwk = encodedPublicKey; + + // We store the state in-memory, to verify once the oauth flow is completed + this.addFlow(stateB64, { + key: privateKey, + verifier: codeVerifierB64, + requestedScopes: scopes.join(" "), + }); + return queryParams; + } + + /** Completes an OAuth flow and invalidates any other ongoing flows + * @param { string } sessionTokenHex: The session token encoded in hexadecimal + * @param { string } code: OAuth authorization code provided by running an OAuth flow + * @param { string } state: The state first provided by `beginOAuthFlow`, then roundtripped through the server + * + * @returns { Object }: Returns an object representing the result of completing the oauth flow. + * The object includes the following: + * - 'scopedKeys': The encryption keys provided by the server, already decrypted + * - 'refreshToken': The refresh token provided by the server + * - 'accessToken': The access token provided by the server + * */ + async completeOAuthFlow(sessionTokenHex, code, state) { + const flow = this.getFlow(state); + if (!flow) { + throw new Error(ERROR_INVALID_STATE); + } + const { key, verifier, requestedScopes } = flow; + const { keys_jwe, refresh_token, access_token, scope } = + await this.#fxaClient.oauthToken( + sessionTokenHex, + code, + verifier, + FX_OAUTH_CLIENT_ID + ); + if ( + requestedScopes.includes(SCOPE_OLD_SYNC) && + !scope.includes(SCOPE_OLD_SYNC) + ) { + throw new Error(ERROR_SYNC_SCOPE_NOT_GRANTED); + } + if (scope.includes(SCOPE_OLD_SYNC) && !keys_jwe) { + throw new Error(ERROR_NO_KEYS_JWE); + } + let scopedKeys; + if (keys_jwe) { + scopedKeys = JSON.parse( + new TextDecoder().decode(await lazy.jwcrypto.decryptJWE(keys_jwe, key)) + ); + } + + // We make sure no other flow snuck in, and completed before we did + if (!this.getFlow(state)) { + throw new Error(ERROR_OAUTH_FLOW_ABANDONED); + } + + // Clear all flows, so any in-flight or future flows trigger an error as the browser + // would have been signed in + this.clearAllFlows(); + return { + scopedKeys, + refreshToken: refresh_token, + accessToken: access_token, + }; + } +} diff --git a/services/fxaccounts/FxAccountsPairing.sys.mjs b/services/fxaccounts/FxAccountsPairing.sys.mjs new file mode 100644 index 0000000000..e68554f7ab --- /dev/null +++ b/services/fxaccounts/FxAccountsPairing.sys.mjs @@ -0,0 +1,511 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import { + log, + PREF_REMOTE_PAIRING_URI, + COMMAND_PAIR_SUPP_METADATA, + COMMAND_PAIR_AUTHORIZE, + COMMAND_PAIR_DECLINE, + COMMAND_PAIR_HEARTBEAT, + COMMAND_PAIR_COMPLETE, +} from "resource://gre/modules/FxAccountsCommon.sys.mjs"; + +import { + getFxAccountsSingleton, + FxAccounts, +} from "resource://gre/modules/FxAccounts.sys.mjs"; + +const fxAccounts = getFxAccountsSingleton(); +import { setTimeout, clearTimeout } from "resource://gre/modules/Timer.sys.mjs"; + +ChromeUtils.importESModule("resource://services-common/utils.sys.mjs"); +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + FxAccountsPairingChannel: + "resource://gre/modules/FxAccountsPairingChannel.sys.mjs", + + Weave: "resource://services-sync/main.sys.mjs", + jwcrypto: "resource://services-crypto/jwcrypto.sys.mjs", +}); + +const PAIRING_REDIRECT_URI = "urn:ietf:wg:oauth:2.0:oob:pair-auth-webchannel"; +// A pairing flow is not tied to a specific browser window, can also finish in +// various ways and subsequently might leak a Web Socket, so just in case we +// time out and free-up the resources after a specified amount of time. +const FLOW_TIMEOUT_MS = 15 * 60 * 1000; // 15 minutes. + +class PairingStateMachine { + constructor(emitter) { + this._emitter = emitter; + this._transition(SuppConnectionPending); + } + + get currentState() { + return this._currentState; + } + + _transition(StateCtor, ...args) { + const state = new StateCtor(this, ...args); + this._currentState = state; + } + + assertState(RequiredStates, messagePrefix = null) { + if (!(RequiredStates instanceof Array)) { + RequiredStates = [RequiredStates]; + } + if ( + !RequiredStates.some( + RequiredState => this._currentState instanceof RequiredState + ) + ) { + const msg = `${ + messagePrefix ? `${messagePrefix}. ` : "" + }Valid expected states: ${RequiredStates.map(({ name }) => name).join( + ", " + )}. Current state: ${this._currentState.label}.`; + throw new Error(msg); + } + } +} + +/** + * The pairing flow can be modeled by a finite state machine: + * We start by connecting to a WebSocket channel (SuppConnectionPending). + * Then the other party connects and requests some metadata from us (PendingConfirmations). + * A confirmation happens locally first (PendingRemoteConfirmation) + * or the oppposite (PendingLocalConfirmation). + * Any side can decline this confirmation (Aborted). + * Once both sides have confirmed, the pairing flow is finished (Completed). + * During this flow errors can happen and should be handled (Errored). + */ +class State { + constructor(stateMachine, ...args) { + this._transition = (...args) => stateMachine._transition(...args); + this._notify = (...args) => stateMachine._emitter.emit(...args); + this.init(...args); + } + + init() { + /* Does nothing by default but can be re-implemented. */ + } + + get label() { + return this.constructor.name; + } + + hasErrored(error) { + this._notify("view:Error", error); + this._transition(Errored, error); + } + + hasAborted() { + this._transition(Aborted); + } +} +class SuppConnectionPending extends State { + suppConnected(sender, oauthOptions) { + this._transition(PendingConfirmations, sender, oauthOptions); + } +} +class PendingConfirmationsState extends State { + localConfirmed() { + throw new Error("Subclasses must implement this method."); + } + remoteConfirmed() { + throw new Error("Subclasses must implement this method."); + } +} +class PendingConfirmations extends PendingConfirmationsState { + init(sender, oauthOptions) { + this.sender = sender; + this.oauthOptions = oauthOptions; + } + + localConfirmed() { + this._transition(PendingRemoteConfirmation); + } + + remoteConfirmed() { + this._transition(PendingLocalConfirmation, this.sender, this.oauthOptions); + } +} +class PendingLocalConfirmation extends PendingConfirmationsState { + init(sender, oauthOptions) { + this.sender = sender; + this.oauthOptions = oauthOptions; + } + + localConfirmed() { + this._transition(Completed); + } + + remoteConfirmed() { + throw new Error( + "Insane state! Remote has already been confirmed at this point." + ); + } +} +class PendingRemoteConfirmation extends PendingConfirmationsState { + localConfirmed() { + throw new Error( + "Insane state! Local has already been confirmed at this point." + ); + } + + remoteConfirmed() { + this._transition(Completed); + } +} +class Completed extends State {} +class Aborted extends State {} +class Errored extends State { + init(error) { + this.error = error; + } +} + +const flows = new Map(); + +export class FxAccountsPairingFlow { + static get(channelId) { + return flows.get(channelId); + } + + static finalizeAll() { + for (const flow of flows) { + flow.finalize(); + } + } + + static async start(options) { + const { emitter } = options; + const fxaConfig = options.fxaConfig || FxAccounts.config; + const fxa = options.fxAccounts || fxAccounts; + const weave = options.weave || lazy.Weave; + const flowTimeout = options.flowTimeout || FLOW_TIMEOUT_MS; + + const contentPairingURI = await fxaConfig.promisePairingURI(); + const wsUri = Services.urlFormatter.formatURLPref(PREF_REMOTE_PAIRING_URI); + const pairingChannel = + options.pairingChannel || + (await lazy.FxAccountsPairingChannel.create(wsUri)); + const { channelId, channelKey } = pairingChannel; + const channelKeyB64 = ChromeUtils.base64URLEncode(channelKey, { + pad: false, + }); + const pairingFlow = new FxAccountsPairingFlow({ + channelId, + pairingChannel, + emitter, + fxa, + fxaConfig, + flowTimeout, + weave, + }); + flows.set(channelId, pairingFlow); + + return `${contentPairingURI}#channel_id=${channelId}&channel_key=${channelKeyB64}`; + } + + constructor(options) { + this._channelId = options.channelId; + this._pairingChannel = options.pairingChannel; + this._emitter = options.emitter; + this._fxa = options.fxa; + this._fxai = options.fxai || this._fxa._internal; + this._fxaConfig = options.fxaConfig; + this._weave = options.weave; + this._stateMachine = new PairingStateMachine(this._emitter); + this._setupListeners(); + this._flowTimeoutId = setTimeout( + () => this._onFlowTimeout(), + options.flowTimeout + ); + } + + _onFlowTimeout() { + log.warn(`The pairing flow ${this._channelId} timed out.`); + this._onError(new Error("Timeout")); + this.finalize(); + } + + _closeChannel() { + if (!this._closed && !this._pairingChannel.closed) { + this._pairingChannel.close(); + this._closed = true; + } + } + + finalize() { + this._closeChannel(); + clearTimeout(this._flowTimeoutId); + // Free up resources and let the GC do its thing. + flows.delete(this._channelId); + } + + _setupListeners() { + this._pairingChannel.addEventListener( + "message", + ({ detail: { sender, data } }) => + this.onPairingChannelMessage(sender, data) + ); + this._pairingChannel.addEventListener("error", event => + this._onPairingChannelError(event.detail.error) + ); + this._emitter.on("view:Closed", () => this.onPrefViewClosed()); + } + + _onAbort() { + this._stateMachine.currentState.hasAborted(); + this.finalize(); + } + + _onError(error) { + this._stateMachine.currentState.hasErrored(error); + this._closeChannel(); + } + + _onPairingChannelError(error) { + log.error("Pairing channel error", error); + this._onError(error); + } + + // Any non-falsy returned value is sent back through WebChannel. + async onWebChannelMessage(command) { + const stateMachine = this._stateMachine; + const curState = stateMachine.currentState; + try { + switch (command) { + case COMMAND_PAIR_SUPP_METADATA: + stateMachine.assertState( + [PendingConfirmations, PendingLocalConfirmation], + `Wrong state for ${command}` + ); + const { + ua, + city, + region, + country, + remote: ipAddress, + } = curState.sender; + return { ua, city, region, country, ipAddress }; + case COMMAND_PAIR_AUTHORIZE: + stateMachine.assertState( + [PendingConfirmations, PendingLocalConfirmation], + `Wrong state for ${command}` + ); + const { + client_id, + state, + scope, + code_challenge, + code_challenge_method, + keys_jwk, + } = curState.oauthOptions; + const authorizeParams = { + client_id, + access_type: "offline", + state, + scope, + code_challenge, + code_challenge_method, + keys_jwk, + }; + const codeAndState = await this._authorizeOAuthCode(authorizeParams); + if (codeAndState.state != state) { + throw new Error(`OAuth state mismatch`); + } + await this._pairingChannel.send({ + message: "pair:auth:authorize", + data: { + ...codeAndState, + }, + }); + curState.localConfirmed(); + break; + case COMMAND_PAIR_DECLINE: + this._onAbort(); + break; + case COMMAND_PAIR_HEARTBEAT: + if (curState instanceof Errored || this._pairingChannel.closed) { + return { err: curState.error.message || "Pairing channel closed" }; + } + const suppAuthorized = !( + curState instanceof PendingConfirmations || + curState instanceof PendingRemoteConfirmation + ); + return { suppAuthorized }; + case COMMAND_PAIR_COMPLETE: + this.finalize(); + break; + default: + throw new Error(`Received unknown WebChannel command: ${command}`); + } + } catch (e) { + log.error(e); + curState.hasErrored(e); + } + return {}; + } + + async onPairingChannelMessage(sender, payload) { + const { message } = payload; + const stateMachine = this._stateMachine; + const curState = stateMachine.currentState; + try { + switch (message) { + case "pair:supp:request": + stateMachine.assertState( + SuppConnectionPending, + `Wrong state for ${message}` + ); + const oauthUri = await this._fxaConfig.promiseOAuthURI(); + const { uid, email, avatar, displayName } = + await this._fxa.getSignedInUser(); + const deviceName = this._weave.Service.clientsEngine.localName; + await this._pairingChannel.send({ + message: "pair:auth:metadata", + data: { + email, + avatar, + displayName, + deviceName, + }, + }); + const { + client_id, + state, + scope, + code_challenge, + code_challenge_method, + keys_jwk, + } = payload.data; + const url = new URL(oauthUri); + url.searchParams.append("client_id", client_id); + url.searchParams.append("scope", scope); + url.searchParams.append("email", email); + url.searchParams.append("uid", uid); + url.searchParams.append("channel_id", this._channelId); + url.searchParams.append("redirect_uri", PAIRING_REDIRECT_URI); + this._emitter.emit("view:SwitchToWebContent", url.href); + curState.suppConnected(sender, { + client_id, + state, + scope, + code_challenge, + code_challenge_method, + keys_jwk, + }); + break; + case "pair:supp:authorize": + stateMachine.assertState( + [PendingConfirmations, PendingRemoteConfirmation], + `Wrong state for ${message}` + ); + curState.remoteConfirmed(); + break; + default: + throw new Error( + `Received unknown Pairing Channel message: ${message}` + ); + } + } catch (e) { + log.error(e); + curState.hasErrored(e); + } + } + + onPrefViewClosed() { + const curState = this._stateMachine.currentState; + // We don't want to stop the pairing process in the later stages. + if ( + curState instanceof SuppConnectionPending || + curState instanceof Aborted || + curState instanceof Errored + ) { + this.finalize(); + } + } + + /** + * Grant an OAuth authorization code for the connecting client. + * + * @param {Object} options + * @param options.client_id + * @param options.state + * @param options.scope + * @param options.access_type + * @param options.code_challenge_method + * @param options.code_challenge + * @param [options.keys_jwe] + * @returns {Promise<Object>} Object containing "code" and "state" properties. + */ + _authorizeOAuthCode(options) { + return this._fxa._withVerifiedAccountState(async state => { + const { sessionToken } = await state.getUserAccountData(["sessionToken"]); + const params = { ...options }; + if (params.keys_jwk) { + const jwk = JSON.parse( + new TextDecoder().decode( + ChromeUtils.base64URLDecode(params.keys_jwk, { padding: "reject" }) + ) + ); + params.keys_jwe = await this._createKeysJWE( + sessionToken, + params.client_id, + params.scope, + jwk + ); + delete params.keys_jwk; + } + try { + return await this._fxai.fxAccountsClient.oauthAuthorize( + sessionToken, + params + ); + } catch (err) { + throw this._fxai._errorToErrorClass(err); + } + }); + } + + /** + * Create a JWE to deliver keys to another client via the OAuth scoped-keys flow. + * + * This method is used to transfer key material to another client, by providing + * an appropriately-encrypted value for the `keys_jwe` OAuth response parameter. + * Since we're transferring keys from one client to another, two things must be + * true: + * + * * This client must actually have the key. + * * The other client must be allowed to request that key. + * + * @param {String} sessionToken the sessionToken to use when fetching key metadata + * @param {String} clientId the client requesting access to our keys + * @param {String} scopes Space separated requested scopes being requested + * @param {Object} jwk Ephemeral JWK provided by the client for secure key transfer + */ + async _createKeysJWE(sessionToken, clientId, scopes, jwk) { + // This checks with the FxA server about what scopes the client is allowed. + // Note that we pass the requesting client_id here, not our own client_id. + const clientKeyData = await this._fxai.fxAccountsClient.getScopedKeyData( + sessionToken, + clientId, + scopes + ); + const scopedKeys = {}; + for (const scope of Object.keys(clientKeyData)) { + const key = await this._fxai.keys.getKeyForScope(scope); + if (!key) { + throw new Error(`Key not available for scope "${scope}"`); + } + scopedKeys[scope] = key; + } + return lazy.jwcrypto.generateJWE( + jwk, + new TextEncoder().encode(JSON.stringify(scopedKeys)) + ); + } +} diff --git a/services/fxaccounts/FxAccountsPairingChannel.sys.mjs b/services/fxaccounts/FxAccountsPairingChannel.sys.mjs new file mode 100644 index 0000000000..cb6d3fdb91 --- /dev/null +++ b/services/fxaccounts/FxAccountsPairingChannel.sys.mjs @@ -0,0 +1,3693 @@ +/*! + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * The following bundle is from an external repository at github.com/mozilla/fxa-pairing-channel, + * it implements a shared library for two javascript environments to create an encrypted and authenticated + * communication channel by sharing a secret key and by relaying messages through a websocket server. + * + * It is used by the Firefox Accounts pairing flow, with one side of the channel being web + * content from https://accounts.firefox.com and the other side of the channel being chrome native code. + * + * This uses the event-target-shim node library published under the MIT license: + * https://github.com/mysticatea/event-target-shim/blob/master/LICENSE + * + * Bundle generated from https://github.com/mozilla/fxa-pairing-channel.git. Hash:c8ec3119920b4ffa833b, Chunkhash:378a5f51445e7aa7630e. + * + */ + +// This header provides a little bit of plumbing to use `FxAccountsPairingChannel` +// from Firefox browser code, hence the presence of these privileged browser APIs. +// If you're trying to use this from ordinary web content you're in for a bad time. + +import { setTimeout } from "resource://gre/modules/Timer.sys.mjs"; + +// We cannot use WebSocket from chrome code without a window, +// see https://bugzilla.mozilla.org/show_bug.cgi?id=784686 +const browser = Services.appShell.createWindowlessBrowser(true); +const {WebSocket} = browser.document.ownerGlobal; + +export var FxAccountsPairingChannel = +/******/ (function(modules) { // webpackBootstrap +/******/ // The module cache +/******/ var installedModules = {}; +/******/ +/******/ // The require function +/******/ function __webpack_require__(moduleId) { +/******/ +/******/ // Check if module is in cache +/******/ if(installedModules[moduleId]) { +/******/ return installedModules[moduleId].exports; +/******/ } +/******/ // Create a new module (and put it into the cache) +/******/ var module = installedModules[moduleId] = { +/******/ i: moduleId, +/******/ l: false, +/******/ exports: {} +/******/ }; +/******/ +/******/ // Execute the module function +/******/ modules[moduleId].call(module.exports, module, module.exports, __webpack_require__); +/******/ +/******/ // Flag the module as loaded +/******/ module.l = true; +/******/ +/******/ // Return the exports of the module +/******/ return module.exports; +/******/ } +/******/ +/******/ +/******/ // expose the modules object (__webpack_modules__) +/******/ __webpack_require__.m = modules; +/******/ +/******/ // expose the module cache +/******/ __webpack_require__.c = installedModules; +/******/ +/******/ // define getter function for harmony exports +/******/ __webpack_require__.d = function(exports, name, getter) { +/******/ if(!__webpack_require__.o(exports, name)) { +/******/ Object.defineProperty(exports, name, { enumerable: true, get: getter }); +/******/ } +/******/ }; +/******/ +/******/ // define __esModule on exports +/******/ __webpack_require__.r = function(exports) { +/******/ if(typeof Symbol !== 'undefined' && Symbol.toStringTag) { +/******/ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' }); +/******/ } +/******/ Object.defineProperty(exports, '__esModule', { value: true }); +/******/ }; +/******/ +/******/ // create a fake namespace object +/******/ // mode & 1: value is a module id, require it +/******/ // mode & 2: merge all properties of value into the ns +/******/ // mode & 4: return value when already ns object +/******/ // mode & 8|1: behave like require +/******/ __webpack_require__.t = function(value, mode) { +/******/ if(mode & 1) value = __webpack_require__(value); +/******/ if(mode & 8) return value; +/******/ if((mode & 4) && typeof value === 'object' && value && value.__esModule) return value; +/******/ var ns = Object.create(null); +/******/ __webpack_require__.r(ns); +/******/ Object.defineProperty(ns, 'default', { enumerable: true, value: value }); +/******/ if(mode & 2 && typeof value != 'string') for(var key in value) __webpack_require__.d(ns, key, function(key) { return value[key]; }.bind(null, key)); +/******/ return ns; +/******/ }; +/******/ +/******/ // getDefaultExport function for compatibility with non-harmony modules +/******/ __webpack_require__.n = function(module) { +/******/ var getter = module && module.__esModule ? +/******/ function getDefault() { return module['default']; } : +/******/ function getModuleExports() { return module; }; +/******/ __webpack_require__.d(getter, 'a', getter); +/******/ return getter; +/******/ }; +/******/ +/******/ // Object.prototype.hasOwnProperty.call +/******/ __webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); }; +/******/ +/******/ // __webpack_public_path__ +/******/ __webpack_require__.p = ""; +/******/ +/******/ +/******/ // Load entry module and return exports +/******/ return __webpack_require__(__webpack_require__.s = 0); +/******/ }) +/************************************************************************/ +/******/ ([ +/* 0 */ +/***/ (function(module, __webpack_exports__, __webpack_require__) { + +"use strict"; +// ESM COMPAT FLAG +__webpack_require__.r(__webpack_exports__); + +// EXPORTS +__webpack_require__.d(__webpack_exports__, "PairingChannel", function() { return /* binding */ src_PairingChannel; }); +__webpack_require__.d(__webpack_exports__, "base64urlToBytes", function() { return /* reexport */ base64urlToBytes; }); +__webpack_require__.d(__webpack_exports__, "bytesToBase64url", function() { return /* reexport */ bytesToBase64url; }); +__webpack_require__.d(__webpack_exports__, "bytesToHex", function() { return /* reexport */ bytesToHex; }); +__webpack_require__.d(__webpack_exports__, "bytesToUtf8", function() { return /* reexport */ bytesToUtf8; }); +__webpack_require__.d(__webpack_exports__, "hexToBytes", function() { return /* reexport */ hexToBytes; }); +__webpack_require__.d(__webpack_exports__, "TLSCloseNotify", function() { return /* reexport */ TLSCloseNotify; }); +__webpack_require__.d(__webpack_exports__, "TLSError", function() { return /* reexport */ TLSError; }); +__webpack_require__.d(__webpack_exports__, "utf8ToBytes", function() { return /* reexport */ utf8ToBytes; }); +__webpack_require__.d(__webpack_exports__, "_internals", function() { return /* binding */ _internals; }); + +// CONCATENATED MODULE: ./src/alerts.js +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* eslint-disable sorting/sort-object-props */ +const ALERT_LEVEL = { + WARNING: 1, + FATAL: 2 +}; + +const ALERT_DESCRIPTION = { + CLOSE_NOTIFY: 0, + UNEXPECTED_MESSAGE: 10, + BAD_RECORD_MAC: 20, + RECORD_OVERFLOW: 22, + HANDSHAKE_FAILURE: 40, + ILLEGAL_PARAMETER: 47, + DECODE_ERROR: 50, + DECRYPT_ERROR: 51, + PROTOCOL_VERSION: 70, + INTERNAL_ERROR: 80, + MISSING_EXTENSION: 109, + UNSUPPORTED_EXTENSION: 110, + UNKNOWN_PSK_IDENTITY: 115, + NO_APPLICATION_PROTOCOL: 120, +}; +/* eslint-enable sorting/sort-object-props */ + +function alertTypeToName(type) { + for (const name in ALERT_DESCRIPTION) { + if (ALERT_DESCRIPTION[name] === type) { + return `${name} (${type})`; + } + } + return `UNKNOWN (${type})`; +} + +class TLSAlert extends Error { + constructor(description, level) { + super(`TLS Alert: ${alertTypeToName(description)}`); + this.description = description; + this.level = level; + } + + static fromBytes(bytes) { + if (bytes.byteLength !== 2) { + throw new TLSError(ALERT_DESCRIPTION.DECODE_ERROR); + } + switch (bytes[1]) { + case ALERT_DESCRIPTION.CLOSE_NOTIFY: + if (bytes[0] !== ALERT_LEVEL.WARNING) { + // Close notifications should be fatal. + throw new TLSError(ALERT_DESCRIPTION.ILLEGAL_PARAMETER); + } + return new TLSCloseNotify(); + default: + return new TLSError(bytes[1]); + } + } + + toBytes() { + return new Uint8Array([this.level, this.description]); + } +} + +class TLSCloseNotify extends TLSAlert { + constructor() { + super(ALERT_DESCRIPTION.CLOSE_NOTIFY, ALERT_LEVEL.WARNING); + } +} + +class TLSError extends TLSAlert { + constructor(description = ALERT_DESCRIPTION.INTERNAL_ERROR) { + super(description, ALERT_LEVEL.FATAL); + } +} + +// CONCATENATED MODULE: ./src/utils.js +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + + + +// +// Various low-level utility functions. +// +// These are mostly conveniences for working with Uint8Arrays as +// the primitive "bytes" type. +// + +const UTF8_ENCODER = new TextEncoder(); +const UTF8_DECODER = new TextDecoder(); + +function noop() {} + +function assert(cond, msg) { + if (! cond) { + throw new Error('assert failed: ' + msg); + } +} + +function assertIsBytes(value, msg = 'value must be a Uint8Array') { + // Using `value instanceof Uint8Array` seems to fail in Firefox chrome code + // for inscrutable reasons, so we do a less direct check. + assert(ArrayBuffer.isView(value), msg); + assert(value.BYTES_PER_ELEMENT === 1, msg); + return value; +} + +const EMPTY = new Uint8Array(0); + +function zeros(n) { + return new Uint8Array(n); +} + +function arrayToBytes(value) { + return new Uint8Array(value); +} + +function bytesToHex(bytes) { + return Array.prototype.map.call(bytes, byte => { + let s = byte.toString(16); + if (s.length === 1) { + s = '0' + s; + } + return s; + }).join(''); +} + +function hexToBytes(hexstr) { + assert(hexstr.length % 2 === 0, 'hexstr.length must be even'); + return new Uint8Array(Array.prototype.map.call(hexstr, (c, n) => { + if (n % 2 === 1) { + return hexstr[n - 1] + c; + } else { + return ''; + } + }).filter(s => { + return !! s; + }).map(s => { + return parseInt(s, 16); + })); +} + +function bytesToUtf8(bytes) { + return UTF8_DECODER.decode(bytes); +} + +function utf8ToBytes(str) { + return UTF8_ENCODER.encode(str); +} + +function bytesToBase64url(bytes) { + // XXX TODO: try to use something constant-time, in case calling code + // uses it to encode secrets? + const charCodes = String.fromCharCode.apply(String, bytes); + return btoa(charCodes).replace(/\+/g, '-').replace(/\//g, '_'); +} + +function base64urlToBytes(str) { + // XXX TODO: try to use something constant-time, in case calling code + // uses it to decode secrets? + str = atob(str.replace(/-/g, '+').replace(/_/g, '/')); + const bytes = new Uint8Array(str.length); + for (let i = 0; i < str.length; i++) { + bytes[i] = str.charCodeAt(i); + } + return bytes; +} + +function bytesAreEqual(v1, v2) { + assertIsBytes(v1); + assertIsBytes(v2); + if (v1.length !== v2.length) { + return false; + } + for (let i = 0; i < v1.length; i++) { + if (v1[i] !== v2[i]) { + return false; + } + } + return true; +} + +// The `BufferReader` and `BufferWriter` classes are helpers for dealing with the +// binary struct format that's used for various TLS message. Think of them as a +// buffer with a pointer to the "current position" and a bunch of helper methods +// to read/write structured data and advance said pointer. + +class utils_BufferWithPointer { + constructor(buf) { + this._buffer = buf; + this._dataview = new DataView(buf.buffer, buf.byteOffset, buf.byteLength); + this._pos = 0; + } + + length() { + return this._buffer.byteLength; + } + + tell() { + return this._pos; + } + + seek(pos) { + if (pos < 0) { + throw new TLSError(ALERT_DESCRIPTION.DECODE_ERROR); + } + if (pos > this.length()) { + throw new TLSError(ALERT_DESCRIPTION.DECODE_ERROR); + } + this._pos = pos; + } + + incr(offset) { + this.seek(this._pos + offset); + } +} + +// The `BufferReader` class helps you read structured data from a byte array. +// It offers methods for reading both primitive values, and the variable-length +// vector structures defined in https://tools.ietf.org/html/rfc8446#section-3.4. +// +// Such vectors are represented as a length followed by the concatenated +// bytes of each item, and the size of the length field is determined by +// the maximum allowed number of bytes in the vector. For example +// to read a vector that may contain up to 65535 bytes, use `readVector16`. +// +// To read a variable-length vector of between 1 and 100 uint16 values, +// defined in the RFC like this: +// +// uint16 items<2..200>; +// +// You would do something like this: +// +// const items = [] +// buf.readVector8(buf => { +// items.push(buf.readUint16()) +// }) +// +// The various `read` will throw `DECODE_ERROR` if you attempt to read path +// the end of the buffer, or past the end of a variable-length list. +// +class utils_BufferReader extends utils_BufferWithPointer { + + hasMoreBytes() { + return this.tell() < this.length(); + } + + readBytes(length) { + // This avoids copies by returning a view onto the existing buffer. + const start = this._buffer.byteOffset + this.tell(); + this.incr(length); + return new Uint8Array(this._buffer.buffer, start, length); + } + + _rangeErrorToAlert(cb) { + try { + return cb(this); + } catch (err) { + if (err instanceof RangeError) { + throw new TLSError(ALERT_DESCRIPTION.DECODE_ERROR); + } + throw err; + } + } + + readUint8() { + return this._rangeErrorToAlert(() => { + const n = this._dataview.getUint8(this._pos); + this.incr(1); + return n; + }); + } + + readUint16() { + return this._rangeErrorToAlert(() => { + const n = this._dataview.getUint16(this._pos); + this.incr(2); + return n; + }); + } + + readUint24() { + return this._rangeErrorToAlert(() => { + let n = this._dataview.getUint16(this._pos); + n = (n << 8) | this._dataview.getUint8(this._pos + 2); + this.incr(3); + return n; + }); + } + + readUint32() { + return this._rangeErrorToAlert(() => { + const n = this._dataview.getUint32(this._pos); + this.incr(4); + return n; + }); + } + + _readVector(length, cb) { + const contentsBuf = new utils_BufferReader(this.readBytes(length)); + const expectedEnd = this.tell(); + // Keep calling the callback until we've consumed the expected number of bytes. + let n = 0; + while (contentsBuf.hasMoreBytes()) { + const prevPos = contentsBuf.tell(); + cb(contentsBuf, n); + // Check that the callback made forward progress, otherwise we'll infinite loop. + if (contentsBuf.tell() <= prevPos) { + throw new TLSError(ALERT_DESCRIPTION.DECODE_ERROR); + } + n += 1; + } + // Check that the callback correctly consumed the vector's entire contents. + if (this.tell() !== expectedEnd) { + throw new TLSError(ALERT_DESCRIPTION.DECODE_ERROR); + } + } + + readVector8(cb) { + const length = this.readUint8(); + return this._readVector(length, cb); + } + + readVector16(cb) { + const length = this.readUint16(); + return this._readVector(length, cb); + } + + readVector24(cb) { + const length = this.readUint24(); + return this._readVector(length, cb); + } + + readVectorBytes8() { + return this.readBytes(this.readUint8()); + } + + readVectorBytes16() { + return this.readBytes(this.readUint16()); + } + + readVectorBytes24() { + return this.readBytes(this.readUint24()); + } +} + + +class utils_BufferWriter extends utils_BufferWithPointer { + constructor(size = 1024) { + super(new Uint8Array(size)); + } + + _maybeGrow(n) { + const curSize = this._buffer.byteLength; + const newPos = this._pos + n; + const shortfall = newPos - curSize; + if (shortfall > 0) { + // Classic grow-by-doubling, up to 4kB max increment. + // This formula was not arrived at by any particular science. + const incr = Math.min(curSize, 4 * 1024); + const newbuf = new Uint8Array(curSize + Math.ceil(shortfall / incr) * incr); + newbuf.set(this._buffer, 0); + this._buffer = newbuf; + this._dataview = new DataView(newbuf.buffer, newbuf.byteOffset, newbuf.byteLength); + } + } + + slice(start = 0, end = this.tell()) { + if (end < 0) { + end = this.tell() + end; + } + if (start < 0) { + throw new TLSError(ALERT_DESCRIPTION.INTERNAL_ERROR); + } + if (end < 0) { + throw new TLSError(ALERT_DESCRIPTION.INTERNAL_ERROR); + } + if (end > this.length()) { + throw new TLSError(ALERT_DESCRIPTION.INTERNAL_ERROR); + } + return this._buffer.slice(start, end); + } + + flush() { + const slice = this.slice(); + this.seek(0); + return slice; + } + + writeBytes(data) { + this._maybeGrow(data.byteLength); + this._buffer.set(data, this.tell()); + this.incr(data.byteLength); + } + + writeUint8(n) { + this._maybeGrow(1); + this._dataview.setUint8(this._pos, n); + this.incr(1); + } + + writeUint16(n) { + this._maybeGrow(2); + this._dataview.setUint16(this._pos, n); + this.incr(2); + } + + writeUint24(n) { + this._maybeGrow(3); + this._dataview.setUint16(this._pos, n >> 8); + this._dataview.setUint8(this._pos + 2, n & 0xFF); + this.incr(3); + } + + writeUint32(n) { + this._maybeGrow(4); + this._dataview.setUint32(this._pos, n); + this.incr(4); + } + + // These are helpers for writing the variable-length vector structure + // defined in https://tools.ietf.org/html/rfc8446#section-3.4. + // + // Such vectors are represented as a length followed by the concatenated + // bytes of each item, and the size of the length field is determined by + // the maximum allowed size of the vector. For example to write a vector + // that may contain up to 65535 bytes, use `writeVector16`. + // + // To write a variable-length vector of between 1 and 100 uint16 values, + // defined in the RFC like this: + // + // uint16 items<2..200>; + // + // You would do something like this: + // + // buf.writeVector8(buf => { + // for (let item of items) { + // buf.writeUint16(item) + // } + // }) + // + // The helper will automatically take care of writing the appropriate + // length field once the callback completes. + + _writeVector(maxLength, writeLength, cb) { + // Initially, write the length field as zero. + const lengthPos = this.tell(); + writeLength(0); + // Call the callback to write the vector items. + const bodyPos = this.tell(); + cb(this); + const length = this.tell() - bodyPos; + if (length >= maxLength) { + throw new TLSError(ALERT_DESCRIPTION.INTERNAL_ERROR); + } + // Backfill the actual length field. + this.seek(lengthPos); + writeLength(length); + this.incr(length); + return length; + } + + writeVector8(cb) { + return this._writeVector(Math.pow(2, 8), len => this.writeUint8(len), cb); + } + + writeVector16(cb) { + return this._writeVector(Math.pow(2, 16), len => this.writeUint16(len), cb); + } + + writeVector24(cb) { + return this._writeVector(Math.pow(2, 24), len => this.writeUint24(len), cb); + } + + writeVectorBytes8(bytes) { + return this.writeVector8(buf => { + buf.writeBytes(bytes); + }); + } + + writeVectorBytes16(bytes) { + return this.writeVector16(buf => { + buf.writeBytes(bytes); + }); + } + + writeVectorBytes24(bytes) { + return this.writeVector24(buf => { + buf.writeBytes(bytes); + }); + } +} + +// CONCATENATED MODULE: ./src/crypto.js +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// +// Low-level crypto primitives. +// +// This file implements the AEAD encrypt/decrypt and hashing routines +// for the TLS_AES_128_GCM_SHA256 ciphersuite. They are (thankfully) +// fairly light-weight wrappers around what's available via the WebCrypto +// API. +// + + + + +const AEAD_SIZE_INFLATION = 16; +const KEY_LENGTH = 16; +const IV_LENGTH = 12; +const HASH_LENGTH = 32; + +async function prepareKey(key, mode) { + return crypto.subtle.importKey('raw', key, { name: 'AES-GCM' }, false, [mode]); +} + +async function encrypt(key, iv, plaintext, additionalData) { + const ciphertext = await crypto.subtle.encrypt({ + additionalData, + iv, + name: 'AES-GCM', + tagLength: AEAD_SIZE_INFLATION * 8 + }, key, plaintext); + return new Uint8Array(ciphertext); +} + +async function decrypt(key, iv, ciphertext, additionalData) { + try { + const plaintext = await crypto.subtle.decrypt({ + additionalData, + iv, + name: 'AES-GCM', + tagLength: AEAD_SIZE_INFLATION * 8 + }, key, ciphertext); + return new Uint8Array(plaintext); + } catch (err) { + // Yes, we really do throw 'decrypt_error' when failing to verify a HMAC, + // and a 'bad_record_mac' error when failing to decrypt. + throw new TLSError(ALERT_DESCRIPTION.BAD_RECORD_MAC); + } +} + +async function hash(message) { + return new Uint8Array(await crypto.subtle.digest({ name: 'SHA-256' }, message)); +} + +async function hmac(keyBytes, message) { + const key = await crypto.subtle.importKey('raw', keyBytes, { + hash: { name: 'SHA-256' }, + name: 'HMAC', + }, false, ['sign']); + const sig = await crypto.subtle.sign({ name: 'HMAC' }, key, message); + return new Uint8Array(sig); +} + +async function verifyHmac(keyBytes, signature, message) { + const key = await crypto.subtle.importKey('raw', keyBytes, { + hash: { name: 'SHA-256' }, + name: 'HMAC', + }, false, ['verify']); + if (! (await crypto.subtle.verify({ name: 'HMAC' }, key, signature, message))) { + // Yes, we really do throw 'decrypt_error' when failing to verify a HMAC, + // and a 'bad_record_mac' error when failing to decrypt. + throw new TLSError(ALERT_DESCRIPTION.DECRYPT_ERROR); + } +} + +async function hkdfExtract(salt, ikm) { + // Ref https://tools.ietf.org/html/rfc5869#section-2.2 + return await hmac(salt, ikm); +} + +async function hkdfExpand(prk, info, length) { + // Ref https://tools.ietf.org/html/rfc5869#section-2.3 + const N = Math.ceil(length / HASH_LENGTH); + if (N <= 0) { + throw new TLSError(ALERT_DESCRIPTION.INTERNAL_ERROR); + } + if (N >= 255) { + throw new TLSError(ALERT_DESCRIPTION.INTERNAL_ERROR); + } + const input = new utils_BufferWriter(); + const output = new utils_BufferWriter(); + let T = new Uint8Array(0); + for (let i = 1; i <= N; i++) { + input.writeBytes(T); + input.writeBytes(info); + input.writeUint8(i); + T = await hmac(prk, input.flush()); + output.writeBytes(T); + } + return output.slice(0, length); +} + +async function hkdfExpandLabel(secret, label, context, length) { + // struct { + // uint16 length = Length; + // opaque label < 7..255 > = "tls13 " + Label; + // opaque context < 0..255 > = Context; + // } HkdfLabel; + const hkdfLabel = new utils_BufferWriter(); + hkdfLabel.writeUint16(length); + hkdfLabel.writeVectorBytes8(utf8ToBytes('tls13 ' + label)); + hkdfLabel.writeVectorBytes8(context); + return hkdfExpand(secret, hkdfLabel.flush(), length); +} + +async function getRandomBytes(size) { + const bytes = new Uint8Array(size); + crypto.getRandomValues(bytes); + return bytes; +} + +// CONCATENATED MODULE: ./src/extensions.js +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// +// Extension parsing. +// +// This file contains some helpers for reading/writing the various kinds +// of Extension that might appear in a HandshakeMessage. +// +// "Extensions" are how TLS signals the presence of particular bits of optional +// functionality in the protocol. Lots of parts of TLS1.3 that don't seem like +// they're optional are implemented in terms of an extension, IIUC because that's +// what was needed for a clean deployment in amongst earlier versions of the protocol. +// + + + + + +/* eslint-disable sorting/sort-object-props */ +const EXTENSION_TYPE = { + PRE_SHARED_KEY: 41, + SUPPORTED_VERSIONS: 43, + PSK_KEY_EXCHANGE_MODES: 45, +}; +/* eslint-enable sorting/sort-object-props */ + +// Base class for generic reading/writing of extensions, +// which are all uniformly formatted as: +// +// struct { +// ExtensionType extension_type; +// opaque extension_data<0..2^16-1>; +// } Extension; +// +// Extensions always appear inside of a handshake message, +// and their internal structure may differ based on the +// type of that message. + +class extensions_Extension { + + get TYPE_TAG() { + throw new Error('not implemented'); + } + + static read(messageType, buf) { + const type = buf.readUint16(); + let ext = { + TYPE_TAG: type, + }; + buf.readVector16(buf => { + switch (type) { + case EXTENSION_TYPE.PRE_SHARED_KEY: + ext = extensions_PreSharedKeyExtension._read(messageType, buf); + break; + case EXTENSION_TYPE.SUPPORTED_VERSIONS: + ext = extensions_SupportedVersionsExtension._read(messageType, buf); + break; + case EXTENSION_TYPE.PSK_KEY_EXCHANGE_MODES: + ext = extensions_PskKeyExchangeModesExtension._read(messageType, buf); + break; + default: + // Skip over unrecognised extensions. + buf.incr(buf.length()); + } + if (buf.hasMoreBytes()) { + throw new TLSError(ALERT_DESCRIPTION.DECODE_ERROR); + } + }); + return ext; + } + + write(messageType, buf) { + buf.writeUint16(this.TYPE_TAG); + buf.writeVector16(buf => { + this._write(messageType, buf); + }); + } + + static _read(messageType, buf) { + throw new Error('not implemented'); + } + + static _write(messageType, buf) { + throw new Error('not implemented'); + } +} + +// The PreSharedKey extension: +// +// struct { +// opaque identity<1..2^16-1>; +// uint32 obfuscated_ticket_age; +// } PskIdentity; +// opaque PskBinderEntry<32..255>; +// struct { +// PskIdentity identities<7..2^16-1>; +// PskBinderEntry binders<33..2^16-1>; +// } OfferedPsks; +// struct { +// select(Handshake.msg_type) { +// case client_hello: OfferedPsks; +// case server_hello: uint16 selected_identity; +// }; +// } PreSharedKeyExtension; + +class extensions_PreSharedKeyExtension extends extensions_Extension { + constructor(identities, binders, selectedIdentity) { + super(); + this.identities = identities; + this.binders = binders; + this.selectedIdentity = selectedIdentity; + } + + get TYPE_TAG() { + return EXTENSION_TYPE.PRE_SHARED_KEY; + } + + static _read(messageType, buf) { + let identities = null, binders = null, selectedIdentity = null; + switch (messageType) { + case HANDSHAKE_TYPE.CLIENT_HELLO: + identities = []; binders = []; + buf.readVector16(buf => { + const identity = buf.readVectorBytes16(); + buf.readBytes(4); // Skip over the ticket age. + identities.push(identity); + }); + buf.readVector16(buf => { + const binder = buf.readVectorBytes8(); + if (binder.byteLength < HASH_LENGTH) { + throw new TLSError(ALERT_DESCRIPTION.ILLEGAL_PARAMETER); + } + binders.push(binder); + }); + if (identities.length !== binders.length) { + throw new TLSError(ALERT_DESCRIPTION.ILLEGAL_PARAMETER); + } + break; + case HANDSHAKE_TYPE.SERVER_HELLO: + selectedIdentity = buf.readUint16(); + break; + default: + throw new TLSError(ALERT_DESCRIPTION.ILLEGAL_PARAMETER); + } + return new this(identities, binders, selectedIdentity); + } + + _write(messageType, buf) { + switch (messageType) { + case HANDSHAKE_TYPE.CLIENT_HELLO: + buf.writeVector16(buf => { + this.identities.forEach(pskId => { + buf.writeVectorBytes16(pskId); + buf.writeUint32(0); // Zero for "tag age" field. + }); + }); + buf.writeVector16(buf => { + this.binders.forEach(pskBinder => { + buf.writeVectorBytes8(pskBinder); + }); + }); + break; + case HANDSHAKE_TYPE.SERVER_HELLO: + buf.writeUint16(this.selectedIdentity); + break; + default: + throw new TLSError(ALERT_DESCRIPTION.INTERNAL_ERROR); + } + } +} + + +// The SupportedVersions extension: +// +// struct { +// select(Handshake.msg_type) { +// case client_hello: +// ProtocolVersion versions < 2..254 >; +// case server_hello: +// ProtocolVersion selected_version; +// }; +// } SupportedVersions; + +class extensions_SupportedVersionsExtension extends extensions_Extension { + constructor(versions, selectedVersion) { + super(); + this.versions = versions; + this.selectedVersion = selectedVersion; + } + + get TYPE_TAG() { + return EXTENSION_TYPE.SUPPORTED_VERSIONS; + } + + static _read(messageType, buf) { + let versions = null, selectedVersion = null; + switch (messageType) { + case HANDSHAKE_TYPE.CLIENT_HELLO: + versions = []; + buf.readVector8(buf => { + versions.push(buf.readUint16()); + }); + break; + case HANDSHAKE_TYPE.SERVER_HELLO: + selectedVersion = buf.readUint16(); + break; + default: + throw new TLSError(ALERT_DESCRIPTION.ILLEGAL_PARAMETER); + } + return new this(versions, selectedVersion); + } + + _write(messageType, buf) { + switch (messageType) { + case HANDSHAKE_TYPE.CLIENT_HELLO: + buf.writeVector8(buf => { + this.versions.forEach(version => { + buf.writeUint16(version); + }); + }); + break; + case HANDSHAKE_TYPE.SERVER_HELLO: + buf.writeUint16(this.selectedVersion); + break; + default: + throw new TLSError(ALERT_DESCRIPTION.INTERNAL_ERROR); + } + } +} + + +class extensions_PskKeyExchangeModesExtension extends extensions_Extension { + constructor(modes) { + super(); + this.modes = modes; + } + + get TYPE_TAG() { + return EXTENSION_TYPE.PSK_KEY_EXCHANGE_MODES; + } + + static _read(messageType, buf) { + const modes = []; + switch (messageType) { + case HANDSHAKE_TYPE.CLIENT_HELLO: + buf.readVector8(buf => { + modes.push(buf.readUint8()); + }); + break; + default: + throw new TLSError(ALERT_DESCRIPTION.ILLEGAL_PARAMETER); + } + return new this(modes); + } + + _write(messageType, buf) { + switch (messageType) { + case HANDSHAKE_TYPE.CLIENT_HELLO: + buf.writeVector8(buf => { + this.modes.forEach(mode => { + buf.writeUint8(mode); + }); + }); + break; + default: + throw new TLSError(ALERT_DESCRIPTION.INTERNAL_ERROR); + } + } +} + +// CONCATENATED MODULE: ./src/constants.js +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const VERSION_TLS_1_0 = 0x0301; +const VERSION_TLS_1_2 = 0x0303; +const VERSION_TLS_1_3 = 0x0304; +const TLS_AES_128_GCM_SHA256 = 0x1301; +const PSK_MODE_KE = 0; + +// CONCATENATED MODULE: ./src/messages.js +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// +// Message parsing. +// +// Herein we have code for reading and writing the various Handshake +// messages involved in the TLS protocol. +// + + + + + + + +/* eslint-disable sorting/sort-object-props */ +const HANDSHAKE_TYPE = { + CLIENT_HELLO: 1, + SERVER_HELLO: 2, + NEW_SESSION_TICKET: 4, + ENCRYPTED_EXTENSIONS: 8, + FINISHED: 20, +}; +/* eslint-enable sorting/sort-object-props */ + +// Base class for generic reading/writing of handshake messages, +// which are all uniformly formatted as: +// +// struct { +// HandshakeType msg_type; /* handshake type */ +// uint24 length; /* bytes in message */ +// select(Handshake.msg_type) { +// ... type specific cases here ... +// }; +// } Handshake; + +class messages_HandshakeMessage { + + get TYPE_TAG() { + throw new Error('not implemented'); + } + + static fromBytes(bytes) { + // Each handshake message has a type and length prefix, per + // https://tools.ietf.org/html/rfc8446#appendix-B.3 + const buf = new utils_BufferReader(bytes); + const msg = this.read(buf); + if (buf.hasMoreBytes()) { + throw new TLSError(ALERT_DESCRIPTION.DECODE_ERROR); + } + return msg; + } + + toBytes() { + const buf = new utils_BufferWriter(); + this.write(buf); + return buf.flush(); + } + + static read(buf) { + const type = buf.readUint8(); + let msg = null; + buf.readVector24(buf => { + switch (type) { + case HANDSHAKE_TYPE.CLIENT_HELLO: + msg = messages_ClientHello._read(buf); + break; + case HANDSHAKE_TYPE.SERVER_HELLO: + msg = messages_ServerHello._read(buf); + break; + case HANDSHAKE_TYPE.NEW_SESSION_TICKET: + msg = messages_NewSessionTicket._read(buf); + break; + case HANDSHAKE_TYPE.ENCRYPTED_EXTENSIONS: + msg = EncryptedExtensions._read(buf); + break; + case HANDSHAKE_TYPE.FINISHED: + msg = messages_Finished._read(buf); + break; + } + if (buf.hasMoreBytes()) { + throw new TLSError(ALERT_DESCRIPTION.DECODE_ERROR); + } + }); + if (msg === null) { + throw new TLSError(ALERT_DESCRIPTION.UNEXPECTED_MESSAGE); + } + return msg; + } + + write(buf) { + buf.writeUint8(this.TYPE_TAG); + buf.writeVector24(buf => { + this._write(buf); + }); + } + + static _read(buf) { + throw new Error('not implemented'); + } + + _write(buf) { + throw new Error('not implemented'); + } + + // Some little helpers for reading a list of extensions, + // which is uniformly represented as: + // + // Extension extensions<8..2^16-1>; + // + // Recognized extensions are returned as a Map from extension type + // to extension data object, with a special `lastSeenExtension` + // property to make it easy to check which one came last. + + static _readExtensions(messageType, buf) { + const extensions = new Map(); + buf.readVector16(buf => { + const ext = extensions_Extension.read(messageType, buf); + if (extensions.has(ext.TYPE_TAG)) { + throw new TLSError(ALERT_DESCRIPTION.DECODE_ERROR); + } + extensions.set(ext.TYPE_TAG, ext); + extensions.lastSeenExtension = ext.TYPE_TAG; + }); + return extensions; + } + + _writeExtensions(buf, extensions) { + buf.writeVector16(buf => { + extensions.forEach(ext => { + ext.write(this.TYPE_TAG, buf); + }); + }); + } +} + + +// The ClientHello message: +// +// struct { +// ProtocolVersion legacy_version = 0x0303; +// Random random; +// opaque legacy_session_id<0..32>; +// CipherSuite cipher_suites<2..2^16-2>; +// opaque legacy_compression_methods<1..2^8-1>; +// Extension extensions<8..2^16-1>; +// } ClientHello; + +class messages_ClientHello extends messages_HandshakeMessage { + + constructor(random, sessionId, extensions) { + super(); + this.random = random; + this.sessionId = sessionId; + this.extensions = extensions; + } + + get TYPE_TAG() { + return HANDSHAKE_TYPE.CLIENT_HELLO; + } + + static _read(buf) { + // The legacy_version field may indicate an earlier version of TLS + // for backwards compatibility, but must not predate TLS 1.0! + if (buf.readUint16() < VERSION_TLS_1_0) { + throw new TLSError(ALERT_DESCRIPTION.PROTOCOL_VERSION); + } + // The random bytes provided by the peer. + const random = buf.readBytes(32); + // Read legacy_session_id, so the server can echo it. + const sessionId = buf.readVectorBytes8(); + // We only support a single ciphersuite, but the peer may offer several. + // Scan the list to confirm that the one we want is present. + let found = false; + buf.readVector16(buf => { + const cipherSuite = buf.readUint16(); + if (cipherSuite === TLS_AES_128_GCM_SHA256) { + found = true; + } + }); + if (! found) { + throw new TLSError(ALERT_DESCRIPTION.HANDSHAKE_FAILURE); + } + // legacy_compression_methods must be a single zero byte for TLS1.3 ClientHellos. + // It can be non-zero in previous versions of TLS, but we're not going to + // make a successful handshake with such versions, so better to just bail out now. + const legacyCompressionMethods = buf.readVectorBytes8(); + if (legacyCompressionMethods.byteLength !== 1) { + throw new TLSError(ALERT_DESCRIPTION.ILLEGAL_PARAMETER); + } + if (legacyCompressionMethods[0] !== 0x00) { + throw new TLSError(ALERT_DESCRIPTION.ILLEGAL_PARAMETER); + } + // Read and check the extensions. + const extensions = this._readExtensions(HANDSHAKE_TYPE.CLIENT_HELLO, buf); + if (! extensions.has(EXTENSION_TYPE.SUPPORTED_VERSIONS)) { + throw new TLSError(ALERT_DESCRIPTION.MISSING_EXTENSION); + } + if (extensions.get(EXTENSION_TYPE.SUPPORTED_VERSIONS).versions.indexOf(VERSION_TLS_1_3) === -1) { + throw new TLSError(ALERT_DESCRIPTION.PROTOCOL_VERSION); + } + // Was the PreSharedKey extension the last one? + if (extensions.has(EXTENSION_TYPE.PRE_SHARED_KEY)) { + if (extensions.lastSeenExtension !== EXTENSION_TYPE.PRE_SHARED_KEY) { + throw new TLSError(ALERT_DESCRIPTION.ILLEGAL_PARAMETER); + } + } + return new this(random, sessionId, extensions); + } + + _write(buf) { + buf.writeUint16(VERSION_TLS_1_2); + buf.writeBytes(this.random); + buf.writeVectorBytes8(this.sessionId); + // Our single supported ciphersuite + buf.writeVector16(buf => { + buf.writeUint16(TLS_AES_128_GCM_SHA256); + }); + // A single zero byte for legacy_compression_methods + buf.writeVectorBytes8(new Uint8Array(1)); + this._writeExtensions(buf, this.extensions); + } +} + + +// The ServerHello message: +// +// struct { +// ProtocolVersion legacy_version = 0x0303; /* TLS v1.2 */ +// Random random; +// opaque legacy_session_id_echo<0..32>; +// CipherSuite cipher_suite; +// uint8 legacy_compression_method = 0; +// Extension extensions < 6..2 ^ 16 - 1 >; +// } ServerHello; + +class messages_ServerHello extends messages_HandshakeMessage { + + constructor(random, sessionId, extensions) { + super(); + this.random = random; + this.sessionId = sessionId; + this.extensions = extensions; + } + + get TYPE_TAG() { + return HANDSHAKE_TYPE.SERVER_HELLO; + } + + static _read(buf) { + // Fixed value for legacy_version. + if (buf.readUint16() !== VERSION_TLS_1_2) { + throw new TLSError(ALERT_DESCRIPTION.ILLEGAL_PARAMETER); + } + // Random bytes from the server. + const random = buf.readBytes(32); + // It should have echoed our vector for legacy_session_id. + const sessionId = buf.readVectorBytes8(); + // It should have selected our single offered ciphersuite. + if (buf.readUint16() !== TLS_AES_128_GCM_SHA256) { + throw new TLSError(ALERT_DESCRIPTION.ILLEGAL_PARAMETER); + } + // legacy_compression_method must be zero. + if (buf.readUint8() !== 0) { + throw new TLSError(ALERT_DESCRIPTION.ILLEGAL_PARAMETER); + } + const extensions = this._readExtensions(HANDSHAKE_TYPE.SERVER_HELLO, buf); + if (! extensions.has(EXTENSION_TYPE.SUPPORTED_VERSIONS)) { + throw new TLSError(ALERT_DESCRIPTION.MISSING_EXTENSION); + } + if (extensions.get(EXTENSION_TYPE.SUPPORTED_VERSIONS).selectedVersion !== VERSION_TLS_1_3) { + throw new TLSError(ALERT_DESCRIPTION.ILLEGAL_PARAMETER); + } + return new this(random, sessionId, extensions); + } + + _write(buf) { + buf.writeUint16(VERSION_TLS_1_2); + buf.writeBytes(this.random); + buf.writeVectorBytes8(this.sessionId); + // Our single supported ciphersuite + buf.writeUint16(TLS_AES_128_GCM_SHA256); + // A single zero byte for legacy_compression_method + buf.writeUint8(0); + this._writeExtensions(buf, this.extensions); + } +} + + +// The EncryptedExtensions message: +// +// struct { +// Extension extensions < 0..2 ^ 16 - 1 >; +// } EncryptedExtensions; +// +// We don't actually send any EncryptedExtensions, +// but still have to send an empty message. + +class EncryptedExtensions extends messages_HandshakeMessage { + constructor(extensions) { + super(); + this.extensions = extensions; + } + + get TYPE_TAG() { + return HANDSHAKE_TYPE.ENCRYPTED_EXTENSIONS; + } + + static _read(buf) { + const extensions = this._readExtensions(HANDSHAKE_TYPE.ENCRYPTED_EXTENSIONS, buf); + return new this(extensions); + } + + _write(buf) { + this._writeExtensions(buf, this.extensions); + } +} + + +// The Finished message: +// +// struct { +// opaque verify_data[Hash.length]; +// } Finished; + +class messages_Finished extends messages_HandshakeMessage { + + constructor(verifyData) { + super(); + this.verifyData = verifyData; + } + + get TYPE_TAG() { + return HANDSHAKE_TYPE.FINISHED; + } + + static _read(buf) { + const verifyData = buf.readBytes(HASH_LENGTH); + return new this(verifyData); + } + + _write(buf) { + buf.writeBytes(this.verifyData); + } +} + + +// The NewSessionTicket message: +// +// struct { +// uint32 ticket_lifetime; +// uint32 ticket_age_add; +// opaque ticket_nonce < 0..255 >; +// opaque ticket < 1..2 ^ 16 - 1 >; +// Extension extensions < 0..2 ^ 16 - 2 >; +// } NewSessionTicket; +// +// We don't actually make use of these, but we need to be able +// to accept them and do basic validation. + +class messages_NewSessionTicket extends messages_HandshakeMessage { + constructor(ticketLifetime, ticketAgeAdd, ticketNonce, ticket, extensions) { + super(); + this.ticketLifetime = ticketLifetime; + this.ticketAgeAdd = ticketAgeAdd; + this.ticketNonce = ticketNonce; + this.ticket = ticket; + this.extensions = extensions; + } + + get TYPE_TAG() { + return HANDSHAKE_TYPE.NEW_SESSION_TICKET; + } + + static _read(buf) { + const ticketLifetime = buf.readUint32(); + const ticketAgeAdd = buf.readUint32(); + const ticketNonce = buf.readVectorBytes8(); + const ticket = buf.readVectorBytes16(); + if (ticket.byteLength < 1) { + throw new TLSError(ALERT_DESCRIPTION.DECODE_ERROR); + } + const extensions = this._readExtensions(HANDSHAKE_TYPE.NEW_SESSION_TICKET, buf); + return new this(ticketLifetime, ticketAgeAdd, ticketNonce, ticket, extensions); + } + + _write(buf) { + buf.writeUint32(this.ticketLifetime); + buf.writeUint32(this.ticketAgeAdd); + buf.writeVectorBytes8(this.ticketNonce); + buf.writeVectorBytes16(this.ticket); + this._writeExtensions(buf, this.extensions); + } +} + +// CONCATENATED MODULE: ./src/states.js +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + + + + + + + + +// +// State-machine for TLS Handshake Management. +// +// Internally, we manage the TLS connection by explicitly modelling the +// client and server state-machines from RFC8446. You can think of +// these `State` objects as little plugins for the `Connection` class +// that provide different behaviours of `send` and `receive` depending +// on the state of the connection. +// + +class states_State { + + constructor(conn) { + this.conn = conn; + } + + async initialize() { + // By default, nothing to do when entering the state. + } + + async sendApplicationData(bytes) { + // By default, assume we're not ready to send yet and the caller + // should be blocking on the connection promise before reaching here. + throw new TLSError(ALERT_DESCRIPTION.INTERNAL_ERROR); + } + + async recvApplicationData(bytes) { + throw new TLSError(ALERT_DESCRIPTION.UNEXPECTED_MESSAGE); + } + + async recvHandshakeMessage(msg) { + throw new TLSError(ALERT_DESCRIPTION.UNEXPECTED_MESSAGE); + } + + async recvAlertMessage(alert) { + switch (alert.description) { + case ALERT_DESCRIPTION.CLOSE_NOTIFY: + this.conn._closeForRecv(alert); + throw alert; + default: + return await this.handleErrorAndRethrow(alert); + } + } + + async recvChangeCipherSpec(bytes) { + throw new TLSError(ALERT_DESCRIPTION.UNEXPECTED_MESSAGE); + } + + async handleErrorAndRethrow(err) { + let alert = err; + if (! (alert instanceof TLSAlert)) { + alert = new TLSError(ALERT_DESCRIPTION.INTERNAL_ERROR); + } + // Try to send error alert to the peer, but we may not + // be able to if the outgoing connection was already closed. + try { + await this.conn._sendAlertMessage(alert); + } catch (_) { } + await this.conn._transition(ERROR, err); + throw err; + } + + async close() { + const alert = new TLSCloseNotify(); + await this.conn._sendAlertMessage(alert); + this.conn._closeForSend(alert); + } + +} + +// A special "guard" state to prevent us from using +// an improperly-initialized Connection. + +class UNINITIALIZED extends states_State { + async initialize() { + throw new Error('uninitialized state'); + } + async sendApplicationData(bytes) { + throw new Error('uninitialized state'); + } + async recvApplicationData(bytes) { + throw new Error('uninitialized state'); + } + async recvHandshakeMessage(msg) { + throw new Error('uninitialized state'); + } + async recvChangeCipherSpec(bytes) { + throw new Error('uninitialized state'); + } + async handleErrorAndRethrow(err) { + throw err; + } + async close() { + throw new Error('uninitialized state'); + } +} + +// A special "error" state for when something goes wrong. +// This state never transitions to another state, effectively +// terminating the connection. + +class ERROR extends states_State { + async initialize(err) { + this.error = err; + this.conn._setConnectionFailure(err); + // Unceremoniously shut down the record layer on error. + this.conn._recordlayer.setSendError(err); + this.conn._recordlayer.setRecvError(err); + } + async sendApplicationData(bytes) { + throw this.error; + } + async recvApplicationData(bytes) { + throw this.error; + } + async recvHandshakeMessage(msg) { + throw this.error; + } + async recvAlertMessage(err) { + throw this.error; + } + async recvChangeCipherSpec(bytes) { + throw this.error; + } + async handleErrorAndRethrow(err) { + throw err; + } + async close() { + throw this.error; + } +} + +// The "connected" state, for when the handshake is complete +// and we're ready to send application-level data. +// The logic for this is largely symmetric between client and server. + +class states_CONNECTED extends states_State { + async initialize() { + this.conn._setConnectionSuccess(); + } + async sendApplicationData(bytes) { + await this.conn._sendApplicationData(bytes); + } + async recvApplicationData(bytes) { + return bytes; + } + async recvChangeCipherSpec(bytes) { + throw new TLSError(ALERT_DESCRIPTION.UNEXPECTED_MESSAGE); + } +} + +// A base class for states that occur in the middle of the handshake +// (that is, between ClientHello and Finished). These states may receive +// CHANGE_CIPHER_SPEC records for b/w compat reasons, which must contain +// exactly a single 0x01 byte and must otherwise be ignored. + +class states_MidHandshakeState extends states_State { + async recvChangeCipherSpec(bytes) { + if (this.conn._hasSeenChangeCipherSpec) { + throw new TLSError(ALERT_DESCRIPTION.UNEXPECTED_MESSAGE); + } + if (bytes.byteLength !== 1 || bytes[0] !== 1) { + throw new TLSError(ALERT_DESCRIPTION.UNEXPECTED_MESSAGE); + } + this.conn._hasSeenChangeCipherSpec = true; + } +} + +// These states implement (part of) the client state-machine from +// https://tools.ietf.org/html/rfc8446#appendix-A.1 +// +// Since we're only implementing a small subset of TLS1.3, +// we only need a small subset of the handshake. It basically goes: +// +// * send ClientHello +// * receive ServerHello +// * receive EncryptedExtensions +// * receive server Finished +// * send client Finished +// +// We include some unused states for completeness, so that it's easier +// to check the implementation against the diagrams in the RFC. + +class states_CLIENT_START extends states_State { + async initialize() { + const keyschedule = this.conn._keyschedule; + await keyschedule.addPSK(this.conn.psk); + // Construct a ClientHello message with our single PSK. + // We can't know the PSK binder value yet, so we initially write zeros. + const clientHello = new messages_ClientHello( + // Client random salt. + await getRandomBytes(32), + // Random legacy_session_id; we *could* send an empty string here, + // but sending a random one makes it easier to be compatible with + // the data emitted by tlslite-ng for test-case generation. + await getRandomBytes(32), + [ + new extensions_SupportedVersionsExtension([VERSION_TLS_1_3]), + new extensions_PskKeyExchangeModesExtension([PSK_MODE_KE]), + new extensions_PreSharedKeyExtension([this.conn.pskId], [zeros(HASH_LENGTH)]), + ], + ); + const buf = new utils_BufferWriter(); + clientHello.write(buf); + // Now that we know what the ClientHello looks like, + // go back and calculate the appropriate PSK binder value. + // We only support a single PSK, so the length of the binders field is the + // length of the hash plus one for rendering it as a variable-length byte array, + // plus two for rendering the variable-length list of PSK binders. + const PSK_BINDERS_SIZE = HASH_LENGTH + 1 + 2; + const truncatedTranscript = buf.slice(0, buf.tell() - PSK_BINDERS_SIZE); + const pskBinder = await keyschedule.calculateFinishedMAC(keyschedule.extBinderKey, truncatedTranscript); + buf.incr(-HASH_LENGTH); + buf.writeBytes(pskBinder); + await this.conn._sendHandshakeMessageBytes(buf.flush()); + await this.conn._transition(states_CLIENT_WAIT_SH, clientHello.sessionId); + } +} + +class states_CLIENT_WAIT_SH extends states_State { + async initialize(sessionId) { + this._sessionId = sessionId; + } + async recvHandshakeMessage(msg) { + if (! (msg instanceof messages_ServerHello)) { + throw new TLSError(ALERT_DESCRIPTION.UNEXPECTED_MESSAGE); + } + if (! bytesAreEqual(msg.sessionId, this._sessionId)) { + throw new TLSError(ALERT_DESCRIPTION.ILLEGAL_PARAMETER); + } + const pskExt = msg.extensions.get(EXTENSION_TYPE.PRE_SHARED_KEY); + if (! pskExt) { + throw new TLSError(ALERT_DESCRIPTION.MISSING_EXTENSION); + } + // We expect only the SUPPORTED_VERSIONS and PRE_SHARED_KEY extensions. + if (msg.extensions.size !== 2) { + throw new TLSError(ALERT_DESCRIPTION.UNSUPPORTED_EXTENSION); + } + if (pskExt.selectedIdentity !== 0) { + throw new TLSError(ALERT_DESCRIPTION.ILLEGAL_PARAMETER); + } + await this.conn._keyschedule.addECDHE(null); + await this.conn._setSendKey(this.conn._keyschedule.clientHandshakeTrafficSecret); + await this.conn._setRecvKey(this.conn._keyschedule.serverHandshakeTrafficSecret); + await this.conn._transition(states_CLIENT_WAIT_EE); + } +} + +class states_CLIENT_WAIT_EE extends states_MidHandshakeState { + async recvHandshakeMessage(msg) { + // We don't make use of any encrypted extensions, but we still + // have to wait for the server to send the (empty) list of them. + if (! (msg instanceof EncryptedExtensions)) { + throw new TLSError(ALERT_DESCRIPTION.UNEXPECTED_MESSAGE); + } + // We do not support any EncryptedExtensions. + if (msg.extensions.size !== 0) { + throw new TLSError(ALERT_DESCRIPTION.UNSUPPORTED_EXTENSION); + } + const keyschedule = this.conn._keyschedule; + const serverFinishedTranscript = keyschedule.getTranscript(); + await this.conn._transition(states_CLIENT_WAIT_FINISHED, serverFinishedTranscript); + } +} + +class states_CLIENT_WAIT_FINISHED extends states_State { + async initialize(serverFinishedTranscript) { + this._serverFinishedTranscript = serverFinishedTranscript; + } + async recvHandshakeMessage(msg) { + if (! (msg instanceof messages_Finished)) { + throw new TLSError(ALERT_DESCRIPTION.UNEXPECTED_MESSAGE); + } + // Verify server Finished MAC. + const keyschedule = this.conn._keyschedule; + await keyschedule.verifyFinishedMAC(keyschedule.serverHandshakeTrafficSecret, msg.verifyData, this._serverFinishedTranscript); + // Send our own Finished message in return. + // This must be encrypted with the handshake traffic key, + // but must not appear in the transcript used to calculate the application keys. + const clientFinishedMAC = await keyschedule.calculateFinishedMAC(keyschedule.clientHandshakeTrafficSecret); + await keyschedule.finalize(); + await this.conn._sendHandshakeMessage(new messages_Finished(clientFinishedMAC)); + await this.conn._setSendKey(keyschedule.clientApplicationTrafficSecret); + await this.conn._setRecvKey(keyschedule.serverApplicationTrafficSecret); + await this.conn._transition(states_CLIENT_CONNECTED); + } +} + +class states_CLIENT_CONNECTED extends states_CONNECTED { + async recvHandshakeMessage(msg) { + // A connected client must be prepared to accept NewSessionTicket + // messages. We never use them, but other server implementations + // might send them. + if (! (msg instanceof messages_NewSessionTicket)) { + throw new TLSError(ALERT_DESCRIPTION.UNEXPECTED_MESSAGE); + } + } +} + +// These states implement (part of) the server state-machine from +// https://tools.ietf.org/html/rfc8446#appendix-A.2 +// +// Since we're only implementing a small subset of TLS1.3, +// we only need a small subset of the handshake. It basically goes: +// +// * receive ClientHello +// * send ServerHello +// * send empty EncryptedExtensions +// * send server Finished +// * receive client Finished +// +// We include some unused states for completeness, so that it's easier +// to check the implementation against the diagrams in the RFC. + +class states_SERVER_START extends states_State { + async recvHandshakeMessage(msg) { + if (! (msg instanceof messages_ClientHello)) { + throw new TLSError(ALERT_DESCRIPTION.UNEXPECTED_MESSAGE); + } + // In the spec, this is where we select connection parameters, and maybe + // tell the client to try again if we can't find a compatible set. + // Since we only support a fixed cipherset, the only thing to "negotiate" + // is whether they provided an acceptable PSK. + const pskExt = msg.extensions.get(EXTENSION_TYPE.PRE_SHARED_KEY); + const pskModesExt = msg.extensions.get(EXTENSION_TYPE.PSK_KEY_EXCHANGE_MODES); + if (! pskExt || ! pskModesExt) { + throw new TLSError(ALERT_DESCRIPTION.MISSING_EXTENSION); + } + if (pskModesExt.modes.indexOf(PSK_MODE_KE) === -1) { + throw new TLSError(ALERT_DESCRIPTION.HANDSHAKE_FAILURE); + } + const pskIndex = pskExt.identities.findIndex(pskId => bytesAreEqual(pskId, this.conn.pskId)); + if (pskIndex === -1) { + throw new TLSError(ALERT_DESCRIPTION.UNKNOWN_PSK_IDENTITY); + } + await this.conn._keyschedule.addPSK(this.conn.psk); + // Validate the PSK binder. + const keyschedule = this.conn._keyschedule; + const transcript = keyschedule.getTranscript(); + // Calculate size occupied by the PSK binders. + let pskBindersSize = 2; // Vector16 representation overhead. + for (const binder of pskExt.binders) { + pskBindersSize += binder.byteLength + 1; // Vector8 representation overhead. + } + await keyschedule.verifyFinishedMAC(keyschedule.extBinderKey, pskExt.binders[pskIndex], transcript.slice(0, -pskBindersSize)); + await this.conn._transition(states_SERVER_NEGOTIATED, msg.sessionId, pskIndex); + } +} + +class states_SERVER_NEGOTIATED extends states_MidHandshakeState { + async initialize(sessionId, pskIndex) { + await this.conn._sendHandshakeMessage(new messages_ServerHello( + // Server random + await getRandomBytes(32), + sessionId, + [ + new extensions_SupportedVersionsExtension(null, VERSION_TLS_1_3), + new extensions_PreSharedKeyExtension(null, null, pskIndex), + ] + )); + // If the client sent a non-empty sessionId, the server *must* send a change-cipher-spec for b/w compat. + if (sessionId.byteLength > 0) { + await this.conn._sendChangeCipherSpec(); + } + // We can now transition to the encrypted part of the handshake. + const keyschedule = this.conn._keyschedule; + await keyschedule.addECDHE(null); + await this.conn._setSendKey(keyschedule.serverHandshakeTrafficSecret); + await this.conn._setRecvKey(keyschedule.clientHandshakeTrafficSecret); + // Send an empty EncryptedExtensions message. + await this.conn._sendHandshakeMessage(new EncryptedExtensions([])); + // Send the Finished message. + const serverFinishedMAC = await keyschedule.calculateFinishedMAC(keyschedule.serverHandshakeTrafficSecret); + await this.conn._sendHandshakeMessage(new messages_Finished(serverFinishedMAC)); + // We can now *send* using the application traffic key, + // but have to wait to receive the client Finished before receiving under that key. + // We need to remember the handshake state from before the client Finished + // in order to successfully verify the client Finished. + const clientFinishedTranscript = await keyschedule.getTranscript(); + const clientHandshakeTrafficSecret = keyschedule.clientHandshakeTrafficSecret; + await keyschedule.finalize(); + await this.conn._setSendKey(keyschedule.serverApplicationTrafficSecret); + await this.conn._transition(states_SERVER_WAIT_FINISHED, clientHandshakeTrafficSecret, clientFinishedTranscript); + } +} + +class states_SERVER_WAIT_FINISHED extends states_MidHandshakeState { + async initialize(clientHandshakeTrafficSecret, clientFinishedTranscript) { + this._clientHandshakeTrafficSecret = clientHandshakeTrafficSecret; + this._clientFinishedTranscript = clientFinishedTranscript; + } + async recvHandshakeMessage(msg) { + if (! (msg instanceof messages_Finished)) { + throw new TLSError(ALERT_DESCRIPTION.UNEXPECTED_MESSAGE); + } + const keyschedule = this.conn._keyschedule; + await keyschedule.verifyFinishedMAC(this._clientHandshakeTrafficSecret, msg.verifyData, this._clientFinishedTranscript); + this._clientHandshakeTrafficSecret = this._clientFinishedTranscript = null; + await this.conn._setRecvKey(keyschedule.clientApplicationTrafficSecret); + await this.conn._transition(states_CONNECTED); + } +} + +// CONCATENATED MODULE: ./src/keyschedule.js +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// TLS1.3 Key Schedule. +// +// In this file we implement the "key schedule" from +// https://tools.ietf.org/html/rfc8446#section-7.1, which +// defines how to calculate various keys as the handshake +// state progresses. + + + + + + + +// The `KeySchedule` class progresses through three stages corresponding +// to the three phases of the TLS1.3 key schedule: +// +// UNINITIALIZED +// | +// | addPSK() +// v +// EARLY_SECRET +// | +// | addECDHE() +// v +// HANDSHAKE_SECRET +// | +// | finalize() +// v +// MASTER_SECRET +// +// It will error out if the calling code attempts to add key material +// in the wrong order. + +const STAGE_UNINITIALIZED = 0; +const STAGE_EARLY_SECRET = 1; +const STAGE_HANDSHAKE_SECRET = 2; +const STAGE_MASTER_SECRET = 3; + +class keyschedule_KeySchedule { + constructor() { + this.stage = STAGE_UNINITIALIZED; + // WebCrypto doesn't support a rolling hash construct, so we have to + // keep the entire message transcript in memory. + this.transcript = new utils_BufferWriter(); + // This tracks the main secret from with other keys are derived at each stage. + this.secret = null; + // And these are all the various keys we'll derive as the handshake progresses. + this.extBinderKey = null; + this.clientHandshakeTrafficSecret = null; + this.serverHandshakeTrafficSecret = null; + this.clientApplicationTrafficSecret = null; + this.serverApplicationTrafficSecret = null; + } + + async addPSK(psk) { + // Use the selected PSK (if any) to calculate the "early secret". + if (psk === null) { + psk = zeros(HASH_LENGTH); + } + if (this.stage !== STAGE_UNINITIALIZED) { + throw new TLSError(ALERT_DESCRIPTION.INTERNAL_ERROR); + } + this.stage = STAGE_EARLY_SECRET; + this.secret = await hkdfExtract(zeros(HASH_LENGTH), psk); + this.extBinderKey = await this.deriveSecret('ext binder', EMPTY); + this.secret = await this.deriveSecret('derived', EMPTY); + } + + async addECDHE(ecdhe) { + // Mix in the ECDHE output (if any) to calculate the "handshake secret". + if (ecdhe === null) { + ecdhe = zeros(HASH_LENGTH); + } + if (this.stage !== STAGE_EARLY_SECRET) { + throw new TLSError(ALERT_DESCRIPTION.INTERNAL_ERROR); + } + this.stage = STAGE_HANDSHAKE_SECRET; + this.extBinderKey = null; + this.secret = await hkdfExtract(this.secret, ecdhe); + this.clientHandshakeTrafficSecret = await this.deriveSecret('c hs traffic'); + this.serverHandshakeTrafficSecret = await this.deriveSecret('s hs traffic'); + this.secret = await this.deriveSecret('derived', EMPTY); + } + + async finalize() { + if (this.stage !== STAGE_HANDSHAKE_SECRET) { + throw new TLSError(ALERT_DESCRIPTION.INTERNAL_ERROR); + } + this.stage = STAGE_MASTER_SECRET; + this.clientHandshakeTrafficSecret = null; + this.serverHandshakeTrafficSecret = null; + this.secret = await hkdfExtract(this.secret, zeros(HASH_LENGTH)); + this.clientApplicationTrafficSecret = await this.deriveSecret('c ap traffic'); + this.serverApplicationTrafficSecret = await this.deriveSecret('s ap traffic'); + this.secret = null; + } + + addToTranscript(bytes) { + this.transcript.writeBytes(bytes); + } + + getTranscript() { + return this.transcript.slice(); + } + + async deriveSecret(label, transcript = undefined) { + transcript = transcript || this.getTranscript(); + return await hkdfExpandLabel(this.secret, label, await hash(transcript), HASH_LENGTH); + } + + async calculateFinishedMAC(baseKey, transcript = undefined) { + transcript = transcript || this.getTranscript(); + const finishedKey = await hkdfExpandLabel(baseKey, 'finished', EMPTY, HASH_LENGTH); + return await hmac(finishedKey, await hash(transcript)); + } + + async verifyFinishedMAC(baseKey, mac, transcript = undefined) { + transcript = transcript || this.getTranscript(); + const finishedKey = await hkdfExpandLabel(baseKey, 'finished', EMPTY, HASH_LENGTH); + await verifyHmac(finishedKey, mac, await hash(transcript)); + } +} + +// CONCATENATED MODULE: ./src/recordlayer.js +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// +// This file implements the "record layer" for TLS1.3, as defined in +// https://tools.ietf.org/html/rfc8446#section-5. +// +// The record layer is responsible for encrypting/decrypting bytes to be +// sent over the wire, including stateful management of sequence numbers +// for the incoming and outgoing stream. +// +// The main interface is the RecordLayer class, which takes a callback function +// sending data and can be used like so: +// +// rl = new RecordLayer(async function send_encrypted_data(data) { +// // application-specific sending logic here. +// }); +// +// // Records are sent and received in plaintext by default, +// // until you specify the key to use. +// await rl.setSendKey(key) +// +// // Send some data by specifying the record type and the bytes. +// // Where allowed by the record type, it will be buffered until +// // explicitly flushed, and then sent by calling the callback. +// await rl.send(RECORD_TYPE.HANDSHAKE, <bytes for a handshake message>) +// await rl.send(RECORD_TYPE.HANDSHAKE, <bytes for another handshake message>) +// await rl.flush() +// +// // Separate keys are used for sending and receiving. +// rl.setRecvKey(key); +// +// // When data is received, push it into the RecordLayer +// // and pass a callback that will be called with a [type, bytes] +// // pair for each message parsed from the data. +// rl.recv(dataReceivedFromPeer, async (type, bytes) => { +// switch (type) { +// case RECORD_TYPE.APPLICATION_DATA: +// // do something with application data +// case RECORD_TYPE.HANDSHAKE: +// // do something with a handshake message +// default: +// // etc... +// } +// }); +// + + + + + + + +/* eslint-disable sorting/sort-object-props */ +const RECORD_TYPE = { + CHANGE_CIPHER_SPEC: 20, + ALERT: 21, + HANDSHAKE: 22, + APPLICATION_DATA: 23, +}; +/* eslint-enable sorting/sort-object-props */ + +// Encrypting at most 2^24 records will force us to stay +// below data limits on AES-GCM encryption key use, and also +// means we can accurately represent the sequence number as +// a javascript double. +const MAX_SEQUENCE_NUMBER = Math.pow(2, 24); +const MAX_RECORD_SIZE = Math.pow(2, 14); +const MAX_ENCRYPTED_RECORD_SIZE = MAX_RECORD_SIZE + 256; +const RECORD_HEADER_SIZE = 5; + +// These are some helper classes to manage the encryption/decryption state +// for a particular key. + +class recordlayer_CipherState { + constructor(key, iv) { + this.key = key; + this.iv = iv; + this.seqnum = 0; + } + + static async create(baseKey, mode) { + // Derive key and iv per https://tools.ietf.org/html/rfc8446#section-7.3 + const key = await prepareKey(await hkdfExpandLabel(baseKey, 'key', EMPTY, KEY_LENGTH), mode); + const iv = await hkdfExpandLabel(baseKey, 'iv', EMPTY, IV_LENGTH); + return new this(key, iv); + } + + nonce() { + // Ref https://tools.ietf.org/html/rfc8446#section-5.3: + // * left-pad the sequence number with zeros to IV_LENGTH + // * xor with the provided iv + // Our sequence numbers are always less than 2^24, so fit in a Uint32 + // in the last 4 bytes of the nonce. + const nonce = this.iv.slice(); + const dv = new DataView(nonce.buffer, nonce.byteLength - 4, 4); + dv.setUint32(0, dv.getUint32(0) ^ this.seqnum); + this.seqnum += 1; + if (this.seqnum > MAX_SEQUENCE_NUMBER) { + throw new TLSError(ALERT_DESCRIPTION.INTERNAL_ERROR); + } + return nonce; + } +} + +class recordlayer_EncryptionState extends recordlayer_CipherState { + static async create(key) { + return super.create(key, 'encrypt'); + } + + async encrypt(plaintext, additionalData) { + return await encrypt(this.key, this.nonce(), plaintext, additionalData); + } +} + +class recordlayer_DecryptionState extends recordlayer_CipherState { + static async create(key) { + return super.create(key, 'decrypt'); + } + + async decrypt(ciphertext, additionalData) { + return await decrypt(this.key, this.nonce(), ciphertext, additionalData); + } +} + +// The main RecordLayer class. + +class recordlayer_RecordLayer { + constructor(sendCallback) { + this.sendCallback = sendCallback; + this._sendEncryptState = null; + this._sendError = null; + this._recvDecryptState = null; + this._recvError = null; + this._pendingRecordType = 0; + this._pendingRecordBuf = null; + } + + async setSendKey(key) { + await this.flush(); + this._sendEncryptState = await recordlayer_EncryptionState.create(key); + } + + async setRecvKey(key) { + this._recvDecryptState = await recordlayer_DecryptionState.create(key); + } + + async setSendError(err) { + this._sendError = err; + } + + async setRecvError(err) { + this._recvError = err; + } + + async send(type, data) { + if (this._sendError !== null) { + throw this._sendError; + } + // Forbid sending data that doesn't fit into a single record. + // We do not support fragmentation over multiple records. + if (data.byteLength > MAX_RECORD_SIZE) { + throw new TLSError(ALERT_DESCRIPTION.INTERNAL_ERROR); + } + // Flush if we're switching to a different record type. + if (this._pendingRecordType && this._pendingRecordType !== type) { + await this.flush(); + } + // Flush if we would overflow the max size of a record. + if (this._pendingRecordBuf !== null) { + if (this._pendingRecordBuf.tell() + data.byteLength > MAX_RECORD_SIZE) { + await this.flush(); + } + } + // Start a new pending record if necessary. + // We reserve space at the start of the buffer for the record header, + // which is conveniently always a fixed size. + if (this._pendingRecordBuf === null) { + this._pendingRecordType = type; + this._pendingRecordBuf = new utils_BufferWriter(); + this._pendingRecordBuf.incr(RECORD_HEADER_SIZE); + } + this._pendingRecordBuf.writeBytes(data); + } + + async flush() { + // If there's nothing to flush, bail out early. + // Don't throw `_sendError` if we're not sending anything, because `flush()` + // can be called when we're trying to transition into an error state. + const buf = this._pendingRecordBuf; + let type = this._pendingRecordType; + if (! type) { + if (buf !== null) { + throw new TLSError(ALERT_DESCRIPTION.INTERNAL_ERROR); + } + return; + } + if (this._sendError !== null) { + throw this._sendError; + } + // If we're encrypting, turn the existing buffer contents into a `TLSInnerPlaintext` by + // appending the type. We don't do any zero-padding, although the spec allows it. + let inflation = 0, innerPlaintext = null; + if (this._sendEncryptState !== null) { + buf.writeUint8(type); + innerPlaintext = buf.slice(RECORD_HEADER_SIZE); + inflation = AEAD_SIZE_INFLATION; + type = RECORD_TYPE.APPLICATION_DATA; + } + // Write the common header for either `TLSPlaintext` or `TLSCiphertext` record. + const length = buf.tell() - RECORD_HEADER_SIZE + inflation; + buf.seek(0); + buf.writeUint8(type); + buf.writeUint16(VERSION_TLS_1_2); + buf.writeUint16(length); + // Followed by different payload depending on encryption status. + if (this._sendEncryptState !== null) { + const additionalData = buf.slice(0, RECORD_HEADER_SIZE); + const ciphertext = await this._sendEncryptState.encrypt(innerPlaintext, additionalData); + buf.writeBytes(ciphertext); + } else { + buf.incr(length); + } + this._pendingRecordBuf = null; + this._pendingRecordType = 0; + await this.sendCallback(buf.flush()); + } + + async recv(data) { + if (this._recvError !== null) { + throw this._recvError; + } + // For simplicity, we assume that the given data contains exactly one record. + // Peers using this library will send one record at a time over the websocket + // connection, and we can assume that the server-side websocket bridge will split + // up any traffic into individual records if we ever start interoperating with + // peers using a different TLS implementation. + // Similarly, we assume that handshake messages will not be fragmented across + // multiple records. This should be trivially true for the PSK-only mode used + // by this library, but we may want to relax it in future for interoperability + // with e.g. large ClientHello messages that contain lots of different options. + const buf = new utils_BufferReader(data); + // The data to read is either a TLSPlaintext or TLSCiphertext struct, + // depending on whether record protection has been enabled yet: + // + // struct { + // ContentType type; + // ProtocolVersion legacy_record_version; + // uint16 length; + // opaque fragment[TLSPlaintext.length]; + // } TLSPlaintext; + // + // struct { + // ContentType opaque_type = application_data; /* 23 */ + // ProtocolVersion legacy_record_version = 0x0303; /* TLS v1.2 */ + // uint16 length; + // opaque encrypted_record[TLSCiphertext.length]; + // } TLSCiphertext; + // + let type = buf.readUint8(); + // The spec says legacy_record_version "MUST be ignored for all purposes", + // but we know TLS1.3 implementations will only ever emit two possible values, + // so it seems useful to bail out early if we receive anything else. + const version = buf.readUint16(); + if (version !== VERSION_TLS_1_2) { + // TLS1.0 is only acceptable on initial plaintext records. + if (this._recvDecryptState !== null || version !== VERSION_TLS_1_0) { + throw new TLSError(ALERT_DESCRIPTION.DECODE_ERROR); + } + } + const length = buf.readUint16(); + let plaintext; + if (this._recvDecryptState === null || type === RECORD_TYPE.CHANGE_CIPHER_SPEC) { + [type, plaintext] = await this._readPlaintextRecord(type, length, buf); + } else { + [type, plaintext] = await this._readEncryptedRecord(type, length, buf); + } + // Sanity-check that we received exactly one record. + if (buf.hasMoreBytes()) { + throw new TLSError(ALERT_DESCRIPTION.DECODE_ERROR); + } + return [type, plaintext]; + } + + // Helper to read an unencrypted `TLSPlaintext` struct + + async _readPlaintextRecord(type, length, buf) { + if (length > MAX_RECORD_SIZE) { + throw new TLSError(ALERT_DESCRIPTION.RECORD_OVERFLOW); + } + return [type, buf.readBytes(length)]; + } + + // Helper to read an encrypted `TLSCiphertext` struct, + // decrypting it into plaintext. + + async _readEncryptedRecord(type, length, buf) { + if (length > MAX_ENCRYPTED_RECORD_SIZE) { + throw new TLSError(ALERT_DESCRIPTION.RECORD_OVERFLOW); + } + // The outer type for encrypted records is always APPLICATION_DATA. + if (type !== RECORD_TYPE.APPLICATION_DATA) { + throw new TLSError(ALERT_DESCRIPTION.DECODE_ERROR); + } + // Decrypt and decode the contained `TLSInnerPlaintext` struct: + // + // struct { + // opaque content[TLSPlaintext.length]; + // ContentType type; + // uint8 zeros[length_of_padding]; + // } TLSInnerPlaintext; + // + // The additional data for the decryption is the `TLSCiphertext` record + // header, which is a fixed size and immediately prior to current buffer position. + buf.incr(-RECORD_HEADER_SIZE); + const additionalData = buf.readBytes(RECORD_HEADER_SIZE); + const ciphertext = buf.readBytes(length); + const paddedPlaintext = await this._recvDecryptState.decrypt(ciphertext, additionalData); + // We have to scan backwards over the zero padding at the end of the struct + // in order to find the non-zero `type` byte. + let i; + for (i = paddedPlaintext.byteLength - 1; i >= 0; i--) { + if (paddedPlaintext[i] !== 0) { + break; + } + } + if (i < 0) { + throw new TLSError(ALERT_DESCRIPTION.UNEXPECTED_MESSAGE); + } + type = paddedPlaintext[i]; + // `change_cipher_spec` records must always be plaintext. + if (type === RECORD_TYPE.CHANGE_CIPHER_SPEC) { + throw new TLSError(ALERT_DESCRIPTION.DECODE_ERROR); + } + return [type, paddedPlaintext.slice(0, i)]; + } +} + +// CONCATENATED MODULE: ./src/tlsconnection.js +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// The top-level APIs offered by this module are `ClientConnection` and +// `ServerConnection` classes, which provide authenticated and encrypted +// communication via the "externally-provisioned PSK" mode of TLS1.3. +// They each take a callback to be used for sending data to the remote peer, +// and operate like this: +// +// conn = await ClientConnection.create(psk, pskId, async function send_data_to_server(data) { +// // application-specific sending logic here. +// }) +// +// // Send data to the server by calling `send`, +// // which will use the callback provided in the constructor. +// // A single `send()` by the application may result in multiple +// // invokations of the callback. +// +// await conn.send('application-level data') +// +// // When data is received from the server, push it into +// // the connection and let it return any decrypted app-level data. +// // There might not be any app-level data if it was a protocol control +// // message, and the receipt of the data might trigger additional calls +// // to the send callback for protocol control purposes. +// +// serverSocket.on('data', async encrypted_data => { +// const plaintext = await conn.recv(data) +// if (plaintext !== null) { +// do_something_with_app_level_data(plaintext) +// } +// }) +// +// // It's good practice to explicitly close the connection +// // when finished. This will send a "closed" notification +// // to the server. +// +// await conn.close() +// +// // When the peer sends a "closed" notification it will show up +// // as a `TLSCloseNotify` exception from recv: +// +// try { +// data = await conn.recv(data); +// } catch (err) { +// if (! (err instanceof TLSCloseNotify) { throw err } +// do_something_to_cleanly_close_data_connection(); +// } +// +// The `ServerConnection` API operates similarly; the distinction is mainly +// in which side is expected to send vs receieve during the protocol handshake. + + + + + + + + + + +class tlsconnection_Connection { + constructor(psk, pskId, sendCallback) { + this.psk = assertIsBytes(psk); + this.pskId = assertIsBytes(pskId); + this.connected = new Promise((resolve, reject) => { + this._onConnectionSuccess = resolve; + this._onConnectionFailure = reject; + }); + this._state = new UNINITIALIZED(this); + this._handshakeRecvBuffer = null; + this._hasSeenChangeCipherSpec = false; + this._recordlayer = new recordlayer_RecordLayer(sendCallback); + this._keyschedule = new keyschedule_KeySchedule(); + this._lastPromise = Promise.resolve(); + } + + // Subclasses will override this with some async initialization logic. + static async create(psk, pskId, sendCallback) { + return new this(psk, pskId, sendCallback); + } + + // These are the three public API methods that consumers can use + // to send and receive data encrypted with TLS1.3. + + async send(data) { + assertIsBytes(data); + await this.connected; + await this._synchronized(async () => { + await this._state.sendApplicationData(data); + }); + } + + async recv(data) { + assertIsBytes(data); + return await this._synchronized(async () => { + // Decrypt the data using the record layer. + // We expect to receive precisely one record at a time. + const [type, bytes] = await this._recordlayer.recv(data); + // Dispatch based on the type of the record. + switch (type) { + case RECORD_TYPE.CHANGE_CIPHER_SPEC: + await this._state.recvChangeCipherSpec(bytes); + return null; + case RECORD_TYPE.ALERT: + await this._state.recvAlertMessage(TLSAlert.fromBytes(bytes)); + return null; + case RECORD_TYPE.APPLICATION_DATA: + return await this._state.recvApplicationData(bytes); + case RECORD_TYPE.HANDSHAKE: + // Multiple handshake messages may be coalesced into a single record. + // Store the in-progress record buffer on `this` so that we can guard + // against handshake messages that span a change in keys. + this._handshakeRecvBuffer = new utils_BufferReader(bytes); + if (! this._handshakeRecvBuffer.hasMoreBytes()) { + throw new TLSError(ALERT_DESCRIPTION.UNEXPECTED_MESSAGE); + } + do { + // Each handshake messages has a type and length prefix, per + // https://tools.ietf.org/html/rfc8446#appendix-B.3 + this._handshakeRecvBuffer.incr(1); + const mlength = this._handshakeRecvBuffer.readUint24(); + this._handshakeRecvBuffer.incr(-4); + const messageBytes = this._handshakeRecvBuffer.readBytes(mlength + 4); + this._keyschedule.addToTranscript(messageBytes); + await this._state.recvHandshakeMessage(messages_HandshakeMessage.fromBytes(messageBytes)); + } while (this._handshakeRecvBuffer.hasMoreBytes()); + this._handshakeRecvBuffer = null; + return null; + default: + throw new TLSError(ALERT_DESCRIPTION.UNEXPECTED_MESSAGE); + } + }); + } + + async close() { + await this._synchronized(async () => { + await this._state.close(); + }); + } + + // Ensure that async functions execute one at a time, + // by waiting for the previous call to `_synchronized()` to complete + // before starting a new one. This helps ensure that we complete + // one state-machine transition before starting to do the next. + // It's also a convenient place to catch and alert on errors. + + _synchronized(cb) { + const nextPromise = this._lastPromise.then(() => { + return cb(); + }).catch(async err => { + if (err instanceof TLSCloseNotify) { + throw err; + } + await this._state.handleErrorAndRethrow(err); + }); + // We don't want to hold on to the return value or error, + // just synchronize on the fact that it completed. + this._lastPromise = nextPromise.then(noop, noop); + return nextPromise; + } + + // This drives internal transition of the state-machine, + // ensuring that the new state is properly initialized. + + async _transition(State, ...args) { + this._state = new State(this); + await this._state.initialize(...args); + await this._recordlayer.flush(); + } + + // These are helpers to allow the State to manipulate the recordlayer + // and send out various types of data. + + async _sendApplicationData(bytes) { + await this._recordlayer.send(RECORD_TYPE.APPLICATION_DATA, bytes); + await this._recordlayer.flush(); + } + + async _sendHandshakeMessage(msg) { + await this._sendHandshakeMessageBytes(msg.toBytes()); + } + + async _sendHandshakeMessageBytes(bytes) { + this._keyschedule.addToTranscript(bytes); + await this._recordlayer.send(RECORD_TYPE.HANDSHAKE, bytes); + // Don't flush after each handshake message, since we can probably + // coalesce multiple messages into a single record. + } + + async _sendAlertMessage(err) { + await this._recordlayer.send(RECORD_TYPE.ALERT, err.toBytes()); + await this._recordlayer.flush(); + } + + async _sendChangeCipherSpec() { + await this._recordlayer.send(RECORD_TYPE.CHANGE_CIPHER_SPEC, new Uint8Array([0x01])); + await this._recordlayer.flush(); + } + + async _setSendKey(key) { + return await this._recordlayer.setSendKey(key); + } + + async _setRecvKey(key) { + // Handshake messages that change keys must be on a record boundary. + if (this._handshakeRecvBuffer && this._handshakeRecvBuffer.hasMoreBytes()) { + throw new TLSError(ALERT_DESCRIPTION.UNEXPECTED_MESSAGE); + } + return await this._recordlayer.setRecvKey(key); + } + + _setConnectionSuccess() { + if (this._onConnectionSuccess !== null) { + this._onConnectionSuccess(); + this._onConnectionSuccess = null; + this._onConnectionFailure = null; + } + } + + _setConnectionFailure(err) { + if (this._onConnectionFailure !== null) { + this._onConnectionFailure(err); + this._onConnectionSuccess = null; + this._onConnectionFailure = null; + } + } + + _closeForSend(alert) { + this._recordlayer.setSendError(alert); + } + + _closeForRecv(alert) { + this._recordlayer.setRecvError(alert); + } +} + +class tlsconnection_ClientConnection extends tlsconnection_Connection { + static async create(psk, pskId, sendCallback) { + const instance = await super.create(psk, pskId, sendCallback); + await instance._transition(states_CLIENT_START); + return instance; + } +} + +class tlsconnection_ServerConnection extends tlsconnection_Connection { + static async create(psk, pskId, sendCallback) { + const instance = await super.create(psk, pskId, sendCallback); + await instance._transition(states_SERVER_START); + return instance; + } +} + +// CONCATENATED MODULE: ./node_modules/event-target-shim/dist/event-target-shim.mjs +/** + * @author Toru Nagashima <https://github.com/mysticatea> + * @copyright 2015 Toru Nagashima. All rights reserved. + * See LICENSE file in root directory for full license. + */ +/** + * @typedef {object} PrivateData + * @property {EventTarget} eventTarget The event target. + * @property {{type:string}} event The original event object. + * @property {number} eventPhase The current event phase. + * @property {EventTarget|null} currentTarget The current event target. + * @property {boolean} canceled The flag to prevent default. + * @property {boolean} stopped The flag to stop propagation. + * @property {boolean} immediateStopped The flag to stop propagation immediately. + * @property {Function|null} passiveListener The listener if the current listener is passive. Otherwise this is null. + * @property {number} timeStamp The unix time. + * @private + */ + +/** + * Private data for event wrappers. + * @type {WeakMap<Event, PrivateData>} + * @private + */ +const privateData = new WeakMap(); + +/** + * Cache for wrapper classes. + * @type {WeakMap<Object, Function>} + * @private + */ +const wrappers = new WeakMap(); + +/** + * Get private data. + * @param {Event} event The event object to get private data. + * @returns {PrivateData} The private data of the event. + * @private + */ +function pd(event) { + const retv = privateData.get(event); + console.assert( + retv != null, + "'this' is expected an Event object, but got", + event + ); + return retv +} + +/** + * https://dom.spec.whatwg.org/#set-the-canceled-flag + * @param data {PrivateData} private data. + */ +function setCancelFlag(data) { + if (data.passiveListener != null) { + if ( + typeof console !== "undefined" && + typeof console.error === "function" + ) { + console.error( + "Unable to preventDefault inside passive event listener invocation.", + data.passiveListener + ); + } + return + } + if (!data.event.cancelable) { + return + } + + data.canceled = true; + if (typeof data.event.preventDefault === "function") { + data.event.preventDefault(); + } +} + +/** + * @see https://dom.spec.whatwg.org/#interface-event + * @private + */ +/** + * The event wrapper. + * @constructor + * @param {EventTarget} eventTarget The event target of this dispatching. + * @param {Event|{type:string}} event The original event to wrap. + */ +function Event(eventTarget, event) { + privateData.set(this, { + eventTarget, + event, + eventPhase: 2, + currentTarget: eventTarget, + canceled: false, + stopped: false, + immediateStopped: false, + passiveListener: null, + timeStamp: event.timeStamp || Date.now(), + }); + + // https://heycam.github.io/webidl/#Unforgeable + Object.defineProperty(this, "isTrusted", { value: false, enumerable: true }); + + // Define accessors + const keys = Object.keys(event); + for (let i = 0; i < keys.length; ++i) { + const key = keys[i]; + if (!(key in this)) { + Object.defineProperty(this, key, defineRedirectDescriptor(key)); + } + } +} + +// Should be enumerable, but class methods are not enumerable. +Event.prototype = { + /** + * The type of this event. + * @type {string} + */ + get type() { + return pd(this).event.type + }, + + /** + * The target of this event. + * @type {EventTarget} + */ + get target() { + return pd(this).eventTarget + }, + + /** + * The target of this event. + * @type {EventTarget} + */ + get currentTarget() { + return pd(this).currentTarget + }, + + /** + * @returns {EventTarget[]} The composed path of this event. + */ + composedPath() { + const currentTarget = pd(this).currentTarget; + if (currentTarget == null) { + return [] + } + return [currentTarget] + }, + + /** + * Constant of NONE. + * @type {number} + */ + get NONE() { + return 0 + }, + + /** + * Constant of CAPTURING_PHASE. + * @type {number} + */ + get CAPTURING_PHASE() { + return 1 + }, + + /** + * Constant of AT_TARGET. + * @type {number} + */ + get AT_TARGET() { + return 2 + }, + + /** + * Constant of BUBBLING_PHASE. + * @type {number} + */ + get BUBBLING_PHASE() { + return 3 + }, + + /** + * The target of this event. + * @type {number} + */ + get eventPhase() { + return pd(this).eventPhase + }, + + /** + * Stop event bubbling. + * @returns {void} + */ + stopPropagation() { + const data = pd(this); + + data.stopped = true; + if (typeof data.event.stopPropagation === "function") { + data.event.stopPropagation(); + } + }, + + /** + * Stop event bubbling. + * @returns {void} + */ + stopImmediatePropagation() { + const data = pd(this); + + data.stopped = true; + data.immediateStopped = true; + if (typeof data.event.stopImmediatePropagation === "function") { + data.event.stopImmediatePropagation(); + } + }, + + /** + * The flag to be bubbling. + * @type {boolean} + */ + get bubbles() { + return Boolean(pd(this).event.bubbles) + }, + + /** + * The flag to be cancelable. + * @type {boolean} + */ + get cancelable() { + return Boolean(pd(this).event.cancelable) + }, + + /** + * Cancel this event. + * @returns {void} + */ + preventDefault() { + setCancelFlag(pd(this)); + }, + + /** + * The flag to indicate cancellation state. + * @type {boolean} + */ + get defaultPrevented() { + return pd(this).canceled + }, + + /** + * The flag to be composed. + * @type {boolean} + */ + get composed() { + return Boolean(pd(this).event.composed) + }, + + /** + * The unix time of this event. + * @type {number} + */ + get timeStamp() { + return pd(this).timeStamp + }, + + /** + * The target of this event. + * @type {EventTarget} + * @deprecated + */ + get srcElement() { + return pd(this).eventTarget + }, + + /** + * The flag to stop event bubbling. + * @type {boolean} + * @deprecated + */ + get cancelBubble() { + return pd(this).stopped + }, + set cancelBubble(value) { + if (!value) { + return + } + const data = pd(this); + + data.stopped = true; + if (typeof data.event.cancelBubble === "boolean") { + data.event.cancelBubble = true; + } + }, + + /** + * The flag to indicate cancellation state. + * @type {boolean} + * @deprecated + */ + get returnValue() { + return !pd(this).canceled + }, + set returnValue(value) { + if (!value) { + setCancelFlag(pd(this)); + } + }, + + /** + * Initialize this event object. But do nothing under event dispatching. + * @param {string} type The event type. + * @param {boolean} [bubbles=false] The flag to be possible to bubble up. + * @param {boolean} [cancelable=false] The flag to be possible to cancel. + * @deprecated + */ + initEvent() { + // Do nothing. + }, +}; + +// `constructor` is not enumerable. +Object.defineProperty(Event.prototype, "constructor", { + value: Event, + configurable: true, + writable: true, +}); + +// Ensure `event instanceof window.Event` is `true`. +if (typeof window !== "undefined" && typeof window.Event !== "undefined") { + Object.setPrototypeOf(Event.prototype, window.Event.prototype); + + // Make association for wrappers. + wrappers.set(window.Event.prototype, Event); +} + +/** + * Get the property descriptor to redirect a given property. + * @param {string} key Property name to define property descriptor. + * @returns {PropertyDescriptor} The property descriptor to redirect the property. + * @private + */ +function defineRedirectDescriptor(key) { + return { + get() { + return pd(this).event[key] + }, + set(value) { + pd(this).event[key] = value; + }, + configurable: true, + enumerable: true, + } +} + +/** + * Get the property descriptor to call a given method property. + * @param {string} key Property name to define property descriptor. + * @returns {PropertyDescriptor} The property descriptor to call the method property. + * @private + */ +function defineCallDescriptor(key) { + return { + value() { + const event = pd(this).event; + return event[key].apply(event, arguments) + }, + configurable: true, + enumerable: true, + } +} + +/** + * Define new wrapper class. + * @param {Function} BaseEvent The base wrapper class. + * @param {Object} proto The prototype of the original event. + * @returns {Function} The defined wrapper class. + * @private + */ +function defineWrapper(BaseEvent, proto) { + const keys = Object.keys(proto); + if (keys.length === 0) { + return BaseEvent + } + + /** CustomEvent */ + function CustomEvent(eventTarget, event) { + BaseEvent.call(this, eventTarget, event); + } + + CustomEvent.prototype = Object.create(BaseEvent.prototype, { + constructor: { value: CustomEvent, configurable: true, writable: true }, + }); + + // Define accessors. + for (let i = 0; i < keys.length; ++i) { + const key = keys[i]; + if (!(key in BaseEvent.prototype)) { + const descriptor = Object.getOwnPropertyDescriptor(proto, key); + const isFunc = typeof descriptor.value === "function"; + Object.defineProperty( + CustomEvent.prototype, + key, + isFunc + ? defineCallDescriptor(key) + : defineRedirectDescriptor(key) + ); + } + } + + return CustomEvent +} + +/** + * Get the wrapper class of a given prototype. + * @param {Object} proto The prototype of the original event to get its wrapper. + * @returns {Function} The wrapper class. + * @private + */ +function getWrapper(proto) { + if (proto == null || proto === Object.prototype) { + return Event + } + + let wrapper = wrappers.get(proto); + if (wrapper == null) { + wrapper = defineWrapper(getWrapper(Object.getPrototypeOf(proto)), proto); + wrappers.set(proto, wrapper); + } + return wrapper +} + +/** + * Wrap a given event to management a dispatching. + * @param {EventTarget} eventTarget The event target of this dispatching. + * @param {Object} event The event to wrap. + * @returns {Event} The wrapper instance. + * @private + */ +function wrapEvent(eventTarget, event) { + const Wrapper = getWrapper(Object.getPrototypeOf(event)); + return new Wrapper(eventTarget, event) +} + +/** + * Get the immediateStopped flag of a given event. + * @param {Event} event The event to get. + * @returns {boolean} The flag to stop propagation immediately. + * @private + */ +function isStopped(event) { + return pd(event).immediateStopped +} + +/** + * Set the current event phase of a given event. + * @param {Event} event The event to set current target. + * @param {number} eventPhase New event phase. + * @returns {void} + * @private + */ +function setEventPhase(event, eventPhase) { + pd(event).eventPhase = eventPhase; +} + +/** + * Set the current target of a given event. + * @param {Event} event The event to set current target. + * @param {EventTarget|null} currentTarget New current target. + * @returns {void} + * @private + */ +function setCurrentTarget(event, currentTarget) { + pd(event).currentTarget = currentTarget; +} + +/** + * Set a passive listener of a given event. + * @param {Event} event The event to set current target. + * @param {Function|null} passiveListener New passive listener. + * @returns {void} + * @private + */ +function setPassiveListener(event, passiveListener) { + pd(event).passiveListener = passiveListener; +} + +/** + * @typedef {object} ListenerNode + * @property {Function} listener + * @property {1|2|3} listenerType + * @property {boolean} passive + * @property {boolean} once + * @property {ListenerNode|null} next + * @private + */ + +/** + * @type {WeakMap<object, Map<string, ListenerNode>>} + * @private + */ +const listenersMap = new WeakMap(); + +// Listener types +const CAPTURE = 1; +const BUBBLE = 2; +const ATTRIBUTE = 3; + +/** + * Check whether a given value is an object or not. + * @param {any} x The value to check. + * @returns {boolean} `true` if the value is an object. + */ +function isObject(x) { + return x !== null && typeof x === "object" //eslint-disable-line no-restricted-syntax +} + +/** + * Get listeners. + * @param {EventTarget} eventTarget The event target to get. + * @returns {Map<string, ListenerNode>} The listeners. + * @private + */ +function getListeners(eventTarget) { + const listeners = listenersMap.get(eventTarget); + if (listeners == null) { + throw new TypeError( + "'this' is expected an EventTarget object, but got another value." + ) + } + return listeners +} + +/** + * Get the property descriptor for the event attribute of a given event. + * @param {string} eventName The event name to get property descriptor. + * @returns {PropertyDescriptor} The property descriptor. + * @private + */ +function defineEventAttributeDescriptor(eventName) { + return { + get() { + const listeners = getListeners(this); + let node = listeners.get(eventName); + while (node != null) { + if (node.listenerType === ATTRIBUTE) { + return node.listener + } + node = node.next; + } + return null + }, + + set(listener) { + if (typeof listener !== "function" && !isObject(listener)) { + listener = null; // eslint-disable-line no-param-reassign + } + const listeners = getListeners(this); + + // Traverse to the tail while removing old value. + let prev = null; + let node = listeners.get(eventName); + while (node != null) { + if (node.listenerType === ATTRIBUTE) { + // Remove old value. + if (prev !== null) { + prev.next = node.next; + } else if (node.next !== null) { + listeners.set(eventName, node.next); + } else { + listeners.delete(eventName); + } + } else { + prev = node; + } + + node = node.next; + } + + // Add new value. + if (listener !== null) { + const newNode = { + listener, + listenerType: ATTRIBUTE, + passive: false, + once: false, + next: null, + }; + if (prev === null) { + listeners.set(eventName, newNode); + } else { + prev.next = newNode; + } + } + }, + configurable: true, + enumerable: true, + } +} + +/** + * Define an event attribute (e.g. `eventTarget.onclick`). + * @param {Object} eventTargetPrototype The event target prototype to define an event attrbite. + * @param {string} eventName The event name to define. + * @returns {void} + */ +function defineEventAttribute(eventTargetPrototype, eventName) { + Object.defineProperty( + eventTargetPrototype, + `on${eventName}`, + defineEventAttributeDescriptor(eventName) + ); +} + +/** + * Define a custom EventTarget with event attributes. + * @param {string[]} eventNames Event names for event attributes. + * @returns {EventTarget} The custom EventTarget. + * @private + */ +function defineCustomEventTarget(eventNames) { + /** CustomEventTarget */ + function CustomEventTarget() { + EventTarget.call(this); + } + + CustomEventTarget.prototype = Object.create(EventTarget.prototype, { + constructor: { + value: CustomEventTarget, + configurable: true, + writable: true, + }, + }); + + for (let i = 0; i < eventNames.length; ++i) { + defineEventAttribute(CustomEventTarget.prototype, eventNames[i]); + } + + return CustomEventTarget +} + +/** + * EventTarget. + * + * - This is constructor if no arguments. + * - This is a function which returns a CustomEventTarget constructor if there are arguments. + * + * For example: + * + * class A extends EventTarget {} + * class B extends EventTarget("message") {} + * class C extends EventTarget("message", "error") {} + * class D extends EventTarget(["message", "error"]) {} + */ +function EventTarget() { + /*eslint-disable consistent-return */ + if (this instanceof EventTarget) { + listenersMap.set(this, new Map()); + return + } + if (arguments.length === 1 && Array.isArray(arguments[0])) { + return defineCustomEventTarget(arguments[0]) + } + if (arguments.length > 0) { + const types = new Array(arguments.length); + for (let i = 0; i < arguments.length; ++i) { + types[i] = arguments[i]; + } + return defineCustomEventTarget(types) + } + throw new TypeError("Cannot call a class as a function") + /*eslint-enable consistent-return */ +} + +// Should be enumerable, but class methods are not enumerable. +EventTarget.prototype = { + /** + * Add a given listener to this event target. + * @param {string} eventName The event name to add. + * @param {Function} listener The listener to add. + * @param {boolean|{capture?:boolean,passive?:boolean,once?:boolean}} [options] The options for this listener. + * @returns {void} + */ + addEventListener(eventName, listener, options) { + if (listener == null) { + return + } + if (typeof listener !== "function" && !isObject(listener)) { + throw new TypeError("'listener' should be a function or an object.") + } + + const listeners = getListeners(this); + const optionsIsObj = isObject(options); + const capture = optionsIsObj + ? Boolean(options.capture) + : Boolean(options); + const listenerType = capture ? CAPTURE : BUBBLE; + const newNode = { + listener, + listenerType, + passive: optionsIsObj && Boolean(options.passive), + once: optionsIsObj && Boolean(options.once), + next: null, + }; + + // Set it as the first node if the first node is null. + let node = listeners.get(eventName); + if (node === undefined) { + listeners.set(eventName, newNode); + return + } + + // Traverse to the tail while checking duplication.. + let prev = null; + while (node != null) { + if ( + node.listener === listener && + node.listenerType === listenerType + ) { + // Should ignore duplication. + return + } + prev = node; + node = node.next; + } + + // Add it. + prev.next = newNode; + }, + + /** + * Remove a given listener from this event target. + * @param {string} eventName The event name to remove. + * @param {Function} listener The listener to remove. + * @param {boolean|{capture?:boolean,passive?:boolean,once?:boolean}} [options] The options for this listener. + * @returns {void} + */ + removeEventListener(eventName, listener, options) { + if (listener == null) { + return + } + + const listeners = getListeners(this); + const capture = isObject(options) + ? Boolean(options.capture) + : Boolean(options); + const listenerType = capture ? CAPTURE : BUBBLE; + + let prev = null; + let node = listeners.get(eventName); + while (node != null) { + if ( + node.listener === listener && + node.listenerType === listenerType + ) { + if (prev !== null) { + prev.next = node.next; + } else if (node.next !== null) { + listeners.set(eventName, node.next); + } else { + listeners.delete(eventName); + } + return + } + + prev = node; + node = node.next; + } + }, + + /** + * Dispatch a given event. + * @param {Event|{type:string}} event The event to dispatch. + * @returns {boolean} `false` if canceled. + */ + dispatchEvent(event) { + if (event == null || typeof event.type !== "string") { + throw new TypeError('"event.type" should be a string.') + } + + // If listeners aren't registered, terminate. + const listeners = getListeners(this); + const eventName = event.type; + let node = listeners.get(eventName); + if (node == null) { + return true + } + + // Since we cannot rewrite several properties, so wrap object. + const wrappedEvent = wrapEvent(this, event); + + // This doesn't process capturing phase and bubbling phase. + // This isn't participating in a tree. + let prev = null; + while (node != null) { + // Remove this listener if it's once + if (node.once) { + if (prev !== null) { + prev.next = node.next; + } else if (node.next !== null) { + listeners.set(eventName, node.next); + } else { + listeners.delete(eventName); + } + } else { + prev = node; + } + + // Call this listener + setPassiveListener( + wrappedEvent, + node.passive ? node.listener : null + ); + if (typeof node.listener === "function") { + try { + node.listener.call(this, wrappedEvent); + } catch (err) { + if ( + typeof console !== "undefined" && + typeof console.error === "function" + ) { + console.error(err); + } + } + } else if ( + node.listenerType !== ATTRIBUTE && + typeof node.listener.handleEvent === "function" + ) { + node.listener.handleEvent(wrappedEvent); + } + + // Break if `event.stopImmediatePropagation` was called. + if (isStopped(wrappedEvent)) { + break + } + + node = node.next; + } + setPassiveListener(wrappedEvent, null); + setEventPhase(wrappedEvent, 0); + setCurrentTarget(wrappedEvent, null); + + return !wrappedEvent.defaultPrevented + }, +}; + +// `constructor` is not enumerable. +Object.defineProperty(EventTarget.prototype, "constructor", { + value: EventTarget, + configurable: true, + writable: true, +}); + +// Ensure `eventTarget instanceof window.EventTarget` is `true`. +if ( + typeof window !== "undefined" && + typeof window.EventTarget !== "undefined" +) { + Object.setPrototypeOf(EventTarget.prototype, window.EventTarget.prototype); +} + +/* harmony default export */ var event_target_shim = (EventTarget); + + +// CONCATENATED MODULE: ./src/index.js +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// A wrapper that combines a WebSocket to the channelserver +// with some client-side encryption for securing the channel. +// +// This code is responsible for the event handling and the consumer API. +// All the details of encrypting the messages are delegated to`./tlsconnection.js`. + + + + + + + +const CLOSE_FLUSH_BUFFER_INTERVAL_MS = 200; +const CLOSE_FLUSH_BUFFER_MAX_TRIES = 5; + +class src_PairingChannel extends EventTarget { + constructor(channelId, channelKey, socket, connection) { + super(); + this._channelId = channelId; + this._channelKey = channelKey; + this._socket = socket; + this._connection = connection; + this._selfClosed = false; + this._peerClosed = false; + this._setupListeners(); + } + + /** + * Create a new pairing channel. + * + * This will open a channel on the channelserver, and generate a random client-side + * encryption key. When the promise resolves, `this.channelId` and `this.channelKey` + * can be transferred to another client to allow it to securely connect to the channel. + * + * @returns Promise<PairingChannel> + */ + static create(channelServerURI) { + const wsURI = new URL('/v1/ws/', channelServerURI).href; + const channelKey = crypto.getRandomValues(new Uint8Array(32)); + // The one who creates the channel plays the role of 'server' in the underlying TLS exchange. + return this._makePairingChannel(wsURI, tlsconnection_ServerConnection, channelKey); + } + + /** + * Connect to an existing pairing channel. + * + * This will connect to a channel on the channelserver previously established by + * another client calling `create`. The `channelId` and `channelKey` must have been + * obtained via some out-of-band mechanism (such as by scanning from a QR code). + * + * @returns Promise<PairingChannel> + */ + static connect(channelServerURI, channelId, channelKey) { + const wsURI = new URL(`/v1/ws/${channelId}`, channelServerURI).href; + // The one who connects to an existing channel plays the role of 'client' + // in the underlying TLS exchange. + return this._makePairingChannel(wsURI, tlsconnection_ClientConnection, channelKey); + } + + static _makePairingChannel(wsUri, ConnectionClass, psk) { + const socket = new WebSocket(wsUri); + return new Promise((resolve, reject) => { + // eslint-disable-next-line prefer-const + let stopListening; + const onConnectionError = async () => { + stopListening(); + reject(new Error('Error while creating the pairing channel')); + }; + const onFirstMessage = async event => { + stopListening(); + try { + // The channelserver echos back the channel id, and we use it as an + // additional input to the TLS handshake via the "psk id" field. + const {channelid: channelId} = JSON.parse(event.data); + const pskId = utf8ToBytes(channelId); + const connection = await ConnectionClass.create(psk, pskId, data => { + // Send data by forwarding it via the channelserver websocket. + // The TLS connection gives us `data` as raw bytes, but channelserver + // expects b64urlsafe strings, because it wraps them in a JSON object envelope. + socket.send(bytesToBase64url(data)); + }); + const instance = new this(channelId, psk, socket, connection); + resolve(instance); + } catch (err) { + reject(err); + } + }; + stopListening = () => { + socket.removeEventListener('close', onConnectionError); + socket.removeEventListener('error', onConnectionError); + socket.removeEventListener('message', onFirstMessage); + }; + socket.addEventListener('close', onConnectionError); + socket.addEventListener('error', onConnectionError); + socket.addEventListener('message', onFirstMessage); + }); + } + + _setupListeners() { + this._socket.addEventListener('message', async event => { + try { + // When we receive data from the channelserver, pump it through the TLS connection + // to decrypt it, then echo it back out to consumers as an event. + const channelServerEnvelope = JSON.parse(event.data); + const payload = await this._connection.recv(base64urlToBytes(channelServerEnvelope.message)); + if (payload !== null) { + const data = JSON.parse(bytesToUtf8(payload)); + this.dispatchEvent(new CustomEvent('message', { + detail: { + data, + sender: channelServerEnvelope.sender, + }, + })); + } + } catch (error) { + let event; + // The underlying TLS connection will signal a clean shutdown of the channel + // by throwing a special error, because it doesn't really have a better + // signally mechanism available. + if (error instanceof TLSCloseNotify) { + this._peerClosed = true; + if (this._selfClosed) { + this._shutdown(); + } + event = new CustomEvent('close'); + } else { + event = new CustomEvent('error', { + detail: { + error, + } + }); + } + this.dispatchEvent(event); + } + }); + // Relay the WebSocket events. + this._socket.addEventListener('error', () => { + this._shutdown(); + // The dispatched event that we receive has no useful information. + this.dispatchEvent(new CustomEvent('error', { + detail: { + error: new Error('WebSocket error.'), + }, + })); + }); + // In TLS, the peer has to explicitly send a close notification, + // which we dispatch above. Unexpected socket close is an error. + this._socket.addEventListener('close', () => { + this._shutdown(); + if (! this._peerClosed) { + this.dispatchEvent(new CustomEvent('error', { + detail: { + error: new Error('WebSocket unexpectedly closed'), + } + })); + } + }); + } + + /** + * @param {Object} data + */ + async send(data) { + const payload = utf8ToBytes(JSON.stringify(data)); + await this._connection.send(payload); + } + + async close() { + this._selfClosed = true; + await this._connection.close(); + try { + // Ensure all queued bytes have been sent before closing the connection. + let tries = 0; + while (this._socket.bufferedAmount > 0) { + if (++tries > CLOSE_FLUSH_BUFFER_MAX_TRIES) { + throw new Error('Could not flush the outgoing buffer in time.'); + } + await new Promise(res => setTimeout(res, CLOSE_FLUSH_BUFFER_INTERVAL_MS)); + } + } finally { + // If the peer hasn't closed, we might still receive some data. + if (this._peerClosed) { + this._shutdown(); + } + } + } + + _shutdown() { + if (this._socket) { + this._socket.close(); + this._socket = null; + this._connection = null; + } + } + + get closed() { + return (! this._socket) || (this._socket.readyState === 3); + } + + get channelId() { + return this._channelId; + } + + get channelKey() { + return this._channelKey; + } +} + +// Re-export helpful utilities for calling code to use. + + +// For running tests using the built bundle, +// expose a bunch of implementation details. + + + + + + + +const _internals = { + arrayToBytes: arrayToBytes, + BufferReader: utils_BufferReader, + BufferWriter: utils_BufferWriter, + bytesAreEqual: bytesAreEqual, + bytesToHex: bytesToHex, + bytesToUtf8: bytesToUtf8, + ClientConnection: tlsconnection_ClientConnection, + Connection: tlsconnection_Connection, + DecryptionState: recordlayer_DecryptionState, + EncryptedExtensions: EncryptedExtensions, + EncryptionState: recordlayer_EncryptionState, + Finished: messages_Finished, + HASH_LENGTH: HASH_LENGTH, + hexToBytes: hexToBytes, + hkdfExpand: hkdfExpand, + KeySchedule: keyschedule_KeySchedule, + NewSessionTicket: messages_NewSessionTicket, + RecordLayer: recordlayer_RecordLayer, + ServerConnection: tlsconnection_ServerConnection, + utf8ToBytes: utf8ToBytes, + zeros: zeros, +}; + + +/***/ }) +/******/ ])["PairingChannel"]; diff --git a/services/fxaccounts/FxAccountsProfile.sys.mjs b/services/fxaccounts/FxAccountsProfile.sys.mjs new file mode 100644 index 0000000000..de8bdb2f0e --- /dev/null +++ b/services/fxaccounts/FxAccountsProfile.sys.mjs @@ -0,0 +1,193 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Firefox Accounts Profile helper. + * + * This class abstracts interaction with the profile server for an account. + * It will handle things like fetching profile data, listening for updates to + * the user's profile in open browser tabs, and cacheing/invalidating profile data. + */ + +import { + ON_PROFILE_CHANGE_NOTIFICATION, + log, +} from "resource://gre/modules/FxAccountsCommon.sys.mjs"; + +import { getFxAccountsSingleton } from "resource://gre/modules/FxAccounts.sys.mjs"; + +const fxAccounts = getFxAccountsSingleton(); + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + FxAccountsProfileClient: + "resource://gre/modules/FxAccountsProfileClient.sys.mjs", +}); + +export var FxAccountsProfile = function (options = {}) { + this._currentFetchPromise = null; + this._cachedAt = 0; // when we saved the cached version. + this._isNotifying = false; // are we sending a notification? + this.fxai = options.fxai || fxAccounts._internal; + this.client = + options.profileClient || + new lazy.FxAccountsProfileClient({ + fxai: this.fxai, + serverURL: options.profileServerUrl, + }); + + // An observer to invalidate our _cachedAt optimization. We use a weak-ref + // just incase this.tearDown isn't called in some cases. + Services.obs.addObserver(this, ON_PROFILE_CHANGE_NOTIFICATION, true); + // for testing + if (options.channel) { + this.channel = options.channel; + } +}; + +FxAccountsProfile.prototype = { + // If we get subsequent requests for a profile within this period, don't bother + // making another request to determine if it is fresh or not. + PROFILE_FRESHNESS_THRESHOLD: 120000, // 2 minutes + + observe(subject, topic, data) { + // If we get a profile change notification from our webchannel it means + // the user has just changed their profile via the web, so we want to + // ignore our "freshness threshold" + if (topic == ON_PROFILE_CHANGE_NOTIFICATION && !this._isNotifying) { + log.debug("FxAccountsProfile observed profile change"); + this._cachedAt = 0; + } + }, + + tearDown() { + this.fxai = null; + this.client = null; + Services.obs.removeObserver(this, ON_PROFILE_CHANGE_NOTIFICATION); + }, + + _notifyProfileChange(uid) { + this._isNotifying = true; + Services.obs.notifyObservers(null, ON_PROFILE_CHANGE_NOTIFICATION, uid); + this._isNotifying = false; + }, + + // Cache fetched data and send out a notification so that UI can update. + _cacheProfile(response) { + return this.fxai.withCurrentAccountState(async state => { + const profile = response.body; + const userData = await state.getUserAccountData(); + if (profile.uid != userData.uid) { + throw new Error( + "The fetched profile does not correspond with the current account." + ); + } + let profileCache = { + profile, + etag: response.etag, + }; + await state.updateUserAccountData({ profileCache }); + if (profile.email != userData.email) { + await this.fxai._handleEmailUpdated(profile.email); + } + log.debug("notifying profile changed for user ${uid}", userData); + this._notifyProfileChange(userData.uid); + return profile; + }); + }, + + async _getProfileCache() { + let data = await this.fxai.currentAccountState.getUserAccountData([ + "profileCache", + ]); + return data ? data.profileCache : null; + }, + + async _fetchAndCacheProfileInternal() { + try { + const profileCache = await this._getProfileCache(); + const etag = profileCache ? profileCache.etag : null; + let response; + try { + response = await this.client.fetchProfile(etag); + } catch (err) { + await this.fxai._handleTokenError(err); + // _handleTokenError always re-throws. + throw new Error("not reached!"); + } + + // response may be null if the profile was not modified (same ETag). + if (!response) { + return null; + } + return await this._cacheProfile(response); + } finally { + this._cachedAt = Date.now(); + this._currentFetchPromise = null; + } + }, + + _fetchAndCacheProfile() { + if (!this._currentFetchPromise) { + this._currentFetchPromise = this._fetchAndCacheProfileInternal(); + } + return this._currentFetchPromise; + }, + + // Returns cached data right away if available, otherwise returns null - if + // it returns null, or if the profile is possibly stale, it attempts to + // fetch the latest profile data in the background. After data is fetched a + // notification will be sent out if the profile has changed. + async getProfile() { + const profileCache = await this._getProfileCache(); + if (!profileCache) { + // fetch and cache it in the background. + this._fetchAndCacheProfile().catch(err => { + log.error("Background refresh of initial profile failed", err); + }); + return null; + } + if (Date.now() > this._cachedAt + this.PROFILE_FRESHNESS_THRESHOLD) { + // Note that _fetchAndCacheProfile isn't returned, so continues + // in the background. + this._fetchAndCacheProfile().catch(err => { + log.error("Background refresh of profile failed", err); + }); + } else { + log.trace("not checking freshness of profile as it remains recent"); + } + return profileCache.profile; + }, + + // Get the user's profile data, fetching from the network if necessary. + // Most callers should instead use `getProfile()`; this methods exists to support + // callers who need to await the underlying network request. + async ensureProfile({ staleOk = false, forceFresh = false } = {}) { + if (staleOk && forceFresh) { + throw new Error("contradictory options specified"); + } + const profileCache = await this._getProfileCache(); + if ( + forceFresh || + !profileCache || + (Date.now() > this._cachedAt + this.PROFILE_FRESHNESS_THRESHOLD && + !staleOk) + ) { + const profile = await this._fetchAndCacheProfile().catch(err => { + log.error("Background refresh of profile failed", err); + }); + if (profile) { + return profile; + } + } + log.trace("not checking freshness of profile as it remains recent"); + return profileCache ? profileCache.profile : null; + }, + + QueryInterface: ChromeUtils.generateQI([ + "nsIObserver", + "nsISupportsWeakReference", + ]), +}; diff --git a/services/fxaccounts/FxAccountsProfileClient.sys.mjs b/services/fxaccounts/FxAccountsProfileClient.sys.mjs new file mode 100644 index 0000000000..7ae1bd95db --- /dev/null +++ b/services/fxaccounts/FxAccountsProfileClient.sys.mjs @@ -0,0 +1,273 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * A client to fetch profile information for a Firefox Account. + */ +"use strict;"; + +import { + ERRNO_NETWORK, + ERRNO_PARSE, + ERRNO_UNKNOWN_ERROR, + ERROR_CODE_METHOD_NOT_ALLOWED, + ERROR_MSG_METHOD_NOT_ALLOWED, + ERROR_NETWORK, + ERROR_PARSE, + ERROR_UNKNOWN, + log, + SCOPE_PROFILE, + SCOPE_PROFILE_WRITE, +} from "resource://gre/modules/FxAccountsCommon.sys.mjs"; + +import { getFxAccountsSingleton } from "resource://gre/modules/FxAccounts.sys.mjs"; + +const fxAccounts = getFxAccountsSingleton(); +import { RESTRequest } from "resource://services-common/rest.sys.mjs"; + +/** + * Create a new FxAccountsProfileClient to be able to fetch Firefox Account profile information. + * + * @param {Object} options Options + * @param {String} options.serverURL + * The URL of the profile server to query. + * Example: https://profile.accounts.firefox.com/v1 + * @param {String} options.token + * The bearer token to access the profile server + * @constructor + */ +export var FxAccountsProfileClient = function (options) { + if (!options || !options.serverURL) { + throw new Error("Missing 'serverURL' configuration option"); + } + + this.fxai = options.fxai || fxAccounts._internal; + + try { + this.serverURL = new URL(options.serverURL); + } catch (e) { + throw new Error("Invalid 'serverURL'"); + } + log.debug("FxAccountsProfileClient: Initialized"); +}; + +FxAccountsProfileClient.prototype = { + /** + * {nsIURI} + * The server to fetch profile information from. + */ + serverURL: null, + + /** + * Interface for making remote requests. + */ + _Request: RESTRequest, + + /** + * Remote request helper which abstracts authentication away. + * + * @param {String} path + * Profile server path, i.e "/profile". + * @param {String} [method] + * Type of request, e.g. "GET". + * @param {String} [etag] + * Optional ETag used for caching purposes. + * @param {Object} [body] + * Optional request body, to be sent as application/json. + * @return Promise + * Resolves: {body: Object, etag: Object} Successful response from the Profile server. + * Rejects: {FxAccountsProfileClientError} Profile client error. + * @private + */ + async _createRequest(path, method = "GET", etag = null, body = null) { + method = method.toUpperCase(); + let token = await this._getTokenForRequest(method); + try { + return await this._rawRequest(path, method, token, etag, body); + } catch (ex) { + if (!(ex instanceof FxAccountsProfileClientError) || ex.code != 401) { + throw ex; + } + // it's an auth error - assume our token expired and retry. + log.info( + "Fetching the profile returned a 401 - revoking our token and retrying" + ); + await this.fxai.removeCachedOAuthToken({ token }); + token = await this._getTokenForRequest(method); + // and try with the new token - if that also fails then we fail after + // revoking the token. + try { + return await this._rawRequest(path, method, token, etag, body); + } catch (ex) { + if (!(ex instanceof FxAccountsProfileClientError) || ex.code != 401) { + throw ex; + } + log.info( + "Retry fetching the profile still returned a 401 - revoking our token and failing" + ); + await this.fxai.removeCachedOAuthToken({ token }); + throw ex; + } + } + }, + + /** + * Helper to get an OAuth token for a request. + * + * OAuth tokens are cached, so it's fine to call this for each request. + * + * @param {String} [method] + * Type of request, i.e "GET". + * @return Promise + * Resolves: Object containing "scope", "token" and "key" properties + * Rejects: {FxAccountsProfileClientError} Profile client error. + * @private + */ + async _getTokenForRequest(method) { + let scope = SCOPE_PROFILE; + if (method === "POST") { + scope = SCOPE_PROFILE_WRITE; + } + return this.fxai.getOAuthToken({ scope }); + }, + + /** + * Remote "raw" request helper - doesn't handle auth errors and tokens. + * + * @param {String} path + * Profile server path, i.e "/profile". + * @param {String} method + * Type of request, i.e "GET". + * @param {String} token + * @param {String} etag + * @param {Object} payload + * The payload of the request, if any. + * @return Promise + * Resolves: {body: Object, etag: Object} Successful response from the Profile server + or null if 304 is hit (same ETag). + * Rejects: {FxAccountsProfileClientError} Profile client error. + * @private + */ + async _rawRequest(path, method, token, etag = null, payload = null) { + let profileDataUrl = this.serverURL + path; + let request = new this._Request(profileDataUrl); + + request.setHeader("Authorization", "Bearer " + token); + request.setHeader("Accept", "application/json"); + if (etag) { + request.setHeader("If-None-Match", etag); + } + + if (method != "GET" && method != "POST") { + // method not supported + throw new FxAccountsProfileClientError({ + error: ERROR_NETWORK, + errno: ERRNO_NETWORK, + code: ERROR_CODE_METHOD_NOT_ALLOWED, + message: ERROR_MSG_METHOD_NOT_ALLOWED, + }); + } + try { + await request.dispatch(method, payload); + } catch (error) { + throw new FxAccountsProfileClientError({ + error: ERROR_NETWORK, + errno: ERRNO_NETWORK, + message: error.toString(), + }); + } + + let body = null; + try { + if (request.response.status == 304) { + return null; + } + body = JSON.parse(request.response.body); + } catch (e) { + throw new FxAccountsProfileClientError({ + error: ERROR_PARSE, + errno: ERRNO_PARSE, + code: request.response.status, + message: request.response.body, + }); + } + + // "response.success" means status code is 200 + if (!request.response.success) { + throw new FxAccountsProfileClientError({ + error: body.error || ERROR_UNKNOWN, + errno: body.errno || ERRNO_UNKNOWN_ERROR, + code: request.response.status, + message: body.message || body, + }); + } + return { + body, + etag: request.response.headers.etag, + }; + }, + + /** + * Retrieve user's profile from the server + * + * @param {String} [etag] + * Optional ETag used for caching purposes. (may generate a 304 exception) + * @return Promise + * Resolves: {body: Object, etag: Object} Successful response from the '/profile' endpoint. + * Rejects: {FxAccountsProfileClientError} profile client error. + */ + fetchProfile(etag) { + log.debug("FxAccountsProfileClient: Requested profile"); + return this._createRequest("/profile", "GET", etag); + }, +}; + +/** + * Normalized profile client errors + * @param {Object} [details] + * Error details object + * @param {number} [details.code] + * Error code + * @param {number} [details.errno] + * Error number + * @param {String} [details.error] + * Error description + * @param {String|null} [details.message] + * Error message + * @constructor + */ +export var FxAccountsProfileClientError = function (details) { + details = details || {}; + + this.name = "FxAccountsProfileClientError"; + this.code = details.code || null; + this.errno = details.errno || ERRNO_UNKNOWN_ERROR; + this.error = details.error || ERROR_UNKNOWN; + this.message = details.message || null; +}; + +/** + * Returns error object properties + * + * @returns {{name: *, code: *, errno: *, error: *, message: *}} + * @private + */ +FxAccountsProfileClientError.prototype._toStringFields = function () { + return { + name: this.name, + code: this.code, + errno: this.errno, + error: this.error, + message: this.message, + }; +}; + +/** + * String representation of a profile client error + * + * @returns {String} + */ +FxAccountsProfileClientError.prototype.toString = function () { + return this.name + "(" + JSON.stringify(this._toStringFields()) + ")"; +}; diff --git a/services/fxaccounts/FxAccountsPush.sys.mjs b/services/fxaccounts/FxAccountsPush.sys.mjs new file mode 100644 index 0000000000..e3e5f32de5 --- /dev/null +++ b/services/fxaccounts/FxAccountsPush.sys.mjs @@ -0,0 +1,315 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { Async } from "resource://services-common/async.sys.mjs"; + +import { + FXA_PUSH_SCOPE_ACCOUNT_UPDATE, + ONLOGOUT_NOTIFICATION, + ON_ACCOUNT_DESTROYED_NOTIFICATION, + ON_COLLECTION_CHANGED_NOTIFICATION, + ON_COMMAND_RECEIVED_NOTIFICATION, + ON_DEVICE_CONNECTED_NOTIFICATION, + ON_DEVICE_DISCONNECTED_NOTIFICATION, + ON_PASSWORD_CHANGED_NOTIFICATION, + ON_PASSWORD_RESET_NOTIFICATION, + ON_PROFILE_CHANGE_NOTIFICATION, + ON_PROFILE_UPDATED_NOTIFICATION, + ON_VERIFY_LOGIN_NOTIFICATION, + log, +} from "resource://gre/modules/FxAccountsCommon.sys.mjs"; + +/** + * FxAccountsPushService manages Push notifications for Firefox Accounts in the browser + * + * @param [options] + * Object, custom options that used for testing + * @constructor + */ +export function FxAccountsPushService(options = {}) { + this.log = log; + + if (options.log) { + // allow custom log for testing purposes + this.log = options.log; + } + + this.log.debug("FxAccountsPush loading service"); + this.wrappedJSObject = this; + this.initialize(options); +} + +FxAccountsPushService.prototype = { + /** + * Helps only initialize observers once. + */ + _initialized: false, + /** + * Instance of the nsIPushService or a mocked object. + */ + pushService: null, + /** + * Instance of FxAccountsInternal or a mocked object. + */ + fxai: null, + /** + * Component ID of this service, helps register this component. + */ + classID: Components.ID("{1b7db999-2ecd-4abf-bb95-a726896798ca}"), + /** + * Register used interfaces in this service + */ + QueryInterface: ChromeUtils.generateQI(["nsIObserver"]), + /** + * Initialize the service and register all the required observers. + * + * @param [options] + */ + initialize(options) { + if (this._initialized) { + return false; + } + + this._initialized = true; + + if (options.pushService) { + this.pushService = options.pushService; + } else { + this.pushService = Cc["@mozilla.org/push/Service;1"].getService( + Ci.nsIPushService + ); + } + + if (options.fxai) { + this.fxai = options.fxai; + } else { + const { getFxAccountsSingleton } = ChromeUtils.importESModule( + "resource://gre/modules/FxAccounts.sys.mjs" + ); + const fxAccounts = getFxAccountsSingleton(); + this.fxai = fxAccounts._internal; + } + + this.asyncObserver = Async.asyncObserver(this, this.log); + // We use an async observer because a device waking up can + // observe multiple "Send Tab received" push notifications at the same time. + // The way these notifications are handled is as follows: + // Read index from storage, make network request, update the index. + // You can imagine what happens when multiple calls race: we load + // the same index multiple times and receive the same exact tabs, multiple times. + // The async observer will ensure we make these network requests serially. + Services.obs.addObserver(this.asyncObserver, this.pushService.pushTopic); + Services.obs.addObserver( + this.asyncObserver, + this.pushService.subscriptionChangeTopic + ); + Services.obs.addObserver(this.asyncObserver, ONLOGOUT_NOTIFICATION); + + this.log.debug("FxAccountsPush initialized"); + return true; + }, + /** + * Registers a new endpoint with the Push Server + * + * @returns {Promise} + * Promise always resolves with a subscription or a null if failed to subscribe. + */ + registerPushEndpoint() { + this.log.trace("FxAccountsPush registerPushEndpoint"); + + return new Promise(resolve => { + this.pushService.subscribe( + FXA_PUSH_SCOPE_ACCOUNT_UPDATE, + Services.scriptSecurityManager.getSystemPrincipal(), + (result, subscription) => { + if (Components.isSuccessCode(result)) { + this.log.debug("FxAccountsPush got subscription"); + resolve(subscription); + } else { + this.log.warn("FxAccountsPush failed to subscribe", result); + resolve(null); + } + } + ); + }); + }, + /** + * Async observer interface to listen to push messages, changes and logout. + * + * @param subject + * @param topic + * @param data + * @returns {Promise} + */ + async observe(subject, topic, data) { + try { + this.log.trace( + `observed topic=${topic}, data=${data}, subject=${subject}` + ); + switch (topic) { + case this.pushService.pushTopic: + if (data === FXA_PUSH_SCOPE_ACCOUNT_UPDATE) { + let message = subject.QueryInterface(Ci.nsIPushMessage); + await this._onPushMessage(message); + } + break; + case this.pushService.subscriptionChangeTopic: + if (data === FXA_PUSH_SCOPE_ACCOUNT_UPDATE) { + await this._onPushSubscriptionChange(); + } + break; + case ONLOGOUT_NOTIFICATION: + // user signed out, we need to stop polling the Push Server + await this.unsubscribe(); + break; + } + } catch (err) { + this.log.error(err); + } + }, + + /** + * Fired when the Push server sends a notification. + * + * @private + * @returns {Promise} + */ + async _onPushMessage(message) { + this.log.trace("FxAccountsPushService _onPushMessage"); + if (!message.data) { + // Use the empty signal to check the verification state of the account right away + this.log.debug("empty push message - checking account status"); + this.fxai.checkVerificationStatus(); + return; + } + let payload = message.data.json(); + this.log.debug(`push command: ${payload.command}`); + switch (payload.command) { + case ON_COMMAND_RECEIVED_NOTIFICATION: + await this.fxai.commands.pollDeviceCommands(payload.data.index); + break; + case ON_DEVICE_CONNECTED_NOTIFICATION: + Services.obs.notifyObservers( + null, + ON_DEVICE_CONNECTED_NOTIFICATION, + payload.data.deviceName + ); + break; + case ON_DEVICE_DISCONNECTED_NOTIFICATION: + this.fxai._handleDeviceDisconnection(payload.data.id); + return; + case ON_PROFILE_UPDATED_NOTIFICATION: + // We already have a "profile updated" notification sent via WebChannel, + // let's just re-use that. + Services.obs.notifyObservers(null, ON_PROFILE_CHANGE_NOTIFICATION); + return; + case ON_PASSWORD_CHANGED_NOTIFICATION: + case ON_PASSWORD_RESET_NOTIFICATION: + this._onPasswordChanged(); + return; + case ON_ACCOUNT_DESTROYED_NOTIFICATION: + this.fxai._handleAccountDestroyed(payload.data.uid); + return; + case ON_COLLECTION_CHANGED_NOTIFICATION: + Services.obs.notifyObservers( + null, + ON_COLLECTION_CHANGED_NOTIFICATION, + payload.data.collections + ); + return; + case ON_VERIFY_LOGIN_NOTIFICATION: + Services.obs.notifyObservers( + null, + ON_VERIFY_LOGIN_NOTIFICATION, + JSON.stringify(payload.data) + ); + break; + default: + this.log.warn("FxA Push command unrecognized: " + payload.command); + } + }, + /** + * Check the FxA session status after a password change/reset event. + * If the session is invalid, reset credentials and notify listeners of + * ON_ACCOUNT_STATE_CHANGE_NOTIFICATION that the account may have changed + * + * @returns {Promise} + * @private + */ + _onPasswordChanged() { + return this.fxai.withCurrentAccountState(async state => { + return this.fxai.checkAccountStatus(state); + }); + }, + /** + * Fired when the Push server drops a subscription, or the subscription identifier changes. + * + * https://developer.mozilla.org/en-US/docs/Mozilla/Tech/XPCOM/Reference/Interface/nsIPushService#Receiving_Push_Messages + * + * @returns {Promise} + * @private + */ + _onPushSubscriptionChange() { + this.log.trace("FxAccountsPushService _onPushSubscriptionChange"); + return this.fxai.updateDeviceRegistration(); + }, + /** + * Unsubscribe from the Push server + * + * Ref: https://developer.mozilla.org/en-US/docs/Mozilla/Tech/XPCOM/Reference/Interface/nsIPushService#unsubscribe() + * + * @returns {Promise} - The promise resolves with a bool to indicate if we successfully unsubscribed. + * The promise never rejects. + * @private + */ + unsubscribe() { + this.log.trace("FxAccountsPushService unsubscribe"); + return new Promise(resolve => { + this.pushService.unsubscribe( + FXA_PUSH_SCOPE_ACCOUNT_UPDATE, + Services.scriptSecurityManager.getSystemPrincipal(), + (result, ok) => { + if (Components.isSuccessCode(result)) { + if (ok === true) { + this.log.debug("FxAccountsPushService unsubscribed"); + } else { + this.log.debug( + "FxAccountsPushService had no subscription to unsubscribe" + ); + } + } else { + this.log.warn( + "FxAccountsPushService failed to unsubscribe", + result + ); + } + return resolve(ok); + } + ); + }); + }, + + /** + * Get our Push server subscription. + * + * Ref: https://developer.mozilla.org/en-US/docs/Mozilla/Tech/XPCOM/Reference/Interface/nsIPushService#getSubscription() + * + * @returns {Promise} - resolves with the subscription or null. Never rejects. + */ + getSubscription() { + return new Promise(resolve => { + this.pushService.getSubscription( + FXA_PUSH_SCOPE_ACCOUNT_UPDATE, + Services.scriptSecurityManager.getSystemPrincipal(), + (result, subscription) => { + if (!subscription) { + this.log.info("FxAccountsPushService no subscription found"); + return resolve(null); + } + return resolve(subscription); + } + ); + }); + }, +}; diff --git a/services/fxaccounts/FxAccountsStorage.sys.mjs b/services/fxaccounts/FxAccountsStorage.sys.mjs new file mode 100644 index 0000000000..24c85dbc2d --- /dev/null +++ b/services/fxaccounts/FxAccountsStorage.sys.mjs @@ -0,0 +1,618 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { + DATA_FORMAT_VERSION, + DEFAULT_STORAGE_FILENAME, + FXA_PWDMGR_HOST, + FXA_PWDMGR_PLAINTEXT_FIELDS, + FXA_PWDMGR_REALM, + FXA_PWDMGR_SECURE_FIELDS, + log, +} from "resource://gre/modules/FxAccountsCommon.sys.mjs"; + +// A helper function so code can check what fields are able to be stored by +// the storage manager without having a reference to a manager instance. +export function FxAccountsStorageManagerCanStoreField(fieldName) { + return ( + FXA_PWDMGR_PLAINTEXT_FIELDS.has(fieldName) || + FXA_PWDMGR_SECURE_FIELDS.has(fieldName) + ); +} + +// The storage manager object. +export var FxAccountsStorageManager = function (options = {}) { + this.options = { + filename: options.filename || DEFAULT_STORAGE_FILENAME, + baseDir: options.baseDir || Services.dirsvc.get("ProfD", Ci.nsIFile).path, + }; + this.plainStorage = new JSONStorage(this.options); + // Tests may want to pretend secure storage isn't available. + let useSecure = "useSecure" in options ? options.useSecure : true; + if (useSecure) { + this.secureStorage = new LoginManagerStorage(); + } else { + this.secureStorage = null; + } + this._clearCachedData(); + // See .initialize() below - this protects against it not being called. + this._promiseInitialized = Promise.reject("initialize not called"); + // A promise to avoid storage races - see _queueStorageOperation + this._promiseStorageComplete = Promise.resolve(); +}; + +FxAccountsStorageManager.prototype = { + _initialized: false, + _needToReadSecure: true, + + // An initialization routine that *looks* synchronous to the callers, but + // is actually async as everything else waits for it to complete. + initialize(accountData) { + if (this._initialized) { + throw new Error("already initialized"); + } + this._initialized = true; + // If we just throw away our pre-rejected promise it is reported as an + // unhandled exception when it is GCd - so add an empty .catch handler here + // to prevent this. + this._promiseInitialized.catch(() => {}); + this._promiseInitialized = this._initialize(accountData); + }, + + async _initialize(accountData) { + log.trace("initializing new storage manager"); + try { + if (accountData) { + // If accountData is passed we don't need to read any storage. + this._needToReadSecure = false; + // split it into the 2 parts, write it and we are done. + for (let [name, val] of Object.entries(accountData)) { + if (FXA_PWDMGR_PLAINTEXT_FIELDS.has(name)) { + this.cachedPlain[name] = val; + } else if (FXA_PWDMGR_SECURE_FIELDS.has(name)) { + this.cachedSecure[name] = val; + } else { + // Unknown fields are silently discarded, because there is no way + // for them to be read back later. + log.error( + "Unknown FxA field name in user data, it will be ignored", + name + ); + } + } + // write it out and we are done. + await this._write(); + return; + } + // So we were initialized without account data - that means we need to + // read the state from storage. We try and read plain storage first and + // only attempt to read secure storage if the plain storage had a user. + this._needToReadSecure = await this._readPlainStorage(); + if (this._needToReadSecure && this.secureStorage) { + await this._doReadAndUpdateSecure(); + } + } finally { + log.trace("initializing of new storage manager done"); + } + }, + + finalize() { + // We can't throw this instance away while it is still writing or we may + // end up racing with the newly created one. + log.trace("StorageManager finalizing"); + return this._promiseInitialized + .then(() => { + return this._promiseStorageComplete; + }) + .then(() => { + this._promiseStorageComplete = null; + this._promiseInitialized = null; + this._clearCachedData(); + log.trace("StorageManager finalized"); + }); + }, + + // We want to make sure we don't end up doing multiple storage requests + // concurrently - which has a small window for reads if the master-password + // is locked at initialization time and becomes unlocked later, and always + // has an opportunity for updates. + // We also want to make sure we finished writing when finalizing, so we + // can't accidentally end up with the previous user's write finishing after + // a signOut attempts to clear it. + // So all such operations "queue" themselves via this. + _queueStorageOperation(func) { + // |result| is the promise we return - it has no .catch handler, so callers + // of the storage operation still see failure as a normal rejection. + let result = this._promiseStorageComplete.then(func); + // But the promise we assign to _promiseStorageComplete *does* have a catch + // handler so that rejections in one storage operation does not prevent + // future operations from starting (ie, _promiseStorageComplete must never + // be in a rejected state) + this._promiseStorageComplete = result.catch(err => { + log.error("${func} failed: ${err}", { func, err }); + }); + return result; + }, + + // Get the account data by combining the plain and secure storage. + // If fieldNames is specified, it may be a string or an array of strings, + // and only those fields are returned. If not specified the entire account + // data is returned except for "in memory" fields. Note that not specifying + // field names will soon be deprecated/removed - we want all callers to + // specify the fields they care about. + async getAccountData(fieldNames = null) { + await this._promiseInitialized; + // We know we are initialized - this means our .cachedPlain is accurate + // and doesn't need to be read (it was read if necessary by initialize). + // So if there's no uid, there's no user signed in. + if (!("uid" in this.cachedPlain)) { + return null; + } + let result = {}; + if (fieldNames === null) { + // The "old" deprecated way of fetching a logged in user. + for (let [name, value] of Object.entries(this.cachedPlain)) { + result[name] = value; + } + // But the secure data may not have been read, so try that now. + await this._maybeReadAndUpdateSecure(); + // .cachedSecure now has as much as it possibly can (which is possibly + // nothing if (a) secure storage remains locked and (b) we've never updated + // a field to be stored in secure storage.) + for (let [name, value] of Object.entries(this.cachedSecure)) { + result[name] = value; + } + return result; + } + // The new explicit way of getting attributes. + if (!Array.isArray(fieldNames)) { + fieldNames = [fieldNames]; + } + let checkedSecure = false; + for (let fieldName of fieldNames) { + if (FXA_PWDMGR_PLAINTEXT_FIELDS.has(fieldName)) { + if (this.cachedPlain[fieldName] !== undefined) { + result[fieldName] = this.cachedPlain[fieldName]; + } + } else if (FXA_PWDMGR_SECURE_FIELDS.has(fieldName)) { + // We may not have read secure storage yet. + if (!checkedSecure) { + await this._maybeReadAndUpdateSecure(); + checkedSecure = true; + } + if (this.cachedSecure[fieldName] !== undefined) { + result[fieldName] = this.cachedSecure[fieldName]; + } + } else { + throw new Error("unexpected field '" + fieldName + "'"); + } + } + return result; + }, + + // Update just the specified fields. This DOES NOT allow you to change to + // a different user, nor to set the user as signed-out. + async updateAccountData(newFields) { + await this._promiseInitialized; + if (!("uid" in this.cachedPlain)) { + // If this storage instance shows no logged in user, then you can't + // update fields. + throw new Error("No user is logged in"); + } + if (!newFields || "uid" in newFields) { + throw new Error("Can't change uid"); + } + log.debug("_updateAccountData with items", Object.keys(newFields)); + // work out what bucket. + for (let [name, value] of Object.entries(newFields)) { + if (value == null) { + delete this.cachedPlain[name]; + // no need to do the "delete on null" thing for this.cachedSecure - + // we need to keep it until we have managed to read so we can nuke + // it on write. + this.cachedSecure[name] = null; + } else if (FXA_PWDMGR_PLAINTEXT_FIELDS.has(name)) { + this.cachedPlain[name] = value; + } else if (FXA_PWDMGR_SECURE_FIELDS.has(name)) { + this.cachedSecure[name] = value; + } else { + // Throwing seems reasonable here as some client code has explicitly + // specified the field name, so it's either confused or needs to update + // how this field is to be treated. + throw new Error("unexpected field '" + name + "'"); + } + } + // If we haven't yet read the secure data, do so now, else we may write + // out partial data. + await this._maybeReadAndUpdateSecure(); + // Now save it - but don't wait on the _write promise - it's queued up as + // a storage operation, so .finalize() will wait for completion, but no need + // for us to. + this._write(); + }, + + _clearCachedData() { + this.cachedPlain = {}; + // If we don't have secure storage available we have cachedPlain and + // cachedSecure be the same object. + this.cachedSecure = this.secureStorage == null ? this.cachedPlain : {}; + }, + + /* Reads the plain storage and caches the read values in this.cachedPlain. + Only ever called once and unlike the "secure" storage, is expected to never + fail (ie, plain storage is considered always available, whereas secure + storage may be unavailable if it is locked). + + Returns a promise that resolves with true if valid account data was found, + false otherwise. + + Note: _readPlainStorage is only called during initialize, so isn't + protected via _queueStorageOperation() nor _promiseInitialized. + */ + async _readPlainStorage() { + let got; + try { + got = await this.plainStorage.get(); + } catch (err) { + // File hasn't been created yet. That will be done + // when write is called. + if (!err.name == "NotFoundError") { + log.error("Failed to read plain storage", err); + } + // either way, we return null. + got = null; + } + if ( + !got || + !got.accountData || + !got.accountData.uid || + got.version != DATA_FORMAT_VERSION + ) { + return false; + } + // We need to update our .cachedPlain, but can't just assign to it as + // it may need to be the exact same object as .cachedSecure + // As a sanity check, .cachedPlain must be empty (as we are called by init) + // XXX - this would be a good use-case for a RuntimeAssert or similar, as + // being added in bug 1080457. + if (Object.keys(this.cachedPlain).length) { + throw new Error("should be impossible to have cached data already."); + } + for (let [name, value] of Object.entries(got.accountData)) { + this.cachedPlain[name] = value; + } + return true; + }, + + /* If we haven't managed to read the secure storage, try now, so + we can merge our cached data with the data that's already been set. + */ + _maybeReadAndUpdateSecure() { + if (this.secureStorage == null || !this._needToReadSecure) { + return null; + } + return this._queueStorageOperation(() => { + if (this._needToReadSecure) { + // we might have read it by now! + return this._doReadAndUpdateSecure(); + } + return null; + }); + }, + + /* Unconditionally read the secure storage and merge our cached data (ie, data + which has already been set while the secure storage was locked) with + the read data + */ + async _doReadAndUpdateSecure() { + let { uid, email } = this.cachedPlain; + try { + log.debug( + "reading secure storage with existing", + Object.keys(this.cachedSecure) + ); + // If we already have anything in .cachedSecure it means something has + // updated cachedSecure before we've read it. That means that after we do + // manage to read we must write back the merged data. + let needWrite = !!Object.keys(this.cachedSecure).length; + let readSecure = await this.secureStorage.get(uid, email); + // and update our cached data with it - anything already in .cachedSecure + // wins (including the fact it may be null or undefined, the latter + // which means it will be removed from storage. + if (readSecure && readSecure.version != DATA_FORMAT_VERSION) { + log.warn("got secure data but the data format version doesn't match"); + readSecure = null; + } + if (readSecure && readSecure.accountData) { + log.debug( + "secure read fetched items", + Object.keys(readSecure.accountData) + ); + for (let [name, value] of Object.entries(readSecure.accountData)) { + if (!(name in this.cachedSecure)) { + this.cachedSecure[name] = value; + } + } + if (needWrite) { + log.debug("successfully read secure data; writing updated data back"); + await this._doWriteSecure(); + } + } + this._needToReadSecure = false; + } catch (ex) { + if (ex instanceof this.secureStorage.STORAGE_LOCKED) { + log.debug("setAccountData: secure storage is locked trying to read"); + } else { + log.error("failed to read secure storage", ex); + throw ex; + } + } + }, + + _write() { + // We don't want multiple writes happening concurrently, and we also need to + // know when an "old" storage manager is done (this.finalize() waits for this) + return this._queueStorageOperation(() => this.__write()); + }, + + async __write() { + // Write everything back - later we could track what's actually dirty, + // but for now we write it all. + log.debug("writing plain storage", Object.keys(this.cachedPlain)); + let toWritePlain = { + version: DATA_FORMAT_VERSION, + accountData: this.cachedPlain, + }; + await this.plainStorage.set(toWritePlain); + + // If we have no secure storage manager we are done. + if (this.secureStorage == null) { + return; + } + // and only attempt to write to secure storage if we've managed to read it, + // otherwise we might clobber data that's already there. + if (!this._needToReadSecure) { + await this._doWriteSecure(); + } + }, + + /* Do the actual write of secure data. Caller is expected to check if we actually + need to write and to ensure we are in a queued storage operation. + */ + async _doWriteSecure() { + // We need to remove null items here. + for (let [name, value] of Object.entries(this.cachedSecure)) { + if (value == null) { + delete this.cachedSecure[name]; + } + } + log.debug("writing secure storage", Object.keys(this.cachedSecure)); + let toWriteSecure = { + version: DATA_FORMAT_VERSION, + accountData: this.cachedSecure, + }; + try { + await this.secureStorage.set(this.cachedPlain.uid, toWriteSecure); + } catch (ex) { + if (!(ex instanceof this.secureStorage.STORAGE_LOCKED)) { + throw ex; + } + // This shouldn't be possible as once it is unlocked it can't be + // re-locked, and we can only be here if we've previously managed to + // read. + log.error("setAccountData: secure storage is locked trying to write"); + } + }, + + // Delete the data for an account - ie, called on "sign out". + deleteAccountData() { + return this._queueStorageOperation(() => this._deleteAccountData()); + }, + + async _deleteAccountData() { + log.debug("removing account data"); + await this._promiseInitialized; + await this.plainStorage.set(null); + if (this.secureStorage) { + await this.secureStorage.set(null); + } + this._clearCachedData(); + log.debug("account data reset"); + }, +}; + +/** + * JSONStorage constructor that creates instances that may set/get + * to a specified file, in a directory that will be created if it + * doesn't exist. + * + * @param options { + * filename: of the file to write to + * baseDir: directory where the file resides + * } + * @return instance + */ +function JSONStorage(options) { + this.baseDir = options.baseDir; + this.path = PathUtils.join(options.baseDir, options.filename); +} + +JSONStorage.prototype = { + set(contents) { + log.trace( + "starting write of json user data", + contents ? Object.keys(contents.accountData) : "null" + ); + let start = Date.now(); + return IOUtils.makeDirectory(this.baseDir, { ignoreExisting: true }) + .then(IOUtils.writeJSON.bind(null, this.path, contents)) + .then(result => { + log.trace( + "finished write of json user data - took", + Date.now() - start + ); + return result; + }); + }, + + get() { + log.trace("starting fetch of json user data"); + let start = Date.now(); + return IOUtils.readJSON(this.path).then(result => { + log.trace("finished fetch of json user data - took", Date.now() - start); + return result; + }); + }, +}; + +function StorageLockedError() {} + +/** + * LoginManagerStorage constructor that creates instances that set/get + * data stored securely in the nsILoginManager. + * + * @return instance + */ + +export function LoginManagerStorage() {} + +LoginManagerStorage.prototype = { + STORAGE_LOCKED: StorageLockedError, + // The fields in the credentials JSON object that are stored in plain-text + // in the profile directory. All other fields are stored in the login manager, + // and thus are only available when the master-password is unlocked. + + // a hook point for testing. + get _isLoggedIn() { + return Services.logins.isLoggedIn; + }, + + // Clear any data from the login manager. Returns true if the login manager + // was unlocked (even if no existing logins existed) or false if it was + // locked (meaning we don't even know if it existed or not.) + async _clearLoginMgrData() { + try { + // Services.logins might be third-party and broken... + await Services.logins.initializationPromise; + if (!this._isLoggedIn) { + return false; + } + let logins = await Services.logins.searchLoginsAsync({ + origin: FXA_PWDMGR_HOST, + httpRealm: FXA_PWDMGR_REALM, + }); + for (let login of logins) { + Services.logins.removeLogin(login); + } + return true; + } catch (ex) { + log.error("Failed to clear login data: ${}", ex); + return false; + } + }, + + async set(uid, contents) { + if (!contents) { + // Nuke it from the login manager. + let cleared = await this._clearLoginMgrData(); + if (!cleared) { + // just log a message - we verify that the uid matches when + // we reload it, so having a stale entry doesn't really hurt. + log.info("not removing credentials from login manager - not logged in"); + } + log.trace("storage set finished clearing account data"); + return; + } + + // We are saving actual data. + log.trace("starting write of user data to the login manager"); + try { + // Services.logins might be third-party and broken... + // and the stuff into the login manager. + await Services.logins.initializationPromise; + // If MP is locked we silently fail - the user may need to re-auth + // next startup. + if (!this._isLoggedIn) { + log.info("not saving credentials to login manager - not logged in"); + throw new this.STORAGE_LOCKED(); + } + // write the data to the login manager. + let loginInfo = new Components.Constructor( + "@mozilla.org/login-manager/loginInfo;1", + Ci.nsILoginInfo, + "init" + ); + let login = new loginInfo( + FXA_PWDMGR_HOST, + null, // aFormActionOrigin, + FXA_PWDMGR_REALM, // aHttpRealm, + uid, // aUsername + JSON.stringify(contents), // aPassword + "", // aUsernameField + "" + ); // aPasswordField + + let existingLogins = await Services.logins.searchLoginsAsync({ + origin: FXA_PWDMGR_HOST, + httpRealm: FXA_PWDMGR_REALM, + }); + if (existingLogins.length) { + Services.logins.modifyLogin(existingLogins[0], login); + } else { + await Services.logins.addLoginAsync(login); + } + log.trace("finished write of user data to the login manager"); + } catch (ex) { + if (ex instanceof this.STORAGE_LOCKED) { + throw ex; + } + // just log and consume the error here - it may be a 3rd party login + // manager replacement that's simply broken. + log.error("Failed to save data to the login manager", ex); + } + }, + + async get(uid, email) { + log.trace("starting fetch of user data from the login manager"); + + try { + // Services.logins might be third-party and broken... + // read the data from the login manager and merge it for return. + await Services.logins.initializationPromise; + + if (!this._isLoggedIn) { + log.info( + "returning partial account data as the login manager is locked." + ); + throw new this.STORAGE_LOCKED(); + } + + let logins = await Services.logins.searchLoginsAsync({ + origin: FXA_PWDMGR_HOST, + httpRealm: FXA_PWDMGR_REALM, + }); + if (!logins.length) { + // This could happen if the MP was locked when we wrote the data. + log.info("Can't find any credentials in the login manager"); + return null; + } + let login = logins[0]; + // Support either the uid or the email as the username - as of bug 1183951 + // we store the uid, but we support having either for b/w compat. + if (login.username == uid || login.username == email) { + return JSON.parse(login.password); + } + log.info("username in the login manager doesn't match - ignoring it"); + await this._clearLoginMgrData(); + } catch (ex) { + if (ex instanceof this.STORAGE_LOCKED) { + throw ex; + } + // just log and consume the error here - it may be a 3rd party login + // manager replacement that's simply broken. + log.error("Failed to get data from the login manager", ex); + } + return null; + }, +}; diff --git a/services/fxaccounts/FxAccountsTelemetry.sys.mjs b/services/fxaccounts/FxAccountsTelemetry.sys.mjs new file mode 100644 index 0000000000..1d7b3d4954 --- /dev/null +++ b/services/fxaccounts/FxAccountsTelemetry.sys.mjs @@ -0,0 +1,173 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// FxA Telemetry support. For hysterical raisins, the actual implementation +// is inside "sync". We should move the core implementation somewhere that's +// sanely shared (eg, services-common?), but let's wait and see where we end up +// first... + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + CryptoUtils: "resource://services-crypto/utils.sys.mjs", + + // We use this observers module because we leverage its support for richer + // "subject" data. + Observers: "resource://services-common/observers.sys.mjs", +}); + +import { + PREF_ACCOUNT_ROOT, + log, +} from "resource://gre/modules/FxAccountsCommon.sys.mjs"; + +const PREF_SANITIZED_UID = PREF_ACCOUNT_ROOT + "telemetry.sanitized_uid"; +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "pref_sanitizedUid", + PREF_SANITIZED_UID, + "" +); + +export class FxAccountsTelemetry { + constructor(fxai) { + this._fxai = fxai; + Services.telemetry.setEventRecordingEnabled("fxa", true); + } + + // Records an event *in the Fxa/Sync ping*. + recordEvent(object, method, value, extra = undefined) { + // We need to ensure the telemetry module is loaded. + ChromeUtils.importESModule("resource://services-sync/telemetry.sys.mjs"); + // Now it will be listening for the notifications... + lazy.Observers.notify("fxa:telemetry:event", { + object, + method, + value, + extra, + }); + } + + generateUUID() { + return Services.uuid.generateUUID().toString().slice(1, -1); + } + + // A flow ID can be anything that's "probably" unique, so for now use a UUID. + generateFlowID() { + return this.generateUUID(); + } + + // FxA- and Sync-related metrics are submitted in a special-purpose "sync ping". This ping + // identifies the user by a version of their FxA uid that is HMAC-ed with a server-side secret + // key, in an attempt to provide a bit of anonymity. + + // Secret back-channel by which tokenserver client code can set the hashed UID. + // This value conceptually belongs to FxA, but we currently get it from tokenserver, + // so there's some light hackery to put it in the right place. + _setHashedUID(hashedUID) { + if (!hashedUID) { + Services.prefs.clearUserPref(PREF_SANITIZED_UID); + } else { + Services.prefs.setStringPref(PREF_SANITIZED_UID, hashedUID); + } + } + + getSanitizedUID() { + // Sadly, we can only currently obtain this value if the user has enabled sync. + return lazy.pref_sanitizedUid || null; + } + + // Sanitize the ID of a device into something suitable for including in the + // ping. Returns null if no transformation is possible. + sanitizeDeviceId(deviceId) { + const uid = this.getSanitizedUID(); + if (!uid) { + // Sadly, we can only currently get this if the user has enabled sync. + return null; + } + // Combine the raw device id with the sanitized uid to create a stable + // unique identifier that can't be mapped back to the user's FxA + // identity without knowing the metrics HMAC key. + // The result is 64 bytes long, which in retrospect is probably excessive, + // but it's already shipping... + return lazy.CryptoUtils.sha256(deviceId + uid); + } + + // Record the connection of FxA or one of its services. + // Note that you must call this before performing the actual connection + // or we may record incorrect data - for example, we will not be able to + // determine whether FxA itself was connected before this call. + // + // Currently sends an event in the main telemetry event ping rather than the + // FxA/Sync ping (although this might change in the future) + // + // @param services - An array of service names which should be recorded. FxA + // itself is not counted as a "service" - ie, an empty array should be passed + // if the account is connected without anything else . + // + // @param how - How the connection was done. + async recordConnection(services, how = null) { + try { + let extra = {}; + // Record that fxa was connected if it isn't currently - it will be soon. + if (!(await this._fxai.getUserAccountData())) { + extra.fxa = "true"; + } + // Events.yaml only declares "sync" as a valid service. + if (services.includes("sync")) { + extra.sync = "true"; + } + Services.telemetry.recordEvent("fxa", "connect", "account", how, extra); + } catch (ex) { + log.error("Failed to record connection telemetry", ex); + console.error("Failed to record connection telemetry", ex); + } + } + + // Record the disconnection of FxA or one of its services. + // Note that you must call this before performing the actual disconnection + // or we may record incomplete data - for example, if this is called after + // disconnection, we've almost certainly lost the ability to record what + // services were enabled prior to disconnection. + // + // Currently sends an event in the main telemetry event ping rather than the + // FxA/Sync ping (although this might change in the future) + // + // @param service - the service being disconnected. If null, the account + // itself is being disconnected, so all connected services are too. + // + // @param how - how the disconnection was done. + async recordDisconnection(service = null, how = null) { + try { + let extra = {}; + if (!service) { + extra.fxa = "true"; + // We need a way to enumerate all services - but for now we just hard-code + // all possibilities here. + if (Services.prefs.prefHasUserValue("services.sync.username")) { + extra.sync = "true"; + } + } else if (service == "sync") { + extra[service] = "true"; + } else { + // Events.yaml only declares "sync" as a valid service. + log.warn( + `recordDisconnection has invalid value for service: ${service}` + ); + } + Services.telemetry.recordEvent( + "fxa", + "disconnect", + "account", + how, + extra + ); + } catch (ex) { + log.error("Failed to record disconnection telemetry", ex); + console.error("Failed to record disconnection telemetry", ex); + } + } +} diff --git a/services/fxaccounts/FxAccountsWebChannel.sys.mjs b/services/fxaccounts/FxAccountsWebChannel.sys.mjs new file mode 100644 index 0000000000..fdd0b75e93 --- /dev/null +++ b/services/fxaccounts/FxAccountsWebChannel.sys.mjs @@ -0,0 +1,824 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Firefox Accounts Web Channel. + * + * Uses the WebChannel component to receive messages + * about account state changes. + */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +import { + COMMAND_PROFILE_CHANGE, + COMMAND_LOGIN, + COMMAND_LOGOUT, + COMMAND_OAUTH, + COMMAND_DELETE, + COMMAND_CAN_LINK_ACCOUNT, + COMMAND_SYNC_PREFERENCES, + COMMAND_CHANGE_PASSWORD, + COMMAND_FXA_STATUS, + COMMAND_PAIR_HEARTBEAT, + COMMAND_PAIR_SUPP_METADATA, + COMMAND_PAIR_AUTHORIZE, + COMMAND_PAIR_DECLINE, + COMMAND_PAIR_COMPLETE, + COMMAND_PAIR_PREFERENCES, + COMMAND_FIREFOX_VIEW, + FX_OAUTH_CLIENT_ID, + ON_PROFILE_CHANGE_NOTIFICATION, + PREF_LAST_FXA_USER, + WEBCHANNEL_ID, + log, + logPII, +} from "resource://gre/modules/FxAccountsCommon.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + CryptoUtils: "resource://services-crypto/utils.sys.mjs", + FxAccountsPairingFlow: "resource://gre/modules/FxAccountsPairing.sys.mjs", + FxAccountsStorageManagerCanStoreField: + "resource://gre/modules/FxAccountsStorage.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", + Weave: "resource://services-sync/main.sys.mjs", + WebChannel: "resource://gre/modules/WebChannel.sys.mjs", +}); +ChromeUtils.defineLazyGetter(lazy, "fxAccounts", () => { + return ChromeUtils.importESModule( + "resource://gre/modules/FxAccounts.sys.mjs" + ).getFxAccountsSingleton(); +}); +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "pairingEnabled", + "identity.fxaccounts.pairing.enabled" +); +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "separatePrivilegedMozillaWebContentProcess", + "browser.tabs.remote.separatePrivilegedMozillaWebContentProcess", + false +); +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "separatedMozillaDomains", + "browser.tabs.remote.separatedMozillaDomains", + "", + false, + val => val.split(",") +); +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "accountServer", + "identity.fxaccounts.remote.root", + null, + false, + val => Services.io.newURI(val) +); + +// These engines were added years after Sync had been introduced, they need +// special handling since they are system add-ons and are un-available on +// older versions of Firefox. +const EXTRA_ENGINES = ["addresses", "creditcards"]; + +// These engines will be displayed to the user to pick which they would like to +// use +const CHOOSE_WHAT_TO_SYNC = [ + "addons", + "addresses", + "bookmarks", + "creditcards", + "history", + "passwords", + "preferences", + "tabs", +]; + +/** + * A helper function that extracts the message and stack from an error object. + * Returns a `{ message, stack }` tuple. `stack` will be null if the error + * doesn't have a stack trace. + */ +function getErrorDetails(error) { + // Replace anything that looks like it might be a filepath on Windows or Unix + let cleanMessage = String(error) + .replace(/\\.*\\/gm, "[REDACTED]") + .replace(/\/.*\//gm, "[REDACTED]"); + let details = { message: cleanMessage, stack: null }; + + // Adapted from Console.sys.mjs. + if (error.stack) { + let frames = []; + for (let frame = error.stack; frame; frame = frame.caller) { + frames.push(String(frame).padStart(4)); + } + details.stack = frames.join("\n"); + } + + return details; +} + +/** + * Create a new FxAccountsWebChannel to listen for account updates + * + * @param {Object} options Options + * @param {Object} options + * @param {String} options.content_uri + * The FxA Content server uri + * @param {String} options.channel_id + * The ID of the WebChannel + * @param {String} options.helpers + * Helpers functions. Should only be passed in for testing. + * @constructor + */ +export function FxAccountsWebChannel(options) { + if (!options) { + throw new Error("Missing configuration options"); + } + if (!options.content_uri) { + throw new Error("Missing 'content_uri' option"); + } + this._contentUri = options.content_uri; + + if (!options.channel_id) { + throw new Error("Missing 'channel_id' option"); + } + this._webChannelId = options.channel_id; + + // options.helpers is only specified by tests. + ChromeUtils.defineLazyGetter(this, "_helpers", () => { + return options.helpers || new FxAccountsWebChannelHelpers(options); + }); + + this._setupChannel(); +} + +FxAccountsWebChannel.prototype = { + /** + * WebChannel that is used to communicate with content page + */ + _channel: null, + + /** + * Helpers interface that does the heavy lifting. + */ + _helpers: null, + + /** + * WebChannel ID. + */ + _webChannelId: null, + /** + * WebChannel origin, used to validate origin of messages + */ + _webChannelOrigin: null, + + /** + * Release all resources that are in use. + */ + tearDown() { + this._channel.stopListening(); + this._channel = null; + this._channelCallback = null; + }, + + /** + * Configures and registers a new WebChannel + * + * @private + */ + _setupChannel() { + // if this.contentUri is present but not a valid URI, then this will throw an error. + try { + this._webChannelOrigin = Services.io.newURI(this._contentUri); + this._registerChannel(); + } catch (e) { + log.error(e); + throw e; + } + }, + + _receiveMessage(message, sendingContext) { + const { command, data } = message; + let shouldCheckRemoteType = + lazy.separatePrivilegedMozillaWebContentProcess && + lazy.separatedMozillaDomains.some(function (val) { + return ( + lazy.accountServer.asciiHost == val || + lazy.accountServer.asciiHost.endsWith("." + val) + ); + }); + let { currentRemoteType } = sendingContext.browsingContext; + if (shouldCheckRemoteType && currentRemoteType != "privilegedmozilla") { + log.error( + `Rejected FxA webchannel message from remoteType = ${currentRemoteType}` + ); + return; + } + + let browser = sendingContext.browsingContext.top.embedderElement; + switch (command) { + case COMMAND_PROFILE_CHANGE: + Services.obs.notifyObservers( + null, + ON_PROFILE_CHANGE_NOTIFICATION, + data.uid + ); + break; + case COMMAND_LOGIN: + this._helpers + .login(data) + .catch(error => this._sendError(error, message, sendingContext)); + break; + case COMMAND_OAUTH: + this._helpers + .oauthLogin(data) + .catch(error => this._sendError(error, message, sendingContext)); + break; + case COMMAND_LOGOUT: + case COMMAND_DELETE: + this._helpers + .logout(data.uid) + .catch(error => this._sendError(error, message, sendingContext)); + break; + case COMMAND_CAN_LINK_ACCOUNT: + let canLinkAccount = this._helpers.shouldAllowRelink(data.email); + + let response = { + command, + messageId: message.messageId, + data: { ok: canLinkAccount }, + }; + + log.debug("FxAccountsWebChannel response", response); + this._channel.send(response, sendingContext); + break; + case COMMAND_SYNC_PREFERENCES: + this._helpers.openSyncPreferences(browser, data.entryPoint); + break; + case COMMAND_PAIR_PREFERENCES: + if (lazy.pairingEnabled) { + let window = browser.ownerGlobal; + // We should close the FxA tab after we open our pref page + let selectedTab = window.gBrowser.selectedTab; + window.switchToTabHavingURI( + "about:preferences?action=pair#sync", + true, + { + ignoreQueryString: true, + replaceQueryString: true, + adoptIntoActiveWindow: true, + ignoreFragment: "whenComparing", + triggeringPrincipal: + Services.scriptSecurityManager.getSystemPrincipal(), + } + ); + // close the tab + window.gBrowser.removeTab(selectedTab); + } + break; + case COMMAND_FIREFOX_VIEW: + this._helpers.openFirefoxView(browser, data.entryPoint); + break; + case COMMAND_CHANGE_PASSWORD: + this._helpers + .changePassword(data) + .catch(error => this._sendError(error, message, sendingContext)); + break; + case COMMAND_FXA_STATUS: + log.debug("fxa_status received"); + + const service = data && data.service; + const isPairing = data && data.isPairing; + const context = data && data.context; + this._helpers + .getFxaStatus(service, sendingContext, isPairing, context) + .then(fxaStatus => { + let response = { + command, + messageId: message.messageId, + data: fxaStatus, + }; + this._channel.send(response, sendingContext); + }) + .catch(error => this._sendError(error, message, sendingContext)); + break; + case COMMAND_PAIR_HEARTBEAT: + case COMMAND_PAIR_SUPP_METADATA: + case COMMAND_PAIR_AUTHORIZE: + case COMMAND_PAIR_DECLINE: + case COMMAND_PAIR_COMPLETE: + log.debug(`Pairing command ${command} received`); + const { channel_id: channelId } = data; + delete data.channel_id; + const flow = lazy.FxAccountsPairingFlow.get(channelId); + if (!flow) { + log.warn(`Could not find a pairing flow for ${channelId}`); + return; + } + flow.onWebChannelMessage(command, data).then(replyData => { + this._channel.send( + { + command, + messageId: message.messageId, + data: replyData, + }, + sendingContext + ); + }); + break; + default: + log.warn("Unrecognized FxAccountsWebChannel command", command); + // As a safety measure we also terminate any pending FxA pairing flow. + lazy.FxAccountsPairingFlow.finalizeAll(); + break; + } + }, + + _sendError(error, incomingMessage, sendingContext) { + log.error("Failed to handle FxAccountsWebChannel message", error); + this._channel.send( + { + command: incomingMessage.command, + messageId: incomingMessage.messageId, + data: { + error: getErrorDetails(error), + }, + }, + sendingContext + ); + }, + + /** + * Create a new channel with the WebChannelBroker, setup a callback listener + * @private + */ + _registerChannel() { + /** + * Processes messages that are called back from the FxAccountsChannel + * + * @param webChannelId {String} + * Command webChannelId + * @param message {Object} + * Command message + * @param sendingContext {Object} + * Message sending context. + * @param sendingContext.browsingContext {BrowsingContext} + * The browsingcontext from which the + * WebChannelMessageToChrome was sent. + * @param sendingContext.eventTarget {EventTarget} + * The <EventTarget> where the message was sent. + * @param sendingContext.principal {Principal} + * The <Principal> of the EventTarget where the message was sent. + * @private + * + */ + let listener = (webChannelId, message, sendingContext) => { + if (message) { + log.debug("FxAccountsWebChannel message received", message.command); + if (logPII()) { + log.debug("FxAccountsWebChannel message details", message); + } + try { + this._receiveMessage(message, sendingContext); + } catch (error) { + this._sendError(error, message, sendingContext); + } + } + }; + + this._channelCallback = listener; + this._channel = new lazy.WebChannel( + this._webChannelId, + this._webChannelOrigin + ); + this._channel.listen(listener); + log.debug( + "FxAccountsWebChannel registered: " + + this._webChannelId + + " with origin " + + this._webChannelOrigin.prePath + ); + }, +}; + +export function FxAccountsWebChannelHelpers(options) { + options = options || {}; + + this._fxAccounts = options.fxAccounts || lazy.fxAccounts; + this._weaveXPCOM = options.weaveXPCOM || null; + this._privateBrowsingUtils = + options.privateBrowsingUtils || lazy.PrivateBrowsingUtils; +} + +FxAccountsWebChannelHelpers.prototype = { + // If the last fxa account used for sync isn't this account, we display + // a modal dialog checking they really really want to do this... + // (This is sync-specific, so ideally would be in sync's identity module, + // but it's a little more seamless to do here, and sync is currently the + // only fxa consumer, so... + shouldAllowRelink(acctName) { + return ( + !this._needRelinkWarning(acctName) || this._promptForRelink(acctName) + ); + }, + + async _initializeSync() { + // A sync-specific hack - we want to ensure sync has been initialized + // before we set the signed-in user. + // XXX - probably not true any more, especially now we have observerPreloads + // in FxAccounts.jsm? + let xps = + this._weaveXPCOM || + Cc["@mozilla.org/weave/service;1"].getService(Ci.nsISupports) + .wrappedJSObject; + await xps.whenLoaded(); + return xps; + }, + + _setEnabledEngines(offeredEngines, declinedEngines) { + if (offeredEngines && declinedEngines) { + EXTRA_ENGINES.forEach(engine => { + if ( + offeredEngines.includes(engine) && + !declinedEngines.includes(engine) + ) { + // These extra engines are disabled by default. + Services.prefs.setBoolPref(`services.sync.engine.${engine}`, true); + } + }); + log.debug("Received declined engines", declinedEngines); + lazy.Weave.Service.engineManager.setDeclined(declinedEngines); + declinedEngines.forEach(engine => { + Services.prefs.setBoolPref(`services.sync.engine.${engine}`, false); + }); + } + }, + /** + * stores sync login info it in the fxaccounts service + * + * @param accountData the user's account data and credentials + */ + async login(accountData) { + // We don't act on customizeSync anymore, it used to open a dialog inside + // the browser to selecte the engines to sync but we do it on the web now. + log.debug("Webchannel is logging a user in."); + delete accountData.customizeSync; + + // Save requested services for later. + const requestedServices = accountData.services; + delete accountData.services; + + // the user has already been shown the "can link account" + // screen. No need to keep this data around. + delete accountData.verifiedCanLinkAccount; + + // Remember who it was so we can log out next time. + if (accountData.verified) { + this.setPreviousAccountNameHashPref(accountData.email); + } + + await this._fxAccounts.telemetry.recordConnection( + Object.keys(requestedServices || {}), + "webchannel" + ); + + const xps = await this._initializeSync(); + await this._fxAccounts._internal.setSignedInUser(accountData); + + if (requestedServices) { + // User has enabled Sync. + if (requestedServices.sync) { + const { offeredEngines, declinedEngines } = requestedServices.sync; + this._setEnabledEngines(offeredEngines, declinedEngines); + log.debug("Webchannel is enabling sync"); + await xps.Weave.Service.configure(); + } + } + }, + + /** + * Logins in to sync by completing an OAuth flow + * @param { Object } oauthData: The oauth code and state as returned by the server */ + async oauthLogin(oauthData) { + log.debug("Webchannel is completing the oauth flow"); + const xps = await this._initializeSync(); + const { code, state, declinedSyncEngines, offeredSyncEngines } = oauthData; + const { sessionToken } = + await this._fxAccounts._internal.getUserAccountData(["sessionToken"]); + // First we finish the ongoing oauth flow + const { scopedKeys, refreshToken } = + await this._fxAccounts._internal.completeOAuthFlow( + sessionToken, + code, + state + ); + + // We don't currently use the refresh token in Firefox Desktop, lets be good citizens and revoke it. + await this._fxAccounts._internal.destroyOAuthToken({ token: refreshToken }); + + // Then, we persist the sync keys + await this._fxAccounts._internal.setScopedKeys(scopedKeys); + + // Now that we have the scoped keys, we set our status to verified + await this._fxAccounts._internal.setUserVerified(); + this._setEnabledEngines(offeredSyncEngines, declinedSyncEngines); + log.debug("Webchannel is enabling sync"); + xps.Weave.Service.configure(); + }, + + /** + * logout the fxaccounts service + * + * @param the uid of the account which have been logged out + */ + async logout(uid) { + let fxa = this._fxAccounts; + let userData = await fxa._internal.getUserAccountData(["uid"]); + if (userData && userData.uid === uid) { + await fxa.telemetry.recordDisconnection(null, "webchannel"); + // true argument is `localOnly`, because server-side stuff + // has already been taken care of by the content server + await fxa.signOut(true); + } + }, + + /** + * Check if `sendingContext` is in private browsing mode. + */ + isPrivateBrowsingMode(sendingContext) { + if (!sendingContext) { + log.error("Unable to check for private browsing mode, assuming true"); + return true; + } + + let browser = sendingContext.browsingContext.top.embedderElement; + const isPrivateBrowsing = + this._privateBrowsingUtils.isBrowserPrivate(browser); + log.debug("is private browsing", isPrivateBrowsing); + return isPrivateBrowsing; + }, + + /** + * Check whether sending fxa_status data should be allowed. + */ + shouldAllowFxaStatus(service, sendingContext, isPairing, context) { + // Return user data for any service in non-PB mode. In PB mode, + // only return user data if service==="sync" or is in pairing mode + // (as service will be equal to the OAuth client ID and not "sync"). + // + // This behaviour allows users to click the "Manage Account" + // link from about:preferences#sync while in PB mode and things + // "just work". While in non-PB mode, users can sign into + // Pocket w/o entering their password a 2nd time, while in PB + // mode they *will* have to enter their email/password again. + // + // The difference in behaviour is to try to match user + // expectations as to what is and what isn't part of the browser. + // Sync is viewed as an integral part of the browser, interacting + // with FxA as part of a Sync flow should work all the time. If + // Sync is broken in PB mode, users will think Firefox is broken. + // See https://bugzilla.mozilla.org/show_bug.cgi?id=1323853 + log.debug("service", service); + return ( + !this.isPrivateBrowsingMode(sendingContext) || + service === "sync" || + context === "fx_desktop_v3" || + isPairing + ); + }, + + /** + * Get fxa_status information. Resolves to { signedInUser: <user_data> }. + * If returning status information is not allowed or no user is signed into + * Sync, `user_data` will be null. + */ + async getFxaStatus(service, sendingContext, isPairing, context) { + let signedInUser = null; + + if ( + this.shouldAllowFxaStatus(service, sendingContext, isPairing, context) + ) { + const userData = await this._fxAccounts._internal.getUserAccountData([ + "email", + "sessionToken", + "uid", + "verified", + ]); + if (userData) { + signedInUser = { + email: userData.email, + sessionToken: userData.sessionToken, + uid: userData.uid, + verified: userData.verified, + }; + } + } + + const capabilities = this._getCapabilities(); + + return { + signedInUser, + clientId: FX_OAUTH_CLIENT_ID, + capabilities, + }; + }, + _getCapabilities() { + if ( + Services.prefs.getBoolPref("identity.fxaccounts.oauth.enabled", false) + ) { + return { + multiService: true, + pairing: lazy.pairingEnabled, + choose_what_to_sync: true, + engines: CHOOSE_WHAT_TO_SYNC, + }; + } + return { + multiService: true, + pairing: lazy.pairingEnabled, + engines: this._getAvailableExtraEngines(), + }; + }, + + _getAvailableExtraEngines() { + return EXTRA_ENGINES.filter(engineName => { + try { + return Services.prefs.getBoolPref( + `services.sync.engine.${engineName}.available` + ); + } catch (e) { + return false; + } + }); + }, + + async changePassword(credentials) { + // If |credentials| has fields that aren't handled by accounts storage, + // updateUserAccountData will throw - mainly to prevent errors in code + // that hard-codes field names. + // However, in this case the field names aren't really in our control. + // We *could* still insist the server know what fields names are valid, + // but that makes life difficult for the server when Firefox adds new + // features (ie, new fields) - forcing the server to track a map of + // versions to supported field names doesn't buy us much. + // So we just remove field names we know aren't handled. + let newCredentials = { + device: null, // Force a brand new device registration. + // We force the re-encryption of the send tab keys using the new sync key after the password change + encryptedSendTabKeys: null, + }; + for (let name of Object.keys(credentials)) { + if ( + name == "email" || + name == "uid" || + lazy.FxAccountsStorageManagerCanStoreField(name) + ) { + newCredentials[name] = credentials[name]; + } else { + log.info("changePassword ignoring unsupported field", name); + } + } + await this._fxAccounts._internal.updateUserAccountData(newCredentials); + await this._fxAccounts._internal.updateDeviceRegistration(); + }, + + /** + * Get the hash of account name of the previously signed in account + */ + getPreviousAccountNameHashPref() { + try { + return Services.prefs.getStringPref(PREF_LAST_FXA_USER); + } catch (_) { + return ""; + } + }, + + /** + * Given an account name, set the hash of the previously signed in account + * + * @param acctName the account name of the user's account. + */ + setPreviousAccountNameHashPref(acctName) { + Services.prefs.setStringPref( + PREF_LAST_FXA_USER, + lazy.CryptoUtils.sha256Base64(acctName) + ); + }, + + /** + * Open Sync Preferences in the current tab of the browser + * + * @param {Object} browser the browser in which to open preferences + * @param {String} [entryPoint] entryPoint to use for logging + */ + openSyncPreferences(browser, entryPoint) { + let uri = "about:preferences"; + if (entryPoint) { + uri += "?entrypoint=" + encodeURIComponent(entryPoint); + } + uri += "#sync"; + + browser.loadURI(Services.io.newURI(uri), { + triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(), + }); + }, + + /** + * Open Firefox View in the browser's window + * + * @param {Object} browser the browser in whose window we'll open Firefox View + */ + openFirefoxView(browser) { + browser.ownerGlobal.FirefoxViewHandler.openTab("syncedtabs"); + }, + + /** + * If a user signs in using a different account, the data from the + * previous account and the new account will be merged. Ask the user + * if they want to continue. + * + * @private + */ + _needRelinkWarning(acctName) { + let prevAcctHash = this.getPreviousAccountNameHashPref(); + return ( + prevAcctHash && prevAcctHash != lazy.CryptoUtils.sha256Base64(acctName) + ); + }, + + /** + * Show the user a warning dialog that the data from the previous account + * and the new account will be merged. + * + * @private + */ + _promptForRelink(acctName) { + let sb = Services.strings.createBundle( + "chrome://browser/locale/syncSetup.properties" + ); + let continueLabel = sb.GetStringFromName("continue.label"); + let title = sb.GetStringFromName("relinkVerify.title"); + let description = sb.formatStringFromName("relinkVerify.description", [ + acctName, + ]); + let body = + sb.GetStringFromName("relinkVerify.heading") + "\n\n" + description; + let ps = Services.prompt; + let buttonFlags = + ps.BUTTON_POS_0 * ps.BUTTON_TITLE_IS_STRING + + ps.BUTTON_POS_1 * ps.BUTTON_TITLE_CANCEL + + ps.BUTTON_POS_1_DEFAULT; + + // If running in context of the browser chrome, window does not exist. + let pressed = Services.prompt.confirmEx( + null, + title, + body, + buttonFlags, + continueLabel, + null, + null, + null, + {} + ); + return pressed === 0; // 0 is the "continue" button + }, +}; + +var singleton; + +// The entry-point for this module, which ensures only one of our channels is +// ever created - we require this because the WebChannel is global in scope +// (eg, it uses the observer service to tell interested parties of interesting +// things) and allowing multiple channels would cause such notifications to be +// sent multiple times. +export var EnsureFxAccountsWebChannel = () => { + let contentUri = Services.urlFormatter.formatURLPref( + "identity.fxaccounts.remote.root" + ); + if (singleton && singleton._contentUri !== contentUri) { + singleton.tearDown(); + singleton = null; + } + if (!singleton) { + try { + if (contentUri) { + // The FxAccountsWebChannel listens for events and updates + // the state machine accordingly. + singleton = new FxAccountsWebChannel({ + content_uri: contentUri, + channel_id: WEBCHANNEL_ID, + }); + } else { + log.warn("FxA WebChannel functionaly is disabled due to no URI pref."); + } + } catch (ex) { + log.error("Failed to create FxA WebChannel", ex); + } + } +}; diff --git a/services/fxaccounts/components.conf b/services/fxaccounts/components.conf new file mode 100644 index 0000000000..992c88d0cb --- /dev/null +++ b/services/fxaccounts/components.conf @@ -0,0 +1,16 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Classes = [ + { + 'cid': '{1b7db999-2ecd-4abf-bb95-a726896798ca}', + 'contract_ids': ['@mozilla.org/fxaccounts/push;1'], + 'esModule': 'resource://gre/modules/FxAccountsPush.sys.mjs', + 'constructor': 'FxAccountsPushService', + 'processes': ProcessSelector.MAIN_PROCESS_ONLY, + 'categories': {'push': 'chrome://fxa-device-update'}, + }, +] diff --git a/services/fxaccounts/moz.build b/services/fxaccounts/moz.build new file mode 100644 index 0000000000..0047122c2d --- /dev/null +++ b/services/fxaccounts/moz.build @@ -0,0 +1,38 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +with Files("**"): + BUG_COMPONENT = ("Firefox", "Firefox Accounts") + +MOCHITEST_CHROME_MANIFESTS += ["tests/mochitest/chrome.toml"] + +BROWSER_CHROME_MANIFESTS += ["tests/browser/browser.toml"] + +XPCSHELL_TESTS_MANIFESTS += ["tests/xpcshell/xpcshell.toml"] + +EXTRA_JS_MODULES += [ + "Credentials.sys.mjs", + "FxAccounts.sys.mjs", + "FxAccountsClient.sys.mjs", + "FxAccountsCommands.sys.mjs", + "FxAccountsCommon.sys.mjs", + "FxAccountsConfig.sys.mjs", + "FxAccountsDevice.sys.mjs", + "FxAccountsKeys.sys.mjs", + "FxAccountsOAuth.sys.mjs", + "FxAccountsPairing.sys.mjs", + "FxAccountsPairingChannel.sys.mjs", + "FxAccountsProfile.sys.mjs", + "FxAccountsProfileClient.sys.mjs", + "FxAccountsPush.sys.mjs", + "FxAccountsStorage.sys.mjs", + "FxAccountsTelemetry.sys.mjs", + "FxAccountsWebChannel.sys.mjs", +] + +XPCOM_MANIFESTS += [ + "components.conf", +] diff --git a/services/fxaccounts/tests/browser/browser.toml b/services/fxaccounts/tests/browser/browser.toml new file mode 100644 index 0000000000..7cb752b59a --- /dev/null +++ b/services/fxaccounts/tests/browser/browser.toml @@ -0,0 +1,6 @@ +[DEFAULT] +support-files = ["head.js"] + +["browser_device_connected.js"] + +["browser_verify_login.js"] diff --git a/services/fxaccounts/tests/browser/browser_device_connected.js b/services/fxaccounts/tests/browser/browser_device_connected.js new file mode 100644 index 0000000000..8e567ddf35 --- /dev/null +++ b/services/fxaccounts/tests/browser/browser_device_connected.js @@ -0,0 +1,51 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { FxAccounts } = ChromeUtils.importESModule( + "resource://gre/modules/FxAccounts.sys.mjs" +); + +const gBrowserGlue = Cc["@mozilla.org/browser/browserglue;1"].getService( + Ci.nsIObserver +); +const DEVICES_URL = "https://example.com/devices"; + +add_setup(async function () { + const origManageDevicesURI = FxAccounts.config.promiseManageDevicesURI; + FxAccounts.config.promiseManageDevicesURI = () => + Promise.resolve(DEVICES_URL); + setupMockAlertsService(); + + registerCleanupFunction(function () { + FxAccounts.config.promiseManageDevicesURI = origManageDevicesURI; + delete window.FxAccounts; + }); +}); + +async function testDeviceConnected(deviceName) { + info("testDeviceConnected with deviceName=" + deviceName); + BrowserTestUtils.startLoadingURIString( + gBrowser.selectedBrowser, + "about:mozilla" + ); + await waitForDocLoadComplete(); + + let waitForTabPromise = BrowserTestUtils.waitForNewTab(gBrowser); + + Services.obs.notifyObservers(null, "fxaccounts:device_connected", deviceName); + + let tab = await waitForTabPromise; + Assert.ok("Tab successfully opened"); + + Assert.equal(tab.linkedBrowser.currentURI.spec, DEVICES_URL); + + BrowserTestUtils.removeTab(tab); +} + +add_task(async function () { + await testDeviceConnected("My phone"); +}); + +add_task(async function () { + await testDeviceConnected(null); +}); diff --git a/services/fxaccounts/tests/browser/browser_verify_login.js b/services/fxaccounts/tests/browser/browser_verify_login.js new file mode 100644 index 0000000000..fa9d952a52 --- /dev/null +++ b/services/fxaccounts/tests/browser/browser_verify_login.js @@ -0,0 +1,33 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_task(async function () { + let payload = { + data: { + deviceName: "Laptop", + url: "https://example.com/newLogin", + title: "Sign-in Request", + body: "New sign-in request from vershwal's Nighty on Intel Mac OS X 10.12", + }, + }; + info("testVerifyNewSignin"); + setupMockAlertsService(); + BrowserTestUtils.startLoadingURIString( + gBrowser.selectedBrowser, + "about:mozilla" + ); + await waitForDocLoadComplete(); + + let waitForTabPromise = BrowserTestUtils.waitForNewTab(gBrowser); + + Services.obs.notifyObservers( + null, + "fxaccounts:verify_login", + JSON.stringify(payload.data) + ); + + let tab = await waitForTabPromise; + Assert.ok("Tab successfully opened"); + Assert.equal(tab.linkedBrowser.currentURI.spec, payload.data.url); + BrowserTestUtils.removeTab(tab); +}); diff --git a/services/fxaccounts/tests/browser/head.js b/services/fxaccounts/tests/browser/head.js new file mode 100644 index 0000000000..e9fb8ad0dc --- /dev/null +++ b/services/fxaccounts/tests/browser/head.js @@ -0,0 +1,73 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Waits for the next load to complete in any browser or the given browser. + * If a <tabbrowser> is given it waits for a load in any of its browsers. + * + * @return promise + */ +function waitForDocLoadComplete(aBrowser = gBrowser) { + return new Promise(resolve => { + let listener = { + onStateChange(webProgress, req, flags, status) { + let docStop = + Ci.nsIWebProgressListener.STATE_IS_NETWORK | + Ci.nsIWebProgressListener.STATE_STOP; + info( + "Saw state " + + flags.toString(16) + + " and status " + + status.toString(16) + ); + + // When a load needs to be retargetted to a new process it is cancelled + // with NS_BINDING_ABORTED so ignore that case + if ((flags & docStop) == docStop && status != Cr.NS_BINDING_ABORTED) { + aBrowser.removeProgressListener(this); + waitForDocLoadComplete.listeners.delete(this); + + let chan = req.QueryInterface(Ci.nsIChannel); + info("Browser loaded " + chan.originalURI.spec); + resolve(); + } + }, + QueryInterface: ChromeUtils.generateQI([ + "nsIWebProgressListener", + "nsISupportsWeakReference", + ]), + }; + aBrowser.addProgressListener(listener); + waitForDocLoadComplete.listeners.add(listener); + info("Waiting for browser load"); + }); +} + +function setupMockAlertsService() { + const alertsService = { + showAlertNotification: ( + image, + title, + text, + clickable, + cookie, + clickCallback + ) => { + // We are invoking the event handler ourselves directly. + clickCallback(null, "alertclickcallback", null); + }, + }; + const gBrowserGlue = Cc["@mozilla.org/browser/browserglue;1"].getService( + Ci.nsIObserver + ); + gBrowserGlue.observe( + { wrappedJSObject: alertsService }, + "browser-glue-test", + "mock-alerts-service" + ); +} + +// Keep a set of progress listeners for waitForDocLoadComplete() to make sure +// they're not GC'ed before we saw the page load. +waitForDocLoadComplete.listeners = new Set(); +registerCleanupFunction(() => waitForDocLoadComplete.listeners.clear()); diff --git a/services/fxaccounts/tests/mochitest/chrome.toml b/services/fxaccounts/tests/mochitest/chrome.toml new file mode 100644 index 0000000000..5e88133317 --- /dev/null +++ b/services/fxaccounts/tests/mochitest/chrome.toml @@ -0,0 +1,5 @@ +[DEFAULT] +skip-if = ["os == 'android'"] +support-files = ["file_invalidEmailCase.sjs"] + +["test_invalidEmailCase.html"] diff --git a/services/fxaccounts/tests/mochitest/file_invalidEmailCase.sjs b/services/fxaccounts/tests/mochitest/file_invalidEmailCase.sjs new file mode 100644 index 0000000000..971cf52bba --- /dev/null +++ b/services/fxaccounts/tests/mochitest/file_invalidEmailCase.sjs @@ -0,0 +1,81 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * This server simulates the behavior of /account/login on the Firefox Accounts + * auth server in the case where the user is trying to sign in with an email + * with the wrong capitalization. + * + * https://github.com/mozilla/fxa-auth-server/blob/master/docs/api.md#post-v1accountlogin + * + * The expected behavior is that on the first attempt, with the wrong email, + * the server will respond with a 400 and the canonical email capitalization + * that the client should use. The client then has one chance to sign in with + * this different capitalization. + * + * In this test, the user with the account id "Greta.Garbo@gmail.COM" initially + * tries to sign in as "greta.garbo@gmail.com". + * + * On success, the client is responsible for updating its sign-in user state + * and recording the proper email capitalization. + */ + +const CC = Components.Constructor; +const BinaryInputStream = CC( + "@mozilla.org/binaryinputstream;1", + "nsIBinaryInputStream", + "setInputStream" +); + +const goodEmail = "Greta.Garbo@gmail.COM"; +const badEmail = "greta.garbo@gmail.com"; + +function handleRequest(request, response) { + let body = new BinaryInputStream(request.bodyInputStream); + let bytes = []; + let available; + while ((available = body.available()) > 0) { + Array.prototype.push.apply(bytes, body.readByteArray(available)); + } + + let data = JSON.parse(String.fromCharCode.apply(null, bytes)); + let message; + + switch (data.email) { + case badEmail: + // Almost - try again with fixed email case + message = { + code: 400, + errno: 120, + error: "Incorrect email case", + email: goodEmail, + }; + response.setStatusLine(request.httpVersion, 400, "Almost"); + break; + + case goodEmail: + // Successful login. + message = { + uid: "your-uid", + sessionToken: "your-sessionToken", + keyFetchToken: "your-keyFetchToken", + verified: true, + authAt: 1392144866, + }; + response.setStatusLine(request.httpVersion, 200, "Yay"); + break; + + default: + // Anything else happening in this test is a failure. + message = { + code: 400, + errno: 999, + error: "What happened!?", + }; + response.setStatusLine(request.httpVersion, 400, "Ouch"); + break; + } + + let messageStr = JSON.stringify(message); + response.bodyOutputStream.write(messageStr, messageStr.length); +} diff --git a/services/fxaccounts/tests/mochitest/test_invalidEmailCase.html b/services/fxaccounts/tests/mochitest/test_invalidEmailCase.html new file mode 100644 index 0000000000..4b5e943591 --- /dev/null +++ b/services/fxaccounts/tests/mochitest/test_invalidEmailCase.html @@ -0,0 +1,129 @@ +<!-- + Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ +--> +<!DOCTYPE HTML> +<html> +<!-- +Tests for Firefox Accounts signin with invalid email case +https://bugzilla.mozilla.org/show_bug.cgi?id=963835 +--> +<head> + <title>Test for Firefox Accounts (Bug 963835)</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css" /> +</head> +<body> + +<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=963835">Mozilla Bug 963835</a> +<p id="display"></p> +<div id="content" style="display: none"> + Test for correction of invalid email case in Fx Accounts signIn +</div> +<pre id="test"> +<script class="testbody" type="text/javascript"> + +SimpleTest.waitForExplicitFinish(); + +const {FxAccounts} = ChromeUtils.importESModule( + "resource://gre/modules/FxAccounts.sys.mjs" +); +const {FxAccountsClient} = ChromeUtils.importESModule( + "resource://gre/modules/FxAccountsClient.sys.mjs" +); + +const TEST_SERVER = + "http://mochi.test:8888/chrome/services/fxaccounts/tests/mochitest/file_invalidEmailCase.sjs?path="; + +let MockStorage = function() { + this.data = null; +}; +MockStorage.prototype = Object.freeze({ + set(contents) { + this.data = contents; + return Promise.resolve(null); + }, + get() { + return Promise.resolve(this.data); + }, + getOAuthTokens() { + return Promise.resolve(null); + }, + setOAuthTokens(contents) { + return Promise.resolve(); + }, +}); + +function MockFxAccounts() { + return new FxAccounts({ + _now_is: new Date(), + + now() { + return this._now_is; + }, + + signedInUserStorage: new MockStorage(), + + fxAccountsClient: new FxAccountsClient(TEST_SERVER), + }); +} + +let wrongEmail = "greta.garbo@gmail.com"; +let rightEmail = "Greta.Garbo@gmail.COM"; +let password = "123456"; + +function runTest() { + is(Services.prefs.getStringPref("identity.fxaccounts.auth.uri"), TEST_SERVER, + "Pref for auth.uri should be set to test server"); + + let fxa = new MockFxAccounts(); + let client = fxa._internal.fxAccountsClient; + + is(true, !!fxa, "Couldn't mock fxa"); + is(true, !!client, "Couldn't mock fxa client"); + is(client.host, TEST_SERVER, "Should be using the test auth server uri"); + + // First try to sign in using the email with the wrong capitalization. The + // FxAccountsClient will receive a 400 from the server with the corrected email. + // It will automatically try to sign in again. We expect this to succeed. + client.signIn(wrongEmail, password).then( + user => { + // Now store the signed-in user state. This will include the correct + // email capitalization. + fxa._internal.setSignedInUser(user).then( + () => { + // Confirm that the correct email got stored. + fxa.getSignedInUser().then( + data => { + is(data.email, rightEmail); + SimpleTest.finish(); + }, + getUserError => { + ok(false, JSON.stringify(getUserError)); + } + ); + }, + setSignedInUserError => { + ok(false, JSON.stringify(setSignedInUserError)); + } + ); + }, + signInError => { + ok(false, JSON.stringify(signInError)); + } + ); +} + +SpecialPowers.pushPrefEnv({"set": [ + ["identity.fxaccounts.enabled", true], // fx accounts + ["identity.fxaccounts.auth.uri", TEST_SERVER], // our sjs server + ["browser.dom.window.dump.enabled", true], + ["devtools.console.stdout.chrome", true], + ]}, + function() { runTest(); } +); + +</script> +</pre> +</body> +</html> diff --git a/services/fxaccounts/tests/xpcshell/head.js b/services/fxaccounts/tests/xpcshell/head.js new file mode 100644 index 0000000000..921888e2e3 --- /dev/null +++ b/services/fxaccounts/tests/xpcshell/head.js @@ -0,0 +1,38 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* import-globals-from ../../../common/tests/unit/head_helpers.js */ +/* import-globals-from ../../../common/tests/unit/head_http.js */ + +"use strict"; + +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); +const { SCOPE_OLD_SYNC } = ChromeUtils.importESModule( + "resource://gre/modules/FxAccountsCommon.sys.mjs" +); + +// Some mock key data, in both scoped-key and legacy field formats. +const MOCK_ACCOUNT_KEYS = { + scopedKeys: { + [SCOPE_OLD_SYNC]: { + kid: "1234567890123-u7u7u7u7u7u7u7u7u7u7uw", + k: "qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqg", + kty: "oct", + }, + }, +}; + +(function initFxAccountsTestingInfrastructure() { + do_get_profile(); + + let { initTestLogging } = ChromeUtils.importESModule( + "resource://testing-common/services/common/logging.sys.mjs" + ); + + initTestLogging("Trace"); +}).call(this); diff --git a/services/fxaccounts/tests/xpcshell/test_accounts.js b/services/fxaccounts/tests/xpcshell/test_accounts.js new file mode 100644 index 0000000000..c4aec73a03 --- /dev/null +++ b/services/fxaccounts/tests/xpcshell/test_accounts.js @@ -0,0 +1,1642 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { CryptoUtils } = ChromeUtils.importESModule( + "resource://services-crypto/utils.sys.mjs" +); +const { FxAccounts } = ChromeUtils.importESModule( + "resource://gre/modules/FxAccounts.sys.mjs" +); +const { FxAccountsClient } = ChromeUtils.importESModule( + "resource://gre/modules/FxAccountsClient.sys.mjs" +); +const { + ERRNO_INVALID_AUTH_TOKEN, + ERROR_NO_ACCOUNT, + FX_OAUTH_CLIENT_ID, + ONLOGIN_NOTIFICATION, + ONLOGOUT_NOTIFICATION, + ONVERIFIED_NOTIFICATION, + DEPRECATED_SCOPE_ECOSYSTEM_TELEMETRY, + PREF_LAST_FXA_USER, +} = ChromeUtils.importESModule( + "resource://gre/modules/FxAccountsCommon.sys.mjs" +); + +// We grab some additional stuff via backstage passes. +var { AccountState } = ChromeUtils.importESModule( + "resource://gre/modules/FxAccounts.sys.mjs" +); + +const MOCK_TOKEN_RESPONSE = { + access_token: + "43793fdfffec22eb39fc3c44ed09193a6fde4c24e5d6a73f73178597b268af69", + token_type: "bearer", + scope: "https://identity.mozilla.com/apps/oldsync", + expires_in: 21600, + auth_at: 1589579900, +}; + +initTestLogging("Trace"); + +var log = Log.repository.getLogger("Services.FxAccounts.test"); +log.level = Log.Level.Debug; + +// See verbose logging from FxAccounts.jsm and jwcrypto.jsm. +Services.prefs.setStringPref("identity.fxaccounts.loglevel", "Trace"); +Log.repository.getLogger("FirefoxAccounts").level = Log.Level.Trace; +Services.prefs.setStringPref("services.crypto.jwcrypto.log.level", "Debug"); + +/* + * The FxAccountsClient communicates with the remote Firefox + * Accounts auth server. Mock the server calls, with a little + * lag time to simulate some latency. + * + * We add the _verified attribute to mock the change in verification + * state on the FXA server. + */ + +function MockStorageManager() {} + +MockStorageManager.prototype = { + promiseInitialized: Promise.resolve(), + + initialize(accountData) { + this.accountData = accountData; + }, + + finalize() { + return Promise.resolve(); + }, + + getAccountData(fields = null) { + let result; + if (!this.accountData) { + result = null; + } else if (fields == null) { + // can't use cloneInto as the keys get upset... + result = {}; + for (let field of Object.keys(this.accountData)) { + result[field] = this.accountData[field]; + } + } else { + if (!Array.isArray(fields)) { + fields = [fields]; + } + result = {}; + for (let field of fields) { + result[field] = this.accountData[field]; + } + } + return Promise.resolve(result); + }, + + updateAccountData(updatedFields) { + if (!this.accountData) { + return Promise.resolve(); + } + for (let [name, value] of Object.entries(updatedFields)) { + if (value == null) { + delete this.accountData[name]; + } else { + this.accountData[name] = value; + } + } + return Promise.resolve(); + }, + + deleteAccountData() { + this.accountData = null; + return Promise.resolve(); + }, +}; + +function MockFxAccountsClient() { + this._email = "nobody@example.com"; + this._verified = false; + this._deletedOnServer = false; // for our accountStatus mock + + // mock calls up to the auth server to determine whether the + // user account has been verified + this.recoveryEmailStatus = async function (sessionToken) { + // simulate a call to /recovery_email/status + return { + email: this._email, + verified: this._verified, + }; + }; + + this.accountStatus = async function (uid) { + return !!uid && !this._deletedOnServer; + }; + + this.sessionStatus = async function () { + // If the sessionStatus check says an account is OK, we typically will not + // end up calling accountStatus - so this must return false if accountStatus + // would. + return !this._deletedOnServer; + }; + + this.accountKeys = function (keyFetchToken) { + return new Promise(resolve => { + do_timeout(50, () => { + resolve({ + kA: expandBytes("11"), + wrapKB: expandBytes("22"), + }); + }); + }); + }; + + this.getScopedKeyData = function (sessionToken, client_id, scopes) { + Assert.ok(sessionToken); + Assert.equal(client_id, FX_OAUTH_CLIENT_ID); + Assert.equal(scopes, SCOPE_OLD_SYNC); + return new Promise(resolve => { + do_timeout(50, () => { + resolve({ + "https://identity.mozilla.com/apps/oldsync": { + identifier: "https://identity.mozilla.com/apps/oldsync", + keyRotationSecret: + "0000000000000000000000000000000000000000000000000000000000000000", + keyRotationTimestamp: 1234567890123, + }, + }); + }); + }); + }; + + this.resendVerificationEmail = function (sessionToken) { + // Return the session token to show that we received it in the first place + return Promise.resolve(sessionToken); + }; + + this.signOut = () => Promise.resolve(); + + FxAccountsClient.apply(this); +} +MockFxAccountsClient.prototype = {}; +Object.setPrototypeOf( + MockFxAccountsClient.prototype, + FxAccountsClient.prototype +); +/* + * We need to mock the FxAccounts module's interfaces to external + * services, such as storage and the FxAccounts client. We also + * mock the now() method, so that we can simulate the passing of + * time and verify that signatures expire correctly. + */ +function MockFxAccounts(credentials = null) { + let result = new FxAccounts({ + VERIFICATION_POLL_TIMEOUT_INITIAL: 100, // 100ms + + _getCertificateSigned_calls: [], + _d_signCertificate: Promise.withResolvers(), + _now_is: new Date(), + now() { + return this._now_is; + }, + newAccountState(newCredentials) { + // we use a real accountState but mocked storage. + let storage = new MockStorageManager(); + storage.initialize(newCredentials); + return new AccountState(storage); + }, + fxAccountsClient: new MockFxAccountsClient(), + observerPreloads: [], + device: { + _registerOrUpdateDevice() {}, + _checkRemoteCommandsUpdateNeeded: async () => false, + }, + profile: { + getProfile() { + return null; + }, + }, + }); + // and for convenience so we don't have to touch as many lines in this test + // when we refactored FxAccounts.jsm :) + result.setSignedInUser = function (creds) { + return result._internal.setSignedInUser(creds); + }; + return result; +} + +/* + * Some tests want a "real" fxa instance - however, we still mock the storage + * to keep the tests fast on b2g. + */ +async function MakeFxAccounts({ internal = {}, credentials } = {}) { + if (!internal.newAccountState) { + // we use a real accountState but mocked storage. + internal.newAccountState = function (newCredentials) { + let storage = new MockStorageManager(); + storage.initialize(newCredentials); + return new AccountState(storage); + }; + } + if (!internal._signOutServer) { + internal._signOutServer = () => Promise.resolve(); + } + if (internal.device) { + if (!internal.device._registerOrUpdateDevice) { + internal.device._registerOrUpdateDevice = () => Promise.resolve(); + internal.device._checkRemoteCommandsUpdateNeeded = async () => false; + } + } else { + internal.device = { + _registerOrUpdateDevice() {}, + _checkRemoteCommandsUpdateNeeded: async () => false, + }; + } + if (!internal.observerPreloads) { + internal.observerPreloads = []; + } + let result = new FxAccounts(internal); + + if (credentials) { + await result._internal.setSignedInUser(credentials); + } + return result; +} + +add_task(async function test_get_signed_in_user_initially_unset() { + _("Check getSignedInUser initially and after signout reports no user"); + let account = await MakeFxAccounts(); + let credentials = { + email: "foo@example.com", + uid: "1234567890abcdef1234567890abcdef", + sessionToken: "dead", + verified: true, + ...MOCK_ACCOUNT_KEYS, + }; + let result = await account.getSignedInUser(); + Assert.equal(result, null); + + await account._internal.setSignedInUser(credentials); + + // getSignedInUser only returns a subset. + result = await account.getSignedInUser(); + Assert.deepEqual(result.email, credentials.email); + Assert.deepEqual(result.scopedKeys, undefined); + + // for the sake of testing, use the low-level function to check it's all there + result = await account._internal.currentAccountState.getUserAccountData(); + Assert.deepEqual(result.email, credentials.email); + Assert.deepEqual(result.scopedKeys, credentials.scopedKeys); + + // sign out + let localOnly = true; + await account.signOut(localOnly); + + // user should be undefined after sign out + result = await account.getSignedInUser(); + Assert.equal(result, null); +}); + +add_task(async function test_set_signed_in_user_signs_out_previous_account() { + _("Check setSignedInUser signs out the previous account."); + let signOutServerCalled = false; + let credentials = { + email: "foo@example.com", + uid: "1234567890abcdef1234567890abcdef", + sessionToken: "dead", + verified: true, + ...MOCK_ACCOUNT_KEYS, + }; + let account = await MakeFxAccounts({ credentials }); + + account._internal._signOutServer = () => { + signOutServerCalled = true; + return Promise.resolve(true); + }; + + await account._internal.setSignedInUser(credentials); + Assert.ok(signOutServerCalled); +}); + +add_task(async function test_update_account_data() { + _("Check updateUserAccountData does the right thing."); + let credentials = { + email: "foo@example.com", + uid: "1234567890abcdef1234567890abcdef", + sessionToken: "dead", + verified: true, + ...MOCK_ACCOUNT_KEYS, + }; + let account = await MakeFxAccounts({ credentials }); + + let newCreds = { + email: credentials.email, + uid: credentials.uid, + sessionToken: "alive", + }; + await account._internal.updateUserAccountData(newCreds); + Assert.equal( + (await account._internal.getUserAccountData()).sessionToken, + "alive", + "new field value was saved" + ); + + // but we should fail attempting to change the uid. + newCreds = { + email: credentials.email, + uid: "11111111111111111111222222222222", + sessionToken: "alive", + }; + await Assert.rejects( + account._internal.updateUserAccountData(newCreds), + /The specified credentials aren't for the current user/ + ); + + // should fail without the uid. + newCreds = { + sessionToken: "alive", + }; + await Assert.rejects( + account._internal.updateUserAccountData(newCreds), + /The specified credentials have no uid/ + ); + + // and should fail with a field name that's not known by storage. + newCreds = { + email: credentials.email, + uid: "11111111111111111111222222222222", + foo: "bar", + }; + await Assert.rejects( + account._internal.updateUserAccountData(newCreds), + /The specified credentials aren't for the current user/ + ); +}); + +// Sanity-check that our mocked client is working correctly +add_test(function test_client_mock() { + let fxa = new MockFxAccounts(); + let client = fxa._internal.fxAccountsClient; + Assert.equal(client._verified, false); + Assert.equal(typeof client.signIn, "function"); + + // The recoveryEmailStatus function eventually fulfills its promise + client.recoveryEmailStatus().then(response => { + Assert.equal(response.verified, false); + run_next_test(); + }); +}); + +// Sign in a user, and after a little while, verify the user's email. +// Right after signing in the user, we should get the 'onlogin' notification. +// Polling should detect that the email is verified, and eventually +// 'onverified' should be observed +add_test(function test_verification_poll() { + let fxa = new MockFxAccounts(); + let test_user = getTestUser("francine"); + let login_notification_received = false; + + makeObserver(ONVERIFIED_NOTIFICATION, function () { + log.debug("test_verification_poll observed onverified"); + // Once email verification is complete, we will observe onverified + fxa._internal + .getUserAccountData() + .then(user => { + // And confirm that the user's state has changed + Assert.equal(user.verified, true); + Assert.equal(user.email, test_user.email); + Assert.equal( + Services.prefs.getStringPref(PREF_LAST_FXA_USER), + CryptoUtils.sha256Base64(test_user.email) + ); + Assert.ok(login_notification_received); + }) + .finally(run_next_test); + }); + + makeObserver(ONLOGIN_NOTIFICATION, function () { + log.debug("test_verification_poll observer onlogin"); + login_notification_received = true; + }); + + fxa.setSignedInUser(test_user).then(() => { + fxa._internal.getUserAccountData().then(user => { + // The user is signing in, but email has not been verified yet + Assert.equal(user.verified, false); + do_timeout(200, function () { + log.debug("Mocking verification of francine's email"); + fxa._internal.fxAccountsClient._email = test_user.email; + fxa._internal.fxAccountsClient._verified = true; + }); + }); + }); +}); + +// Sign in the user, but never verify the email. The check-email +// poll should time out. No verifiedlogin event should be observed, and the +// internal whenVerified promise should be rejected +add_test(function test_polling_timeout() { + // This test could be better - the onverified observer might fire on + // somebody else's stack, and we're not making sure that we're not receiving + // such a message. In other words, this tests either failure, or success, but + // not both. + + let fxa = new MockFxAccounts(); + let test_user = getTestUser("carol"); + + let removeObserver = makeObserver(ONVERIFIED_NOTIFICATION, function () { + do_throw("We should not be getting a login event!"); + }); + + fxa._internal.POLL_SESSION = 1; + + let p = fxa._internal.whenVerified({}); + + fxa.setSignedInUser(test_user).then(() => { + p.then( + success => { + do_throw("this should not succeed"); + }, + fail => { + removeObserver(); + fxa.signOut().then(run_next_test); + } + ); + }); +}); + +// For bug 1585299 - ensure we only get a single ONVERIFIED notification. +add_task(async function test_onverified_once() { + let fxa = new MockFxAccounts(); + let user = getTestUser("francine"); + + let numNotifications = 0; + + function observe(aSubject, aTopic, aData) { + numNotifications += 1; + } + Services.obs.addObserver(observe, ONVERIFIED_NOTIFICATION); + + fxa._internal.POLL_SESSION = 1; + + await fxa.setSignedInUser(user); + + Assert.ok(!(await fxa.getSignedInUser()).verified, "starts unverified"); + + await fxa._internal.startPollEmailStatus( + fxa._internal.currentAccountState, + user.sessionToken, + "start" + ); + + Assert.ok(!(await fxa.getSignedInUser()).verified, "still unverified"); + + log.debug("Mocking verification of francine's email"); + fxa._internal.fxAccountsClient._email = user.email; + fxa._internal.fxAccountsClient._verified = true; + + await fxa._internal.startPollEmailStatus( + fxa._internal.currentAccountState, + user.sessionToken, + "again" + ); + + Assert.ok((await fxa.getSignedInUser()).verified, "now verified"); + + Assert.equal(numNotifications, 1, "expect exactly 1 ONVERIFIED"); + + Services.obs.removeObserver(observe, ONVERIFIED_NOTIFICATION); + await fxa.signOut(); +}); + +add_test(function test_pollEmailStatus_start_verified() { + let fxa = new MockFxAccounts(); + let test_user = getTestUser("carol"); + + fxa._internal.POLL_SESSION = 20 * 60000; + fxa._internal.VERIFICATION_POLL_TIMEOUT_INITIAL = 50000; + + fxa.setSignedInUser(test_user).then(() => { + fxa._internal.getUserAccountData().then(user => { + fxa._internal.fxAccountsClient._email = test_user.email; + fxa._internal.fxAccountsClient._verified = true; + const mock = sinon.mock(fxa._internal); + mock.expects("_scheduleNextPollEmailStatus").never(); + fxa._internal + .startPollEmailStatus( + fxa._internal.currentAccountState, + user.sessionToken, + "start" + ) + .then(() => { + mock.verify(); + mock.restore(); + run_next_test(); + }); + }); + }); +}); + +add_test(function test_pollEmailStatus_start() { + let fxa = new MockFxAccounts(); + let test_user = getTestUser("carol"); + + fxa._internal.POLL_SESSION = 20 * 60000; + fxa._internal.VERIFICATION_POLL_TIMEOUT_INITIAL = 123456; + + fxa.setSignedInUser(test_user).then(() => { + fxa._internal.getUserAccountData().then(user => { + const mock = sinon.mock(fxa._internal); + mock + .expects("_scheduleNextPollEmailStatus") + .once() + .withArgs( + fxa._internal.currentAccountState, + user.sessionToken, + 123456, + "start" + ); + fxa._internal + .startPollEmailStatus( + fxa._internal.currentAccountState, + user.sessionToken, + "start" + ) + .then(() => { + mock.verify(); + mock.restore(); + run_next_test(); + }); + }); + }); +}); + +add_test(function test_pollEmailStatus_start_subsequent() { + let fxa = new MockFxAccounts(); + let test_user = getTestUser("carol"); + + fxa._internal.POLL_SESSION = 20 * 60000; + fxa._internal.VERIFICATION_POLL_TIMEOUT_INITIAL = 123456; + fxa._internal.VERIFICATION_POLL_TIMEOUT_SUBSEQUENT = 654321; + fxa._internal.VERIFICATION_POLL_START_SLOWDOWN_THRESHOLD = -1; + + fxa.setSignedInUser(test_user).then(() => { + fxa._internal.getUserAccountData().then(user => { + const mock = sinon.mock(fxa._internal); + mock + .expects("_scheduleNextPollEmailStatus") + .once() + .withArgs( + fxa._internal.currentAccountState, + user.sessionToken, + 654321, + "start" + ); + fxa._internal + .startPollEmailStatus( + fxa._internal.currentAccountState, + user.sessionToken, + "start" + ) + .then(() => { + mock.verify(); + mock.restore(); + run_next_test(); + }); + }); + }); +}); + +add_test(function test_pollEmailStatus_browser_startup() { + let fxa = new MockFxAccounts(); + let test_user = getTestUser("carol"); + + fxa._internal.POLL_SESSION = 20 * 60000; + fxa._internal.VERIFICATION_POLL_TIMEOUT_SUBSEQUENT = 654321; + + fxa.setSignedInUser(test_user).then(() => { + fxa._internal.getUserAccountData().then(user => { + const mock = sinon.mock(fxa._internal); + mock + .expects("_scheduleNextPollEmailStatus") + .once() + .withArgs( + fxa._internal.currentAccountState, + user.sessionToken, + 654321, + "browser-startup" + ); + fxa._internal + .startPollEmailStatus( + fxa._internal.currentAccountState, + user.sessionToken, + "browser-startup" + ) + .then(() => { + mock.verify(); + mock.restore(); + run_next_test(); + }); + }); + }); +}); + +add_test(function test_pollEmailStatus_push() { + let fxa = new MockFxAccounts(); + let test_user = getTestUser("carol"); + + fxa.setSignedInUser(test_user).then(() => { + fxa._internal.getUserAccountData().then(user => { + const mock = sinon.mock(fxa._internal); + mock.expects("_scheduleNextPollEmailStatus").never(); + fxa._internal + .startPollEmailStatus( + fxa._internal.currentAccountState, + user.sessionToken, + "push" + ) + .then(() => { + mock.verify(); + mock.restore(); + run_next_test(); + }); + }); + }); +}); + +add_test(function test_getKeyForScope() { + let fxa = new MockFxAccounts(); + let user = getTestUser("eusebius"); + + // Once email has been verified, we will be able to get keys + user.verified = true; + + fxa.setSignedInUser(user).then(() => { + fxa._internal.getUserAccountData().then(user2 => { + // Before getKeyForScope, we have no keys + Assert.equal(!!user2.scopedKeys, false); + // And we still have a key-fetch token and unwrapBKey to use + Assert.equal(!!user2.keyFetchToken, true); + Assert.equal(!!user2.unwrapBKey, true); + + fxa.keys.getKeyForScope(SCOPE_OLD_SYNC).then(() => { + fxa._internal.getUserAccountData().then(user3 => { + // Now we should have keys + Assert.equal(fxa._internal.isUserEmailVerified(user3), true); + Assert.equal(!!user3.verified, true); + Assert.notEqual(null, user3.scopedKeys); + Assert.equal(user3.keyFetchToken, undefined); + Assert.equal(user3.unwrapBKey, undefined); + run_next_test(); + }); + }); + }); + }); +}); + +add_task( + async function test_getKeyForScope_scopedKeys_migration_removes_deprecated_high_level_keys() { + let fxa = new MockFxAccounts(); + let user = getTestUser("eusebius"); + + user.verified = true; + + // An account state with the deprecated kinto extension sync keys... + user.kExtSync = + "f5ccd9cfdefd9b1ac4d02c56964f59239d8dfa1ca326e63696982765c1352cdc" + + "5d78a5a9c633a6d25edfea0a6c221a3480332a49fd866f311c2e3508ddd07395"; + user.kExtKbHash = + "6192f1cc7dce95334455ba135fa1d8fca8f70e8f594ae318528de06f24ed0273"; + user.scopedKeys = { + ...MOCK_ACCOUNT_KEYS.scopedKeys, + }; + + await fxa.setSignedInUser(user); + // getKeyForScope will run the migration + await fxa.keys.getKeyForScope(SCOPE_OLD_SYNC); + let newUser = await fxa._internal.getUserAccountData(); + // Then, the deprecated keys will be removed + Assert.strictEqual(newUser.kExtSync, undefined); + Assert.strictEqual(newUser.kExtKbHash, undefined); + } +); + +add_task( + async function test_getKeyForScope_scopedKeys_migration_removes_deprecated_scoped_keys() { + let fxa = new MockFxAccounts(); + let user = getTestUser("eusebius"); + const DEPRECATED_SCOPE_WEBEXT_SYNC = "sync:addon_storage"; + const EXTRA_SCOPE = "an unknown, but non-deprecated scope"; + user.verified = true; + user.ecosystemUserId = "ecoUserId"; + user.ecosystemAnonId = "ecoAnonId"; + user.scopedKeys = { + ...MOCK_ACCOUNT_KEYS.scopedKeys, + [DEPRECATED_SCOPE_ECOSYSTEM_TELEMETRY]: + MOCK_ACCOUNT_KEYS.scopedKeys[SCOPE_OLD_SYNC], + [DEPRECATED_SCOPE_WEBEXT_SYNC]: + MOCK_ACCOUNT_KEYS.scopedKeys[SCOPE_OLD_SYNC], + [EXTRA_SCOPE]: MOCK_ACCOUNT_KEYS.scopedKeys[SCOPE_OLD_SYNC], + }; + + await fxa.setSignedInUser(user); + await fxa.keys.getKeyForScope(SCOPE_OLD_SYNC); + let newUser = await fxa._internal.getUserAccountData(); + // It should have removed the deprecated ecosystem_telemetry key, + // and the old kinto extension sync key + // but left the other keys intact. + const expectedScopedKeys = { + ...MOCK_ACCOUNT_KEYS.scopedKeys, + [EXTRA_SCOPE]: MOCK_ACCOUNT_KEYS.scopedKeys[SCOPE_OLD_SYNC], + }; + Assert.deepEqual(newUser.scopedKeys, expectedScopedKeys); + Assert.equal(newUser.ecosystemUserId, null); + Assert.equal(newUser.ecosystemAnonId, null); + } +); + +add_task(async function test_getKeyForScope_nonexistent_account() { + let fxa = new MockFxAccounts(); + let bismarck = getTestUser("bismarck"); + + let client = fxa._internal.fxAccountsClient; + client.accountStatus = () => Promise.resolve(false); + client.sessionStatus = () => Promise.resolve(false); + client.accountKeys = () => { + return Promise.reject({ + code: 401, + errno: ERRNO_INVALID_AUTH_TOKEN, + }); + }; + + await fxa.setSignedInUser(bismarck); + + let promiseLogout = new Promise(resolve => { + makeObserver(ONLOGOUT_NOTIFICATION, function () { + log.debug("test_getKeyForScope_nonexistent_account observed logout"); + resolve(); + }); + }); + + // XXX - the exception message here isn't ideal, but doesn't really matter... + await Assert.rejects( + fxa.keys.getKeyForScope(SCOPE_OLD_SYNC), + /A different user signed in/ + ); + + await promiseLogout; + + let user = await fxa._internal.getUserAccountData(); + Assert.equal(user, null); +}); + +// getKeyForScope with invalid keyFetchToken should delete keyFetchToken from storage +add_task(async function test_getKeyForScope_invalid_token() { + let fxa = new MockFxAccounts(); + let yusuf = getTestUser("yusuf"); + + let client = fxa._internal.fxAccountsClient; + client.accountStatus = () => Promise.resolve(true); // account exists. + client.sessionStatus = () => Promise.resolve(false); // session is invalid. + client.accountKeys = () => { + return Promise.reject({ + code: 401, + errno: ERRNO_INVALID_AUTH_TOKEN, + }); + }; + + await fxa.setSignedInUser(yusuf); + let user = await fxa._internal.getUserAccountData(); + Assert.notEqual(user.encryptedSendTabKeys, null); + + try { + await fxa.keys.getKeyForScope(SCOPE_OLD_SYNC); + Assert.ok(false); + } catch (err) { + Assert.equal(err.code, 401); + Assert.equal(err.errno, ERRNO_INVALID_AUTH_TOKEN); + } + + user = await fxa._internal.getUserAccountData(); + Assert.equal(user.email, yusuf.email); + Assert.equal(user.keyFetchToken, null); + // We verify that encryptedSendTabKeys are also wiped + // when a user's credentials are wiped + Assert.equal(user.encryptedSendTabKeys, null); + await fxa._internal.abortExistingFlow(); +}); + +// Test vectors from +// https://wiki.mozilla.org/Identity/AttachedServices/KeyServerProtocol#Test_Vectors +add_task(async function test_getKeyForScope_oldsync() { + let fxa = new MockFxAccounts(); + let client = fxa._internal.fxAccountsClient; + client.getScopedKeyData = () => + Promise.resolve({ + "https://identity.mozilla.com/apps/oldsync": { + identifier: "https://identity.mozilla.com/apps/oldsync", + keyRotationSecret: + "0000000000000000000000000000000000000000000000000000000000000000", + keyRotationTimestamp: 1510726317123, + }, + }); + + // We mock the server returning the wrapKB from our test vectors + client.accountKeys = async () => { + return { + wrapKB: CommonUtils.hexToBytes( + "404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f" + ), + }; + }; + + // We set the user to have the keyFetchToken and unwrapBKey from our test vectors + let user = { + ...getTestUser("eusebius"), + uid: "aeaa1725c7a24ff983c6295725d5fc9b", + keyFetchToken: + "808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9f", + unwrapBKey: + "6ea660be9c89ec355397f89afb282ea0bf21095760c8c5009bbcc894155bbe2a", + sessionToken: "mock session token, used in metadata request", + verified: true, + }; + await fxa.setSignedInUser(user); + + // We derive, persist and return the sync key + const key = await fxa.keys.getKeyForScope(SCOPE_OLD_SYNC); + + // We verify the key returned matches what we would expect from the test vectors + // kb = 2ee722fdd8ccaa721bdeb2d1b76560efef705b04349d9357c3e592cf4906e075 (from test vectors) + // + // kid can be verified by "${keyRotationTimestamp}-${sha256(kb)[0:16]}" + // + // k can be verified by HKDF(kb, undefined, "identity.mozilla.com/picl/v1/oldsync", 64) + Assert.deepEqual(key, { + scope: SCOPE_OLD_SYNC, + kid: "1510726317123-BAik7hEOdpGnPZnPBSdaTg", + k: "fwM5VZu0Spf5XcFRZYX2zk6MrqZP7zvovCBcvuKwgYMif3hz98FHmIVa3qjKjrW0J244Zj-P5oWaOcQbvypmpw", + kty: "oct", + }); +}); + +add_task(async function test_getScopedKeys_cached_key() { + let fxa = new MockFxAccounts(); + let user = { + ...getTestUser("eusebius"), + uid: "aeaa1725c7a24ff983c6295725d5fc9b", + verified: true, + ...MOCK_ACCOUNT_KEYS, + }; + + await fxa.setSignedInUser(user); + let key = await fxa.keys.getKeyForScope(SCOPE_OLD_SYNC); + Assert.deepEqual(key, { + scope: SCOPE_OLD_SYNC, + ...MOCK_ACCOUNT_KEYS.scopedKeys[SCOPE_OLD_SYNC], + }); +}); + +add_task(async function test_getScopedKeys_unavailable_scope() { + let fxa = new MockFxAccounts(); + let user = { + ...getTestUser("eusebius"), + uid: "aeaa1725c7a24ff983c6295725d5fc9b", + verified: true, + ...MOCK_ACCOUNT_KEYS, + }; + await fxa.setSignedInUser(user); + await Assert.rejects( + fxa.keys.getKeyForScope("otherkeybearingscope"), + /Key not available for scope/ + ); +}); + +add_task(async function test_getScopedKeys_misconfigured_fxa_server() { + let fxa = new MockFxAccounts(); + let client = fxa._internal.fxAccountsClient; + client.getScopedKeyData = () => + Promise.resolve({ + wrongscope: { + identifier: "wrongscope", + keyRotationSecret: + "0000000000000000000000000000000000000000000000000000000000000000", + keyRotationTimestamp: 1510726331712, + }, + }); + let user = { + ...getTestUser("eusebius"), + uid: "aeaa1725c7a24ff983c6295725d5fc9b", + verified: true, + keyFetchToken: + "808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9f", + unwrapBKey: + "6ea660be9c89ec355397f89afb282ea0bf21095760c8c5009bbcc894155bbe2a", + sessionToken: "mock session token, used in metadata request", + }; + await fxa.setSignedInUser(user); + await Assert.rejects( + fxa.keys.getKeyForScope(SCOPE_OLD_SYNC), + /The FxA server did not grant Firefox the `oldsync` scope/ + ); +}); + +add_task(async function test_setScopedKeys() { + const fxa = new MockFxAccounts(); + const user = { + ...getTestUser("foo"), + verified: true, + }; + await fxa.setSignedInUser(user); + await fxa.keys.setScopedKeys(MOCK_ACCOUNT_KEYS.scopedKeys); + const key = await fxa.keys.getKeyForScope(SCOPE_OLD_SYNC); + Assert.deepEqual(key, { + scope: SCOPE_OLD_SYNC, + ...MOCK_ACCOUNT_KEYS.scopedKeys[SCOPE_OLD_SYNC], + }); +}); + +add_task(async function test_setScopedKeys_user_not_signed_in() { + const fxa = new MockFxAccounts(); + await Assert.rejects( + fxa.keys.setScopedKeys(MOCK_ACCOUNT_KEYS.scopedKeys), + /Cannot persist keys, no user signed in/ + ); +}); + +// _fetchAndUnwrapAndDeriveKeys with no keyFetchToken should trigger signOut +// XXX - actually, it probably shouldn't - bug 1572313. +add_test(function test_fetchAndUnwrapAndDeriveKeys_no_token() { + let fxa = new MockFxAccounts(); + let user = getTestUser("lettuce.protheroe"); + delete user.keyFetchToken; + + makeObserver(ONLOGOUT_NOTIFICATION, function () { + log.debug("test_fetchAndUnwrapKeys_no_token observed logout"); + fxa._internal.getUserAccountData().then(user2 => { + fxa._internal.abortExistingFlow().then(run_next_test); + }); + }); + + fxa + .setSignedInUser(user) + .then(user2 => { + return fxa.keys._fetchAndUnwrapAndDeriveKeys(); + }) + .catch(error => { + log.info("setSignedInUser correctly rejected"); + }); +}); + +// Alice (User A) signs up but never verifies her email. Then Bob (User B) +// signs in with a verified email. Ensure that no sign-in events are triggered +// on Alice's behalf. In the end, Bob should be the signed-in user. +add_test(function test_overlapping_signins() { + let fxa = new MockFxAccounts(); + let alice = getTestUser("alice"); + let bob = getTestUser("bob"); + + makeObserver(ONVERIFIED_NOTIFICATION, function () { + log.debug("test_overlapping_signins observed onverified"); + // Once email verification is complete, we will observe onverified + fxa._internal.getUserAccountData().then(user => { + Assert.equal(user.email, bob.email); + Assert.equal(user.verified, true); + run_next_test(); + }); + }); + + // Alice is the user signing in; her email is unverified. + fxa.setSignedInUser(alice).then(() => { + log.debug("Alice signing in ..."); + fxa._internal.getUserAccountData().then(user => { + Assert.equal(user.email, alice.email); + Assert.equal(user.verified, false); + log.debug("Alice has not verified her email ..."); + + // Now Bob signs in instead and actually verifies his email + log.debug("Bob signing in ..."); + fxa.setSignedInUser(bob).then(() => { + do_timeout(200, function () { + // Mock email verification ... + log.debug("Bob verifying his email ..."); + fxa._internal.fxAccountsClient._verified = true; + }); + }); + }); + }); +}); + +add_task(async function test_resend_email_not_signed_in() { + let fxa = new MockFxAccounts(); + + try { + await fxa.resendVerificationEmail(); + } catch (err) { + Assert.equal(err.message, ERROR_NO_ACCOUNT); + return; + } + do_throw("Should not be able to resend email when nobody is signed in"); +}); + +add_task(async function test_accountStatus() { + let fxa = new MockFxAccounts(); + let alice = getTestUser("alice"); + + // If we have no user, we have no account server-side + let result = await fxa.checkAccountStatus(); + Assert.ok(!result); + // Set a user - the fxAccountsClient mock will say "ok". + await fxa.setSignedInUser(alice); + result = await fxa.checkAccountStatus(); + Assert.ok(result); + // flag the item as deleted on the server. + fxa._internal.fxAccountsClient._deletedOnServer = true; + result = await fxa.checkAccountStatus(); + Assert.ok(!result); + fxa._internal.fxAccountsClient._deletedOnServer = false; + await fxa.signOut(); +}); + +add_task(async function test_resend_email_invalid_token() { + let fxa = new MockFxAccounts(); + let sophia = getTestUser("sophia"); + Assert.notEqual(sophia.sessionToken, null); + + let client = fxa._internal.fxAccountsClient; + client.resendVerificationEmail = () => { + return Promise.reject({ + code: 401, + errno: ERRNO_INVALID_AUTH_TOKEN, + }); + }; + // This test wants the account to exist but the local session invalid. + client.accountStatus = uid => { + Assert.ok(uid, "got a uid to check"); + return Promise.resolve(true); + }; + client.sessionStatus = token => { + Assert.ok(token, "got a token to check"); + return Promise.resolve(false); + }; + + await fxa.setSignedInUser(sophia); + let user = await fxa._internal.getUserAccountData(); + Assert.equal(user.email, sophia.email); + Assert.equal(user.verified, false); + log.debug("Sophia wants verification email resent"); + + try { + await fxa.resendVerificationEmail(); + Assert.ok( + false, + "resendVerificationEmail should reject invalid session token" + ); + } catch (err) { + Assert.equal(err.code, 401); + Assert.equal(err.errno, ERRNO_INVALID_AUTH_TOKEN); + } + + user = await fxa._internal.getUserAccountData(); + Assert.equal(user.email, sophia.email); + Assert.equal(user.sessionToken, null); + await fxa._internal.abortExistingFlow(); +}); + +add_test(function test_resend_email() { + let fxa = new MockFxAccounts(); + let alice = getTestUser("alice"); + + let initialState = fxa._internal.currentAccountState; + + // Alice is the user signing in; her email is unverified. + fxa.setSignedInUser(alice).then(() => { + log.debug("Alice signing in"); + + // We're polling for the first email + Assert.ok(fxa._internal.currentAccountState !== initialState); + let aliceState = fxa._internal.currentAccountState; + + // The polling timer is ticking + Assert.ok(fxa._internal.currentTimer > 0); + + fxa._internal.getUserAccountData().then(user => { + Assert.equal(user.email, alice.email); + Assert.equal(user.verified, false); + log.debug("Alice wants verification email resent"); + + fxa.resendVerificationEmail().then(result => { + // Mock server response; ensures that the session token actually was + // passed to the client to make the hawk call + Assert.equal(result, "alice's session token"); + + // Timer was not restarted + Assert.ok(fxa._internal.currentAccountState === aliceState); + + // Timer is still ticking + Assert.ok(fxa._internal.currentTimer > 0); + + // Ok abort polling before we go on to the next test + fxa._internal.abortExistingFlow(); + run_next_test(); + }); + }); + }); +}); + +Services.prefs.setStringPref( + "identity.fxaccounts.remote.oauth.uri", + "https://example.com/v1" +); + +add_test(async function test_getOAuthTokenWithSessionToken() { + Services.prefs.setBoolPref( + "identity.fxaccounts.useSessionTokensForOAuth", + true + ); + let fxa = new MockFxAccounts(); + let alice = getTestUser("alice"); + alice.verified = true; + let oauthTokenCalled = false; + + let client = fxa._internal.fxAccountsClient; + client.accessTokenWithSessionToken = async ( + sessionTokenHex, + clientId, + scope, + ttl + ) => { + oauthTokenCalled = true; + Assert.equal(sessionTokenHex, "alice's session token"); + Assert.equal(clientId, "5882386c6d801776"); + Assert.equal(scope, "profile"); + Assert.equal(ttl, undefined); + return MOCK_TOKEN_RESPONSE; + }; + + await fxa.setSignedInUser(alice); + const result = await fxa.getOAuthToken({ scope: "profile" }); + Assert.ok(oauthTokenCalled); + Assert.equal(result, MOCK_TOKEN_RESPONSE.access_token); + Services.prefs.setBoolPref( + "identity.fxaccounts.useSessionTokensForOAuth", + false + ); + run_next_test(); +}); + +add_task(async function test_getOAuthTokenCachedWithSessionToken() { + Services.prefs.setBoolPref( + "identity.fxaccounts.useSessionTokensForOAuth", + true + ); + let fxa = new MockFxAccounts(); + let alice = getTestUser("alice"); + alice.verified = true; + let numOauthTokenCalls = 0; + + let client = fxa._internal.fxAccountsClient; + client.accessTokenWithSessionToken = async () => { + numOauthTokenCalls++; + return MOCK_TOKEN_RESPONSE; + }; + + await fxa.setSignedInUser(alice); + let result = await fxa.getOAuthToken({ + scope: "profile", + service: "test-service", + }); + Assert.equal(numOauthTokenCalls, 1); + Assert.equal( + result, + "43793fdfffec22eb39fc3c44ed09193a6fde4c24e5d6a73f73178597b268af69" + ); + + // requesting it again should not re-fetch the token. + result = await fxa.getOAuthToken({ + scope: "profile", + service: "test-service", + }); + Assert.equal(numOauthTokenCalls, 1); + Assert.equal( + result, + "43793fdfffec22eb39fc3c44ed09193a6fde4c24e5d6a73f73178597b268af69" + ); + // But requesting the same service and a different scope *will* get a new one. + result = await fxa.getOAuthToken({ + scope: "something-else", + service: "test-service", + }); + Assert.equal(numOauthTokenCalls, 2); + Assert.equal( + result, + "43793fdfffec22eb39fc3c44ed09193a6fde4c24e5d6a73f73178597b268af69" + ); + Services.prefs.setBoolPref( + "identity.fxaccounts.useSessionTokensForOAuth", + false + ); +}); + +add_test(function test_getOAuthTokenScopedWithSessionToken() { + let fxa = new MockFxAccounts(); + let alice = getTestUser("alice"); + alice.verified = true; + let numOauthTokenCalls = 0; + + let client = fxa._internal.fxAccountsClient; + client.accessTokenWithSessionToken = async ( + _sessionTokenHex, + _clientId, + scopeString + ) => { + equal(scopeString, "bar foo"); // scopes are sorted locally before request. + numOauthTokenCalls++; + return MOCK_TOKEN_RESPONSE; + }; + + fxa.setSignedInUser(alice).then(() => { + fxa.getOAuthToken({ scope: ["foo", "bar"] }).then(result => { + Assert.equal(numOauthTokenCalls, 1); + Assert.equal( + result, + "43793fdfffec22eb39fc3c44ed09193a6fde4c24e5d6a73f73178597b268af69" + ); + run_next_test(); + }); + }); +}); + +add_task(async function test_getOAuthTokenCachedScopeNormalization() { + let fxa = new MockFxAccounts(); + let alice = getTestUser("alice"); + alice.verified = true; + let numOAuthTokenCalls = 0; + + let client = fxa._internal.fxAccountsClient; + client.accessTokenWithSessionToken = async ( + _sessionTokenHex, + _clientId, + scopeString + ) => { + numOAuthTokenCalls++; + return MOCK_TOKEN_RESPONSE; + }; + + await fxa.setSignedInUser(alice); + let result = await fxa.getOAuthToken({ + scope: ["foo", "bar"], + service: "test-service", + }); + Assert.equal(numOAuthTokenCalls, 1); + Assert.equal( + result, + "43793fdfffec22eb39fc3c44ed09193a6fde4c24e5d6a73f73178597b268af69" + ); + + // requesting it again with the scope array in a different order should not re-fetch the token. + result = await fxa.getOAuthToken({ + scope: ["bar", "foo"], + service: "test-service", + }); + Assert.equal(numOAuthTokenCalls, 1); + Assert.equal( + result, + "43793fdfffec22eb39fc3c44ed09193a6fde4c24e5d6a73f73178597b268af69" + ); + // requesting it again with the scope array in different case should not re-fetch the token. + result = await fxa.getOAuthToken({ + scope: ["Bar", "Foo"], + service: "test-service", + }); + Assert.equal(numOAuthTokenCalls, 1); + Assert.equal( + result, + "43793fdfffec22eb39fc3c44ed09193a6fde4c24e5d6a73f73178597b268af69" + ); + // But requesting with a new entry in the array does fetch one. + result = await fxa.getOAuthToken({ + scope: ["foo", "bar", "etc"], + service: "test-service", + }); + Assert.equal(numOAuthTokenCalls, 2); + Assert.equal( + result, + "43793fdfffec22eb39fc3c44ed09193a6fde4c24e5d6a73f73178597b268af69" + ); +}); + +add_test(function test_getOAuthToken_invalid_param() { + let fxa = new MockFxAccounts(); + + fxa.getOAuthToken().catch(err => { + Assert.equal(err.message, "INVALID_PARAMETER"); + fxa.signOut().then(run_next_test); + }); +}); + +add_test(function test_getOAuthToken_invalid_scope_array() { + let fxa = new MockFxAccounts(); + + fxa.getOAuthToken({ scope: [] }).catch(err => { + Assert.equal(err.message, "INVALID_PARAMETER"); + fxa.signOut().then(run_next_test); + }); +}); + +add_test(function test_getOAuthToken_misconfigure_oauth_uri() { + let fxa = new MockFxAccounts(); + + const prevServerURL = Services.prefs.getStringPref( + "identity.fxaccounts.remote.oauth.uri" + ); + Services.prefs.deleteBranch("identity.fxaccounts.remote.oauth.uri"); + + fxa.getOAuthToken().catch(err => { + Assert.equal(err.message, "INVALID_PARAMETER"); + // revert the pref + Services.prefs.setStringPref( + "identity.fxaccounts.remote.oauth.uri", + prevServerURL + ); + fxa.signOut().then(run_next_test); + }); +}); + +add_test(function test_getOAuthToken_no_account() { + let fxa = new MockFxAccounts(); + + fxa._internal.currentAccountState.getUserAccountData = function () { + return Promise.resolve(null); + }; + + fxa.getOAuthToken({ scope: "profile" }).catch(err => { + Assert.equal(err.message, "NO_ACCOUNT"); + fxa.signOut().then(run_next_test); + }); +}); + +add_test(function test_getOAuthToken_unverified() { + let fxa = new MockFxAccounts(); + let alice = getTestUser("alice"); + + fxa.setSignedInUser(alice).then(() => { + fxa.getOAuthToken({ scope: "profile" }).catch(err => { + Assert.equal(err.message, "UNVERIFIED_ACCOUNT"); + fxa.signOut().then(run_next_test); + }); + }); +}); + +add_test(function test_getOAuthToken_error() { + let fxa = new MockFxAccounts(); + let alice = getTestUser("alice"); + alice.verified = true; + + let client = fxa._internal.fxAccountsClient; + client.accessTokenWithSessionToken = () => { + return Promise.reject("boom"); + }; + + fxa.setSignedInUser(alice).then(() => { + fxa.getOAuthToken({ scope: "profile" }).catch(err => { + equal(err.details, "boom"); + run_next_test(); + }); + }); +}); + +add_task(async function test_listAttachedOAuthClients() { + const ONE_HOUR = 60 * 60 * 1000; + const ONE_DAY = 24 * ONE_HOUR; + + const timestamp = Date.now(); + + let fxa = new MockFxAccounts(); + let alice = getTestUser("alice"); + alice.verified = true; + + let client = fxa._internal.fxAccountsClient; + client.attachedClients = async () => { + return { + body: [ + // This entry was previously filtered but no longer is! + { + clientId: "a2270f727f45f648", + deviceId: "deadbeef", + sessionTokenId: null, + name: "Firefox Preview (no session token)", + scope: ["profile", "https://identity.mozilla.com/apps/oldsync"], + lastAccessTime: Date.now(), + }, + { + clientId: "802d56ef2a9af9fa", + deviceId: null, + sessionTokenId: null, + name: "Firefox Monitor", + scope: ["profile"], + lastAccessTime: Date.now() - ONE_DAY - ONE_HOUR, + }, + { + clientId: "1f30e32975ae5112", + deviceId: null, + sessionTokenId: null, + name: "Firefox Send", + scope: ["profile", "https://identity.mozilla.com/apps/send"], + lastAccessTime: Date.now() - ONE_DAY * 2 - ONE_HOUR, + }, + // One with a future date should be impossible, but having a negative + // result here would almost certainly confuse something! + { + clientId: "future-date", + deviceId: null, + sessionTokenId: null, + name: "Whatever", + lastAccessTime: Date.now() + ONE_DAY, + }, + // A missing/null lastAccessTime should end up with a missing lastAccessedDaysAgo + { + clientId: "missing-date", + deviceId: null, + sessionTokenId: null, + name: "Whatever", + }, + ], + headers: { "x-timestamp": timestamp.toString() }, + }; + }; + + await fxa.setSignedInUser(alice); + const clients = await fxa.listAttachedOAuthClients(); + Assert.deepEqual(clients, [ + { + id: "a2270f727f45f648", + lastAccessedDaysAgo: 0, + }, + { + id: "802d56ef2a9af9fa", + lastAccessedDaysAgo: 1, + }, + { + id: "1f30e32975ae5112", + lastAccessedDaysAgo: 2, + }, + { + id: "future-date", + lastAccessedDaysAgo: 0, + }, + { + id: "missing-date", + lastAccessedDaysAgo: null, + }, + ]); +}); + +add_task(async function test_getSignedInUserProfile() { + let alice = getTestUser("alice"); + alice.verified = true; + + let mockProfile = { + getProfile() { + return Promise.resolve({ avatar: "image" }); + }, + tearDown() {}, + }; + let fxa = new FxAccounts({ + _signOutServer() { + return Promise.resolve(); + }, + device: { + _registerOrUpdateDevice() { + return Promise.resolve(); + }, + }, + }); + + await fxa._internal.setSignedInUser(alice); + fxa._internal._profile = mockProfile; + let result = await fxa.getSignedInUser(); + Assert.ok(!!result); + Assert.equal(result.avatar, "image"); +}); + +add_task(async function test_getSignedInUserProfile_error_uses_account_data() { + let fxa = new MockFxAccounts(); + let alice = getTestUser("alice"); + alice.verified = true; + + fxa._internal.getSignedInUser = function () { + return Promise.resolve({ email: "foo@bar.com" }); + }; + fxa._internal._profile = { + getProfile() { + return Promise.reject("boom"); + }, + tearDown() { + teardownCalled = true; + }, + }; + + let teardownCalled = false; + await fxa.setSignedInUser(alice); + let result = await fxa.getSignedInUser(); + Assert.deepEqual(result.avatar, null); + await fxa.signOut(); + Assert.ok(teardownCalled); +}); + +add_task(async function test_checkVerificationStatusFailed() { + let fxa = new MockFxAccounts(); + let alice = getTestUser("alice"); + alice.verified = true; + + let client = fxa._internal.fxAccountsClient; + client.recoveryEmailStatus = () => { + return Promise.reject({ + code: 401, + errno: ERRNO_INVALID_AUTH_TOKEN, + }); + }; + client.accountStatus = () => Promise.resolve(true); + client.sessionStatus = () => Promise.resolve(false); + + await fxa.setSignedInUser(alice); + let user = await fxa._internal.getUserAccountData(); + Assert.notEqual(alice.sessionToken, null); + Assert.equal(user.email, alice.email); + Assert.equal(user.verified, true); + + await fxa._internal.checkVerificationStatus(); + + user = await fxa._internal.getUserAccountData(); + Assert.equal(user.email, alice.email); + Assert.equal(user.sessionToken, null); +}); + +add_task(async function test_flushLogFile() { + _("Tests flushLogFile"); + let account = await MakeFxAccounts(); + let promiseObserved = new Promise(res => { + log.info("Adding flush-log-file observer."); + Services.obs.addObserver(function onFlushLogFile() { + Services.obs.removeObserver( + onFlushLogFile, + "service:log-manager:flush-log-file" + ); + res(); + }, "service:log-manager:flush-log-file"); + }); + account.flushLogFile(); + await promiseObserved; +}); + +/* + * End of tests. + * Utility functions follow. + */ + +function expandHex(two_hex) { + // Return a 64-character hex string, encoding 32 identical bytes. + let eight_hex = two_hex + two_hex + two_hex + two_hex; + let thirtytwo_hex = eight_hex + eight_hex + eight_hex + eight_hex; + return thirtytwo_hex + thirtytwo_hex; +} + +function expandBytes(two_hex) { + return CommonUtils.hexToBytes(expandHex(two_hex)); +} + +function getTestUser(name) { + return { + email: name + "@example.com", + uid: "1ad7f5024cc74ec1a209071fd2fae348", + sessionToken: name + "'s session token", + keyFetchToken: name + "'s keyfetch token", + unwrapBKey: expandHex("44"), + verified: false, + encryptedSendTabKeys: name + "'s encrypted Send tab keys", + }; +} + +function makeObserver(aObserveTopic, aObserveFunc) { + let observer = { + // nsISupports provides type management in C++ + // nsIObserver is to be an observer + QueryInterface: ChromeUtils.generateQI(["nsIObserver"]), + + observe(aSubject, aTopic, aData) { + log.debug("observed " + aTopic + " " + aData); + if (aTopic == aObserveTopic) { + removeMe(); + aObserveFunc(aSubject, aTopic, aData); + } + }, + }; + + function removeMe() { + log.debug("removing observer for " + aObserveTopic); + Services.obs.removeObserver(observer, aObserveTopic); + } + + Services.obs.addObserver(observer, aObserveTopic); + return removeMe; +} diff --git a/services/fxaccounts/tests/xpcshell/test_accounts_config.js b/services/fxaccounts/tests/xpcshell/test_accounts_config.js new file mode 100644 index 0000000000..33ace13c47 --- /dev/null +++ b/services/fxaccounts/tests/xpcshell/test_accounts_config.js @@ -0,0 +1,58 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { FxAccounts } = ChromeUtils.importESModule( + "resource://gre/modules/FxAccounts.sys.mjs" +); + +add_task( + async function test_non_https_remote_server_uri_with_requireHttps_false() { + Services.prefs.setBoolPref("identity.fxaccounts.allowHttp", true); + Services.prefs.setStringPref( + "identity.fxaccounts.remote.root", + "http://example.com/" + ); + Assert.equal( + await FxAccounts.config.promiseConnectAccountURI("test"), + "http://example.com/?context=fx_desktop_v3&entrypoint=test&action=email&service=sync" + ); + + Services.prefs.clearUserPref("identity.fxaccounts.remote.root"); + Services.prefs.clearUserPref("identity.fxaccounts.allowHttp"); + } +); + +add_task(async function test_non_https_remote_server_uri() { + Services.prefs.setStringPref( + "identity.fxaccounts.remote.root", + "http://example.com/" + ); + await Assert.rejects( + FxAccounts.config.promiseConnectAccountURI(), + /Firefox Accounts server must use HTTPS/ + ); + Services.prefs.clearUserPref("identity.fxaccounts.remote.root"); +}); + +add_task(async function test_is_production_config() { + // should start with no auto-config URL. + Assert.ok(!FxAccounts.config.getAutoConfigURL()); + // which means we are using prod. + Assert.ok(FxAccounts.config.isProductionConfig()); + + // Set an auto-config URL. + Services.prefs.setStringPref( + "identity.fxaccounts.autoconfig.uri", + "http://x" + ); + Assert.equal(FxAccounts.config.getAutoConfigURL(), "http://x"); + Assert.ok(!FxAccounts.config.isProductionConfig()); + + // Clear the auto-config URL, but set one of the other config params. + Services.prefs.clearUserPref("identity.fxaccounts.autoconfig.uri"); + Services.prefs.setStringPref("identity.sync.tokenserver.uri", "http://t"); + Assert.ok(!FxAccounts.config.isProductionConfig()); + Services.prefs.clearUserPref("identity.sync.tokenserver.uri"); +}); diff --git a/services/fxaccounts/tests/xpcshell/test_accounts_device_registration.js b/services/fxaccounts/tests/xpcshell/test_accounts_device_registration.js new file mode 100644 index 0000000000..68337eb69e --- /dev/null +++ b/services/fxaccounts/tests/xpcshell/test_accounts_device_registration.js @@ -0,0 +1,1204 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { FxAccounts } = ChromeUtils.importESModule( + "resource://gre/modules/FxAccounts.sys.mjs" +); +const { FxAccountsClient } = ChromeUtils.importESModule( + "resource://gre/modules/FxAccountsClient.sys.mjs" +); +const { FxAccountsDevice } = ChromeUtils.importESModule( + "resource://gre/modules/FxAccountsDevice.sys.mjs" +); +const { + ERRNO_DEVICE_SESSION_CONFLICT, + ERRNO_TOO_MANY_CLIENT_REQUESTS, + ERRNO_UNKNOWN_DEVICE, + ON_DEVICE_CONNECTED_NOTIFICATION, + ON_DEVICE_DISCONNECTED_NOTIFICATION, + ON_DEVICELIST_UPDATED, +} = ChromeUtils.importESModule( + "resource://gre/modules/FxAccountsCommon.sys.mjs" +); +var { AccountState } = ChromeUtils.importESModule( + "resource://gre/modules/FxAccounts.sys.mjs" +); + +initTestLogging("Trace"); + +var log = Log.repository.getLogger("Services.FxAccounts.test"); +log.level = Log.Level.Debug; + +const BOGUS_PUBLICKEY = + "BBXOKjUb84pzws1wionFpfCBjDuCh4-s_1b52WA46K5wYL2gCWEOmFKWn_NkS5nmJwTBuO8qxxdjAIDtNeklvQc"; +const BOGUS_AUTHKEY = "GSsIiaD2Mr83iPqwFNK4rw"; + +Services.prefs.setStringPref("identity.fxaccounts.loglevel", "Trace"); + +const DEVICE_REGISTRATION_VERSION = 42; + +function MockStorageManager() {} + +MockStorageManager.prototype = { + initialize(accountData) { + this.accountData = accountData; + }, + + finalize() { + return Promise.resolve(); + }, + + getAccountData() { + return Promise.resolve(this.accountData); + }, + + updateAccountData(updatedFields) { + for (let [name, value] of Object.entries(updatedFields)) { + if (value == null) { + delete this.accountData[name]; + } else { + this.accountData[name] = value; + } + } + return Promise.resolve(); + }, + + deleteAccountData() { + this.accountData = null; + return Promise.resolve(); + }, +}; + +function MockFxAccountsClient(device) { + this._email = "nobody@example.com"; + // Be careful relying on `this._verified` as it doesn't change if the user's + // state does via setting the `verified` flag in the user data. + this._verified = false; + this._deletedOnServer = false; // for testing accountStatus + + // mock calls up to the auth server to determine whether the + // user account has been verified + this.recoveryEmailStatus = function (sessionToken) { + // simulate a call to /recovery_email/status + return Promise.resolve({ + email: this._email, + verified: this._verified, + }); + }; + + this.accountKeys = function (keyFetchToken) { + Assert.ok(keyFetchToken, "must be called with a key-fetch-token"); + // ideally we'd check the verification status here to more closely simulate + // the server, but `this._verified` is a test-only construct and doesn't + // update when the user changes verification status. + Assert.ok(!this._deletedOnServer, "this test thinks the acct is deleted!"); + return { + kA: "test-ka", + wrapKB: "X".repeat(32), + }; + }; + + this.accountStatus = function (uid) { + return Promise.resolve(!!uid && !this._deletedOnServer); + }; + + this.registerDevice = (st, name, type) => + Promise.resolve({ id: device.id, name }); + this.updateDevice = (st, id, name) => Promise.resolve({ id, name }); + this.signOut = () => Promise.resolve({}); + this.getDeviceList = st => + Promise.resolve([ + { + id: device.id, + name: device.name, + type: device.type, + pushCallback: device.pushCallback, + pushEndpointExpired: device.pushEndpointExpired, + isCurrentDevice: st === device.sessionToken, + }, + ]); + + FxAccountsClient.apply(this); +} +MockFxAccountsClient.prototype = {}; +Object.setPrototypeOf( + MockFxAccountsClient.prototype, + FxAccountsClient.prototype +); + +async function MockFxAccounts(credentials, device = {}) { + let fxa = new FxAccounts({ + newAccountState(creds) { + // we use a real accountState but mocked storage. + let storage = new MockStorageManager(); + storage.initialize(creds); + return new AccountState(storage); + }, + fxAccountsClient: new MockFxAccountsClient(device, credentials), + fxaPushService: { + registerPushEndpoint() { + return new Promise(resolve => { + resolve({ + endpoint: "http://mochi.test:8888", + getKey(type) { + return ChromeUtils.base64URLDecode( + type === "auth" ? BOGUS_AUTHKEY : BOGUS_PUBLICKEY, + { padding: "ignore" } + ); + }, + }); + }); + }, + unsubscribe() { + return Promise.resolve(); + }, + }, + commands: { + async availableCommands() { + return {}; + }, + }, + device: { + DEVICE_REGISTRATION_VERSION, + _checkRemoteCommandsUpdateNeeded: async () => false, + }, + VERIFICATION_POLL_TIMEOUT_INITIAL: 1, + }); + fxa._internal.device._fxai = fxa._internal; + await fxa._internal.setSignedInUser(credentials); + Services.prefs.setStringPref( + "identity.fxaccounts.account.device.name", + device.name || "mock device name" + ); + return fxa; +} + +function updateUserAccountData(fxa, data) { + return fxa._internal.updateUserAccountData(data); +} + +add_task(async function test_updateDeviceRegistration_with_new_device() { + const deviceName = "foo"; + const deviceType = "bar"; + + const credentials = getTestUser("baz"); + const fxa = await MockFxAccounts(credentials, { name: deviceName }); + // Remove the current device registration (setSignedInUser does one!). + await updateUserAccountData(fxa, { uid: credentials.uid, device: null }); + + const spy = { + registerDevice: { count: 0, args: [] }, + updateDevice: { count: 0, args: [] }, + getDeviceList: { count: 0, args: [] }, + }; + const client = fxa._internal.fxAccountsClient; + client.registerDevice = function () { + spy.registerDevice.count += 1; + spy.registerDevice.args.push(arguments); + return Promise.resolve({ + id: "newly-generated device id", + createdAt: Date.now(), + name: deviceName, + type: deviceType, + }); + }; + client.updateDevice = function () { + spy.updateDevice.count += 1; + spy.updateDevice.args.push(arguments); + return Promise.resolve({}); + }; + client.getDeviceList = function () { + spy.getDeviceList.count += 1; + spy.getDeviceList.args.push(arguments); + return Promise.resolve([]); + }; + + await fxa.updateDeviceRegistration(); + + Assert.equal(spy.updateDevice.count, 0); + Assert.equal(spy.getDeviceList.count, 0); + Assert.equal(spy.registerDevice.count, 1); + Assert.equal(spy.registerDevice.args[0].length, 4); + Assert.equal(spy.registerDevice.args[0][0], credentials.sessionToken); + Assert.equal(spy.registerDevice.args[0][1], deviceName); + Assert.equal(spy.registerDevice.args[0][2], "desktop"); + Assert.equal( + spy.registerDevice.args[0][3].pushCallback, + "http://mochi.test:8888" + ); + Assert.equal(spy.registerDevice.args[0][3].pushPublicKey, BOGUS_PUBLICKEY); + Assert.equal(spy.registerDevice.args[0][3].pushAuthKey, BOGUS_AUTHKEY); + + const state = fxa._internal.currentAccountState; + const data = await state.getUserAccountData(); + + Assert.equal(data.device.id, "newly-generated device id"); + Assert.equal(data.device.registrationVersion, DEVICE_REGISTRATION_VERSION); + await fxa.signOut(true); +}); + +add_task(async function test_updateDeviceRegistration_with_existing_device() { + const deviceId = "my device id"; + const deviceName = "phil's device"; + + const credentials = getTestUser("pb"); + const fxa = await MockFxAccounts(credentials, { name: deviceName }); + await updateUserAccountData(fxa, { + uid: credentials.uid, + device: { + id: deviceId, + registeredCommandsKeys: [], + registrationVersion: 1, // < 42 + }, + }); + + const spy = { + registerDevice: { count: 0, args: [] }, + updateDevice: { count: 0, args: [] }, + getDeviceList: { count: 0, args: [] }, + }; + const client = fxa._internal.fxAccountsClient; + client.registerDevice = function () { + spy.registerDevice.count += 1; + spy.registerDevice.args.push(arguments); + return Promise.resolve({}); + }; + client.updateDevice = function () { + spy.updateDevice.count += 1; + spy.updateDevice.args.push(arguments); + return Promise.resolve({ + id: deviceId, + name: deviceName, + }); + }; + client.getDeviceList = function () { + spy.getDeviceList.count += 1; + spy.getDeviceList.args.push(arguments); + return Promise.resolve([]); + }; + await fxa.updateDeviceRegistration(); + + Assert.equal(spy.registerDevice.count, 0); + Assert.equal(spy.getDeviceList.count, 0); + Assert.equal(spy.updateDevice.count, 1); + Assert.equal(spy.updateDevice.args[0].length, 4); + Assert.equal(spy.updateDevice.args[0][0], credentials.sessionToken); + Assert.equal(spy.updateDevice.args[0][1], deviceId); + Assert.equal(spy.updateDevice.args[0][2], deviceName); + Assert.equal( + spy.updateDevice.args[0][3].pushCallback, + "http://mochi.test:8888" + ); + Assert.equal(spy.updateDevice.args[0][3].pushPublicKey, BOGUS_PUBLICKEY); + Assert.equal(spy.updateDevice.args[0][3].pushAuthKey, BOGUS_AUTHKEY); + + const state = fxa._internal.currentAccountState; + const data = await state.getUserAccountData(); + + Assert.equal(data.device.id, deviceId); + Assert.equal(data.device.registrationVersion, DEVICE_REGISTRATION_VERSION); + await fxa.signOut(true); +}); + +add_task( + async function test_updateDeviceRegistration_with_unknown_device_error() { + const deviceName = "foo"; + const deviceType = "bar"; + const currentDeviceId = "my device id"; + + const credentials = getTestUser("baz"); + const fxa = await MockFxAccounts(credentials, { name: deviceName }); + await updateUserAccountData(fxa, { + uid: credentials.uid, + device: { + id: currentDeviceId, + registeredCommandsKeys: [], + registrationVersion: 1, // < 42 + }, + }); + + const spy = { + registerDevice: { count: 0, args: [] }, + updateDevice: { count: 0, args: [] }, + getDeviceList: { count: 0, args: [] }, + }; + const client = fxa._internal.fxAccountsClient; + client.registerDevice = function () { + spy.registerDevice.count += 1; + spy.registerDevice.args.push(arguments); + return Promise.resolve({ + id: "a different newly-generated device id", + createdAt: Date.now(), + name: deviceName, + type: deviceType, + }); + }; + client.updateDevice = function () { + spy.updateDevice.count += 1; + spy.updateDevice.args.push(arguments); + return Promise.reject({ + code: 400, + errno: ERRNO_UNKNOWN_DEVICE, + }); + }; + client.getDeviceList = function () { + spy.getDeviceList.count += 1; + spy.getDeviceList.args.push(arguments); + return Promise.resolve([]); + }; + + await fxa.updateDeviceRegistration(); + + Assert.equal(spy.getDeviceList.count, 0); + Assert.equal(spy.registerDevice.count, 0); + Assert.equal(spy.updateDevice.count, 1); + Assert.equal(spy.updateDevice.args[0].length, 4); + Assert.equal(spy.updateDevice.args[0][0], credentials.sessionToken); + Assert.equal(spy.updateDevice.args[0][1], currentDeviceId); + Assert.equal(spy.updateDevice.args[0][2], deviceName); + Assert.equal( + spy.updateDevice.args[0][3].pushCallback, + "http://mochi.test:8888" + ); + Assert.equal(spy.updateDevice.args[0][3].pushPublicKey, BOGUS_PUBLICKEY); + Assert.equal(spy.updateDevice.args[0][3].pushAuthKey, BOGUS_AUTHKEY); + + const state = fxa._internal.currentAccountState; + const data = await state.getUserAccountData(); + + Assert.equal(null, data.device); + await fxa.signOut(true); + } +); + +add_task( + async function test_updateDeviceRegistration_with_device_session_conflict_error() { + const deviceName = "foo"; + const deviceType = "bar"; + const currentDeviceId = "my device id"; + const conflictingDeviceId = "conflicting device id"; + + const credentials = getTestUser("baz"); + const fxa = await MockFxAccounts(credentials, { name: deviceName }); + await updateUserAccountData(fxa, { + uid: credentials.uid, + device: { + id: currentDeviceId, + registeredCommandsKeys: [], + registrationVersion: 1, // < 42 + }, + }); + + const spy = { + registerDevice: { count: 0, args: [] }, + updateDevice: { count: 0, args: [], times: [] }, + getDeviceList: { count: 0, args: [] }, + }; + const client = fxa._internal.fxAccountsClient; + client.registerDevice = function () { + spy.registerDevice.count += 1; + spy.registerDevice.args.push(arguments); + return Promise.resolve({}); + }; + client.updateDevice = function () { + spy.updateDevice.count += 1; + spy.updateDevice.args.push(arguments); + spy.updateDevice.time = Date.now(); + if (spy.updateDevice.count === 1) { + return Promise.reject({ + code: 400, + errno: ERRNO_DEVICE_SESSION_CONFLICT, + }); + } + return Promise.resolve({ + id: conflictingDeviceId, + name: deviceName, + }); + }; + client.getDeviceList = function () { + spy.getDeviceList.count += 1; + spy.getDeviceList.args.push(arguments); + spy.getDeviceList.time = Date.now(); + return Promise.resolve([ + { + id: "ignore", + name: "ignore", + type: "ignore", + isCurrentDevice: false, + }, + { + id: conflictingDeviceId, + name: deviceName, + type: deviceType, + isCurrentDevice: true, + }, + ]); + }; + + await fxa.updateDeviceRegistration(); + + Assert.equal(spy.registerDevice.count, 0); + Assert.equal(spy.updateDevice.count, 1); + Assert.equal(spy.updateDevice.args[0].length, 4); + Assert.equal(spy.updateDevice.args[0][0], credentials.sessionToken); + Assert.equal(spy.updateDevice.args[0][1], currentDeviceId); + Assert.equal(spy.updateDevice.args[0][2], deviceName); + Assert.equal( + spy.updateDevice.args[0][3].pushCallback, + "http://mochi.test:8888" + ); + Assert.equal(spy.updateDevice.args[0][3].pushPublicKey, BOGUS_PUBLICKEY); + Assert.equal(spy.updateDevice.args[0][3].pushAuthKey, BOGUS_AUTHKEY); + Assert.equal(spy.getDeviceList.count, 1); + Assert.equal(spy.getDeviceList.args[0].length, 1); + Assert.equal(spy.getDeviceList.args[0][0], credentials.sessionToken); + Assert.ok(spy.getDeviceList.time >= spy.updateDevice.time); + + const state = fxa._internal.currentAccountState; + const data = await state.getUserAccountData(); + + Assert.equal(data.device.id, conflictingDeviceId); + Assert.equal(data.device.registrationVersion, null); + await fxa.signOut(true); + } +); + +add_task( + async function test_updateDeviceRegistration_with_unrecoverable_error() { + const deviceName = "foo"; + + const credentials = getTestUser("baz"); + const fxa = await MockFxAccounts(credentials, { name: deviceName }); + await updateUserAccountData(fxa, { uid: credentials.uid, device: null }); + + const spy = { + registerDevice: { count: 0, args: [] }, + updateDevice: { count: 0, args: [] }, + getDeviceList: { count: 0, args: [] }, + }; + const client = fxa._internal.fxAccountsClient; + client.registerDevice = function () { + spy.registerDevice.count += 1; + spy.registerDevice.args.push(arguments); + return Promise.reject({ + code: 400, + errno: ERRNO_TOO_MANY_CLIENT_REQUESTS, + }); + }; + client.updateDevice = function () { + spy.updateDevice.count += 1; + spy.updateDevice.args.push(arguments); + return Promise.resolve({}); + }; + client.getDeviceList = function () { + spy.getDeviceList.count += 1; + spy.getDeviceList.args.push(arguments); + return Promise.resolve([]); + }; + + await fxa.updateDeviceRegistration(); + + Assert.equal(spy.getDeviceList.count, 0); + Assert.equal(spy.updateDevice.count, 0); + Assert.equal(spy.registerDevice.count, 1); + Assert.equal(spy.registerDevice.args[0].length, 4); + + const state = fxa._internal.currentAccountState; + const data = await state.getUserAccountData(); + + Assert.equal(null, data.device); + await fxa.signOut(true); + } +); + +add_task( + async function test_getDeviceId_with_no_device_id_invokes_device_registration() { + const credentials = getTestUser("foo"); + credentials.verified = true; + const fxa = await MockFxAccounts(credentials); + await updateUserAccountData(fxa, { uid: credentials.uid, device: null }); + + const spy = { count: 0, args: [] }; + fxa._internal.currentAccountState.getUserAccountData = () => + Promise.resolve({ + email: credentials.email, + registrationVersion: DEVICE_REGISTRATION_VERSION, + }); + fxa._internal.device._registerOrUpdateDevice = function () { + spy.count += 1; + spy.args.push(arguments); + return Promise.resolve("bar"); + }; + + const result = await fxa.device.getLocalId(); + + Assert.equal(spy.count, 1); + Assert.equal(spy.args[0].length, 2); + Assert.equal(spy.args[0][1].email, credentials.email); + Assert.equal(null, spy.args[0][1].device); + Assert.equal(result, "bar"); + await fxa.signOut(true); + } +); + +add_task( + async function test_getDeviceId_with_registration_version_outdated_invokes_device_registration() { + const credentials = getTestUser("foo"); + credentials.verified = true; + const fxa = await MockFxAccounts(credentials); + + const spy = { count: 0, args: [] }; + fxa._internal.currentAccountState.getUserAccountData = () => + Promise.resolve({ + device: { + id: "my id", + registrationVersion: 0, + registeredCommandsKeys: [], + }, + }); + fxa._internal.device._registerOrUpdateDevice = function () { + spy.count += 1; + spy.args.push(arguments); + return Promise.resolve("wibble"); + }; + + const result = await fxa.device.getLocalId(); + + Assert.equal(spy.count, 1); + Assert.equal(spy.args[0].length, 2); + Assert.equal(spy.args[0][1].device.id, "my id"); + Assert.equal(result, "wibble"); + await fxa.signOut(true); + } +); + +add_task( + async function test_getDeviceId_with_device_id_and_uptodate_registration_version_doesnt_invoke_device_registration() { + const credentials = getTestUser("foo"); + credentials.verified = true; + const fxa = await MockFxAccounts(credentials); + + const spy = { count: 0 }; + fxa._internal.currentAccountState.getUserAccountData = async () => ({ + device: { + id: "foo's device id", + registrationVersion: DEVICE_REGISTRATION_VERSION, + registeredCommandsKeys: [], + }, + }); + fxa._internal.device._registerOrUpdateDevice = function () { + spy.count += 1; + return Promise.resolve("bar"); + }; + + const result = await fxa.device.getLocalId(); + + Assert.equal(spy.count, 0); + Assert.equal(result, "foo's device id"); + await fxa.signOut(true); + } +); + +add_task( + async function test_getDeviceId_with_device_id_and_with_no_registration_version_invokes_device_registration() { + const credentials = getTestUser("foo"); + credentials.verified = true; + const fxa = await MockFxAccounts(credentials); + + const spy = { count: 0, args: [] }; + fxa._internal.currentAccountState.getUserAccountData = () => + Promise.resolve({ device: { id: "wibble" } }); + fxa._internal.device._registerOrUpdateDevice = function () { + spy.count += 1; + spy.args.push(arguments); + return Promise.resolve("wibble"); + }; + + const result = await fxa.device.getLocalId(); + + Assert.equal(spy.count, 1); + Assert.equal(spy.args[0].length, 2); + Assert.equal(spy.args[0][1].device.id, "wibble"); + Assert.equal(result, "wibble"); + await fxa.signOut(true); + } +); + +add_task(async function test_verification_updates_registration() { + const deviceName = "foo"; + + const credentials = getTestUser("baz"); + const fxa = await MockFxAccounts(credentials, { + id: "device-id", + name: deviceName, + }); + + // We should already have a device registration, but without send-tab due to + // our inability to fetch keys for an unverified users. + const state = fxa._internal.currentAccountState; + const { device } = await state.getUserAccountData(); + Assert.equal(device.registeredCommandsKeys.length, 0); + + let updatePromise = new Promise(resolve => { + const old_registerOrUpdateDevice = fxa.device._registerOrUpdateDevice.bind( + fxa.device + ); + fxa.device._registerOrUpdateDevice = async function ( + currentState, + signedInUser + ) { + await old_registerOrUpdateDevice(currentState, signedInUser); + fxa.device._registerOrUpdateDevice = old_registerOrUpdateDevice; + resolve(); + }; + }); + + fxa._internal.checkEmailStatus = async function (sessionToken) { + credentials.verified = true; + return credentials; + }; + + await updatePromise; + + const { device: newDevice, encryptedSendTabKeys } = + await state.getUserAccountData(); + Assert.equal(newDevice.registeredCommandsKeys.length, 1); + Assert.notEqual(encryptedSendTabKeys, null); + await fxa.signOut(true); +}); + +add_task(async function test_devicelist_pushendpointexpired() { + const deviceId = "mydeviceid"; + const credentials = getTestUser("baz"); + credentials.verified = true; + const fxa = await MockFxAccounts(credentials); + await updateUserAccountData(fxa, { + uid: credentials.uid, + device: { + id: deviceId, + registeredCommandsKeys: [], + registrationVersion: 1, // < 42 + }, + }); + + const spy = { + updateDevice: { count: 0, args: [] }, + getDeviceList: { count: 0, args: [] }, + }; + const client = fxa._internal.fxAccountsClient; + client.updateDevice = function () { + spy.updateDevice.count += 1; + spy.updateDevice.args.push(arguments); + return Promise.resolve({}); + }; + client.getDeviceList = function () { + spy.getDeviceList.count += 1; + spy.getDeviceList.args.push(arguments); + return Promise.resolve([ + { + id: "mydeviceid", + name: "foo", + type: "desktop", + isCurrentDevice: true, + pushEndpointExpired: true, + pushCallback: "https://example.com", + }, + ]); + }; + let polledForMissedCommands = false; + fxa._internal.commands.pollDeviceCommands = () => { + polledForMissedCommands = true; + }; + + await fxa.device.refreshDeviceList(); + + Assert.equal(spy.getDeviceList.count, 1); + Assert.equal(spy.updateDevice.count, 1); + Assert.ok(polledForMissedCommands); + await fxa.signOut(true); +}); + +add_task(async function test_devicelist_nopushcallback() { + const deviceId = "mydeviceid"; + const credentials = getTestUser("baz"); + credentials.verified = true; + const fxa = await MockFxAccounts(credentials); + await updateUserAccountData(fxa, { + uid: credentials.uid, + device: { + id: deviceId, + registeredCommandsKeys: [], + registrationVersion: 1, + }, + }); + + const spy = { + updateDevice: { count: 0, args: [] }, + getDeviceList: { count: 0, args: [] }, + }; + const client = fxa._internal.fxAccountsClient; + client.updateDevice = function () { + spy.updateDevice.count += 1; + spy.updateDevice.args.push(arguments); + return Promise.resolve({}); + }; + client.getDeviceList = function () { + spy.getDeviceList.count += 1; + spy.getDeviceList.args.push(arguments); + return Promise.resolve([ + { + id: "mydeviceid", + name: "foo", + type: "desktop", + isCurrentDevice: true, + pushEndpointExpired: false, + pushCallback: null, + }, + ]); + }; + + let polledForMissedCommands = false; + fxa._internal.commands.pollDeviceCommands = () => { + polledForMissedCommands = true; + }; + + await fxa.device.refreshDeviceList(); + + Assert.equal(spy.getDeviceList.count, 1); + Assert.equal(spy.updateDevice.count, 1); + Assert.ok(polledForMissedCommands); + await fxa.signOut(true); +}); + +add_task(async function test_refreshDeviceList() { + let credentials = getTestUser("baz"); + + let storage = new MockStorageManager(); + storage.initialize(credentials); + let state = new AccountState(storage); + + let fxAccountsClient = new MockFxAccountsClient({ + id: "deviceAAAAAA", + name: "iPhone", + type: "phone", + pushCallback: "http://mochi.test:8888", + pushEndpointExpired: false, + sessionToken: credentials.sessionToken, + }); + let spy = { + getDeviceList: { count: 0 }, + }; + const deviceListUpdateObserver = { + count: 0, + observe(subject, topic, data) { + this.count++; + }, + }; + Services.obs.addObserver(deviceListUpdateObserver, ON_DEVICELIST_UPDATED); + + fxAccountsClient.getDeviceList = (function (old) { + return function getDeviceList() { + spy.getDeviceList.count += 1; + return old.apply(this, arguments); + }; + })(fxAccountsClient.getDeviceList); + let fxai = { + _now: Date.now(), + _generation: 0, + fxAccountsClient, + now() { + return this._now; + }, + withVerifiedAccountState(func) { + // Ensure `func` is called asynchronously, and simulate the possibility + // of a different user signng in while the promise is in-flight. + const currentGeneration = this._generation; + return Promise.resolve() + .then(_ => func(state)) + .then(result => { + if (currentGeneration < this._generation) { + throw new Error("Another user has signed in"); + } + return result; + }); + }, + fxaPushService: { + registerPushEndpoint() { + return new Promise(resolve => { + resolve({ + endpoint: "http://mochi.test:8888", + getKey(type) { + return ChromeUtils.base64URLDecode( + type === "auth" ? BOGUS_AUTHKEY : BOGUS_PUBLICKEY, + { padding: "ignore" } + ); + }, + }); + }); + }, + unsubscribe() { + return Promise.resolve(); + }, + getSubscription() { + return Promise.resolve({ + isExpired: () => { + return false; + }, + endpoint: "http://mochi.test:8888", + }); + }, + }, + async _handleTokenError(e) { + _(`Test failure: ${e} - ${e.stack}`); + throw e; + }, + }; + let device = new FxAccountsDevice(fxai); + device._checkRemoteCommandsUpdateNeeded = async () => false; + + Assert.equal( + device.recentDeviceList, + null, + "Should not have device list initially" + ); + Assert.ok(await device.refreshDeviceList(), "Should refresh list"); + Assert.equal( + deviceListUpdateObserver.count, + 1, + `${ON_DEVICELIST_UPDATED} was notified` + ); + Assert.deepEqual( + device.recentDeviceList, + [ + { + id: "deviceAAAAAA", + name: "iPhone", + type: "phone", + pushCallback: "http://mochi.test:8888", + pushEndpointExpired: false, + isCurrentDevice: true, + }, + ], + "Should fetch device list" + ); + Assert.equal( + spy.getDeviceList.count, + 1, + "Should make request to refresh list" + ); + Assert.ok( + !(await device.refreshDeviceList()), + "Should not refresh device list if fresh" + ); + Assert.equal( + deviceListUpdateObserver.count, + 1, + `${ON_DEVICELIST_UPDATED} was not notified` + ); + + fxai._now += device.TIME_BETWEEN_FXA_DEVICES_FETCH_MS; + + let refreshPromise = device.refreshDeviceList(); + let secondRefreshPromise = device.refreshDeviceList(); + Assert.ok( + await Promise.all([refreshPromise, secondRefreshPromise]), + "Should refresh list if stale" + ); + Assert.equal( + spy.getDeviceList.count, + 2, + "Should only make one request if called with pending request" + ); + Assert.equal( + deviceListUpdateObserver.count, + 2, + `${ON_DEVICELIST_UPDATED} only notified once` + ); + + device.observe(null, ON_DEVICE_CONNECTED_NOTIFICATION); + await device.refreshDeviceList(); + Assert.equal( + spy.getDeviceList.count, + 3, + "Should refresh device list after connecting new device" + ); + Assert.equal( + deviceListUpdateObserver.count, + 3, + `${ON_DEVICELIST_UPDATED} notified when new device connects` + ); + device.observe( + null, + ON_DEVICE_DISCONNECTED_NOTIFICATION, + JSON.stringify({ isLocalDevice: false }) + ); + await device.refreshDeviceList(); + Assert.equal( + spy.getDeviceList.count, + 4, + "Should refresh device list after disconnecting device" + ); + Assert.equal( + deviceListUpdateObserver.count, + 4, + `${ON_DEVICELIST_UPDATED} notified when device disconnects` + ); + device.observe( + null, + ON_DEVICE_DISCONNECTED_NOTIFICATION, + JSON.stringify({ isLocalDevice: true }) + ); + await device.refreshDeviceList(); + Assert.equal( + spy.getDeviceList.count, + 4, + "Should not refresh device list after disconnecting this device" + ); + Assert.equal( + deviceListUpdateObserver.count, + 4, + `${ON_DEVICELIST_UPDATED} not notified again` + ); + + let refreshBeforeResetPromise = device.refreshDeviceList({ + ignoreCached: true, + }); + fxai._generation++; + Assert.equal( + deviceListUpdateObserver.count, + 4, + `${ON_DEVICELIST_UPDATED} not notified` + ); + await Assert.rejects(refreshBeforeResetPromise, /Another user has signed in/); + + device.reset(); + Assert.equal( + device.recentDeviceList, + null, + "Should clear device list after resetting" + ); + Assert.ok( + await device.refreshDeviceList(), + "Should fetch new list after resetting" + ); + Assert.equal( + deviceListUpdateObserver.count, + 5, + `${ON_DEVICELIST_UPDATED} notified after reset` + ); + Services.obs.removeObserver(deviceListUpdateObserver, ON_DEVICELIST_UPDATED); +}); + +add_task(async function test_push_resubscribe() { + let credentials = getTestUser("baz"); + + let storage = new MockStorageManager(); + storage.initialize(credentials); + let state = new AccountState(storage); + + let mockDevice = { + id: "deviceAAAAAA", + name: "iPhone", + type: "phone", + pushCallback: "http://mochi.test:8888", + pushEndpointExpired: false, + sessionToken: credentials.sessionToken, + }; + + var mockSubscription = { + isExpired: () => { + return false; + }, + endpoint: "http://mochi.test:8888", + }; + + let fxAccountsClient = new MockFxAccountsClient(mockDevice); + + const spy = { + _registerOrUpdateDevice: { count: 0 }, + }; + + let fxai = { + _now: Date.now(), + _generation: 0, + fxAccountsClient, + now() { + return this._now; + }, + withVerifiedAccountState(func) { + // Ensure `func` is called asynchronously, and simulate the possibility + // of a different user signng in while the promise is in-flight. + const currentGeneration = this._generation; + return Promise.resolve() + .then(_ => func(state)) + .then(result => { + if (currentGeneration < this._generation) { + throw new Error("Another user has signed in"); + } + return result; + }); + }, + fxaPushService: { + registerPushEndpoint() { + return new Promise(resolve => { + resolve({ + endpoint: "http://mochi.test:8888", + getKey(type) { + return ChromeUtils.base64URLDecode( + type === "auth" ? BOGUS_AUTHKEY : BOGUS_PUBLICKEY, + { padding: "ignore" } + ); + }, + }); + }); + }, + unsubscribe() { + return Promise.resolve(); + }, + getSubscription() { + return Promise.resolve(mockSubscription); + }, + }, + commands: { + async pollDeviceCommands() {}, + }, + async _handleTokenError(e) { + _(`Test failure: ${e} - ${e.stack}`); + throw e; + }, + }; + let device = new FxAccountsDevice(fxai); + device._checkRemoteCommandsUpdateNeeded = async () => false; + device._registerOrUpdateDevice = async () => { + spy._registerOrUpdateDevice.count += 1; + }; + + Assert.ok(await device.refreshDeviceList(), "Should refresh list"); + Assert.equal(spy._registerOrUpdateDevice.count, 0, "not expecting a refresh"); + + mockDevice.pushEndpointExpired = true; + Assert.ok( + await device.refreshDeviceList({ ignoreCached: true }), + "Should refresh list" + ); + Assert.equal( + spy._registerOrUpdateDevice.count, + 1, + "end-point expired means should resubscribe" + ); + + mockDevice.pushEndpointExpired = false; + mockSubscription.isExpired = () => true; + Assert.ok( + await device.refreshDeviceList({ ignoreCached: true }), + "Should refresh list" + ); + Assert.equal( + spy._registerOrUpdateDevice.count, + 2, + "push service saying expired should resubscribe" + ); + + mockSubscription.isExpired = () => false; + mockSubscription.endpoint = "something-else"; + Assert.ok( + await device.refreshDeviceList({ ignoreCached: true }), + "Should refresh list" + ); + Assert.equal( + spy._registerOrUpdateDevice.count, + 3, + "push service endpoint diff should resubscribe" + ); + + mockSubscription = null; + Assert.ok( + await device.refreshDeviceList({ ignoreCached: true }), + "Should refresh list" + ); + Assert.equal( + spy._registerOrUpdateDevice.count, + 4, + "push service saying no sub should resubscribe" + ); + + // reset everything to make sure we didn't leave something behind causing the above to + // not check what we thought it was. + mockSubscription = { + isExpired: () => { + return false; + }, + endpoint: "http://mochi.test:8888", + }; + Assert.ok( + await device.refreshDeviceList({ ignoreCached: true }), + "Should refresh list" + ); + Assert.equal( + spy._registerOrUpdateDevice.count, + 4, + "resetting to good data should not resubscribe" + ); +}); + +add_task(async function test_checking_remote_availableCommands_mismatch() { + const credentials = getTestUser("baz"); + credentials.verified = true; + const fxa = await MockFxAccounts(credentials); + fxa.device._checkRemoteCommandsUpdateNeeded = + FxAccountsDevice.prototype._checkRemoteCommandsUpdateNeeded; + fxa.commands.availableCommands = async () => { + return { + "https://identity.mozilla.com/cmd/open-uri": "local-keys", + }; + }; + + const ourDevice = { + isCurrentDevice: true, + availableCommands: { + "https://identity.mozilla.com/cmd/open-uri": "remote-keys", + }, + }; + Assert.ok( + await fxa.device._checkRemoteCommandsUpdateNeeded( + ourDevice.availableCommands + ) + ); +}); + +add_task(async function test_checking_remote_availableCommands_match() { + const credentials = getTestUser("baz"); + credentials.verified = true; + const fxa = await MockFxAccounts(credentials); + fxa.device._checkRemoteCommandsUpdateNeeded = + FxAccountsDevice.prototype._checkRemoteCommandsUpdateNeeded; + fxa.commands.availableCommands = async () => { + return { + "https://identity.mozilla.com/cmd/open-uri": "local-keys", + }; + }; + + const ourDevice = { + isCurrentDevice: true, + availableCommands: { + "https://identity.mozilla.com/cmd/open-uri": "local-keys", + }, + }; + Assert.ok( + !(await fxa.device._checkRemoteCommandsUpdateNeeded( + ourDevice.availableCommands + )) + ); +}); + +function getTestUser(name) { + return { + email: name + "@example.com", + uid: "1ad7f502-4cc7-4ec1-a209-071fd2fae348", + sessionToken: name + "'s session token", + verified: false, + ...MOCK_ACCOUNT_KEYS, + }; +} diff --git a/services/fxaccounts/tests/xpcshell/test_client.js b/services/fxaccounts/tests/xpcshell/test_client.js new file mode 100644 index 0000000000..f3cc48a70e --- /dev/null +++ b/services/fxaccounts/tests/xpcshell/test_client.js @@ -0,0 +1,966 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { FxAccountsClient } = ChromeUtils.importESModule( + "resource://gre/modules/FxAccountsClient.sys.mjs" +); + +const FAKE_SESSION_TOKEN = + "a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf"; + +// https://wiki.mozilla.org/Identity/AttachedServices/KeyServerProtocol#.2Faccount.2Fkeys +var ACCOUNT_KEYS = { + keyFetch: h( + // eslint-disable-next-line no-useless-concat + "8081828384858687 88898a8b8c8d8e8f" + "9091929394959697 98999a9b9c9d9e9f" + ), + + response: h( + "ee5c58845c7c9412 b11bbd20920c2fdd" + + "d83c33c9cd2c2de2 d66b222613364636" + + "c2c0f8cfbb7c6304 72c0bd88451342c6" + + "c05b14ce342c5ad4 6ad89e84464c993c" + + "3927d30230157d08 17a077eef4b20d97" + + "6f7a97363faf3f06 4c003ada7d01aa70" + ), + + kA: h( + // eslint-disable-next-line no-useless-concat + "2021222324252627 28292a2b2c2d2e2f" + "3031323334353637 38393a3b3c3d3e3f" + ), + + wrapKB: h( + // eslint-disable-next-line no-useless-concat + "4041424344454647 48494a4b4c4d4e4f" + "5051525354555657 58595a5b5c5d5e5f" + ), +}; + +add_task(async function test_authenticated_get_request() { + let message = '{"msg": "Great Success!"}'; + let credentials = { + id: "eyJleHBpcmVzIjogMTM2NTAxMDg5OC4x", + key: "qTZf4ZFpAMpMoeSsX3zVRjiqmNs=", + algorithm: "sha256", + }; + let method = "GET"; + + let server = httpd_setup({ + "/foo": function (request, response) { + Assert.ok(request.hasHeader("Authorization")); + + response.setStatusLine(request.httpVersion, 200, "OK"); + response.bodyOutputStream.write(message, message.length); + }, + }); + + let client = new FxAccountsClient(server.baseURI); + + let result = await client._request("/foo", method, credentials); + Assert.equal("Great Success!", result.msg); + + await promiseStopServer(server); +}); + +add_task(async function test_authenticated_post_request() { + let credentials = { + id: "eyJleHBpcmVzIjogMTM2NTAxMDg5OC4x", + key: "qTZf4ZFpAMpMoeSsX3zVRjiqmNs=", + algorithm: "sha256", + }; + let method = "POST"; + + let server = httpd_setup({ + "/foo": function (request, response) { + Assert.ok(request.hasHeader("Authorization")); + + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "application/json"); + response.bodyOutputStream.writeFrom( + request.bodyInputStream, + request.bodyInputStream.available() + ); + }, + }); + + let client = new FxAccountsClient(server.baseURI); + + let result = await client._request("/foo", method, credentials, { + foo: "bar", + }); + Assert.equal("bar", result.foo); + + await promiseStopServer(server); +}); + +add_task(async function test_500_error() { + let message = "<h1>Ooops!</h1>"; + let method = "GET"; + + let server = httpd_setup({ + "/foo": function (request, response) { + response.setStatusLine(request.httpVersion, 500, "Internal Server Error"); + response.bodyOutputStream.write(message, message.length); + }, + }); + + let client = new FxAccountsClient(server.baseURI); + + try { + await client._request("/foo", method); + do_throw("Expected to catch an exception"); + } catch (e) { + Assert.equal(500, e.code); + Assert.equal("Internal Server Error", e.message); + } + + await promiseStopServer(server); +}); + +add_task(async function test_backoffError() { + let method = "GET"; + let server = httpd_setup({ + "/retryDelay": function (request, response) { + response.setHeader("Retry-After", "30"); + response.setStatusLine( + request.httpVersion, + 429, + "Client has sent too many requests" + ); + let message = "<h1>Ooops!</h1>"; + response.bodyOutputStream.write(message, message.length); + }, + "/duringDelayIShouldNotBeCalled": function (request, response) { + response.setStatusLine(request.httpVersion, 200, "OK"); + let jsonMessage = '{"working": "yes"}'; + response.bodyOutputStream.write(jsonMessage, jsonMessage.length); + }, + }); + + let client = new FxAccountsClient(server.baseURI); + + // Retry-After header sets client.backoffError + Assert.equal(client.backoffError, null); + try { + await client._request("/retryDelay", method); + } catch (e) { + Assert.equal(429, e.code); + Assert.equal(30, e.retryAfter); + Assert.notEqual(typeof client.fxaBackoffTimer, "undefined"); + Assert.notEqual(client.backoffError, null); + } + // While delay is in effect, client short-circuits any requests + // and re-rejects with previous error. + try { + await client._request("/duringDelayIShouldNotBeCalled", method); + throw new Error("I should not be reached"); + } catch (e) { + Assert.equal(e.retryAfter, 30); + Assert.equal(e.message, "Client has sent too many requests"); + Assert.notEqual(client.backoffError, null); + } + // Once timer fires, client nulls error out and HTTP calls work again. + client._clearBackoff(); + let result = await client._request("/duringDelayIShouldNotBeCalled", method); + Assert.equal(client.backoffError, null); + Assert.equal(result.working, "yes"); + + await promiseStopServer(server); +}); + +add_task(async function test_signUp() { + let creationMessage_noKey = JSON.stringify({ + uid: "uid", + sessionToken: "sessionToken", + }); + let creationMessage_withKey = JSON.stringify({ + uid: "uid", + sessionToken: "sessionToken", + keyFetchToken: "keyFetchToken", + }); + let errorMessage = JSON.stringify({ + code: 400, + errno: 101, + error: "account exists", + }); + let created = false; + + // Note these strings must be unicode and not already utf-8 encoded. + let unicodeUsername = "andr\xe9@example.org"; // 'andré@example.org' + let unicodePassword = "p\xe4ssw\xf6rd"; // 'pässwörd' + let server = httpd_setup({ + "/account/create": function (request, response) { + let body = CommonUtils.readBytesFromInputStream(request.bodyInputStream); + body = CommonUtils.decodeUTF8(body); + let jsonBody = JSON.parse(body); + + // https://github.com/mozilla/fxa-auth-server/wiki/onepw-protocol#wiki-test-vectors + + if (created) { + // Error trying to create same account a second time + response.setStatusLine(request.httpVersion, 400, "Bad request"); + response.bodyOutputStream.write(errorMessage, errorMessage.length); + return; + } + + if (jsonBody.email == unicodeUsername) { + Assert.equal("", request._queryString); + Assert.equal( + jsonBody.authPW, + "247b675ffb4c46310bc87e26d712153abe5e1c90ef00a4784594f97ef54f2375" + ); + + response.setStatusLine(request.httpVersion, 200, "OK"); + response.bodyOutputStream.write( + creationMessage_noKey, + creationMessage_noKey.length + ); + return; + } + + if (jsonBody.email == "you@example.org") { + Assert.equal("keys=true", request._queryString); + Assert.equal( + jsonBody.authPW, + "e5c1cdfdaa5fcee06142db865b212cc8ba8abee2a27d639d42c139f006cdb930" + ); + created = true; + + response.setStatusLine(request.httpVersion, 200, "OK"); + response.bodyOutputStream.write( + creationMessage_withKey, + creationMessage_withKey.length + ); + return; + } + // just throwing here doesn't make any log noise, so have an assertion + // fail instead. + Assert.ok(false, "unexpected email: " + jsonBody.email); + }, + }); + + // Try to create an account without retrieving optional keys. + let client = new FxAccountsClient(server.baseURI); + let result = await client.signUp(unicodeUsername, unicodePassword); + Assert.equal("uid", result.uid); + Assert.equal("sessionToken", result.sessionToken); + Assert.equal(undefined, result.keyFetchToken); + Assert.equal( + result.unwrapBKey, + "de6a2648b78284fcb9ffa81ba95803309cfba7af583c01a8a1a63e567234dd28" + ); + + // Try to create an account retrieving optional keys. + result = await client.signUp("you@example.org", "pässwörd", true); + Assert.equal("uid", result.uid); + Assert.equal("sessionToken", result.sessionToken); + Assert.equal("keyFetchToken", result.keyFetchToken); + Assert.equal( + result.unwrapBKey, + "f589225b609e56075d76eb74f771ff9ab18a4dc0e901e131ba8f984c7fb0ca8c" + ); + + // Try to create an existing account. Triggers error path. + try { + result = await client.signUp(unicodeUsername, unicodePassword); + do_throw("Expected to catch an exception"); + } catch (expectedError) { + Assert.equal(101, expectedError.errno); + } + + await promiseStopServer(server); +}); + +add_task(async function test_signIn() { + let sessionMessage_noKey = JSON.stringify({ + sessionToken: FAKE_SESSION_TOKEN, + }); + let sessionMessage_withKey = JSON.stringify({ + sessionToken: FAKE_SESSION_TOKEN, + keyFetchToken: "keyFetchToken", + }); + let errorMessage_notExistent = JSON.stringify({ + code: 400, + errno: 102, + error: "doesn't exist", + }); + let errorMessage_wrongCap = JSON.stringify({ + code: 400, + errno: 120, + error: "Incorrect email case", + email: "you@example.com", + }); + + // Note this strings must be unicode and not already utf-8 encoded. + let unicodeUsername = "m\xe9@example.com"; // 'mé@example.com' + let server = httpd_setup({ + "/account/login": function (request, response) { + let body = CommonUtils.readBytesFromInputStream(request.bodyInputStream); + body = CommonUtils.decodeUTF8(body); + let jsonBody = JSON.parse(body); + + if (jsonBody.email == unicodeUsername) { + Assert.equal("", request._queryString); + Assert.equal( + jsonBody.authPW, + "08b9d111196b8408e8ed92439da49206c8ecfbf343df0ae1ecefcd1e0174a8b6" + ); + response.setStatusLine(request.httpVersion, 200, "OK"); + response.bodyOutputStream.write( + sessionMessage_noKey, + sessionMessage_noKey.length + ); + } else if (jsonBody.email == "you@example.com") { + Assert.equal("keys=true", request._queryString); + Assert.equal( + jsonBody.authPW, + "93d20ec50304d496d0707ec20d7e8c89459b6396ec5dd5b9e92809c5e42856c7" + ); + response.setStatusLine(request.httpVersion, 200, "OK"); + response.bodyOutputStream.write( + sessionMessage_withKey, + sessionMessage_withKey.length + ); + } else if (jsonBody.email == "You@example.com") { + // Error trying to sign in with a wrong capitalization + response.setStatusLine(request.httpVersion, 400, "Bad request"); + response.bodyOutputStream.write( + errorMessage_wrongCap, + errorMessage_wrongCap.length + ); + } else { + // Error trying to sign in to nonexistent account + response.setStatusLine(request.httpVersion, 400, "Bad request"); + response.bodyOutputStream.write( + errorMessage_notExistent, + errorMessage_notExistent.length + ); + } + }, + }); + + // Login without retrieving optional keys + let client = new FxAccountsClient(server.baseURI); + let result = await client.signIn(unicodeUsername, "bigsecret"); + Assert.equal(FAKE_SESSION_TOKEN, result.sessionToken); + Assert.equal( + result.unwrapBKey, + "c076ec3f4af123a615157154c6e1d0d6293e514fd7b0221e32d50517ecf002b8" + ); + Assert.equal(undefined, result.keyFetchToken); + + // Login with retrieving optional keys + result = await client.signIn("you@example.com", "bigsecret", true); + Assert.equal(FAKE_SESSION_TOKEN, result.sessionToken); + Assert.equal( + result.unwrapBKey, + "65970516211062112e955d6420bebe020269d6b6a91ebd288319fc8d0cb49624" + ); + Assert.equal("keyFetchToken", result.keyFetchToken); + + // Retry due to wrong email capitalization + result = await client.signIn("You@example.com", "bigsecret", true); + Assert.equal(FAKE_SESSION_TOKEN, result.sessionToken); + Assert.equal( + result.unwrapBKey, + "65970516211062112e955d6420bebe020269d6b6a91ebd288319fc8d0cb49624" + ); + Assert.equal("keyFetchToken", result.keyFetchToken); + + // Trigger error path + try { + result = await client.signIn("yøü@bad.example.org", "nofear"); + do_throw("Expected to catch an exception"); + } catch (expectedError) { + Assert.equal(102, expectedError.errno); + } + + await promiseStopServer(server); +}); + +add_task(async function test_signOut() { + let signoutMessage = JSON.stringify({}); + let errorMessage = JSON.stringify({ + code: 400, + errno: 102, + error: "doesn't exist", + }); + let signedOut = false; + + let server = httpd_setup({ + "/session/destroy": function (request, response) { + if (!signedOut) { + signedOut = true; + Assert.ok(request.hasHeader("Authorization")); + response.setStatusLine(request.httpVersion, 200, "OK"); + response.bodyOutputStream.write(signoutMessage, signoutMessage.length); + return; + } + + // Error trying to sign out of nonexistent account + response.setStatusLine(request.httpVersion, 400, "Bad request"); + response.bodyOutputStream.write(errorMessage, errorMessage.length); + }, + }); + + let client = new FxAccountsClient(server.baseURI); + let result = await client.signOut("FakeSession"); + Assert.equal(typeof result, "object"); + + // Trigger error path + try { + result = await client.signOut("FakeSession"); + do_throw("Expected to catch an exception"); + } catch (expectedError) { + Assert.equal(102, expectedError.errno); + } + + await promiseStopServer(server); +}); + +add_task(async function test_recoveryEmailStatus() { + let emailStatus = JSON.stringify({ verified: true }); + let errorMessage = JSON.stringify({ + code: 400, + errno: 102, + error: "doesn't exist", + }); + let tries = 0; + + let server = httpd_setup({ + "/recovery_email/status": function (request, response) { + Assert.ok(request.hasHeader("Authorization")); + Assert.equal("", request._queryString); + + if (tries === 0) { + tries += 1; + response.setStatusLine(request.httpVersion, 200, "OK"); + response.bodyOutputStream.write(emailStatus, emailStatus.length); + return; + } + + // Second call gets an error trying to query a nonexistent account + response.setStatusLine(request.httpVersion, 400, "Bad request"); + response.bodyOutputStream.write(errorMessage, errorMessage.length); + }, + }); + + let client = new FxAccountsClient(server.baseURI); + let result = await client.recoveryEmailStatus(FAKE_SESSION_TOKEN); + Assert.equal(result.verified, true); + + // Trigger error path + try { + result = await client.recoveryEmailStatus("some bogus session"); + do_throw("Expected to catch an exception"); + } catch (expectedError) { + Assert.equal(102, expectedError.errno); + } + + await promiseStopServer(server); +}); + +add_task(async function test_recoveryEmailStatusWithReason() { + let emailStatus = JSON.stringify({ verified: true }); + let server = httpd_setup({ + "/recovery_email/status": function (request, response) { + Assert.ok(request.hasHeader("Authorization")); + // if there is a query string then it will have a reason + Assert.equal("reason=push", request._queryString); + + response.setStatusLine(request.httpVersion, 200, "OK"); + response.bodyOutputStream.write(emailStatus, emailStatus.length); + }, + }); + + let client = new FxAccountsClient(server.baseURI); + let result = await client.recoveryEmailStatus(FAKE_SESSION_TOKEN, { + reason: "push", + }); + Assert.equal(result.verified, true); + await promiseStopServer(server); +}); + +add_task(async function test_resendVerificationEmail() { + let emptyMessage = "{}"; + let errorMessage = JSON.stringify({ + code: 400, + errno: 102, + error: "doesn't exist", + }); + let tries = 0; + + let server = httpd_setup({ + "/recovery_email/resend_code": function (request, response) { + Assert.ok(request.hasHeader("Authorization")); + if (tries === 0) { + tries += 1; + response.setStatusLine(request.httpVersion, 200, "OK"); + response.bodyOutputStream.write(emptyMessage, emptyMessage.length); + return; + } + + // Second call gets an error trying to query a nonexistent account + response.setStatusLine(request.httpVersion, 400, "Bad request"); + response.bodyOutputStream.write(errorMessage, errorMessage.length); + }, + }); + + let client = new FxAccountsClient(server.baseURI); + let result = await client.resendVerificationEmail(FAKE_SESSION_TOKEN); + Assert.equal(JSON.stringify(result), emptyMessage); + + // Trigger error path + try { + result = await client.resendVerificationEmail("some bogus session"); + do_throw("Expected to catch an exception"); + } catch (expectedError) { + Assert.equal(102, expectedError.errno); + } + + await promiseStopServer(server); +}); + +add_task(async function test_accountKeys() { + // Four calls to accountKeys(). The first one should work correctly, and we + // should get a valid bundle back, in exchange for our keyFetch token, from + // which we correctly derive kA and wrapKB. The subsequent three calls + // should all trigger separate error paths. + let responseMessage = JSON.stringify({ bundle: ACCOUNT_KEYS.response }); + let errorMessage = JSON.stringify({ + code: 400, + errno: 102, + error: "doesn't exist", + }); + let emptyMessage = "{}"; + let attempt = 0; + + let server = httpd_setup({ + "/account/keys": function (request, response) { + Assert.ok(request.hasHeader("Authorization")); + attempt += 1; + + switch (attempt) { + case 1: + // First time succeeds + response.setStatusLine(request.httpVersion, 200, "OK"); + response.bodyOutputStream.write( + responseMessage, + responseMessage.length + ); + break; + + case 2: + // Second time, return no bundle to trigger client error + response.setStatusLine(request.httpVersion, 200, "OK"); + response.bodyOutputStream.write(emptyMessage, emptyMessage.length); + break; + + case 3: + // Return gibberish to trigger client MAC error + // Tweak a byte + let garbageResponse = JSON.stringify({ + bundle: ACCOUNT_KEYS.response.slice(0, -1) + "1", + }); + response.setStatusLine(request.httpVersion, 200, "OK"); + response.bodyOutputStream.write( + garbageResponse, + garbageResponse.length + ); + break; + + case 4: + // Trigger error for nonexistent account + response.setStatusLine(request.httpVersion, 400, "Bad request"); + response.bodyOutputStream.write(errorMessage, errorMessage.length); + break; + } + }, + }); + + let client = new FxAccountsClient(server.baseURI); + + // First try, all should be good + let result = await client.accountKeys(ACCOUNT_KEYS.keyFetch); + Assert.equal(CommonUtils.hexToBytes(ACCOUNT_KEYS.kA), result.kA); + Assert.equal(CommonUtils.hexToBytes(ACCOUNT_KEYS.wrapKB), result.wrapKB); + + // Second try, empty bundle should trigger error + try { + result = await client.accountKeys(ACCOUNT_KEYS.keyFetch); + do_throw("Expected to catch an exception"); + } catch (expectedError) { + Assert.equal(expectedError.message, "failed to retrieve keys"); + } + + // Third try, bad bundle results in MAC error + try { + result = await client.accountKeys(ACCOUNT_KEYS.keyFetch); + do_throw("Expected to catch an exception"); + } catch (expectedError) { + Assert.equal(expectedError.message, "error unbundling encryption keys"); + } + + // Fourth try, pretend account doesn't exist + try { + result = await client.accountKeys(ACCOUNT_KEYS.keyFetch); + do_throw("Expected to catch an exception"); + } catch (expectedError) { + Assert.equal(102, expectedError.errno); + } + + await promiseStopServer(server); +}); + +add_task(async function test_accessTokenWithSessionToken() { + let server = httpd_setup({ + "/oauth/token": function (request, response) { + const responseMessage = JSON.stringify({ + access_token: + "43793fdfffec22eb39fc3c44ed09193a6fde4c24e5d6a73f73178597b268af69", + token_type: "bearer", + scope: "https://identity.mozilla.com/apps/oldsync", + expires_in: 21600, + auth_at: 1589579900, + }); + + response.setStatusLine(request.httpVersion, 200, "OK"); + response.bodyOutputStream.write(responseMessage, responseMessage.length); + }, + }); + + let client = new FxAccountsClient(server.baseURI); + let sessionTokenHex = + "0599c36ebb5cad6feb9285b9547b65342b5434d55c07b33bffd4307ab8f82dc4"; + let clientId = "5882386c6d801776"; + let scope = "https://identity.mozilla.com/apps/oldsync"; + let ttl = 100; + let result = await client.accessTokenWithSessionToken( + sessionTokenHex, + clientId, + scope, + ttl + ); + Assert.ok(result); + + await promiseStopServer(server); +}); + +add_task(async function test_accountExists() { + let existsMessage = JSON.stringify({ + error: "wrong password", + code: 400, + errno: 103, + }); + let doesntExistMessage = JSON.stringify({ + error: "no such account", + code: 400, + errno: 102, + }); + let emptyMessage = "{}"; + + let server = httpd_setup({ + "/account/login": function (request, response) { + let body = CommonUtils.readBytesFromInputStream(request.bodyInputStream); + let jsonBody = JSON.parse(body); + + switch (jsonBody.email) { + // We'll test that these users' accounts exist + case "i.exist@example.com": + case "i.also.exist@example.com": + response.setStatusLine(request.httpVersion, 400, "Bad request"); + response.bodyOutputStream.write(existsMessage, existsMessage.length); + break; + + // This user's account doesn't exist + case "i.dont.exist@example.com": + response.setStatusLine(request.httpVersion, 400, "Bad request"); + response.bodyOutputStream.write( + doesntExistMessage, + doesntExistMessage.length + ); + break; + + // This user throws an unexpected response + // This will reject the client signIn promise + case "i.break.things@example.com": + response.setStatusLine(request.httpVersion, 500, "Alas"); + response.bodyOutputStream.write(emptyMessage, emptyMessage.length); + break; + + default: + throw new Error("Unexpected login from " + jsonBody.email); + } + }, + }); + + let client = new FxAccountsClient(server.baseURI); + let result; + + result = await client.accountExists("i.exist@example.com"); + Assert.ok(result); + + result = await client.accountExists("i.also.exist@example.com"); + Assert.ok(result); + + result = await client.accountExists("i.dont.exist@example.com"); + Assert.ok(!result); + + try { + result = await client.accountExists("i.break.things@example.com"); + do_throw("Expected to catch an exception"); + } catch (unexpectedError) { + Assert.equal(unexpectedError.code, 500); + } + + await promiseStopServer(server); +}); + +add_task(async function test_registerDevice() { + const DEVICE_ID = "device id"; + const DEVICE_NAME = "device name"; + const DEVICE_TYPE = "device type"; + const ERROR_NAME = "test that the client promise rejects"; + + const server = httpd_setup({ + "/account/device": function (request, response) { + const body = JSON.parse( + CommonUtils.readBytesFromInputStream(request.bodyInputStream) + ); + + if ( + body.id || + !body.name || + !body.type || + Object.keys(body).length !== 2 + ) { + response.setStatusLine(request.httpVersion, 400, "Invalid request"); + response.bodyOutputStream.write("{}", 2); + return; + } + + if (body.name === ERROR_NAME) { + response.setStatusLine(request.httpVersion, 500, "Alas"); + response.bodyOutputStream.write("{}", 2); + return; + } + + body.id = DEVICE_ID; + body.createdAt = Date.now(); + + const responseMessage = JSON.stringify(body); + + response.setStatusLine(request.httpVersion, 200, "OK"); + response.bodyOutputStream.write(responseMessage, responseMessage.length); + }, + }); + + const client = new FxAccountsClient(server.baseURI); + const result = await client.registerDevice( + FAKE_SESSION_TOKEN, + DEVICE_NAME, + DEVICE_TYPE + ); + + Assert.ok(result); + Assert.equal(Object.keys(result).length, 4); + Assert.equal(result.id, DEVICE_ID); + Assert.equal(typeof result.createdAt, "number"); + Assert.ok(result.createdAt > 0); + Assert.equal(result.name, DEVICE_NAME); + Assert.equal(result.type, DEVICE_TYPE); + + try { + await client.registerDevice(FAKE_SESSION_TOKEN, ERROR_NAME, DEVICE_TYPE); + do_throw("Expected to catch an exception"); + } catch (unexpectedError) { + Assert.equal(unexpectedError.code, 500); + } + + await promiseStopServer(server); +}); + +add_task(async function test_updateDevice() { + const DEVICE_ID = "some other id"; + const DEVICE_NAME = "some other name"; + const ERROR_ID = "test that the client promise rejects"; + + const server = httpd_setup({ + "/account/device": function (request, response) { + const body = JSON.parse( + CommonUtils.readBytesFromInputStream(request.bodyInputStream) + ); + + if ( + !body.id || + !body.name || + body.type || + Object.keys(body).length !== 2 + ) { + response.setStatusLine(request.httpVersion, 400, "Invalid request"); + response.bodyOutputStream.write("{}", 2); + return; + } + + if (body.id === ERROR_ID) { + response.setStatusLine(request.httpVersion, 500, "Alas"); + response.bodyOutputStream.write("{}", 2); + return; + } + + const responseMessage = JSON.stringify(body); + + response.setStatusLine(request.httpVersion, 200, "OK"); + response.bodyOutputStream.write(responseMessage, responseMessage.length); + }, + }); + + const client = new FxAccountsClient(server.baseURI); + const result = await client.updateDevice( + FAKE_SESSION_TOKEN, + DEVICE_ID, + DEVICE_NAME + ); + + Assert.ok(result); + Assert.equal(Object.keys(result).length, 2); + Assert.equal(result.id, DEVICE_ID); + Assert.equal(result.name, DEVICE_NAME); + + try { + await client.updateDevice(FAKE_SESSION_TOKEN, ERROR_ID, DEVICE_NAME); + do_throw("Expected to catch an exception"); + } catch (unexpectedError) { + Assert.equal(unexpectedError.code, 500); + } + + await promiseStopServer(server); +}); + +add_task(async function test_getDeviceList() { + let canReturnDevices; + + const server = httpd_setup({ + "/account/devices": function (request, response) { + if (canReturnDevices) { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.bodyOutputStream.write("[]", 2); + } else { + response.setStatusLine(request.httpVersion, 500, "Alas"); + response.bodyOutputStream.write("{}", 2); + } + }, + }); + + const client = new FxAccountsClient(server.baseURI); + + canReturnDevices = true; + const result = await client.getDeviceList(FAKE_SESSION_TOKEN); + Assert.ok(Array.isArray(result)); + Assert.equal(result.length, 0); + + try { + canReturnDevices = false; + await client.getDeviceList(FAKE_SESSION_TOKEN); + do_throw("Expected to catch an exception"); + } catch (unexpectedError) { + Assert.equal(unexpectedError.code, 500); + } + + await promiseStopServer(server); +}); + +add_task(async function test_client_metrics() { + function writeResp(response, msg) { + if (typeof msg === "object") { + msg = JSON.stringify(msg); + } + response.bodyOutputStream.write(msg, msg.length); + } + + let server = httpd_setup({ + "/session/destroy": function (request, response) { + response.setHeader("Content-Type", "application/json; charset=utf-8"); + response.setStatusLine(request.httpVersion, 401, "Unauthorized"); + writeResp(response, { + error: "invalid authentication timestamp", + code: 401, + errno: 111, + }); + }, + }); + + let client = new FxAccountsClient(server.baseURI); + + await Assert.rejects( + client.signOut(FAKE_SESSION_TOKEN, { + service: "sync", + }), + function (err) { + return err.errno == 111; + } + ); + + await promiseStopServer(server); +}); + +add_task(async function test_email_case() { + let canonicalEmail = "greta.garbo@gmail.com"; + let clientEmail = "Greta.Garbo@gmail.COM"; + let attempts = 0; + + function writeResp(response, msg) { + if (typeof msg === "object") { + msg = JSON.stringify(msg); + } + response.bodyOutputStream.write(msg, msg.length); + } + + let server = httpd_setup({ + "/account/login": function (request, response) { + response.setHeader("Content-Type", "application/json; charset=utf-8"); + attempts += 1; + if (attempts > 2) { + response.setStatusLine( + request.httpVersion, + 429, + "Sorry, you had your chance" + ); + return writeResp(response, ""); + } + + let body = CommonUtils.readBytesFromInputStream(request.bodyInputStream); + let jsonBody = JSON.parse(body); + let email = jsonBody.email; + + // If the client has the wrong case on the email, we return a 400, with + // the capitalization of the email as saved in the accounts database. + if (email == canonicalEmail) { + response.setStatusLine(request.httpVersion, 200, "Yay"); + return writeResp(response, { areWeHappy: "yes" }); + } + + response.setStatusLine(request.httpVersion, 400, "Incorrect email case"); + return writeResp(response, { + code: 400, + errno: 120, + error: "Incorrect email case", + email: canonicalEmail, + }); + }, + }); + + let client = new FxAccountsClient(server.baseURI); + + let result = await client.signIn(clientEmail, "123456"); + Assert.equal(result.areWeHappy, "yes"); + Assert.equal(attempts, 2); + + await promiseStopServer(server); +}); + +// turn formatted test vectors into normal hex strings +function h(hexStr) { + return hexStr.replace(/\s+/g, ""); +} diff --git a/services/fxaccounts/tests/xpcshell/test_commands.js b/services/fxaccounts/tests/xpcshell/test_commands.js new file mode 100644 index 0000000000..3fa42da439 --- /dev/null +++ b/services/fxaccounts/tests/xpcshell/test_commands.js @@ -0,0 +1,708 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { FxAccountsCommands, SendTab } = ChromeUtils.importESModule( + "resource://gre/modules/FxAccountsCommands.sys.mjs" +); + +const { FxAccountsClient } = ChromeUtils.importESModule( + "resource://gre/modules/FxAccountsClient.sys.mjs" +); + +const { COMMAND_SENDTAB, COMMAND_SENDTAB_TAIL } = ChromeUtils.importESModule( + "resource://gre/modules/FxAccountsCommon.sys.mjs" +); + +class TelemetryMock { + constructor() { + this._events = []; + this._uuid_counter = 0; + } + + recordEvent(object, method, value, extra = undefined) { + this._events.push({ object, method, value, extra }); + } + + generateFlowID() { + this._uuid_counter += 1; + return this._uuid_counter.toString(); + } + + sanitizeDeviceId(id) { + return id + "-san"; + } +} + +function FxaInternalMock() { + return { + telemetry: new TelemetryMock(), + }; +} + +function MockFxAccountsClient() { + FxAccountsClient.apply(this); +} + +MockFxAccountsClient.prototype = {}; +Object.setPrototypeOf( + MockFxAccountsClient.prototype, + FxAccountsClient.prototype +); + +add_task(async function test_sendtab_isDeviceCompatible() { + const sendTab = new SendTab(null, null); + let device = { name: "My device" }; + Assert.ok(!sendTab.isDeviceCompatible(device)); + device = { name: "My device", availableCommands: {} }; + Assert.ok(!sendTab.isDeviceCompatible(device)); + device = { + name: "My device", + availableCommands: { + "https://identity.mozilla.com/cmd/open-uri": "payload", + }, + }; + Assert.ok(sendTab.isDeviceCompatible(device)); +}); + +add_task(async function test_sendtab_send() { + const commands = { + invoke: sinon.spy((cmd, device, payload) => { + if (device.name == "Device 1") { + throw new Error("Invoke error!"); + } + Assert.equal(payload.encrypted, "encryptedpayload"); + }), + }; + const fxai = FxaInternalMock(); + const sendTab = new SendTab(commands, fxai); + sendTab._encrypt = (bytes, device) => { + if (device.name == "Device 2") { + throw new Error("Encrypt error!"); + } + return "encryptedpayload"; + }; + const to = [ + { name: "Device 1" }, + { name: "Device 2" }, + { id: "dev3", name: "Device 3" }, + ]; + // although we are sending to 3 devices, only 1 is successful - so there's + // only 1 streamID we care about. However, we've created IDs even for the + // failing items - so it's "4" + const expectedTelemetryStreamID = "4"; + const tab = { title: "Foo", url: "https://foo.bar/" }; + const report = await sendTab.send(to, tab); + Assert.equal(report.succeeded.length, 1); + Assert.equal(report.failed.length, 2); + Assert.equal(report.succeeded[0].name, "Device 3"); + Assert.equal(report.failed[0].device.name, "Device 1"); + Assert.equal(report.failed[0].error.message, "Invoke error!"); + Assert.equal(report.failed[1].device.name, "Device 2"); + Assert.equal(report.failed[1].error.message, "Encrypt error!"); + Assert.ok(commands.invoke.calledTwice); + Assert.deepEqual(fxai.telemetry._events, [ + { + object: "command-sent", + method: COMMAND_SENDTAB_TAIL, + value: "dev3-san", + extra: { flowID: "1", streamID: expectedTelemetryStreamID }, + }, + ]); +}); + +add_task(async function test_sendtab_send_rate_limit() { + const rateLimitReject = { + code: 429, + retryAfter: 5, + retryAfterLocalized: "retry after 5 seconds", + }; + const fxAccounts = { + fxAccountsClient: new MockFxAccountsClient(), + getUserAccountData() { + return {}; + }, + telemetry: new TelemetryMock(), + }; + let rejected = false; + let invoked = 0; + fxAccounts.fxAccountsClient.invokeCommand = async function invokeCommand() { + invoked++; + Assert.ok(invoked <= 2, "only called twice and not more"); + if (rejected) { + return {}; + } + rejected = true; + return Promise.reject(rateLimitReject); + }; + const commands = new FxAccountsCommands(fxAccounts); + const sendTab = new SendTab(commands, fxAccounts); + sendTab._encrypt = () => "encryptedpayload"; + + const tab = { title: "Foo", url: "https://foo.bar/" }; + let report = await sendTab.send([{ name: "Device 1" }], tab); + Assert.equal(report.succeeded.length, 0); + Assert.equal(report.failed.length, 1); + Assert.equal(report.failed[0].error, rateLimitReject); + + report = await sendTab.send([{ name: "Device 1" }], tab); + Assert.equal(report.succeeded.length, 0); + Assert.equal(report.failed.length, 1); + Assert.ok( + report.failed[0].error.message.includes( + "Invoke for " + + "https://identity.mozilla.com/cmd/open-uri is rate-limited" + ) + ); + + commands._invokeRateLimitExpiry = Date.now() - 1000; + report = await sendTab.send([{ name: "Device 1" }], tab); + Assert.equal(report.succeeded.length, 1); + Assert.equal(report.failed.length, 0); +}); + +add_task(async function test_sendtab_receive() { + // We are testing 'receive' here, but might as well go through 'send' + // to package the data and for additional testing... + const commands = { + _invokes: [], + invoke(cmd, device, payload) { + this._invokes.push({ cmd, device, payload }); + }, + }; + + const fxai = FxaInternalMock(); + const sendTab = new SendTab(commands, fxai); + sendTab._encrypt = (bytes, device) => { + return bytes; + }; + sendTab._decrypt = bytes => { + return bytes; + }; + const tab = { title: "tab title", url: "http://example.com" }; + const to = [{ id: "devid", name: "The Device" }]; + const reason = "push"; + + await sendTab.send(to, tab); + Assert.equal(commands._invokes.length, 1); + + for (let { cmd, device, payload } of commands._invokes) { + Assert.equal(cmd, COMMAND_SENDTAB); + // Older Firefoxes would send a plaintext flowID in the top-level payload. + // Test that we sensibly ignore it. + Assert.ok(!payload.hasOwnProperty("flowID")); + // change it - ensure we still get what we expect in telemetry later. + payload.flowID = "ignore-me"; + Assert.deepEqual(await sendTab.handle(device.id, payload, reason), { + title: "tab title", + uri: "http://example.com", + }); + } + + Assert.deepEqual(fxai.telemetry._events, [ + { + object: "command-sent", + method: COMMAND_SENDTAB_TAIL, + value: "devid-san", + extra: { flowID: "1", streamID: "2" }, + }, + { + object: "command-received", + method: COMMAND_SENDTAB_TAIL, + value: "devid-san", + extra: { flowID: "1", streamID: "2", reason }, + }, + ]); +}); + +// Test that a client which only sends the flowID in the envelope and not in the +// encrypted body gets recorded without the flowID. +add_task(async function test_sendtab_receive_old_client() { + const fxai = FxaInternalMock(); + const sendTab = new SendTab(null, fxai); + sendTab._decrypt = bytes => { + return bytes; + }; + const data = { entries: [{ title: "title", url: "url" }] }; + // No 'flowID' in the encrypted payload, no 'streamID' anywhere. + const payload = { + flowID: "flow-id", + encrypted: new TextEncoder().encode(JSON.stringify(data)), + }; + const reason = "push"; + await sendTab.handle("sender-id", payload, reason); + Assert.deepEqual(fxai.telemetry._events, [ + { + object: "command-received", + method: COMMAND_SENDTAB_TAIL, + value: "sender-id-san", + // deepEqual doesn't ignore undefined, but our telemetry code and + // JSON.stringify() do... + extra: { flowID: undefined, streamID: undefined, reason }, + }, + ]); +}); + +add_task(function test_commands_getReason() { + const fxAccounts = { + async withCurrentAccountState(cb) { + await cb({}); + }, + }; + const commands = new FxAccountsCommands(fxAccounts); + const testCases = [ + { + receivedIndex: 0, + currentIndex: 0, + expectedReason: "poll", + message: "should return reason 'poll'", + }, + { + receivedIndex: 7, + currentIndex: 3, + expectedReason: "push-missed", + message: "should return reason 'push-missed'", + }, + { + receivedIndex: 2, + currentIndex: 8, + expectedReason: "push", + message: "should return reason 'push'", + }, + ]; + for (const tc of testCases) { + const reason = commands._getReason(tc.receivedIndex, tc.currentIndex); + Assert.equal(reason, tc.expectedReason, tc.message); + } +}); + +add_task(async function test_commands_pollDeviceCommands_push() { + // Server state. + const remoteMessages = [ + { + index: 11, + data: {}, + }, + { + index: 12, + data: {}, + }, + ]; + const remoteIndex = 12; + + // Local state. + const pushIndexReceived = 11; + const accountState = { + data: { + device: { + lastCommandIndex: 10, + }, + }, + getUserAccountData() { + return this.data; + }, + updateUserAccountData(data) { + this.data = data; + }, + }; + + const fxAccounts = { + async withCurrentAccountState(cb) { + await cb(accountState); + }, + }; + const commands = new FxAccountsCommands(fxAccounts); + const mockCommands = sinon.mock(commands); + mockCommands.expects("_fetchDeviceCommands").once().withArgs(11).returns({ + index: remoteIndex, + messages: remoteMessages, + }); + mockCommands + .expects("_handleCommands") + .once() + .withArgs(remoteMessages, pushIndexReceived); + await commands.pollDeviceCommands(pushIndexReceived); + + mockCommands.verify(); + Assert.equal(accountState.data.device.lastCommandIndex, 12); +}); + +add_task( + async function test_commands_pollDeviceCommands_push_already_fetched() { + // Local state. + const pushIndexReceived = 12; + const accountState = { + data: { + device: { + lastCommandIndex: 12, + }, + }, + getUserAccountData() { + return this.data; + }, + updateUserAccountData(data) { + this.data = data; + }, + }; + + const fxAccounts = { + async withCurrentAccountState(cb) { + await cb(accountState); + }, + }; + const commands = new FxAccountsCommands(fxAccounts); + const mockCommands = sinon.mock(commands); + mockCommands.expects("_fetchDeviceCommands").never(); + mockCommands.expects("_handleCommands").never(); + await commands.pollDeviceCommands(pushIndexReceived); + + mockCommands.verify(); + Assert.equal(accountState.data.device.lastCommandIndex, 12); + } +); + +add_task(async function test_commands_handleCommands() { + // This test ensures that `_getReason` is being called by + // `_handleCommands` with the expected parameters. + const pushIndexReceived = 12; + const senderID = "6d09f6c4-89b2-41b3-a0ac-e4c2502b5485"; + const remoteMessageIndex = 8; + const remoteMessages = [ + { + index: remoteMessageIndex, + data: { + command: COMMAND_SENDTAB, + payload: { + encrypted: {}, + }, + sender: senderID, + }, + }, + ]; + + const fxAccounts = { + async withCurrentAccountState(cb) { + await cb({}); + }, + }; + const commands = new FxAccountsCommands(fxAccounts); + commands.sendTab.handle = (sender, data, reason) => { + return { + title: "testTitle", + uri: "https://testURI", + }; + }; + commands._fxai.device = { + refreshDeviceList: () => {}, + recentDeviceList: [ + { + id: senderID, + }, + ], + }; + const mockCommands = sinon.mock(commands); + mockCommands + .expects("_getReason") + .once() + .withExactArgs(pushIndexReceived, remoteMessageIndex); + mockCommands.expects("_notifyFxATabsReceived").once(); + await commands._handleCommands(remoteMessages, pushIndexReceived); + mockCommands.verify(); +}); + +add_task(async function test_commands_handleCommands_invalid_tab() { + // This test ensures that `_getReason` is being called by + // `_handleCommands` with the expected parameters. + const pushIndexReceived = 12; + const senderID = "6d09f6c4-89b2-41b3-a0ac-e4c2502b5485"; + const remoteMessageIndex = 8; + const remoteMessages = [ + { + index: remoteMessageIndex, + data: { + command: COMMAND_SENDTAB, + payload: { + encrypted: {}, + }, + sender: senderID, + }, + }, + ]; + + const fxAccounts = { + async withCurrentAccountState(cb) { + await cb({}); + }, + }; + const commands = new FxAccountsCommands(fxAccounts); + commands.sendTab.handle = (sender, data, reason) => { + return { + title: "badUriTab", + uri: "file://path/to/pdf", + }; + }; + commands._fxai.device = { + refreshDeviceList: () => {}, + recentDeviceList: [ + { + id: senderID, + }, + ], + }; + const mockCommands = sinon.mock(commands); + mockCommands + .expects("_getReason") + .once() + .withExactArgs(pushIndexReceived, remoteMessageIndex); + // We shouldn't have tried to open a tab with an invalid uri + mockCommands.expects("_notifyFxATabsReceived").never(); + + await commands._handleCommands(remoteMessages, pushIndexReceived); + mockCommands.verify(); +}); + +add_task( + async function test_commands_pollDeviceCommands_push_local_state_empty() { + // Server state. + const remoteMessages = [ + { + index: 11, + data: {}, + }, + { + index: 12, + data: {}, + }, + ]; + const remoteIndex = 12; + + // Local state. + const pushIndexReceived = 11; + const accountState = { + data: { + device: {}, + }, + getUserAccountData() { + return this.data; + }, + updateUserAccountData(data) { + this.data = data; + }, + }; + + const fxAccounts = { + async withCurrentAccountState(cb) { + await cb(accountState); + }, + }; + const commands = new FxAccountsCommands(fxAccounts); + const mockCommands = sinon.mock(commands); + mockCommands.expects("_fetchDeviceCommands").once().withArgs(11).returns({ + index: remoteIndex, + messages: remoteMessages, + }); + mockCommands + .expects("_handleCommands") + .once() + .withArgs(remoteMessages, pushIndexReceived); + await commands.pollDeviceCommands(pushIndexReceived); + + mockCommands.verify(); + Assert.equal(accountState.data.device.lastCommandIndex, 12); + } +); + +add_task(async function test_commands_pollDeviceCommands_scheduled_local() { + // Server state. + const remoteMessages = [ + { + index: 11, + data: {}, + }, + { + index: 12, + data: {}, + }, + ]; + const remoteIndex = 12; + const pushIndexReceived = 0; + // Local state. + const accountState = { + data: { + device: { + lastCommandIndex: 10, + }, + }, + getUserAccountData() { + return this.data; + }, + updateUserAccountData(data) { + this.data = data; + }, + }; + + const fxAccounts = { + async withCurrentAccountState(cb) { + await cb(accountState); + }, + }; + const commands = new FxAccountsCommands(fxAccounts); + const mockCommands = sinon.mock(commands); + mockCommands.expects("_fetchDeviceCommands").once().withArgs(11).returns({ + index: remoteIndex, + messages: remoteMessages, + }); + mockCommands + .expects("_handleCommands") + .once() + .withArgs(remoteMessages, pushIndexReceived); + await commands.pollDeviceCommands(); + + mockCommands.verify(); + Assert.equal(accountState.data.device.lastCommandIndex, 12); +}); + +add_task( + async function test_commands_pollDeviceCommands_scheduled_local_state_empty() { + // Server state. + const remoteMessages = [ + { + index: 11, + data: {}, + }, + { + index: 12, + data: {}, + }, + ]; + const remoteIndex = 12; + const pushIndexReceived = 0; + // Local state. + const accountState = { + data: { + device: {}, + }, + getUserAccountData() { + return this.data; + }, + updateUserAccountData(data) { + this.data = data; + }, + }; + + const fxAccounts = { + async withCurrentAccountState(cb) { + await cb(accountState); + }, + }; + const commands = new FxAccountsCommands(fxAccounts); + const mockCommands = sinon.mock(commands); + mockCommands.expects("_fetchDeviceCommands").once().withArgs(0).returns({ + index: remoteIndex, + messages: remoteMessages, + }); + mockCommands + .expects("_handleCommands") + .once() + .withArgs(remoteMessages, pushIndexReceived); + await commands.pollDeviceCommands(); + + mockCommands.verify(); + Assert.equal(accountState.data.device.lastCommandIndex, 12); + } +); + +add_task(async function test_send_tab_keys_regenerated_if_lost() { + const commands = { + _invokes: [], + invoke(cmd, device, payload) { + this._invokes.push({ cmd, device, payload }); + }, + }; + + // Local state. + const accountState = { + data: { + // Since the device object has no + // sendTabKeys, it will recover + // when we attempt to get the + // encryptedSendTabKeys + device: { + lastCommandIndex: 10, + }, + encryptedSendTabKeys: "keys", + }, + getUserAccountData() { + return this.data; + }, + updateUserAccountData(data) { + this.data = data; + }, + }; + + const fxAccounts = { + async withCurrentAccountState(cb) { + await cb(accountState); + }, + async getUserAccountData(data) { + return accountState.getUserAccountData(data); + }, + telemetry: new TelemetryMock(), + }; + const sendTab = new SendTab(commands, fxAccounts); + let generateEncryptedKeysCalled = false; + sendTab._generateAndPersistEncryptedSendTabKey = async () => { + generateEncryptedKeysCalled = true; + }; + await sendTab.getEncryptedSendTabKeys(); + Assert.ok(generateEncryptedKeysCalled); +}); + +add_task(async function test_send_tab_keys_are_not_regenerated_if_not_lost() { + const commands = { + _invokes: [], + invoke(cmd, device, payload) { + this._invokes.push({ cmd, device, payload }); + }, + }; + + // Local state. + const accountState = { + data: { + // Since the device object has + // sendTabKeys, it will not try + // to regenerate them + // when we attempt to get the + // encryptedSendTabKeys + device: { + lastCommandIndex: 10, + sendTabKeys: "keys", + }, + encryptedSendTabKeys: "encrypted-keys", + }, + getUserAccountData() { + return this.data; + }, + updateUserAccountData(data) { + this.data = data; + }, + }; + + const fxAccounts = { + async withCurrentAccountState(cb) { + await cb(accountState); + }, + async getUserAccountData(data) { + return accountState.getUserAccountData(data); + }, + telemetry: new TelemetryMock(), + }; + const sendTab = new SendTab(commands, fxAccounts); + let generateEncryptedKeysCalled = false; + sendTab._generateAndPersistEncryptedSendTabKey = async () => { + generateEncryptedKeysCalled = true; + }; + await sendTab.getEncryptedSendTabKeys(); + Assert.ok(!generateEncryptedKeysCalled); +}); diff --git a/services/fxaccounts/tests/xpcshell/test_credentials.js b/services/fxaccounts/tests/xpcshell/test_credentials.js new file mode 100644 index 0000000000..c3656f219d --- /dev/null +++ b/services/fxaccounts/tests/xpcshell/test_credentials.js @@ -0,0 +1,130 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { Credentials } = ChromeUtils.importESModule( + "resource://gre/modules/Credentials.sys.mjs" +); +const { CryptoUtils } = ChromeUtils.importESModule( + "resource://services-crypto/utils.sys.mjs" +); + +var { hexToBytes: h2b, hexAsString: h2s, bytesAsHex: b2h } = CommonUtils; + +// Test vectors for the "onepw" protocol: +// https://github.com/mozilla/fxa-auth-server/wiki/onepw-protocol#wiki-test-vectors +var vectors = { + "client stretch-KDF": { + email: h("616e6472c3a94065 78616d706c652e6f 7267"), + password: h("70c3a4737377c3b6 7264"), + quickStretchedPW: h( + "e4e8889bd8bd61ad 6de6b95c059d56e7 b50dacdaf62bd846 44af7e2add84345d" + ), + authPW: h( + "247b675ffb4c4631 0bc87e26d712153a be5e1c90ef00a478 4594f97ef54f2375" + ), + authSalt: h( + "00f0000000000000 0000000000000000 0000000000000000 0000000000000000" + ), + }, +}; + +// A simple test suite with no utf8 encoding madness. +add_task(async function test_onepw_setup_credentials() { + let email = "francine@example.org"; + let password = CommonUtils.encodeUTF8("i like pie"); + + let pbkdf2 = CryptoUtils.pbkdf2Generate; + let hkdf = CryptoUtils.hkdfLegacy; + + // quickStretch the email + let saltyEmail = Credentials.keyWordExtended("quickStretch", email); + + Assert.equal( + b2h(saltyEmail), + "6964656e746974792e6d6f7a696c6c612e636f6d2f7069636c2f76312f717569636b537472657463683a6672616e63696e65406578616d706c652e6f7267" + ); + + let pbkdf2Rounds = 1000; + let pbkdf2Len = 32; + + let quickStretchedPW = await pbkdf2( + password, + saltyEmail, + pbkdf2Rounds, + pbkdf2Len + ); + let quickStretchedActual = + "6b88094c1c73bbf133223f300d101ed70837af48d9d2c1b6e7d38804b20cdde4"; + Assert.equal(b2h(quickStretchedPW), quickStretchedActual); + + // obtain hkdf info + let authKeyInfo = Credentials.keyWord("authPW"); + Assert.equal( + b2h(authKeyInfo), + "6964656e746974792e6d6f7a696c6c612e636f6d2f7069636c2f76312f617574685057" + ); + + // derive auth password + let hkdfSalt = h2b("00"); + let hkdfLen = 32; + let authPW = await hkdf(quickStretchedPW, hkdfSalt, authKeyInfo, hkdfLen); + + Assert.equal( + b2h(authPW), + "4b8dec7f48e7852658163601ff766124c312f9392af6c3d4e1a247eb439be342" + ); + + // derive unwrap key + let unwrapKeyInfo = Credentials.keyWord("unwrapBkey"); + let unwrapKey = await hkdf( + quickStretchedPW, + hkdfSalt, + unwrapKeyInfo, + hkdfLen + ); + + Assert.equal( + b2h(unwrapKey), + "8ff58975be391338e4ec5d7138b5ed7b65c7d1bfd1f3a4f93e05aa47d5b72be9" + ); +}); + +add_task(async function test_client_stretch_kdf() { + let expected = vectors["client stretch-KDF"]; + + let email = h2s(expected.email); + let password = h2s(expected.password); + + // Intermediate value from sjcl implementation in fxa-js-client + // The key thing is the c3a9 sequence in "andré" + let salt = Credentials.keyWordExtended("quickStretch", email); + Assert.equal( + b2h(salt), + "6964656e746974792e6d6f7a696c6c612e636f6d2f7069636c2f76312f717569636b537472657463683a616e6472c3a9406578616d706c652e6f7267" + ); + + let options = { + stretchedPassLength: 32, + pbkdf2Rounds: 1000, + hkdfSalt: h2b("00"), + hkdfLength: 32, + }; + + let results = await Credentials.setup(email, password, options); + + Assert.equal( + expected.quickStretchedPW, + b2h(results.quickStretchedPW), + "quickStretchedPW is wrong" + ); + + Assert.equal(expected.authPW, b2h(results.authPW), "authPW is wrong"); +}); + +// End of tests +// Utility functions follow + +// turn formatted test vectors into normal hex strings +function h(hexStr) { + return hexStr.replace(/\s+/g, ""); +} diff --git a/services/fxaccounts/tests/xpcshell/test_device.js b/services/fxaccounts/tests/xpcshell/test_device.js new file mode 100644 index 0000000000..037db2b101 --- /dev/null +++ b/services/fxaccounts/tests/xpcshell/test_device.js @@ -0,0 +1,127 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { getFxAccountsSingleton } = ChromeUtils.importESModule( + "resource://gre/modules/FxAccounts.sys.mjs" +); +const fxAccounts = getFxAccountsSingleton(); + +const { ON_NEW_DEVICE_ID, PREF_ACCOUNT_ROOT } = ChromeUtils.importESModule( + "resource://gre/modules/FxAccountsCommon.sys.mjs" +); + +function promiseObserved(topic) { + return new Promise(res => { + Services.obs.addObserver(res, topic); + }); +} + +_("Misc tests for FxAccounts.device"); + +fxAccounts.device._checkRemoteCommandsUpdateNeeded = async () => false; + +add_test(function test_default_device_name() { + // Note that head_helpers overrides getDefaultLocalName - this test is + // really just to ensure the actual implementation is sane - we can't + // really check the value it uses is correct. + // We are just hoping to avoid a repeat of bug 1369285. + let def = fxAccounts.device.getDefaultLocalName(); // make sure it doesn't throw. + _("default value is " + def); + ok(!!def.length); + + // This is obviously tied to the implementation, but we want early warning + // if any of these things fail. + // We really want one of these 2 to provide a value. + let hostname = Services.sysinfo.get("device") || Services.dns.myHostName; + _("hostname is " + hostname); + ok(!!hostname.length); + // the hostname should be in the default. + ok(def.includes(hostname)); + // We expect the following to work as a fallback to the above. + let fallback = Cc["@mozilla.org/network/protocol;1?name=http"].getService( + Ci.nsIHttpProtocolHandler + ).oscpu; + _("UA fallback is " + fallback); + ok(!!fallback.length); + // the fallback should not be in the default + ok(!def.includes(fallback)); + + run_next_test(); +}); + +add_test(function test_migration() { + Services.prefs.clearUserPref("identity.fxaccounts.account.device.name"); + Services.prefs.setStringPref("services.sync.client.name", "my client name"); + // calling getLocalName() should move the name to the new pref and reset the old. + equal(fxAccounts.device.getLocalName(), "my client name"); + equal( + Services.prefs.getStringPref("identity.fxaccounts.account.device.name"), + "my client name" + ); + ok(!Services.prefs.prefHasUserValue("services.sync.client.name")); + run_next_test(); +}); + +add_test(function test_migration_set_before_get() { + Services.prefs.setStringPref("services.sync.client.name", "old client name"); + fxAccounts.device.setLocalName("new client name"); + equal(fxAccounts.device.getLocalName(), "new client name"); + run_next_test(); +}); + +add_task(async function test_reset() { + // We don't test the client name specifically here because the client name + // is set as part of signing the user in via the attempt to register the + // device. + const testPref = PREF_ACCOUNT_ROOT + "test-pref"; + Services.prefs.setStringPref(testPref, "whatever"); + let credentials = { + email: "foo@example.com", + uid: "1234@lcip.org", + sessionToken: "dead", + verified: true, + ...MOCK_ACCOUNT_KEYS, + }; + // FxA will try to register its device record in the background after signin. + const registerDevice = sinon + .stub(fxAccounts._internal.fxAccountsClient, "registerDevice") + .callsFake(async () => { + return { id: "foo" }; + }); + await fxAccounts._internal.setSignedInUser(credentials); + // wait for device registration to complete. + await promiseObserved(ON_NEW_DEVICE_ID); + ok(!Services.prefs.prefHasUserValue(testPref)); + // signing the user out should reset the name pref. + const namePref = PREF_ACCOUNT_ROOT + "device.name"; + ok(Services.prefs.prefHasUserValue(namePref)); + await fxAccounts.signOut(/* localOnly = */ true); + ok(!Services.prefs.prefHasUserValue(namePref)); + registerDevice.restore(); +}); + +add_task(async function test_name_sanitization() { + fxAccounts.device.setLocalName("emoji is valid \u2665"); + Assert.equal(fxAccounts.device.getLocalName(), "emoji is valid \u2665"); + + let invalid = "x\uFFFD\n\r\t" + "x".repeat(255); + let sanitized = "x\uFFFD\uFFFD\uFFFD\uFFFD" + "x".repeat(250); // 255 total. + + // If the pref already has the invalid value we still get the valid one back. + Services.prefs.setStringPref( + "identity.fxaccounts.account.device.name", + invalid + ); + Assert.equal(fxAccounts.device.getLocalName(), sanitized); + + // But if we explicitly set it to an invalid name, the sanitized value ends + // up in the pref. + fxAccounts.device.setLocalName(invalid); + Assert.equal(fxAccounts.device.getLocalName(), sanitized); + Assert.equal( + Services.prefs.getStringPref("identity.fxaccounts.account.device.name"), + sanitized + ); +}); diff --git a/services/fxaccounts/tests/xpcshell/test_keys.js b/services/fxaccounts/tests/xpcshell/test_keys.js new file mode 100644 index 0000000000..6e650a1609 --- /dev/null +++ b/services/fxaccounts/tests/xpcshell/test_keys.js @@ -0,0 +1,182 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { FxAccountsKeys } = ChromeUtils.importESModule( + "resource://gre/modules/FxAccountsKeys.sys.mjs" +); + +// Ref https://github.com/mozilla/fxa-crypto-relier/ for the details +// of these test vectors. + +add_task(async function test_derive_scoped_key_test_vector() { + const keys = new FxAccountsKeys(null); + const uid = "aeaa1725c7a24ff983c6295725d5fc9b"; + const kB = "8b2e1303e21eee06a945683b8d495b9bf079ca30baa37eb8392d9ffa4767be45"; + const scopedKeyMetadata = { + identifier: "app_key:https%3A//example.com", + keyRotationTimestamp: 1510726317000, + keyRotationSecret: + "517d478cb4f994aa69930416648a416fdaa1762c5abf401a2acf11a0f185e98d", + }; + + const scopedKey = await keys._deriveScopedKey( + uid, + CommonUtils.hexToBytes(kB), + "app_key", + scopedKeyMetadata + ); + + Assert.deepEqual(scopedKey, { + kty: "oct", + kid: "1510726317-Voc-Eb9IpoTINuo9ll7bjA", + k: "Kkbk1_Q0oCcTmggeDH6880bQrxin2RLu5D00NcJazdQ", + }); +}); + +add_task(async function test_derive_legacy_sync_key_test_vector() { + const keys = new FxAccountsKeys(null); + const uid = "aeaa1725c7a24ff983c6295725d5fc9b"; + const kB = "eaf9570b7219a4187d3d6bf3cec2770c2e0719b7cc0dfbb38243d6f1881675e9"; + const scopedKeyMetadata = { + identifier: "https://identity.mozilla.com/apps/oldsync", + keyRotationTimestamp: 1510726317123, + keyRotationSecret: + "0000000000000000000000000000000000000000000000000000000000000000", + }; + + const scopedKey = await keys._deriveLegacyScopedKey( + uid, + CommonUtils.hexToBytes(kB), + "https://identity.mozilla.com/apps/oldsync", + scopedKeyMetadata + ); + + Assert.deepEqual(scopedKey, { + kty: "oct", + kid: "1510726317123-IqQv4onc7VcVE1kTQkyyOw", + k: "DW_ll5GwX6SJ5GPqJVAuMUP2t6kDqhUulc2cbt26xbTcaKGQl-9l29FHAQ7kUiJETma4s9fIpEHrt909zgFang", + }); +}); + +add_task(async function test_derive_multiple_keys_at_once() { + const keys = new FxAccountsKeys(null); + const uid = "aeaa1725c7a24ff983c6295725d5fc9b"; + const kB = "eaf9570b7219a4187d3d6bf3cec2770c2e0719b7cc0dfbb38243d6f1881675e9"; + const scopedKeysMetadata = { + app_key: { + identifier: "app_key:https%3A//example.com", + keyRotationTimestamp: 1510726317000, + keyRotationSecret: + "517d478cb4f994aa69930416648a416fdaa1762c5abf401a2acf11a0f185e98d", + }, + "https://identity.mozilla.com/apps/oldsync": { + identifier: "https://identity.mozilla.com/apps/oldsync", + keyRotationTimestamp: 1510726318123, + keyRotationSecret: + "0000000000000000000000000000000000000000000000000000000000000000", + }, + }; + + const scopedKeys = await keys._deriveScopedKeys( + uid, + CommonUtils.hexToBytes(kB), + scopedKeysMetadata + ); + + Assert.deepEqual(scopedKeys, { + app_key: { + kty: "oct", + kid: "1510726317-tUkxiR1lTlFrTgkF0tJidA", + k: "TYK6Hmj86PfKiqsk9DZmX61nxk9VsExGrwo94HP-0wU", + }, + "https://identity.mozilla.com/apps/oldsync": { + kty: "oct", + kid: "1510726318123-IqQv4onc7VcVE1kTQkyyOw", + k: "DW_ll5GwX6SJ5GPqJVAuMUP2t6kDqhUulc2cbt26xbTcaKGQl-9l29FHAQ7kUiJETma4s9fIpEHrt909zgFang", + }, + }); +}); + +add_task(async function test_rejects_bad_scoped_key_data() { + const keys = new FxAccountsKeys(null); + const uid = "aeaa1725c7a24ff983c6295725d5fc9b"; + const kB = "8b2e1303e21eee06a945683b8d495b9bf079ca30baa37eb8392d9ffa4767be45"; + const scopedKeyMetadata = { + identifier: "app_key:https%3A//example.com", + keyRotationTimestamp: 1510726317000, + keyRotationSecret: + "517d478cb4f994aa69930416648a416fdaa1762c5abf401a2acf11a0f185e98d", + }; + + await Assert.rejects( + keys._deriveScopedKey( + uid.slice(0, -1), + CommonUtils.hexToBytes(kB), + "app_key", + scopedKeyMetadata + ), + /uid must be a 32-character hex string/ + ); + await Assert.rejects( + keys._deriveScopedKey( + uid.slice(0, -1) + "Q", + CommonUtils.hexToBytes(kB), + "app_key", + scopedKeyMetadata + ), + /uid must be a 32-character hex string/ + ); + await Assert.rejects( + keys._deriveScopedKey( + uid, + CommonUtils.hexToBytes(kB).slice(0, -1), + "app_key", + scopedKeyMetadata + ), + /kBbytes must be exactly 32 bytes/ + ); + await Assert.rejects( + keys._deriveScopedKey(uid, CommonUtils.hexToBytes(kB), "app_key", { + ...scopedKeyMetadata, + identifier: "foo", + }), + /identifier must be a string of length >= 10/ + ); + await Assert.rejects( + keys._deriveScopedKey(uid, CommonUtils.hexToBytes(kB), "app_key", { + ...scopedKeyMetadata, + identifier: {}, + }), + /identifier must be a string of length >= 10/ + ); + await Assert.rejects( + keys._deriveScopedKey(uid, CommonUtils.hexToBytes(kB), "app_key", { + ...scopedKeyMetadata, + keyRotationTimestamp: "xyz", + }), + /keyRotationTimestamp must be a number/ + ); + await Assert.rejects( + keys._deriveScopedKey(uid, CommonUtils.hexToBytes(kB), "app_key", { + ...scopedKeyMetadata, + keyRotationTimestamp: 12345, + }), + /keyRotationTimestamp must round to a 10-digit number/ + ); + await Assert.rejects( + keys._deriveScopedKey(uid, CommonUtils.hexToBytes(kB), "app_key", { + ...scopedKeyMetadata, + keyRotationSecret: scopedKeyMetadata.keyRotationSecret.slice(0, -1), + }), + /keyRotationSecret must be a 64-character hex string/ + ); + await Assert.rejects( + keys._deriveScopedKey(uid, CommonUtils.hexToBytes(kB), "app_key", { + ...scopedKeyMetadata, + keyRotationSecret: scopedKeyMetadata.keyRotationSecret.slice(0, -1) + "z", + }), + /keyRotationSecret must be a 64-character hex string/ + ); +}); diff --git a/services/fxaccounts/tests/xpcshell/test_loginmgr_storage.js b/services/fxaccounts/tests/xpcshell/test_loginmgr_storage.js new file mode 100644 index 0000000000..5b80035418 --- /dev/null +++ b/services/fxaccounts/tests/xpcshell/test_loginmgr_storage.js @@ -0,0 +1,307 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests for FxAccounts, storage and the master password. + +// See verbose logging from FxAccounts.jsm +Services.prefs.setStringPref("identity.fxaccounts.loglevel", "Trace"); + +const { FxAccounts } = ChromeUtils.importESModule( + "resource://gre/modules/FxAccounts.sys.mjs" +); +const { FXA_PWDMGR_HOST, FXA_PWDMGR_REALM } = ChromeUtils.importESModule( + "resource://gre/modules/FxAccountsCommon.sys.mjs" +); + +// Use a backstage pass to get at our LoginManagerStorage object, so we can +// mock the prototype. +var { LoginManagerStorage } = ChromeUtils.importESModule( + "resource://gre/modules/FxAccountsStorage.sys.mjs" +); +var isLoggedIn = true; +LoginManagerStorage.prototype.__defineGetter__("_isLoggedIn", () => isLoggedIn); + +function setLoginMgrLoggedInState(loggedIn) { + isLoggedIn = loggedIn; +} + +initTestLogging("Trace"); + +async function getLoginMgrData() { + let logins = await Services.logins.searchLoginsAsync({ + origin: FXA_PWDMGR_HOST, + httpRealm: FXA_PWDMGR_REALM, + }); + if (!logins.length) { + return null; + } + Assert.equal(logins.length, 1, "only 1 login available"); + return logins[0]; +} + +function createFxAccounts() { + return new FxAccounts({ + _fxAccountsClient: { + async registerDevice() { + return { id: "deviceAAAAAA" }; + }, + async recoveryEmailStatus() { + return { verified: true }; + }, + async signOut() {}, + }, + updateDeviceRegistration() {}, + _getDeviceName() { + return "mock device name"; + }, + observerPreloads: [], + fxaPushService: { + async registerPushEndpoint() { + return { + endpoint: "http://mochi.test:8888", + getKey() { + return null; + }, + }; + }, + async unsubscribe() { + return true; + }, + }, + }); +} + +add_task(async function test_simple() { + let fxa = createFxAccounts(); + + let creds = { + uid: "abcd", + email: "test@example.com", + sessionToken: "sessionToken", + scopedKeys: { + ...MOCK_ACCOUNT_KEYS.scopedKeys, + }, + verified: true, + }; + await fxa._internal.setSignedInUser(creds); + + // This should have stored stuff in both the .json file in the profile + // dir, and the login dir. + let path = PathUtils.join(PathUtils.profileDir, "signedInUser.json"); + let data = await IOUtils.readJSON(path); + + Assert.strictEqual( + data.accountData.email, + creds.email, + "correct email in the clear text" + ); + Assert.strictEqual( + data.accountData.sessionToken, + creds.sessionToken, + "correct sessionToken in the clear text" + ); + Assert.strictEqual( + data.accountData.verified, + creds.verified, + "correct verified flag" + ); + + Assert.ok( + !("scopedKeys" in data.accountData), + "scopedKeys not stored in clear text" + ); + + let login = await getLoginMgrData(); + Assert.strictEqual(login.username, creds.uid, "uid used for username"); + let loginData = JSON.parse(login.password); + Assert.strictEqual( + loginData.version, + data.version, + "same version flag in both places" + ); + Assert.deepEqual( + loginData.accountData.scopedKeys, + creds.scopedKeys, + "correct scoped keys in the login mgr" + ); + Assert.ok(!("email" in loginData), "email not stored in the login mgr json"); + Assert.ok( + !("sessionToken" in loginData), + "sessionToken not stored in the login mgr json" + ); + Assert.ok( + !("verified" in loginData), + "verified not stored in the login mgr json" + ); + + await fxa.signOut(/* localOnly = */ true); + Assert.strictEqual( + await getLoginMgrData(), + null, + "login mgr data deleted on logout" + ); +}); + +add_task(async function test_MPLocked() { + let fxa = createFxAccounts(); + + let creds = { + uid: "abcd", + email: "test@example.com", + sessionToken: "sessionToken", + scopedKeys: { + ...MOCK_ACCOUNT_KEYS.scopedKeys, + }, + verified: true, + }; + + Assert.strictEqual( + await getLoginMgrData(), + null, + "no login mgr at the start" + ); + // tell the storage that the MP is locked. + setLoginMgrLoggedInState(false); + await fxa._internal.setSignedInUser(creds); + + // This should have stored stuff in the .json, and the login manager stuff + // will not exist. + let path = PathUtils.join(PathUtils.profileDir, "signedInUser.json"); + let data = await IOUtils.readJSON(path); + + Assert.strictEqual( + data.accountData.email, + creds.email, + "correct email in the clear text" + ); + Assert.strictEqual( + data.accountData.sessionToken, + creds.sessionToken, + "correct sessionToken in the clear text" + ); + Assert.strictEqual( + data.accountData.verified, + creds.verified, + "correct verified flag" + ); + + Assert.ok( + !("scopedKeys" in data.accountData), + "scopedKeys not stored in clear text" + ); + + Assert.strictEqual( + await getLoginMgrData(), + null, + "login mgr data doesn't exist" + ); + await fxa.signOut(/* localOnly = */ true); +}); + +add_task(async function test_consistentWithMPEdgeCases() { + setLoginMgrLoggedInState(true); + + let fxa = createFxAccounts(); + + let creds1 = { + uid: "uid1", + email: "test@example.com", + sessionToken: "sessionToken", + scopedKeys: { + [SCOPE_OLD_SYNC]: { + kid: "key id 1", + k: "key material 1", + kty: "oct", + }, + }, + verified: true, + }; + + let creds2 = { + uid: "uid2", + email: "test2@example.com", + sessionToken: "sessionToken2", + [SCOPE_OLD_SYNC]: { + kid: "key id 2", + k: "key material 2", + kty: "oct", + }, + verified: false, + }; + + // Log a user in while MP is unlocked. + await fxa._internal.setSignedInUser(creds1); + + // tell the storage that the MP is locked - this will prevent logout from + // being able to clear the data. + setLoginMgrLoggedInState(false); + + // now set the second credentials. + await fxa._internal.setSignedInUser(creds2); + + // We should still have creds1 data in the login manager. + let login = await getLoginMgrData(); + Assert.strictEqual(login.username, creds1.uid); + // and that we do have the first scopedKeys in the login manager. + Assert.deepEqual( + JSON.parse(login.password).accountData.scopedKeys, + creds1.scopedKeys, + "stale data still in login mgr" + ); + + // Make a new FxA instance (otherwise the values in memory will be used) + // and we want the login manager to be unlocked. + setLoginMgrLoggedInState(true); + fxa = createFxAccounts(); + + let accountData = await fxa.getSignedInUser(); + Assert.strictEqual(accountData.email, creds2.email); + // we should have no scopedKeys at all. + Assert.strictEqual( + accountData.scopedKeys, + undefined, + "stale scopedKey wasn't used" + ); + await fxa.signOut(/* localOnly = */ true); +}); + +// A test for the fact we will accept either a UID or email when looking in +// the login manager. +add_task(async function test_uidMigration() { + setLoginMgrLoggedInState(true); + Assert.strictEqual( + await getLoginMgrData(), + null, + "expect no logins at the start" + ); + + // create the login entry using email as a key. + let contents = { + scopedKeys: { + ...MOCK_ACCOUNT_KEYS.scopedKeys, + }, + }; + + let loginInfo = new Components.Constructor( + "@mozilla.org/login-manager/loginInfo;1", + Ci.nsILoginInfo, + "init" + ); + let login = new loginInfo( + FXA_PWDMGR_HOST, + null, // aFormActionOrigin, + FXA_PWDMGR_REALM, // aHttpRealm, + "foo@bar.com", // aUsername + JSON.stringify(contents), // aPassword + "", // aUsernameField + "" + ); // aPasswordField + await Services.logins.addLoginAsync(login); + + // ensure we read it. + let storage = new LoginManagerStorage(); + let got = await storage.get("uid", "foo@bar.com"); + Assert.deepEqual(got, contents); +}); diff --git a/services/fxaccounts/tests/xpcshell/test_oauth_flow.js b/services/fxaccounts/tests/xpcshell/test_oauth_flow.js new file mode 100644 index 0000000000..ef5102ae17 --- /dev/null +++ b/services/fxaccounts/tests/xpcshell/test_oauth_flow.js @@ -0,0 +1,274 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* global crypto */ + +"use strict"; + +const { + FxAccountsOAuth, + ERROR_INVALID_SCOPES, + ERROR_INVALID_STATE, + ERROR_SYNC_SCOPE_NOT_GRANTED, + ERROR_NO_KEYS_JWE, + ERROR_OAUTH_FLOW_ABANDONED, +} = ChromeUtils.importESModule( + "resource://gre/modules/FxAccountsOAuth.sys.mjs" +); + +const { SCOPE_PROFILE, FX_OAUTH_CLIENT_ID } = ChromeUtils.importESModule( + "resource://gre/modules/FxAccountsCommon.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + jwcrypto: "resource://services-crypto/jwcrypto.sys.mjs", +}); + +initTestLogging("Trace"); + +add_task(function test_begin_oauth_flow() { + const oauth = new FxAccountsOAuth(); + add_task(async function test_begin_oauth_flow_invalid_scopes() { + try { + await oauth.beginOAuthFlow("foo,fi,fum", "foo"); + Assert.fail("Should have thrown error, scopes must be an array"); + } catch (e) { + Assert.equal(e.message, ERROR_INVALID_SCOPES); + } + try { + await oauth.beginOAuthFlow(["not-a-real-scope", SCOPE_PROFILE]); + Assert.fail("Should have thrown an error, must use a valid scope"); + } catch (e) { + Assert.equal(e.message, ERROR_INVALID_SCOPES); + } + }); + add_task(async function test_begin_oauth_flow_ok() { + const scopes = [SCOPE_PROFILE, SCOPE_OLD_SYNC]; + const queryParams = await oauth.beginOAuthFlow(scopes); + + // First verify default query parameters + Assert.equal(queryParams.client_id, FX_OAUTH_CLIENT_ID); + Assert.equal(queryParams.action, "email"); + Assert.equal(queryParams.response_type, "code"); + Assert.equal(queryParams.access_type, "offline"); + Assert.equal(queryParams.scope, [SCOPE_PROFILE, SCOPE_OLD_SYNC].join(" ")); + + // Then, we verify that the state is a valid Base64 value + const state = queryParams.state; + ChromeUtils.base64URLDecode(state, { padding: "reject" }); + + // Then, we verify that the codeVerifier, can be used to verify the code_challenge + const code_challenge = queryParams.code_challenge; + Assert.equal(queryParams.code_challenge_method, "S256"); + const oauthFlow = oauth.getFlow(state); + const codeVerifierB64 = oauthFlow.verifier; + const expectedChallenge = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(codeVerifierB64) + ); + const expectedChallengeB64 = ChromeUtils.base64URLEncode( + expectedChallenge, + { pad: false } + ); + Assert.equal(expectedChallengeB64, code_challenge); + + // Then, we verify that something encrypted with the `keys_jwk`, can be decrypted using the private key + const keysJwk = queryParams.keys_jwk; + const decodedKeysJwk = JSON.parse( + new TextDecoder().decode( + ChromeUtils.base64URLDecode(keysJwk, { padding: "reject" }) + ) + ); + const plaintext = "text to be encrypted and decrypted!"; + delete decodedKeysJwk.key_ops; + const jwe = await jwcrypto.generateJWE( + decodedKeysJwk, + new TextEncoder().encode(plaintext) + ); + const privateKey = oauthFlow.key; + const decrypted = await jwcrypto.decryptJWE(jwe, privateKey); + Assert.equal(new TextDecoder().decode(decrypted), plaintext); + + // Finally, we verify that we stored the requested scopes + Assert.deepEqual(oauthFlow.requestedScopes, scopes.join(" ")); + }); +}); + +add_task(function test_complete_oauth_flow() { + add_task(async function test_invalid_state() { + const oauth = new FxAccountsOAuth(); + const code = "foo"; + const state = "bar"; + const sessionToken = "01abcef12"; + try { + await oauth.completeOAuthFlow(sessionToken, code, state); + Assert.fail("Should have thrown an error"); + } catch (err) { + Assert.equal(err.message, ERROR_INVALID_STATE); + } + }); + add_task(async function test_sync_scope_not_authorized() { + const fxaClient = { + oauthToken: () => + Promise.resolve({ + access_token: "access_token", + refresh_token: "refresh_token", + // Note that the scope does not include the sync scope + scope: SCOPE_PROFILE, + }), + }; + const oauth = new FxAccountsOAuth(fxaClient); + const scopes = [SCOPE_PROFILE, SCOPE_OLD_SYNC]; + const sessionToken = "01abcef12"; + const queryParams = await oauth.beginOAuthFlow(scopes); + try { + await oauth.completeOAuthFlow(sessionToken, "foo", queryParams.state); + Assert.fail( + "Should have thrown an error because the sync scope was not authorized" + ); + } catch (err) { + Assert.equal(err.message, ERROR_SYNC_SCOPE_NOT_GRANTED); + } + }); + add_task(async function test_jwe_not_returned() { + const scopes = [SCOPE_PROFILE, SCOPE_OLD_SYNC]; + const fxaClient = { + oauthToken: () => + Promise.resolve({ + access_token: "access_token", + refresh_token: "refresh_token", + scope: scopes.join(" "), + }), + }; + const oauth = new FxAccountsOAuth(fxaClient); + const queryParams = await oauth.beginOAuthFlow(scopes); + const sessionToken = "01abcef12"; + try { + await oauth.completeOAuthFlow(sessionToken, "foo", queryParams.state); + Assert.fail( + "Should have thrown an error because we didn't get back a keys_nwe" + ); + } catch (err) { + Assert.equal(err.message, ERROR_NO_KEYS_JWE); + } + }); + add_task(async function test_complete_oauth_ok() { + // First, we initialize some fake values we would typically get + // from outside our system + const scopes = [SCOPE_PROFILE, SCOPE_OLD_SYNC]; + const oauthCode = "fake oauth code"; + const sessionToken = "01abcef12"; + const plainTextScopedKeys = { + kid: "fake key id", + k: "fake key", + kty: "oct", + }; + const fakeAccessToken = "fake access token"; + const fakeRefreshToken = "fake refresh token"; + // Then, we initialize a fake http client, we'll add our fake oauthToken call + // once we have started the oauth flow (so we have the public keys!) + const fxaClient = {}; + // Then, we initialize our oauth object with the given client and begin a new flow + const oauth = new FxAccountsOAuth(fxaClient); + const queryParams = await oauth.beginOAuthFlow(scopes); + // Now that we have the public keys in `keys_jwk`, we use it to generate a JWE + // representing our scoped keys + const keysJwk = queryParams.keys_jwk; + const decodedKeysJwk = JSON.parse( + new TextDecoder().decode( + ChromeUtils.base64URLDecode(keysJwk, { padding: "reject" }) + ) + ); + delete decodedKeysJwk.key_ops; + const jwe = await jwcrypto.generateJWE( + decodedKeysJwk, + new TextEncoder().encode(JSON.stringify(plainTextScopedKeys)) + ); + // We also grab the stored PKCE verifier that the oauth object stored internally + // to verify that we correctly send it as a part of our HTTP request + const storedVerifier = oauth.getFlow(queryParams.state).verifier; + + // To test what happens when more than one flow is completed simulatniously + // We mimic a slow network call on the first oauthToken call and let the second + // one win + let callCount = 0; + let slowResolve; + const resolveFn = (payload, resolve) => { + if (callCount === 1) { + // This is the second call + // lets resolve it so the second call wins + resolve(payload); + } else { + callCount += 1; + // This is the first call, let store our resolve function for later + // it will be resolved once the fast flow is fully completed + slowResolve = () => resolve(payload); + } + }; + + // Now we initialize our mock of the HTTP request, it verifies we passed in all the correct + // parameters and returns what we'd expect a healthy HTTP Response would look like + fxaClient.oauthToken = (sessionTokenHex, code, verifier, clientId) => { + Assert.equal(sessionTokenHex, sessionToken); + Assert.equal(code, oauthCode); + Assert.equal(verifier, storedVerifier); + Assert.equal(clientId, queryParams.client_id); + const response = { + access_token: fakeAccessToken, + refresh_token: fakeRefreshToken, + scope: scopes.join(" "), + keys_jwe: jwe, + }; + return new Promise(resolve => { + resolveFn(response, resolve); + }); + }; + + // Then, we call the completeOAuthFlow function, and get back our access token, + // refresh token and scopedKeys + + // To test what happens when multiple flows race, we create two flows, + // A slow one that will start first, but finish last + // And a fast one that will beat the slow one + const firstCompleteOAuthFlow = oauth + .completeOAuthFlow(sessionToken, oauthCode, queryParams.state) + .then(res => { + // To mimic the slow network connection on the slowCompleteOAuthFlow + // We resume the slow completeOAuthFlow once this one is complete + slowResolve(); + return res; + }); + const secondCompleteOAuthFlow = oauth + .completeOAuthFlow(sessionToken, oauthCode, queryParams.state) + .then(res => { + // since we can't fully gaurentee which oauth flow finishes first, we also resolve here + slowResolve(); + return res; + }); + + const { accessToken, refreshToken, scopedKeys } = await Promise.allSettled([ + firstCompleteOAuthFlow, + secondCompleteOAuthFlow, + ]).then(results => { + let fast; + let slow; + for (const result of results) { + if (result.status === "fulfilled") { + fast = result.value; + } else { + slow = result.reason; + } + } + // We make sure that we indeed have one slow flow that lost + Assert.equal(slow.message, ERROR_OAUTH_FLOW_ABANDONED); + return fast; + }); + + Assert.equal(accessToken, fakeAccessToken); + Assert.equal(refreshToken, fakeRefreshToken); + Assert.deepEqual(scopedKeys, plainTextScopedKeys); + + // Finally, we verify that all stored flows were cleared + Assert.equal(oauth.numOfFlows(), 0); + }); +}); diff --git a/services/fxaccounts/tests/xpcshell/test_oauth_token_storage.js b/services/fxaccounts/tests/xpcshell/test_oauth_token_storage.js new file mode 100644 index 0000000000..798c439212 --- /dev/null +++ b/services/fxaccounts/tests/xpcshell/test_oauth_token_storage.js @@ -0,0 +1,180 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { FxAccounts } = ChromeUtils.importESModule( + "resource://gre/modules/FxAccounts.sys.mjs" +); +const { FxAccountsClient } = ChromeUtils.importESModule( + "resource://gre/modules/FxAccountsClient.sys.mjs" +); + +// We grab some additional stuff via backstage passes. +var { AccountState } = ChromeUtils.importESModule( + "resource://gre/modules/FxAccounts.sys.mjs" +); + +function promiseNotification(topic) { + return new Promise(resolve => { + let observe = () => { + Services.obs.removeObserver(observe, topic); + resolve(); + }; + Services.obs.addObserver(observe, topic); + }); +} + +// A storage manager that doesn't actually write anywhere. +function MockStorageManager() {} + +MockStorageManager.prototype = { + promiseInitialized: Promise.resolve(), + + initialize(accountData) { + this.accountData = accountData; + }, + + finalize() { + return Promise.resolve(); + }, + + getAccountData() { + return Promise.resolve(this.accountData); + }, + + updateAccountData(updatedFields) { + for (let [name, value] of Object.entries(updatedFields)) { + if (value == null) { + delete this.accountData[name]; + } else { + this.accountData[name] = value; + } + } + return Promise.resolve(); + }, + + deleteAccountData() { + this.accountData = null; + return Promise.resolve(); + }, +}; + +// Just enough mocks so we can avoid hawk etc. +function MockFxAccountsClient() { + this._email = "nobody@example.com"; + this._verified = false; + + this.accountStatus = function (uid) { + return Promise.resolve(!!uid && !this._deletedOnServer); + }; + + this.signOut = function () { + return Promise.resolve(); + }; + this.registerDevice = function () { + return Promise.resolve(); + }; + this.updateDevice = function () { + return Promise.resolve(); + }; + this.signOutAndDestroyDevice = function () { + return Promise.resolve(); + }; + this.getDeviceList = function () { + return Promise.resolve(); + }; + + FxAccountsClient.apply(this); +} + +MockFxAccountsClient.prototype = {}; +Object.setPrototypeOf( + MockFxAccountsClient.prototype, + FxAccountsClient.prototype +); + +function MockFxAccounts(device = {}) { + return new FxAccounts({ + fxAccountsClient: new MockFxAccountsClient(), + newAccountState(credentials) { + // we use a real accountState but mocked storage. + let storage = new MockStorageManager(); + storage.initialize(credentials); + return new AccountState(storage); + }, + _getDeviceName() { + return "mock device name"; + }, + fxaPushService: { + registerPushEndpoint() { + return new Promise(resolve => { + resolve({ + endpoint: "http://mochi.test:8888", + }); + }); + }, + }, + }); +} + +async function createMockFxA() { + let fxa = new MockFxAccounts(); + let credentials = { + email: "foo@example.com", + uid: "1234@lcip.org", + sessionToken: "dead", + scopedKeys: { + [SCOPE_OLD_SYNC]: { + kid: "key id for sync key", + k: "key material for sync key", + kty: "oct", + }, + }, + verified: true, + }; + await fxa._internal.setSignedInUser(credentials); + return fxa; +} + +// The tests. + +add_task(async function testCacheStorage() { + let fxa = await createMockFxA(); + + // Hook what the impl calls to save to disk. + let cas = fxa._internal.currentAccountState; + let origPersistCached = cas._persistCachedTokens.bind(cas); + cas._persistCachedTokens = function () { + return origPersistCached().then(() => { + Services.obs.notifyObservers(null, "testhelper-fxa-cache-persist-done"); + }); + }; + + let promiseWritten = promiseNotification("testhelper-fxa-cache-persist-done"); + let tokenData = { token: "token1", somethingelse: "something else" }; + let scopeArray = ["foo", "bar"]; + cas.setCachedToken(scopeArray, tokenData); + deepEqual(cas.getCachedToken(scopeArray), tokenData); + + deepEqual(cas.oauthTokens, { "bar|foo": tokenData }); + // wait for background write to complete. + await promiseWritten; + + // Check the token cache made it to our mocked storage. + deepEqual(cas.storageManager.accountData.oauthTokens, { + "bar|foo": tokenData, + }); + + // Drop the token from the cache and ensure it is removed from the json. + promiseWritten = promiseNotification("testhelper-fxa-cache-persist-done"); + await cas.removeCachedToken("token1"); + deepEqual(cas.oauthTokens, {}); + await promiseWritten; + deepEqual(cas.storageManager.accountData.oauthTokens, {}); + + // sign out and the token storage should end up with null. + let storageManager = cas.storageManager; // .signOut() removes the attribute. + await fxa.signOut(/* localOnly = */ true); + deepEqual(storageManager.accountData, null); +}); diff --git a/services/fxaccounts/tests/xpcshell/test_oauth_tokens.js b/services/fxaccounts/tests/xpcshell/test_oauth_tokens.js new file mode 100644 index 0000000000..82f174edd1 --- /dev/null +++ b/services/fxaccounts/tests/xpcshell/test_oauth_tokens.js @@ -0,0 +1,255 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { FxAccounts } = ChromeUtils.importESModule( + "resource://gre/modules/FxAccounts.sys.mjs" +); +const { FxAccountsClient } = ChromeUtils.importESModule( + "resource://gre/modules/FxAccountsClient.sys.mjs" +); +var { AccountState } = ChromeUtils.importESModule( + "resource://gre/modules/FxAccounts.sys.mjs" +); + +function promiseNotification(topic) { + return new Promise(resolve => { + let observe = () => { + Services.obs.removeObserver(observe, topic); + resolve(); + }; + Services.obs.addObserver(observe, topic); + }); +} + +// Just enough mocks so we can avoid hawk and storage. +function MockStorageManager() {} + +MockStorageManager.prototype = { + promiseInitialized: Promise.resolve(), + + initialize(accountData) { + this.accountData = accountData; + }, + + finalize() { + return Promise.resolve(); + }, + + getAccountData() { + return Promise.resolve(this.accountData); + }, + + updateAccountData(updatedFields) { + for (let [name, value] of Object.entries(updatedFields)) { + if (value == null) { + delete this.accountData[name]; + } else { + this.accountData[name] = value; + } + } + return Promise.resolve(); + }, + + deleteAccountData() { + this.accountData = null; + return Promise.resolve(); + }, +}; + +function MockFxAccountsClient(activeTokens) { + this._email = "nobody@example.com"; + this._verified = false; + + this.accountStatus = function (uid) { + return Promise.resolve(!!uid && !this._deletedOnServer); + }; + + this.signOut = function () { + return Promise.resolve(); + }; + this.registerDevice = function () { + return Promise.resolve(); + }; + this.updateDevice = function () { + return Promise.resolve(); + }; + this.signOutAndDestroyDevice = function () { + return Promise.resolve(); + }; + this.getDeviceList = function () { + return Promise.resolve(); + }; + this.accessTokenWithSessionToken = function ( + sessionTokenHex, + clientId, + scope, + ttl + ) { + let token = `token${this.numTokenFetches}`; + if (ttl) { + token += `-ttl-${ttl}`; + } + this.numTokenFetches += 1; + this.activeTokens.add(token); + print("accessTokenWithSessionToken returning token", token); + return Promise.resolve({ access_token: token, ttl }); + }; + this.oauthDestroy = sinon.stub().callsFake((_clientId, token) => { + this.activeTokens.delete(token); + return Promise.resolve(); + }); + + // Test only stuff. + this.activeTokens = activeTokens; + this.numTokenFetches = 0; + + FxAccountsClient.apply(this); +} + +MockFxAccountsClient.prototype = {}; +Object.setPrototypeOf( + MockFxAccountsClient.prototype, + FxAccountsClient.prototype +); + +function MockFxAccounts() { + // The FxA "auth" and "oauth" servers both share the same db of tokens, + // so we need to simulate the same here in the tests. + const activeTokens = new Set(); + return new FxAccounts({ + fxAccountsClient: new MockFxAccountsClient(activeTokens), + newAccountState(credentials) { + // we use a real accountState but mocked storage. + let storage = new MockStorageManager(); + storage.initialize(credentials); + return new AccountState(storage); + }, + _getDeviceName() { + return "mock device name"; + }, + fxaPushService: { + registerPushEndpoint() { + return new Promise(resolve => { + resolve({ + endpoint: "http://mochi.test:8888", + }); + }); + }, + }, + }); +} + +async function createMockFxA() { + let fxa = new MockFxAccounts(); + let credentials = { + email: "foo@example.com", + uid: "1234@lcip.org", + sessionToken: "dead", + scopedKeys: { + [SCOPE_OLD_SYNC]: { + kid: "key id for sync key", + k: "key material for sync key", + kty: "oct", + }, + }, + verified: true, + }; + + await fxa._internal.setSignedInUser(credentials); + return fxa; +} + +// The tests. + +add_task(async function testRevoke() { + let tokenOptions = { scope: "test-scope" }; + let fxa = await createMockFxA(); + let client = fxa._internal.fxAccountsClient; + + // get our first token and check we hit the mock. + let token1 = await fxa.getOAuthToken(tokenOptions); + equal(client.numTokenFetches, 1); + equal(client.activeTokens.size, 1); + ok(token1, "got a token"); + equal(token1, "token0"); + + // drop the new token from our cache. + await fxa.removeCachedOAuthToken({ token: token1 }); + ok(client.oauthDestroy.calledOnce); + + // the revoke should have been successful. + equal(client.activeTokens.size, 0); + // fetching it again hits the server. + let token2 = await fxa.getOAuthToken(tokenOptions); + equal(client.numTokenFetches, 2); + equal(client.activeTokens.size, 1); + ok(token2, "got a token"); + notEqual(token1, token2, "got a different token"); +}); + +add_task(async function testSignOutDestroysTokens() { + let fxa = await createMockFxA(); + let client = fxa._internal.fxAccountsClient; + + // get our first token and check we hit the mock. + let token1 = await fxa.getOAuthToken({ scope: "test-scope" }); + equal(client.numTokenFetches, 1); + equal(client.activeTokens.size, 1); + ok(token1, "got a token"); + + // get another + let token2 = await fxa.getOAuthToken({ scope: "test-scope-2" }); + equal(client.numTokenFetches, 2); + equal(client.activeTokens.size, 2); + ok(token2, "got a token"); + notEqual(token1, token2, "got a different token"); + + // FxA fires an observer when the "background" signout is complete. + let signoutComplete = promiseNotification("testhelper-fxa-signout-complete"); + // now sign out - they should be removed. + await fxa.signOut(); + await signoutComplete; + ok(client.oauthDestroy.calledTwice); + // No active tokens left. + equal(client.activeTokens.size, 0); +}); + +add_task(async function testTokenRaces() { + // Here we do 2 concurrent fetches each for 2 different token scopes (ie, + // 4 token fetches in total). + // This should provoke a potential race in the token fetching but we use + // a map of in-flight token fetches, so we should still only perform 2 + // fetches, but each of the 4 calls should resolve with the correct values. + let fxa = await createMockFxA(); + let client = fxa._internal.fxAccountsClient; + + let results = await Promise.all([ + fxa.getOAuthToken({ scope: "test-scope" }), + fxa.getOAuthToken({ scope: "test-scope" }), + fxa.getOAuthToken({ scope: "test-scope-2" }), + fxa.getOAuthToken({ scope: "test-scope-2" }), + ]); + + equal(client.numTokenFetches, 2, "should have fetched 2 tokens."); + + // Should have 2 unique tokens + results.sort(); + equal(results[0], results[1]); + equal(results[2], results[3]); + // should be 2 active. + equal(client.activeTokens.size, 2); + await fxa.removeCachedOAuthToken({ token: results[0] }); + equal(client.activeTokens.size, 1); + await fxa.removeCachedOAuthToken({ token: results[2] }); + equal(client.activeTokens.size, 0); + ok(client.oauthDestroy.calledTwice); +}); + +add_task(async function testTokenTTL() { + // This tests the TTL option passed into the method + let fxa = await createMockFxA(); + let token = await fxa.getOAuthToken({ scope: "test-ttl", ttl: 1000 }); + equal(token, "token0-ttl-1000"); +}); diff --git a/services/fxaccounts/tests/xpcshell/test_pairing.js b/services/fxaccounts/tests/xpcshell/test_pairing.js new file mode 100644 index 0000000000..eac3112242 --- /dev/null +++ b/services/fxaccounts/tests/xpcshell/test_pairing.js @@ -0,0 +1,384 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { FxAccountsPairingFlow } = ChromeUtils.importESModule( + "resource://gre/modules/FxAccountsPairing.sys.mjs" +); +const { EventEmitter } = ChromeUtils.importESModule( + "resource://gre/modules/EventEmitter.sys.mjs" +); +ChromeUtils.defineESModuleGetters(this, { + jwcrypto: "resource://services-crypto/jwcrypto.sys.mjs", +}); + +const CHANNEL_ID = "sW-UA97Q6Dljqen7XRlYPw"; +const CHANNEL_KEY = crypto.getRandomValues(new Uint8Array(32)); + +const SENDER_SUPP = { + ua: "Firefox Supp", + city: "Nice", + region: "PACA", + country: "France", + remote: "127.0.0.1", +}; +const UID = "abcd"; +const EMAIL = "foo@bar.com"; +const AVATAR = "https://foo.bar/avatar"; +const DISPLAY_NAME = "Foo bar"; +const DEVICE_NAME = "Foo's computer"; + +const PAIR_URI = "https://foo.bar/pair"; +const OAUTH_URI = "https://foo.bar/oauth"; +const KSYNC = "myksync"; +const SESSION = "mysession"; +const fxaConfig = { + promisePairingURI() { + return PAIR_URI; + }, + promiseOAuthURI() { + return OAUTH_URI; + }, +}; +const fxAccounts = { + getSignedInUser() { + return { + uid: UID, + email: EMAIL, + avatar: AVATAR, + displayName: DISPLAY_NAME, + }; + }, + async _withVerifiedAccountState(cb) { + return cb({ + async getUserAccountData() { + return { + sessionToken: SESSION, + }; + }, + }); + }, + _internal: { + keys: { + getKeyForScope(scope) { + return { + kid: "123456", + k: KSYNC, + kty: "oct", + }; + }, + }, + fxAccountsClient: { + async getScopedKeyData() { + return { + "https://identity.mozilla.com/apps/oldsync": { + identifier: "https://identity.mozilla.com/apps/oldsync", + keyRotationTimestamp: 12345678, + }, + }; + }, + async oauthAuthorize() { + return { code: "mycode", state: "mystate" }; + }, + }, + }, +}; +const weave = { + Service: { clientsEngine: { localName: DEVICE_NAME } }, +}; + +class MockPairingChannel extends EventTarget { + get channelId() { + return CHANNEL_ID; + } + + get channelKey() { + return CHANNEL_KEY; + } + + send(data) { + this.dispatchEvent( + new CustomEvent("send", { + detail: { data }, + }) + ); + } + + simulateIncoming(data) { + this.dispatchEvent( + new CustomEvent("message", { + detail: { data, sender: SENDER_SUPP }, + }) + ); + } + + close() { + this.closed = true; + } +} + +add_task(async function testFullFlow() { + const emitter = new EventEmitter(); + const pairingChannel = new MockPairingChannel(); + const pairingUri = await FxAccountsPairingFlow.start({ + emitter, + pairingChannel, + fxAccounts, + fxaConfig, + weave, + }); + Assert.equal( + pairingUri, + `${PAIR_URI}#channel_id=${CHANNEL_ID}&channel_key=${ChromeUtils.base64URLEncode( + CHANNEL_KEY, + { pad: false } + )}` + ); + + const flow = FxAccountsPairingFlow.get(CHANNEL_ID); + + const promiseSwitchToWebContent = emitter.once("view:SwitchToWebContent"); + const promiseMetadataSent = promiseOutgoingMessage(pairingChannel); + const epk = await generateEphemeralKeypair(); + + pairingChannel.simulateIncoming({ + message: "pair:supp:request", + data: { + client_id: "client_id_1", + state: "mystate", + keys_jwk: ChromeUtils.base64URLEncode( + new TextEncoder().encode(JSON.stringify(epk.publicJWK)), + { pad: false } + ), + scope: "profile https://identity.mozilla.com/apps/oldsync", + code_challenge: "chal", + code_challenge_method: "S256", + }, + }); + const sentAuthMetadata = await promiseMetadataSent; + Assert.deepEqual(sentAuthMetadata, { + message: "pair:auth:metadata", + data: { + email: EMAIL, + avatar: AVATAR, + displayName: DISPLAY_NAME, + deviceName: DEVICE_NAME, + }, + }); + const oauthUrl = await promiseSwitchToWebContent; + Assert.equal( + oauthUrl, + `${OAUTH_URI}?client_id=client_id_1&scope=profile+https%3A%2F%2Fidentity.mozilla.com%2Fapps%2Foldsync&email=foo%40bar.com&uid=abcd&channel_id=${CHANNEL_ID}&redirect_uri=urn%3Aietf%3Awg%3Aoauth%3A2.0%3Aoob%3Apair-auth-webchannel` + ); + + let pairSuppMetadata = await simulateIncomingWebChannel( + flow, + "fxaccounts:pair_supplicant_metadata" + ); + Assert.deepEqual( + { + ua: "Firefox Supp", + city: "Nice", + region: "PACA", + country: "France", + ipAddress: "127.0.0.1", + }, + pairSuppMetadata + ); + + const generateJWE = sinon.spy(jwcrypto, "generateJWE"); + const oauthAuthorize = sinon.spy( + fxAccounts._internal.fxAccountsClient, + "oauthAuthorize" + ); + const promiseOAuthParamsMsg = promiseOutgoingMessage(pairingChannel); + await simulateIncomingWebChannel(flow, "fxaccounts:pair_authorize"); + // We should have generated the expected JWE. + Assert.ok(generateJWE.calledOnce); + const generateArgs = generateJWE.firstCall.args; + Assert.deepEqual(generateArgs[0], epk.publicJWK); + Assert.deepEqual(JSON.parse(new TextDecoder().decode(generateArgs[1])), { + "https://identity.mozilla.com/apps/oldsync": { + kid: "123456", + k: KSYNC, + kty: "oct", + }, + }); + // We should have authorized an oauth code with expected parameters. + Assert.ok(oauthAuthorize.calledOnce); + const oauthCodeArgs = oauthAuthorize.firstCall.args[1]; + console.log(oauthCodeArgs); + Assert.ok(!oauthCodeArgs.keys_jwk); + Assert.deepEqual( + oauthCodeArgs.keys_jwe, + await generateJWE.firstCall.returnValue + ); + Assert.equal(oauthCodeArgs.client_id, "client_id_1"); + Assert.equal(oauthCodeArgs.access_type, "offline"); + Assert.equal(oauthCodeArgs.state, "mystate"); + Assert.equal( + oauthCodeArgs.scope, + "profile https://identity.mozilla.com/apps/oldsync" + ); + Assert.equal(oauthCodeArgs.code_challenge, "chal"); + Assert.equal(oauthCodeArgs.code_challenge_method, "S256"); + + const oAuthParams = await promiseOAuthParamsMsg; + Assert.deepEqual(oAuthParams, { + message: "pair:auth:authorize", + data: { code: "mycode", state: "mystate" }, + }); + + let heartbeat = await simulateIncomingWebChannel( + flow, + "fxaccounts:pair_heartbeat" + ); + Assert.ok(!heartbeat.suppAuthorized); + + await pairingChannel.simulateIncoming({ + message: "pair:supp:authorize", + }); + + heartbeat = await simulateIncomingWebChannel( + flow, + "fxaccounts:pair_heartbeat" + ); + Assert.ok(heartbeat.suppAuthorized); + + await simulateIncomingWebChannel(flow, "fxaccounts:pair_complete"); + // The flow should have been destroyed! + Assert.ok(!FxAccountsPairingFlow.get(CHANNEL_ID)); + Assert.ok(pairingChannel.closed); + generateJWE.restore(); + oauthAuthorize.restore(); +}); + +add_task(async function testUnknownPairingMessage() { + const emitter = new EventEmitter(); + const pairingChannel = new MockPairingChannel(); + await FxAccountsPairingFlow.start({ + emitter, + pairingChannel, + fxAccounts, + fxaConfig, + weave, + }); + const flow = FxAccountsPairingFlow.get(CHANNEL_ID); + const viewErrorObserved = emitter.once("view:Error"); + pairingChannel.simulateIncoming({ + message: "pair:boom", + }); + await viewErrorObserved; + let heartbeat = await simulateIncomingWebChannel( + flow, + "fxaccounts:pair_heartbeat" + ); + Assert.ok(heartbeat.err); +}); + +add_task(async function testUnknownWebChannelCommand() { + const emitter = new EventEmitter(); + const pairingChannel = new MockPairingChannel(); + await FxAccountsPairingFlow.start({ + emitter, + pairingChannel, + fxAccounts, + fxaConfig, + weave, + }); + const flow = FxAccountsPairingFlow.get(CHANNEL_ID); + const viewErrorObserved = emitter.once("view:Error"); + await simulateIncomingWebChannel(flow, "fxaccounts:boom"); + await viewErrorObserved; + let heartbeat = await simulateIncomingWebChannel( + flow, + "fxaccounts:pair_heartbeat" + ); + Assert.ok(heartbeat.err); +}); + +add_task(async function testPairingChannelFailure() { + const emitter = new EventEmitter(); + const pairingChannel = new MockPairingChannel(); + await FxAccountsPairingFlow.start({ + emitter, + pairingChannel, + fxAccounts, + fxaConfig, + weave, + }); + const flow = FxAccountsPairingFlow.get(CHANNEL_ID); + const viewErrorObserved = emitter.once("view:Error"); + sinon.stub(pairingChannel, "send").callsFake(() => { + throw new Error("Boom!"); + }); + pairingChannel.simulateIncoming({ + message: "pair:supp:request", + data: { + client_id: "client_id_1", + state: "mystate", + scope: "profile https://identity.mozilla.com/apps/oldsync", + code_challenge: "chal", + code_challenge_method: "S256", + }, + }); + await viewErrorObserved; + + let heartbeat = await simulateIncomingWebChannel( + flow, + "fxaccounts:pair_heartbeat" + ); + Assert.ok(heartbeat.err); +}); + +add_task(async function testFlowTimeout() { + const emitter = new EventEmitter(); + const pairingChannel = new MockPairingChannel(); + const viewErrorObserved = emitter.once("view:Error"); + await FxAccountsPairingFlow.start({ + emitter, + pairingChannel, + fxAccounts, + fxaConfig, + weave, + flowTimeout: 1, + }); + const flow = FxAccountsPairingFlow.get(CHANNEL_ID); + await viewErrorObserved; + + let heartbeat = await simulateIncomingWebChannel( + flow, + "fxaccounts:pair_heartbeat" + ); + Assert.ok(heartbeat.err.match(/Timeout/)); +}); + +async function simulateIncomingWebChannel(flow, command) { + return flow.onWebChannelMessage(command); +} + +async function promiseOutgoingMessage(pairingChannel) { + return new Promise(res => { + const onMessage = event => { + pairingChannel.removeEventListener("send", onMessage); + res(event.detail.data); + }; + pairingChannel.addEventListener("send", onMessage); + }); +} + +async function generateEphemeralKeypair() { + const keypair = await crypto.subtle.generateKey( + { name: "ECDH", namedCurve: "P-256" }, + true, + ["deriveKey"] + ); + const publicJWK = await crypto.subtle.exportKey("jwk", keypair.publicKey); + const privateJWK = await crypto.subtle.exportKey("jwk", keypair.privateKey); + delete publicJWK.key_ops; + return { + publicJWK, + privateJWK, + }; +} diff --git a/services/fxaccounts/tests/xpcshell/test_profile.js b/services/fxaccounts/tests/xpcshell/test_profile.js new file mode 100644 index 0000000000..f8137b5691 --- /dev/null +++ b/services/fxaccounts/tests/xpcshell/test_profile.js @@ -0,0 +1,677 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { ON_PROFILE_CHANGE_NOTIFICATION, log } = ChromeUtils.importESModule( + "resource://gre/modules/FxAccountsCommon.sys.mjs" +); +const { FxAccountsProfileClient } = ChromeUtils.importESModule( + "resource://gre/modules/FxAccountsProfileClient.sys.mjs" +); +const { FxAccountsProfile } = ChromeUtils.importESModule( + "resource://gre/modules/FxAccountsProfile.sys.mjs" +); +const { setTimeout } = ChromeUtils.importESModule( + "resource://gre/modules/Timer.sys.mjs" +); + +let mockClient = function (fxa) { + let options = { + serverURL: "http://127.0.0.1:1111/v1", + fxa, + }; + return new FxAccountsProfileClient(options); +}; + +const ACCOUNT_UID = "abc123"; +const ACCOUNT_EMAIL = "foo@bar.com"; +const ACCOUNT_DATA = { + uid: ACCOUNT_UID, + email: ACCOUNT_EMAIL, +}; + +let mockFxa = function () { + let fxa = { + // helpers to make the tests using this mock less verbose... + set _testProfileCache(profileCache) { + this._internal.currentAccountState._data.profileCache = profileCache; + }, + get _testProfileCache() { + return this._internal.currentAccountState._data.profileCache; + }, + }; + fxa._internal = Object.assign( + {}, + { + currentAccountState: Object.assign( + {}, + { + _data: Object.assign({}, ACCOUNT_DATA), + + get isCurrent() { + return true; + }, + + async getUserAccountData() { + return this._data; + }, + + async updateUserAccountData(data) { + this._data = Object.assign(this._data, data); + }, + } + ), + + withCurrentAccountState(cb) { + return cb(this.currentAccountState); + }, + + async _handleTokenError(err) { + // handleTokenError always rethrows. + throw err; + }, + } + ); + return fxa; +}; + +function CreateFxAccountsProfile(fxa = null, client = null) { + if (!fxa) { + fxa = mockFxa(); + } + let options = { + fxai: fxa._internal, + profileServerUrl: "http://127.0.0.1:1111/v1", + }; + if (client) { + options.profileClient = client; + } + return new FxAccountsProfile(options); +} + +add_test(function cacheProfile_change() { + let setProfileCacheCalled = false; + let fxa = mockFxa(); + fxa._internal.currentAccountState.updateUserAccountData = data => { + setProfileCacheCalled = true; + Assert.equal(data.profileCache.profile.avatar, "myurl"); + Assert.equal(data.profileCache.etag, "bogusetag"); + return Promise.resolve(); + }; + let profile = CreateFxAccountsProfile(fxa); + + makeObserver(ON_PROFILE_CHANGE_NOTIFICATION, function (subject, topic, data) { + Assert.equal(data, ACCOUNT_DATA.uid); + Assert.ok(setProfileCacheCalled); + run_next_test(); + }); + + return profile._cacheProfile({ + body: { uid: ACCOUNT_UID, email: ACCOUNT_EMAIL, avatar: "myurl" }, + etag: "bogusetag", + }); +}); + +add_test(function fetchAndCacheProfile_ok() { + let client = mockClient(mockFxa()); + client.fetchProfile = function () { + return Promise.resolve({ body: { uid: ACCOUNT_UID, avatar: "myimg" } }); + }; + let profile = CreateFxAccountsProfile(null, client); + profile._cachedAt = 12345; + + profile._cacheProfile = function (toCache) { + Assert.equal(toCache.body.avatar, "myimg"); + return Promise.resolve(toCache.body); + }; + + return profile._fetchAndCacheProfile().then(result => { + Assert.equal(result.avatar, "myimg"); + Assert.notEqual(profile._cachedAt, 12345, "cachedAt has been bumped"); + run_next_test(); + }); +}); + +add_test(function fetchAndCacheProfile_always_bumps_cachedAt() { + let client = mockClient(mockFxa()); + client.fetchProfile = function () { + return Promise.reject(new Error("oops")); + }; + let profile = CreateFxAccountsProfile(null, client); + profile._cachedAt = 12345; + + return profile._fetchAndCacheProfile().then( + result => { + do_throw("Should not succeed"); + }, + err => { + Assert.notEqual(profile._cachedAt, 12345, "cachedAt has been bumped"); + run_next_test(); + } + ); +}); + +add_test(function fetchAndCacheProfile_sendsETag() { + let fxa = mockFxa(); + fxa._testProfileCache = { profile: {}, etag: "bogusETag" }; + let client = mockClient(fxa); + client.fetchProfile = function (etag) { + Assert.equal(etag, "bogusETag"); + return Promise.resolve({ + body: { uid: ACCOUNT_UID, email: ACCOUNT_EMAIL, avatar: "myimg" }, + }); + }; + let profile = CreateFxAccountsProfile(fxa, client); + + return profile._fetchAndCacheProfile().then(result => { + run_next_test(); + }); +}); + +// Check that a second profile request when one is already in-flight reuses +// the in-flight one. +add_task(async function fetchAndCacheProfileOnce() { + // A promise that remains unresolved while we fire off 2 requests for + // a profile. + let resolveProfile; + let promiseProfile = new Promise(resolve => { + resolveProfile = resolve; + }); + let numFetches = 0; + let client = mockClient(mockFxa()); + client.fetchProfile = function () { + numFetches += 1; + return promiseProfile; + }; + let fxa = mockFxa(); + let profile = CreateFxAccountsProfile(fxa, client); + + let request1 = profile._fetchAndCacheProfile(); + profile._fetchAndCacheProfile(); + await new Promise(res => setTimeout(res, 0)); // Yield so fetchProfile() is called (promise) + + // should be one request made to fetch the profile (but the promise returned + // by it remains unresolved) + Assert.equal(numFetches, 1); + + // resolve the promise. + resolveProfile({ + body: { uid: ACCOUNT_UID, email: ACCOUNT_EMAIL, avatar: "myimg" }, + }); + + // both requests should complete with the same data. + let got1 = await request1; + Assert.equal(got1.avatar, "myimg"); + let got2 = await request1; + Assert.equal(got2.avatar, "myimg"); + + // and still only 1 request was made. + Assert.equal(numFetches, 1); +}); + +// Check that sharing a single fetch promise works correctly when the promise +// is rejected. +add_task(async function fetchAndCacheProfileOnce() { + // A promise that remains unresolved while we fire off 2 requests for + // a profile. + let rejectProfile; + let promiseProfile = new Promise((resolve, reject) => { + rejectProfile = reject; + }); + let numFetches = 0; + let client = mockClient(mockFxa()); + client.fetchProfile = function () { + numFetches += 1; + return promiseProfile; + }; + let fxa = mockFxa(); + let profile = CreateFxAccountsProfile(fxa, client); + + let request1 = profile._fetchAndCacheProfile(); + let request2 = profile._fetchAndCacheProfile(); + await new Promise(res => setTimeout(res, 0)); // Yield so fetchProfile() is called (promise) + + // should be one request made to fetch the profile (but the promise returned + // by it remains unresolved) + Assert.equal(numFetches, 1); + + // reject the promise. + rejectProfile("oh noes"); + + // both requests should reject. + try { + await request1; + throw new Error("should have rejected"); + } catch (ex) { + if (ex != "oh noes") { + throw ex; + } + } + try { + await request2; + throw new Error("should have rejected"); + } catch (ex) { + if (ex != "oh noes") { + throw ex; + } + } + + // but a new request should works. + client.fetchProfile = function () { + return Promise.resolve({ + body: { uid: ACCOUNT_UID, email: ACCOUNT_EMAIL, avatar: "myimg" }, + }); + }; + + let got = await profile._fetchAndCacheProfile(); + Assert.equal(got.avatar, "myimg"); +}); + +add_test(function fetchAndCacheProfile_alreadyCached() { + let cachedUrl = "cachedurl"; + let fxa = mockFxa(); + fxa._testProfileCache = { + profile: { uid: ACCOUNT_UID, avatar: cachedUrl }, + etag: "bogusETag", + }; + let client = mockClient(fxa); + client.fetchProfile = function (etag) { + Assert.equal(etag, "bogusETag"); + return Promise.resolve(null); + }; + + let profile = CreateFxAccountsProfile(fxa, client); + profile._cacheProfile = function (toCache) { + do_throw("This method should not be called."); + }; + + return profile._fetchAndCacheProfile().then(result => { + Assert.equal(result, null); + Assert.equal(fxa._testProfileCache.profile.avatar, cachedUrl); + run_next_test(); + }); +}); + +// Check that a new profile request within PROFILE_FRESHNESS_THRESHOLD of the +// last one doesn't kick off a new request to check the cached copy is fresh. +add_task(async function fetchAndCacheProfileAfterThreshold() { + /* + * This test was observed to cause a timeout for... any timer precision reduction. + * Even 1 us. Exact reason is still undiagnosed. + */ + Services.prefs.setBoolPref("privacy.reduceTimerPrecision", false); + + registerCleanupFunction(async () => { + Services.prefs.clearUserPref("privacy.reduceTimerPrecision"); + }); + + let numFetches = 0; + let client = mockClient(mockFxa()); + client.fetchProfile = async function () { + numFetches += 1; + return { + body: { uid: ACCOUNT_UID, email: ACCOUNT_EMAIL, avatar: "myimg" }, + }; + }; + let profile = CreateFxAccountsProfile(null, client); + profile.PROFILE_FRESHNESS_THRESHOLD = 1000; + + // first fetch should return null as we don't have data. + let p = await profile.getProfile(); + Assert.equal(p, null); + // ensure we kicked off a fetch. + Assert.notEqual(profile._currentFetchPromise, null); + // wait for that fetch to finish + await profile._currentFetchPromise; + Assert.equal(numFetches, 1); + Assert.equal(profile._currentFetchPromise, null); + + await profile.getProfile(); + Assert.equal(numFetches, 1); + Assert.equal(profile._currentFetchPromise, null); + + await new Promise(resolve => { + do_timeout(1000, resolve); + }); + + let origFetchAndCatch = profile._fetchAndCacheProfile; + let backgroundFetchDone = Promise.withResolvers(); + profile._fetchAndCacheProfile = async () => { + await origFetchAndCatch.call(profile); + backgroundFetchDone.resolve(); + }; + await profile.getProfile(); + await backgroundFetchDone.promise; + Assert.equal(numFetches, 2); +}); + +add_task(async function test_ensureProfile() { + let client = new FxAccountsProfileClient({ + serverURL: "http://127.0.0.1:1111/v1", + fxa: mockFxa(), + }); + let profile = CreateFxAccountsProfile(null, client); + + const testCases = [ + // profile retrieval when there is no cached profile info + { + threshold: 1000, + expectsCachedProfileReturned: false, + cachedProfile: null, + fetchedProfile: { + uid: ACCOUNT_UID, + email: ACCOUNT_EMAIL, + avatar: "myimg", + }, + }, + // profile retrieval when the cached profile is recent + { + // Note: The threshold for this test case is being set to an arbitrary value that will + // be greater than Date.now() so the retrieved cached profile will be deemed recent. + threshold: Date.now() + 5000, + expectsCachedProfileReturned: true, + cachedProfile: { + uid: `${ACCOUNT_UID}2`, + email: `${ACCOUNT_EMAIL}2`, + avatar: "myimg2", + }, + }, + // profile retrieval when the cached profile is old and a new profile is fetched + { + threshold: 1000, + expectsCachedProfileReturned: false, + cachedProfile: { + uid: `${ACCOUNT_UID}3`, + email: `${ACCOUNT_EMAIL}3`, + avatar: "myimg3", + }, + fetchAndCacheProfileResolves: true, + fetchedProfile: { + uid: `${ACCOUNT_UID}4`, + email: `${ACCOUNT_EMAIL}4`, + avatar: "myimg4", + }, + }, + // profile retrieval when the cached profile is old and a null profile is fetched + { + threshold: 1000, + expectsCachedProfileReturned: false, + cachedProfile: { + uid: `${ACCOUNT_UID}5`, + email: `${ACCOUNT_EMAIL}5`, + avatar: "myimg5", + }, + fetchAndCacheProfileResolves: true, + fetchedProfile: null, + }, + // profile retrieval when the cached profile is old and fetching a new profile errors + { + threshold: 1000, + expectsCachedProfileReturned: false, + cachedProfile: { + uid: `${ACCOUNT_UID}6`, + email: `${ACCOUNT_EMAIL}6`, + avatar: "myimg6", + }, + fetchAndCacheProfileResolves: false, + }, + // profile retrieval when we've cached a failure to fetch profile data + { + // Note: The threshold for this test case is being set to an arbitrary value that will + // be greater than Date.now() so the retrieved cached profile will be deemed recent. + threshold: Date.now() + 5000, + expectsCachedProfileReturned: false, + cachedProfile: null, + fetchedProfile: { + uid: `${ACCOUNT_UID}7`, + email: `${ACCOUNT_EMAIL}7`, + avatar: "myimg7", + }, + fetchAndCacheProfileResolves: true, + }, + // profile retrieval when the cached profile is old but staleOk is true. + { + threshold: 1000, + expectsCachedProfileReturned: true, + cachedProfile: { + uid: `${ACCOUNT_UID}8`, + email: `${ACCOUNT_EMAIL}8`, + avatar: "myimg8", + }, + fetchAndCacheProfileResolves: false, + options: { staleOk: true }, + }, + // staleOk but no cached profile + { + threshold: 1000, + expectsCachedProfileReturned: false, + cachedProfile: null, + fetchedProfile: { + uid: `${ACCOUNT_UID}9`, + email: `${ACCOUNT_EMAIL}9`, + avatar: "myimg9", + }, + options: { staleOk: true }, + }, + // fresh profile but forceFresh = true + { + // Note: The threshold for this test case is being set to an arbitrary value that will + // be greater than Date.now() so the retrieved cached profile will be deemed recent. + threshold: Date.now() + 5000, + expectsCachedProfileReturned: false, + fetchedProfile: { + uid: `${ACCOUNT_UID}10`, + email: `${ACCOUNT_EMAIL}10`, + avatar: "myimg10", + }, + options: { forceFresh: true }, + }, + ]; + + for (const tc of testCases) { + print(`test case: ${JSON.stringify(tc)}`); + let mockProfile = sinon.mock(profile); + mockProfile + .expects("_getProfileCache") + .once() + .returns( + tc.cachedProfile + ? { + profile: tc.cachedProfile, + } + : null + ); + profile.PROFILE_FRESHNESS_THRESHOLD = tc.threshold; + + let options = tc.options || {}; + if (tc.expectsCachedProfileReturned) { + mockProfile.expects("_fetchAndCacheProfile").never(); + let actualProfile = await profile.ensureProfile(options); + mockProfile.verify(); + Assert.equal(actualProfile, tc.cachedProfile); + } else if (tc.fetchAndCacheProfileResolves) { + mockProfile + .expects("_fetchAndCacheProfile") + .once() + .resolves(tc.fetchedProfile); + + let actualProfile = await profile.ensureProfile(options); + let expectedProfile = tc.fetchedProfile + ? tc.fetchedProfile + : tc.cachedProfile; + mockProfile.verify(); + Assert.equal(actualProfile, expectedProfile); + } else { + mockProfile.expects("_fetchAndCacheProfile").once().rejects(); + + let actualProfile = await profile.ensureProfile(options); + mockProfile.verify(); + Assert.equal(actualProfile, tc.cachedProfile); + } + } +}); + +// Check that a new profile request within PROFILE_FRESHNESS_THRESHOLD of the +// last one *does* kick off a new request if ON_PROFILE_CHANGE_NOTIFICATION +// is sent. +add_task(async function fetchAndCacheProfileBeforeThresholdOnNotification() { + let numFetches = 0; + let client = mockClient(mockFxa()); + client.fetchProfile = async function () { + numFetches += 1; + return { + body: { uid: ACCOUNT_UID, email: ACCOUNT_EMAIL, avatar: "myimg" }, + }; + }; + let profile = CreateFxAccountsProfile(null, client); + profile.PROFILE_FRESHNESS_THRESHOLD = 1000; + + // first fetch should return null as we don't have data. + let p = await profile.getProfile(); + Assert.equal(p, null); + // ensure we kicked off a fetch. + Assert.notEqual(profile._currentFetchPromise, null); + // wait for that fetch to finish + await profile._currentFetchPromise; + Assert.equal(numFetches, 1); + Assert.equal(profile._currentFetchPromise, null); + + Services.obs.notifyObservers(null, ON_PROFILE_CHANGE_NOTIFICATION); + + let origFetchAndCatch = profile._fetchAndCacheProfile; + let backgroundFetchDone = Promise.withResolvers(); + profile._fetchAndCacheProfile = async () => { + await origFetchAndCatch.call(profile); + backgroundFetchDone.resolve(); + }; + await profile.getProfile(); + await backgroundFetchDone.promise; + Assert.equal(numFetches, 2); +}); + +add_test(function tearDown_ok() { + let profile = CreateFxAccountsProfile(); + + Assert.ok(!!profile.client); + Assert.ok(!!profile.fxai); + + profile.tearDown(); + Assert.equal(null, profile.fxai); + Assert.equal(null, profile.client); + + run_next_test(); +}); + +add_task(async function getProfile_ok() { + let cachedUrl = "myurl"; + let didFetch = false; + + let fxa = mockFxa(); + fxa._testProfileCache = { profile: { uid: ACCOUNT_UID, avatar: cachedUrl } }; + let profile = CreateFxAccountsProfile(fxa); + + profile._fetchAndCacheProfile = function () { + didFetch = true; + return Promise.resolve(); + }; + + let result = await profile.getProfile(); + + Assert.equal(result.avatar, cachedUrl); + Assert.ok(didFetch); +}); + +add_task(async function getProfile_no_cache() { + let fetchedUrl = "newUrl"; + let fxa = mockFxa(); + let profile = CreateFxAccountsProfile(fxa); + + profile._fetchAndCacheProfileInternal = function () { + return Promise.resolve({ uid: ACCOUNT_UID, avatar: fetchedUrl }); + }; + + await profile.getProfile(); // returns null. + let result = await profile._currentFetchPromise; + Assert.equal(result.avatar, fetchedUrl); +}); + +add_test(function getProfile_has_cached_fetch_deleted() { + let cachedUrl = "myurl"; + + let fxa = mockFxa(); + let client = mockClient(fxa); + client.fetchProfile = function () { + return Promise.resolve({ + body: { uid: ACCOUNT_UID, email: ACCOUNT_EMAIL, avatar: null }, + }); + }; + + let profile = CreateFxAccountsProfile(fxa, client); + fxa._testProfileCache = { + profile: { uid: ACCOUNT_UID, email: ACCOUNT_EMAIL, avatar: cachedUrl }, + }; + + // instead of checking this in a mocked "save" function, just check after the + // observer + makeObserver(ON_PROFILE_CHANGE_NOTIFICATION, function (subject, topic, data) { + profile.getProfile().then(profileData => { + Assert.equal(null, profileData.avatar); + run_next_test(); + }); + }); + + return profile.getProfile().then(result => { + Assert.equal(result.avatar, "myurl"); + }); +}); + +add_test(function getProfile_fetchAndCacheProfile_throws() { + let fxa = mockFxa(); + fxa._testProfileCache = { + profile: { uid: ACCOUNT_UID, email: ACCOUNT_EMAIL, avatar: "myimg" }, + }; + let profile = CreateFxAccountsProfile(fxa); + + profile._fetchAndCacheProfile = () => Promise.reject(new Error()); + + return profile.getProfile().then(result => { + Assert.equal(result.avatar, "myimg"); + run_next_test(); + }); +}); + +add_test(function getProfile_email_changed() { + let fxa = mockFxa(); + let client = mockClient(fxa); + client.fetchProfile = function () { + return Promise.resolve({ + body: { uid: ACCOUNT_UID, email: "newemail@bar.com" }, + }); + }; + fxa._internal._handleEmailUpdated = email => { + Assert.equal(email, "newemail@bar.com"); + run_next_test(); + }; + + let profile = CreateFxAccountsProfile(fxa, client); + return profile._fetchAndCacheProfile(); +}); + +function makeObserver(aObserveTopic, aObserveFunc) { + let callback = function (aSubject, aTopic, aData) { + log.debug("observed " + aTopic + " " + aData); + if (aTopic == aObserveTopic) { + removeMe(); + aObserveFunc(aSubject, aTopic, aData); + } + }; + + function removeMe() { + log.debug("removing observer for " + aObserveTopic); + Services.obs.removeObserver(callback, aObserveTopic); + } + + Services.obs.addObserver(callback, aObserveTopic); + return removeMe; +} diff --git a/services/fxaccounts/tests/xpcshell/test_profile_client.js b/services/fxaccounts/tests/xpcshell/test_profile_client.js new file mode 100644 index 0000000000..22fcc293f8 --- /dev/null +++ b/services/fxaccounts/tests/xpcshell/test_profile_client.js @@ -0,0 +1,422 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { + ERRNO_NETWORK, + ERRNO_PARSE, + ERRNO_UNKNOWN_ERROR, + ERROR_CODE_METHOD_NOT_ALLOWED, + ERROR_MSG_METHOD_NOT_ALLOWED, + ERROR_NETWORK, + ERROR_PARSE, + ERROR_UNKNOWN, +} = ChromeUtils.importESModule( + "resource://gre/modules/FxAccountsCommon.sys.mjs" +); +const { FxAccountsProfileClient, FxAccountsProfileClientError } = + ChromeUtils.importESModule( + "resource://gre/modules/FxAccountsProfileClient.sys.mjs" + ); + +const STATUS_SUCCESS = 200; + +/** + * Mock request responder + * @param {String} response + * Mocked raw response from the server + * @returns {Function} + */ +let mockResponse = function (response) { + let Request = function (requestUri) { + // Store the request uri so tests can inspect it + Request._requestUri = requestUri; + Request.ifNoneMatchSet = false; + return { + setHeader(header, value) { + if (header == "If-None-Match" && value == "bogusETag") { + Request.ifNoneMatchSet = true; + } + }, + async dispatch(method, payload) { + this.response = response; + return this.response; + }, + }; + }; + + return Request; +}; + +// A simple mock FxA that hands out tokens without checking them and doesn't +// expect tokens to be revoked. We have specific token tests further down that +// has more checks here. +let mockFxaInternal = { + getOAuthToken(options) { + Assert.equal(options.scope, "profile"); + return "token"; + }, +}; + +const PROFILE_OPTIONS = { + serverURL: "http://127.0.0.1:1111/v1", + fxai: mockFxaInternal, +}; + +/** + * Mock request error responder + * @param {Error} error + * Error object + * @returns {Function} + */ +let mockResponseError = function (error) { + return function () { + return { + setHeader() {}, + async dispatch(method, payload) { + throw error; + }, + }; + }; +}; + +add_test(function successfulResponse() { + let client = new FxAccountsProfileClient(PROFILE_OPTIONS); + let response = { + success: true, + status: STATUS_SUCCESS, + headers: { etag: "bogusETag" }, + body: '{"email":"someone@restmail.net","uid":"0d5c1a89b8c54580b8e3e8adadae864a"}', + }; + + client._Request = new mockResponse(response); + client.fetchProfile().then(function (result) { + Assert.equal( + client._Request._requestUri, + "http://127.0.0.1:1111/v1/profile" + ); + Assert.equal(result.body.email, "someone@restmail.net"); + Assert.equal(result.body.uid, "0d5c1a89b8c54580b8e3e8adadae864a"); + Assert.equal(result.etag, "bogusETag"); + run_next_test(); + }); +}); + +add_test(function setsIfNoneMatchETagHeader() { + let client = new FxAccountsProfileClient(PROFILE_OPTIONS); + let response = { + success: true, + status: STATUS_SUCCESS, + headers: {}, + body: '{"email":"someone@restmail.net","uid":"0d5c1a89b8c54580b8e3e8adadae864a"}', + }; + + let req = new mockResponse(response); + client._Request = req; + client.fetchProfile("bogusETag").then(function (result) { + Assert.equal( + client._Request._requestUri, + "http://127.0.0.1:1111/v1/profile" + ); + Assert.equal(result.body.email, "someone@restmail.net"); + Assert.equal(result.body.uid, "0d5c1a89b8c54580b8e3e8adadae864a"); + Assert.ok(req.ifNoneMatchSet); + run_next_test(); + }); +}); + +add_test(function successful304Response() { + let client = new FxAccountsProfileClient(PROFILE_OPTIONS); + let response = { + success: true, + headers: { etag: "bogusETag" }, + status: 304, + }; + + client._Request = new mockResponse(response); + client.fetchProfile().then(function (result) { + Assert.equal(result, null); + run_next_test(); + }); +}); + +add_test(function parseErrorResponse() { + let client = new FxAccountsProfileClient(PROFILE_OPTIONS); + let response = { + success: true, + status: STATUS_SUCCESS, + body: "unexpected", + }; + + client._Request = new mockResponse(response); + client.fetchProfile().catch(function (e) { + Assert.equal(e.name, "FxAccountsProfileClientError"); + Assert.equal(e.code, STATUS_SUCCESS); + Assert.equal(e.errno, ERRNO_PARSE); + Assert.equal(e.error, ERROR_PARSE); + Assert.equal(e.message, "unexpected"); + run_next_test(); + }); +}); + +add_test(function serverErrorResponse() { + let client = new FxAccountsProfileClient(PROFILE_OPTIONS); + let response = { + status: 500, + body: '{ "code": 500, "errno": 100, "error": "Bad Request", "message": "Something went wrong", "reason": "Because the internet" }', + }; + + client._Request = new mockResponse(response); + client.fetchProfile().catch(function (e) { + Assert.equal(e.name, "FxAccountsProfileClientError"); + Assert.equal(e.code, 500); + Assert.equal(e.errno, 100); + Assert.equal(e.error, "Bad Request"); + Assert.equal(e.message, "Something went wrong"); + run_next_test(); + }); +}); + +// Test that we get a token, then if we get a 401 we revoke it, get a new one +// and retry. +add_test(function server401ResponseThenSuccess() { + // The last token we handed out. + let lastToken = -1; + // The number of times our removeCachedOAuthToken function was called. + let numTokensRemoved = 0; + + let mockFxaWithRemove = { + getOAuthToken(options) { + Assert.equal(options.scope, "profile"); + return "" + ++lastToken; // tokens are strings. + }, + removeCachedOAuthToken(options) { + // This test never has more than 1 token alive at once, so the token + // being revoked must always be the last token we handed out. + Assert.equal(parseInt(options.token), lastToken); + ++numTokensRemoved; + }, + }; + let profileOptions = { + serverURL: "http://127.0.0.1:1111/v1", + fxai: mockFxaWithRemove, + }; + let client = new FxAccountsProfileClient(profileOptions); + + // 2 responses - first one implying the token has expired, second works. + let responses = [ + { + status: 401, + body: '{ "code": 401, "errno": 100, "error": "Token expired", "message": "That token is too old", "reason": "Because security" }', + }, + { + success: true, + status: STATUS_SUCCESS, + headers: {}, + body: '{"avatar":"http://example.com/image.jpg","id":"0d5c1a89b8c54580b8e3e8adadae864a"}', + }, + ]; + + let numRequests = 0; + let numAuthHeaders = 0; + // Like mockResponse but we want access to headers etc. + client._Request = function (requestUri) { + return { + setHeader(name, value) { + if (name == "Authorization") { + numAuthHeaders++; + Assert.equal(value, "Bearer " + lastToken); + } + }, + async dispatch(method, payload) { + this.response = responses[numRequests]; + ++numRequests; + return this.response; + }, + }; + }; + + client.fetchProfile().then(result => { + Assert.equal(result.body.avatar, "http://example.com/image.jpg"); + Assert.equal(result.body.id, "0d5c1a89b8c54580b8e3e8adadae864a"); + // should have been exactly 2 requests and exactly 2 auth headers. + Assert.equal(numRequests, 2); + Assert.equal(numAuthHeaders, 2); + // and we should have seen one token revoked. + Assert.equal(numTokensRemoved, 1); + + run_next_test(); + }); +}); + +// Test that we get a token, then if we get a 401 we revoke it, get a new one +// and retry - but we *still* get a 401 on the retry, so the caller sees that. +add_test(function server401ResponsePersists() { + // The last token we handed out. + let lastToken = -1; + // The number of times our removeCachedOAuthToken function was called. + let numTokensRemoved = 0; + + let mockFxaWithRemove = { + getOAuthToken(options) { + Assert.equal(options.scope, "profile"); + return "" + ++lastToken; // tokens are strings. + }, + removeCachedOAuthToken(options) { + // This test never has more than 1 token alive at once, so the token + // being revoked must always be the last token we handed out. + Assert.equal(parseInt(options.token), lastToken); + ++numTokensRemoved; + }, + }; + let profileOptions = { + serverURL: "http://127.0.0.1:1111/v1", + fxai: mockFxaWithRemove, + }; + let client = new FxAccountsProfileClient(profileOptions); + + let response = { + status: 401, + body: '{ "code": 401, "errno": 100, "error": "It\'s not your token, it\'s you!", "message": "I don\'t like you", "reason": "Because security" }', + }; + + let numRequests = 0; + let numAuthHeaders = 0; + client._Request = function (requestUri) { + return { + setHeader(name, value) { + if (name == "Authorization") { + numAuthHeaders++; + Assert.equal(value, "Bearer " + lastToken); + } + }, + async dispatch(method, payload) { + this.response = response; + ++numRequests; + return this.response; + }, + }; + }; + + client.fetchProfile().catch(function (e) { + Assert.equal(e.name, "FxAccountsProfileClientError"); + Assert.equal(e.code, 401); + Assert.equal(e.errno, 100); + Assert.equal(e.error, "It's not your token, it's you!"); + // should have been exactly 2 requests and exactly 2 auth headers. + Assert.equal(numRequests, 2); + Assert.equal(numAuthHeaders, 2); + // and we should have seen both tokens revoked. + Assert.equal(numTokensRemoved, 2); + run_next_test(); + }); +}); + +add_test(function networkErrorResponse() { + let client = new FxAccountsProfileClient({ + serverURL: "http://domain.dummy", + fxai: mockFxaInternal, + }); + client.fetchProfile().catch(function (e) { + Assert.equal(e.name, "FxAccountsProfileClientError"); + Assert.equal(e.code, null); + Assert.equal(e.errno, ERRNO_NETWORK); + Assert.equal(e.error, ERROR_NETWORK); + run_next_test(); + }); +}); + +add_test(function unsupportedMethod() { + let client = new FxAccountsProfileClient(PROFILE_OPTIONS); + + return client._createRequest("/profile", "PUT").catch(function (e) { + Assert.equal(e.name, "FxAccountsProfileClientError"); + Assert.equal(e.code, ERROR_CODE_METHOD_NOT_ALLOWED); + Assert.equal(e.errno, ERRNO_NETWORK); + Assert.equal(e.error, ERROR_NETWORK); + Assert.equal(e.message, ERROR_MSG_METHOD_NOT_ALLOWED); + run_next_test(); + }); +}); + +add_test(function onCompleteRequestError() { + let client = new FxAccountsProfileClient(PROFILE_OPTIONS); + client._Request = new mockResponseError(new Error("onComplete error")); + client.fetchProfile().catch(function (e) { + Assert.equal(e.name, "FxAccountsProfileClientError"); + Assert.equal(e.code, null); + Assert.equal(e.errno, ERRNO_NETWORK); + Assert.equal(e.error, ERROR_NETWORK); + Assert.equal(e.message, "Error: onComplete error"); + run_next_test(); + }); +}); + +add_test(function constructorTests() { + validationHelper( + undefined, + "Error: Missing 'serverURL' configuration option" + ); + + validationHelper({}, "Error: Missing 'serverURL' configuration option"); + + validationHelper({ serverURL: "badUrl" }, "Error: Invalid 'serverURL'"); + + run_next_test(); +}); + +add_test(function errorTests() { + let error1 = new FxAccountsProfileClientError(); + Assert.equal(error1.name, "FxAccountsProfileClientError"); + Assert.equal(error1.code, null); + Assert.equal(error1.errno, ERRNO_UNKNOWN_ERROR); + Assert.equal(error1.error, ERROR_UNKNOWN); + Assert.equal(error1.message, null); + + let error2 = new FxAccountsProfileClientError({ + code: STATUS_SUCCESS, + errno: 1, + error: "Error", + message: "Something", + }); + let fields2 = error2._toStringFields(); + let statusCode = 1; + + Assert.equal(error2.name, "FxAccountsProfileClientError"); + Assert.equal(error2.code, STATUS_SUCCESS); + Assert.equal(error2.errno, statusCode); + Assert.equal(error2.error, "Error"); + Assert.equal(error2.message, "Something"); + + Assert.equal(fields2.name, "FxAccountsProfileClientError"); + Assert.equal(fields2.code, STATUS_SUCCESS); + Assert.equal(fields2.errno, statusCode); + Assert.equal(fields2.error, "Error"); + Assert.equal(fields2.message, "Something"); + + Assert.ok(error2.toString().includes("Something")); + run_next_test(); +}); + +/** + * Quick way to test the "FxAccountsProfileClient" constructor. + * + * @param {Object} options + * FxAccountsProfileClient constructor options + * @param {String} expected + * Expected error message + * @returns {*} + */ +function validationHelper(options, expected) { + // add fxai to options - that missing isn't what we are testing here. + if (options) { + options.fxai = mockFxaInternal; + } + try { + new FxAccountsProfileClient(options); + } catch (e) { + return Assert.equal(e.toString(), expected); + } + throw new Error("Validation helper error"); +} diff --git a/services/fxaccounts/tests/xpcshell/test_push_service.js b/services/fxaccounts/tests/xpcshell/test_push_service.js new file mode 100644 index 0000000000..0441888847 --- /dev/null +++ b/services/fxaccounts/tests/xpcshell/test_push_service.js @@ -0,0 +1,522 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests for the FxA push service. + +/* eslint-disable mozilla/use-chromeutils-generateqi */ + +const { + FXA_PUSH_SCOPE_ACCOUNT_UPDATE, + ONLOGOUT_NOTIFICATION, + ON_ACCOUNT_DESTROYED_NOTIFICATION, + ON_DEVICE_CONNECTED_NOTIFICATION, + ON_DEVICE_DISCONNECTED_NOTIFICATION, + ON_PASSWORD_CHANGED_NOTIFICATION, + ON_PASSWORD_RESET_NOTIFICATION, + ON_PROFILE_CHANGE_NOTIFICATION, + ON_PROFILE_UPDATED_NOTIFICATION, + ON_VERIFY_LOGIN_NOTIFICATION, + log, +} = ChromeUtils.importESModule( + "resource://gre/modules/FxAccountsCommon.sys.mjs" +); + +const { FxAccountsPushService } = ChromeUtils.importESModule( + "resource://gre/modules/FxAccountsPush.sys.mjs" +); + +XPCOMUtils.defineLazyServiceGetter( + this, + "PushService", + "@mozilla.org/push/Service;1", + "nsIPushService" +); + +initTestLogging("Trace"); +log.level = Log.Level.Trace; + +const MOCK_ENDPOINT = "http://mochi.test:8888"; + +// tests do not allow external connections, mock the PushService +let mockPushService = { + pushTopic: PushService.pushTopic, + subscriptionChangeTopic: PushService.subscriptionChangeTopic, + subscribe(scope, principal, cb) { + cb(Cr.NS_OK, { + endpoint: MOCK_ENDPOINT, + }); + }, + unsubscribe(scope, principal, cb) { + cb(Cr.NS_OK, true); + }, +}; + +let mockFxAccounts = { + checkVerificationStatus() {}, + updateDeviceRegistration() {}, +}; + +let mockLog = { + trace() {}, + debug() {}, + warn() {}, + error() {}, +}; + +add_task(async function initialize() { + let pushService = new FxAccountsPushService(); + equal(pushService.initialize(), false); +}); + +add_task(async function registerPushEndpointSuccess() { + let pushService = new FxAccountsPushService({ + pushService: mockPushService, + fxai: mockFxAccounts, + }); + + let subscription = await pushService.registerPushEndpoint(); + equal(subscription.endpoint, MOCK_ENDPOINT); +}); + +add_task(async function registerPushEndpointFailure() { + let failPushService = Object.assign(mockPushService, { + subscribe(scope, principal, cb) { + cb(Cr.NS_ERROR_ABORT); + }, + }); + + let pushService = new FxAccountsPushService({ + pushService: failPushService, + fxai: mockFxAccounts, + }); + + let subscription = await pushService.registerPushEndpoint(); + equal(subscription, null); +}); + +add_task(async function unsubscribeSuccess() { + let pushService = new FxAccountsPushService({ + pushService: mockPushService, + fxai: mockFxAccounts, + }); + + let result = await pushService.unsubscribe(); + equal(result, true); +}); + +add_task(async function unsubscribeFailure() { + let failPushService = Object.assign(mockPushService, { + unsubscribe(scope, principal, cb) { + cb(Cr.NS_ERROR_ABORT); + }, + }); + + let pushService = new FxAccountsPushService({ + pushService: failPushService, + fxai: mockFxAccounts, + }); + + let result = await pushService.unsubscribe(); + equal(result, null); +}); + +add_test(function observeLogout() { + let customLog = Object.assign(mockLog, { + trace(msg) { + if (msg === "FxAccountsPushService unsubscribe") { + // logout means we unsubscribe + run_next_test(); + } + }, + }); + + let pushService = new FxAccountsPushService({ + pushService: mockPushService, + log: customLog, + }); + + pushService.observe(null, ONLOGOUT_NOTIFICATION); +}); + +add_test(function observePushTopicVerify() { + let emptyMsg = { + QueryInterface() { + return this; + }, + }; + let customAccounts = Object.assign(mockFxAccounts, { + checkVerificationStatus() { + // checking verification status on push messages without data + run_next_test(); + }, + }); + + let pushService = new FxAccountsPushService({ + pushService: mockPushService, + fxai: customAccounts, + }); + + pushService.observe( + emptyMsg, + mockPushService.pushTopic, + FXA_PUSH_SCOPE_ACCOUNT_UPDATE + ); +}); + +add_test(function observePushTopicDeviceConnected() { + let msg = { + data: { + json: () => ({ + command: ON_DEVICE_CONNECTED_NOTIFICATION, + data: { + deviceName: "My phone", + }, + }), + }, + QueryInterface() { + return this; + }, + }; + let obs = (subject, topic, data) => { + Services.obs.removeObserver(obs, topic); + run_next_test(); + }; + Services.obs.addObserver(obs, ON_DEVICE_CONNECTED_NOTIFICATION); + + let pushService = new FxAccountsPushService({ + pushService: mockPushService, + fxai: mockFxAccounts, + }); + + pushService.observe( + msg, + mockPushService.pushTopic, + FXA_PUSH_SCOPE_ACCOUNT_UPDATE + ); +}); + +add_task(async function observePushTopicDeviceDisconnected_current_device() { + const deviceId = "bogusid"; + let msg = { + data: { + json: () => ({ + command: ON_DEVICE_DISCONNECTED_NOTIFICATION, + data: { + id: deviceId, + }, + }), + }, + QueryInterface() { + return this; + }, + }; + + let signoutCalled = false; + let { FxAccounts } = ChromeUtils.importESModule( + "resource://gre/modules/FxAccounts.sys.mjs" + ); + const fxAccountsMock = new FxAccounts({ + newAccountState() { + return { + async getUserAccountData() { + return { device: { id: deviceId } }; + }, + }; + }, + signOut() { + signoutCalled = true; + }, + })._internal; + + const deviceDisconnectedNotificationObserved = new Promise(resolve => { + Services.obs.addObserver(function obs(subject, topic, data) { + Services.obs.removeObserver(obs, topic); + equal(data, JSON.stringify({ isLocalDevice: true })); + resolve(); + }, ON_DEVICE_DISCONNECTED_NOTIFICATION); + }); + + let pushService = new FxAccountsPushService({ + pushService: mockPushService, + fxai: fxAccountsMock, + }); + + pushService.observe( + msg, + mockPushService.pushTopic, + FXA_PUSH_SCOPE_ACCOUNT_UPDATE + ); + + await deviceDisconnectedNotificationObserved; + ok(signoutCalled); +}); + +add_task(async function observePushTopicDeviceDisconnected_another_device() { + const deviceId = "bogusid"; + let msg = { + data: { + json: () => ({ + command: ON_DEVICE_DISCONNECTED_NOTIFICATION, + data: { + id: deviceId, + }, + }), + }, + QueryInterface() { + return this; + }, + }; + + let signoutCalled = false; + let { FxAccounts } = ChromeUtils.importESModule( + "resource://gre/modules/FxAccounts.sys.mjs" + ); + const fxAccountsMock = new FxAccounts({ + newAccountState() { + return { + async getUserAccountData() { + return { device: { id: "thelocaldevice" } }; + }, + }; + }, + signOut() { + signoutCalled = true; + }, + })._internal; + + const deviceDisconnectedNotificationObserved = new Promise(resolve => { + Services.obs.addObserver(function obs(subject, topic, data) { + Services.obs.removeObserver(obs, topic); + equal(data, JSON.stringify({ isLocalDevice: false })); + resolve(); + }, ON_DEVICE_DISCONNECTED_NOTIFICATION); + }); + + let pushService = new FxAccountsPushService({ + pushService: mockPushService, + fxai: fxAccountsMock, + }); + + pushService.observe( + msg, + mockPushService.pushTopic, + FXA_PUSH_SCOPE_ACCOUNT_UPDATE + ); + + await deviceDisconnectedNotificationObserved; + ok(!signoutCalled); +}); + +add_test(function observePushTopicAccountDestroyed() { + const uid = "bogusuid"; + let msg = { + data: { + json: () => ({ + command: ON_ACCOUNT_DESTROYED_NOTIFICATION, + data: { + uid, + }, + }), + }, + QueryInterface() { + return this; + }, + }; + let customAccounts = Object.assign(mockFxAccounts, { + _handleAccountDestroyed() { + // checking verification status on push messages without data + run_next_test(); + }, + }); + + let pushService = new FxAccountsPushService({ + pushService: mockPushService, + fxai: customAccounts, + }); + + pushService.observe( + msg, + mockPushService.pushTopic, + FXA_PUSH_SCOPE_ACCOUNT_UPDATE + ); +}); + +add_test(function observePushTopicVerifyLogin() { + let url = "http://localhost/newLogin"; + let title = "bogustitle"; + let body = "bogusbody"; + let msg = { + data: { + json: () => ({ + command: ON_VERIFY_LOGIN_NOTIFICATION, + data: { + body, + title, + url, + }, + }), + }, + QueryInterface() { + return this; + }, + }; + let obs = (subject, topic, data) => { + Services.obs.removeObserver(obs, topic); + Assert.equal(data, JSON.stringify(msg.data.json().data)); + run_next_test(); + }; + Services.obs.addObserver(obs, ON_VERIFY_LOGIN_NOTIFICATION); + + let pushService = new FxAccountsPushService({ + pushService: mockPushService, + fxai: mockFxAccounts, + }); + + pushService.observe( + msg, + mockPushService.pushTopic, + FXA_PUSH_SCOPE_ACCOUNT_UPDATE + ); +}); + +add_test(function observePushTopicProfileUpdated() { + let msg = { + data: { + json: () => ({ + command: ON_PROFILE_UPDATED_NOTIFICATION, + }), + }, + QueryInterface() { + return this; + }, + }; + let obs = (subject, topic, data) => { + Services.obs.removeObserver(obs, topic); + run_next_test(); + }; + Services.obs.addObserver(obs, ON_PROFILE_CHANGE_NOTIFICATION); + + let pushService = new FxAccountsPushService({ + pushService: mockPushService, + fxai: mockFxAccounts, + }); + + pushService.observe( + msg, + mockPushService.pushTopic, + FXA_PUSH_SCOPE_ACCOUNT_UPDATE + ); +}); + +add_test(function observePushTopicPasswordChanged() { + let msg = { + data: { + json: () => ({ + command: ON_PASSWORD_CHANGED_NOTIFICATION, + }), + }, + QueryInterface() { + return this; + }, + }; + + let pushService = new FxAccountsPushService({ + pushService: mockPushService, + }); + + pushService._onPasswordChanged = function () { + run_next_test(); + }; + + pushService.observe( + msg, + mockPushService.pushTopic, + FXA_PUSH_SCOPE_ACCOUNT_UPDATE + ); +}); + +add_test(function observePushTopicPasswordReset() { + let msg = { + data: { + json: () => ({ + command: ON_PASSWORD_RESET_NOTIFICATION, + }), + }, + QueryInterface() { + return this; + }, + }; + + let pushService = new FxAccountsPushService({ + pushService: mockPushService, + }); + + pushService._onPasswordChanged = function () { + run_next_test(); + }; + + pushService.observe( + msg, + mockPushService.pushTopic, + FXA_PUSH_SCOPE_ACCOUNT_UPDATE + ); +}); + +add_task(async function commandReceived() { + let msg = { + data: { + json: () => ({ + command: "fxaccounts:command_received", + data: { + url: "https://api.accounts.firefox.com/auth/v1/account/device/commands?index=42&limit=1", + }, + }), + }, + QueryInterface() { + return this; + }, + }; + + let fxAccountsMock = {}; + const promiseConsumeRemoteMessagesCalled = new Promise(res => { + fxAccountsMock.commands = { + pollDeviceCommands() { + res(); + }, + }; + }); + + let pushService = new FxAccountsPushService({ + pushService: mockPushService, + fxai: fxAccountsMock, + }); + + pushService.observe( + msg, + mockPushService.pushTopic, + FXA_PUSH_SCOPE_ACCOUNT_UPDATE + ); + await promiseConsumeRemoteMessagesCalled; +}); + +add_test(function observeSubscriptionChangeTopic() { + let customAccounts = Object.assign(mockFxAccounts, { + updateDeviceRegistration() { + // subscription change means updating the device registration + run_next_test(); + }, + }); + + let pushService = new FxAccountsPushService({ + pushService: mockPushService, + fxai: customAccounts, + }); + + pushService.observe( + null, + mockPushService.subscriptionChangeTopic, + FXA_PUSH_SCOPE_ACCOUNT_UPDATE + ); +}); diff --git a/services/fxaccounts/tests/xpcshell/test_storage_manager.js b/services/fxaccounts/tests/xpcshell/test_storage_manager.js new file mode 100644 index 0000000000..05c565d2f4 --- /dev/null +++ b/services/fxaccounts/tests/xpcshell/test_storage_manager.js @@ -0,0 +1,586 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests for the FxA storage manager. + +const { FxAccountsStorageManager } = ChromeUtils.importESModule( + "resource://gre/modules/FxAccountsStorage.sys.mjs" +); +const { DATA_FORMAT_VERSION, log } = ChromeUtils.importESModule( + "resource://gre/modules/FxAccountsCommon.sys.mjs" +); + +initTestLogging("Trace"); +log.level = Log.Level.Trace; + +const DEVICE_REGISTRATION_VERSION = 42; + +// A couple of mocks we can use. +function MockedPlainStorage(accountData) { + let data = null; + if (accountData) { + data = { + version: DATA_FORMAT_VERSION, + accountData, + }; + } + this.data = data; + this.numReads = 0; +} +MockedPlainStorage.prototype = { + async get() { + this.numReads++; + Assert.equal(this.numReads, 1, "should only ever be 1 read of acct data"); + return this.data; + }, + + async set(data) { + this.data = data; + }, +}; + +function MockedSecureStorage(accountData) { + let data = null; + if (accountData) { + data = { + version: DATA_FORMAT_VERSION, + accountData, + }; + } + this.data = data; + this.numReads = 0; +} + +MockedSecureStorage.prototype = { + fetchCount: 0, + locked: false, + /* eslint-disable object-shorthand */ + // This constructor must be declared without + // object shorthand or we get an exception of + // "TypeError: this.STORAGE_LOCKED is not a constructor" + STORAGE_LOCKED: function () {}, + /* eslint-enable object-shorthand */ + async get(uid, email) { + this.fetchCount++; + if (this.locked) { + throw new this.STORAGE_LOCKED(); + } + this.numReads++; + Assert.equal( + this.numReads, + 1, + "should only ever be 1 read of unlocked data" + ); + return this.data; + }, + + async set(uid, contents) { + this.data = contents; + }, +}; + +function add_storage_task(testFunction) { + add_task(async function () { + print("Starting test with secure storage manager"); + await testFunction(new FxAccountsStorageManager()); + }); + add_task(async function () { + print("Starting test with simple storage manager"); + await testFunction(new FxAccountsStorageManager({ useSecure: false })); + }); +} + +// initialized without account data and there's nothing to read. Not logged in. +add_storage_task(async function checkInitializedEmpty(sm) { + if (sm.secureStorage) { + sm.secureStorage = new MockedSecureStorage(null); + } + await sm.initialize(); + Assert.strictEqual(await sm.getAccountData(), null); + await Assert.rejects( + sm.updateAccountData({ scopedKeys: { ...MOCK_ACCOUNT_KEYS.scopedKeys } }), + /No user is logged in/ + ); +}); + +// Initialized with account data (ie, simulating a new user being logged in). +// Should reflect the initial data and be written to storage. +add_storage_task(async function checkNewUser(sm) { + let initialAccountData = { + uid: "uid", + email: "someone@somewhere.com", + scopedKeys: { ...MOCK_ACCOUNT_KEYS.scopedKeys }, + device: { + id: "device id", + }, + }; + sm.plainStorage = new MockedPlainStorage(); + if (sm.secureStorage) { + sm.secureStorage = new MockedSecureStorage(null); + } + await sm.initialize(initialAccountData); + let accountData = await sm.getAccountData(); + Assert.equal(accountData.uid, initialAccountData.uid); + Assert.equal(accountData.email, initialAccountData.email); + Assert.deepEqual(accountData.scopedKeys, initialAccountData.scopedKeys); + Assert.deepEqual(accountData.device, initialAccountData.device); + + // and it should have been written to storage. + Assert.equal(sm.plainStorage.data.accountData.uid, initialAccountData.uid); + Assert.equal( + sm.plainStorage.data.accountData.email, + initialAccountData.email + ); + Assert.deepEqual( + sm.plainStorage.data.accountData.device, + initialAccountData.device + ); + // check secure + if (sm.secureStorage) { + Assert.deepEqual( + sm.secureStorage.data.accountData.scopedKeys, + initialAccountData.scopedKeys + ); + } else { + Assert.deepEqual( + sm.plainStorage.data.accountData.scopedKeys, + initialAccountData.scopedKeys + ); + } +}); + +// Initialized without account data but storage has it available. +add_storage_task(async function checkEverythingRead(sm) { + sm.plainStorage = new MockedPlainStorage({ + uid: "uid", + email: "someone@somewhere.com", + device: { + id: "wibble", + registrationVersion: null, + }, + }); + if (sm.secureStorage) { + sm.secureStorage = new MockedSecureStorage(null); + } + await sm.initialize(); + let accountData = await sm.getAccountData(); + Assert.ok(accountData, "read account data"); + Assert.equal(accountData.uid, "uid"); + Assert.equal(accountData.email, "someone@somewhere.com"); + Assert.deepEqual(accountData.device, { + id: "wibble", + registrationVersion: null, + }); + // Update the data - we should be able to fetch it back and it should appear + // in our storage. + await sm.updateAccountData({ + verified: true, + scopedKeys: { ...MOCK_ACCOUNT_KEYS.scopedKeys }, + device: { + id: "wibble", + registrationVersion: DEVICE_REGISTRATION_VERSION, + }, + }); + accountData = await sm.getAccountData(); + Assert.deepEqual(accountData.scopedKeys, MOCK_ACCOUNT_KEYS.scopedKeys); + Assert.deepEqual(accountData.device, { + id: "wibble", + registrationVersion: DEVICE_REGISTRATION_VERSION, + }); + // Check the new value was written to storage. + await sm._promiseStorageComplete; // storage is written in the background. + Assert.equal(sm.plainStorage.data.accountData.verified, true); + Assert.deepEqual(sm.plainStorage.data.accountData.device, { + id: "wibble", + registrationVersion: DEVICE_REGISTRATION_VERSION, + }); + // derive keys are secure + if (sm.secureStorage) { + Assert.deepEqual( + sm.secureStorage.data.accountData.scopedKeys, + MOCK_ACCOUNT_KEYS.scopedKeys + ); + } else { + Assert.deepEqual( + sm.plainStorage.data.accountData.scopedKeys, + MOCK_ACCOUNT_KEYS.scopedKeys + ); + } +}); + +add_storage_task(async function checkInvalidUpdates(sm) { + sm.plainStorage = new MockedPlainStorage({ + uid: "uid", + email: "someone@somewhere.com", + }); + if (sm.secureStorage) { + sm.secureStorage = new MockedSecureStorage(null); + } + await sm.initialize(); + + await Assert.rejects( + sm.updateAccountData({ uid: "another" }), + /Can't change uid/ + ); +}); + +add_storage_task(async function checkNullUpdatesRemovedUnlocked(sm) { + if (sm.secureStorage) { + sm.plainStorage = new MockedPlainStorage({ + uid: "uid", + email: "someone@somewhere.com", + }); + sm.secureStorage = new MockedSecureStorage({ + scopedKeys: { ...MOCK_ACCOUNT_KEYS.scopedKeys }, + unwrapBKey: "unwrapBKey", + }); + } else { + sm.plainStorage = new MockedPlainStorage({ + uid: "uid", + email: "someone@somewhere.com", + scopedKeys: { ...MOCK_ACCOUNT_KEYS.scopedKeys }, + unwrapBKey: "unwrapBKey", + }); + } + await sm.initialize(); + + await sm.updateAccountData({ unwrapBKey: null }); + let accountData = await sm.getAccountData(); + Assert.ok(!accountData.unwrapBKey); + Assert.deepEqual(accountData.scopedKeys, MOCK_ACCOUNT_KEYS.scopedKeys); +}); + +add_storage_task(async function checkNullRemovesUnlistedFields(sm) { + // kA and kB are not listed in FXA_PWDMGR_*_FIELDS, but we still want to + // be able to delete them (migration case). + if (sm.secureStorage) { + sm.plainStorage = new MockedPlainStorage({ + uid: "uid", + email: "someone@somewhere.com", + }); + sm.secureStorage = new MockedSecureStorage({ kA: "kA", kb: "kB" }); + } else { + sm.plainStorage = new MockedPlainStorage({ + uid: "uid", + email: "someone@somewhere.com", + kA: "kA", + kb: "kB", + }); + } + await sm.initialize(); + + await sm.updateAccountData({ kA: null, kB: null }); + let accountData = await sm.getAccountData(); + Assert.ok(!accountData.kA); + Assert.ok(!accountData.kB); +}); + +add_storage_task(async function checkDelete(sm) { + if (sm.secureStorage) { + sm.plainStorage = new MockedPlainStorage({ + uid: "uid", + email: "someone@somewhere.com", + }); + sm.secureStorage = new MockedSecureStorage({ + scopedKeys: { ...MOCK_ACCOUNT_KEYS.scopedKeys }, + }); + } else { + sm.plainStorage = new MockedPlainStorage({ + uid: "uid", + email: "someone@somewhere.com", + scopedKeys: { ...MOCK_ACCOUNT_KEYS.scopedKeys }, + }); + } + await sm.initialize(); + + await sm.deleteAccountData(); + // Storage should have been reset to null. + Assert.equal(sm.plainStorage.data, null); + if (sm.secureStorage) { + Assert.equal(sm.secureStorage.data, null); + } + // And everything should reflect no user. + Assert.equal(await sm.getAccountData(), null); +}); + +// Some tests only for the secure storage manager. +add_task(async function checkNullUpdatesRemovedLocked() { + let sm = new FxAccountsStorageManager(); + sm.plainStorage = new MockedPlainStorage({ + uid: "uid", + email: "someone@somewhere.com", + }); + sm.secureStorage = new MockedSecureStorage({ + scopedKeys: { ...MOCK_ACCOUNT_KEYS.scopedKeys }, + unwrapBKey: "unwrapBKey is another secure value", + }); + sm.secureStorage.locked = true; + await sm.initialize(); + + await sm.updateAccountData({ scopedKeys: null }); + let accountData = await sm.getAccountData(); + // No scopedKeys because it was removed. + Assert.ok(!accountData.scopedKeys); + // No unwrapBKey because we are locked + Assert.ok(!accountData.unwrapBKey); + + // now unlock - should still be no scopedKeys but unwrapBKey should appear. + sm.secureStorage.locked = false; + accountData = await sm.getAccountData(); + Assert.ok(!accountData.scopedKeys); + Assert.equal(accountData.unwrapBKey, "unwrapBKey is another secure value"); + // And secure storage should have been written with our previously-cached + // data. + Assert.strictEqual(sm.secureStorage.data.accountData.scopedKeys, undefined); + Assert.strictEqual( + sm.secureStorage.data.accountData.unwrapBKey, + "unwrapBKey is another secure value" + ); +}); + +add_task(async function checkEverythingReadSecure() { + let sm = new FxAccountsStorageManager(); + sm.plainStorage = new MockedPlainStorage({ + uid: "uid", + email: "someone@somewhere.com", + }); + sm.secureStorage = new MockedSecureStorage({ + scopedKeys: { ...MOCK_ACCOUNT_KEYS.scopedKeys }, + }); + await sm.initialize(); + + let accountData = await sm.getAccountData(); + Assert.ok(accountData, "read account data"); + Assert.equal(accountData.uid, "uid"); + Assert.equal(accountData.email, "someone@somewhere.com"); + Assert.deepEqual(accountData.scopedKeys, MOCK_ACCOUNT_KEYS.scopedKeys); +}); + +add_task(async function checkExplicitGet() { + let sm = new FxAccountsStorageManager(); + sm.plainStorage = new MockedPlainStorage({ + uid: "uid", + email: "someone@somewhere.com", + }); + sm.secureStorage = new MockedSecureStorage({ + scopedKeys: { ...MOCK_ACCOUNT_KEYS.scopedKeys }, + }); + await sm.initialize(); + + let accountData = await sm.getAccountData(["uid", "scopedKeys"]); + Assert.ok(accountData, "read account data"); + Assert.equal(accountData.uid, "uid"); + Assert.deepEqual(accountData.scopedKeys, MOCK_ACCOUNT_KEYS.scopedKeys); + // We didn't ask for email so shouldn't have got it. + Assert.strictEqual(accountData.email, undefined); +}); + +add_task(async function checkExplicitGetNoSecureRead() { + let sm = new FxAccountsStorageManager(); + sm.plainStorage = new MockedPlainStorage({ + uid: "uid", + email: "someone@somewhere.com", + }); + sm.secureStorage = new MockedSecureStorage({ + scopedKeys: { ...MOCK_ACCOUNT_KEYS.scopedKeys }, + }); + await sm.initialize(); + + Assert.equal(sm.secureStorage.fetchCount, 0); + // request 2 fields in secure storage - it should have caused a single fetch. + let accountData = await sm.getAccountData(["email", "uid"]); + Assert.ok(accountData, "read account data"); + Assert.equal(accountData.uid, "uid"); + Assert.equal(accountData.email, "someone@somewhere.com"); + Assert.strictEqual(accountData.scopedKeys, undefined); + Assert.equal(sm.secureStorage.fetchCount, 1); +}); + +add_task(async function checkLockedUpdates() { + let sm = new FxAccountsStorageManager(); + sm.plainStorage = new MockedPlainStorage({ + uid: "uid", + email: "someone@somewhere.com", + }); + sm.secureStorage = new MockedSecureStorage({ + scopedKeys: { ...MOCK_ACCOUNT_KEYS.scopedKeys }, + unwrapBKey: "unwrapBKey", + }); + sm.secureStorage.locked = true; + await sm.initialize(); + + let accountData = await sm.getAccountData(); + // requesting scopedKeys will fail as storage is locked. + Assert.ok(!accountData.scopedKeys); + // While locked we can still update it and see the updated value. + sm.updateAccountData({ unwrapBKey: "new-unwrapBKey" }); + accountData = await sm.getAccountData(); + Assert.equal(accountData.unwrapBKey, "new-unwrapBKey"); + // unlock. + sm.secureStorage.locked = false; + accountData = await sm.getAccountData(); + // should reflect the value we updated and the one we didn't. + Assert.equal(accountData.unwrapBKey, "new-unwrapBKey"); + Assert.deepEqual(accountData.scopedKeys, MOCK_ACCOUNT_KEYS.scopedKeys); + // And storage should also reflect it. + Assert.deepEqual( + sm.secureStorage.data.accountData.scopedKeys, + MOCK_ACCOUNT_KEYS.scopedKeys + ); + Assert.strictEqual( + sm.secureStorage.data.accountData.unwrapBKey, + "new-unwrapBKey" + ); +}); + +// Some tests for the "storage queue" functionality. + +// A helper for our queued tests. It creates a StorageManager and then queues +// an unresolved promise. The tests then do additional setup and checks, then +// resolves or rejects the blocked promise. +async function setupStorageManagerForQueueTest() { + let sm = new FxAccountsStorageManager(); + sm.plainStorage = new MockedPlainStorage({ + uid: "uid", + email: "someone@somewhere.com", + }); + sm.secureStorage = new MockedSecureStorage({ + scopedKeys: { ...MOCK_ACCOUNT_KEYS.scopedKeys }, + }); + sm.secureStorage.locked = true; + await sm.initialize(); + + let resolveBlocked, rejectBlocked; + let blockedPromise = new Promise((resolve, reject) => { + resolveBlocked = resolve; + rejectBlocked = reject; + }); + + sm._queueStorageOperation(() => blockedPromise); + return { sm, blockedPromise, resolveBlocked, rejectBlocked }; +} + +// First the general functionality. +add_task(async function checkQueueSemantics() { + let { sm, resolveBlocked } = await setupStorageManagerForQueueTest(); + + // We've one unresolved promise in the queue - add another promise. + let resolveSubsequent; + let subsequentPromise = new Promise(resolve => { + resolveSubsequent = resolve; + }); + let subsequentCalled = false; + + sm._queueStorageOperation(() => { + subsequentCalled = true; + resolveSubsequent(); + return subsequentPromise; + }); + + // Our "subsequent" function should not have been called yet. + Assert.ok(!subsequentCalled); + + // Release our blocked promise. + resolveBlocked(); + + // Our subsequent promise should end up resolved. + await subsequentPromise; + Assert.ok(subsequentCalled); + await sm.finalize(); +}); + +// Check that a queued promise being rejected works correctly. +add_task(async function checkQueueSemanticsOnError() { + let { sm, blockedPromise, rejectBlocked } = + await setupStorageManagerForQueueTest(); + + let resolveSubsequent; + let subsequentPromise = new Promise(resolve => { + resolveSubsequent = resolve; + }); + let subsequentCalled = false; + + sm._queueStorageOperation(() => { + subsequentCalled = true; + resolveSubsequent(); + return subsequentPromise; + }); + + // Our "subsequent" function should not have been called yet. + Assert.ok(!subsequentCalled); + + // Reject our blocked promise - the subsequent operations should still work + // correctly. + rejectBlocked("oh no"); + + // Our subsequent promise should end up resolved. + await subsequentPromise; + Assert.ok(subsequentCalled); + + // But the first promise should reflect the rejection. + try { + await blockedPromise; + Assert.ok(false, "expected this promise to reject"); + } catch (ex) { + Assert.equal(ex, "oh no"); + } + await sm.finalize(); +}); + +// And some tests for the specific operations that are queued. +add_task(async function checkQueuedReadAndUpdate() { + let { sm, resolveBlocked } = await setupStorageManagerForQueueTest(); + // Mock the underlying operations + // _doReadAndUpdateSecure is queued by _maybeReadAndUpdateSecure + let _doReadCalled = false; + sm._doReadAndUpdateSecure = () => { + _doReadCalled = true; + return Promise.resolve(); + }; + + let resultPromise = sm._maybeReadAndUpdateSecure(); + Assert.ok(!_doReadCalled); + + resolveBlocked(); + await resultPromise; + Assert.ok(_doReadCalled); + await sm.finalize(); +}); + +add_task(async function checkQueuedWrite() { + let { sm, resolveBlocked } = await setupStorageManagerForQueueTest(); + // Mock the underlying operations + let __writeCalled = false; + sm.__write = () => { + __writeCalled = true; + return Promise.resolve(); + }; + + let writePromise = sm._write(); + Assert.ok(!__writeCalled); + + resolveBlocked(); + await writePromise; + Assert.ok(__writeCalled); + await sm.finalize(); +}); + +add_task(async function checkQueuedDelete() { + let { sm, resolveBlocked } = await setupStorageManagerForQueueTest(); + // Mock the underlying operations + let _deleteCalled = false; + sm._deleteAccountData = () => { + _deleteCalled = true; + return Promise.resolve(); + }; + + let resultPromise = sm.deleteAccountData(); + Assert.ok(!_deleteCalled); + + resolveBlocked(); + await resultPromise; + Assert.ok(_deleteCalled); + await sm.finalize(); +}); diff --git a/services/fxaccounts/tests/xpcshell/test_telemetry.js b/services/fxaccounts/tests/xpcshell/test_telemetry.js new file mode 100644 index 0000000000..3b9d318404 --- /dev/null +++ b/services/fxaccounts/tests/xpcshell/test_telemetry.js @@ -0,0 +1,52 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { getFxAccountsSingleton } = ChromeUtils.importESModule( + "resource://gre/modules/FxAccounts.sys.mjs" +); +const fxAccounts = getFxAccountsSingleton(); + +_("Misc tests for FxAccounts.telemetry"); + +const MOCK_HASHED_UID = "00112233445566778899aabbccddeeff"; +const MOCK_DEVICE_ID = "ffeeddccbbaa99887766554433221100"; + +add_task(function test_sanitized_uid() { + Services.prefs.deleteBranch( + "identity.fxaccounts.account.telemetry.sanitized_uid" + ); + + // Returns `null` by default. + Assert.equal(fxAccounts.telemetry.getSanitizedUID(), null); + + // Returns provided value if set. + fxAccounts.telemetry._setHashedUID(MOCK_HASHED_UID); + Assert.equal(fxAccounts.telemetry.getSanitizedUID(), MOCK_HASHED_UID); + + // Reverts to unset for falsey values. + fxAccounts.telemetry._setHashedUID(""); + Assert.equal(fxAccounts.telemetry.getSanitizedUID(), null); +}); + +add_task(function test_sanitize_device_id() { + Services.prefs.deleteBranch( + "identity.fxaccounts.account.telemetry.sanitized_uid" + ); + + // Returns `null` by default. + Assert.equal(fxAccounts.telemetry.sanitizeDeviceId(MOCK_DEVICE_ID), null); + + // Hashes with the sanitized UID if set. + // (test value here is SHA256(MOCK_DEVICE_ID + MOCK_HASHED_UID)) + fxAccounts.telemetry._setHashedUID(MOCK_HASHED_UID); + Assert.equal( + fxAccounts.telemetry.sanitizeDeviceId(MOCK_DEVICE_ID), + "dd7c845006df9baa1c6d756926519c8ce12f91230e11b6057bf8ec65f9b55c1a" + ); + + // Reverts to unset for falsey values. + fxAccounts.telemetry._setHashedUID(""); + Assert.equal(fxAccounts.telemetry.sanitizeDeviceId(MOCK_DEVICE_ID), null); +}); diff --git a/services/fxaccounts/tests/xpcshell/test_web_channel.js b/services/fxaccounts/tests/xpcshell/test_web_channel.js new file mode 100644 index 0000000000..48f043d0b9 --- /dev/null +++ b/services/fxaccounts/tests/xpcshell/test_web_channel.js @@ -0,0 +1,1380 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { ON_PROFILE_CHANGE_NOTIFICATION, WEBCHANNEL_ID, log } = + ChromeUtils.importESModule("resource://gre/modules/FxAccountsCommon.sys.mjs"); +const { CryptoUtils } = ChromeUtils.importESModule( + "resource://services-crypto/utils.sys.mjs" +); +const { FxAccountsWebChannel, FxAccountsWebChannelHelpers } = + ChromeUtils.importESModule( + "resource://gre/modules/FxAccountsWebChannel.sys.mjs" + ); + +const URL_STRING = "https://example.com"; + +const mockSendingContext = { + browsingContext: { top: { embedderElement: {} } }, + principal: {}, + eventTarget: {}, +}; + +add_test(function () { + validationHelper(undefined, "Error: Missing configuration options"); + + validationHelper( + { + channel_id: WEBCHANNEL_ID, + }, + "Error: Missing 'content_uri' option" + ); + + validationHelper( + { + content_uri: "bad uri", + channel_id: WEBCHANNEL_ID, + }, + /NS_ERROR_MALFORMED_URI/ + ); + + validationHelper( + { + content_uri: URL_STRING, + }, + "Error: Missing 'channel_id' option" + ); + + run_next_test(); +}); + +add_task(async function test_rejection_reporting() { + Services.prefs.setBoolPref( + "browser.tabs.remote.separatePrivilegedMozillaWebContentProcess", + false + ); + + let mockMessage = { + command: "fxaccounts:login", + messageId: "1234", + data: { email: "testuser@testuser.com" }, + }; + + let channel = new FxAccountsWebChannel({ + channel_id: WEBCHANNEL_ID, + content_uri: URL_STRING, + helpers: { + login(accountData) { + equal( + accountData.email, + "testuser@testuser.com", + "Should forward incoming message data to the helper" + ); + return Promise.reject(new Error("oops")); + }, + }, + }); + + let promiseSend = new Promise(resolve => { + channel._channel.send = (message, context) => { + resolve({ message, context }); + }; + }); + + channel._channelCallback(WEBCHANNEL_ID, mockMessage, mockSendingContext); + + let { message, context } = await promiseSend; + + equal(context, mockSendingContext, "Should forward the original context"); + equal( + message.command, + "fxaccounts:login", + "Should include the incoming command" + ); + equal(message.messageId, "1234", "Should include the message ID"); + equal( + message.data.error.message, + "Error: oops", + "Should convert the error message to a string" + ); + notStrictEqual( + message.data.error.stack, + null, + "Should include the stack for JS error rejections" + ); +}); + +add_test(function test_exception_reporting() { + let mockMessage = { + command: "fxaccounts:sync_preferences", + messageId: "5678", + data: { entryPoint: "fxa:verification_complete" }, + }; + + let channel = new FxAccountsWebChannel({ + channel_id: WEBCHANNEL_ID, + content_uri: URL_STRING, + helpers: { + openSyncPreferences(browser, entryPoint) { + equal( + entryPoint, + "fxa:verification_complete", + "Should forward incoming message data to the helper" + ); + throw new TypeError("splines not reticulated"); + }, + }, + }); + + channel._channel.send = (message, context) => { + equal(context, mockSendingContext, "Should forward the original context"); + equal( + message.command, + "fxaccounts:sync_preferences", + "Should include the incoming command" + ); + equal(message.messageId, "5678", "Should include the message ID"); + equal( + message.data.error.message, + "TypeError: splines not reticulated", + "Should convert the exception to a string" + ); + notStrictEqual( + message.data.error.stack, + null, + "Should include the stack for JS exceptions" + ); + + run_next_test(); + }; + + channel._channelCallback(WEBCHANNEL_ID, mockMessage, mockSendingContext); +}); + +add_test(function test_error_message_remove_profile_path() { + const errors = { + windows: { + err: new Error( + "Win error 183 during operation rename on file C:\\Users\\Some Computer\\AppData\\Roaming\\" + + "Mozilla\\Firefox\\Profiles\\dbzjmzxa.default\\signedInUser.json (Cannot create a file)" + ), + expected: + "Error: Win error 183 during operation rename on file C:[REDACTED]signedInUser.json (Cannot create a file)", + }, + unix: { + err: new Error( + "Unix error 28 during operation write on file /Users/someuser/Library/Application Support/" + + "Firefox/Profiles/dbzjmzxa.default-release-7/signedInUser.json (No space left on device)" + ), + expected: + "Error: Unix error 28 during operation write on file [REDACTED]signedInUser.json (No space left on device)", + }, + netpath: { + err: new Error( + "Win error 32 during operation rename on file \\\\SVC.LOC\\HOMEDIRS$\\USERNAME\\Mozilla\\" + + "Firefox\\Profiles\\dbzjmzxa.default-release-7\\signedInUser.json (No space left on device)" + ), + expected: + "Error: Win error 32 during operation rename on file [REDACTED]signedInUser.json (No space left on device)", + }, + mount: { + err: new Error( + "Win error 649 during operation rename on file C:\\SnapVolumes\\MountPoints\\" + + "{9e399ec5-0000-0000-0000-100000000000}\\SVROOT\\Users\\username\\AppData\\Roaming\\Mozilla\\Firefox\\" + + "Profiles\\dbzjmzxa.default-release\\signedInUser.json (The create operation failed)" + ), + expected: + "Error: Win error 649 during operation rename on file C:[REDACTED]signedInUser.json " + + "(The create operation failed)", + }, + }; + const mockMessage = { + command: "fxaccounts:sync_preferences", + messageId: "1234", + }; + const channel = new FxAccountsWebChannel({ + channel_id: WEBCHANNEL_ID, + content_uri: URL_STRING, + }); + + let testNum = 0; + const toTest = Object.keys(errors).length; + for (const key in errors) { + let error = errors[key]; + channel._channel.send = (message, context) => { + equal( + message.data.error.message, + error.expected, + "Should remove the profile path from the error message" + ); + testNum++; + if (testNum === toTest) { + run_next_test(); + } + }; + channel._sendError(error.err, mockMessage, mockSendingContext); + } +}); + +add_test(function test_profile_image_change_message() { + var mockMessage = { + command: "profile:change", + data: { uid: "foo" }, + }; + + makeObserver(ON_PROFILE_CHANGE_NOTIFICATION, function (subject, topic, data) { + Assert.equal(data, "foo"); + run_next_test(); + }); + + var channel = new FxAccountsWebChannel({ + channel_id: WEBCHANNEL_ID, + content_uri: URL_STRING, + }); + + channel._channelCallback(WEBCHANNEL_ID, mockMessage, mockSendingContext); +}); + +add_test(function test_login_message() { + let mockMessage = { + command: "fxaccounts:login", + data: { email: "testuser@testuser.com" }, + }; + + let channel = new FxAccountsWebChannel({ + channel_id: WEBCHANNEL_ID, + content_uri: URL_STRING, + helpers: { + login(accountData) { + Assert.equal(accountData.email, "testuser@testuser.com"); + run_next_test(); + return Promise.resolve(); + }, + }, + }); + + channel._channelCallback(WEBCHANNEL_ID, mockMessage, mockSendingContext); +}); + +add_test(function test_oauth_login() { + const mockData = { + code: "oauth code", + state: "state parameter", + declinedSyncEngines: ["tabs", "creditcards"], + offeredSyncEngines: ["tabs", "creditcards", "history"], + }; + const mockMessage = { + command: "fxaccounts:oauth_login", + data: mockData, + }; + const channel = new FxAccountsWebChannel({ + channel_id: WEBCHANNEL_ID, + content_uri: URL_STRING, + helpers: { + oauthLogin(data) { + Assert.deepEqual(data, mockData); + run_next_test(); + return Promise.resolve(); + }, + }, + }); + channel._channelCallback(WEBCHANNEL_ID, mockMessage, mockSendingContext); +}); + +add_test(function test_logout_message() { + let mockMessage = { + command: "fxaccounts:logout", + data: { uid: "foo" }, + }; + + let channel = new FxAccountsWebChannel({ + channel_id: WEBCHANNEL_ID, + content_uri: URL_STRING, + helpers: { + logout(uid) { + Assert.equal(uid, "foo"); + run_next_test(); + return Promise.resolve(); + }, + }, + }); + + channel._channelCallback(WEBCHANNEL_ID, mockMessage, mockSendingContext); +}); + +add_test(function test_delete_message() { + let mockMessage = { + command: "fxaccounts:delete", + data: { uid: "foo" }, + }; + + let channel = new FxAccountsWebChannel({ + channel_id: WEBCHANNEL_ID, + content_uri: URL_STRING, + helpers: { + logout(uid) { + Assert.equal(uid, "foo"); + run_next_test(); + return Promise.resolve(); + }, + }, + }); + + channel._channelCallback(WEBCHANNEL_ID, mockMessage, mockSendingContext); +}); + +add_test(function test_can_link_account_message() { + let mockMessage = { + command: "fxaccounts:can_link_account", + data: { email: "testuser@testuser.com" }, + }; + + let channel = new FxAccountsWebChannel({ + channel_id: WEBCHANNEL_ID, + content_uri: URL_STRING, + helpers: { + shouldAllowRelink(email) { + Assert.equal(email, "testuser@testuser.com"); + run_next_test(); + }, + }, + }); + + channel._channelCallback(WEBCHANNEL_ID, mockMessage, mockSendingContext); +}); + +add_test(function test_sync_preferences_message() { + let mockMessage = { + command: "fxaccounts:sync_preferences", + data: { entryPoint: "fxa:verification_complete" }, + }; + + let channel = new FxAccountsWebChannel({ + channel_id: WEBCHANNEL_ID, + content_uri: URL_STRING, + helpers: { + openSyncPreferences(browser, entryPoint) { + Assert.equal(entryPoint, "fxa:verification_complete"); + Assert.equal( + browser, + mockSendingContext.browsingContext.top.embedderElement + ); + run_next_test(); + }, + }, + }); + + channel._channelCallback(WEBCHANNEL_ID, mockMessage, mockSendingContext); +}); + +add_test(function test_fxa_status_message() { + let mockMessage = { + command: "fxaccounts:fxa_status", + messageId: 123, + data: { + service: "sync", + context: "fx_desktop_v3", + }, + }; + + let channel = new FxAccountsWebChannel({ + channel_id: WEBCHANNEL_ID, + content_uri: URL_STRING, + helpers: { + async getFxaStatus(service, sendingContext, isPairing, context) { + Assert.equal(service, "sync"); + Assert.equal(sendingContext, mockSendingContext); + Assert.ok(!isPairing); + Assert.equal(context, "fx_desktop_v3"); + return { + signedInUser: { + email: "testuser@testuser.com", + sessionToken: "session-token", + uid: "uid", + verified: true, + }, + capabilities: { + engines: ["creditcards", "addresses"], + }, + }; + }, + }, + }); + + channel._channel = { + send(response, sendingContext) { + Assert.equal(response.command, "fxaccounts:fxa_status"); + Assert.equal(response.messageId, 123); + + let signedInUser = response.data.signedInUser; + Assert.ok(!!signedInUser); + Assert.equal(signedInUser.email, "testuser@testuser.com"); + Assert.equal(signedInUser.sessionToken, "session-token"); + Assert.equal(signedInUser.uid, "uid"); + Assert.equal(signedInUser.verified, true); + + deepEqual(response.data.capabilities.engines, [ + "creditcards", + "addresses", + ]); + + run_next_test(); + }, + }; + + channel._channelCallback(WEBCHANNEL_ID, mockMessage, mockSendingContext); +}); + +add_test(function test_unrecognized_message() { + let mockMessage = { + command: "fxaccounts:unrecognized", + data: {}, + }; + + let channel = new FxAccountsWebChannel({ + channel_id: WEBCHANNEL_ID, + content_uri: URL_STRING, + }); + + // no error is expected. + channel._channelCallback(WEBCHANNEL_ID, mockMessage, mockSendingContext); + run_next_test(); +}); + +add_test(function test_helpers_should_allow_relink_same_email() { + let helpers = new FxAccountsWebChannelHelpers(); + + helpers.setPreviousAccountNameHashPref("testuser@testuser.com"); + Assert.ok(helpers.shouldAllowRelink("testuser@testuser.com")); + + run_next_test(); +}); + +add_test(function test_helpers_should_allow_relink_different_email() { + let helpers = new FxAccountsWebChannelHelpers(); + + helpers.setPreviousAccountNameHashPref("testuser@testuser.com"); + + helpers._promptForRelink = acctName => { + return acctName === "allowed_to_relink@testuser.com"; + }; + + Assert.ok(helpers.shouldAllowRelink("allowed_to_relink@testuser.com")); + Assert.ok(!helpers.shouldAllowRelink("not_allowed_to_relink@testuser.com")); + + run_next_test(); +}); + +add_task(async function test_helpers_login_without_customize_sync() { + let helpers = new FxAccountsWebChannelHelpers({ + fxAccounts: { + _internal: { + setSignedInUser(accountData) { + return new Promise(resolve => { + // ensure fxAccounts is informed of the new user being signed in. + Assert.equal(accountData.email, "testuser@testuser.com"); + + // verifiedCanLinkAccount should be stripped in the data. + Assert.equal(false, "verifiedCanLinkAccount" in accountData); + + resolve(); + }); + }, + }, + telemetry: { + recordConnection: sinon.spy(), + }, + }, + weaveXPCOM: { + whenLoaded() {}, + Weave: { + Service: { + configure() {}, + }, + }, + }, + }); + + // ensure the previous account pref is overwritten. + helpers.setPreviousAccountNameHashPref("lastuser@testuser.com"); + + await helpers.login({ + email: "testuser@testuser.com", + verifiedCanLinkAccount: true, + customizeSync: false, + }); + Assert.ok( + helpers._fxAccounts.telemetry.recordConnection.calledWith([], "webchannel") + ); +}); + +add_task(async function test_helpers_login_set_previous_account_name_hash() { + let helpers = new FxAccountsWebChannelHelpers({ + fxAccounts: { + _internal: { + setSignedInUser(accountData) { + return new Promise(resolve => { + // previously signed in user preference is updated. + Assert.equal( + helpers.getPreviousAccountNameHashPref(), + CryptoUtils.sha256Base64("newuser@testuser.com") + ); + resolve(); + }); + }, + }, + telemetry: { + recordConnection() {}, + }, + }, + weaveXPCOM: { + whenLoaded() {}, + Weave: { + Service: { + configure() {}, + }, + }, + }, + }); + + // ensure the previous account pref is overwritten. + helpers.setPreviousAccountNameHashPref("lastuser@testuser.com"); + + await helpers.login({ + email: "newuser@testuser.com", + verifiedCanLinkAccount: true, + customizeSync: false, + verified: true, + }); +}); + +add_task( + async function test_helpers_login_dont_set_previous_account_name_hash_for_unverified_emails() { + let helpers = new FxAccountsWebChannelHelpers({ + fxAccounts: { + _internal: { + setSignedInUser(accountData) { + return new Promise(resolve => { + // previously signed in user preference should not be updated. + Assert.equal( + helpers.getPreviousAccountNameHashPref(), + CryptoUtils.sha256Base64("lastuser@testuser.com") + ); + resolve(); + }); + }, + }, + telemetry: { + recordConnection() {}, + }, + }, + weaveXPCOM: { + whenLoaded() {}, + Weave: { + Service: { + configure() {}, + }, + }, + }, + }); + + // ensure the previous account pref is overwritten. + helpers.setPreviousAccountNameHashPref("lastuser@testuser.com"); + + await helpers.login({ + email: "newuser@testuser.com", + verifiedCanLinkAccount: true, + customizeSync: false, + }); + } +); + +add_task(async function test_helpers_login_with_customize_sync() { + let helpers = new FxAccountsWebChannelHelpers({ + fxAccounts: { + _internal: { + setSignedInUser(accountData) { + return new Promise(resolve => { + // ensure fxAccounts is informed of the new user being signed in. + Assert.equal(accountData.email, "testuser@testuser.com"); + + // customizeSync should be stripped in the data. + Assert.equal(false, "customizeSync" in accountData); + + resolve(); + }); + }, + }, + telemetry: { + recordConnection: sinon.spy(), + }, + }, + weaveXPCOM: { + whenLoaded() {}, + Weave: { + Service: { + configure() {}, + }, + }, + }, + }); + + await helpers.login({ + email: "testuser@testuser.com", + verifiedCanLinkAccount: true, + customizeSync: true, + }); + Assert.ok( + helpers._fxAccounts.telemetry.recordConnection.calledWith([], "webchannel") + ); +}); + +add_task( + async function test_helpers_login_with_customize_sync_and_declined_engines() { + let configured = false; + let helpers = new FxAccountsWebChannelHelpers({ + fxAccounts: { + _internal: { + setSignedInUser(accountData) { + return new Promise(resolve => { + // ensure fxAccounts is informed of the new user being signed in. + Assert.equal(accountData.email, "testuser@testuser.com"); + + // customizeSync should be stripped in the data. + Assert.equal(false, "customizeSync" in accountData); + Assert.equal(false, "services" in accountData); + resolve(); + }); + }, + }, + telemetry: { + recordConnection: sinon.spy(), + }, + }, + weaveXPCOM: { + whenLoaded() {}, + Weave: { + Service: { + configure() { + configured = true; + }, + }, + }, + }, + }); + + Assert.equal( + Services.prefs.getBoolPref("services.sync.engine.addons"), + true + ); + Assert.equal( + Services.prefs.getBoolPref("services.sync.engine.bookmarks"), + true + ); + Assert.equal( + Services.prefs.getBoolPref("services.sync.engine.history"), + true + ); + Assert.equal( + Services.prefs.getBoolPref("services.sync.engine.passwords"), + true + ); + Assert.equal( + Services.prefs.getBoolPref("services.sync.engine.prefs"), + true + ); + Assert.equal(Services.prefs.getBoolPref("services.sync.engine.tabs"), true); + await helpers.login({ + email: "testuser@testuser.com", + verifiedCanLinkAccount: true, + customizeSync: true, + services: { + sync: { + offeredEngines: [ + "addons", + "bookmarks", + "history", + "passwords", + "prefs", + ], + declinedEngines: ["addons", "prefs"], + }, + }, + }); + Assert.equal( + Services.prefs.getBoolPref("services.sync.engine.addons"), + false + ); + Assert.equal( + Services.prefs.getBoolPref("services.sync.engine.bookmarks"), + true + ); + Assert.equal( + Services.prefs.getBoolPref("services.sync.engine.history"), + true + ); + Assert.equal( + Services.prefs.getBoolPref("services.sync.engine.passwords"), + true + ); + Assert.equal( + Services.prefs.getBoolPref("services.sync.engine.prefs"), + false + ); + Assert.equal(Services.prefs.getBoolPref("services.sync.engine.tabs"), true); + Assert.ok(configured, "sync was configured"); + Assert.ok( + helpers._fxAccounts.telemetry.recordConnection.calledWith( + ["sync"], + "webchannel" + ) + ); + } +); + +add_task(async function test_helpers_login_with_offered_sync_engines() { + let helpers; + let configured = false; + const setSignedInUserCalled = new Promise(resolve => { + helpers = new FxAccountsWebChannelHelpers({ + fxAccounts: { + _internal: { + async setSignedInUser(accountData) { + resolve(accountData); + }, + }, + telemetry: { + recordConnection() {}, + }, + }, + weaveXPCOM: { + whenLoaded() {}, + Weave: { + Service: { + configure() { + configured = true; + }, + }, + }, + }, + }); + }); + + Services.prefs.setBoolPref("services.sync.engine.creditcards", false); + Services.prefs.setBoolPref("services.sync.engine.addresses", false); + + await helpers.login({ + email: "testuser@testuser.com", + verifiedCanLinkAccount: true, + customizeSync: true, + services: { + sync: { + declinedEngines: ["addresses"], + offeredEngines: ["creditcards", "addresses"], + }, + }, + }); + + const accountData = await setSignedInUserCalled; + + // ensure fxAccounts is informed of the new user being signed in. + equal(accountData.email, "testuser@testuser.com"); + + // services should be stripped in the data. + ok(!("services" in accountData)); + // credit cards was offered but not declined. + equal(Services.prefs.getBoolPref("services.sync.engine.creditcards"), true); + // addresses was offered and explicitely declined. + equal(Services.prefs.getBoolPref("services.sync.engine.addresses"), false); + ok(configured); +}); + +add_task(async function test_helpers_login_nothing_offered() { + let helpers; + let configured = false; + const setSignedInUserCalled = new Promise(resolve => { + helpers = new FxAccountsWebChannelHelpers({ + fxAccounts: { + _internal: { + async setSignedInUser(accountData) { + resolve(accountData); + }, + }, + telemetry: { + recordConnection() {}, + }, + }, + weaveXPCOM: { + whenLoaded() {}, + Weave: { + Service: { + configure() { + configured = true; + }, + }, + }, + }, + }); + }); + + // doesn't really matter if it's *all* engines... + const allEngines = [ + "addons", + "addresses", + "bookmarks", + "creditcards", + "history", + "passwords", + "prefs", + ]; + for (let name of allEngines) { + Services.prefs.clearUserPref("services.sync.engine." + name); + } + + await helpers.login({ + email: "testuser@testuser.com", + verifiedCanLinkAccount: true, + services: { + sync: {}, + }, + }); + + const accountData = await setSignedInUserCalled; + // ensure fxAccounts is informed of the new user being signed in. + equal(accountData.email, "testuser@testuser.com"); + + for (let name of allEngines) { + Assert.ok(!Services.prefs.prefHasUserValue("services.sync.engine." + name)); + } + Assert.ok(configured); +}); + +add_test(function test_helpers_open_sync_preferences() { + let helpers = new FxAccountsWebChannelHelpers({ + fxAccounts: {}, + }); + + let mockBrowser = { + loadURI(uri) { + Assert.equal( + uri.spec, + "about:preferences?entrypoint=fxa%3Averification_complete#sync" + ); + run_next_test(); + }, + }; + + helpers.openSyncPreferences(mockBrowser, "fxa:verification_complete"); +}); + +add_task(async function test_helpers_getFxAStatus_extra_engines() { + let helpers = new FxAccountsWebChannelHelpers({ + fxAccounts: { + _internal: { + getUserAccountData() { + return Promise.resolve({ + email: "testuser@testuser.com", + sessionToken: "sessionToken", + uid: "uid", + verified: true, + }); + }, + }, + }, + privateBrowsingUtils: { + isBrowserPrivate: () => true, + }, + }); + + Services.prefs.setBoolPref( + "services.sync.engine.creditcards.available", + true + ); + // Not defining "services.sync.engine.addresses.available" on purpose. + + let fxaStatus = await helpers.getFxaStatus("sync", mockSendingContext); + ok(!!fxaStatus); + ok(!!fxaStatus.signedInUser); + deepEqual(fxaStatus.capabilities.engines, ["creditcards"]); +}); + +add_task(async function test_helpers_getFxaStatus_allowed_signedInUser() { + let wasCalled = { + getUserAccountData: false, + shouldAllowFxaStatus: false, + }; + + let helpers = new FxAccountsWebChannelHelpers({ + fxAccounts: { + _internal: { + getUserAccountData() { + wasCalled.getUserAccountData = true; + return Promise.resolve({ + email: "testuser@testuser.com", + sessionToken: "sessionToken", + uid: "uid", + verified: true, + }); + }, + }, + }, + }); + + helpers.shouldAllowFxaStatus = (service, sendingContext) => { + wasCalled.shouldAllowFxaStatus = true; + Assert.equal(service, "sync"); + Assert.equal(sendingContext, mockSendingContext); + + return true; + }; + + return helpers.getFxaStatus("sync", mockSendingContext).then(fxaStatus => { + Assert.ok(!!fxaStatus); + Assert.ok(wasCalled.getUserAccountData); + Assert.ok(wasCalled.shouldAllowFxaStatus); + + Assert.ok(!!fxaStatus.signedInUser); + let { signedInUser } = fxaStatus; + + Assert.equal(signedInUser.email, "testuser@testuser.com"); + Assert.equal(signedInUser.sessionToken, "sessionToken"); + Assert.equal(signedInUser.uid, "uid"); + Assert.ok(signedInUser.verified); + + // These properties are filtered and should not + // be returned to the requester. + Assert.equal(false, "scopedKeys" in signedInUser); + }); +}); + +add_task(async function test_helpers_getFxaStatus_allowed_no_signedInUser() { + let wasCalled = { + getUserAccountData: false, + shouldAllowFxaStatus: false, + }; + + let helpers = new FxAccountsWebChannelHelpers({ + fxAccounts: { + _internal: { + getUserAccountData() { + wasCalled.getUserAccountData = true; + return Promise.resolve(null); + }, + }, + }, + }); + + helpers.shouldAllowFxaStatus = (service, sendingContext) => { + wasCalled.shouldAllowFxaStatus = true; + Assert.equal(service, "sync"); + Assert.equal(sendingContext, mockSendingContext); + + return true; + }; + + return helpers.getFxaStatus("sync", mockSendingContext).then(fxaStatus => { + Assert.ok(!!fxaStatus); + Assert.ok(wasCalled.getUserAccountData); + Assert.ok(wasCalled.shouldAllowFxaStatus); + + Assert.equal(null, fxaStatus.signedInUser); + }); +}); + +add_task(async function test_helpers_getFxaStatus_not_allowed() { + let wasCalled = { + getUserAccountData: false, + shouldAllowFxaStatus: false, + }; + + let helpers = new FxAccountsWebChannelHelpers({ + fxAccounts: { + _internal: { + getUserAccountData() { + wasCalled.getUserAccountData = true; + return Promise.resolve(null); + }, + }, + }, + }); + + helpers.shouldAllowFxaStatus = ( + service, + sendingContext, + isPairing, + context + ) => { + wasCalled.shouldAllowFxaStatus = true; + Assert.equal(service, "sync"); + Assert.equal(sendingContext, mockSendingContext); + Assert.ok(!isPairing); + Assert.equal(context, "fx_desktop_v3"); + + return false; + }; + + return helpers + .getFxaStatus("sync", mockSendingContext, false, "fx_desktop_v3") + .then(fxaStatus => { + Assert.ok(!!fxaStatus); + Assert.ok(!wasCalled.getUserAccountData); + Assert.ok(wasCalled.shouldAllowFxaStatus); + + Assert.equal(null, fxaStatus.signedInUser); + }); +}); + +add_task( + async function test_helpers_shouldAllowFxaStatus_sync_service_not_private_browsing() { + let wasCalled = { + isPrivateBrowsingMode: false, + }; + let helpers = new FxAccountsWebChannelHelpers({}); + + helpers.isPrivateBrowsingMode = sendingContext => { + wasCalled.isPrivateBrowsingMode = true; + Assert.equal(sendingContext, mockSendingContext); + return false; + }; + + let shouldAllowFxaStatus = helpers.shouldAllowFxaStatus( + "sync", + mockSendingContext, + false + ); + Assert.ok(shouldAllowFxaStatus); + Assert.ok(wasCalled.isPrivateBrowsingMode); + } +); + +add_task( + async function test_helpers_shouldAllowFxaStatus_desktop_context_not_private_browsing() { + let wasCalled = { + isPrivateBrowsingMode: false, + }; + let helpers = new FxAccountsWebChannelHelpers({}); + + helpers.isPrivateBrowsingMode = sendingContext => { + wasCalled.isPrivateBrowsingMode = true; + Assert.equal(sendingContext, mockSendingContext); + return false; + }; + + let shouldAllowFxaStatus = helpers.shouldAllowFxaStatus( + "", + mockSendingContext, + false, + "fx_desktop_v3" + ); + Assert.ok(shouldAllowFxaStatus); + Assert.ok(wasCalled.isPrivateBrowsingMode); + } +); + +add_task( + async function test_helpers_shouldAllowFxaStatus_oauth_service_not_private_browsing() { + let wasCalled = { + isPrivateBrowsingMode: false, + }; + let helpers = new FxAccountsWebChannelHelpers({}); + + helpers.isPrivateBrowsingMode = sendingContext => { + wasCalled.isPrivateBrowsingMode = true; + Assert.equal(sendingContext, mockSendingContext); + return false; + }; + + let shouldAllowFxaStatus = helpers.shouldAllowFxaStatus( + "dcdb5ae7add825d2", + mockSendingContext, + false + ); + Assert.ok(shouldAllowFxaStatus); + Assert.ok(wasCalled.isPrivateBrowsingMode); + } +); + +add_task( + async function test_helpers_shouldAllowFxaStatus_no_service_not_private_browsing() { + let wasCalled = { + isPrivateBrowsingMode: false, + }; + let helpers = new FxAccountsWebChannelHelpers({}); + + helpers.isPrivateBrowsingMode = sendingContext => { + wasCalled.isPrivateBrowsingMode = true; + Assert.equal(sendingContext, mockSendingContext); + return false; + }; + + let shouldAllowFxaStatus = helpers.shouldAllowFxaStatus( + "", + mockSendingContext, + false + ); + Assert.ok(shouldAllowFxaStatus); + Assert.ok(wasCalled.isPrivateBrowsingMode); + } +); + +add_task( + async function test_helpers_shouldAllowFxaStatus_sync_service_private_browsing() { + let wasCalled = { + isPrivateBrowsingMode: false, + }; + let helpers = new FxAccountsWebChannelHelpers({}); + + helpers.isPrivateBrowsingMode = sendingContext => { + wasCalled.isPrivateBrowsingMode = true; + Assert.equal(sendingContext, mockSendingContext); + return true; + }; + + let shouldAllowFxaStatus = helpers.shouldAllowFxaStatus( + "sync", + mockSendingContext, + false + ); + Assert.ok(shouldAllowFxaStatus); + Assert.ok(wasCalled.isPrivateBrowsingMode); + } +); + +add_task( + async function test_helpers_shouldAllowFxaStatus_desktop_context_private_browsing() { + let wasCalled = { + isPrivateBrowsingMode: false, + }; + let helpers = new FxAccountsWebChannelHelpers({}); + + helpers.isPrivateBrowsingMode = sendingContext => { + wasCalled.isPrivateBrowsingMode = true; + Assert.equal(sendingContext, mockSendingContext); + return true; + }; + + let shouldAllowFxaStatus = helpers.shouldAllowFxaStatus( + "", + mockSendingContext, + false, + "fx_desktop_v3" + ); + Assert.ok(shouldAllowFxaStatus); + Assert.ok(wasCalled.isPrivateBrowsingMode); + } +); + +add_task( + async function test_helpers_shouldAllowFxaStatus_oauth_service_private_browsing() { + let wasCalled = { + isPrivateBrowsingMode: false, + }; + let helpers = new FxAccountsWebChannelHelpers({}); + + helpers.isPrivateBrowsingMode = sendingContext => { + wasCalled.isPrivateBrowsingMode = true; + Assert.equal(sendingContext, mockSendingContext); + return true; + }; + + let shouldAllowFxaStatus = helpers.shouldAllowFxaStatus( + "dcdb5ae7add825d2", + mockSendingContext, + false + ); + Assert.ok(!shouldAllowFxaStatus); + Assert.ok(wasCalled.isPrivateBrowsingMode); + } +); + +add_task( + async function test_helpers_shouldAllowFxaStatus_oauth_service_pairing_private_browsing() { + let wasCalled = { + isPrivateBrowsingMode: false, + }; + let helpers = new FxAccountsWebChannelHelpers({}); + + helpers.isPrivateBrowsingMode = sendingContext => { + wasCalled.isPrivateBrowsingMode = true; + Assert.equal(sendingContext, mockSendingContext); + return true; + }; + + let shouldAllowFxaStatus = helpers.shouldAllowFxaStatus( + "dcdb5ae7add825d2", + mockSendingContext, + true + ); + Assert.ok(shouldAllowFxaStatus); + Assert.ok(wasCalled.isPrivateBrowsingMode); + } +); + +add_task( + async function test_helpers_shouldAllowFxaStatus_no_service_private_browsing() { + let wasCalled = { + isPrivateBrowsingMode: false, + }; + let helpers = new FxAccountsWebChannelHelpers({}); + + helpers.isPrivateBrowsingMode = sendingContext => { + wasCalled.isPrivateBrowsingMode = true; + Assert.equal(sendingContext, mockSendingContext); + return true; + }; + + let shouldAllowFxaStatus = helpers.shouldAllowFxaStatus( + "", + mockSendingContext, + false + ); + Assert.ok(!shouldAllowFxaStatus); + Assert.ok(wasCalled.isPrivateBrowsingMode); + } +); + +add_task(async function test_helpers_isPrivateBrowsingMode_private_browsing() { + let wasCalled = { + isBrowserPrivate: false, + }; + let helpers = new FxAccountsWebChannelHelpers({ + privateBrowsingUtils: { + isBrowserPrivate(browser) { + wasCalled.isBrowserPrivate = true; + Assert.equal( + browser, + mockSendingContext.browsingContext.top.embedderElement + ); + return true; + }, + }, + }); + + let isPrivateBrowsingMode = helpers.isPrivateBrowsingMode(mockSendingContext); + Assert.ok(isPrivateBrowsingMode); + Assert.ok(wasCalled.isBrowserPrivate); +}); + +add_task(async function test_helpers_isPrivateBrowsingMode_private_browsing() { + let wasCalled = { + isBrowserPrivate: false, + }; + let helpers = new FxAccountsWebChannelHelpers({ + privateBrowsingUtils: { + isBrowserPrivate(browser) { + wasCalled.isBrowserPrivate = true; + Assert.equal( + browser, + mockSendingContext.browsingContext.top.embedderElement + ); + return false; + }, + }, + }); + + let isPrivateBrowsingMode = helpers.isPrivateBrowsingMode(mockSendingContext); + Assert.ok(!isPrivateBrowsingMode); + Assert.ok(wasCalled.isBrowserPrivate); +}); + +add_task(async function test_helpers_change_password() { + let wasCalled = { + updateUserAccountData: false, + updateDeviceRegistration: false, + }; + let helpers = new FxAccountsWebChannelHelpers({ + fxAccounts: { + _internal: { + updateUserAccountData(credentials) { + return new Promise(resolve => { + Assert.ok(credentials.hasOwnProperty("email")); + Assert.ok(credentials.hasOwnProperty("uid")); + Assert.ok(credentials.hasOwnProperty("unwrapBKey")); + Assert.ok(credentials.hasOwnProperty("device")); + Assert.equal(null, credentials.device); + Assert.equal(null, credentials.encryptedSendTabKeys); + // "foo" isn't a field known by storage, so should be dropped. + Assert.ok(!credentials.hasOwnProperty("foo")); + wasCalled.updateUserAccountData = true; + + resolve(); + }); + }, + + updateDeviceRegistration() { + Assert.equal(arguments.length, 0); + wasCalled.updateDeviceRegistration = true; + return Promise.resolve(); + }, + }, + }, + }); + await helpers.changePassword({ + email: "email", + uid: "uid", + unwrapBKey: "unwrapBKey", + foo: "foo", + }); + Assert.ok(wasCalled.updateUserAccountData); + Assert.ok(wasCalled.updateDeviceRegistration); +}); + +add_task(async function test_helpers_change_password_with_error() { + let wasCalled = { + updateUserAccountData: false, + updateDeviceRegistration: false, + }; + let helpers = new FxAccountsWebChannelHelpers({ + fxAccounts: { + _internal: { + updateUserAccountData() { + wasCalled.updateUserAccountData = true; + return Promise.reject(); + }, + + updateDeviceRegistration() { + wasCalled.updateDeviceRegistration = true; + return Promise.resolve(); + }, + }, + }, + }); + try { + await helpers.changePassword({}); + Assert.equal(false, "changePassword should have rejected"); + } catch (_) { + Assert.ok(wasCalled.updateUserAccountData); + Assert.ok(!wasCalled.updateDeviceRegistration); + } +}); + +function makeObserver(aObserveTopic, aObserveFunc) { + let callback = function (aSubject, aTopic, aData) { + log.debug("observed " + aTopic + " " + aData); + if (aTopic == aObserveTopic) { + removeMe(); + aObserveFunc(aSubject, aTopic, aData); + } + }; + + function removeMe() { + log.debug("removing observer for " + aObserveTopic); + Services.obs.removeObserver(callback, aObserveTopic); + } + + Services.obs.addObserver(callback, aObserveTopic); + return removeMe; +} + +function validationHelper(params, expected) { + try { + new FxAccountsWebChannel(params); + } catch (e) { + if (typeof expected === "string") { + return Assert.equal(e.toString(), expected); + } + return Assert.ok(e.toString().match(expected)); + } + throw new Error("Validation helper error"); +} diff --git a/services/fxaccounts/tests/xpcshell/xpcshell.toml b/services/fxaccounts/tests/xpcshell/xpcshell.toml new file mode 100644 index 0000000000..7fc9c60006 --- /dev/null +++ b/services/fxaccounts/tests/xpcshell/xpcshell.toml @@ -0,0 +1,49 @@ +[DEFAULT] +head = "head.js ../../../common/tests/unit/head_helpers.js ../../../common/tests/unit/head_http.js" +firefox-appdir = "browser" +skip-if = [ + "os == 'android'", + "appname == 'thunderbird'", +] +support-files = [ + "!/services/common/tests/unit/head_helpers.js", + "!/services/common/tests/unit/head_http.js", +] + +["test_accounts.js"] + +["test_accounts_config.js"] + +["test_accounts_device_registration.js"] + +["test_client.js"] + +["test_commands.js"] + +["test_credentials.js"] + +["test_device.js"] + +["test_keys.js"] + +["test_loginmgr_storage.js"] + +["test_oauth_flow.js"] + +["test_oauth_token_storage.js"] + +["test_oauth_tokens.js"] + +["test_pairing.js"] + +["test_profile.js"] + +["test_profile_client.js"] + +["test_push_service.js"] + +["test_storage_manager.js"] + +["test_telemetry.js"] + +["test_web_channel.js"] |