CryptoUtils.java

/*
** Module   : CryptoUtis.java
** Abstract : Provides support for the 4GL cryptography
**
** Copyright (c) 2019-2024, Golden Code Development Corporation.
**
**
** -#- -I- --Date-- ----------------------------------------Description---------------------------------------
** 001 IAS 20190518 Created initial version.
** 002 CA  20220318 Fixed GENERATE-PBE-KEY, tail bytes are added by computing the hash of the previous hash
**                  appended with the password.
** 003 TJD 20240220 Removed compiler warnings related with libraries updates
*/

/*
** This program is free software: you can redistribute it and/or modify
** it under the terms of the GNU Affero General Public License as
** published by the Free Software Foundation, either version 3 of the
** License, or (at your option) any later version.
**
** This program 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 Affero General Public License for more details.
**
** You may find a copy of the GNU Affero GPL version 3 at the following
** location: https://www.gnu.org/licenses/agpl-3.0.en.html
** 
** Additional terms under GNU Affero GPL version 3 section 7:
** 
**   Under Section 7 of the GNU Affero GPL version 3, the following additional
**   terms apply to the works covered under the License.  These additional terms
**   are non-permissive additional terms allowed under Section 7 of the GNU
**   Affero GPL version 3 and may not be removed by you.
** 
**   0. Attribution Requirement.
** 
**     You must preserve all legal notices or author attributions in the covered
**     work or Appropriate Legal Notices displayed by works containing the covered
**     work.  You may not remove from the covered work any author or developer
**     credit already included within the covered work.
** 
**   1. No License To Use Trademarks.
** 
**     This license does not grant any license or rights to use the trademarks
**     Golden Code, FWD, any Golden Code or FWD logo, or any other trademarks
**     of Golden Code Development Corporation. You are not authorized to use the
**     name Golden Code, FWD, or the names of any author or contributor, for
**     publicity purposes without written authorization.
** 
**   2. No Misrepresentation of Affiliation.
** 
**     You may not represent yourself as Golden Code Development Corporation or FWD.
** 
**     You may not represent yourself for publicity purposes as associated with
**     Golden Code Development Corporation, FWD, or any author or contributor to
**     the covered work, without written authorization.
** 
**   3. No Misrepresentation of Source or Origin.
** 
**     You may not represent the covered work as solely your work.  All modified
**     versions of the covered work must be marked in a reasonable way to make it
**     clear that the modified work is not originating from Golden Code Development
**     Corporation or FWD.  All modified versions must contain the notices of
**     attribution required in this license.
*/

package com.goldencode.p2j.util;

import java.nio.charset.*;
import java.security.*;
import java.util.*;
import java.util.function.Supplier;

import javax.crypto.*;
import javax.crypto.spec.*;

import org.apache.commons.codec.binary.*;
import org.bouncycastle.crypto.*;
import org.bouncycastle.crypto.digests.*;
import org.bouncycastle.crypto.engines.*;
import org.bouncycastle.crypto.generators.*;
import org.bouncycastle.crypto.params.*;

/**
 * Provides helper classes for the 4GL cryptography
 */

public class CryptoUtils
{
   /**
    * Supported symmetric ciphers' list as Set Please note that ImmutableSet.of()
    * "preserves order" so the names will be iterated in the order they are
    * specified below
    */
   public static final Set<String> CIPHERS = Collections.unmodifiableSet(
         new LinkedHashSet<>(Arrays.asList(
               "DES3_CBC_168", "DES3_ECB_168", "DES3_CFB_168", "DES3_OFB_168", "DES_CBC_56", 
               "DES_ECB_56", "DES_CFB_56", "DES_OFB_56", "AES_CBC_128", "AES_CBC_192", 
               "AES_CBC_256", "AES_ECB_128", "AES_ECB_192", "AES_ECB_256", "AES_CFB_128", 
               "AES_CFB_192", "AES_CFB_256", "AES_OFB_128", "AES_OFB_192", "AES_OFB_256", 
               "RC4_ECB_128"
            )
         )
   );
         
   /**
    * Supported PBE hash algorithms
    */
   public static final Map<String, Supplier<Digest>> DIGESTS = 
         Collections.unmodifiableMap(
               new LinkedHashMap<String, Supplier<Digest>>() 
               {{
                  put("MD5", () -> new MD5Digest()); 
                  put("SHA-1", () -> new SHA1Digest());
                  put("SHA-256", () -> new SHA256Digest());
                  put("SHA-512", () -> new SHA512Digest());
               }}
         );

