/*
 * Copyright 2018 Scytl Secure Electronic Voting SA
 *
 * All rights reserved
 *
 * See our extended copyright notice in *file 'Copyright.txt'
 * which is part of this source code package
 */

package com.scytl.slogger;

import static java.nio.file.Files.exists;
import static java.nio.file.Files.newInputStream;
import static java.text.MessageFormat.format;

import com.scytl.slogger.crypto.DefaultSecureLoggerKeyStore;
import com.scytl.slogger.crypto.SecureLoggerCryptographyFactory;
import com.scytl.slogger.crypto.SecureLoggerCryptographyProvider;
import com.scytl.slogger.crypto.SecureLoggerKeyStore;
import com.scytl.slogger.processor.LoggingEventProcessorList;
import com.scytl.slogger.processor.LoggingEventProcessorListFactory;

import java.io.IOException;
import java.io.InputStream;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.security.GeneralSecurityException;
import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.interfaces.RSAKey;
import java.util.Arrays;
import java.util.Base64;

import javax.crypto.SecretKey;

/**
 * Secure appender that combines encrypted hmac processor with signed checkpoints processors.
 *
 * <p>This appender can be configured in a log4j.xml configuration file. In this case the following
 * parameters must be used:
 *
 * <ul>
 *
 * <li>file -> the log file (required) <li>multipleFiles -> a new log file is created each time the
 * logger is initialized. A backup of the previous log file is performed. (in this implementation it
 * is always set to TRUE)
 *
 * <li>pattern -> the appender uses a PatternLayout that needs to be initialized with this parameter
 * (required)
 *
 * <li>verifyPattern -> pattern to parse the log file and perform the verification (optional). See
 * {@see org.apache.log4j.varia.LogFilePatternReceiver}
 *
 * <li>numberLogLines -> number of lines to log a secure checkpoint entry (optional)
 *
 * <li>timerLogMilliseconds -> number of milliseconds to log a secure checkpoint entry (optional)
 *
 * <li> pkcs12File -> file that contains the PKCS12 certificate (required)
 *
 * <li> pkcs12Password -> pkcs12 password
 *
 * <li> cryptographyProviderClass (optional) a class to be used as a provider instead of the default
 * one.
 *
 * </ul>
 */
public class SecureAppender extends SecureFileAppender {

  private static final String SIGNATURE_KEY_ALGORITHM = "RSA";
  
  private static final int SIGNATURE_KEY_LENGTH = 2048;
  
  private static final String CIPHER_KEY_ALGORITHM = "RSA";

  private static final int CIPHER_KEY_LENGTH = 2048;

  protected SecureLoggerKeyStore signaturePkcs12;

  protected String signaturePkcs12FileName;

  protected char[] signaturePkcs12Password;

  protected char[] cipherPkcs12Password;

  protected int numberLogLines;

  protected long timerLogMilliseconds;

  protected PrivateKey signaturePrivateKey;

  protected PrivateKey cipherPrivateKey;

  protected PublicKey cipherPublicKey;

  protected String cryptographyProviderClass;

  private SecureLoggerKeyStore cipherPkcs12;

  private String cipherPkcs12FileName;

  private SecureLoggerCryptographyProvider cryptographyProvider;

  @Override
  public void activateOptions() {
    try {
      doActivateOptions();
    } catch (GeneralSecurityException | IOException | SecureLoggerException e) {
      logError("Secure exception while initializing appender", e);
    } finally {
      cleanSensitiveData();
    }
  }

  /**
   * Returns the cryptography provider class.
   *
   * @return the cryptography provider class.
   */
  public String getCryptographyProviderClass() {
    return cryptographyProviderClass;
  }

  /**
   * Returns the number of log lines.
   *
   * @return the number of log lines.
   */
  public int getNumberLogLines() {
    return numberLogLines;
  }

  /**
   * Returns the timer log in milliseconds.
   *
   * @return the timer log in milliseconds.
   */
  public long getTimerLogMilliseconds() {
    return timerLogMilliseconds;
  }

  /**
   * Sets the cipher PKCS#12 certificate.
   *
   * @param cipherPkcs12Certificate the certificate
   * @throws SecureLoggerException failed to set the certificate.
   */
  public void setCipherPkcs12Certificate(final KeyStore cipherPkcs12Certificate)
      throws SecureLoggerException {
    cipherPkcs12 = new DefaultSecureLoggerKeyStore(cipherPkcs12Certificate);
  }

