// Copyright 2020 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package com.google.security.cryptauth.lib.securegcm;

import com.google.common.annotations.VisibleForTesting;
import com.google.protobuf.ByteString;
import com.google.protobuf.InvalidProtocolBufferException;
import com.google.security.cryptauth.lib.securegcm.DeviceToDeviceMessagesProto.DeviceToDeviceMessage;
import com.google.security.cryptauth.lib.securegcm.TransportCryptoOps.Payload;
import com.google.security.cryptauth.lib.securegcm.TransportCryptoOps.PayloadType;
import java.io.UnsupportedEncodingException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.security.SignatureException;
import java.util.Arrays;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;

/**
 * The full context of a secure connection. This object has methods to encode and decode messages
 * that are to be sent to another device.
 *
 * Subclasses keep track of the keys shared with the other device, and of the sequence in which the
 * messages are expected.
 */
public abstract class D2DConnectionContext {
  private static final String UTF8 = "UTF-8";
  private final int protocolVersion;

  protected D2DConnectionContext(int protocolVersion) {
    this.protocolVersion = protocolVersion;
  }

  /**
   * @return the version of the D2D protocol.
   */
  public int getProtocolVersion() {
    return protocolVersion;
  }

  /**
   * Once initiator and responder have exchanged public keys, use this method to encrypt and
   * sign a payload. Both initiator and responder devices can use this message.
   *
   * @param payload the payload that should be encrypted.
   */
  public byte[] encodeMessageToPeer(byte[] payload) {
    incrementSequenceNumberForEncoding();
    DeviceToDeviceMessage message = createDeviceToDeviceMessage(
        payload, getSequenceNumberForEncoding());
    try {
      return D2DCryptoOps.signcryptPayload(
          new Payload(PayloadType.DEVICE_TO_DEVICE_MESSAGE,
              message.toByteArray()),
          getEncodeKey());
    } catch (InvalidKeyException e) {
      // should never happen, since we agreed on the key earlier
      throw new RuntimeException(e);
    } catch (NoSuchAlgorithmException e) {
      // should never happen
      throw new RuntimeException(e);
    }
  }

  /**
   * Encrypting/signing a string for transmission to another device.
   *
   * @see #encodeMessageToPeer(byte[])
   *
   * @param payload the payload that should be encrypted.
   */
  public byte[] encodeMessageToPeer(String payload) {
    try {
      return encodeMessageToPeer(payload.getBytes(UTF8));
    } catch (UnsupportedEncodingException e) {
      // Should never happen - we should always be able to UTF-8-encode a string
      throw new RuntimeException(e);
    }
  }

  /**
   * Once InitiatorHello and ResponderHello(AndPayload) are exchanged, use this method
   * to decrypt and verify a message received from the other device. Both initiator and
   * responder device can use this message.
   *
   * @param message the message that should be encrypted.
   * @throws SignatureException if the message from the remote peer did not pass verification
   */
  public byte[] decodeMessageFromPeer(byte[] message) throws SignatureException {
    try {
      Payload payload = D2DCryptoOps.verifydecryptPayload(message, getDecodeKey());
      if (!PayloadType.DEVICE_TO_DEVICE_MESSAGE.equals(payload.getPayloadType())) {
        throw new SignatureException("wrong message type in device-to-device message");
      }

      DeviceToDeviceMessage messageProto = DeviceToDeviceMessage.parseFrom(payload.getMessage());
      incrementSequenceNumberForDecoding();
      if (messageProto.getSequenceNumber() != getSequenceNumberForDecoding()) {
        throw new SignatureException("Incorrect sequence number");
      }

      return messageProto.getMessage().toByteArray();
    } catch (InvalidKeyException e) {
      throw new SignatureException(e);
    } catch (NoSuchAlgorithmException e) {
      // this shouldn't happen - the algorithms are hard-coded.
      throw new RuntimeException(e);
    } catch (InvalidProtocolBufferException e) {
      throw new SignatureException(e);
    }
  }

  /**
   * Once InitiatorHello and ResponderHello(AndPayload) are exchanged, use this method
   * to decrypt and verify a message received from the other device. Both initiator and
   * responder device can use this message.
   *
   * @param message the message that should be encrypted.
   */
  public String decodeMessageFromPeerAsString(byte[] message) throws SignatureException {
    try {
      return new String(decodeMessageFromPeer(message), UTF8);
    } catch (UnsupportedEncodingException e) {
      // Should never happen - we should always be able to UTF-8-encode a string
      throw new RuntimeException(e);
    }
  }

  // package-private
  static DeviceToDeviceMessage createDeviceToDeviceMessage(byte[] message, int sequenceNumber) {
    DeviceToDeviceMessage.Builder deviceToDeviceMessage = DeviceToDeviceMessage.newBuilder();
    deviceToDeviceMessage.setSequenceNumber(sequenceNumber);
    deviceToDeviceMessage.setMessage(ByteString.copyFrom(message));
    return deviceToDeviceMessage.build();
  }

