/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

import { PromiseUtils } from "resource://gre/modules/PromiseUtils.sys.mjs";

import { CommonUtils } from "resource://services-common/utils.sys.mjs";

import { CryptoUtils } from "resource://services-crypto/utils.sys.mjs";

import {
  SCOPE_OLD_SYNC,
  DEPRECATED_SCOPE_ECOSYSTEM_TELEMETRY,
  FX_OAUTH_CLIENT_ID,
  log,
  logPII,
} from "resource://gre/modules/FxAccountsCommon.sys.mjs";

// The following top-level fields have since been deprecated and exist here purely
// to be removed from the account state when seen. After a reasonable period of time
// has passed, where users have been migrated away from those keys they should be safe to be removed
const DEPRECATED_DERIVED_KEYS_NAMES = [
  "kSync",
  "kXCS",
  "kExtSync",
  "kExtKbHash",
  "ecosystemUserId",
  "ecosystemAnonId",
];

// This scope and its associated key material were used by the old Kinto webextension
// storage backend, but has since been decommissioned. It's here entirely so that we
// 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.
const DEPRECATED_SCOPE_WEBEXT_SYNC = "sync:addon_storage";

// 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];

// 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,
  DEPRECATED_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.
 */
export class FxAccountsKeys {
  constructor(fxAccountsInternal) {
    this._fxai = fxAccountsInternal;
  }