  /**
   * Password to open the PKCS12 Certificate for cipher operations. Since method copies array
   * {@code cipherPkcs12Password}, it is recommended to clean {@code cipherPkcs12Password} after
   * calling it.
   *
   * @param cipherPkcs12Password the password
   */
  public void setCipherPkcs12CharPassword(final char[] cipherPkcs12Password) {
    this.cipherPkcs12Password = Arrays.copyOf(cipherPkcs12Password, cipherPkcs12Password.length);
  }

  /**
   * Sets the file that contains the PKCS12 Certificate used for cipher operations.
   *
   * @param cipherPkcs12FileName the file name.
   */
  public void setCipherPkcs12FileName(final String cipherPkcs12FileName)
      throws SecureLoggerException {
    this.cipherPkcs12FileName = cipherPkcs12FileName;
  }

  /**
   * Sets the password to open the PKCS12 Certificate for cipher operations. This method is used to
   * load password from log4j properties. In all other cases it is recommended to use
   * {@link #setCipherPkcs12CharPassword(char[])}
   *
   * @param cipherPkcs12Password the password.
   */
  public void setCipherPkcs12Password(final String cipherPkcs12Password) {
    this.cipherPkcs12Password = cipherPkcs12Password.toCharArray();
  }

  /**
   * Sets the cipher private key.
   *
   * @param cipherPrivateKey the key
   */
  public void setCipherPrivateKey(final PrivateKey cipherPrivateKey) {
    this.cipherPrivateKey = cipherPrivateKey;
  }

  /**
   * Sets the cipher public key.
   *
   * @param cipherPublicKey the cipher public key.
   */
  public void setCipherPublicKey(final PublicKey cipherPublicKey) {
    this.cipherPublicKey = cipherPublicKey;
  }

  /**
   * Sets the cipher cryptography provider class.
   *
   * @param cryptographyProviderClass the cipher cryptography provider class.
   */
  public void setCryptographyProviderClass(final String cryptographyProviderClass) {
    this.cryptographyProviderClass = cryptographyProviderClass;
  }

  /**
   * Sets the number of lines to log a secure checkpoint entry.
   *
   * @param numberLogLines the number of lines to log a secure checkpoint entry.
   */
  public void setNumberLogLines(final int numberLogLines) {
    this.numberLogLines = numberLogLines;
  }

  /**
   * Sets the signature PKCS#12 certificate
   *
   * @param signaturePkcs12Certificate the signature PKCS#12 certificate
   * @throws SecureLoggerException failed to set the certificate.
   */
  public void setSignaturePkcs12Certificate(final KeyStore signaturePkcs12Certificate)
      throws SecureLoggerException {
    signaturePkcs12 = new DefaultSecureLoggerKeyStore(signaturePkcs12Certificate);
  }

  /**
   * Sets the password to open de PKCS12 Certificate for signature operations. Since method copies
   * array {@code signaturePkcs12Password}, it is recommended to clean
   * {@code signaturePkcs12Password} after calling it.
   *
   * @param signaturePkcs12Password the password.
   */
  public void setSignaturePkcs12CharPassword(final char[] signaturePkcs12Password) {
    this.signaturePkcs12Password =
        Arrays.copyOf(signaturePkcs12Password, signaturePkcs12Password.length);
  }

  /**
   * Sets the file that contains the PKCS12 Certificate used for signature operations.
   *
   * @param signaturePkcs12FileName the file
   */
  public void setSignaturePkcs12FileName(final String signaturePkcs12FileName) {
    this.signaturePkcs12FileName = signaturePkcs12FileName;
  }

  /**
   * Sets the password to open de PKCS12 Certificate for signature operations. This method is used
   * to load password from log4j properties. In all other cases it is recommended to use
   * {@link #setSignaturePkcs12CharPassword(char[])}
   *
   * @param signaturePkcs12Password the password.
   */
  public void setSignaturePkcs12Password(final String signaturePkcs12Password) {
    this.signaturePkcs12Password = signaturePkcs12Password.toCharArray();
  }

  /**
   * Sets the signature private key.
   *
   * @param signaturePrivateKey the signature private key.
   */
  public void setSignaturePrivateKey(final PrivateKey signaturePrivateKey) {
    this.signaturePrivateKey = signaturePrivateKey;
  }

