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

// This module expects to be able to load in both main-thread module contexts,
// as well as ChromeWorker contexts. Do not ChromeUtils.importESModule
// anything there at the top-level that's not compatible with both contexts.

// The ArchiveUtils module is designed to be imported in both worker and
// main thread contexts.
import { ArchiveUtils } from "resource:///modules/backup/ArchiveUtils.sys.mjs";

const lazy = {};

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

/**
 * Both ArchiveEncryptor and ArchiveDecryptor maintain an internal nonce used as
 * a big-endian chunk counter. That counter is Uint8Array(16) array, which makes
 * doing simple things like adding to the counter somewhat cumbersome.
 * NonceUtils contains helper methods to do nonce-related management and
 * arithmetic.
 */
export const NonceUtils = {
  /**
   * Flips the bit in the nonce to indicate that the nonce will be used for the
   * last chunk to be encrypted. The specification calls for this bit to be the
   * 12th bit from the end.
   *
   * @param {Uint8Array} nonce
   *   The nonce to flip the bit on.
   */
  setLastChunkOnNonce(nonce) {
    if (nonce[4] != 0) {
      throw new lazy.BackupError(
        "Last chunk byte on nonce already set!",
        lazy.ERRORS.ENCRYPTION_FAILED
      );
    }

    // The nonce is 16 bytes so that we can use DataView / getBigUint64 for
    // arithmetic, but the spec says that we set the top byte of a 12-byte nonce
    // to 0x01. We ignore the first 4 bytes of the 16-byte nonce then, and stick
    // the 1 on the 12th byte (which in big-endian order is the 4th byte).
    nonce[4] = 1;
  },

  /**
   * Returns true if `setLastChunkOnNonce` has been called on the nonce already.
   *
   * @param {Uint8Array} nonce
   *   The nonce to check for the bit on.
   * @returns {boolean}
   */
  lastChunkSetOnNonce(nonce) {
    return nonce[4] == 1;
  },

  /**
   * Increments a nonce by some amount (defaulting to 1). The nonce should be
   * incremented once per chunk of maximum ARCHIVE_CHUNK_MAX_BYTES_SIZE bytes.
   * If this incrementing indicates that the number of bytes encrypted exceeds
   * ARCHIVE_MAX_BYTES_SIZE, an exception is thrown.
   *
   * @param {Uint8Array} nonce
   *   The nonce to increment.
   * @param {number} [incrementBy=1]
   *   The amount to increment the nonce by, defaulting to 1.
   */
  incrementNonce(nonce, incrementBy = 1) {
    let view = new DataView(nonce.buffer, 8);
    let nonceBigInt = view.getBigUint64(0);
    nonceBigInt += BigInt(incrementBy);
    if (
      nonceBigInt * BigInt(ArchiveUtils.ARCHIVE_CHUNK_MAX_BYTES_SIZE) >
      BigInt(ArchiveUtils.ARCHIVE_MAX_BYTES_SIZE)
    ) {
      throw new lazy.BackupError(
        "Exceeded archive maximum size.",
        lazy.ERRORS.ENCRYPTION_FAILED
      );
    }

    view.setBigUint64(0, nonceBigInt);
  },
};

/**
 * A class that is used to encrypt one or more chunks of a backup archive.
 * Callers must use the async static initialize() method to create an
 * ArchiveEncryptor, and then can encrypt() individual chunks. Callers can
 * call confirm() to generate the serializable JSON block to be included with
 * the archive.
 */
export class ArchiveEncryptor {
  /**
   * A hack that lets us ensure that an ArchiveEncryptor cannot be
   * constructed except via the ArchiveEncryptor.initialize static
   * method.
   *
   * See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes/Private_properties#simulating_private_constructors
   */
  static #isInternalConstructing = false;

  /**
   * The RSA-OAEP public key generated via an ArchiveEncryptionState to
   * encrypt a backup.
   *
   * @type {CryptoKey}
   */
  #publicKey = null;

  /**
   * A unique key generated for the individual archive, used to MAC the
   * metadata for a backup.
   *
   * @type {CryptoKey}
   */
  #authKey = null;

