From 08d2c540ecdf010338539e807be80a9d1c065a52 Mon Sep 17 00:00:00 2001 From: Ryan Kelly Date: Thu, 1 Oct 2020 10:06:24 +0000 Subject: [PATCH] Bug 1661407 - refactor FxA key handling to use "scoped keys". r=markh Differential Revision: https://phabricator.services.mozilla.com/D90361 --- services/common/utils.js | 6 + services/fxaccounts/FxAccounts.jsm | 100 +-- services/fxaccounts/FxAccountsCommands.js | 39 +- services/fxaccounts/FxAccountsCommon.js | 15 +- services/fxaccounts/FxAccountsDevice.jsm | 11 +- services/fxaccounts/FxAccountsKeys.jsm | 662 +++++++++++++----- services/fxaccounts/FxAccountsPairing.jsm | 90 ++- services/fxaccounts/FxAccountsWebChannel.jsm | 5 +- services/fxaccounts/tests/xpcshell/head.js | 34 +- .../tests/xpcshell/test_accounts.js | 299 ++++---- .../test_accounts_device_registration.js | 3 +- .../fxaccounts/tests/xpcshell/test_device.js | 12 +- .../fxaccounts/tests/xpcshell/test_pairing.js | 90 ++- services/sync/modules-testing/utils.js | 23 +- services/sync/modules/browserid_identity.js | 104 ++- services/sync/modules/keys.js | 8 + .../tests/unit/test_browserid_identity.js | 99 ++- .../sync/tests/unit/test_syncscheduler.js | 2 +- .../extensions/ExtensionStorageSyncKinto.jsm | 11 +- .../xpcshell/test_ext_storage_sync_kinto.js | 61 +- 20 files changed, 1075 insertions(+), 599 deletions(-) diff --git a/services/common/utils.js b/services/common/utils.js index 652f994dd330..45723eb7de4f 100644 --- a/services/common/utils.js +++ b/services/common/utils.js @@ -269,6 +269,12 @@ var CommonUtils = { return CommonUtils.decodeUTF8(CommonUtils.hexToBytes(hex)); }, + base64urlToHex(b64str) { + return CommonUtils.bufferToHex( + new Uint8Array(ChromeUtils.base64URLDecode(b64str, { padding: "reject" })) + ); + }, + /** * Base32 encode (RFC 4648) a string */ diff --git a/services/fxaccounts/FxAccounts.jsm b/services/fxaccounts/FxAccounts.jsm index 6573204eb77a..54da4917060a 100644 --- a/services/fxaccounts/FxAccounts.jsm +++ b/services/fxaccounts/FxAccounts.jsm @@ -176,7 +176,7 @@ AccountState.prototype = { } if (this.whenKeysReadyDeferred) { this.whenKeysReadyDeferred.reject( - new Error("Verification aborted; Another user signing in") + new Error("Key fetching aborted; Another user signing in") ); this.whenKeysReadyDeferred = null; } @@ -492,57 +492,15 @@ class FxAccounts { }); } - /** - * Retrieves an OAuth authorization code. - * - * @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 containing "code" and "state" properties. - */ - authorizeOAuthCode(options) { - return this._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._internal.createKeysJWE( - params.client_id, - params.scope, - jwk - ); - delete params.keys_jwk; - } - try { - return await this._internal.fxAccountsClient.oauthAuthorize( - sessionToken, - params - ); - } catch (err) { - throw this._internal._errorToErrorClass(err); - } - }); - } - /** * Get an OAuth token for the user. - * If you need a corresponding scoped key to go along with this - * token, consider using the new 'getAccessToken' method instead. * * @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. @@ -563,48 +521,6 @@ class FxAccounts { } } - /** - * Fetches an OAuth token based on the given scope - * and its key in a single operation. - * - * @param scope {String} the requested OAuth scope - * @param ttl {Number} OAuth token TTL - * @returns {Promise} Object containing "scope", "token" - * and "key" properties. - */ - async getAccessToken(scope, ttl) { - log.debug("getAccessToken enter"); - const token = await this._internal.getOAuthToken({ scope, ttl }); - const ACCT_DATA_FIELDS = ["scopedKeys"]; - - return this._withCurrentAccountState(async currentState => { - const data = await currentState.getUserAccountData(ACCT_DATA_FIELDS); - const scopedKeys = data.scopedKeys || {}; - let key; - - if (!scopedKeys.hasOwnProperty(scope)) { - log.debug(`Fetching scopedKeys data for ${scope}`); - const newKeyData = await this._internal.keys.getScopedKeys( - scope, - FX_OAUTH_CLIENT_ID - ); - - scopedKeys[scope] = newKeyData[scope] || null; - await currentState.updateUserAccountData({ scopedKeys }); - } else { - log.debug(`Using cached scopedKeys data for ${scope}`); - } - - key = scopedKeys[scope]; - - return { - scope, - token, - key, - }; - }); - } - /** * 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 @@ -1772,18 +1688,6 @@ FxAccountsInternal.prototype = { }); }, - /** - * - * @param {String} clientId - * @param {String} scope Space separated requested scopes - * @param {Object} jwk - */ - async createKeysJWE(clientId, scope, jwk) { - let scopedKeys = await this.keys.getScopedKeys(scope, clientId); - scopedKeys = new TextEncoder().encode(JSON.stringify(scopedKeys)); - return jwcrypto.generateJWE(jwk, scopedKeys); - }, - async _getVerifiedAccountOrReject() { let data = await this.currentAccountState.getUserAccountData(); if (!data) { diff --git a/services/fxaccounts/FxAccountsCommands.js b/services/fxaccounts/FxAccountsCommands.js index 6fa5d93b7a30..a55a3e20f1b9 100644 --- a/services/fxaccounts/FxAccountsCommands.js +++ b/services/fxaccounts/FxAccountsCommands.js @@ -4,9 +4,12 @@ const EXPORTED_SYMBOLS = ["SendTab", "FxAccountsCommands"]; -const { COMMAND_SENDTAB, COMMAND_SENDTAB_TAIL, log } = ChromeUtils.import( - "resource://gre/modules/FxAccountsCommon.js" -); +const { + COMMAND_SENDTAB, + COMMAND_SENDTAB_TAIL, + SCOPE_OLD_SYNC, + log, +} = ChromeUtils.import("resource://gre/modules/FxAccountsCommon.js"); ChromeUtils.defineModuleGetter( this, "PushCrypto", @@ -234,9 +237,9 @@ class FxAccountsCommands { /** * Send Tab is built on top of FxA commands. * - * Devices exchange keys wrapped in kSync between themselves (getEncryptedKey) + * Devices exchange keys wrapped in the oldsync key between themselves (getEncryptedKey) * during the device registration flow. The FxA server can theorically never - * retrieve the send tab keys since it doesn't know kSync. + * retrieve the send tab keys since it doesn't know the oldsync key. */ class SendTab { constructor(commands, fxAccountsInternal) { @@ -329,7 +332,9 @@ class SendTab { if (!bundle) { throw new Error(`Device ${device.id} does not have send tab keys.`); } - const { kSync, kXCS: ourKid } = await this._fxai.keys.getKeys(); + 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] ); @@ -339,7 +344,7 @@ class SendTab { const json = JSON.parse(bundle); const wrapper = new CryptoWrapper(); wrapper.deserialize({ payload: json }); - const syncKeyBundle = BulkKeyBundle.fromHexKey(kSync); + const syncKeyBundle = BulkKeyBundle.fromJWK(oldsyncKey); let { publicKey, authSecret } = await wrapper.decrypt(syncKeyBundle); authSecret = urlsafeBase64Decode(authSecret); publicKey = urlsafeBase64Decode(publicKey); @@ -406,33 +411,29 @@ class SendTab { }; // getEncryptedKey() will be called as part of device registration, which // happens immediately after signup/signin, so there's a good chance we - // don't yet have kSync et. al. Unverified users will be unable to fetch + // don't yet have the sync keys. Unverified users will be unable to fetch // keys, meaning they will end up registering the device twice (once without // sendtab support, then once with sendtab support when they verify), but // that's OK. - if (!(await this._fxai.keys.canGetKeys())) { + // TODO: this will fail if master password is locked; should we prompt to unlock it here? + if (!(await this._fxai.keys.canGetKeyForScope(SCOPE_OLD_SYNC))) { log.info("Can't fetch keys, so unable to determine sendtab keys"); return null; } - let kSync, kXCS; + let oldsyncKey; try { - ({ kSync, kXCS } = await this._fxai.keys.getKeys()); - if (!kSync || !kXCS) { - log.warn( - "Fetched the keys but didn't get any, so unable to determine sendtab keys" - ); - return null; - } + 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 CryptoWrapper(); wrapper.cleartext = keyToEncrypt; - const keyBundle = BulkKeyBundle.fromHexKey(kSync); + const keyBundle = BulkKeyBundle.fromJWK(oldsyncKey); await wrapper.encrypt(keyBundle); return JSON.stringify({ - kid: kXCS, + // Older clients expect this to be hex, due to pre-JWK sync key ids :-( + kid: this._fxai.keys.kidAsHex(oldsyncKey), IV: wrapper.IV, hmac: wrapper.hmac, ciphertext: wrapper.ciphertext, diff --git a/services/fxaccounts/FxAccountsCommon.js b/services/fxaccounts/FxAccountsCommon.js index c4696ae3924c..0dfc860b2b90 100644 --- a/services/fxaccounts/FxAccountsCommon.js +++ b/services/fxaccounts/FxAccountsCommon.js @@ -113,6 +113,10 @@ exports.FX_OAUTH_CLIENT_ID = "5882386c6d801776"; exports.SCOPE_PROFILE = "profile"; exports.SCOPE_PROFILE_WRITE = "profile:write"; exports.SCOPE_OLD_SYNC = "https://identity.mozilla.com/apps/oldsync"; +// This scope and its associated key material are used by the old Kinto webextension +// storage backend. We plan to remove that at some point (ref Bug 1637465) and when +// we do, all uses of this legacy scope can be removed. +exports.LEGACY_SCOPE_WEBEXT_SYNC = "sync:addon_storage"; // OAuth metadata for other Firefox-related services that we might need to know about // in order to provide an enhanced user experience. @@ -265,7 +269,14 @@ exports.ERROR_INVALID_PARAMETER = "INVALID_PARAMETER"; exports.ERROR_CODE_METHOD_NOT_ALLOWED = 405; exports.ERROR_MSG_METHOD_NOT_ALLOWED = "METHOD_NOT_ALLOWED"; -exports.DERIVED_KEYS_NAMES = ["kSync", "kXCS", "kExtSync", "kExtKbHash"]; +// When FxA support first landed in Firefox, it was only used for sync and +// we stored the relevant encryption keys as top-level fields in the account state. +// We've since grown a more elaborate scheme of derived keys linked to specific +// OAuth scopes, which are stored in a map in the `scopedKeys` field. +// These are the names of pre-scoped-keys key material, maintained for b/w +// compatibility to code elsewhere in Firefox; once all consuming code is updated +// to use scoped keys, these fields can be removed from the account userData. +exports.LEGACY_DERIVED_KEYS_NAMES = ["kSync", "kXCS", "kExtSync", "kExtKbHash"]; // FxAccounts has the ability to "split" the credentials between a plain-text // JSON file in the profile dir and in the login manager. @@ -289,7 +300,7 @@ exports.FXA_PWDMGR_PLAINTEXT_FIELDS = new Set([ // Fields we store in secure storage if it exists. exports.FXA_PWDMGR_SECURE_FIELDS = new Set([ - ...exports.DERIVED_KEYS_NAMES, + ...exports.LEGACY_DERIVED_KEYS_NAMES, "keyFetchToken", "unwrapBKey", "assertion", diff --git a/services/fxaccounts/FxAccountsDevice.jsm b/services/fxaccounts/FxAccountsDevice.jsm index 23239631d669..01427ba86f42 100644 --- a/services/fxaccounts/FxAccountsDevice.jsm +++ b/services/fxaccounts/FxAccountsDevice.jsm @@ -384,6 +384,15 @@ class FxAccountsDevice { // 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"); @@ -550,7 +559,7 @@ class FxAccountsDevice { // registration will be retried. log.error("device registration failed", error); try { - currentState.updateUserAccountData({ + await currentState.updateUserAccountData({ device: null, }); } catch (secondError) { diff --git a/services/fxaccounts/FxAccountsKeys.jsm b/services/fxaccounts/FxAccountsKeys.jsm index c37aec1539e6..fa5df2526ad3 100644 --- a/services/fxaccounts/FxAccountsKeys.jsm +++ b/services/fxaccounts/FxAccountsKeys.jsm @@ -14,21 +14,57 @@ const { CryptoUtils } = ChromeUtils.import( "resource://services-crypto/utils.js" ); -const { DERIVED_KEYS_NAMES, SCOPE_OLD_SYNC, log, logPII } = ChromeUtils.import( - "resource://gre/modules/FxAccountsCommon.js" -); +const { + LEGACY_DERIVED_KEYS_NAMES, + SCOPE_OLD_SYNC, + LEGACY_SCOPE_WEBEXT_SYNC, + FX_OAUTH_CLIENT_ID, + log, + logPII, +} = ChromeUtils.import("resource://gre/modules/FxAccountsCommon.js"); +// 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, LEGACY_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. + */ class FxAccountsKeys { constructor(fxAccountsInternal) { - this._fxia = fxAccountsInternal; + this._fxai = fxAccountsInternal; } /** - * Checks if we currently have encryption keys or if we have enough to - * be able to successfully fetch them for the signed-in-user. + * 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. */ - canGetKeys() { - return this._fxia.withCurrentAccountState(async currentState => { + 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"); @@ -37,22 +73,102 @@ class FxAccountsKeys { log.info("Can't get keys; user is not verified"); return false; } - // - keyFetchToken means we can almost certainly grab them. - // - kSync, kXCS, kExtSync and kExtKbHash means we already have them. - // - kB is deprecated but |getKeys| will help us migrate to kSync and friends. - return ( - userData && - (userData.keyFetchToken || - DERIVED_KEYS_NAMES.every(k => userData[k]) || - userData.kB) - ); + + if (userData.scopedKeys && userData.scopedKeys.hasOwnProperty(scope)) { + return true; + } + + // For sync-related scopes, we might have stored the keys in a legacy format. + if (scope == SCOPE_OLD_SYNC) { + if (userData.kSync && userData.kXCS) { + return true; + } + } + if (scope == LEGACY_SCOPE_WEBEXT_SYNC) { + if (userData.kExtSync && userData.kExtKbHash) { + return true; + } + } + + // `kB` is deprecated, but if we have it, we can use it to derive any scoped key. + if (userData.kB) { + 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 + * 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 key material as hex rather than base64. + * + * This is a backwards-compatibility helper for code that needs raw key bytes rather + * than the JWK format offered by FxA scopes keys. + * + * @param {Object} jwk The JWK from which to extract the `k` field as hex. + * + */ + keyAsHex(jwk) { + return CommonUtils.base64urlToHex(jwk.k); + } + + /** + * 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 fetch. + * Not for user consumption. Exists to cause the keys to be fetched. * * Returns user data so that it can be chained with other methods. * @@ -62,72 +178,202 @@ class FxAccountsKeys { * 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 * kSync: An encryption key for Sync * kXCS: A key hash of kB for the X-Client-State header * kExtSync: An encryption key for WebExtensions syncing * kExtKbHash: A key hash of kB for WebExtensions syncing + * ecosystemUserId: A derived key used for Account EcosystemTelemetry * verified: email verification status * } - * or null if no user is signed in + * @throws If there is no user signed in. */ - async getKeys() { - return this._fxia.withCurrentAccountState(async currentState => { + 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 (userData.kB) { - // Bug 1426306 - Migrate from kB to derived keys. - log.info("Migrating kB to derived keys."); - const { uid, kB } = userData; - await currentState.updateUserAccountData({ - uid, - ...(await this._deriveKeys(uid, CommonUtils.hexToBytes(kB))), - kA: null, // Remove kA and kB from storage. - kB: null, - }); - userData = await currentState.getUserAccountData(); - } - if (DERIVED_KEYS_NAMES.every(k => !!userData[k])) { - return userData; + // 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) + ) + ) { + return userData; + } } + // If not, we've got work to do, and we debounce to avoid duplicating it. if (!currentState.whenKeysReadyDeferred) { currentState.whenKeysReadyDeferred = PromiseUtils.defer(); - if (userData.keyFetchToken) { - this.fetchAndUnwrapKeys(userData.keyFetchToken).then( - dataWithKeys => { - if (DERIVED_KEYS_NAMES.some(k => !dataWithKeys[k])) { - const missing = DERIVED_KEYS_NAMES.filter( - k => !dataWithKeys[k] - ); - currentState.whenKeysReadyDeferred.reject( - new Error(`user data missing: ${missing.join(", ")}`) - ); - return; - } - currentState.whenKeysReadyDeferred.resolve(dataWithKeys); - }, - err => { - currentState.whenKeysReadyDeferred.reject(err); - } - ); - } else { - currentState.whenKeysReadyDeferred.reject("No keyFetchToken"); - } + // 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._fxia._handleTokenError(err); + return this._fxai._handleTokenError(err); } }); } /** - * Once the user's email is verified, we can request the keys + * 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. + * */ - fetchKeys(keyFetchToken) { - let client = this._fxia.fxAccountsClient; + async _migrateOrFetchKeys(currentState, userData) { + // Bug 1661407 - migrate from legacy storage of keys as top-level account + // data fields, to storing them as scoped keys in the `scopedKeys` object. + if ( + LEGACY_DERIVED_KEYS_NAMES.every(name => userData.hasOwnProperty(name)) + ) { + log.info("Migrating from legacy key fields to scopedKeys."); + const scopedKeys = userData.scopedKeys || {}; + await currentState.updateUserAccountData({ + scopedKeys: { + ...scopedKeys, + ...(await this._deriveScopedKeysFromAccountData(userData)), + }, + }); + userData = await currentState.getUserAccountData(); + return userData; + } + // Bug 1426306 - Migrate from kB to derived keys. + if (userData.kB) { + log.info("Migrating kB to derived keys."); + const { uid, kB, sessionToken } = userData; + const scopedKeysMetadata = await this._fetchScopedKeysMetadata( + sessionToken + ); + await currentState.updateUserAccountData({ + uid, + ...(await this._deriveKeys( + uid, + CommonUtils.hexToBytes(kB), + scopedKeysMetadata + )), + kA: null, // Remove kA and kB from storage. + kB: null, + }); + userData = await currentState.getUserAccountData(); + return 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 + ); + } + + /** + * Fetch keys from the server, unwrap them, and derive required sub-keys. + * + * Once the user's email is verified, we can request 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 we derived all the right stuff. + const EXPECTED_FIELDS = LEGACY_DERIVED_KEYS_NAMES.concat(["scopedKeys"]); + if (EXPECTED_FIELDS.some(k => !updateData[k])) { + const missing = EXPECTED_FIELDS.filter(k => !updateData[k]); + throw new Error(`user data missing: ${missing.join(", ")}`); + } + + 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}` ); @@ -137,114 +383,204 @@ class FxAccountsKeys { return client.accountKeys(keyFetchToken); } - fetchAndUnwrapKeys(keyFetchToken) { - return this._fxia.withCurrentAccountState(async currentState => { - if (logPII) { - log.debug("fetchAndUnwrapKeys: token: " + keyFetchToken); - } - // Sign out if we don't have a key fetch token. - if (!keyFetchToken) { - // this seems really bad and we should remove this - bug 1572313. - log.warn("improper fetchAndUnwrapKeys() call: token missing"); - await this._fxia.signOut(); - return null; - } - - 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 our _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 + /** + * 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. + // Note that SCOPE_OLD_SYNC_WEBEXT is not in this list, it get special-case handling below. + const scopes = [SCOPE_OLD_SYNC].join(" "); + const scopedKeysMetadata = await this._fxai.fxAccountsClient.getScopedKeyData( + sessionToken, + FX_OAUTH_CLIENT_ID, + scopes + ); + 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)}` ); - - if (logPII) { - log.debug("kBbytes: " + kBbytes); - } - let updateData = { - ...(await this._deriveKeys(data.uid, kBbytes)), - keyFetchToken: null, // null values cause the item to be removed. - unwrapBKey: null, - }; - - log.debug( - "Keys Obtained:" + - DERIVED_KEYS_NAMES.map(k => `${k}=${!!updateData[k]}`).join(", ") + throw new Error( + "The FxA server did not grant Firefox the `oldsync` scope" ); - if (logPII) { - log.debug( - "Keys Obtained:" + - DERIVED_KEYS_NAMES.map(k => `${k}=${updateData[k]}`).join(", ") - ); - } - - await currentState.updateUserAccountData(updateData); - data = await currentState.getUserAccountData(); - return data; - }); + } + // Firefox Desktop invented its own special scope for legacy webextension syncing, + // with its own special key. Rather than teach the rest of FxA about this scope + // that will never be used anywhere else, just give it the same metadata as + // the main sync scope. This can go away once legacy webext sync is removed. + // (ref Bug 1637465 for tracking that removal) + scopedKeysMetadata[LEGACY_SCOPE_WEBEXT_SYNC] = { + ...scopedKeysMetadata[SCOPE_OLD_SYNC], + identifier: LEGACY_SCOPE_WEBEXT_SYNC, + }; + return scopedKeysMetadata; } /** - * @param {String} scope Single key bearing scope + * 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 getKeyForScope(scope, { keyRotationTimestamp }) { - if (scope !== SCOPE_OLD_SYNC) { - throw new Error(`Unavailable key material for ${scope}`); - } - let { kSync, kXCS } = await this.getKeys(); - if (!kSync || !kXCS) { - throw new Error("Could not find requested key."); - } - kXCS = ChromeUtils.base64URLEncode(CommonUtils.hexToArrayBuffer(kXCS), { - pad: false, - }); - kSync = ChromeUtils.base64URLEncode(CommonUtils.hexToArrayBuffer(kSync), { - pad: false, - }); - const kid = `${keyRotationTimestamp}-${kXCS}`; + async _deriveKeys(uid, kBbytes, scopedKeysMetadata) { + const scopedKeys = await this._deriveScopedKeys( + uid, + kBbytes, + scopedKeysMetadata + ); return { - scope, - kid, - k: kSync, - kty: "oct", + scopedKeys, + // Existing browser code might expect sync keys to be available as top-level account data. + // For b/w compat we can derive these even if they're not in our list of scoped keys for + // some reason (since the derivation doesn't depend on server-provided data). + kSync: scopedKeys[SCOPE_OLD_SYNC] + ? this.keyAsHex(scopedKeys[SCOPE_OLD_SYNC]) + : CommonUtils.bytesAsHex(await this._deriveSyncKey(kBbytes)), + kXCS: scopedKeys[SCOPE_OLD_SYNC] + ? this.kidAsHex(scopedKeys[SCOPE_OLD_SYNC]) + : CommonUtils.bytesAsHex(await this._deriveXClientState(kBbytes)), + kExtSync: scopedKeys[LEGACY_SCOPE_WEBEXT_SYNC] + ? this.keyAsHex(scopedKeys[LEGACY_SCOPE_WEBEXT_SYNC]) + : CommonUtils.bytesAsHex(await this._deriveWebExtSyncStoreKey(kBbytes)), + kExtKbHash: scopedKeys[LEGACY_SCOPE_WEBEXT_SYNC] + ? this.kidAsHex(scopedKeys[LEGACY_SCOPE_WEBEXT_SYNC]) + : CommonUtils.bytesAsHex(await this._deriveWebExtKbHash(uid, kBbytes)), }; } /** - * @param {String} scopes Space separated requested scopes - * @param {String} clientId oauth client id + * 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 getScopedKeys(scopes, clientId) { - const { sessionToken } = await this._fxia._getVerifiedAccountOrReject(); - const keyData = await this._fxia.fxAccountsClient.getScopedKeyData( - sessionToken, - clientId, - scopes - ); + async _deriveScopedKeys(uid, kBbytes, scopedKeysMetadata) { const scopedKeys = {}; - for (const [scope, data] of Object.entries(keyData)) { - scopedKeys[scope] = await this.getKeyForScope(scope, data); + 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; } - async _deriveKeys(uid, kBbytes) { + /** + * Derive the `scopedKeys` data field based on current account data. + * + * This is a backwards-compatibility convenience for users who are already signed in to Firefox + * and have legacy fields like `kSync` and `kXCS` in their top-level account data, but do not have + * the newer `scopedKeys` field. We populate it with the scoped keys for sync and webext-sync. + * + */ + async _deriveScopedKeysFromAccountData(userData) { + const scopedKeysMetadata = await this._fetchScopedKeysMetadata( + userData.sessionToken + ); + const scopedKeys = userData.scopedKeys || {}; + for (const scope of LEGACY_DERIVED_KEY_SCOPES) { + if (scopedKeysMetadata.hasOwnProperty(scope)) { + let kid, key; + if (scope == SCOPE_OLD_SYNC) { + ({ kXCS: kid, kSync: key } = userData); + } else if (scope == LEGACY_SCOPE_WEBEXT_SYNC) { + ({ kExtKbHash: kid, kExtSync: key } = userData); + } else { + // Should never happen, but a nice internal consistency check. + throw new Error(`Unexpected legacy key-bearing scope: ${scope}`); + } + if (!kid || !key) { + throw new Error( + `Account is missing legacy key fields for scope: ${scope}` + ); + } + scopedKeys[scope] = await this._formatLegacyScopedKey( + CommonUtils.hexToArrayBuffer(kid), + CommonUtils.hexToArrayBuffer(key), + scope, + scopedKeysMetadata[scope] + ); + } + } + return scopedKeys; + } + + /** + * Derive a scoped key for an individual OAuth scope. + * + */ + async _deriveScopedKey(uid, kBbytes, scope, scopedKeyMetadata) { + // N.B. when we come to implement this, remember that `kBbytes` will be a string + // with bytes in it, not an `ArrayBuffer`. + throw new Error("Only legacy scoped keys are currently implemented"); + } + + /** + * 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 if (scope == LEGACY_SCOPE_WEBEXT_SYNC) { + kid = await this._deriveWebExtKbHash(uid, kBbytes); + key = await this._deriveWebExtSyncStoreKey(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 { - kSync: CommonUtils.bytesAsHex(await this._deriveSyncKey(kBbytes)), - kXCS: CommonUtils.bytesAsHex(this._deriveXClientState(kBbytes)), - kExtSync: CommonUtils.bytesAsHex( - await this._deriveWebExtSyncStoreKey(kBbytes) - ), - kExtKbHash: CommonUtils.bytesAsHex( - this._deriveWebExtKbHash(uid, kBbytes) - ), + kid: `${keyRotationTimestamp}-${kid}`, + k: key, + kty: "oct", }; } @@ -253,7 +589,7 @@ class FxAccountsKeys { * * @returns Promise */ - _deriveSyncKey(kBbytes) { + async _deriveSyncKey(kBbytes) { return CryptoUtils.hkdfLegacy( kBbytes, undefined, @@ -262,12 +598,21 @@ class FxAccountsKeys { ); } + /** + * Derive the X-Client-State header given the byte string kB. + * + * @returns Promise + */ + async _deriveXClientState(kBbytes) { + return this._sha256(kBbytes).slice(0, 16); + } + /** * Derive the WebExtensions Sync Storage Key given the byte string kB. * * @returns Promise */ - _deriveWebExtSyncStoreKey(kBbytes) { + async _deriveWebExtSyncStoreKey(kBbytes) { return CryptoUtils.hkdfLegacy( kBbytes, undefined, @@ -279,21 +624,12 @@ class FxAccountsKeys { /** * Derive the WebExtensions kbHash given the byte string kB. * - * @returns SHA256(uid + kB) + * @returns Promise */ - _deriveWebExtKbHash(uid, kBbytes) { + async _deriveWebExtKbHash(uid, kBbytes) { return this._sha256(uid + kBbytes); } - /** - * Derive the X-Client-State header given the byte string kB. - * - * @returns SHA256(kB)[:16] - */ - _deriveXClientState(kBbytes) { - return this._sha256(kBbytes).slice(0, 16); - } - _sha256(bytes) { let hasher = Cc["@mozilla.org/security/hash;1"].createInstance( Ci.nsICryptoHash diff --git a/services/fxaccounts/FxAccountsPairing.jsm b/services/fxaccounts/FxAccountsPairing.jsm index d24895af1650..15ea7b5c324d 100644 --- a/services/fxaccounts/FxAccountsPairing.jsm +++ b/services/fxaccounts/FxAccountsPairing.jsm @@ -26,6 +26,11 @@ ChromeUtils.defineModuleGetter( "Weave", "resource://services-sync/main.js" ); +ChromeUtils.defineModuleGetter( + this, + "jwcrypto", + "resource://services-crypto/jwcrypto.jsm" +); ChromeUtils.defineModuleGetter( this, "FxAccountsPairingChannel", @@ -214,6 +219,7 @@ this.FxAccountsPairingFlow = class FxAccountsPairingFlow { 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); @@ -312,9 +318,7 @@ this.FxAccountsPairingFlow = class FxAccountsPairingFlow { code_challenge_method, keys_jwk, }; - const codeAndState = await this._fxa.authorizeOAuthCode( - authorizeParams - ); + const codeAndState = await this._authorizeOAuthCode(authorizeParams); if (codeAndState.state != state) { throw new Error(`OAuth state mismatch`); } @@ -433,6 +437,86 @@ this.FxAccountsPairingFlow = class FxAccountsPairingFlow { 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 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 jwcrypto.generateJWE( + jwk, + new TextEncoder().encode(JSON.stringify(scopedKeys)) + ); + } }; const EXPORTED_SYMBOLS = ["FxAccountsPairingFlow"]; diff --git a/services/fxaccounts/FxAccountsWebChannel.jsm b/services/fxaccounts/FxAccountsWebChannel.jsm index 71a5f418b02b..0886e7436f7a 100644 --- a/services/fxaccounts/FxAccountsWebChannel.jsm +++ b/services/fxaccounts/FxAccountsWebChannel.jsm @@ -33,6 +33,7 @@ const { FX_OAUTH_CLIENT_ID, ON_PROFILE_CHANGE_NOTIFICATION, PREF_LAST_FXA_USER, + SCOPE_OLD_SYNC, WEBCHANNEL_ID, log, logPII, @@ -624,9 +625,9 @@ FxAccountsWebChannelHelpers.prototype = { // in updateDeviceRegistration (but it's not clear we really do need to // force keys here - see bug 1580398 for more) try { - await this._fxAccounts.keys.getKeys(); + await this._fxAccounts.keys.getKeyForScope(SCOPE_OLD_SYNC); } catch (e) { - log.error("getKeys errored", e); + log.error("getKeyForScope errored", e); } await this._fxAccounts._internal.updateDeviceRegistration(); }, diff --git a/services/fxaccounts/tests/xpcshell/head.js b/services/fxaccounts/tests/xpcshell/head.js index f1dfbde33332..7c79cd62380c 100644 --- a/services/fxaccounts/tests/xpcshell/head.js +++ b/services/fxaccounts/tests/xpcshell/head.js @@ -6,11 +6,39 @@ "use strict"; -var { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); -var { XPCOMUtils } = ChromeUtils.import( +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { XPCOMUtils } = ChromeUtils.import( "resource://gre/modules/XPCOMUtils.jsm" ); -var { sinon } = ChromeUtils.import("resource://testing-common/Sinon.jsm"); +const { sinon } = ChromeUtils.import("resource://testing-common/Sinon.jsm"); +const { SCOPE_OLD_SYNC, LEGACY_SCOPE_WEBEXT_SYNC } = ChromeUtils.import( + "resource://gre/modules/FxAccountsCommon.js" +); + +// 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", + }, + [LEGACY_SCOPE_WEBEXT_SYNC]: { + kid: "1234567890123-3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d0", + k: + "zMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzA", + kty: "oct", + }, + }, + kSync: + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + kXCS: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + kExtSync: + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + kExtKbHash: + "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd ", +}; (function initFxAccountsTestingInfrastructure() { do_get_profile(); diff --git a/services/fxaccounts/tests/xpcshell/test_accounts.js b/services/fxaccounts/tests/xpcshell/test_accounts.js index c1f4ce799fd8..1d4a11cb45f3 100644 --- a/services/fxaccounts/tests/xpcshell/test_accounts.js +++ b/services/fxaccounts/tests/xpcshell/test_accounts.js @@ -15,11 +15,11 @@ const { ERRNO_INVALID_AUTH_TOKEN, ERROR_NETWORK, ERROR_NO_ACCOUNT, + FX_OAUTH_CLIENT_ID, KEY_LIFETIME, ONLOGIN_NOTIFICATION, ONLOGOUT_NOTIFICATION, ONVERIFIED_NOTIFICATION, - SCOPE_OLD_SYNC, } = ChromeUtils.import("resource://gre/modules/FxAccountsCommon.js"); const { PromiseUtils } = ChromeUtils.import( "resource://gre/modules/PromiseUtils.jsm" @@ -153,6 +153,24 @@ function MockFxAccountsClient() { }); }; + 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); @@ -263,11 +281,8 @@ add_task(async function test_get_signed_in_user_initially_unset() { uid: "1234@lcip.org", assertion: "foobar", sessionToken: "dead", - kSync: "beef", - kXCS: "cafe", - kExtSync: "bacon", - kExtKbHash: "cheese", verified: true, + ...MOCK_ACCOUNT_KEYS, }; let result = await account.getSignedInUser(); Assert.equal(result, null); @@ -278,6 +293,7 @@ add_task(async function test_get_signed_in_user_initially_unset() { result = await account.getSignedInUser(); Assert.deepEqual(result.email, credentials.email); Assert.deepEqual(result.assertion, undefined); + Assert.deepEqual(result.scopedKeys, undefined); Assert.deepEqual(result.kSync, undefined); Assert.deepEqual(result.kXCS, undefined); Assert.deepEqual(result.kExtSync, undefined); @@ -286,20 +302,11 @@ add_task(async function test_get_signed_in_user_initially_unset() { result = await account._internal.currentAccountState.getUserAccountData(); Assert.deepEqual(result.email, credentials.email); Assert.deepEqual(result.assertion, credentials.assertion); - Assert.deepEqual(result.kSync, credentials.kSync); - Assert.deepEqual(result.kXCS, credentials.kXCS); - Assert.deepEqual(result.kExtSync, credentials.kExtSync); - Assert.deepEqual(result.kExtKbHash, credentials.kExtKbHash); - - // Delete the memory cache and force the user - // to be read and parsed from storage (e.g. disk via JSONStorage). - result = await account._internal.currentAccountState.getUserAccountData(); - Assert.equal(result.email, credentials.email); - Assert.equal(result.assertion, credentials.assertion); - Assert.equal(result.kSync, credentials.kSync); - Assert.equal(result.kXCS, credentials.kXCS); - Assert.equal(result.kExtSync, credentials.kExtSync); - Assert.equal(result.kExtKbHash, credentials.kExtKbHash); + Assert.deepEqual(result.scopedKeys, credentials.scopedKeys); + Assert.ok(result.kSync); + Assert.ok(result.kXCS); + Assert.ok(result.kExtSync); + Assert.ok(result.kExtKbHash); // sign out let localOnly = true; @@ -318,11 +325,8 @@ add_task(async function test_set_signed_in_user_signs_out_previous_account() { uid: "1234@lcip.org", assertion: "foobar", sessionToken: "dead", - kSync: "beef", - kXCS: "cafe", - kExtSync: "bacon", - kExtKbHash: "cheese", verified: true, + ...MOCK_ACCOUNT_KEYS, }; let account = await MakeFxAccounts({ credentials }); @@ -342,11 +346,8 @@ add_task(async function test_update_account_data() { uid: "1234@lcip.org", assertion: "foobar", sessionToken: "dead", - kSync: "beef", - kXCS: "cafe", - kExtSync: "bacon", - kExtKbHash: "cheese", verified: true, + ...MOCK_ACCOUNT_KEYS, }; let account = await MakeFxAccounts({ credentials }); @@ -806,7 +807,7 @@ add_test(function test_pollEmailStatus_push() { }); }); -add_test(function test_getKeys() { +add_test(function test_getKeyForScope() { let fxa = new MockFxAccounts(); let user = getTestUser("eusebius"); @@ -815,7 +816,8 @@ add_test(function test_getKeys() { fxa.setSignedInUser(user).then(() => { fxa._internal.getUserAccountData().then(user2 => { - // Before getKeys, we have no keys + // Before getKeyForScope, we have no keys + Assert.equal(!!user2.scopedKeys, false); Assert.equal(!!user2.kSync, false); Assert.equal(!!user2.kXCS, false); Assert.equal(!!user2.kExtSync, false); @@ -824,11 +826,12 @@ add_test(function test_getKeys() { Assert.equal(!!user2.keyFetchToken, true); Assert.equal(!!user2.unwrapBKey, true); - fxa.keys.getKeys().then(() => { + 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.notEqual(null, user3.kSync); Assert.notEqual(null, user3.kXCS); Assert.notEqual(null, user3.kExtSync); @@ -842,7 +845,7 @@ add_test(function test_getKeys() { }); }); -add_task(async function test_getKeys_kb_migration() { +add_task(async function test_getKeyForScope_kb_migration() { let fxa = new MockFxAccounts(); let user = getTestUser("eusebius"); @@ -852,10 +855,25 @@ add_task(async function test_getKeys_kb_migration() { user.kB = "eaf9570b7219a4187d3d6bf3cec2770c2e0719b7cc0dfbb38243d6f1881675e9"; await fxa.setSignedInUser(user); - await fxa.keys.getKeys(); + await fxa.keys.getKeyForScope(SCOPE_OLD_SYNC); let newUser = await fxa._internal.getUserAccountData(); Assert.equal(newUser.kA, null); Assert.equal(newUser.kB, null); + Assert.deepEqual(newUser.scopedKeys, { + "https://identity.mozilla.com/apps/oldsync": { + kid: "1234567890123-IqQv4onc7VcVE1kTQkyyOw", + k: + "DW_ll5GwX6SJ5GPqJVAuMUP2t6kDqhUulc2cbt26xbTcaKGQl-9l29FHAQ7kUiJETma4s9fIpEHrt909zgFang", + kty: "oct", + }, + "sync:addon_storage": { + kid: "1234567890123-Je0Ks64vHlNl2SPJQC1CVXcNvmznmwntSfUWmFwKoME", + k: + "ut7VPrNYfXkA5gTopo2GCr-d4wtclV08TV26Y_Jv2IJlzYWSP26dzRau87gryIA5qJxZ7NnojeCadBjH2U-QyQ", + kty: "oct", + }, + }); + // These hex values were manually confirmed to be equivalent to the b64 values above. Assert.equal( newUser.kSync, "0d6fe59791b05fa489e463ea25502e3143f6b7a903aa152e95cd9c6eddbac5b4" + @@ -873,7 +891,33 @@ add_task(async function test_getKeys_kb_migration() { ); }); -add_task(async function test_getKeys_nonexistent_account() { +add_task(async function test_getKeyForScope_scopedKeys_migration() { + let fxa = new MockFxAccounts(); + let user = getTestUser("eusebius"); + + user.verified = true; + // Set-up the keys in deprecated fields. + user.kSync = MOCK_ACCOUNT_KEYS.kSync; + user.kXCS = MOCK_ACCOUNT_KEYS.kXCS; + user.kExtSync = MOCK_ACCOUNT_KEYS.kExtSync; + user.kExtKbHash = MOCK_ACCOUNT_KEYS.kExtKbHash; + Assert.equal(user.scopedKeys, null); + + await fxa.setSignedInUser(user); + await fxa.keys.getKeyForScope(SCOPE_OLD_SYNC); + let newUser = await fxa._internal.getUserAccountData(); + Assert.equal(newUser.kA, null); + Assert.equal(newUser.kB, null); + // It should have correctly formatted the corresponding scoped keys. + Assert.deepEqual(newUser.scopedKeys, MOCK_ACCOUNT_KEYS.scopedKeys); + // And left the existing key fields unchanged. + Assert.equal(newUser.kSync, user.kSync); + Assert.equal(newUser.kXCS, user.kXCS); + Assert.equal(newUser.kExtSync, user.kExtSync); + Assert.equal(newUser.kExtKbHash, user.kExtKbHash); +}); + +add_task(async function test_getKeyForScope_nonexistent_account() { let fxa = new MockFxAccounts(); let bismarck = getTestUser("bismarck"); @@ -891,13 +935,16 @@ add_task(async function test_getKeys_nonexistent_account() { let promiseLogout = new Promise(resolve => { makeObserver(ONLOGOUT_NOTIFICATION, function() { - log.debug("test_getKeys_nonexistent_account observed logout"); + 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.getKeys(), /A different user signed in/); + await Assert.rejects( + fxa.keys.getKeyForScope(SCOPE_OLD_SYNC), + /A different user signed in/ + ); await promiseLogout; @@ -905,8 +952,8 @@ add_task(async function test_getKeys_nonexistent_account() { Assert.equal(user, null); }); -// getKeys with invalid keyFetchToken should delete keyFetchToken from storage -add_task(async function test_getKeys_invalid_token() { +// 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"); @@ -923,7 +970,7 @@ add_task(async function test_getKeys_invalid_token() { await fxa.setSignedInUser(yusuf); try { - await fxa.keys.getKeys(); + await fxa.keys.getKeyForScope(SCOPE_OLD_SYNC); Assert.ok(false); } catch (err) { Assert.equal(err.code, 401); @@ -938,7 +985,7 @@ add_task(async function test_getKeys_invalid_token() { // This is the exact same test vectors as // https://github.com/mozilla/fxa-crypto-relier/blob/f94f441159029a645a474d4b6439c38308da0bb0/test/deriver/ScopedKeys.js#L58 -add_task(async function test_getScopedKeys_oldsync() { +add_task(async function test_getKeyForScope_oldsync() { let fxa = new MockFxAccounts(); let client = fxa._internal.fxAccountsClient; client.getScopedKeyData = () => @@ -953,44 +1000,42 @@ add_task(async function test_getScopedKeys_oldsync() { let user = { ...getTestUser("eusebius"), uid: "aeaa1725c7a24ff983c6295725d5fc9b", + kB: "eaf9570b7219a4187d3d6bf3cec2770c2e0719b7cc0dfbb38243d6f1881675e9", verified: true, - kSync: - "0d6fe59791b05fa489e463ea25502e3143f6b7a903aa152e95cd9c6eddbac5b4dc68a19097ef65dbd147010ee45222444e66b8b3d7c8a441ebb7dd3dce015a9e", - kXCS: "22a42fe289dced5715135913424cb23b", - kExtSync: - "baded53eb3587d7900e604e8a68d860abf9de30b5c955d3c4d5dba63f26fd88265cd85923f6e9dcd16aef3b82bc88039a89c59ecd9e88de09a7418c7d94f90c9", - kExtKbHash: - "b776a89db29f22daedd154b44ff88397d0b210223fb956f5a749521dd8de8ddf", }; await fxa.setSignedInUser(user); - const keys = await fxa.keys.getScopedKeys( - `${SCOPE_OLD_SYNC} profile`, - "123456789a" - ); - Assert.deepEqual(keys, { - [SCOPE_OLD_SYNC]: { - scope: SCOPE_OLD_SYNC, - kid: "1510726317123-IqQv4onc7VcVE1kTQkyyOw", - k: - "DW_ll5GwX6SJ5GPqJVAuMUP2t6kDqhUulc2cbt26xbTcaKGQl-9l29FHAQ7kUiJETma4s9fIpEHrt909zgFang", - kty: "oct", - }, + const key = await fxa.keys.getKeyForScope(SCOPE_OLD_SYNC); + Assert.deepEqual(key, { + scope: SCOPE_OLD_SYNC, + kid: "1510726317123-IqQv4onc7VcVE1kTQkyyOw", + k: + "DW_ll5GwX6SJ5GPqJVAuMUP2t6kDqhUulc2cbt26xbTcaKGQl-9l29FHAQ7kUiJETma4s9fIpEHrt909zgFang", + kty: "oct", }); }); -add_task(async function test_getScopedKeys_unavailable_key() { +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({ - "https://identity.mozilla.com/apps/oldsync": { - identifier: "https://identity.mozilla.com/apps/oldsync", - keyRotationSecret: - "0000000000000000000000000000000000000000000000000000000000000000", - keyRotationTimestamp: 1510726317123, - }, - otherkeybearingscope: { - identifier: "otherkeybearingscope", + wrongscope: { + identifier: "wrongscope", keyRotationSecret: "0000000000000000000000000000000000000000000000000000000000000000", keyRotationTimestamp: 1510726331712, @@ -1010,17 +1055,14 @@ add_task(async function test_getScopedKeys_unavailable_key() { }; await fxa.setSignedInUser(user); await Assert.rejects( - fxa.keys.getScopedKeys( - `${SCOPE_OLD_SYNC} otherkeybearingscope profile`, - "123456789a" - ), - /Unavailable key material for otherkeybearingscope/ + fxa.keys.getKeyForScope(SCOPE_OLD_SYNC), + /The FxA server did not grant Firefox the `oldsync` scope/ ); }); -// fetchAndUnwrapKeys with no keyFetchToken should trigger signOut +// _fetchAndUnwrapAndDeriveKeys with no keyFetchToken should trigger signOut // XXX - actually, it probably shouldn't - bug 1572313. -add_test(function test_fetchAndUnwrapKeys_no_token() { +add_test(function test_fetchAndUnwrapAndDeriveKeys_no_token() { let fxa = new MockFxAccounts(); let user = getTestUser("lettuce.protheroe"); delete user.keyFetchToken; @@ -1035,7 +1077,7 @@ add_test(function test_fetchAndUnwrapKeys_no_token() { fxa .setSignedInUser(user) .then(user2 => { - return fxa.keys.fetchAndUnwrapKeys(); + return fxa.keys._fetchAndUnwrapAndDeriveKeys(); }) .catch(error => { log.info("setSignedInUser correctly rejected"); @@ -1090,12 +1132,9 @@ add_task(async function test_getAssertion_invalid_token() { let creds = { sessionToken: "sessionToken", - kSync: expandHex("11"), - kXCS: expandHex("66"), - kExtSync: expandHex("88"), - kExtKbHash: expandHex("22"), verified: true, email: "sonia@example.com", + ...MOCK_ACCOUNT_KEYS, }; await fxa.setSignedInUser(creds); // we have what we still believe to be a valid session token, so we should @@ -1126,14 +1165,10 @@ add_task(async function test_getAssertion() { let creds = { sessionToken: "sessionToken", - kSync: expandHex("11"), - kXCS: expandHex("66"), - kExtSync: expandHex("88"), - kExtKbHash: expandHex("22"), verified: true, + ...MOCK_ACCOUNT_KEYS, }; - // By putting kSync/kXCS/kExtSync/kExtKbHash/verified in "creds", we skip ahead - // to the "we're ready" stage. + // By putting scopedKeys in "creds", we skip ahead to the "we're ready" stage. await fxa.setSignedInUser(creds); _("== ready to go\n"); @@ -1695,100 +1730,6 @@ add_task(async function test_getOAuthToken_authErrorRefreshesCertificate() { Assert.equal(fxa._internal._getCertificateSigned_calls.length, 2); }); -add_test(async function test_getAccessToken() { - let fxa = new MockFxAccounts(); - let alice = getTestUser("alice"); - alice.verified = true; - let oauthTokenCalled = false; - const TTL = 100; - const SCOPE = "https://identity.mozilla.com/apps/oldsync"; - - fxa._internal._d_signCertificate.resolve("cert1"); - - let client = fxa._internal.fxAccountsOAuthGrantClient; - client.getTokenFromAssertion = (assertion, scopeString, ttl) => { - Assert.ok(assertion); - Assert.equal(scopeString, SCOPE); - Assert.equal(ttl, TTL); - oauthTokenCalled = true; - return Promise.resolve({ access_token: "token" }); - }; - - const KEY_DATA = { - "https://identity.mozilla.com/apps/oldsync": { - k: - "3TVYx0exDTbrc5SGMkNg_C_eoNfjV0elHClP7npHrAtrlJu-esNyTUQaR6UcJBVYilPr8-T4BqWlIp4TOpKavA", - kid: "1569964308879-5y6waestOxDDM-Ia4_2u1Q", - kty: "oct", - scope: "https://identity.mozilla.com/apps/oldsync", - }, - }; - - fxa._internal.keys.getScopedKeys = () => Promise.resolve(KEY_DATA); - - await fxa.setSignedInUser(alice); - let result = await fxa.getAccessToken(SCOPE, TTL); - Assert.ok(oauthTokenCalled); - Assert.equal(result.scope, SCOPE); - Assert.equal(result.key, KEY_DATA[SCOPE]); - Assert.equal(result.token, "token"); - // Test that the scoped key cache works - fxa._internal.keys.getScopedKeys = () => { - throw new Error("Should not be called"); - }; - result = await fxa.getAccessToken(SCOPE, TTL); - Assert.ok(oauthTokenCalled); - Assert.equal(result.scope, SCOPE); - Assert.equal(result.key, KEY_DATA[SCOPE]); - Assert.equal(result.token, "token"); - run_next_test(); -}); - -add_test(function test_getAccessToken_error_bad_scope() { - let fxa = new MockFxAccounts(); - fxa.getAccessToken().catch(err => { - Assert.equal(err.details, "Missing or invalid 'scope' option"); - run_next_test(); - }); -}); - -add_test(async function test_getAccessToken_no_scoped_keys() { - let fxa = new MockFxAccounts(); - let alice = getTestUser("alice"); - alice.verified = true; - let oauthTokenCalled = false; - const TTL = 100; - const SCOPE = "profile"; - - fxa._internal._d_signCertificate.resolve("cert1"); - - let client = fxa._internal.fxAccountsOAuthGrantClient; - client.getTokenFromAssertion = () => { - oauthTokenCalled = true; - return Promise.resolve({ access_token: "token" }); - }; - - fxa._internal.keys.getScopedKeys = () => Promise.resolve({}); - - await fxa.setSignedInUser(alice); - let result = await fxa.getAccessToken(SCOPE, TTL); - Assert.ok(oauthTokenCalled); - Assert.equal(result.scope, SCOPE); - Assert.equal(result.key, undefined); - Assert.equal(result.token, "token"); - - // Test that the scoped key cache works - fxa._internal.keys.getScopedKeys = () => { - throw new Error("Should not be called"); - }; - result = await fxa.getAccessToken(SCOPE, TTL); - Assert.ok(oauthTokenCalled); - Assert.equal(result.scope, SCOPE); - Assert.equal(result.key, undefined); - Assert.equal(result.token, "token"); - run_next_test(); -}); - add_task(async function test_listAttachedOAuthClients() { const ONE_HOUR = 60 * 60 * 1000; const ONE_DAY = 24 * ONE_HOUR; diff --git a/services/fxaccounts/tests/xpcshell/test_accounts_device_registration.js b/services/fxaccounts/tests/xpcshell/test_accounts_device_registration.js index 7a832d1cc821..7a687b90f383 100644 --- a/services/fxaccounts/tests/xpcshell/test_accounts_device_registration.js +++ b/services/fxaccounts/tests/xpcshell/test_accounts_device_registration.js @@ -921,8 +921,7 @@ function getTestUser(name) { email: name + "@example.com", uid: "1ad7f502-4cc7-4ec1-a209-071fd2fae348", sessionToken: name + "'s session token", - keyFetchToken: name + "'s keyfetch token", - unwrapBKey: expandHex("44"), verified: false, + ...MOCK_ACCOUNT_KEYS, }; } diff --git a/services/fxaccounts/tests/xpcshell/test_device.js b/services/fxaccounts/tests/xpcshell/test_device.js index 58445e4f9f95..66bca4c62790 100644 --- a/services/fxaccounts/tests/xpcshell/test_device.js +++ b/services/fxaccounts/tests/xpcshell/test_device.js @@ -76,12 +76,15 @@ add_task(async function test_reset() { uid: "1234@lcip.org", assertion: "foobar", sessionToken: "dead", - kSync: "beef", - kXCS: "cafe", - kExtSync: "bacon", - kExtKbHash: "cheese", 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); ok(!Services.prefs.prefHasUserValue(testPref)); // signing the user out should reset the name pref. @@ -89,6 +92,7 @@ add_task(async function test_reset() { ok(Services.prefs.prefHasUserValue(namePref)); await fxAccounts.signOut(/* localOnly = */ true); ok(!Services.prefs.prefHasUserValue(namePref)); + registerDevice.restore(); }); add_task(async function test_name_sanitization() { diff --git a/services/fxaccounts/tests/xpcshell/test_pairing.js b/services/fxaccounts/tests/xpcshell/test_pairing.js index d269723f4c5f..6d342e0bee94 100644 --- a/services/fxaccounts/tests/xpcshell/test_pairing.js +++ b/services/fxaccounts/tests/xpcshell/test_pairing.js @@ -43,6 +43,7 @@ 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; @@ -52,20 +53,6 @@ const fxaConfig = { }, }; const fxAccounts = { - keys: { - getScopedKeys(scope) { - return { - [scope]: { - kid: "123456", - k: KSYNC, - kty: "oct", - }, - }; - }, - }, - authorizeOAuthCode() { - return { code: "mycode", state: "mystate" }; - }, getSignedInUser() { return { uid: UID, @@ -74,6 +61,39 @@ const fxAccounts = { 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 } }, @@ -132,6 +152,7 @@ add_task(async function testFullFlow() { const promiseSwitchToWebContent = emitter.once("view:SwitchToWebContent"); const promiseMetadataSent = promiseOutgoingMessage(pairingChannel); const epk = await generateEphemeralKeypair(); + pairingChannel.simulateIncoming({ message: "pair:supp:request", data: { @@ -177,17 +198,32 @@ add_task(async function testFullFlow() { pairSuppMetadata ); - const authorizeOAuthCode = sinon.spy(fxAccounts, "authorizeOAuthCode"); + const generateJWE = sinon.spy(jwcrypto, "generateJWE"); + const oauthAuthorize = sinon.spy( + fxAccounts._internal.fxAccountsClient, + "oauthAuthorize" + ); const promiseOAuthParamsMsg = promiseOutgoingMessage(pairingChannel); await simulateIncomingWebChannel(flow, "fxaccounts:pair_authorize"); - Assert.ok(authorizeOAuthCode.calledOnce); - const oauthCodeArgs = authorizeOAuthCode.firstCall.args[0]; - Assert.equal( - oauthCodeArgs.keys_jwk, - ChromeUtils.base64URLEncode( - new TextEncoder().encode(JSON.stringify(epk.publicJWK)), - { pad: false } - ) + // 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"); @@ -198,29 +234,35 @@ add_task(async function testFullFlow() { ); 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); - fxAccounts.authorizeOAuthCode.restore(); + generateJWE.restore(); + oauthAuthorize.restore(); }); add_task(async function testUnknownPairingMessage() { diff --git a/services/sync/modules-testing/utils.js b/services/sync/modules-testing/utils.js index bebdcd792236..ae3741d60fd3 100644 --- a/services/sync/modules-testing/utils.js +++ b/services/sync/modules-testing/utils.js @@ -44,6 +44,9 @@ const { FxAccounts } = ChromeUtils.import( const { FxAccountsClient } = ChromeUtils.import( "resource://gre/modules/FxAccountsClient.jsm" ); +const { SCOPE_OLD_SYNC, LEGACY_SCOPE_WEBEXT_SYNC } = ChromeUtils.import( + "resource://gre/modules/FxAccountsCommon.js" +); const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); // and grab non-exported stuff via a backstage pass. @@ -150,9 +153,23 @@ var makeIdentityConfig = function(overrides) { assertion: "assertion", email: "foo", kSync: "a".repeat(128), - kXCS: "a".repeat(32), - kExtSync: "a".repeat(128), - kExtKbHash: "a".repeat(32), + kXCS: "b".repeat(32), + kExtSync: "c".repeat(128), + kExtKbHash: "d".repeat(64), + scopedKeys: { + [SCOPE_OLD_SYNC]: { + kid: "1234567890123-u7u7u7u7u7u7u7u7u7u7uw", + k: + "qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqg", + kty: "oct", + }, + [LEGACY_SCOPE_WEBEXT_SYNC]: { + kid: "1234567890123-3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d0", + k: + "zMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzA", + kty: "oct", + }, + }, sessionToken: "sessionToken", uid: "a".repeat(32), verified: true, diff --git a/services/sync/modules/browserid_identity.js b/services/sync/modules/browserid_identity.js index 829265716b9e..36b39ee45832 100644 --- a/services/sync/modules/browserid_identity.js +++ b/services/sync/modules/browserid_identity.js @@ -50,6 +50,12 @@ ChromeUtils.defineModuleGetter( "resource://gre/modules/FxAccounts.jsm" ); +ChromeUtils.defineModuleGetter( + this, + "CommonUtils", + "resource://services-common/utils.js" +); + XPCOMUtils.defineLazyGetter(this, "log", function() { let log = Log.repository.getLogger("Sync.BrowserIDManager"); log.manageLevelFromPref("services.sync.log.logger.identity"); @@ -75,6 +81,8 @@ ChromeUtils.import( fxAccountsCommon ); +const SCOPE_OLD_SYNC = fxAccountsCommon.SCOPE_OLD_SYNC; + const OBSERVER_TOPICS = [ fxAccountsCommon.ONLOGIN_NOTIFICATION, fxAccountsCommon.ONVERIFIED_NOTIFICATION, @@ -306,6 +314,7 @@ this.BrowserIDManager.prototype = { */ async unlockAndVerifyAuthState() { let data = await this.getSignedInUser(); + const fxa = this._fxaService; if (!data) { log.debug("unlockAndVerifyAuthState has no FxA user"); return LOGIN_FAILED_NO_USERNAME; @@ -320,7 +329,7 @@ this.BrowserIDManager.prototype = { log.debug("unlockAndVerifyAuthState has an unverified user"); return LOGIN_FAILED_LOGIN_REJECTED; } - if (await this._fxaService.keys.canGetKeys()) { + if (await fxa.keys.canGetKeyForScope(SCOPE_OLD_SYNC)) { log.debug( "unlockAndVerifyAuthState already has (or can fetch) sync keys" ); @@ -338,7 +347,7 @@ this.BrowserIDManager.prototype = { // without unlocking the MP or cleared the saved logins, so we've now // lost them - the user will need to reauth before continuing. let result; - if (await this._fxaService.keys.canGetKeys()) { + if (await fxa.keys.canGetKeyForScope(SCOPE_OLD_SYNC)) { result = STATUS_OK; } else { result = LOGIN_FAILED_LOGIN_REJECTED; @@ -392,7 +401,7 @@ this.BrowserIDManager.prototype = { // We need keys for things to work. If we don't have them, just // return null for the token - sync calling unlockAndVerifyAuthState() // before actually syncing will setup the error states if necessary. - if (!(await fxa.keys.canGetKeys())) { + if (!(await fxa.keys.canGetKeyForScope(SCOPE_OLD_SYNC))) { this._log.info( "Unable to fetch keys (master-password locked?), so aborting token fetch" ); @@ -400,47 +409,38 @@ this.BrowserIDManager.prototype = { } // Do the assertion/certificate/token dance, with a retry. - let getToken = async keys => { - this._log.info("Getting an assertion from", this._tokenServerUrl); + let getToken = async key => { + this._log.info("Getting a sync token from", this._tokenServerUrl); let token; - if (USE_OAUTH_FOR_SYNC_TOKEN) { - token = await this._fetchTokenUsingOAuth(); + token = await this._fetchTokenUsingOAuth(key); } else { - const audience = Services.io.newURI(this._tokenServerUrl).prePath; - const assertion = await fxa._internal.getAssertion(audience); - this._log.debug("Getting a token using an Assertion"); - const headers = { "X-Client-State": keys.kXCS }; - token = await this._tokenServerClient.getTokenFromBrowserIDAssertion( - this._tokenServerUrl, - assertion, - headers - ); + token = await this._fetchTokenUsingBrowserID(key); } - this._log.trace("Successfully got a token"); return token; }; try { - let token; - let keys; + let token, key; try { - this._log.info("Getting keys"); - keys = await fxa.keys.getKeys(); // throws if the user changed. - token = await getToken(keys); + this._log.info("Getting sync key"); + key = await fxa.keys.getKeyForScope(SCOPE_OLD_SYNC); + if (!key) { + throw new Error("browser does not have the sync key, cannot sync"); + } + token = await getToken(key); } catch (err) { - // If we get a 401 fetching the token it may be that our certificate - // needs to be regenerated. + // If we get a 401 fetching the token it may be that our auth tokens needed + // to be regenerated; retry exactly once. if (!err.response || err.response.status !== 401) { throw err; } this._log.warn( - "Token server returned 401, refreshing certificate and retrying token fetch" + "Token server returned 401, retrying token fetch with fresh credentials" ); - await fxa._internal.invalidateCertificate(); - keys = await fxa.keys.getKeys(); - token = await getToken(keys); + key = await fxa.keys.getKeyForScope(SCOPE_OLD_SYNC); + token = await getToken(key); } // TODO: Make it be only 80% of the duration, so refresh the token // before it actually expires. This is to avoid sync storage errors @@ -448,7 +448,7 @@ this.BrowserIDManager.prototype = { // (XXX - the above may no longer be true - someone should check ;) token.expiration = this._now() + token.duration * 1000 * 0.8; if (!this._syncKeyBundle) { - this._syncKeyBundle = BulkKeyBundle.fromHexKey(keys.kSync); + this._syncKeyBundle = BulkKeyBundle.fromJWK(key); } Weave.Status.login = LOGIN_SUCCEEDED; this._token = token; @@ -486,30 +486,62 @@ this.BrowserIDManager.prototype = { }, /** - * Fetches an OAuth token using the OLD_SYNC scope and later exchanges it + * Fetches an OAuth token using the OLD_SYNC scope and exchanges it * for a TokenServer token. * * @returns {Promise} * @private */ - async _fetchTokenUsingOAuth() { + async _fetchTokenUsingOAuth(key) { this._log.debug("Getting a token using OAuth"); const fxa = this._fxaService; - const scope = fxAccountsCommon.SCOPE_OLD_SYNC; const ttl = fxAccountsCommon.OAUTH_TOKEN_FOR_SYNC_LIFETIME_SECONDS; - const { token, key } = await fxa.getAccessToken(scope, ttl); + const accessToken = await fxa.getOAuthToken({ scope: SCOPE_OLD_SYNC, ttl }); const headers = { "X-KeyId": key.kid, }; return this._tokenServerClient - .getTokenFromOAuthToken(this._tokenServerUrl, token, headers) + .getTokenFromOAuthToken(this._tokenServerUrl, accessToken, headers) .catch(async err => { - if (err.response || err.response.status === 401) { + if (err.response && err.response.status === 401) { // remove the cached token if we cannot authorize with it. // we have to do this here because we know which `token` to remove // from cache. - await fxa.removeCachedOAuthToken({ token }); + console.log("REMOVE CACHED", accessToken); + await fxa.removeCachedOAuthToken({ token: accessToken }); + } + + // continue the error chain, so other handlers can deal with the error. + throw err; + }); + }, + + /** + * Exchanges a BrowserID assertion for a TokenServer token. + * + * This is a legacy access method that we're in the process of deprecating; + * if you have a choice you should use `_fetchTokenUsingOAuth` above. + * + * @returns {Promise} + * @private + */ + async _fetchTokenUsingBrowserID(key) { + this._log.debug("Getting a token using BrowserID"); + const fxa = this._fxaService; + const audience = Services.io.newURI(this._tokenServerUrl).prePath; + const assertion = await fxa._internal.getAssertion(audience); + const headers = { + "X-Client-State": fxa._internal.keys.kidAsHex(key), + }; + return this._tokenServerClient + .getTokenFromBrowserIDAssertion(this._tokenServerUrl, assertion, headers) + .catch(async err => { + if (err.response && err.response.status === 401) { + this._log.warn( + "Token server returned 401, refreshing certificate and retrying token fetch" + ); + await fxa._internal.invalidateCertificate(); } // continue the error chain, so other handlers can deal with the error. diff --git a/services/sync/modules/keys.js b/services/sync/modules/keys.js index 70b09f7dd850..5dba9c92bdac 100644 --- a/services/sync/modules/keys.js +++ b/services/sync/modules/keys.js @@ -129,6 +129,7 @@ function BulkKeyBundle(collection) { this._collection = collection; } + BulkKeyBundle.fromHexKey = function(hexKey) { let key = CommonUtils.hexToBytes(hexKey); let bundle = new BulkKeyBundle(); @@ -137,6 +138,13 @@ BulkKeyBundle.fromHexKey = function(hexKey) { return bundle; }; +BulkKeyBundle.fromJWK = function(jwk) { + if (!jwk || !jwk.k || jwk.kty !== "oct") { + throw new Error("Invalid JWK provided to BulkKeyBundle.fromJWK"); + } + return BulkKeyBundle.fromHexKey(CommonUtils.base64urlToHex(jwk.k)); +}; + BulkKeyBundle.prototype = { __proto__: KeyBundle.prototype, diff --git a/services/sync/tests/unit/test_browserid_identity.js b/services/sync/tests/unit/test_browserid_identity.js index a9f83dd0cba6..31b035434b17 100644 --- a/services/sync/tests/unit/test_browserid_identity.js +++ b/services/sync/tests/unit/test_browserid_identity.js @@ -41,11 +41,8 @@ const MOCK_SCOPED_KEY = { scope: "https://identity.mozilla.com/apps/oldsync", }; -const MOCK_ACCESS_TOKEN_RESPONSE = { - token: "e3c5caf17f27a0d9e351926a928938b3737df43e91d4992a5a5fca9a7bdef8ba", - key: MOCK_SCOPED_KEY, - scope: "https://identity.mozilla.com/apps/oldsync", -}; +const MOCK_ACCESS_TOKEN = + "e3c5caf17f27a0d9e351926a928938b3737df43e91d4992a5a5fca9a7bdef8ba"; var globalIdentityConfig = makeIdentityConfig(); var globalBrowseridManager = new BrowserIDManager(); @@ -65,6 +62,16 @@ MockFxAccountsClient.prototype = { accountStatus() { return Promise.resolve(true); }, + getScopedKeyData() { + return Promise.resolve({ + "https://identity.mozilla.com/apps/oldsync": { + identifier: "https://identity.mozilla.com/apps/oldsync", + keyRotationSecret: + "0000000000000000000000000000000000000000000000000000000000000000", + keyRotationTimestamp: 1234567890123, + }, + }); + }, }; add_test(function test_initial_state() { @@ -90,8 +97,8 @@ add_task(async function test_initialialize_via_oauth_token() { let fxaInternal = makeFxAccountsInternalMock(identityConfig); configureFxAccountIdentity(browseridManager, identityConfig, fxaInternal); browseridManager._fxaService._internal.initialize(); - browseridManager._fxaService.getAccessToken = () => - Promise.resolve(MOCK_ACCESS_TOKEN_RESPONSE); + browseridManager._fxaService.getOAuthToken = () => + Promise.resolve(MOCK_ACCESS_TOKEN); await browseridManager._ensureValidToken(); Assert.ok(!!browseridManager._token); @@ -108,9 +115,9 @@ add_task(async function test_refreshOAuthTokenOn401() { let fxaInternal = makeFxAccountsInternalMock(identityConfig); configureFxAccountIdentity(browseridManager, identityConfig, fxaInternal); browseridManager._fxaService._internal.initialize(); - browseridManager._fxaService.getAccessToken = () => { + browseridManager._fxaService.getOAuthToken = () => { ++getTokenCount; - return Promise.resolve(MOCK_ACCESS_TOKEN_RESPONSE); + return Promise.resolve(MOCK_ACCESS_TOKEN); }; let didReturn401 = false; @@ -601,6 +608,7 @@ add_task(async function test_getKeysErrorWithBackoff() { let config = makeIdentityConfig(); // We want no kSync, kXCS, kExtSync or kExtKbHash so we attempt to fetch them. + delete config.fxaccount.user.scopedKeys; delete config.fxaccount.user.kSync; delete config.fxaccount.user.kXCS; delete config.fxaccount.user.kExtSync; @@ -643,6 +651,7 @@ add_task(async function test_getKeysErrorWithRetry() { let config = makeIdentityConfig(); // We want no kSync, kXCS, kExtSync or kExtKbHash so we attempt to fetch them. + delete config.fxaccount.user.scopedKeys; delete config.fxaccount.user.kSync; delete config.fxaccount.user.kXCS; delete config.fxaccount.user.kExtSync; @@ -728,6 +737,7 @@ add_task(async function test_getGetKeysFailing401() { _("Arrange for a 401 - Sync should reflect an auth error."); let config = makeIdentityConfig(); // We want no kSync, kXCS, kExtSync or kExtKbHash so we attempt to fetch them. + delete config.fxaccount.user.scopedKeys; delete config.fxaccount.user.kSync; delete config.fxaccount.user.kXCS; delete config.fxaccount.user.kExtSync; @@ -755,6 +765,7 @@ add_task(async function test_getGetKeysFailing503() { _("Arrange for a 503 - Sync should reflect a network error."); let config = makeIdentityConfig(); // We want no kSync, kXCS, kExtSync or kExtKbHash so we attempt to fetch them. + delete config.fxaccount.user.scopedKeys; delete config.fxaccount.user.kSync; delete config.fxaccount.user.kXCS; delete config.fxaccount.user.kExtSync; @@ -782,13 +793,14 @@ add_task(async function test_getGetKeysFailing503() { add_task(async function test_getKeysMissing() { _( - "BrowserIDManager correctly handles getKeys succeeding but not returning keys." + "BrowserIDManager correctly handles getKeyForScope succeeding but not returning the key." ); let browseridManager = new BrowserIDManager(); let identityConfig = makeIdentityConfig(); // our mock identity config already has kSync, kXCS, kExtSync and kExtKbHash - remove them or we never // try and fetch them. + delete identityConfig.fxaccount.user.scopedKeys; delete identityConfig.fxaccount.user.kSync; delete identityConfig.fxaccount.user.kXCS; delete identityConfig.fxaccount.user.kExtSync; @@ -812,30 +824,64 @@ add_task(async function test_getKeysMissing() { }, // And the keys object with a mock that returns no keys. keys: { - fetchAndUnwrapKeys() { - return Promise.resolve({}); + getKeyForScope() { + return Promise.resolve(null); }, }, }); - // Add a mock to the currentAccountState object. - fxa._internal.currentAccountState.getCertificate = function( - data, - keyPair, - mustBeValidUntil - ) { - this.cert = { - validUntil: fxa._internal.now() + CERT_LIFETIME, - cert: "certificate", - }; - return Promise.resolve(this.cert.cert); - }; - browseridManager._fxaService = fxa; await Assert.rejects( browseridManager._ensureValidToken(), - /user data missing: kSync, kXCS, kExtSync, kExtKbHash/ + /browser does not have the sync key, cannot sync/ + ); +}); + +add_task(async function test_getKeysUnexpecedError() { + _( + "BrowserIDManager correctly handles getKeyForScope throwing an unexpected error." + ); + + let browseridManager = new BrowserIDManager(); + let identityConfig = makeIdentityConfig(); + // our mock identity config already has kSync, kXCS, kExtSync and kExtKbHash - remove them or we never + // try and fetch them. + delete identityConfig.fxaccount.user.scopedKeys; + delete identityConfig.fxaccount.user.kSync; + delete identityConfig.fxaccount.user.kXCS; + delete identityConfig.fxaccount.user.kExtSync; + delete identityConfig.fxaccount.user.kExtKbHash; + identityConfig.fxaccount.user.keyFetchToken = "keyFetchToken"; + + configureFxAccountIdentity(browseridManager, identityConfig); + + // Mock a fxAccounts object + let fxa = new FxAccounts({ + fxAccountsClient: new MockFxAccountsClient(), + newAccountState(credentials) { + // We only expect this to be called with null indicating the (mock) + // storage should be read. + if (credentials) { + throw new Error("Not expecting to have credentials passed"); + } + let storageManager = new MockFxaStorageManager(); + storageManager.initialize(identityConfig.fxaccount.user); + return new AccountState(storageManager); + }, + // And the keys object with a mock that returns no keys. + keys: { + async getKeyForScope() { + throw new Error("well that was unexpected"); + }, + }, + }); + + browseridManager._fxaService = fxa; + + await Assert.rejects( + browseridManager._ensureValidToken(), + /well that was unexpected/ ); }); @@ -846,6 +892,7 @@ add_task(async function test_signedInUserMissing() { let browseridManager = new BrowserIDManager(); // Delete stored keys and the key fetch token. + delete globalIdentityConfig.fxaccount.user.scopedKeys; delete globalIdentityConfig.fxaccount.user.kSync; delete globalIdentityConfig.fxaccount.user.kXCS; delete globalIdentityConfig.fxaccount.user.kExtSync; diff --git a/services/sync/tests/unit/test_syncscheduler.js b/services/sync/tests/unit/test_syncscheduler.js index f53d4cf831a6..fd5460b00022 100644 --- a/services/sync/tests/unit/test_syncscheduler.js +++ b/services/sync/tests/unit/test_syncscheduler.js @@ -622,7 +622,7 @@ add_task(async function test_autoconnect_mp_locked() { }, }, keys: { - canGetKeys() { + canGetKeyForScope() { return false; }, }, diff --git a/toolkit/components/extensions/ExtensionStorageSyncKinto.jsm b/toolkit/components/extensions/ExtensionStorageSyncKinto.jsm index b1a5aedb415b..71bdb34c86bb 100644 --- a/toolkit/components/extensions/ExtensionStorageSyncKinto.jsm +++ b/toolkit/components/extensions/ExtensionStorageSyncKinto.jsm @@ -143,7 +143,8 @@ function ciphertextHMAC(keyBundle, id, IV, ciphertext) { * @returns {string} sha256 of the user's kB as a hex string */ const getKBHash = async function(fxaService) { - return (await fxaService.keys.getKeys()).kExtKbHash; + const key = await fxaService.keys.getKeyForScope(STORAGE_SYNC_SCOPE); + return fxaService.keys.kidAsHex(key); }; /** @@ -272,12 +273,8 @@ class KeyRingEncryptionRemoteTransformer extends EncryptionRemoteTransformer { throwIfNoFxA(this._fxaService, "encrypting chrome.storage.sync records"); const self = this; return (async function() { - let keys = await self._fxaService.keys.getKeys(); - if (!keys.kExtSync) { - throw new Error("user doesn't have kExtSync"); - } - - return BulkKeyBundle.fromHexKey(keys.kExtSync); + let key = await self._fxaService.keys.getKeyForScope(STORAGE_SYNC_SCOPE); + return BulkKeyBundle.fromJWK(key); })(); } // Pass through the kbHash field from the unencrypted record. If diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_storage_sync_kinto.js b/toolkit/components/extensions/test/xpcshell/test_ext_storage_sync_kinto.js index 95978b6413b6..db7091db8d41 100644 --- a/toolkit/components/extensions/test/xpcshell/test_ext_storage_sync_kinto.js +++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_sync_kinto.js @@ -27,6 +27,9 @@ const { const { BulkKeyBundle } = ChromeUtils.import( "resource://services-sync/keys.js" ); +const { FxAccountsKeys } = ChromeUtils.import( + "resource://gre/modules/FxAccountsKeys.jsm" +); const { Utils } = ChromeUtils.import("resource://services-sync/util.js"); const { createAppInfo, promiseStartupManager } = AddonTestUtils; @@ -500,11 +503,11 @@ async function withSignedInUser(user, f) { return Promise.resolve(); }, keys: { - getKeys() { - return Promise.resolve({ - kExtSync: user.kExtSync, - kExtKbHash: user.kExtKbHash, - }); + getKeyForScope(scope) { + return Promise.resolve({ ...user.scopedKeys[scope] }); + }, + kidAsHex(jwk) { + return new FxAccountsKeys({}).kidAsHex(jwk); }, }, }; @@ -628,16 +631,18 @@ const assertExtensionRecord = async function(fxaService, post, extension, key) { const defaultExtensionId = "{13bdde76-4dc7-11e6-9bdc-54ee758d6342}"; const defaultExtension = { id: defaultExtensionId }; -const kExtSync = - "63f9057577c04bbbb9f0c3fd85b5d4032b60e13edc1f8dd309bf4305d66f2cc312dde16ce46021a496f713950d0a6c566ce181521a44726e7be97cf577b31b31"; -const KB_HASH = - "2350cba8fced5a2fbae3b1f180baf860f78f6542bef7be709fda96cd3e3dc800"; const loggedInUser = { uid: "0123456789abcdef0123456789abcdef", - kExtSync, - kExtKbHash: KB_HASH, + scopedKeys: { + "sync:addon_storage": { + kid: "1234567890123-I1DLqPztWi-647HxgLr4YPePZUK-975wn9qWzT49yAA", + k: + "Y_kFdXfAS7u58MP9hbXUAytg4T7cH43TCb9DBdZvLMMS3eFs5GAhpJb3E5UNCmxWbOGBUhpEcm576Xz1d7MbMQ", + kty: "oct", + }, + }, oauthTokens: { - "sync:addon-storage": { + "sync:addon_storage": { token: "some-access-token", }, }, @@ -1316,14 +1321,16 @@ add_task(async function checkSyncKeyRing_reuploads_keys() { }); // The user changes their password. This is their new kbHash, with - // the last 0 changed to a 1. - const NEW_KB_HASH = - "2350cba8fced5a2fbae3b1f180baf860f78f6542bef7be709fda96cd3e3dc801"; - const NEW_KEXT = - "63f9057577c04bbbb9f0c3fd85b5d4032b60e13edc1f8dd309bf4305d66f2cc312dde16ce46021a496f713950d0a6c566ce181521a44726e7be97cf577b31b30"; + // the last character changed. const newUser = Object.assign({}, loggedInUser, { - kExtKbHash: NEW_KB_HASH, - kExtSync: NEW_KEXT, + scopedKeys: { + "sync:addon_storage": { + kid: "1234567890123-I1DLqPztWi-647HxgLr4YPePZUK-975wn9qWzT49yAE", + k: + "Y_kFdXfAS7u58MP9hbXUAytg4T7cH43TCb9DBdZvLMMS3eFs5GAhpJb3E5UNCmxWbOGBUhpEcm576Xz1d7MbMA", + kty: "oct", + }, + }, }); let postedKeys; await withSignedInUser(newUser, async function( @@ -1386,14 +1393,16 @@ add_task(async function checkSyncKeyRing_overwrites_on_conflict() { await withSyncContext(async function(context) { await withServer(async function(server) { // The old device has this kbHash, which is very similar to the - // current kbHash but with the last 0 changed to a 1. - const NEW_KB_HASH = - "2350cba8fced5a2fbae3b1f180baf860f78f6542bef7be709fda96cd3e3dc801"; - const NEW_KEXT = - "63f9057577c04bbbb9f0c3fd85b5d4032b60e13edc1f8dd309bf4305d66f2cc312dde16ce46021a496f713950d0a6c566ce181521a44726e7be97cf577b31b30"; + // current kbHash but with the last character changed. const oldUser = Object.assign({}, loggedInUser, { - kExtKbHash: NEW_KB_HASH, - kExtSync: NEW_KEXT, + scopedKeys: { + "sync:addon_storage": { + kid: "1234567890123-I1DLqPztWi-647HxgLr4YPePZUK-975wn9qWzT49yAE", + k: + "Y_kFdXfAS7u58MP9hbXUAytg4T7cH43TCb9DBdZvLMMS3eFs5GAhpJb3E5UNCmxWbOGBUhpEcm576Xz1d7MbMA", + kty: "oct", + }, + }, }); server.installDeleteBucket(); await withSignedInUser(oldUser, async function(