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

const lazy = {};

ChromeUtils.defineLazyGetter(lazy, "logConsole", function () {
  return console.createInstance({
    prefix: "BackupService::ArchiveEncryption",
    maxLogLevel: Services.prefs.getBoolPref("browser.backup.log", false)
      ? "Debug"
      : "Warn",
  });
});

ChromeUtils.defineESModuleGetters(lazy, {
  ArchiveUtils: "resource:///modules/backup/ArchiveUtils.sys.mjs",
  OSKeyStore: "resource://gre/modules/OSKeyStore.sys.mjs",
  BackupError: "resource:///modules/backup/BackupError.mjs",
  ERRORS: "chrome://browser/content/backup/backup-constants.mjs",
});

/**
 * ArchiveEncryptionState encapsulates key primitives and wrapped secrets that
 * can be safely serialized to the filesystem. An ArchiveEncryptionState is
 * used to compute the necessary keys for encrypting a backup archive.
 */
export class ArchiveEncryptionState {
  /**
   * A hack that lets us ensure that an ArchiveEncryptionState cannot be
   * constructed except via the ArchiveEncryptionState.initialize static
   * method.
   *
   * See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes/Private_properties#simulating_private_constructors
   */
  static #isInternalConstructing = false;

  /**
   * A reference to an object holding the current state of the
   * ArchiveEncryptionState instance. When this reference is null, encryption
   * is not considered enabled.
   */
  #state = null;

  /**
   * The current version number of the ArchiveEncryptionState. This is encoded
   * in the serialized state, and is also used during calculation of the salt
   * in enable().
   *
   * @type {number}
   */
  static get VERSION() {
    return 1;
  }

  /**
   * The number of characters to generate with a CSRNG (crypto.getRandomValues)
   * if no recovery code is passed in to enable();
   *
   * @type {number}
   */
  static get GENERATED_RECOVERY_CODE_LENGTH() {
    return 14;
  }

  /**
   * The RSA-OAEP public key that will be used to derive keys for encrypting
   * backups.
   *
   * @type {CryptoKey}
   */
  get publicKey() {
    return this.#state.publicKey;
  }

  /**
   * The AES-GCM key that will be used to authenticate the owner of the backup.
   *
   * @type {CryptoKey}
   */
  get backupAuthKey() {
    return this.#state.backupAuthKey;
  }

  /**
   * A salt computed for the PBKDF2 stretching of the recovery code.
   *
   * @type {Uint8Array}
   */
  get salt() {
    return this.#state.salt;
  }

  /**
   * A nonce computed when wrapping the private key and OSKeyStore secret.
   *
   * @type {Uint8Array}
   */
  get nonce() {
    return this.#state.nonce;
  }

  /**
   * The wrapped static secrets, including the RSA-OAEP private key, and the
   * OSKeyStore secret.
   *
   * @type {Uint8Array}
   */
  get wrappedSecrets() {
    return this.#state.wrappedSecrets;
  }

  constructor() {
    if (!ArchiveEncryptionState.#isInternalConstructing) {
      throw new lazy.BackupError(
        "ArchiveEncryptionState is not constructable.",
        lazy.ERRORS.UNKNOWN
      );
    }
    ArchiveEncryptionState.#isInternalConstructing = false;
  }

  /**
   * Calculates various encryption keys and other information necessary to
   * encrypt backups, based on the passed in recoveryCode.
   *
   * This will throw if encryption is already enabled for this
   * ArchiveEncryptionState.
   *
   * @throws {Exception}
   * @param {string} [recoveryCode=null]
   *   A recovery code that will be used to drive the various encryption keys
   *   and data for backup encryption. If not supplied by the caller, a
   *   recovery code will be generated.
   * @returns {Promise<string>}
   *   Resolves with the recovery code string. If callers did not pass the
   *   recovery code in as an argument, they should not store it. They should
   *   instead display this string to the user, and then forget it altogether.
   */
  async #enable(recoveryCode = null) {
    lazy.logConsole.debug("Creating new enabled ArchiveEncryptionState");

    lazy.logConsole.debug("Generating an RSA-OEAP keyPair");
    let keyPair = await crypto.subtle.generateKey(
      {
        name: "RSA-OAEP",
        modulusLength: 2048,
        publicExponent: new Uint8Array([1, 0, 1]),
        hash: { name: "SHA-256" },
      },
      true /* extractable */,
      ["encrypt", "decrypt"]
    );

    if (!recoveryCode) {
      // A recovery code wasn't provided, so we'll generate one using
      // getRandomValues, and make sure it's GENERATED_RECOVERY_CODE_LENGTH
      // characters long.
      recoveryCode = "";
      // We've intentionally replaced some lookalike characters (O, o, 0, l, I,
      // 1) with symbols.
      const charset =
        "ABCDEFGH#JKLMN@PQRSTUVWXYZabcdefgh=jklmn+pqrstuvwxyz%!23456789";
      // getRandomValues will return a value between 0-255. In order to not
      // gain a bias on any particular character (due to wrap-around), we'll
      // ensure that we only consider random values that are less than or
      // equal to the highest multiple of charset.length that is less than
      // 255.
      let highestMultiple =
        Math.floor((255 /* upper limit */ - 1) / charset.length) *
        charset.length;

      while (
        recoveryCode.length <
        ArchiveEncryptionState.GENERATED_RECOVERY_CODE_LENGTH
      ) {
        let randomValue = new Uint8Array(1);
        crypto.getRandomValues(randomValue);
        // If the random value is higher than highestMultiple, try again.
        if (randomValue > highestMultiple) {
          continue;
        }
        // Otherwise, we're within the highest multiple, meaning we can mod
        // the generated number to choose a character from charset.
        let randomIndex = randomValue % charset.length;
        recoveryCode += charset[randomIndex];
      }
    }