   /**
    * Generates a password-based encryption key, based on the PKCS#5/RFC 2898 standard
    * 
    * @param password
    *        password
    * @param salt
    *        optional salt
    * @param digestName
    *        a name of the hash algorithm 
    * @param rounds
    *        a number of rounds
    *        
    * @return
    *        a 16-byte password-based encryption key, based on the PKCS#5/RFC 2898 
    * @throws NoSuchAlgorithmException 
    */
   public static byte[] generatePbeKey(byte[] password, byte[] salt, 
                                       String digestName, int rounds) 
   throws NoSuchAlgorithmException
   {
      Supplier<Digest> digestFactory = DIGESTS.get(digestName.toUpperCase());
      if (digestFactory == null)
      {
         throw new UnsupportedOperationException("Unknown digest: [" + digestName + "]");
      }
      Digest digest = digestFactory.get();
      PBEParametersGenerator generator = new PKCS5S1ParametersGenerator(digest); 
      byte[] bsalt;
      if (salt != null && salt.length > 0)
      {
         bsalt = new byte[8];
         System.arraycopy(salt, 0, bsalt, 0, Math.min(bsalt.length, salt.length));
      }
      else
      {
         bsalt = new byte[0];
      }
      int dL = digest.getDigestSize();
      int kL = SecurityPolicyManager.getSymmetricCiperParams().getKeySize();
      generator.init(password, bsalt, rounds);
      KeyParameter keyParameter = (KeyParameter)generator.generateDerivedParameters(
               Math.min(dL, kL) * 8
      );
      if (kL <= dL)
      {
         return keyParameter.getKey();
      }
      
      // Generate tail bytes, which is not PBKDF1.  4GL uses the OpenSSL encryption library, which generates
      // the tail bytes by hashing the previous hash appended with the password, using this formula:
      // D_i = HASH^count(D_(i-1) || data || salt)
      byte[] key = new byte[kL];
      int pos = 0;
      byte[] k1 = keyParameter.getKey();
      System.arraycopy(k1, 0, key, 0, k1.length);
      pos += k1.length;
      kL -= dL;

      do
      {
         byte[] newPass = new byte[k1.length + password.length];
         // append the password to the previous hash
         System.arraycopy(k1, 0, newPass, 0, k1.length);
         System.arraycopy(password, 0, newPass, k1.length, password.length);
         generator.init(newPass, bsalt, rounds);
         
         keyParameter = (KeyParameter)generator.generateDerivedParameters(dL * 8);
         k1 = keyParameter.getKey();
         System.arraycopy(k1, 0, key, pos, Math.min(kL, k1.length));
         pos += k1.length;
         kL -= dL;
      } 
      while (kL > dL );
      return key;
   }
   /**
    * The supported symmetric ciphers
    */
   public static enum CipherEngine
   {
      AES("AES"), DES("DES"), DES3("DESede"), RC4("RC4");
      private final String name;

      private CipherEngine(String name)
      {
         this.name = name;
      }

      /** 
       * @see java.lang.Enum#toString()
       */
      @Override
      public String toString()
      {
         return name;
      }

   }

   /**
    * The supported symmetric ciphers' modes
    */
   public static enum CipherMode
   {
      CBC, CFB, ECB, OFB
   }
   
   /**
    * Symmetric cipher parameters
    */
   public static class CipherParams
   {
      /** Cipher algorithm name */
      private final CipherEngine engine;
      /** Cipher algorithm mode */
      private final CipherMode mode;
      /** Cipher key size */
      private final int keySize;
      /** Cipher IV size */
      private final int ivSize;

      /**
       * Constructor which creates CipherParams instance for a 4GL style spec string 
       * 
       * @param spec
       *        A 4GL style spec string
       */
      public CipherParams(String spec)
      {
         if (spec == null || !CIPHERS.contains(spec))
         {
            throw new IllegalArgumentException(
                  "Symmetic encryption algorithm [" + spec + "] is not supported");
         }
         String[] parts = spec.toUpperCase().split("_");
         this.engine = CipherEngine.valueOf(parts[0]);
         this.mode = CipherMode.valueOf(parts[1]);
         int bs = Integer.parseInt(parts[2]);
         if (engine == CipherEngine.DES)
         {
            bs = 64;
         }
         if (engine == CipherEngine.DES3)
         {
            bs = 192;
         }
         this.keySize = bs / 8;
         this.ivSize = mode == CipherMode.ECB ? 0
               : engine == CipherEngine.DES || engine == CipherEngine.DES3 ? 8
                     : engine == CipherEngine.AES ? 16 : 0;
      }