  /**
   * Returns a cryptographic digest (SHA256) of the session keys prepended by the SHA256 hash
   * of the ASCII string "D2D"
   * @throws NoSuchAlgorithmException if SHA 256 doesn't exist on this platform
   */
  public abstract byte[] getSessionUnique() throws NoSuchAlgorithmException;

  /**
   * Increments the sequence number used for encoding messages.
   */
  protected abstract void incrementSequenceNumberForEncoding();

  /**
   * Increments the sequence number used for decoding messages.
   */
  protected abstract void incrementSequenceNumberForDecoding();

  /**
   * @return the last sequence number used to encode a message.
   */
  @VisibleForTesting
  abstract int getSequenceNumberForEncoding();

  /**
   * @return the last sequence number used to decode a message.
   */
  @VisibleForTesting
  abstract int getSequenceNumberForDecoding();

  /**
   * @return the {@link SecretKey} used for encoding messages.
   */
  @VisibleForTesting
  abstract SecretKey getEncodeKey();

  /**
   * @return the {@link SecretKey} used for decoding messages.
   */
  @VisibleForTesting
  abstract SecretKey getDecodeKey();

  /**
   * Creates a saved session that can later be used for resumption.  Note, this must be stored in a
   * secure location.
   *
   * @return the saved session, suitable for resumption.
   */
  public abstract byte[] saveSession();

  /**
   * Parse a saved session info and attempt to construct a resumed context.
   * The first byte in a saved session info must always be the protocol version.
   * Note that an {@link IllegalArgumentException} will be thrown if the savedSessionInfo is not
   * properly formatted.
   *
   * @return a resumed context from a saved session.
   */
  public static D2DConnectionContext fromSavedSession(byte[] savedSessionInfo) {
    if (savedSessionInfo == null || savedSessionInfo.length == 0) {
      throw new IllegalArgumentException("savedSessionInfo null or too short");
    }

    int protocolVersion = savedSessionInfo[0] & 0xff;

    switch (protocolVersion) {
      case 0:
        // Version 0 has a 1 byte protocol version, a 4 byte sequence number,
        // and 32 bytes of AES key (1 + 4 + 32 = 37)
        if (savedSessionInfo.length != 37) {
          throw new IllegalArgumentException("Incorrect data length (" + savedSessionInfo.length
              + ") for v0 protocol");
        }
        int sequenceNumber = bytesToSignedInt(Arrays.copyOfRange(savedSessionInfo, 1, 5));
        SecretKey sharedKey = new SecretKeySpec(Arrays.copyOfRange(savedSessionInfo, 5, 37), "AES");
        return new D2DConnectionContextV0(sharedKey, sequenceNumber);

      case 1:
        // Version 1 has a 1 byte protocol version, two 4 byte sequence numbers,
        // and two 32 byte AES keys (1 + 4 + 4 + 32 + 32 = 73)
        if (savedSessionInfo.length != 73) {
          throw new IllegalArgumentException("Incorrect data length for v1 protocol");
        }
        int encodeSequenceNumber = bytesToSignedInt(Arrays.copyOfRange(savedSessionInfo, 1, 5));
        int decodeSequenceNumber = bytesToSignedInt(Arrays.copyOfRange(savedSessionInfo, 5, 9));
        SecretKey encodeKey =
            new SecretKeySpec(Arrays.copyOfRange(savedSessionInfo, 9, 41), "AES");
        SecretKey decodeKey =
            new SecretKeySpec(Arrays.copyOfRange(savedSessionInfo, 41, 73), "AES");
        return new D2DConnectionContextV1(encodeKey, decodeKey, encodeSequenceNumber,
            decodeSequenceNumber);

      default:
        throw new IllegalArgumentException("Cannot rebuild context, unkown protocol version: "
            + protocolVersion);
    }
  }

  /**
   * Convert 4 bytes in big-endian representation into a signed int.
   */
  static int bytesToSignedInt(byte[] bytes) {
    if (bytes.length != 4) {
      throw new IllegalArgumentException("Expected 4 bytes to encode int, but got: "
          + bytes.length + " bytes");
    }

    return ((bytes[0] << 24) & 0xff000000)
        |  ((bytes[1] << 16) & 0x00ff0000)
        |  ((bytes[2] << 8)  & 0x0000ff00)
        |   (bytes[3]        & 0x000000ff);
  }

  /**
   * Convert a signed int into a 4 byte big-endian representation
   */
  static byte[] signedIntToBytes(int val) {
    byte[] bytes = new byte[4];

    bytes[0] = (byte) ((val >> 24) & 0xff);
    bytes[1] = (byte) ((val >> 16) & 0xff);
    bytes[2] = (byte) ((val >> 8)  & 0xff);
    bytes[3] = (byte)  (val        & 0xff);

    return bytes;
  }
}