  /**
   * Sets the milliseconds to log a secure checkpoint entry.
   *
   * @param timerLogMilliseconds the milliseconds to log a secure checkpoint entry.
   */
  public void setTimerLogMilliseconds(final long timerLogMilliseconds) {
    this.timerLogMilliseconds = timerLogMilliseconds;
  }

  /**
   * Initializes the appender logging event processor based by using the existing public and private
   * keys.
   *
   * @throws SecureLoggerException failed to initialize the appender.
   */
  protected void initializeLoggingEventProcessor() throws SecureLoggerException {
    try {
      if (getLoggingEventProcessor() == null) {
        LogFileSearcher searcher = new LogFileSearcher(lastLogFile);
        searcher.processLogFile();

        byte[] lastEncSessionKey = searcher.getLastEncryptedSessionKey();
        SecretKey secretKey = null;

        if (lastEncSessionKey != null) {
          byte[] keyData = Base64.getDecoder().decode(
              cryptographyProvider.asymmetricDecrypt(lastEncSessionKey, cipherPrivateKey));
          secretKey = cryptographyProvider.generateSymmetricKey(keyData);
        }

        final LoggingEventProcessorList loggingEventProcessor =
            LoggingEventProcessorListFactory.createLoggingEventProcessorList(cipherPublicKey,
                signaturePrivateKey, numberLogLines, this, timerLogMilliseconds,
                searcher.getLastHMac(), secretKey, cryptographyProvider);
        setLoggingEventProcessor(loggingEventProcessor);
      }
    } catch (GeneralSecurityException e) {
      throw new SecureLoggerException(e);
    }
  }

  /**
   * Performs activation. For internal use only.
   * 
   * @throws GeneralSecurityException security error occurred
   * @throws IOException I/E error occurred
   * @throws SecureLoggerException logger error occurred.
   */
  void doActivateOptions() throws GeneralSecurityException, IOException, SecureLoggerException {
    initializeCryptographyProvider();
    extractKeys();
    validateKeys();
    useKeys();
  }

  private void checkCipherKeyAttributesAreCorrect() throws SecureLoggerException {
    if (cipherPkcs12 != null && !cipherPkcs12.isForCipherOnly()) {
      throw new SecureLoggerException(
          "Key usage of cipher key does not contain keyEncipherment and dataEncipherment options.");
    }
    if (cipherPublicKey != null) {
      String algorithm = cipherPublicKey.getAlgorithm();
      if (!CIPHER_KEY_ALGORITHM.equals(algorithm)) {
        throw new SecureLoggerException(
            format("Invalid cipher key algorithm ''{0}''.", algorithm));
      }
      int length = ((RSAKey)cipherPublicKey).getModulus().bitLength();
      if (length != CIPHER_KEY_LENGTH) {
        throw new SecureLoggerException(
            format("Invalid cipher key length ''{0}''.", length));
      }
    }
  }

  private void checkCipherKeysMatch() throws SecureLoggerException, GeneralSecurityException {
    if (cipherPrivateKey != null && cipherPublicKey != null) {
      // We cannot use arbitrary data here because it is not guaranteed that cipher can encrypt it,
      // for example, RSA cannot encrypt data longer than key size / 8. However the provider 
      // configuration must guarantee encryption for a secret key.
      byte[] data = cryptographyProvider.generateSymmetricKey().getEncoded();
      byte[] encrypted = cryptographyProvider.asymmetricEncrypt(data, cipherPublicKey);
      byte[] decrypted = cryptographyProvider.asymmetricDecrypt(encrypted, cipherPrivateKey);
      if (!Arrays.equals(data, decrypted)) {
        throw new SecureLoggerException("Cipher private and public keys ado not match.");
      }
    }
  }

  private void checkPrivateKeysDiffer() throws SecureLoggerException {
    if (signaturePrivateKey != null && signaturePrivateKey.equals(cipherPrivateKey)) {
      throw new SecureLoggerException("Signature and Cipher keys should be different.");
    }
  }

  private void checkSignatureKeyAttributesAreCorrect() throws SecureLoggerException {
    if (signaturePkcs12 != null && !signaturePkcs12.isForSignatureOnly()) {
      throw new SecureLoggerException(
          "Key usage of signature key does not contain digitalSignature and nonRupudiation "
              + "options.");
    }
    if (signaturePrivateKey != null) {
      String algorithm = signaturePrivateKey.getAlgorithm();
      if (!SIGNATURE_KEY_ALGORITHM.equals(algorithm)) {
        throw new SecureLoggerException(
            format("Invalid signature key algorithm ''{0}''.", algorithm));
      }
      int length = ((RSAKey)signaturePrivateKey).getModulus().bitLength();
      if (length != SIGNATURE_KEY_LENGTH) {
        throw new SecureLoggerException(
            format("Invalid signature key length ''{0}''.", length));
      }
    }
  }

