1 /* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
5 import { PromiseUtils } from "resource://gre/modules/PromiseUtils.sys.mjs";
7 import { CommonUtils } from "resource://services-common/utils.sys.mjs";
9 import { CryptoUtils } from "resource://services-crypto/utils.sys.mjs";
12 LEGACY_DERIVED_KEYS_NAMES,
14 LEGACY_SCOPE_WEBEXT_SYNC,
15 DEPRECATED_SCOPE_ECOSYSTEM_TELEMETRY,
19 } = ChromeUtils.import("resource://gre/modules/FxAccountsCommon.js");
21 // These are the scopes that correspond to new storage for the `LEGACY_DERIVED_KEYS_NAMES`.
22 // We will, if necessary, migrate storage for those keys so that it's associated with
24 const LEGACY_DERIVED_KEY_SCOPES = [SCOPE_OLD_SYNC, LEGACY_SCOPE_WEBEXT_SYNC];
26 // These are scopes that we used to store, but are no longer using,
27 // and hence should be deleted from storage if present.
28 const DEPRECATED_KEY_SCOPES = [DEPRECATED_SCOPE_ECOSYSTEM_TELEMETRY];
31 * Utilities for working with key material linked to the user's account.
33 * Each Firefox Account has 32 bytes of root key material called `kB` which is
34 * linked to the user's password, and which is used to derive purpose-specific
35 * subkeys for things like encrypting the user's sync data. This class provides
36 * the interface for working with such key material.
38 * Most recent FxA clients obtain appropriate key material directly as part of
39 * their sign-in flow, using a special extension of the OAuth2.0 protocol to
40 * securely deliver the derived keys without revealing `kB`. Keys obtained in
41 * in this way are called "scoped keys" since each corresponds to a particular
42 * OAuth scope, and this class provides a `getKeyForScope` method that is the
43 * preferred method for consumers to work with such keys.
45 * However, since the FxA integration in Firefox Desktop pre-dates the use of
46 * OAuth2.0, we also have a lot of code for fetching keys via an older flow.
47 * This flow uses a special `keyFetchToken` to obtain `kB` and then derive various
48 * sub-keys from it. Consumers should consider this an internal implementation
49 * detail of the `FxAccountsKeys` class and should prefer `getKeyForScope` where
50 * possible. We intend to remove support for Firefox ever directly handling `kB`
51 * at some point in the future.
53 export class FxAccountsKeys {
54 constructor(fxAccountsInternal) {
55 this._fxai = fxAccountsInternal;
59 * Checks if we currently have the key for a given scope, or if we have enough to
60 * be able to successfully fetch and unwrap it for the signed-in-user.
62 * Unlike `getKeyForScope`, this will not hit the network to fetch wrapped keys if
63 * they aren't available locally.
65 canGetKeyForScope(scope) {
66 return this._fxai.withCurrentAccountState(async currentState => {
67 let userData = await currentState.getUserAccountData();
69 throw new Error("Can't possibly get keys; User is not signed in");
71 if (!userData.verified) {
72 log.info("Can't get keys; user is not verified");
76 if (userData.scopedKeys && userData.scopedKeys.hasOwnProperty(scope)) {
80 // For sync-related scopes, we might have stored the keys in a legacy format.
81 if (scope == SCOPE_OLD_SYNC) {
82 if (userData.kSync && userData.kXCS) {
86 if (scope == LEGACY_SCOPE_WEBEXT_SYNC) {
87 if (userData.kExtSync && userData.kExtKbHash) {
92 // `kB` is deprecated, but if we have it, we can use it to derive any scoped key.
97 // If we have a `keyFetchToken` we can fetch `kB`.
98 if (userData.keyFetchToken) {
102 log.info("Can't get keys; no key material or tokens available");
108 * Get the key for a specified OAuth scope.
110 * @param {String} scope The OAuth scope whose key should be returned
112 * @return Promise<JWK>
113 * If no key is available the promise resolves to `null`.
114 * If a key is available for the given scope, th promise resolves to a JWK with fields:
116 * scope: The requested scope
117 * kid: Key identifier
118 * k: Derived key material
119 * kty: Always "oct" for scoped keys
123 async getKeyForScope(scope) {
124 const { scopedKeys } = await this._loadOrFetchKeys();
125 if (!scopedKeys.hasOwnProperty(scope)) {
126 throw new Error(`Key not available for scope "${scope}"`);
130 ...scopedKeys[scope],
135 * Format a JWK key material as hex rather than base64.
137 * This is a backwards-compatibility helper for code that needs raw key bytes rather
138 * than the JWK format offered by FxA scopes keys.
140 * @param {Object} jwk The JWK from which to extract the `k` field as hex.
144 return CommonUtils.base64urlToHex(jwk.k);
148 * Format a JWK kid as hex rather than base64.
150 * This is a backwards-compatibility helper for code that needs a raw key fingerprint
151 * for use as a key identifier, rather than the timestamp+fingerprint format used by
154 * @param {Object} jwk The JWK from which to extract the `kid` field as hex.
157 // The kid format is "{timestamp}-{b64url(fingerprint)}", but we have to be careful
158 // because the fingerprint component may contain "-" as well, and we want to ensure
159 // the timestamp component was non-empty.
160 const idx = jwk.kid.indexOf("-") + 1;
162 throw new Error(`Invalid kid: ${jwk.kid}`);
164 return CommonUtils.base64urlToHex(jwk.kid.slice(idx));
168 * Fetch encryption keys for the signed-in-user from the FxA API server.
170 * Not for user consumption. Exists to cause the keys to be fetched.
172 * Returns user data so that it can be chained with other methods.
175 * The promise resolves to the credentials object of the signed-in user:
177 * email: The user's email address
178 * uid: The user's unique id
179 * sessionToken: Session for the FxA server
180 * scopedKeys: Object mapping OAuth scopes to corresponding derived keys
181 * kSync: An encryption key for Sync
182 * kXCS: A key hash of kB for the X-Client-State header
183 * kExtSync: An encryption key for WebExtensions syncing
184 * kExtKbHash: A key hash of kB for WebExtensions syncing
185 * verified: email verification status
187 * @throws If there is no user signed in.
189 async _loadOrFetchKeys() {
190 return this._fxai.withCurrentAccountState(async currentState => {
192 let userData = await currentState.getUserAccountData();
194 throw new Error("Can't get keys; User is not signed in");
196 // If we have all the keys in latest storage location, we're good.
197 if (userData.scopedKeys) {
199 LEGACY_DERIVED_KEY_SCOPES.every(scope =>
200 userData.scopedKeys.hasOwnProperty(scope)
202 !DEPRECATED_KEY_SCOPES.some(scope =>
203 userData.scopedKeys.hasOwnProperty(scope)
209 // If not, we've got work to do, and we debounce to avoid duplicating it.
210 if (!currentState.whenKeysReadyDeferred) {
211 currentState.whenKeysReadyDeferred = PromiseUtils.defer();
212 // N.B. we deliberately don't `await` here, and instead use the promise
213 // to resolve `whenKeysReadyDeferred` (which we then `await` below).
214 this._migrateOrFetchKeys(currentState, userData).then(
216 currentState.whenKeysReadyDeferred.resolve(dataWithKeys);
217 currentState.whenKeysReadyDeferred = null;
220 currentState.whenKeysReadyDeferred.reject(err);
221 currentState.whenKeysReadyDeferred = null;
225 return await currentState.whenKeysReadyDeferred.promise;
227 return this._fxai._handleTokenError(err);
233 * Key storage migration or fetching logic.
235 * This method contains the doing-expensive-operations part of the logic of
236 * _loadOrFetchKeys(), factored out into a separate method so we can debounce it.
239 async _migrateOrFetchKeys(currentState, userData) {
240 // Bug 1697596 - delete any deprecated scoped keys from storage.
241 // If any of the deprecated keys are present, then we know that we've
242 // previously applied all the other migrations below, otherwise there
243 // would not be any `scopedKeys` field.
244 if (userData.scopedKeys) {
245 const toRemove = DEPRECATED_KEY_SCOPES.filter(scope =>
246 userData.scopedKeys.hasOwnProperty(scope)
248 if (toRemove.length) {
249 for (const scope of toRemove) {
250 delete userData.scopedKeys[scope];
252 await currentState.updateUserAccountData({
253 scopedKeys: userData.scopedKeys,
254 // Prior to deprecating SCOPE_ECOSYSTEM_TELEMETRY, this file had some
255 // special code to store it as a top-level user data field. So, this
256 // file also gets to delete it as part of the deprecation.
257 ecosystemUserId: null,
258 ecosystemAnonId: null,
260 userData = await currentState.getUserAccountData();
264 // Bug 1661407 - migrate from legacy storage of keys as top-level account
265 // data fields, to storing them as scoped keys in the `scopedKeys` object.
267 LEGACY_DERIVED_KEYS_NAMES.every(name => userData.hasOwnProperty(name))
269 log.info("Migrating from legacy key fields to scopedKeys.");
270 const scopedKeys = userData.scopedKeys || {};
271 await currentState.updateUserAccountData({
274 ...(await this._deriveScopedKeysFromAccountData(userData)),
277 userData = await currentState.getUserAccountData();
280 // Bug 1426306 - Migrate from kB to derived keys.
282 log.info("Migrating kB to derived keys.");
283 const { uid, kB, sessionToken } = userData;
284 const scopedKeysMetadata = await this._fetchScopedKeysMetadata(
287 await currentState.updateUserAccountData({
289 ...(await this._deriveKeys(
291 CommonUtils.hexToBytes(kB),
294 kA: null, // Remove kA and kB from storage.
297 userData = await currentState.getUserAccountData();
300 // Otherwise, we need to fetch from the network and unwrap.
301 if (!userData.sessionToken) {
302 throw new Error("No sessionToken");
304 if (!userData.keyFetchToken) {
305 throw new Error("No keyFetchToken");
307 return this._fetchAndUnwrapAndDeriveKeys(
309 userData.sessionToken,
310 userData.keyFetchToken
315 * Fetch keys from the server, unwrap them, and derive required sub-keys.
317 * Once the user's email is verified, we can resquest the root key `kB` from the
318 * FxA server, unwrap it using the client-side secret `unwrapBKey`, and then
319 * derive all the sub-keys required for operation of the browser.
321 async _fetchAndUnwrapAndDeriveKeys(
328 `fetchAndUnwrapKeys: sessionToken: ${sessionToken}, keyFetchToken: ${keyFetchToken}`
332 // Sign out if we don't have the necessary tokens.
333 if (!sessionToken || !keyFetchToken) {
334 // this seems really bad and we should remove this - bug 1572313.
335 log.warn("improper _fetchAndUnwrapKeys() call: token missing");
336 await this._fxai.signOut();
340 // Deriving OAuth scoped keys requires additional metadata from the server.
341 // We fetch this first, before fetching the actual key material, because the
342 // keyFetchToken is single-use and we don't want to do a potentially-fallible
343 // operation after consuming it.
344 const scopedKeysMetadata = await this._fetchScopedKeysMetadata(
348 // Fetch the wrapped keys.
349 // It would be nice to be able to fetch this in a single operation with fetching
350 // the metadata above, but that requires server-side changes in FxA.
351 let { wrapKB } = await this._fetchKeys(keyFetchToken);
353 let data = await currentState.getUserAccountData();
355 // Sanity check that the user hasn't changed out from under us (which should
356 // be impossible given this is called within _withCurrentAccountState, but...)
357 if (data.keyFetchToken !== keyFetchToken) {
358 throw new Error("Signed in user changed while fetching keys!");
361 let kBbytes = CryptoUtils.xor(
362 CommonUtils.hexToBytes(data.unwrapBKey),
367 log.debug("kBbytes: " + kBbytes);
371 ...(await this._deriveKeys(data.uid, kBbytes, scopedKeysMetadata)),
372 keyFetchToken: null, // null values cause the item to be removed.
377 log.debug(`Keys Obtained: ${updateData.scopedKeys}`);
380 "Keys Obtained: " + Object.keys(updateData.scopedKeys).join(", ")
384 // Just double-check that we derived all the right stuff.
385 const EXPECTED_FIELDS = LEGACY_DERIVED_KEYS_NAMES.concat(["scopedKeys"]);
386 if (EXPECTED_FIELDS.some(k => !updateData[k])) {
387 const missing = EXPECTED_FIELDS.filter(k => !updateData[k]);
388 throw new Error(`user data missing: ${missing.join(", ")}`);
391 await currentState.updateUserAccountData(updateData);
392 return currentState.getUserAccountData();
396 * Fetch the wrapped root key `wrapKB` from the FxA server.
398 * This consumes the single-use `keyFetchToken`.
400 _fetchKeys(keyFetchToken) {
401 let client = this._fxai.fxAccountsClient;
403 `Fetching keys with token ${!!keyFetchToken} from ${client.host}`
406 log.debug("fetchKeys - the token is " + keyFetchToken);
408 return client.accountKeys(keyFetchToken);
412 * Fetch additional metadata required for deriving scoped keys.
414 * This includes timestamps and a server-provided secret to mix in to
415 * the derived value in order to support key rotation.
417 async _fetchScopedKeysMetadata(sessionToken) {
418 // Hard-coded list of scopes that we know about.
419 // This list will probably grow in future.
420 // Note that LEGACY_SCOPE_WEBEXT_SYNC is not in this list, it gets special-case handling below.
421 const scopes = [SCOPE_OLD_SYNC].join(" ");
422 const scopedKeysMetadata = await this._fxai.fxAccountsClient.getScopedKeyData(
427 // The server may decline us permission for some of those scopes, although it really shouldn't.
428 // We can live without them...except for the OLDSYNC scope, whose absence would be catastrophic.
429 if (!scopedKeysMetadata.hasOwnProperty(SCOPE_OLD_SYNC)) {
431 "The FxA server did not grant Firefox the `oldsync` scope; this is most unexpected!" +
432 ` scopes were: ${Object.keys(scopedKeysMetadata)}`
435 "The FxA server did not grant Firefox the `oldsync` scope"
438 // Firefox Desktop invented its own special scope for legacy webextension syncing,
439 // with its own special key. Rather than teach the rest of FxA about this scope
440 // that will never be used anywhere else, just give it the same metadata as
441 // the main sync scope. This can go away once legacy webext sync is removed.
442 // (ref Bug 1637465 for tracking that removal)
443 scopedKeysMetadata[LEGACY_SCOPE_WEBEXT_SYNC] = {
444 ...scopedKeysMetadata[SCOPE_OLD_SYNC],
445 identifier: LEGACY_SCOPE_WEBEXT_SYNC,
447 return scopedKeysMetadata;
451 * Derive purpose-specific keys from the root FxA key `kB`.
453 * Everything that uses an encryption key from FxA uses a purpose-specific derived
454 * key. For new uses this is derived in a structured way based on OAuth scopes,
455 * while for legacy uses (mainly Firefox Sync) it is derived in a more ad-hoc fashion.
456 * This method does all the derivations for the uses that we know about.
459 async _deriveKeys(uid, kBbytes, scopedKeysMetadata) {
460 const scopedKeys = await this._deriveScopedKeys(
467 // Existing browser code might expect sync keys to be available as top-level account data.
468 // For b/w compat we can derive these even if they're not in our list of scoped keys for
469 // some reason (since the derivation doesn't depend on server-provided data).
470 kSync: scopedKeys[SCOPE_OLD_SYNC]
471 ? this.keyAsHex(scopedKeys[SCOPE_OLD_SYNC])
472 : CommonUtils.bytesAsHex(await this._deriveSyncKey(kBbytes)),
473 kXCS: scopedKeys[SCOPE_OLD_SYNC]
474 ? this.kidAsHex(scopedKeys[SCOPE_OLD_SYNC])
475 : CommonUtils.bytesAsHex(await this._deriveXClientState(kBbytes)),
476 kExtSync: scopedKeys[LEGACY_SCOPE_WEBEXT_SYNC]
477 ? this.keyAsHex(scopedKeys[LEGACY_SCOPE_WEBEXT_SYNC])
478 : CommonUtils.bytesAsHex(await this._deriveWebExtSyncStoreKey(kBbytes)),
479 kExtKbHash: scopedKeys[LEGACY_SCOPE_WEBEXT_SYNC]
480 ? this.kidAsHex(scopedKeys[LEGACY_SCOPE_WEBEXT_SYNC])
481 : CommonUtils.bytesAsHex(await this._deriveWebExtKbHash(uid, kBbytes)),
486 * Derive various scoped keys from the root FxA key `kB`.
488 * The `scopedKeysMetadata` object is additional information fetched from the server that
489 * that gets mixed in to the key derivation, with each member of the object corresponding
490 * to an OAuth scope that keys its own scoped key.
492 * As a special case for backwards-compatibility, sync-related scopes get special
493 * treatment to use a legacy derivation algorithm.
496 async _deriveScopedKeys(uid, kBbytes, scopedKeysMetadata) {
497 const scopedKeys = {};
498 for (const scope in scopedKeysMetadata) {
499 if (LEGACY_DERIVED_KEY_SCOPES.includes(scope)) {
500 scopedKeys[scope] = await this._deriveLegacyScopedKey(
504 scopedKeysMetadata[scope]
507 scopedKeys[scope] = await this._deriveScopedKey(
511 scopedKeysMetadata[scope]
519 * Derive the `scopedKeys` data field based on current account data.
521 * This is a backwards-compatibility convenience for users who are already signed in to Firefox
522 * and have legacy fields like `kSync` and `kXCS` in their top-level account data, but do not have
523 * the newer `scopedKeys` field. We populate it with the scoped keys for sync and webext-sync.
526 async _deriveScopedKeysFromAccountData(userData) {
527 const scopedKeysMetadata = await this._fetchScopedKeysMetadata(
528 userData.sessionToken
530 const scopedKeys = userData.scopedKeys || {};
531 for (const scope of LEGACY_DERIVED_KEY_SCOPES) {
532 if (scopedKeysMetadata.hasOwnProperty(scope)) {
534 if (scope == SCOPE_OLD_SYNC) {
535 ({ kXCS: kid, kSync: key } = userData);
536 } else if (scope == LEGACY_SCOPE_WEBEXT_SYNC) {
537 ({ kExtKbHash: kid, kExtSync: key } = userData);
539 // Should never happen, but a nice internal consistency check.
540 throw new Error(`Unexpected legacy key-bearing scope: ${scope}`);
544 `Account is missing legacy key fields for scope: ${scope}`
547 scopedKeys[scope] = await this._formatLegacyScopedKey(
548 CommonUtils.hexToArrayBuffer(kid),
549 CommonUtils.hexToArrayBuffer(key),
551 scopedKeysMetadata[scope]
559 * Derive a scoped key for an individual OAuth scope.
561 * The derivation here uses HKDF to combine:
562 * - the root key material kB
563 * - a unique identifier for this scoped key
564 * - a server-provided secret that allows for key rotation
565 * - the account uid as an additional salt
567 * It produces 32 bytes of (secret) key material along with a (potentially public)
568 * key identifier, formatted as a JWK.
570 * The full details are in the technical docs at
571 * https://docs.google.com/document/d/1IvQJFEBFz0PnL4uVlIvt8fBS_IPwSK-avK0BRIHucxQ/
573 async _deriveScopedKey(uid, kBbytes, scope, scopedKeyMetadata) {
574 kBbytes = CommonUtils.byteStringToArrayBuffer(kBbytes);
576 const FINGERPRINT_LENGTH = 16;
577 const KEY_LENGTH = 32;
578 const VALID_UID = /^[0-9a-f]{32}$/i;
579 const VALID_ROTATION_SECRET = /^[0-9a-f]{64}$/i;
581 // Engage paranoia mode for input data.
582 if (!VALID_UID.test(uid)) {
583 throw new Error("uid must be a 32-character hex string");
585 if (kBbytes.length != 32) {
586 throw new Error("kBbytes must be exactly 32 bytes");
589 typeof scopedKeyMetadata.identifier !== "string" ||
590 scopedKeyMetadata.identifier.length < 10
592 throw new Error("identifier must be a string of length >= 10");
594 if (typeof scopedKeyMetadata.keyRotationTimestamp !== "number") {
595 throw new Error("keyRotationTimestamp must be a number");
597 if (!VALID_ROTATION_SECRET.test(scopedKeyMetadata.keyRotationSecret)) {
598 throw new Error("keyRotationSecret must be a 64-character hex string");
601 // The server returns milliseconds, we want seconds as a string.
602 const keyRotationTimestamp =
603 "" + Math.round(scopedKeyMetadata.keyRotationTimestamp / 1000);
604 if (keyRotationTimestamp.length < 10) {
605 throw new Error("keyRotationTimestamp must round to a 10-digit number");
608 const keyRotationSecret = CommonUtils.hexToArrayBuffer(
609 scopedKeyMetadata.keyRotationSecret
611 const salt = CommonUtils.hexToArrayBuffer(uid);
612 const context = new TextEncoder().encode(
613 "identity.mozilla.com/picl/v1/scoped_key\n" + scopedKeyMetadata.identifier
616 const inputKey = new Uint8Array(64);
617 inputKey.set(kBbytes, 0);
618 inputKey.set(keyRotationSecret, 32);
620 const derivedKeyMaterial = await CryptoUtils.hkdf(
624 FINGERPRINT_LENGTH + KEY_LENGTH
626 const fingerprint = derivedKeyMaterial.slice(0, FINGERPRINT_LENGTH);
627 const key = derivedKeyMaterial.slice(
629 FINGERPRINT_LENGTH + KEY_LENGTH
634 keyRotationTimestamp +
636 ChromeUtils.base64URLEncode(fingerprint, {
639 k: ChromeUtils.base64URLEncode(key, {
647 * Derive the scoped key for the one of our legacy sync-related scopes.
649 * These uses a different key-derivation algoritm that incorporates less server-provided
650 * data, for backwards-compatibility reasons.
653 async _deriveLegacyScopedKey(uid, kBbytes, scope, scopedKeyMetadata) {
655 if (scope == SCOPE_OLD_SYNC) {
656 kid = await this._deriveXClientState(kBbytes);
657 key = await this._deriveSyncKey(kBbytes);
658 } else if (scope == LEGACY_SCOPE_WEBEXT_SYNC) {
659 kid = await this._deriveWebExtKbHash(uid, kBbytes);
660 key = await this._deriveWebExtSyncStoreKey(kBbytes);
662 throw new Error(`Unexpected legacy key-bearing scope: ${scope}`);
664 kid = CommonUtils.byteStringToArrayBuffer(kid);
665 key = CommonUtils.byteStringToArrayBuffer(key);
666 return this._formatLegacyScopedKey(kid, key, scope, scopedKeyMetadata);
670 * Format key material for a legacy scyne-related scope as a JWK.
672 * @param {ArrayBuffer} kid bytes of the key hash to use in the key identifier
673 * @param {ArrayBuffer} key bytes of the derived sync key
674 * @param {String} scope the scope with which this key is associated
675 * @param {Number} keyRotationTimestamp server-provided timestamp of last key rotation
676 * @returns {Object} key material formatted as a JWK object
678 _formatLegacyScopedKey(kid, key, scope, { keyRotationTimestamp }) {
679 kid = ChromeUtils.base64URLEncode(kid, {
682 key = ChromeUtils.base64URLEncode(key, {
686 kid: `${keyRotationTimestamp}-${kid}`,
693 * Derive the Sync Key given the byte string kB.
695 * @returns Promise<HKDF(kB, undefined, "identity.mozilla.com/picl/v1/oldsync", 64)>
697 async _deriveSyncKey(kBbytes) {
698 return CryptoUtils.hkdfLegacy(
701 "identity.mozilla.com/picl/v1/oldsync",
707 * Derive the X-Client-State header given the byte string kB.
709 * @returns Promise<SHA256(kB)[:16]>
711 async _deriveXClientState(kBbytes) {
712 return this._sha256(kBbytes).slice(0, 16);
716 * Derive the WebExtensions Sync Storage Key given the byte string kB.
718 * @returns Promise<HKDF(kB, undefined, "identity.mozilla.com/picl/v1/chrome.storage.sync", 64)>
720 async _deriveWebExtSyncStoreKey(kBbytes) {
721 return CryptoUtils.hkdfLegacy(
724 "identity.mozilla.com/picl/v1/chrome.storage.sync",
730 * Derive the WebExtensions kbHash given the byte string kB.
732 * @returns Promise<SHA256(uid + kB)>
734 async _deriveWebExtKbHash(uid, kBbytes) {
735 return this._sha256(uid + kBbytes);
739 let hasher = Cc["@mozilla.org/security/hash;1"].createInstance(
742 hasher.init(hasher.SHA256);
743 return CryptoUtils.digestBytes(bytes, hasher);