      /**
       * Get cipher code
       * 
       * @return cipher code
       */
      public CipherEngine getEngine()
      {
         return engine;
      }
      
      /**
       * Get cipher mode
       * 
       * @return cipher mode
       */
      public CipherMode getMode()
      {
         return mode;
      }

      /**
       * Get key size in bytes
       * 
       * @return key size in bytes
       */
      public int getKeySize()
      {
         return keySize;
      }

      /**
       * Get IV size in bytes
       * 
       * @return IV size in bytes
       */
      public int getIvSize()
      {
         return ivSize;
      }
      
      /**
       * Get empty byte array of the required IV size 
       * 
       * @return empty byte array of the required IV size
       */
      public byte[] newIV()
      {
         return new byte[ivSize];
      }

      /**
       * Get empty byte array of the required key size 
       * 
       * @return empty byte array of the required key size
       */
      public byte[] newKey()
      {
         return new byte[keySize];
      }
      
      /**
       * Encrypt/Decript byte array
       * 
       * @param data
       *        source data
       * @param mode
       *        operation mode
       * @param key
       *        encryption/decryption key
       * @param iv
       *        encryption/decryption IV
       * 
       * @return
       *       the result of the encryption/decryption 
       *       
       * @throws NoSuchAlgorithmException
       * @throws NoSuchProviderException
       * @throws NoSuchPaddingException
       * @throws InvalidKeyException
       * @throws InvalidAlgorithmParameterException
       * @throws IllegalBlockSizeException
       * @throws BadPaddingException
       */
      public byte[] process(byte[] data, int mode, byte[] key, byte[] iv) 
      throws NoSuchAlgorithmException, 
             NoSuchProviderException, 
             NoSuchPaddingException, 
             InvalidKeyException, 
             InvalidAlgorithmParameterException, 
             IllegalBlockSizeException, 
             BadPaddingException {
         Cipher cipher = cipher();
         SecretKeySpec secretKeySpec = new SecretKeySpec(key, engine.toString());
         byte[] ivn = newIV();
         if (iv == null || iv.length == 0)
         {
            if (ivn.length == 0)
            {
               cipher.init(mode, secretKeySpec);
            }
            else 
            {
               cipher.init(mode, secretKeySpec, new IvParameterSpec(ivn));
            }
         } 
         else
         {
            System.arraycopy(iv, 0, ivn, 0, Math.min(ivn.length, iv.length));
            cipher.init(mode, secretKeySpec, new IvParameterSpec(ivn));
         }
         return cipher.doFinal(data);
      }
      
      /**
       * return String representation of the CipherParams instance
       */
      @Override
      public String toString()
      {
         return "CipherParams [engine=" + engine + ", mode=" + mode + ", keySize=" + keySize
               + ", ivSize=" + ivSize + "]";
      }
      
      /**
       * Get a new Cipher instance for the specified parameters
       * 
       * @return a new Cipher instance for the specified parameters
       * 
       * @throws NoSuchAlgorithmException
       * @throws NoSuchProviderException
       * @throws NoSuchPaddingException
       */
      public Cipher cipher()
            throws NoSuchAlgorithmException, NoSuchProviderException, NoSuchPaddingException
      {
         Cipher cipher = null;
         switch (engine)
         {
         case AES:
            BlockCipher bc = AESEngine.newInstance();
            switch (mode)
            {
            case CBC:
               cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
               break;
            case CFB:
               cipher = Cipher.getInstance("AES/CFB/NoPadding");
               break;
            case ECB:
               cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
               break;
            case OFB:
               cipher = Cipher.getInstance("AES/OFB/NoPadding");
               break;
            default:
               break;
            }
            break;
         case DES:
            switch (mode)
            {
            case CBC:
               cipher = Cipher.getInstance("DES/CBC/PKCS5Padding");
               break;
            case CFB:
               cipher = Cipher.getInstance("DES/CFB/NoPadding");
               break;
            case ECB:
               cipher = Cipher.getInstance("DES/ECB/PKCS5Padding");
               break;
            case OFB:
               cipher = Cipher.getInstance("DES/OFB/NoPadding");
               break;
            default:
               break;
            }
            break;
         case DES3:
            switch (mode)
            {
            case CBC:
               cipher = Cipher.getInstance("DESede/CBC/PKCS5Padding");
               break;
            case CFB:
               cipher = Cipher.getInstance("DESede/CFB/NoPadding");
               break;
            case ECB:
               cipher = Cipher.getInstance("DESede/ECB/PKCS5Padding");
               break;
            case OFB:
               cipher = Cipher.getInstance("DESede/OFB/NoPadding");
               break;
            default:
               break;
            }
            break;
         case RC4:
            cipher = Cipher.getInstance("RC4/ECB/NoPadding");
         }
         return cipher;
      }
   }
   
