Bug 1697596 - Remove ecosystem_anon_id from FxA client code. r=markh

Differential Revision: https://phabricator.services.mozilla.com/D108389
This commit is contained in:
Ryan Kelly 2021-03-17 00:27:32 +00:00
Родитель ec7f538e0c
Коммит b7f213243a
9 изменённых файлов: 81 добавлений и 814 удалений

Просмотреть файл

@ -763,14 +763,6 @@ FxAccountsInternal.prototype = {
ChromeUtils.import("resource://services-sync/main.js", scope);
return scope.Weave.Service.promiseInitialized;
},
// Telemetry, so ecosystem telemetry doesn't miss logouts.
async () => {
const { EcosystemTelemetry } = ChromeUtils.import(
"resource://gre/modules/EcosystemTelemetry.jsm",
{}
);
await EcosystemTelemetry.prepareForFxANotification();
},
];
}

Просмотреть файл

@ -113,12 +113,17 @@ 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";
exports.SCOPE_ECOSYSTEM_TELEMETRY =
"https://identity.mozilla.com/ids/ecosystem_telemetry";
// 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";
// This scope was previously used to calculate a telemetry tracking identifier for
// the account, but that system has since been decommissioned. It's here entirely
// so that we can remove the corresponding key from storage if present. We should
// be safe to remove it after some sensible period of time has elapsed to allow
// most clients to update; ref Bug 1697596.
exports.DEPRECATED_SCOPE_ECOSYSTEM_TELEMETRY =
"https://identity.mozilla.com/ids/ecosystem_telemetry";
// OAuth metadata for other Firefox-related services that we might need to know about
// in order to provide an enhanced user experience.
@ -293,8 +298,6 @@ exports.FXA_PWDMGR_PLAINTEXT_FIELDS = new Set([
"authAt",
"sessionToken",
"uid",
"ecosystemAnonId",
"ecosystemUserId",
"oauthTokens",
"profile",
"device",

Просмотреть файл

@ -17,8 +17,8 @@ const { CryptoUtils } = ChromeUtils.import(
const {
LEGACY_DERIVED_KEYS_NAMES,
SCOPE_OLD_SYNC,
SCOPE_ECOSYSTEM_TELEMETRY,
LEGACY_SCOPE_WEBEXT_SYNC,
DEPRECATED_SCOPE_ECOSYSTEM_TELEMETRY,
FX_OAUTH_CLIENT_ID,
log,
logPII,
@ -29,6 +29,10 @@ const {
// these scopes.
const LEGACY_DERIVED_KEY_SCOPES = [SCOPE_OLD_SYNC, LEGACY_SCOPE_WEBEXT_SYNC];
// These are scopes that we used to store, but are no longer using,
// and hence should be deleted from storage if present.
const DEPRECATED_KEY_SCOPES = [DEPRECATED_SCOPE_ECOSYSTEM_TELEMETRY];
/**
* Utilities for working with key material linked to the user's account.
*
@ -184,7 +188,6 @@ class FxAccountsKeys {
* 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
* }
* @throws If there is no user signed in.
@ -201,6 +204,9 @@ class FxAccountsKeys {
if (
LEGACY_DERIVED_KEY_SCOPES.every(scope =>
userData.scopedKeys.hasOwnProperty(scope)
) &&
!DEPRECATED_KEY_SCOPES.some(scope =>
userData.scopedKeys.hasOwnProperty(scope)
)
) {
return userData;
@ -237,6 +243,30 @@ class FxAccountsKeys {
*
*/
async _migrateOrFetchKeys(currentState, userData) {
// Bug 1697596 - delete any deprecated scoped keys from storage.
// If any of the deprecated keys are present, then we know that we've
// previously applied all the other migrations below, otherwise there
// would not be any `scopedKeys` field.
if (userData.scopedKeys) {
const toRemove = DEPRECATED_KEY_SCOPES.filter(scope =>
userData.scopedKeys.hasOwnProperty(scope)
);
if (toRemove.length > 0) {
for (const scope of toRemove) {
delete userData.scopedKeys[scope];
}
await currentState.updateUserAccountData({
scopedKeys: userData.scopedKeys,
// Prior to deprecating SCOPE_ECOSYSTEM_TELEMETRY, this file had some
// special code to store it as a top-level user data field. So, this
// file also gets to delete it as part of the deprecation.
ecosystemUserId: null,
ecosystemAnonId: null,
});
userData = await currentState.getUserAccountData();
return 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 (
@ -290,7 +320,7 @@ class FxAccountsKeys {
/**
* 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
* Once the user's email is verified, we can resquest the root key `kB` from the
* FxA server, unwrap it using the client-side secret `unwrapBKey`, and then
* derive all the sub-keys required for operation of the browser.
*/
@ -394,7 +424,7 @@ class FxAccountsKeys {
// Hard-coded list of scopes that we know about.
// This list will probably grow in future.
// Note that LEGACY_SCOPE_WEBEXT_SYNC is not in this list, it gets special-case handling below.
const scopes = [SCOPE_OLD_SYNC, SCOPE_ECOSYSTEM_TELEMETRY].join(" ");
const scopes = [SCOPE_OLD_SYNC].join(" ");
const scopedKeysMetadata = await this._fxai.fxAccountsClient.getScopedKeyData(
sessionToken,
FX_OAUTH_CLIENT_ID,
@ -455,12 +485,6 @@ class FxAccountsKeys {
kExtKbHash: scopedKeys[LEGACY_SCOPE_WEBEXT_SYNC]
? this.kidAsHex(scopedKeys[LEGACY_SCOPE_WEBEXT_SYNC])
: CommonUtils.bytesAsHex(await this._deriveWebExtKbHash(uid, kBbytes)),
// The `ecosystemUserId` is derived from `kB` but is used more like an identifier
// than a key. We unpack it into the top-level user account data so that it can be
// stored in plaintext storage, and used for telemetry even when passwords are locked.
ecosystemUserId: scopedKeys[SCOPE_ECOSYSTEM_TELEMETRY]
? CommonUtils.base64urlToHex(scopedKeys[SCOPE_ECOSYSTEM_TELEMETRY].k)
: null,
};
}

Просмотреть файл

@ -232,27 +232,6 @@ FxAccountsProfileClient.prototype = {
log.debug("FxAccountsProfileClient: Requested profile");
return this._createRequest("/profile", "GET", etag);
},
/**
* Write an ecosystemAnonId value to the user's profile data on the server.
*
* This should be used only if the user's profile data does not already contain an
* ecosytemAnonId field, and it will reject with a "412 Precondition Failed" if there
* is one already present on the server.
*
* @param {String} [ecosystemAnonId]
* The generated ecosystemAnonId value to store on the server.
* @return Promise
* Resolves: {body: Object} Successful response from the '/ecosystem_anon_id' endpoint.
* Rejects: {FxAccountsProfileClientError} profile client error.
*/
setEcosystemAnonId(ecosystemAnonId) {
log.debug("FxAccountsProfileClient: Setting ecosystemAnonId");
// This uses `If-None-Match: "*"` to prevent two concurrent clients from setting a value.
return this._createRequest("/ecosystem_anon_id", "POST", "*", {
ecosystemAnonId,
});
},
};
/**

Просмотреть файл

@ -18,10 +18,7 @@ XPCOMUtils.defineLazyModuleGetters(this, {
// "subject" data.
Observers: "resource://services-common/observers.js",
Services: "resource://gre/modules/Services.jsm",
CommonUtils: "resource://services-common/utils.js",
CryptoUtils: "resource://services-crypto/utils.js",
FxAccountsConfig: "resource://gre/modules/FxAccountsConfig.jsm",
jwcrypto: "resource://services-crypto/jwcrypto.jsm",
});
const { PREF_ACCOUNT_ROOT, log } = ChromeUtils.import(
@ -40,7 +37,6 @@ class FxAccountsTelemetry {
constructor(fxai) {
this._fxai = fxai;
Services.telemetry.setEventRecordingEnabled("fxa", true);
this._promiseEnsureEcosystemAnonId = null;
}
// Records an event *in the Fxa/Sync ping*.
@ -64,184 +60,9 @@ class FxAccountsTelemetry {
return this.generateUUID();
}
// Account Ecosystem Telemetry identifies the user by a secret id called their "ecosystemUserId".
// To maintain user privacy this value must never be shared with Mozilla servers in plaintext
// (although there may be some client-side-only features that use it in future).
//
// Instead, AET-related telemetry pings can identify the user by their "ecosystemAnonId",
// an encrypted bundle that can communicate the "ecosystemUserId" through to the telemetry
// backend without allowing it to be snooped on in transit.
//
// Thanks to the way this encryption works, it's possible for each signed-in client to have the same
// userid but a *different* value for ecosystemAnonId. This may offer some incremental privacy benefits
// for the un-processed data, and we can rely on the values all decrypting back to the same ecosystemUserId
// value during processing.
//
// Thus, the code below will try to generate its own unique ecosystemAnonId value if possible, but
// will fall back to using a shared value provided by the FxA server if not.
// Get the user's ecosystemAnonId, or null if it's not available.
//
// This method is asynchronous because it may need to load data from storage, but it will not
// block on network access and will return null rather than throwing an error on failure. This is
// designed to simplify usage from telemetry-sending code, which may want to avoid making expensive
// network requests.
//
// If you want to ensure that a value is present then use `ensureEcosystemAnonId()` instead.
async getEcosystemAnonId() {
return this._fxai.withCurrentAccountState(async state => {
// If we know the ecosystemUserId, we generate and store our own unique ecosystemAnonId value.
// Otherwise, we may be able to use a shared value from the user's profile data.
let {
ecosystemAnonId,
ecosystemUserId,
} = await state.getUserAccountData([
"ecosystemAnonId",
"ecosystemUserId",
]);
// N.B. We should never have `ecosystemAnonId` without `ecosystemUserId`.
if (!ecosystemUserId) {
try {
// N.B. `getProfile()` may kick off a silent background update but won't await network requests.
const profile = await this._fxai.profile.getProfile();
if (profile && profile.hasOwnProperty("ecosystemAnonId")) {
ecosystemAnonId = profile.ecosystemAnonId;
}
} catch (err) {
log.error("Getting ecosystemAnonId from profile failed", err);
}
}
// If we don't have ecosystemAnonId, call `ensureEcosystemAnonId()` to fetch or generate it in
// the background, so the calling code doesn't have to do this for itself.
// (ie, so that the next call to `getEcosystemAnonId() will return it)
if (!ecosystemAnonId) {
// N.B. deliberately not awaiting the promise here.
this.ensureEcosystemAnonId().catch(err => {
log.error(
"Failed ensuring we have an anon-id in the background ",
err
);
});
}
return ecosystemAnonId || null;
});
}
// Get the user's ecosystemAnonId, fetching it from the server if necessary.
//
// This asynchronous method resolves with the "ecosystemAnonId" value on success, and rejects
// with an error if no user is signed in or if the value could not be obtained from the
// FxA server.
//
// If a call to this is already in-flight, the promise from that original
// call is returned.
async ensureEcosystemAnonId() {
if (!this._promiseEnsureEcosystemAnonId) {
this._promiseEnsureEcosystemAnonId = this._ensureEcosystemAnonId().finally(
() => {
this._promiseEnsureEcosystemAnonId = null;
}
);
}
return this._promiseEnsureEcosystemAnonId;
}
async _ensureEcosystemAnonId() {
return this._fxai.withCurrentAccountState(async state => {
// If we know the ecosystemUserId, we generate and store our own unique ecosystemAnonId value.
// Otherwise, we need to work with a shared value stored in the user's profile.
let {
ecosystemAnonId,
ecosystemUserId,
} = await state.getUserAccountData([
"ecosystemAnonId",
"ecosystemUserId",
]);
if (ecosystemUserId) {
if (!ecosystemAnonId) {
ecosystemAnonId = await this._generateAnonIdFromUserId(
ecosystemUserId
);
await state.updateUserAccountData({ ecosystemAnonId });
}
} else {
ecosystemAnonId = await this._ensureEcosystemAnonIdInProfile();
}
return ecosystemAnonId;
});
}
// Ensure that we have an ecosystemAnonId obtained from account profile data.
//
// This is a bootstrapping mechanism for clients that are already connected to
// the user's account, to obtain ecosystemAnonId from shared profile data rather
// than from derived key material.
//
async _ensureEcosystemAnonIdInProfile(generatePlaceholder = true) {
// Fetching a fresh profile should never *change* the ID, but it might
// fetch the first value we see, and saving a network request matters for
// telemetry, so:
// * first time around we are fine with a slightly stale profile - if it
// has an ID, it's a stable ID we can be sure is good.
// * But if we didn't have one, so generated a new one, but then raced
// with another client to update it, we *must* fetch a new profile, even
// if our current version is fresh.
let options = generatePlaceholder
? { staleOk: true }
: { forceFresh: true };
const profile = await this._fxai.profile.ensureProfile(options);
if (profile && profile.hasOwnProperty("ecosystemAnonId")) {
return profile.ecosystemAnonId;
}
if (!generatePlaceholder) {
throw new Error("Profile data does not contain an 'ecosystemAnonId'");
}
// If the server doesn't have ecosystemAnonId yet then we can fill it in
// with a randomly-generated placeholder.
const ecosystemUserId = CommonUtils.bufferToHex(
CryptoUtils.generateRandomBytes(32)
);
const ecosystemAnonId = await this._generateAnonIdFromUserId(
ecosystemUserId
);
// Persist the encrypted value to the server so other clients can find it.
try {
await this._fxai.profile.client.setEcosystemAnonId(ecosystemAnonId);
} catch (err) {
if (err && err.code && err.code === 412) {
// Another client raced us to upload the placeholder, fetch it.
return this._ensureEcosystemAnonIdInProfile(false);
}
throw err;
}
return ecosystemAnonId;
}
// Generate an ecosystemAnonId value from the given ecosystemUserId.
//
// To do so, we must fetch the AET public keys from the server, and encrypt
// ecosystemUserId into a JWE using one of those keys.
//
async _generateAnonIdFromUserId(ecosystemUserId) {
const serverConfig = await FxAccountsConfig.fetchConfigDocument();
const ecosystemKeys = serverConfig.ecosystem_anon_id_keys;
if (!ecosystemKeys || !ecosystemKeys.length) {
throw new Error("Unable to fetch ecosystem_anon_id_keys from FxA server");
}
const randomKey = Math.floor(
Math.random() * Math.floor(ecosystemKeys.length)
);
return jwcrypto.generateJWE(
ecosystemKeys[randomKey],
new TextEncoder().encode(ecosystemUserId)
);
}
// Prior to Account Ecosystem Telemetry, FxA- and Sync-related metrics were submitted in
// a special-purpose "sync ping". This ping identified the user by a version of their FxA
// uid that was HMAC-ed with a server-side secret key, but this approach provides weaker
// privacy than "ecosystemAnonId" above. New metrics should prefer to use AET rather than
// the sync ping.
// FxA- and Sync-related metrics are submitted in a special-purpose "sync ping". This ping
// identifies the user by a version of their FxA uid that is HMAC-ed with a server-side secret
// key, in an attempt to provide a bit of anonymity.
// Secret back-channel by which tokenserver client code can set the hashed UID.
// This value conceptually belongs to FxA, but we currently get it from tokenserver,

Просмотреть файл

@ -11,11 +11,9 @@ const { XPCOMUtils } = ChromeUtils.import(
"resource://gre/modules/XPCOMUtils.jsm"
);
const { sinon } = ChromeUtils.import("resource://testing-common/Sinon.jsm");
const {
SCOPE_OLD_SYNC,
SCOPE_ECOSYSTEM_TELEMETRY,
LEGACY_SCOPE_WEBEXT_SYNC,
} = ChromeUtils.import("resource://gre/modules/FxAccountsCommon.js");
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 = {
@ -26,11 +24,6 @@ const MOCK_ACCOUNT_KEYS = {
"qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqg",
kty: "oct",
},
[SCOPE_ECOSYSTEM_TELEMETRY]: {
kid: "1234567890123-7u7u7u7u7u7u7u7u7u7u7g",
k: "7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u4",
kty: "oct",
},
[LEGACY_SCOPE_WEBEXT_SYNC]: {
kid: "1234567890123-3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d0",
k:
@ -45,8 +38,6 @@ const MOCK_ACCOUNT_KEYS = {
"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
kExtKbHash:
"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd ",
ecosystemUserId:
"eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
};
(function initFxAccountsTestingInfrastructure() {

Просмотреть файл

@ -20,6 +20,7 @@ const {
ONLOGIN_NOTIFICATION,
ONLOGOUT_NOTIFICATION,
ONVERIFIED_NOTIFICATION,
DEPRECATED_SCOPE_ECOSYSTEM_TELEMETRY,
} = ChromeUtils.import("resource://gre/modules/FxAccountsCommon.js");
const { PromiseUtils } = ChromeUtils.import(
"resource://gre/modules/PromiseUtils.jsm"
@ -156,7 +157,7 @@ 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 + " " + SCOPE_ECOSYSTEM_TELEMETRY);
Assert.equal(scopes, SCOPE_OLD_SYNC);
return new Promise(resolve => {
do_timeout(50, () => {
resolve({
@ -166,12 +167,6 @@ function MockFxAccountsClient() {
"0000000000000000000000000000000000000000000000000000000000000000",
keyRotationTimestamp: 1234567890123,
},
"https://identity.mozilla.com/ids/ecosystem_telemetry": {
identifier: "https://identity.mozilla.com/ids/ecosystem_telemetry",
keyRotationSecret:
"0000000000000000000000000000000000000000000000000000000000000000",
keyRotationTimestamp: 1234567890123,
},
});
});
});
@ -828,7 +823,6 @@ add_test(function test_getKeyForScope() {
Assert.equal(!!user2.kXCS, false);
Assert.equal(!!user2.kExtSync, false);
Assert.equal(!!user2.kExtKbHash, false);
Assert.equal(!!user2.ecosystemUserId, false);
// And we still have a key-fetch token and unwrapBKey to use
Assert.equal(!!user2.keyFetchToken, true);
Assert.equal(!!user2.unwrapBKey, true);
@ -843,7 +837,6 @@ add_test(function test_getKeyForScope() {
Assert.notEqual(null, user3.kXCS);
Assert.notEqual(null, user3.kExtSync);
Assert.notEqual(null, user3.kExtKbHash);
Assert.notEqual(null, user3.ecosystemUserId);
Assert.equal(user3.keyFetchToken, undefined);
Assert.equal(user3.unwrapBKey, undefined);
run_next_test();
@ -874,11 +867,6 @@ add_task(async function test_getKeyForScope_kb_migration() {
"DW_ll5GwX6SJ5GPqJVAuMUP2t6kDqhUulc2cbt26xbTcaKGQl-9l29FHAQ7kUiJETma4s9fIpEHrt909zgFang",
kty: "oct",
},
"https://identity.mozilla.com/ids/ecosystem_telemetry": {
kid: "1234567890-ruhbB-qilFS-9bwxlCe4Qw",
k: "niMTzlPWb01A2nkO4SkEAUalO7FiQ61yq69X6b8V08Y",
kty: "oct",
},
"sync:addon_storage": {
kid: "1234567890123-pBOR6B6JulbJr3BxKVOqIU4Cq_WAjFp4ApLn5NRVARE",
k:
@ -902,10 +890,6 @@ add_task(async function test_getKeyForScope_kb_migration() {
newUser.kExtKbHash,
"a41391e81e89ba56c9af70712953aa214e02abf5808c5a780292e7e4d4550111"
);
Assert.equal(
newUser.ecosystemUserId,
"9e2313ce53d66f4d40da790ee129040146a53bb16243ad72abaf57e9bf15d3c6"
);
});
add_task(async function test_getKeyForScope_scopedKeys_migration() {
@ -918,7 +902,6 @@ add_task(async function test_getKeyForScope_scopedKeys_migration() {
user.kXCS = MOCK_ACCOUNT_KEYS.kXCS;
user.kExtSync = MOCK_ACCOUNT_KEYS.kExtSync;
user.kExtKbHash = MOCK_ACCOUNT_KEYS.kExtKbHash;
Assert.equal(user.ecosystemUserId, null);
Assert.equal(user.scopedKeys, null);
await fxa.setSignedInUser(user);
@ -926,19 +909,47 @@ add_task(async function test_getKeyForScope_scopedKeys_migration() {
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,
// but failed to magic the ecosystem-telemetry key out of nowhere.
// It should have correctly formatted the corresponding scoped keys.
const expectedScopedKeys = { ...MOCK_ACCOUNT_KEYS.scopedKeys };
delete expectedScopedKeys[SCOPE_ECOSYSTEM_TELEMETRY];
Assert.deepEqual(newUser.scopedKeys, expectedScopedKeys);
// 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);
Assert.equal(user.ecosystemUserId, null);
});
add_task(
async function test_getKeyForScope_scopedKeys_migration_removes_deprecated_scoped_keys() {
let fxa = new MockFxAccounts();
let user = getTestUser("eusebius");
const EXTRA_SCOPE = "an unknown, but non-deprecated scope";
user.verified = true;
user.ecosystemUserId = "ecoUserId";
user.ecosystemAnonId = "ecoAnonId";
user.scopedKeys = {
...MOCK_ACCOUNT_KEYS.scopedKeys,
[DEPRECATED_SCOPE_ECOSYSTEM_TELEMETRY]:
MOCK_ACCOUNT_KEYS.scopedKeys[SCOPE_OLD_SYNC],
[EXTRA_SCOPE]: MOCK_ACCOUNT_KEYS.scopedKeys[SCOPE_OLD_SYNC],
};
await fxa.setSignedInUser(user);
await fxa.keys.getKeyForScope(SCOPE_OLD_SYNC);
let newUser = await fxa._internal.getUserAccountData();
// It should have removed the deprecated ecosystem_telemetry key,
// but left the other keys intact.
const expectedScopedKeys = {
...MOCK_ACCOUNT_KEYS.scopedKeys,
[EXTRA_SCOPE]: MOCK_ACCOUNT_KEYS.scopedKeys[SCOPE_OLD_SYNC],
};
Assert.deepEqual(newUser.scopedKeys, expectedScopedKeys);
Assert.equal(newUser.ecosystemUserId, null);
Assert.equal(newUser.ecosystemAnonId, null);
}
);
add_task(async function test_getKeyForScope_nonexistent_account() {
let fxa = new MockFxAccounts();
let bismarck = getTestUser("bismarck");

Просмотреть файл

@ -185,68 +185,3 @@ add_task(async function test_rejects_bad_scoped_key_data() {
/keyRotationSecret must be a 64-character hex string/
);
});
add_task(
async function test_graceful_handling_of_missing_ecosystem_telemetry_scope() {
const mockClient = {
getScopedKeyData: sinon
.mock()
.once()
.withExactArgs(
"session-token",
FX_OAUTH_CLIENT_ID,
SCOPE_OLD_SYNC + " " + SCOPE_ECOSYSTEM_TELEMETRY
)
.returns(
Promise.resolve({
// N.B. no SCOPE_ECOSYSTEM_TELEMETRY entry.
[SCOPE_OLD_SYNC]: {
identifier: SCOPE_OLD_SYNC,
keyRotationTimestamp: 1234567890000,
keyRotationSecret:
"0000000000000000000000000000000000000000000000000000000000000000",
},
})
),
accountKeys: sinon
.mock()
.once()
.withExactArgs("key-fetch-token")
.returns({
wrapKB: "00000000000000000000000000000000",
}),
};
const mockState = {
getUserAccountData: async () => {
return {
uid: "aeaa1725c7a24ff983c6295725d5fc9b",
sessionToken: "session-token",
keyFetchToken: "key-fetch-token",
unwrapBKey:
"1111111111111111111111111111111111111111111111111111111111111111",
};
},
updateUserAccountData: sinon.spy(async data => {
return data;
}),
};
const keys = new FxAccountsKeys({ fxAccountsClient: mockClient });
await keys._fetchAndUnwrapAndDeriveKeys(
mockState,
"session-token",
"key-fetch-token"
);
Assert.ok(mockState.updateUserAccountData.calledOnce);
const userData = mockState.updateUserAccountData.firstCall.args[0];
Assert.ok(userData.kSync);
Assert.ok(userData.scopedKeys[SCOPE_OLD_SYNC]);
Assert.equal(userData.ecosystemUserId, null);
Assert.equal(userData.scopedKeys[SCOPE_ECOSYSTEM_TELEMETRY], null);
mockClient.getScopedKeyData.verify();
mockClient.accountKeys.verify();
}
);

Просмотреть файл

@ -72,492 +72,3 @@ add_task(function test_sanitize_device_id() {
fxAccounts.telemetry._setHashedUID("");
Assert.equal(fxAccounts.telemetry.sanitizeDeviceId(MOCK_DEVICE_ID), null);
});
add_task(async function test_getEcosystemAnonId() {
const ecosystemAnonId = "aaaaaaaaaaaaaaa";
const testCases = [
{
// testing retrieving the ecosystemAnonId from account state
throw: false,
accountStateObj: { ecosystemAnonId, ecosystemUserId: "eco-uid" },
profileObj: { ecosystemAnonId: "bbbbbbbbbbbbbb" },
expectedEcosystemAnonId: ecosystemAnonId,
},
{
// testing retrieving the ecosystemAnonId when the profile contains it
throw: false,
accountStateObj: {},
profileObj: { ecosystemAnonId },
expectedEcosystemAnonId: ecosystemAnonId,
},
{
// testing retrieving the ecosystemAnonId when the profile doesn't contain it
throw: false,
accountStateObj: {},
profileObj: {},
expectedEcosystemAnonId: null,
},
{
// testing retrieving the ecosystemAnonId when the profile is null
throw: true,
accountStateObj: {},
profileObj: null,
expectedEcosystemAnonId: null,
},
];
for (const tc of testCases) {
const profile = new FxAccountsProfile({
profileServerUrl: "http://testURL",
});
const telemetry = new FxAccountsTelemetry({
profile,
withCurrentAccountState: async cb => {
return cb({
getUserAccountData: async () => {
return { ...tc.accountStateObj };
},
});
},
});
const mockProfile = sinon.mock(profile);
const mockTelemetry = sinon.mock(telemetry);
if (!tc.accountStateObj.ecosystemUserId) {
if (tc.throw) {
mockProfile
.expects("getProfile")
.once()
.throws(Error);
} else {
mockProfile
.expects("getProfile")
.once()
.returns(tc.profileObj);
}
}
if (tc.expectedEcosystemAnonId) {
mockTelemetry.expects("ensureEcosystemAnonId").never();
} else {
mockTelemetry
.expects("ensureEcosystemAnonId")
.once()
.resolves("dddddddddd");
}
const actualEcoSystemAnonId = await telemetry.getEcosystemAnonId();
mockProfile.verify();
mockTelemetry.verify();
Assert.equal(actualEcoSystemAnonId, tc.expectedEcosystemAnonId);
}
});
add_task(async function test_ensureEcosystemAnonId_useAnonIdFromAccountState() {
// If there's an eco-uid and anon-id in the account state,
// we should use them without attempting any other updates.
const expectedEcosystemAnonId = "account-state-anon-id";
const telemetry = new FxAccountsTelemetry({
withCurrentAccountState: async cb => {
return cb({
getUserAccountData: async () => {
return {
ecosystemAnonId: expectedEcosystemAnonId,
ecosystemUserId: "account-state-eco-uid",
};
},
});
},
});
const actualEcoSystemAnonId = await telemetry.ensureEcosystemAnonId();
Assert.equal(actualEcoSystemAnonId, expectedEcosystemAnonId);
});
add_task(async function test_ensureEcosystemAnonId_useUserIdFromAccountState() {
// If there's an eco-uid in the account state but not anon-id,
// we should generate and save our own unique anon-id.
const expectedEcosystemUserId = "02".repeat(32);
const expectedEcosystemAnonId = "bbbbbbbbbbbb";
const mockedUpdate = sinon
.mock()
.once()
.withExactArgs({
ecosystemAnonId: expectedEcosystemAnonId,
});
const telemetry = new FxAccountsTelemetry({
withCurrentAccountState: async cb => {
return cb({
getUserAccountData: async () => {
return {
// Note: no ecosystemAnonId field here.
ecosystemUserId: expectedEcosystemUserId,
};
},
updateUserAccountData: mockedUpdate,
});
},
});
const mockFxAccountsConfig = sinon.mock(FxAccountsConfig);
const mockJwcrypto = sinon.mock(jwcrypto);
mockFxAccountsConfig
.expects("fetchConfigDocument")
.once()
.returns({
ecosystem_anon_id_keys: ["testKey"],
});
mockJwcrypto
.expects("generateJWE")
.once()
.withExactArgs("testKey", new TextEncoder().encode(expectedEcosystemUserId))
.returns(expectedEcosystemAnonId);
const actualEcosystemAnonId = await telemetry.ensureEcosystemAnonId();
Assert.equal(expectedEcosystemAnonId, actualEcosystemAnonId);
mockFxAccountsConfig.verify();
mockJwcrypto.verify();
mockedUpdate.verify();
});
add_task(async function test_ensureEcosystemAnonId_useValueFromProfile() {
// If there's no eco-uid in the account state,
// we should use the anon-id value present in the user's profile data.
const expectedEcosystemAnonId = "bbbbbbbbbbbb";
const profileClient = new FxAccountsProfileClient({
serverURL: "http://testURL",
});
const profile = new FxAccountsProfile({ profileClient });
const telemetry = new FxAccountsTelemetry({
profile,
withCurrentAccountState: async cb => {
return cb({
getUserAccountData: async () => {
return {};
},
});
},
});
const mockProfile = sinon.mock(profile);
mockProfile
.expects("ensureProfile")
.withArgs(sinon.match({ staleOk: true }))
.once()
.returns({
ecosystemAnonId: expectedEcosystemAnonId,
});
const actualEcosystemAnonId = await telemetry.ensureEcosystemAnonId();
Assert.equal(expectedEcosystemAnonId, actualEcosystemAnonId);
mockProfile.verify();
});
add_task(
async function test_ensureEcosystemAnonId_generatePlaceholderInProfile() {
// If there's no eco-uid in the account state, and no anon-id in the profile data,
// we should generate a placeholder value and persist it to the profile data.
const expectedEcosystemUserIdBytes = new Uint8Array(32);
const expectedEcosystemUserId = "0".repeat(64);
const expectedEcosystemAnonId = "bbbbbbbbbbbb";
const profileClient = new FxAccountsProfileClient({
serverURL: "http://testURL",
});
const profile = new FxAccountsProfile({ profileClient });
const telemetry = new FxAccountsTelemetry({
profile,
withCurrentAccountState: async cb => {
return cb({
getUserAccountData: async () => {
return {};
},
});
},
});
const mockProfile = sinon.mock(profile);
const mockFxAccountsConfig = sinon.mock(FxAccountsConfig);
const mockJwcrypto = sinon.mock(jwcrypto);
const mockCryptoUtils = sinon.mock(CryptoUtils);
const mockProfileClient = sinon.mock(profileClient);
mockProfile
.expects("ensureProfile")
.once()
.returns({});
mockCryptoUtils
.expects("generateRandomBytes")
.once()
.withExactArgs(32)
.returns(expectedEcosystemUserIdBytes);
mockFxAccountsConfig
.expects("fetchConfigDocument")
.once()
.returns({
ecosystem_anon_id_keys: ["testKey"],
});
mockJwcrypto
.expects("generateJWE")
.once()
.withExactArgs(
"testKey",
new TextEncoder().encode(expectedEcosystemUserId)
)
.returns(expectedEcosystemAnonId);
mockProfileClient
.expects("setEcosystemAnonId")
.once()
.withExactArgs(expectedEcosystemAnonId)
.returns(null);
const actualEcosystemAnonId = await telemetry.ensureEcosystemAnonId(true);
Assert.equal(expectedEcosystemAnonId, actualEcosystemAnonId);
mockProfile.verify();
mockCryptoUtils.verify();
mockFxAccountsConfig.verify();
mockJwcrypto.verify();
mockProfileClient.verify();
}
);
add_task(async function test_ensureEcosystemAnonId_failToGenerateKeys() {
// If we attempt to generate an anon-id but can't get the right keys,
// we should fail with a sensible error.
const expectedErrorMessage =
"Unable to fetch ecosystem_anon_id_keys from FxA server";
const testCases = [
{
accountStateObj: {},
serverConfig: {},
},
{
accountStateObj: {},
serverConfig: {
ecosystem_anon_id_keys: [],
},
},
{
accountStateObj: { ecosystemUserId: "bbbbbbbbbb" },
serverConfig: {},
},
{
accountStateObj: { ecosystemUserId: "bbbbbbbbbb" },
serverConfig: {
ecosystem_anon_id_keys: [],
},
},
];
for (const tc of testCases) {
const profile = new FxAccountsProfile({
profileServerUrl: "http://testURL",
});
const telemetry = new FxAccountsTelemetry({
profile,
withCurrentAccountState: async cb => {
return cb({
getUserAccountData: async () => {
return { ...tc.accountStateObj };
},
});
},
});
const mockProfile = sinon.mock(profile);
const mockFxAccountsConfig = sinon.mock(FxAccountsConfig);
if (!tc.accountStateObj.ecosystemUserId) {
mockProfile
.expects("ensureProfile")
.once()
.returns({});
} else {
mockProfile.expects("ensureProfile").never();
}
mockFxAccountsConfig
.expects("fetchConfigDocument")
.once()
.returns(tc.serverConfig);
try {
await telemetry.ensureEcosystemAnonId();
} catch (e) {
Assert.equal(expectedErrorMessage, e.message);
mockProfile.verify();
mockFxAccountsConfig.verify();
}
}
});
add_task(async function test_ensureEcosystemAnonId_selfRace() {
// If we somehow end up calling `ensureEcosystemAnonId` twice,
// we should serialize the requests rather than generting two
// different placeholder ids.
const expectedEcosystemAnonId = "self-race-id";
const profileClient = new FxAccountsProfileClient({
serverURL: "http://testURL",
});
const profile = new FxAccountsProfile({ profileClient });
const telemetry = new FxAccountsTelemetry({
profile,
withCurrentAccountState: async cb => {
return cb({
getUserAccountData: async () => {
return {};
},
});
},
});
const mockProfile = sinon.mock(profile);
const mockFxAccountsConfig = sinon.mock(FxAccountsConfig);
const mockJwcrypto = sinon.mock(jwcrypto);
const mockProfileClient = sinon.mock(profileClient);
mockProfile
.expects("ensureProfile")
.once()
.returns({});
mockProfileClient
.expects("setEcosystemAnonId")
.once()
.returns(null);
// We are going to "block" the config document promise and make 2 calls
// to ensureEcosystemAnonId() while blocked, just to ensure we don't
// actually enter the ensureEcosystemAnonId() impl twice.
const deferInConfigDocument = PromiseUtils.defer();
const deferConfigDocument = PromiseUtils.defer();
mockFxAccountsConfig
.expects("fetchConfigDocument")
.once()
.callsFake(() => {
deferInConfigDocument.resolve();
return deferConfigDocument.promise;
});
mockJwcrypto
.expects("generateJWE")
.once()
.returns(expectedEcosystemAnonId);
let p1 = telemetry.ensureEcosystemAnonId();
let p2 = telemetry.ensureEcosystemAnonId();
// Make sure we've entered fetchConfigDocument
await deferInConfigDocument.promise;
// Let it go.
deferConfigDocument.resolve({ ecosystem_anon_id_keys: ["testKey"] });
Assert.equal(await p1, expectedEcosystemAnonId);
Assert.equal(await p2, expectedEcosystemAnonId);
// And all the `.once()` calls on the mocks are checking we only did the
// work once.
mockProfile.verify();
mockFxAccountsConfig.verify();
mockJwcrypto.verify();
mockProfileClient.verify();
});
add_task(async function test_ensureEcosystemAnonId_clientRace() {
// If we attempt to upload a placeholder anon-id to the user's profile,
// and our write conflicts with another client doing a similar upload,
// then we should recover and accept the server version.
const expectedEcosystemAnonId = "bbbbbbbbbbbb";
const expectedErrrorMessage = "test error at 'setEcosystemAnonId'";
const testCases = [
{
errorCode: 412,
errorMessage: null,
},
{
errorCode: 405,
errorMessage: expectedErrrorMessage,
},
];
for (const tc of testCases) {
const profileClient = new FxAccountsProfileClient({
serverURL: "http://testURL",
});
const profile = new FxAccountsProfile({ profileClient });
const telemetry = new FxAccountsTelemetry({
profile,
withCurrentAccountState: async cb => {
return cb({
getUserAccountData: async () => {
return {};
},
});
},
});
const mockProfile = sinon.mock(profile);
const mockFxAccountsConfig = sinon.mock(FxAccountsConfig);
const mockJwcrypto = sinon.mock(jwcrypto);
const mockProfileClient = sinon.mock(profileClient);
mockProfile
.expects("ensureProfile")
.withArgs(sinon.match({ staleOk: true }))
.once()
.returns({});
mockFxAccountsConfig
.expects("fetchConfigDocument")
.once()
.returns({
ecosystem_anon_id_keys: ["testKey"],
});
mockJwcrypto
.expects("generateJWE")
.once()
.returns(expectedEcosystemAnonId);
mockProfileClient
.expects("setEcosystemAnonId")
.once()
.throws({
code: tc.errorCode,
message: tc.errorMessage,
});
if (tc.errorCode === 412) {
mockProfile
.expects("ensureProfile")
.withArgs(sinon.match({ forceFresh: true }))
.once()
.returns({
ecosystemAnonId: expectedEcosystemAnonId,
});
const actualEcosystemAnonId = await telemetry.ensureEcosystemAnonId();
Assert.equal(expectedEcosystemAnonId, actualEcosystemAnonId);
} else {
try {
await telemetry.ensureEcosystemAnonId();
} catch (e) {
Assert.equal(expectedErrrorMessage, e.message);
}
}
mockProfile.verify();
mockFxAccountsConfig.verify();
mockJwcrypto.verify();
mockProfileClient.verify();
}
});