// GPG4Browsers - An OpenPGP implementation in javascript
// Copyright (C) 2011 Recurity Labs GmbH
//
// This library is free software; you can redistribute it and/or
// modify it under the terms of the GNU Lesser General Public
// License as published by the Free Software Foundation; either
// version 3.0 of the License, or (at your option) any later version.
//
// This library is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
// Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public
// License along with this library; if not, write to the Free Software
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA

import * as stream from '@openpgp/web-stream-tools';
import crypto from '../crypto';
import computeHKDF from '../crypto/hkdf';
import enums from '../enums';
import util from '../util';
import defaultConfig from '../config';

import LiteralDataPacket from './literal_data';
import CompressedDataPacket from './compressed_data';
import OnePassSignaturePacket from './one_pass_signature';
import SignaturePacket from './signature';
import PacketList from './packetlist';
import { UnsupportedError } from './packet';

// A SEIP packet can contain the following packet types
const allowedPackets = /*#__PURE__*/ util.constructAllowedPackets([
  LiteralDataPacket,
  CompressedDataPacket,
  OnePassSignaturePacket,
  SignaturePacket
]);

/**
 * Implementation of the Sym. Encrypted Integrity Protected Data Packet (Tag 18)
 *
 * {@link https://tools.ietf.org/html/rfc4880#section-5.13|RFC4880 5.13}:
 * The Symmetrically Encrypted Integrity Protected Data packet is
 * a variant of the Symmetrically Encrypted Data packet. It is a new feature
 * created for OpenPGP that addresses the problem of detecting a modification to
 * encrypted data. It is used in combination with a Modification Detection Code
 * packet.
 */
class SymEncryptedIntegrityProtectedDataPacket {
  static get tag() {
    return enums.packet.symEncryptedIntegrityProtectedData;
  }

  static fromObject({ version, aeadAlgorithm }) {
    if (version !== 1 && version !== 2) {
      throw new Error('Unsupported SEIPD version');
    }

    const seip = new SymEncryptedIntegrityProtectedDataPacket();
    seip.version = version;
    if (version === 2) {
      seip.aeadAlgorithm = aeadAlgorithm;
    }

    return seip;
  }

  constructor() {
    this.version = null;

    // The following 4 fields are for V2 only.
    /** @type {enums.symmetric} */
    this.cipherAlgorithm = null;
    /** @type {enums.aead} */
    this.aeadAlgorithm = null;
    this.chunkSizeByte = null;
    this.salt = null;

    this.encrypted = null;
    this.packets = null;
  }

  async read(bytes) {
    await stream.parse(bytes, async reader => {
      this.version = await reader.readByte();
      // - A one-octet version number with value 1 or 2.
      if (this.version !== 1 && this.version !== 2) {
        throw new UnsupportedError(`Version ${this.version} of the SEIP packet is unsupported.`);
      }

      if (this.version === 2) {
        // - A one-octet cipher algorithm.
        this.cipherAlgorithm = await reader.readByte();
        // - A one-octet AEAD algorithm.
        this.aeadAlgorithm = await reader.readByte();
        // - A one-octet chunk size.
        this.chunkSizeByte = await reader.readByte();
        // - Thirty-two octets of salt. The salt is used to derive the message key and must be unique.
        this.salt = await reader.readBytes(32);
      }

      // For V1:
      // - Encrypted data, the output of the selected symmetric-key cipher
      //   operating in Cipher Feedback mode with shift amount equal to the
      //   block size of the cipher (CFB-n where n is the block size).
      // For V2:
      // - Encrypted data, the output of the selected symmetric-key cipher operating in the given AEAD mode.
      // - A final, summary authentication tag for the AEAD mode.
      this.encrypted = reader.remainder();
    });
  }

  write() {
    if (this.version === 2) {
      return util.concat([new Uint8Array([this.version, this.cipherAlgorithm, this.aeadAlgorithm, this.chunkSizeByte]), this.salt, this.encrypted]);
    }
    return util.concat([new Uint8Array([this.version]), this.encrypted]);
  }

