tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

FxAccountsKeys.sys.mjs (25430B)


      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/. */
      4 
      5 import { CommonUtils } from "resource://services-common/utils.sys.mjs";
      6 
      7 import { CryptoUtils } from "moz-src:///services/crypto/modules/utils.sys.mjs";
      8 
      9 import {
     10  SCOPE_APP_SYNC,
     11  DEPRECATED_SCOPE_ECOSYSTEM_TELEMETRY,
     12  OAUTH_CLIENT_ID,
     13  log,
     14  logPII,
     15 } from "resource://gre/modules/FxAccountsCommon.sys.mjs";
     16 
     17 // The following top-level fields have since been deprecated and exist here purely
     18 // to be removed from the account state when seen. After a reasonable period of time
     19 // has passed, where users have been migrated away from those keys they should be safe to be removed
     20 const DEPRECATED_DERIVED_KEYS_NAMES = [
     21  "kSync",
     22  "kXCS",
     23  "kExtSync",
     24  "kExtKbHash",
     25  "ecosystemUserId",
     26  "ecosystemAnonId",
     27 ];
     28 
     29 // This scope and its associated key material were used by the old Kinto webextension
     30 // storage backend, but has since been decommissioned. It's here entirely so that we
     31 // remove the corresponding key from storage if present. We should be safe to remove it
     32 // after some sensible period of time has elapsed to allow most clients to update.
     33 const DEPRECATED_SCOPE_WEBEXT_SYNC = "sync:addon_storage";
     34 
     35 // These are the scopes that correspond to new storage for the `LEGACY_DERIVED_KEYS_NAMES`.
     36 // We will, if necessary, migrate storage for those keys so that it's associated with
     37 // these scopes.
     38 const LEGACY_DERIVED_KEY_SCOPES = [SCOPE_APP_SYNC];
     39 
     40 // These are scopes that we used to store, but are no longer using,
     41 // and hence should be deleted from storage if present.
     42 const DEPRECATED_KEY_SCOPES = [
     43  DEPRECATED_SCOPE_ECOSYSTEM_TELEMETRY,
     44  DEPRECATED_SCOPE_WEBEXT_SYNC,
     45 ];
     46 
     47 /**
     48 * Utilities for working with key material linked to the user's account.
     49 *
     50 * Each Firefox Account has 32 bytes of root key material called `kB` which is
     51 * linked to the user's password, and which is used to derive purpose-specific
     52 * subkeys for things like encrypting the user's sync data. This class provides
     53 * the interface for working with such key material.
     54 *
     55 * Most recent FxA clients obtain appropriate key material directly as part of
     56 * their sign-in flow, using a special extension of the OAuth2.0 protocol to
     57 * securely deliver the derived keys without revealing `kB`. Keys obtained in
     58 * in this way are called "scoped keys" since each corresponds to a particular
     59 * OAuth scope, and this class provides a `getKeyForScope` method that is the
     60 * preferred method for consumers to work with such keys.
     61 *
     62 * However, since the FxA integration in Firefox Desktop pre-dates the use of
     63 * OAuth2.0, we also have a lot of code for fetching keys via an older flow.
     64 * This flow uses a special `keyFetchToken` to obtain `kB` and then derive various
     65 * sub-keys from it. Consumers should consider this an internal implementation
     66 * detail of the `FxAccountsKeys` class and should prefer `getKeyForScope` where
     67 * possible.  We intend to remove support for Firefox ever directly handling `kB`
     68 * at some point in the future.
     69 *
     70 * Note that Desktop is now slowly moving to these newer oauth flows - so all this
     71 * key fetching and use of the keyFetchToken should be considered deprecated, and
     72 * must not be used when the OAuth is in use. This code remains behind just for
     73 * this transition and should be removed once we are committed to never rolling
     74 * the flows back to the pre-oauth days.
     75 */
     76 export class FxAccountsKeys {
     77  constructor(fxAccountsInternal) {
     78    this._fxai = fxAccountsInternal;
     79  }
     80 
     81  /**
     82   * Checks if we currently have the key for a given scope, or if we have enough to
     83   * be able to successfully fetch and unwrap it for the signed-in-user.
     84   *
     85   * Unlike `getKeyForScope`, this will not hit the network to fetch wrapped keys if
     86   * they aren't available locally.
     87   */
     88  canGetKeyForScope(scope) {
     89    return this._fxai.withCurrentAccountState(async currentState => {
     90      let userData = await currentState.getUserAccountData();
     91      if (!userData) {
     92        throw new Error("Can't possibly get keys; User is not signed in");
     93      }
     94      if (!userData.verified) {
     95        log.info("Can't get keys; user is not verified");
     96        return false;
     97      }
     98      return userData.scopedKeys && userData.scopedKeys.hasOwnProperty(scope);
     99    });
    100  }
    101 
    102  /**
    103   * Checks if we currently have the key for a given scope locally available.
    104   *
    105   * This method only checks if the keys exist in local storage. With OAuth-based
    106   * authentication, keys cannot be fetched on demand - if they don't exist locally,
    107   * there is no way to obtain them.
    108   *
    109   * @param {string} scope The OAuth scope whose key should be checked
    110   *
    111   * @return Promise<boolean>
    112   *        Resolves to true if the key exists locally, false otherwise.
    113   */
    114  hasKeysForScope(scope) {
    115    return this._fxai.withCurrentAccountState(async currentState => {
    116      let userData = await currentState.getUserAccountData();
    117      if (!userData) {
    118        return false;
    119      }
    120      return !!(
    121        userData.scopedKeys && userData.scopedKeys.hasOwnProperty(scope)
    122      );
    123    });
    124  }
    125 
    126  /**
    127   * Get the key for a specified OAuth scope.
    128   *
    129   * @param {string} scope The OAuth scope whose key should be returned
    130   *
    131   * @return Promise<JWK>
    132   *        If no key is available the promise resolves to `null`.
    133   *        If a key is available for the given scope, th promise resolves to a JWK with fields:
    134   *        {
    135   *          scope: The requested scope
    136   *          kid: Key identifier
    137   *          k: Derived key material
    138   *          kty: Always "oct" for scoped keys
    139   *        }
    140   */
    141  async getKeyForScope(scope) {
    142    const { scopedKeys } = await this._loadOrFetchKeys();
    143    if (!scopedKeys.hasOwnProperty(scope)) {
    144      throw new Error(`Key not available for scope "${scope}"`);
    145    }
    146    return {
    147      scope,
    148      ...scopedKeys[scope],
    149    };
    150  }
    151 
    152  /**
    153   * Validates if the given scoped keys are valid keys
    154   *
    155   * @param {object} scopedKeys: The scopedKeys bundle
    156   *
    157   * @return {boolean}: true if the scopedKeys bundle is valid, false otherwise
    158   */
    159  validScopedKeys(scopedKeys) {
    160    for (const expectedScope of Object.keys(scopedKeys)) {
    161      const key = scopedKeys[expectedScope];
    162      if (
    163        !key.hasOwnProperty("scope") ||
    164        !key.hasOwnProperty("kid") ||
    165        !key.hasOwnProperty("kty") ||
    166        !key.hasOwnProperty("k")
    167      ) {
    168        return false;
    169      }
    170      const { scope, kid, kty, k } = key;
    171      if (scope != expectedScope || kty != "oct") {
    172        return false;
    173      }
    174      // We verify the format of the key id is `timestamp-fingerprint`
    175      if (!kid.includes("-")) {
    176        return false;
    177      }
    178      const dashIndex = kid.indexOf("-");
    179      const keyRotationTimestamp = kid.substring(0, dashIndex);
    180      const fingerprint = kid.substring(dashIndex + 1);
    181      // We then verify that the timestamp is a valid timestamp
    182      const keyRotationTimestampNum = Number(keyRotationTimestamp);
    183      // If the value we got back is falsy it's not a valid timestamp
    184      // note that we treat a 0 timestamp as invalid
    185      if (!keyRotationTimestampNum) {
    186        return false;
    187      }
    188      // For extra safety, we validate that the timestamp can be converted into a valid
    189      // Date object
    190      const date = new Date(keyRotationTimestampNum);
    191      if (isNaN(date.getTime()) || date.getTime() <= 0) {
    192        return false;
    193      }
    194 
    195      // Finally, we validate that the fingerprint and the key itself are valid base64 values
    196      // Note that we can't verify the fingerprint is correct here because we don't have kb
    197      const validB64String = b64String => {
    198        let decoded;
    199        try {
    200          decoded = ChromeUtils.base64URLDecode(b64String, {
    201            padding: "reject",
    202          });
    203        } catch (e) {
    204          return false;
    205        }
    206        return !!decoded;
    207      };
    208      if (!validB64String(fingerprint) || !validB64String(k)) {
    209        return false;
    210      }
    211    }
    212    return true;
    213  }
    214 
    215  /**
    216   * Format a JWK kid as hex rather than base64.
    217   *
    218   * This is a backwards-compatibility helper for code that needs a raw key fingerprint
    219   * for use as a key identifier, rather than the timestamp+fingerprint format used by
    220   * FxA scoped keys.
    221   *
    222   * @param {object} jwk The JWK from which to extract the `kid` field as hex.
    223   */
    224  kidAsHex(jwk) {
    225    // The kid format is "{timestamp}-{b64url(fingerprint)}", but we have to be careful
    226    // because the fingerprint component may contain "-" as well, and we want to ensure
    227    // the timestamp component was non-empty.
    228    const idx = jwk.kid.indexOf("-") + 1;
    229    if (idx <= 1) {
    230      throw new Error(`Invalid kid: ${jwk.kid}`);
    231    }
    232    return CommonUtils.base64urlToHex(jwk.kid.slice(idx));
    233  }
    234 
    235  /**
    236   * Fetch encryption keys for the signed-in-user from the FxA API server.
    237   *
    238   * Not for user consumption.  Exists to cause the keys to be fetched.
    239   *
    240   * Returns user data so that it can be chained with other methods.
    241   *
    242   * @return Promise
    243   *        The promise resolves to the credentials object of the signed-in user:
    244   *        {
    245   *          email: The user's email address
    246   *          uid: The user's unique id
    247   *          sessionToken: Session for the FxA server
    248   *          scopedKeys: Object mapping OAuth scopes to corresponding derived keys
    249   *          verified: email verification status
    250   *        }
    251   * @throws If there is no user signed in.
    252   */
    253  async _loadOrFetchKeys() {
    254    return this._fxai.withCurrentAccountState(async currentState => {
    255      try {
    256        let userData = await currentState.getUserAccountData();
    257        if (!userData) {
    258          throw new Error("Can't get keys; User is not signed in");
    259        }
    260        // If we have all the keys in latest storage location, we're good.
    261        if (userData.scopedKeys) {
    262          if (
    263            LEGACY_DERIVED_KEY_SCOPES.every(scope =>
    264              userData.scopedKeys.hasOwnProperty(scope)
    265            ) &&
    266            !DEPRECATED_KEY_SCOPES.some(scope =>
    267              userData.scopedKeys.hasOwnProperty(scope)
    268            ) &&
    269            !DEPRECATED_DERIVED_KEYS_NAMES.some(keyName =>
    270              userData.hasOwnProperty(keyName)
    271            )
    272          ) {
    273            return userData;
    274          }
    275        }
    276        // If not, we've got work to do, and we debounce to avoid duplicating it.
    277        if (!currentState.whenKeysReadyDeferred) {
    278          currentState.whenKeysReadyDeferred = Promise.withResolvers();
    279          // N.B. we deliberately don't `await` here, and instead use the promise
    280          // to resolve `whenKeysReadyDeferred` (which we then `await` below).
    281          this._migrateOrFetchKeys(currentState, userData).then(
    282            dataWithKeys => {
    283              currentState.whenKeysReadyDeferred.resolve(dataWithKeys);
    284              currentState.whenKeysReadyDeferred = null;
    285            },
    286            err => {
    287              currentState.whenKeysReadyDeferred.reject(err);
    288              currentState.whenKeysReadyDeferred = null;
    289            }
    290          );
    291        }
    292        return await currentState.whenKeysReadyDeferred.promise;
    293      } catch (err) {
    294        return this._fxai._handleTokenError(err);
    295      }
    296    });
    297  }
    298 
    299  /**
    300   * Set externally derived scoped keys in internal storage
    301   *
    302   * @param {object} scopedKeys: The scoped keys object derived by the oauth flow
    303   *
    304   * @return { Promise }: A promise that resolves if the keys were successfully stored,
    305   *    or rejects if we failed to persist the keys, or if the user is not signed in already
    306   */
    307  async setScopedKeys(scopedKeys) {
    308    return this._fxai.withCurrentAccountState(async currentState => {
    309      const userData = await currentState.getUserAccountData();
    310      if (!userData) {
    311        throw new Error("Cannot persist keys, no user signed in");
    312      }
    313      await currentState.updateUserAccountData({
    314        scopedKeys,
    315      });
    316    });
    317  }
    318 
    319  /**
    320   * Key storage migration or fetching logic.
    321   *
    322   * This method contains the doing-expensive-operations part of the logic of
    323   * _loadOrFetchKeys(), factored out into a separate method so we can debounce it.
    324   *
    325   */
    326  async _migrateOrFetchKeys(currentState, userData) {
    327    // If the required scopes are present in `scopedKeys`, then we know that we've
    328    // previously applied all earlier migrations
    329    // so we are safe to delete deprecated fields that older migrations
    330    // might have depended on.
    331    if (
    332      userData.scopedKeys &&
    333      LEGACY_DERIVED_KEY_SCOPES.every(scope =>
    334        userData.scopedKeys.hasOwnProperty(scope)
    335      )
    336    ) {
    337      return this._removeDeprecatedKeys(currentState, userData);
    338    }
    339 
    340    // Otherwise, we need to fetch from the network and unwrap.
    341    if (!userData.sessionToken) {
    342      throw new Error("No sessionToken");
    343    }
    344    if (!userData.keyFetchToken) {
    345      throw new Error("No keyFetchToken");
    346    }
    347    return this._fetchAndUnwrapAndDeriveKeys(
    348      currentState,
    349      userData.sessionToken,
    350      userData.keyFetchToken
    351    );
    352  }
    353 
    354  /**
    355   * Removes deprecated keys from storage and returns an
    356   * updated user data object
    357   */
    358  async _removeDeprecatedKeys(currentState, userData) {
    359    // Bug 1838708: Delete any deprecated high level keys from storage
    360    const keysToRemove = DEPRECATED_DERIVED_KEYS_NAMES.filter(keyName =>
    361      userData.hasOwnProperty(keyName)
    362    );
    363    if (keysToRemove.length) {
    364      const removedKeys = {};
    365      for (const keyName of keysToRemove) {
    366        removedKeys[keyName] = null;
    367      }
    368      await currentState.updateUserAccountData({
    369        ...removedKeys,
    370      });
    371      userData = await currentState.getUserAccountData();
    372    }
    373    // Bug 1697596 - delete any deprecated scoped keys from storage.
    374    const scopesToRemove = DEPRECATED_KEY_SCOPES.filter(scope =>
    375      userData.scopedKeys.hasOwnProperty(scope)
    376    );
    377    if (scopesToRemove.length) {
    378      const updatedScopedKeys = {
    379        ...userData.scopedKeys,
    380      };
    381      for (const scope of scopesToRemove) {
    382        delete updatedScopedKeys[scope];
    383      }
    384      await currentState.updateUserAccountData({
    385        scopedKeys: updatedScopedKeys,
    386      });
    387      userData = await currentState.getUserAccountData();
    388    }
    389    return userData;
    390  }
    391 
    392  /**
    393   * Fetch keys from the server, unwrap them, and derive required sub-keys.
    394   *
    395   * Once the user's email is verified, we can resquest the root key `kB` from the
    396   * FxA server, unwrap it using the client-side secret `unwrapBKey`, and then
    397   * derive all the sub-keys required for operation of the browser.
    398   */
    399  async _fetchAndUnwrapAndDeriveKeys(
    400    currentState,
    401    sessionToken,
    402    keyFetchToken
    403  ) {
    404    if (logPII()) {
    405      log.debug(
    406        `fetchAndUnwrapKeys: sessionToken: ${sessionToken}, keyFetchToken: ${keyFetchToken}`
    407      );
    408    }
    409 
    410    // Sign out if we don't have the necessary tokens.
    411    if (!sessionToken || !keyFetchToken) {
    412      // this seems really bad and we should remove this - bug 1572313.
    413      log.warn("improper _fetchAndUnwrapKeys() call: token missing");
    414      await this._fxai.signOut();
    415      return null;
    416    }
    417 
    418    // Deriving OAuth scoped keys requires additional metadata from the server.
    419    // We fetch this first, before fetching the actual key material, because the
    420    // keyFetchToken is single-use and we don't want to do a potentially-fallible
    421    // operation after consuming it.
    422    const scopedKeysMetadata =
    423      await this._fetchScopedKeysMetadata(sessionToken);
    424 
    425    // Fetch the wrapped keys.
    426    // It would be nice to be able to fetch this in a single operation with fetching
    427    // the metadata above, but that requires server-side changes in FxA.
    428    let { wrapKB } = await this._fetchKeys(keyFetchToken);
    429 
    430    let data = await currentState.getUserAccountData();
    431 
    432    // Sanity check that the user hasn't changed out from under us (which should
    433    // be impossible given this is called within _withCurrentAccountState, but...)
    434    if (data.keyFetchToken !== keyFetchToken) {
    435      throw new Error("Signed in user changed while fetching keys!");
    436    }
    437 
    438    let kBbytes = CryptoUtils.xor(
    439      CommonUtils.hexToBytes(data.unwrapBKey),
    440      wrapKB
    441    );
    442 
    443    if (logPII()) {
    444      log.debug("kBbytes: " + kBbytes);
    445    }
    446 
    447    let updateData = {
    448      ...(await this._deriveKeys(data.uid, kBbytes, scopedKeysMetadata)),
    449      keyFetchToken: null, // null values cause the item to be removed.
    450      unwrapBKey: null,
    451    };
    452 
    453    if (logPII()) {
    454      log.debug(`Keys Obtained: ${updateData.scopedKeys}`);
    455    } else {
    456      log.debug(
    457        "Keys Obtained: " + Object.keys(updateData.scopedKeys).join(", ")
    458      );
    459    }
    460 
    461    // Just double-check that scoped keys are there now
    462    if (!updateData.scopedKeys) {
    463      throw new Error(`user data missing: scopedKeys`);
    464    }
    465 
    466    await currentState.updateUserAccountData(updateData);
    467    return currentState.getUserAccountData();
    468  }
    469 
    470  /**
    471   * Fetch the wrapped root key `wrapKB` from the FxA server.
    472   *
    473   * This consumes the single-use `keyFetchToken`.
    474   */
    475  _fetchKeys(keyFetchToken) {
    476    let client = this._fxai.fxAccountsClient;
    477    log.debug(
    478      `Fetching keys with token ${!!keyFetchToken} from ${client.host}`
    479    );
    480    if (logPII()) {
    481      log.debug("fetchKeys - the token is " + keyFetchToken);
    482    }
    483    return client.accountKeys(keyFetchToken);
    484  }
    485 
    486  /**
    487   * Fetch additional metadata required for deriving scoped keys.
    488   *
    489   * This includes timestamps and a server-provided secret to mix in to
    490   * the derived value in order to support key rotation.
    491   */
    492  async _fetchScopedKeysMetadata(sessionToken) {
    493    // Hard-coded list of scopes that we know about.
    494    // This list will probably grow in future.
    495    const scopes = [SCOPE_APP_SYNC].join(" ");
    496    const scopedKeysMetadata =
    497      await this._fxai.fxAccountsClient.getScopedKeyData(
    498        sessionToken,
    499        OAUTH_CLIENT_ID,
    500        scopes
    501      );
    502    // The server may decline us permission for some of those scopes, although it really shouldn't.
    503    // We can live without them...except for the sync scope, whose absence would be catastrophic.
    504    if (!scopedKeysMetadata.hasOwnProperty(SCOPE_APP_SYNC)) {
    505      log.warn(
    506        "The FxA server did not grant Firefox the sync scope; this is most unexpected!" +
    507          ` scopes were: ${Object.keys(scopedKeysMetadata)}`
    508      );
    509      throw new Error("The FxA server did not grant Firefox the sync scope");
    510    }
    511    return scopedKeysMetadata;
    512  }
    513 
    514  /**
    515   * Derive purpose-specific keys from the root FxA key `kB`.
    516   *
    517   * Everything that uses an encryption key from FxA uses a purpose-specific derived
    518   * key. For new uses this is derived in a structured way based on OAuth scopes,
    519   * while for legacy uses (mainly Firefox Sync) it is derived in a more ad-hoc fashion.
    520   * This method does all the derivations for the uses that we know about.
    521   *
    522   */
    523  async _deriveKeys(uid, kBbytes, scopedKeysMetadata) {
    524    const scopedKeys = await this._deriveScopedKeys(
    525      uid,
    526      kBbytes,
    527      scopedKeysMetadata
    528    );
    529    return {
    530      scopedKeys,
    531    };
    532  }
    533 
    534  /**
    535   * Derive various scoped keys from the root FxA key `kB`.
    536   *
    537   * The `scopedKeysMetadata` object is additional information fetched from the server that
    538   * that gets mixed in to the key derivation, with each member of the object corresponding
    539   * to an OAuth scope that keys its own scoped key.
    540   *
    541   * As a special case for backwards-compatibility, sync-related scopes get special
    542   * treatment to use a legacy derivation algorithm.
    543   *
    544   */
    545  async _deriveScopedKeys(uid, kBbytes, scopedKeysMetadata) {
    546    const scopedKeys = {};
    547    for (const scope in scopedKeysMetadata) {
    548      if (LEGACY_DERIVED_KEY_SCOPES.includes(scope)) {
    549        scopedKeys[scope] = await this._deriveLegacyScopedKey(
    550          uid,
    551          kBbytes,
    552          scope,
    553          scopedKeysMetadata[scope]
    554        );
    555      } else {
    556        scopedKeys[scope] = await this._deriveScopedKey(
    557          uid,
    558          kBbytes,
    559          scope,
    560          scopedKeysMetadata[scope]
    561        );
    562      }
    563    }
    564    return scopedKeys;
    565  }
    566 
    567  /**
    568   * Derive a scoped key for an individual OAuth scope.
    569   *
    570   * The derivation here uses HKDF to combine:
    571   *   - the root key material kB
    572   *   - a unique identifier for this scoped key
    573   *   - a server-provided secret that allows for key rotation
    574   *   - the account uid as an additional salt
    575   *
    576   * It produces 32 bytes of (secret) key material along with a (potentially public)
    577   * key identifier, formatted as a JWK.
    578   *
    579   * The full details are in the technical docs at
    580   * https://docs.google.com/document/d/1IvQJFEBFz0PnL4uVlIvt8fBS_IPwSK-avK0BRIHucxQ/
    581   */
    582  async _deriveScopedKey(uid, kBbytes, scope, scopedKeyMetadata) {
    583    kBbytes = CommonUtils.byteStringToArrayBuffer(kBbytes);
    584 
    585    const FINGERPRINT_LENGTH = 16;
    586    const KEY_LENGTH = 32;
    587    const VALID_UID = /^[0-9a-f]{32}$/i;
    588    const VALID_ROTATION_SECRET = /^[0-9a-f]{64}$/i;
    589 
    590    // Engage paranoia mode for input data.
    591    if (!VALID_UID.test(uid)) {
    592      throw new Error("uid must be a 32-character hex string");
    593    }
    594    if (kBbytes.length != 32) {
    595      throw new Error("kBbytes must be exactly 32 bytes");
    596    }
    597    if (
    598      typeof scopedKeyMetadata.identifier !== "string" ||
    599      scopedKeyMetadata.identifier.length < 10
    600    ) {
    601      throw new Error("identifier must be a string of length >= 10");
    602    }
    603    if (typeof scopedKeyMetadata.keyRotationTimestamp !== "number") {
    604      throw new Error("keyRotationTimestamp must be a number");
    605    }
    606    if (!VALID_ROTATION_SECRET.test(scopedKeyMetadata.keyRotationSecret)) {
    607      throw new Error("keyRotationSecret must be a 64-character hex string");
    608    }
    609 
    610    // The server returns milliseconds, we want seconds as a string.
    611    const keyRotationTimestamp =
    612      "" + Math.round(scopedKeyMetadata.keyRotationTimestamp / 1000);
    613    if (keyRotationTimestamp.length < 10) {
    614      throw new Error("keyRotationTimestamp must round to a 10-digit number");
    615    }
    616 
    617    const keyRotationSecret = CommonUtils.hexToArrayBuffer(
    618      scopedKeyMetadata.keyRotationSecret
    619    );
    620    const salt = CommonUtils.hexToArrayBuffer(uid);
    621    const context = new TextEncoder().encode(
    622      "identity.mozilla.com/picl/v1/scoped_key\n" + scopedKeyMetadata.identifier
    623    );
    624 
    625    const inputKey = new Uint8Array(64);
    626    inputKey.set(kBbytes, 0);
    627    inputKey.set(keyRotationSecret, 32);
    628 
    629    const derivedKeyMaterial = await CryptoUtils.hkdf(
    630      inputKey,
    631      salt,
    632      context,
    633      FINGERPRINT_LENGTH + KEY_LENGTH
    634    );
    635    const fingerprint = derivedKeyMaterial.slice(0, FINGERPRINT_LENGTH);
    636    const key = derivedKeyMaterial.slice(
    637      FINGERPRINT_LENGTH,
    638      FINGERPRINT_LENGTH + KEY_LENGTH
    639    );
    640 
    641    return {
    642      kid:
    643        keyRotationTimestamp +
    644        "-" +
    645        ChromeUtils.base64URLEncode(fingerprint, {
    646          pad: false,
    647        }),
    648      k: ChromeUtils.base64URLEncode(key, {
    649        pad: false,
    650      }),
    651      kty: "oct",
    652    };
    653  }
    654 
    655  /**
    656   * Derive the scoped key for the one of our legacy sync-related scopes.
    657   *
    658   * These uses a different key-derivation algoritm that incorporates less server-provided
    659   * data, for backwards-compatibility reasons.
    660   *
    661   */
    662  async _deriveLegacyScopedKey(uid, kBbytes, scope, scopedKeyMetadata) {
    663    let kid, key;
    664    if (scope == SCOPE_APP_SYNC) {
    665      kid = await this._deriveXClientState(kBbytes);
    666      key = await this._deriveSyncKey(kBbytes);
    667    } else {
    668      throw new Error(`Unexpected legacy key-bearing scope: ${scope}`);
    669    }
    670    kid = CommonUtils.byteStringToArrayBuffer(kid);
    671    key = CommonUtils.byteStringToArrayBuffer(key);
    672    return this._formatLegacyScopedKey(kid, key, scope, scopedKeyMetadata);
    673  }
    674 
    675  /**
    676   * Format key material for a legacy scyne-related scope as a JWK.
    677   *
    678   * @param {ArrayBuffer} kid bytes of the key hash to use in the key identifier
    679   * @param {ArrayBuffer} key bytes of the derived sync key
    680   * @param {string} scope the scope with which this key is associated
    681   * @param {number} keyRotationTimestamp server-provided timestamp of last key rotation
    682   * @returns {object} key material formatted as a JWK object
    683   */
    684  _formatLegacyScopedKey(kid, key, scope, { keyRotationTimestamp }) {
    685    kid = ChromeUtils.base64URLEncode(kid, {
    686      pad: false,
    687    });
    688    key = ChromeUtils.base64URLEncode(key, {
    689      pad: false,
    690    });
    691    return {
    692      kid: `${keyRotationTimestamp}-${kid}`,
    693      k: key,
    694      kty: "oct",
    695    };
    696  }
    697 
    698  /**
    699   * Derive the Sync Key given the byte string kB.
    700   *
    701   * @returns Promise<HKDF(kB, undefined, "identity.mozilla.com/picl/v1/oldsync", 64)>
    702   */
    703  async _deriveSyncKey(kBbytes) {
    704    return CryptoUtils.hkdfLegacy(
    705      kBbytes,
    706      undefined,
    707      "identity.mozilla.com/picl/v1/oldsync",
    708      2 * 32
    709    );
    710  }
    711 
    712  /**
    713   * Derive the X-Client-State header given the byte string kB.
    714   *
    715   * @returns Promise<SHA256(kB)[:16]>
    716   */
    717  async _deriveXClientState(kBbytes) {
    718    return this._sha256(kBbytes).slice(0, 16);
    719  }
    720 
    721  _sha256(bytes) {
    722    let hasher = Cc["@mozilla.org/security/hash;1"].createInstance(
    723      Ci.nsICryptoHash
    724    );
    725    hasher.init(hasher.SHA256);
    726    return CryptoUtils.digestBytes(bytes, hasher);
    727  }
    728 }