  /**
   * The wrapped archive encryption key material. The archive encryption key
   * material is randomly generated per backup to derive the encryption keys
   * for encrypting the backup, and is then wrapped using the #publicKey.
   *
   * @type {Uint8Array}
   */
  #wrappedArchiveKeyMaterial = null;

  /**
   * The derived AES-GCM encryption key used to encrypt chunks of the archive.
   *
   * @type {CryptoKey}
   */
  #encKey = null;

  /**
   * A big-endian counter nonce, incremented for each subsequent chunk of the
   * encrypted archive. The size of the nonce must be a multiple of 8 in order
   * to simplify the arithmetic via DataView / getBigUint64 / setBigUint64.
   *
   * @type {Uint8Array}
   */
  #nonce = new Uint8Array(16);

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

  /**
   * True if the last chunk flag has been set on the nonce already. Once this
   * returns true, no further chunks can be encrypted.
   *
   * @returns {boolean}
   */
  #isDone() {
    return NonceUtils.lastChunkSetOnNonce(this.#nonce);
  }

  /**
   * Constructs an ArchiveEncryptor to prepare it to encrypt chunks of an
   * archive. This must only be called via the ArchiveEncryptor.initialize
   * static method.
   *
   * @param {CryptoKey} publicKey
   *   The RSA-OAEP public key generated by an ArchiveEncryptionState.
   * @param {CryptoKey} backupAuthKey
   *   The AES-GCM BackupAuthKey generated by an ArchiveEncryptionState.
   * @returns {Promise<undefined>}
   */
  async #initialize(publicKey, backupAuthKey) {
    this.#publicKey = publicKey;

    // Generate a random archive key ArchiveKey. The key material is 256 random
    // bits.
    let archiveKeyMaterial = crypto.getRandomValues(new Uint8Array(32));