    // Next, we generate a 32-byte salt, and then concatenate a static suffix
    // to it, including the version number.
    lazy.logConsole.debug("Creating salt");
    let textEncoder = new TextEncoder();
    const SALT_SUFFIX = textEncoder.encode(
      "backupkey-v" + ArchiveEncryptionState.VERSION
    );
    let saltPrefix = new Uint8Array(32);
    crypto.getRandomValues(saltPrefix);

    let salt = new Uint8Array(saltPrefix.length + SALT_SUFFIX.length);
    salt.set(saltPrefix);
    salt.set(SALT_SUFFIX, saltPrefix.length);

    let { backupAuthKey, backupEncKey } =
      await lazy.ArchiveUtils.computeBackupKeys(recoveryCode, salt);

    lazy.logConsole.debug("Encrypting secrets with encKey");
    const NONCE_SIZE = 96;
    let nonce = crypto.getRandomValues(new Uint8Array(NONCE_SIZE));

    let secrets = JSON.stringify({
      privateKey: await crypto.subtle.exportKey("jwk", keyPair.privateKey),
      OSKeyStoreSecret: await lazy.OSKeyStore.exportRecoveryPhrase(),
    });
    let secretsBytes = textEncoder.encode(secrets);

    let wrappedSecrets = new Uint8Array(
      await crypto.subtle.encrypt(
        {
          name: "AES-GCM",
          iv: nonce,
        },
        backupEncKey,
        secretsBytes
      )
    );

    this.#state = {
      publicKey: keyPair.publicKey,
      salt,
      backupAuthKey,
      nonce,
      wrappedSecrets,
    };

    return recoveryCode;
  }

  /**
   * Serializes an ArchiveEncryptionState instance into an object that can be
   * safely persisted to disk.
   *
   * @returns {Promise<object>}
   */
  async serialize() {
    let publicKey = await crypto.subtle.exportKey("jwk", this.#state.publicKey);
    let salt = lazy.ArchiveUtils.arrayToBase64(this.#state.salt);
    let backupAuthKey = lazy.ArchiveUtils.arrayToBase64(
      this.#state.backupAuthKey
    );
    let nonce = lazy.ArchiveUtils.arrayToBase64(this.#state.nonce);
    let wrappedSecrets = lazy.ArchiveUtils.arrayToBase64(
      this.#state.wrappedSecrets
    );
    let result = {
      publicKey,
      salt,
      backupAuthKey,
      nonce,
      wrappedSecrets,
      version: ArchiveEncryptionState.VERSION,
    };

    return result;
  }

  /**
   * Deserializes an object created via serialize() and updates its internal
   * state to match the deserialization.
   *
   * @param {object} stateData
   *   The object generated via serialize()
   * @returns {Promise<undefined>}
   */
  async #deserialize(stateData) {
    lazy.logConsole.debug(
      "Deserializing from state with version ",
      stateData.version
    );

    // If we ever need to do a migration from one ArchiveEncryptionState
    // version to another, this is where we might do it. We don't currently
    // have any need to do migrations just yet though, so any version that
    // doesn't match the one that we can accept is rejected.
    if (stateData.version != ArchiveEncryptionState.VERSION) {
      throw new lazy.BackupError(
        "The ArchiveEncryptionState version is from a newer version.",
        lazy.ERRORS.UNSUPPORTED_BACKUP_VERSION
      );
    }

    let publicKey = await crypto.subtle.importKey(
      "jwk",
      stateData.publicKey,
      { name: "RSA-OAEP", hash: "SHA-256" },
      true /* extractable */,
      ["encrypt"]
    );
    let backupAuthKey = lazy.ArchiveUtils.stringToArray(
      stateData.backupAuthKey
    );
    let salt = lazy.ArchiveUtils.stringToArray(stateData.salt);
    let nonce = lazy.ArchiveUtils.stringToArray(stateData.nonce);
    let wrappedSecrets = lazy.ArchiveUtils.stringToArray(
      stateData.wrappedSecrets
    );

    this.#state = {
      publicKey,
      backupAuthKey,
      salt,
      nonce,
      wrappedSecrets,
    };
  }

  /**
   * @typedef {object} InitializationResult
   * @property {string|undefined} recoveryCode
   *   The generated recovery code if the initialization happened without
   *   deserialization.
   * @property {ArchiveEncryptionState} instance
   *   The constructed ArchiveEncryptionState.
   */

  /**
   * Constructs a new ArchiveEncryptionState. If a stateData object is passed,
   * the ArchiveEncryptionState will attempt to be deserialized from it -
   * otherwise, new state data will be generated automatically. This might
   * reject if the user is prompted to authenticate to their OSKeyStore, and
   * they cancel the authentication.
   *
   * @param {object|string|undefined} stateDataOrRecoveryCode
   *   Either the object generated via serialize(), a recovery code to be
   *   used to generate the state, or undefined.
   * @returns {Promise<InitializationResult>}
   */
  static async initialize(stateDataOrRecoveryCode) {
    ArchiveEncryptionState.#isInternalConstructing = true;
    let instance = new ArchiveEncryptionState();
    if (typeof stateDataOrRecoveryCode == "object") {
      await instance.#deserialize(stateDataOrRecoveryCode);
      return { instance };
    }
    let recoveryCode = await instance.#enable(stateDataOrRecoveryCode);
    return { instance, recoveryCode };
  }
}