  /**
   * 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.
   */
  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");
      }
      if (!userData.verified) {
        log.info("Can't get keys; user is not verified");
        return false;
      }

      if (userData.scopedKeys && userData.scopedKeys.hasOwnProperty(scope)) {
        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 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 fetched.
   *
   * Returns user data so that it can be chained with other methods.
   *
   * @return Promise
   *        The promise resolves to the credentials object of the signed-in user:
   *        {
   *          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
   *          verified: email verification status
   *        }
   * @throws If there is no user signed in.
   */
  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 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)
            ) &&
            !DEPRECATED_KEY_SCOPES.some(scope =>
              userData.scopedKeys.hasOwnProperty(scope)
            ) &&
            !DEPRECATED_DERIVED_KEYS_NAMES.some(keyName =>
              userData.hasOwnProperty(keyName)
            )
          ) {
            return userData;
          }
        }
        // If not, we've got work to do, and we debounce to avoid duplicating it.
        if (!currentState.whenKeysReadyDeferred) {
          currentState.whenKeysReadyDeferred = PromiseUtils.defer();
          // 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._fxai._handleTokenError(err);
      }
    });
  }

  /**
   * 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.
   *
   */
  async _migrateOrFetchKeys(currentState, userData) {
    // If the required scopes are present in `scopedKeys`, then we know that we've
    // previously applied all earlier migrations
    // so we are safe to delete deprecated fields that older migrations
    // might have depended on.
    if (
      userData.scopedKeys &&
      LEGACY_DERIVED_KEY_SCOPES.every(scope =>
        userData.scopedKeys.hasOwnProperty(scope)
      )
    ) {
      return this._removeDeprecatedKeys(currentState, 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
    );
  }

  /**
   * Removes deprecated keys from storage and returns an
   * updated user data object
   */
  async _removeDeprecatedKeys(currentState, userData) {
    // Bug 1838708: Delete any deprecated high level keys from storage
    const keysToRemove = DEPRECATED_DERIVED_KEYS_NAMES.filter(keyName =>
      userData.hasOwnProperty(keyName)
    );
    if (keysToRemove.length) {
      const removedKeys = {};
      for (const keyName of keysToRemove) {
        removedKeys[keyName] = null;
      }
      await currentState.updateUserAccountData({
        ...removedKeys,
      });
      userData = await currentState.getUserAccountData();
    }
    // Bug 1697596 - delete any deprecated scoped keys from storage.
    const scopesToRemove = DEPRECATED_KEY_SCOPES.filter(scope =>
      userData.scopedKeys.hasOwnProperty(scope)
    );
    if (scopesToRemove.length) {
      const updatedScopedKeys = {
        ...userData.scopedKeys,
      };
      for (const scope of scopesToRemove) {
        delete updatedScopedKeys[scope];
      }
      await currentState.updateUserAccountData({
        scopedKeys: updatedScopedKeys,
      });
      userData = await currentState.getUserAccountData();
    }
    return userData;
  }

  /**
   * Fetch keys from the server, unwrap them, and derive required sub-keys.
   *
   * 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.
   */
  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 scoped keys are there now
    if (!updateData.scopedKeys) {
      throw new Error(`user data missing: scopedKeys`);
    }

    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}`
    );
    if (logPII()) {
      log.debug("fetchKeys - the token is " + keyFetchToken);
    }
    return client.accountKeys(keyFetchToken);
  }

  /**
   * 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.
    const scopes = [SCOPE_OLD_SYNC].join(" ");
    const scopedKeysMetadata =
      await this._fxai.fxAccountsClient.getScopedKeyData(
        sessionToken,
        FX_OAUTH_CLIENT_ID,
        scopes
      );
    // The server may decline us permission for some of those scopes, although it really shouldn't.
    // We can live without them...except for the OLDSYNC scope, whose absence would be catastrophic.
    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)}`
      );
      throw new Error(
        "The FxA server did not grant Firefox the `oldsync` scope"
      );
    }
    return scopedKeysMetadata;
  }

  /**
   * 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 _deriveKeys(uid, kBbytes, scopedKeysMetadata) {
    const scopedKeys = await this._deriveScopedKeys(
      uid,
      kBbytes,
      scopedKeysMetadata
    );
    return {
      scopedKeys,
    };
  }

  /**
   * 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 _deriveScopedKeys(uid, kBbytes, scopedKeysMetadata) {
    const scopedKeys = {};
    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;
  }

  /**
   * Derive a scoped key for an individual OAuth scope.
   *
   * The derivation here uses HKDF to combine:
   *   - the root key material kB
   *   - a unique identifier for this scoped key
   *   - a server-provided secret that allows for key rotation
   *   - the account uid as an additional salt
   *
   * It produces 32 bytes of (secret) key material along with a (potentially public)
   * key identifier, formatted as a JWK.
   *
   * The full details are in the technical docs at
   * https://docs.google.com/document/d/1IvQJFEBFz0PnL4uVlIvt8fBS_IPwSK-avK0BRIHucxQ/
   */
  async _deriveScopedKey(uid, kBbytes, scope, scopedKeyMetadata) {
    kBbytes = CommonUtils.byteStringToArrayBuffer(kBbytes);

    const FINGERPRINT_LENGTH = 16;
    const KEY_LENGTH = 32;
    const VALID_UID = /^[0-9a-f]{32}$/i;
    const VALID_ROTATION_SECRET = /^[0-9a-f]{64}$/i;

    // Engage paranoia mode for input data.
    if (!VALID_UID.test(uid)) {
      throw new Error("uid must be a 32-character hex string");
    }
    if (kBbytes.length != 32) {
      throw new Error("kBbytes must be exactly 32 bytes");
    }
    if (
      typeof scopedKeyMetadata.identifier !== "string" ||
      scopedKeyMetadata.identifier.length < 10
    ) {
      throw new Error("identifier must be a string of length >= 10");
    }
    if (typeof scopedKeyMetadata.keyRotationTimestamp !== "number") {
      throw new Error("keyRotationTimestamp must be a number");
    }
    if (!VALID_ROTATION_SECRET.test(scopedKeyMetadata.keyRotationSecret)) {
      throw new Error("keyRotationSecret must be a 64-character hex string");
    }

    // The server returns milliseconds, we want seconds as a string.
    const keyRotationTimestamp =
      "" + Math.round(scopedKeyMetadata.keyRotationTimestamp / 1000);
    if (keyRotationTimestamp.length < 10) {
      throw new Error("keyRotationTimestamp must round to a 10-digit number");
    }

    const keyRotationSecret = CommonUtils.hexToArrayBuffer(
      scopedKeyMetadata.keyRotationSecret
    );
    const salt = CommonUtils.hexToArrayBuffer(uid);
    const context = new TextEncoder().encode(
      "identity.mozilla.com/picl/v1/scoped_key\n" + scopedKeyMetadata.identifier
    );

    const inputKey = new Uint8Array(64);
    inputKey.set(kBbytes, 0);
    inputKey.set(keyRotationSecret, 32);

    const derivedKeyMaterial = await CryptoUtils.hkdf(
      inputKey,
      salt,
      context,
      FINGERPRINT_LENGTH + KEY_LENGTH
    );
    const fingerprint = derivedKeyMaterial.slice(0, FINGERPRINT_LENGTH);
    const key = derivedKeyMaterial.slice(
      FINGERPRINT_LENGTH,
      FINGERPRINT_LENGTH + KEY_LENGTH
    );

    return {
      kid:
        keyRotationTimestamp +
        "-" +
        ChromeUtils.base64URLEncode(fingerprint, {
          pad: false,
        }),
      k: ChromeUtils.base64URLEncode(key, {
        pad: false,
      }),
      kty: "oct",
    };
  }

  /**
   * 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 {
      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 {
      kid: `${keyRotationTimestamp}-${kid}`,
      k: key,
      kty: "oct",
    };
  }

  /**
   * Derive the Sync Key given the byte string kB.
   *
   * @returns Promise<HKDF(kB, undefined, "identity.mozilla.com/picl/v1/oldsync", 64)>
   */
  async _deriveSyncKey(kBbytes) {
    return CryptoUtils.hkdfLegacy(
      kBbytes,
      undefined,
      "identity.mozilla.com/picl/v1/oldsync",
      2 * 32
    );
  }

  /**
   * 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);
  }

  _sha256(bytes) {
    let hasher = Cc["@mozilla.org/security/hash;1"].createInstance(
      Ci.nsICryptoHash
    );
    hasher.init(hasher.SHA256);
    return CryptoUtils.digestBytes(bytes, hasher);
  }
}