   /**
    * Test encryption/decryption with supported algorithms
    * @param args
    *        command line args 
    * @throws Exception 
    */
   public static void main(String... args) throws Exception
   {
      final String plain = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eius"
//                        + "mod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad m"
//                        + "inim veniam, quis nostrud exercitation ullamco laboris nisi ut aliqu"
//                        + "ip ex ea commodo consequat. Duis aute irure dolor in reprehenderit i"
//                        + "n voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excep"
//                        + "teur sint occaecat cupidatat non proident, sunt in culpa qui officia"
//                        + " deserunt mollit anim id est laborum."
      ;
      testAll(plain);
      generatePbeKey("123".getBytes(), null, "SHA-1", 10);
      generatePbeKey("123".getBytes(), new byte[8], "SHA-1", 10);
      byte[] mac = new byte[] {
            0x60, 0x62, 0x7f, 0x77, 0x62, 0x75, 0x63, 0x63
      };
      byte[] key = new byte[] {
            0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30
      };
      for(int i = 0; i < key.length; i++)
      {
         key[i] ^= mac[i]; 
      }
      System.out.println(new String(key, StandardCharsets.UTF_8));
   }

   /**
    * Test encryption/decryption for all supported ciphers
    * 
    * @param plain
    *        string to be encrypted
    * 
    * @throws Exception
    */
   private static void testAll(final String plain) throws Exception
   {
      final Random rnd = new Random();
      for (String c : CIPHERS)
      {
         byte[] plainBytes = plain.getBytes(StandardCharsets.UTF_8);
         System.out.println(c);
         CipherParams cp = SecurityPolicyManager.getSymmetricCipherParams(c);
         System.out.println(cp.toString());
         boolean equals = test(rnd, plainBytes, cp);
         System.out.println(equals ? "OK." : "Failed.");
      }
   }

   /**
    * Test encryption/decryption for a particular cipher
    * 
    * @param rnd
    *        Random generator  
    * @param plainBytes
    *        bytes to be encrypted
    * @param cp
    *        cipher parameters
    *        
    * @return <code>true</code> if operation succeeded or <code>false</code>
    *         otherwise
    * 
    * @throws NoSuchAlgorithmException
    * @throws NoSuchProviderException
    * @throws NoSuchPaddingException
    * @throws InvalidKeyException
    * @throws InvalidAlgorithmParameterException
    * @throws IllegalBlockSizeException
    * @throws BadPaddingException
    */
   private static boolean test(Random rnd, byte[] plainBytes, CipherParams cp)
         throws NoSuchAlgorithmException, NoSuchProviderException, NoSuchPaddingException,
         InvalidKeyException, InvalidAlgorithmParameterException, IllegalBlockSizeException,
         BadPaddingException
   {
      byte[] key = cp.newKey();
      rnd.nextBytes(key);
      byte[] iv = cp.newIV();
      rnd.nextBytes(iv);

      // encrypt
      byte[] encrypted = cp.process(plainBytes, Cipher.ENCRYPT_MODE, key, iv);
      System.out.printf("%d %s\n", encrypted.length, String.valueOf(Hex.encodeHex(encrypted)));

      byte[] decrypted = cp.process(encrypted, Cipher.DECRYPT_MODE, key, iv);
      if (plainBytes.length != decrypted.length)
      {
         System.out.printf("%d != %d\n", plainBytes.length, decrypted.length);
         return false;
      }
      boolean equals = true;
      for (int i = 0; i < decrypted.length; i++)
      {
         if (decrypted[i] != plainBytes[i])
         {
            equals = false;
            System.out.printf("!%d: %d != %d\n", i, decrypted[i], plainBytes[i]);
         }
      }
      if (!equals)
      {
         String result = new String(decrypted, StandardCharsets.UTF_8);
         System.out.printf("[%s]\n", result);
      }
      return equals;
   }
}