Bug 1661407 - refactor FxA key handling to use "scoped keys". r=markh

Differential Revision: https://phabricator.services.mozilla.com/D90361
This commit is contained in:
Ryan Kelly 2020-10-01 10:06:24 +00:00
parent 05303de70a
commit 08d2c540ec
20 changed files with 1075 additions and 599 deletions

View File

@ -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
*/

View File

@ -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>} 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.<string | Error>
@ -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>} 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) {

View File

@ -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,

View File

@ -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",

View File

@ -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) {

View File

@ -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<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 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<HKDF(kB, undefined, "identity.mozilla.com/picl/v1/oldsync", 64)>
*/
_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<SHA256(kB)[:16]>
*/
async _deriveXClientState(kBbytes) {
return this._sha256(kBbytes).slice(0, 16);
}
/**
* Derive the WebExtensions Sync Storage Key given the byte string kB.
*
* @returns Promise<HKDF(kB, undefined, "identity.mozilla.com/picl/v1/chrome.storage.sync", 64)>
*/
_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<SHA256(uid + kB)>
*/
_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

View File

@ -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>} 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"];

View File

@ -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();
},

View File

@ -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();

View File

@ -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;

View File

@ -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,
};
}

View File

@ -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() {

View File

@ -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() {

View File

@ -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,

View File

@ -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.

View File

@ -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,

View File

@ -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;

View File

@ -622,7 +622,7 @@ add_task(async function test_autoconnect_mp_locked() {
},
},
keys: {
canGetKeys() {
canGetKeyForScope() {
return false;
},
},

View File

@ -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

View File

@ -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(