    // Encrypt ArchiveKey with the RSA-OEAP Public Key to form WrappedArchiveKey
    this.#wrappedArchiveKeyMaterial = new Uint8Array(
      await crypto.subtle.encrypt(
        {
          name: "RSA-OAEP",
        },
        this.#publicKey,
        archiveKeyMaterial
      )
    );

    let { archiveEncKey, authKey } = await ArchiveUtils.computeEncryptionKeys(
      archiveKeyMaterial,
      backupAuthKey
    );
    this.#authKey = authKey;
    this.#encKey = archiveEncKey;
  }

  /**
   * Encrypts a chunk from a backup archive.
   *
   * @param {Uint8Array} plaintextChunk
   *   The plaintext chunk of bytes to encrypt.
   * @param {boolean} [isLastChunk=false]
   *   Callers should set this to true if the chunk being encrypted is the
   *   last chunk. Once this is done, no additional chunk can be encrypted.
   * @returns {Promise<Uint8Array>}
   */
  async encrypt(plaintextChunk, isLastChunk = false) {
    if (this.#isDone()) {
      throw new lazy.BackupError(
        "Cannot encrypt any more chunks with this ArchiveEncryptor.",
        lazy.ERRORS.ENCRYPTION_FAILED
      );
    }

    if (plaintextChunk.byteLength > ArchiveUtils.ARCHIVE_CHUNK_MAX_BYTES_SIZE) {
      throw new lazy.BackupError(
        `Chunk is too large to encrypt: ${plaintextChunk.byteLength} bytes`,
        lazy.ERRORS.ENCRYPTION_FAILED
      );
    }
    if (
      plaintextChunk.byteLength != ArchiveUtils.ARCHIVE_CHUNK_MAX_BYTES_SIZE &&
      !isLastChunk
    ) {
      throw new lazy.BackupError(
        "Only last chunk can be smaller than the chunk max size",
        lazy.ERRORS.ENCRYPTION_FAILED
      );
    }

    if (isLastChunk) {
      NonceUtils.setLastChunkOnNonce(this.#nonce);
    }

    let ciphertextChunk;
    try {
      ciphertextChunk = await crypto.subtle.encrypt(
        {
          name: "AES-GCM",
          // Take only the last 12 bytes of the nonce, since the WebCrypto API
          // starts to behave differently when the IV is > 96 bits.
          iv: this.#nonce.subarray(4),
          tagLength: ArchiveUtils.TAG_LENGTH,
        },
        this.#encKey,
        plaintextChunk
      );
    } catch (e) {
      throw new lazy.BackupError(
        "Failed to encrypt a chunk.",
        lazy.ERRORS.ENCRYPTION_FAILED
      );
    }

    NonceUtils.incrementNonce(this.#nonce);

    return new Uint8Array(ciphertextChunk);
  }

  /**
   * Signs the metadata of a backup archive. This signature is used to both
   * provide an easy way of checking that a recovery code is valid, but also to
   * ensure that the metadata has not been tampered with. The returned Promise
   * resolves with the JSON block that can be written to the backup archive
   * file.
   *
   * @param {object} meta
   *   The metadata of a backup archive.
   * @param {Uint8Array} wrappedSecrets
   *   The encrypted backup secrets computed by ArchiveEncryptionState.
   * @param {Uint8Array} salt
   *   The salt used by ArchiveEncryptionState for the PBKDF2 stretching of the
   *   recovery code.
   * @param {Uint8Array} nonce
   *   The nonce used by ArchiveEncryptionState when wrapping the private key
   *   and OSKeyStore secret
   * @returns {Promise<Uint8Array>}
   *   The confirmation signature of the JSON block.
   */
  async confirm(meta, wrappedSecrets, salt, nonce) {
    let textEncoder = new TextEncoder();
    let metaBytes = textEncoder.encode(JSON.stringify(meta));
    let confirmation = new Uint8Array(
      await crypto.subtle.sign("HMAC", this.#authKey, metaBytes)
    );

    return {
      version: ArchiveUtils.SCHEMA_VERSION,
      encConfig: {
        wrappedSecrets: ArchiveUtils.arrayToBase64(wrappedSecrets),
        wrappedArchiveKeyMaterial: ArchiveUtils.arrayToBase64(
          this.#wrappedArchiveKeyMaterial
        ),
        salt: ArchiveUtils.arrayToBase64(salt),
        nonce: ArchiveUtils.arrayToBase64(nonce),
        confirmation: ArchiveUtils.arrayToBase64(confirmation),
      },
      meta,
    };
  }

  /**
   * Initializes an ArchiveEncryptor so that a caller can begin encrypting
   * chunks of a backup archive.
   *
   * @param {CryptoKey} publicKey
   *   The RSA-OAEP public key from an ArchiveEncryptionState.
   * @param {CryptoKey} backupAuthKey
   *   The AES-GCM BackupAuthKey from an ArchiveEncryptionState.
   * @returns {Promise<ArchiveEncryptor>}
   */
  static async initialize(publicKey, backupAuthKey) {
    ArchiveEncryptor.#isInternalConstructing = true;
    let instance = new ArchiveEncryptor();
    await instance.#initialize(publicKey, backupAuthKey);
    return instance;
  }
}

/**
 * A class that is used to decrypt one or more chunks of a backup archive.
 * Callers must use the async static initialize() method to create an
 * ArchiveDecryptor, and then can decrypt() individual chunks.
 */
export class ArchiveDecryptor {
  /**
   * A hack that lets us ensure that an ArchiveEncryptor cannot be
   * constructed except via the ArchiveEncryptor.initialize static
   * method.
   *
   * See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes/Private_properties#simulating_private_constructors
   */
  static #isInternalConstructing = false;

  /**
   * The unwrapped RSA-OAEP private key extracted from the wrapped secrets of
   * a backup.
   *
   * @type {CryptoKey}
   */
  #privateKey = null;

  /**
   * The unique AES-GCM encryption key used to encrypt this particular backup,
   * derived from the wrappedArchiveKeyMaterial.
   *
   * @type {CryptoKey}
   */
  #archiveEncKey = null;

  /**
   * @see ArchiveDecryptor.OSKeyStoreSecret
   *
   * @type {string}
   */
  #_OSKeyStoreSecret = null;

  /**
   * A big-endian counter nonce, incremented for each subsequent chunk of the
   * encrypted archive. The size of the nonce must be a multiple of 8 in order
   * to simplify the arithmetic via DataView / getBigUint64 / setBigUint64.
   *
   * @type {Uint8Array}
   */
  #nonce = new Uint8Array(16);

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

  /**
   * The unwrapped OSKeyStore secret that was stored within the JSON block.
   *
   * @type {string}
   */
  get OSKeyStoreSecret() {
    if (!this.isDone()) {
      throw new lazy.BackupError(
        "Cannot access OSKeyStoreSecret until all chunks are decrypted.",
        lazy.ERRORS.UNKNOWN
      );
    }
    return this.#_OSKeyStoreSecret;
  }

  /**
   * Initializes an ArchiveDecryptor to decrypt a backup. This will throw if
   * the recovery code is not valid, or the meta property of the JSON block
   * appears to have been tampered with since signing. It is assumed that a
   * caller of this function has already validated that the JSON block has been
   * validated against the appropriate ArchiveJSONBlock JSON schema.
   *
   * @param {string} recoveryCode
   *   The recovery code originally used to encrypt the backup archive.
   * @param {object} jsonBlock
   *   The parsed JSON block that was stored with the backup archive. See the
   *   ArchiveJSONBlock JSON schema.
   */
  async #initialize(recoveryCode, jsonBlock) {
    if (jsonBlock.version > ArchiveUtils.SCHEMA_VERSION) {
      throw new lazy.BackupError(
        `JSON block version ${jsonBlock.version} is greater than we can handle`,
        lazy.ERRORS.UNSUPPORTED_BACKUP_VERSION
      );
    }

    let { encConfig, meta } = jsonBlock;
    let salt = ArchiveUtils.stringToArray(encConfig.salt);
    let nonce = ArchiveUtils.stringToArray(encConfig.nonce);
    let wrappedSecrets = ArchiveUtils.stringToArray(encConfig.wrappedSecrets);
    let wrappedArchiveKeyMaterial = ArchiveUtils.stringToArray(
      encConfig.wrappedArchiveKeyMaterial
    );
    let confirmation = ArchiveUtils.stringToArray(encConfig.confirmation);

    // First, recompute the BackupAuthKey and BackupEncKey from the recovery
    // code and salt
    let { backupAuthKey, backupEncKey } = await ArchiveUtils.computeBackupKeys(
      recoveryCode,
      salt
    );

    // Next, unwrap the secrets - the private RSA-OAEP key, and the
    // OSKeyStore secret.
    let unwrappedSecrets;
    try {
      unwrappedSecrets = new Uint8Array(
        await crypto.subtle.decrypt(
          {
            name: "AES-GCM",
            iv: nonce,
          },
          backupEncKey,
          wrappedSecrets
        )
      );
    } catch (e) {
      throw new lazy.BackupError("Unauthenticated", lazy.ERRORS.UNAUTHORIZED);
    }

    let textDecoder = new TextDecoder();
    let secrets = JSON.parse(textDecoder.decode(unwrappedSecrets));

    this.#privateKey = await crypto.subtle.importKey(
      "jwk",
      secrets.privateKey,
      { name: "RSA-OAEP", hash: "SHA-256" },
      true /* extractable */,
      ["decrypt"]
    );

    this.#_OSKeyStoreSecret = secrets.OSKeyStoreSecret;

    // Now use the private key to decrypt the wrappedArchiveKeyMaterial
    let archiveKeyMaterial = await crypto.subtle.decrypt(
      {
        name: "RSA-OAEP",
      },
      this.#privateKey,
      wrappedArchiveKeyMaterial
    );

    let { archiveEncKey, authKey } = await ArchiveUtils.computeEncryptionKeys(
      archiveKeyMaterial,
      backupAuthKey
    );

    this.#archiveEncKey = archiveEncKey;

    // Now ensure that the backup metadata has not been tampered with.
    let textEncoder = new TextEncoder();
    let jsonBlockBytes = textEncoder.encode(JSON.stringify(meta));
    let verified = await crypto.subtle.verify(
      "HMAC",
      authKey,
      confirmation,
      jsonBlockBytes
    );
    if (!verified) {
      this.#poisonSelf();
      throw new lazy.BackupError(
        "Backup has been corrupted.",
        lazy.ERRORS.CORRUPTED_ARCHIVE
      );
    }
  }

  /**
   * Decrypts a chunk from a backup archive. This will throw if the cipherText
   * chunk appears to be too large (is greater than ARCHIVE_CHUNK_MAX)
   *
   * @param {Uint8Array} ciphertextChunk
   *   The ciphertext chunk of bytes to decrypt.
   * @param {boolean} [isLastChunk=false]
   *   Callers should set this to true if the chunk being decrypted is the
   *   last chunk. Once this is done, no additional chunks can be decrypted.
   * @returns {Promise<Uint8Array>}
   */
  async decrypt(ciphertextChunk, isLastChunk = false) {
    if (this.isDone()) {
      throw new lazy.BackupError(
        "Cannot decrypt any more chunks with this ArchiveDecryptor.",
        lazy.ERRORS.DECRYPTION_FAILED
      );
    }

    if (
      ciphertextChunk.byteLength >
      ArchiveUtils.ARCHIVE_CHUNK_MAX_BYTES_SIZE + ArchiveUtils.TAG_LENGTH_BYTES
    ) {
      throw new lazy.BackupError(
        `Chunk is too large to decrypt: ${ciphertextChunk.byteLength} bytes`,
        lazy.ERRORS.DECRYPTION_FAILED
      );
    }

    if (
      ciphertextChunk.byteLength !=
        ArchiveUtils.ARCHIVE_CHUNK_MAX_BYTES_SIZE +
          ArchiveUtils.TAG_LENGTH_BYTES &&
      !isLastChunk
    ) {
      throw new lazy.BackupError(
        "Only last chunk can be smaller than the chunk max size",
        lazy.ERRORS.DECRYPTION_FAILED
      );
    }

    if (isLastChunk) {
      NonceUtils.setLastChunkOnNonce(this.#nonce);
    }

    let plaintextChunk;

    try {
      plaintextChunk = await crypto.subtle.decrypt(
        {
          name: "AES-GCM",
          // Take only the last 12 bytes of the nonce, since the WebCrypto API
          // starts to behave differently when the IV is > 96 bits.
          iv: this.#nonce.subarray(4),
          tagLength: ArchiveUtils.TAG_LENGTH,
        },
        this.#archiveEncKey,
        ciphertextChunk
      );
    } catch (e) {
      this.#poisonSelf();
      throw new lazy.BackupError(
        "Failed to decrypt a chunk.",
        lazy.ERRORS.DECRYPTION_FAILED
      );
    }

    NonceUtils.incrementNonce(this.#nonce);

    return new Uint8Array(plaintextChunk);
  }

  /**
   * Something has gone wrong during decryption. We want to make sure we cannot
   * possibly decrypt anything further, so we blow away our internal state,
   * effectively breaking this ArchiveDecryptor.
   */
  #poisonSelf() {
    this.#privateKey = null;
    this.#archiveEncKey = null;
    this.#_OSKeyStoreSecret = null;
    this.#nonce = null;
  }

  /**
   * True if the last chunk flag has been set on the nonce already. Once this
   * returns true, no further chunks can be decrypted.
   *
   * @returns {boolean}
   */
  isDone() {
    return NonceUtils.lastChunkSetOnNonce(this.#nonce);
  }

  /**
   * Initializes an ArchiveDecryptor using the recovery code and the JSON
   * block that was extracted from the archive. The caller is expected to have
   * already checked that the JSON block adheres to the ArchiveJSONBlock
   * schema. The initialization may fail, and the Promise rejected, if the
   * recovery code is not correct, or the meta data of the JSON block has
   * changed since it was signed.
   *
   * @param {string} recoveryCode
   *   The recovery code to attempt to begin decryption with.
   * @param {object} jsonBlock
   *   See the ArchiveJSONBlock schema for details.
   * @returns {Promise<ArchiveDecryptor>}
   */
  static async initialize(recoveryCode, jsonBlock) {
    ArchiveDecryptor.#isInternalConstructing = true;
    let instance = new ArchiveDecryptor();
    await instance.#initialize(recoveryCode, jsonBlock);
    return instance;
  }
}