  /**
   * Encrypt the payload in the packet.
   * @param {enums.symmetric} sessionKeyAlgorithm - The symmetric encryption algorithm to use
   * @param {Uint8Array} key - The key of cipher blocksize length to be used
   * @param {Object} [config] - Full configuration, defaults to openpgp.config
   * @returns {Promise<Boolean>}
   * @throws {Error} on encryption failure
   * @async
   */
  async encrypt(sessionKeyAlgorithm, key, config = defaultConfig) {
    let bytes = this.packets.write();
    if (stream.isArrayStream(bytes)) bytes = await stream.readToEnd(bytes);

    if (this.version === 2) {
      this.cipherAlgorithm = sessionKeyAlgorithm;

      this.salt = crypto.random.getRandomBytes(32);
      this.chunkSizeByte = config.aeadChunkSizeByte;
      this.encrypted = await runAEAD(this, 'encrypt', key, bytes);
    } else {
      const { blockSize } = crypto.getCipherParams(sessionKeyAlgorithm);

      const prefix = await crypto.getPrefixRandom(sessionKeyAlgorithm);
      const mdc = new Uint8Array([0xD3, 0x14]); // modification detection code packet

      const tohash = util.concat([prefix, bytes, mdc]);
      const hash = await crypto.hash.sha1(stream.passiveClone(tohash));
      const plaintext = util.concat([tohash, hash]);

      this.encrypted = await crypto.mode.cfb.encrypt(sessionKeyAlgorithm, key, plaintext, new Uint8Array(blockSize), config);
    }
    return true;
  }

  /**
   * Decrypts the encrypted data contained in the packet.
   * @param {enums.symmetric} sessionKeyAlgorithm - The selected symmetric encryption algorithm to be used
   * @param {Uint8Array} key - The key of cipher blocksize length to be used
   * @param {Object} [config] - Full configuration, defaults to openpgp.config
   * @returns {Promise<Boolean>}
   * @throws {Error} on decryption failure
   * @async
   */
  async decrypt(sessionKeyAlgorithm, key, config = defaultConfig) {
    let encrypted = stream.clone(this.encrypted);
    if (stream.isArrayStream(encrypted)) encrypted = await stream.readToEnd(encrypted);

    let packetbytes;
    if (this.version === 2) {
      packetbytes = await runAEAD(this, 'decrypt', key, encrypted);
    } else {
      const { blockSize } = crypto.getCipherParams(sessionKeyAlgorithm);
      const decrypted = await crypto.mode.cfb.decrypt(sessionKeyAlgorithm, key, encrypted, new Uint8Array(blockSize));

      // there must be a modification detection code packet as the
      // last packet and everything gets hashed except the hash itself
      const realHash = stream.slice(stream.passiveClone(decrypted), -20);
      const tohash = stream.slice(decrypted, 0, -20);
      const verifyHash = Promise.all([
        stream.readToEnd(await crypto.hash.sha1(stream.passiveClone(tohash))),
        stream.readToEnd(realHash)
      ]).then(([hash, mdc]) => {
        if (!util.equalsUint8Array(hash, mdc)) {
          throw new Error('Modification detected.');
        }
        return new Uint8Array();
      });
      const bytes = stream.slice(tohash, blockSize + 2); // Remove random prefix
      packetbytes = stream.slice(bytes, 0, -2); // Remove MDC packet
      packetbytes = stream.concat([packetbytes, stream.fromAsync(() => verifyHash)]);
      if (!util.isStream(encrypted) || !config.allowUnauthenticatedStream) {
        packetbytes = await stream.readToEnd(packetbytes);
      }
    }

    this.packets = await PacketList.fromBinary(packetbytes, allowedPackets, config);
    return true;
  }
}

export default SymEncryptedIntegrityProtectedDataPacket;

/**
 * En/decrypt the payload.
 * @param {encrypt|decrypt} fn - Whether to encrypt or decrypt
 * @param {Uint8Array} key - The session key used to en/decrypt the payload
 * @param {Uint8Array | ReadableStream<Uint8Array>} data - The data to en/decrypt
 * @returns {Promise<Uint8Array | ReadableStream<Uint8Array>>}
 * @async
 */