  private void cleanSensitiveData() {
    if (signaturePkcs12Password != null) {
      Arrays.fill(signaturePkcs12Password, '\u0000');
    }
    if (cipherPkcs12Password != null) {
      Arrays.fill(cipherPkcs12Password, '\u0000');
    }
  }

  private void extractCipherKeys()
      throws IOException, GeneralSecurityException, SecureLoggerException, KeyStoreException {
    if (cipherPrivateKey == null && cipherPublicKey == null) {
      if (cipherPkcs12 == null && cipherPkcs12FileName != null && cipherPkcs12Password != null) {
        cipherPkcs12 = readKeyStore(cipherPkcs12FileName, cipherPkcs12Password);
      }
      if (cipherPkcs12 != null) {
        cipherPrivateKey =
            cryptographyProvider.getUserCipherPrivateKey(cipherPkcs12, cipherPkcs12Password);
        cipherPublicKey = cryptographyProvider.getUserCipherPublicKey(cipherPkcs12);
      }
    }
  }

  private void extractKeys() throws GeneralSecurityException, IOException, SecureLoggerException {
    extractSignaturePrivateKey();
    extractCipherKeys();
  }

  private void extractSignaturePrivateKey()
      throws IOException, GeneralSecurityException, SecureLoggerException, KeyStoreException {
    if (signaturePrivateKey == null) {
      if (signaturePkcs12 == null && signaturePkcs12FileName != null
          && signaturePkcs12Password != null) {
        signaturePkcs12 = readKeyStore(signaturePkcs12FileName, signaturePkcs12Password);
      }
      if (signaturePkcs12 != null) {
        signaturePrivateKey = cryptographyProvider.getUserSignaturePrivateKey(signaturePkcs12,
            signaturePkcs12Password);
      }
    }
  }

  private void initializeCryptographyProvider() throws GeneralSecurityException {
    if (cryptographyProviderClass != null && cryptographyProviderClass.trim().length() > 0) {
      System.err.println(String.format("Using %s as provider class", cryptographyProviderClass));
      this.cryptographyProvider =
          new SecureLoggerCryptographyFactory().build(cryptographyProviderClass);
    } else {
      this.cryptographyProvider = new SecureLoggerCryptographyFactory().build();
    }
  }

  private SecureLoggerKeyStore readKeyStore(final String pkcs12File, final char[] pkcs12Password)
      throws IOException, GeneralSecurityException {
    SecureLoggerKeyStore store;
    // try load Base64 encoded p12 first
    try (InputStream stream = getKeyStoreStream(pkcs12File, true)) {
      store = cryptographyProvider.readKeyStore(stream, pkcs12Password);
    } catch (IOException e) {
      // load normal p12
      try (InputStream stream = getKeyStoreStream(pkcs12File, false)) {
        store = cryptographyProvider.readKeyStore(stream, pkcs12Password);
      }
    }
    return store;
  }

  private InputStream getKeyStoreStream(String pkcs12File, boolean isBase64) throws IOException {
    Path path = Paths.get(pkcs12File);
    InputStream stream;
    if (exists(path)) {
      stream = newInputStream(path);
    } else {
      ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
      stream = classLoader.getResourceAsStream(pkcs12File);
      if (stream == null) {
        throw new NoSuchFileException(pkcs12File);
      }
    }    
    if (isBase64) {
      stream = Base64.getDecoder().wrap(stream);
    }
    return stream;
  }

  private void useKeys() throws GeneralSecurityException, SecureLoggerException {

    // Keys could have either directly injected or extracted from the
    // p12

    if (signaturePrivateKey != null && cipherPublicKey != null && cipherPrivateKey != null) {
      super.activateOptions();
      initializeLoggingEventProcessor();
      appendHeader();
    }

  }

  private void validateKeys() throws SecureLoggerException, GeneralSecurityException {
    checkSignatureKeyAttributesAreCorrect();
    checkCipherKeyAttributesAreCorrect();
    checkPrivateKeysDiffer();
    checkCipherKeysMatch();
  }
}