export async function runAEAD(packet, fn, key, data) {
  const isSEIPDv2 = packet instanceof SymEncryptedIntegrityProtectedDataPacket && packet.version === 2;
  const isAEADP = !isSEIPDv2 && packet.constructor.tag === enums.packet.aeadEncryptedData; // no `instanceof` to avoid importing the corresponding class (circular import)
  if (!isSEIPDv2 && !isAEADP) throw new Error('Unexpected packet type');

  const mode = crypto.getAEADMode(packet.aeadAlgorithm);
  const tagLengthIfDecrypting = fn === 'decrypt' ? mode.tagLength : 0;
  const tagLengthIfEncrypting = fn === 'encrypt' ? mode.tagLength : 0;
  const chunkSize = 2 ** (packet.chunkSizeByte + 6) + tagLengthIfDecrypting; // ((uint64_t)1 << (c + 6))
  const chunkIndexSizeIfAEADEP = isAEADP ? 8 : 0;
  const adataBuffer = new ArrayBuffer(13 + chunkIndexSizeIfAEADEP);
  const adataArray = new Uint8Array(adataBuffer, 0, 5 + chunkIndexSizeIfAEADEP);
  const adataTagArray = new Uint8Array(adataBuffer);
  const adataView = new DataView(adataBuffer);
  const chunkIndexArray = new Uint8Array(adataBuffer, 5, 8);
  adataArray.set([0xC0 | packet.constructor.tag, packet.version, packet.cipherAlgorithm, packet.aeadAlgorithm, packet.chunkSizeByte], 0);
  let chunkIndex = 0;
  let latestPromise = Promise.resolve();
  let cryptedBytes = 0;
  let queuedBytes = 0;
  let iv;
  let ivView;
  if (isSEIPDv2) {
    const { keySize } = crypto.getCipherParams(packet.cipherAlgorithm);
    const { ivLength } = mode;
    const info = new Uint8Array(adataBuffer, 0, 5);
    const derived = await computeHKDF(enums.hash.sha256, key, packet.salt, info, keySize + ivLength);
    key = derived.subarray(0, keySize);
    iv = derived.subarray(keySize); // The last 8 bytes of HKDF output are unneeded, but this avoids one copy.
    iv.fill(0, iv.length - 8);
    ivView = new DataView(iv.buffer, iv.byteOffset, iv.byteLength);
  } else { // AEADEncryptedDataPacket
    iv = packet.iv;
    // ivView is unused in this case
  }
  const modeInstance = await mode(packet.cipherAlgorithm, key);
  return stream.transformPair(data, async (readable, writable) => {
    if (util.isStream(readable) !== 'array') {
      const buffer = new TransformStream({}, {
        highWaterMark: util.getHardwareConcurrency() * 2 ** (packet.chunkSizeByte + 6),
        size: array => array.length
      });
      stream.pipe(buffer.readable, writable);
      writable = buffer.writable;
    }
    const reader = stream.getReader(readable);
    const writer = stream.getWriter(writable);
    try {
      while (true) {
        let chunk = await reader.readBytes(chunkSize + tagLengthIfDecrypting) || new Uint8Array();
        const finalChunk = chunk.subarray(chunk.length - tagLengthIfDecrypting);
        chunk = chunk.subarray(0, chunk.length - tagLengthIfDecrypting);
        let cryptedPromise;
        let done;
        let nonce;
        if (isSEIPDv2) { // SEIPD V2
          nonce = iv;
        } else { // AEADEncryptedDataPacket
          nonce = iv.slice();
          for (let i = 0; i < 8; i++) {
            nonce[iv.length - 8 + i] ^= chunkIndexArray[i];
          }
        }
        if (!chunkIndex || chunk.length) {
          reader.unshift(finalChunk);
          cryptedPromise = modeInstance[fn](chunk, nonce, adataArray);
          cryptedPromise.catch(() => {});
          queuedBytes += chunk.length - tagLengthIfDecrypting + tagLengthIfEncrypting;
        } else {
          // After the last chunk, we either encrypt a final, empty
          // data chunk to get the final authentication tag or
          // validate that final authentication tag.
          adataView.setInt32(5 + chunkIndexSizeIfAEADEP + 4, cryptedBytes); // Should be setInt64(5 + chunkIndexSizeIfAEADEP, ...)
          cryptedPromise = modeInstance[fn](finalChunk, nonce, adataTagArray);
          cryptedPromise.catch(() => {});
          queuedBytes += tagLengthIfEncrypting;
          done = true;
        }
        cryptedBytes += chunk.length - tagLengthIfDecrypting;
        // eslint-disable-next-line no-loop-func
        latestPromise = latestPromise.then(() => cryptedPromise).then(async crypted => {
          await writer.ready;
          await writer.write(crypted);
          queuedBytes -= crypted.length;
        }).catch(err => writer.abort(err));
        if (done || queuedBytes > writer.desiredSize) {
          await latestPromise; // Respect backpressure
        }
        if (!done) {
          if (isSEIPDv2) { // SEIPD V2
            ivView.setInt32(iv.length - 4, ++chunkIndex); // Should be setInt64(iv.length - 8, ...)
          } else { // AEADEncryptedDataPacket
            adataView.setInt32(5 + 4, ++chunkIndex); // Should be setInt64(5, ...)
          }
        } else {
          await writer.close();
          break;
        }
      }
    } catch (e) {
      await writer.ready.catch(() => {});
      await writer.abort(e);
    }
  });
}
