Search code examples
c#phpencryptionrijndaelequivalent

Need PHP 8 version of C#/.NET encryption code


An encryption C# code that has been in use for many years now needs to be converted to PHP 8.
I came close, and there's one remaining issue as described below:

For example, the secret below is longer than 71 characters and it is not encrypted correctly:

secret = "id=jsmith12&timestamp=2022-07-06t11:10:43&expiration=2022-07-06t11:15:43"; //71 chars-long

However, these secrets will be encrypted correctly, since they are less than 71 chars long:

secret = "id=jsmith&timestamp=2022-07-06t11:10:43&expiration=2022-07-06t11:15:43";  // 69 chars-long
    
secret = "id=jsmith1&timestamp=2022-07-06t11:10:43&expiration=2022-07-06t11:15:43"; // 70 chars-long

There is an online page where you can test if the generated token is correct: https://www.mybudgetpak.com/SSOTest/

You can evaluate the token by providing the generated token, the key, and the encryption method (Rijndael or Triple DES).

If the evaluation (decryption of the token) is successful, the test page will diplay the id, timestamp and expiration values used in the secret.

C# Code:

  1. The secret, a concatenated query string values, what needs to be encrypted:

    string secret = "id=jsmith123&timestamp=2022-07-06t11:10:43&expiration=2022-07-06t11:15:43";
    
  2. The key:

    string key = "C000000000000000";  //16 character-long
    
  3. ASCII encoded secret and key converted to byte array:

    System.Text.ASCIIEncoding encoding = new System.Text.ASCIIEncoding();
    
    byte[] encodedSecret = encoding.GetBytes(secret);
    byte[] encodedKey    = encoding.GetBytes(key);
    
  4. Option 1: Rijndael

    // Call the generate token method:
    string token = GenerateRijndaelSecureToken(encodedSecret, encodedKey);
    
    private string GenerateRijndaelSecureToken(byte[] encodedSecret, byte[] encodedKey)
    {
       Rijndael rijndael = Rijndael.Create();
    
       // the encodedKey must be a valid length so we pad it until it is (it checks // number of bits) 
       while (encodedKey.Length * 8 < rijndael.KeySize)
       {
          byte[] tmp = new byte[encodedKey.Length + 1];
          encodedKey.CopyTo(tmp, 0);
          tmp[tmp.Length - 1] = (byte)'\0';
          encodedKey = tmp;
       }
    
       rijndael.Key         = encodedKey;
       rijndael.Mode        = CipherMode.ECB;
       rijndael.Padding     = PaddingMode.Zeros;
       ICryptoTransform ict = rijndael.CreateEncryptor();
    
       byte[] result = ict.TransformFinalBlock(encodedSecret, 0, encodedSecret.Length);
    
       // convert the encodedSecret to a Base64 string to return 
       return Convert.ToBase64String(result);
    }
    
  5. Option 2: Triple DES

    // Call the generate token method:
    string token = GenerateSecureTripleDesToken(encodedSecret, encodedKey);
    
    private string generateSecureTripleDesToken(byte[] encodedSecret, byte[] encodedKey) 
    { 
       // Generate the secure token (this implementation uses 3DES) 
       TripleDESCryptoServiceProvider tdes = new TripleDESCryptoServiceProvider();             
    
       // the encodedKey must be a valid length so we pad it until it is (it checks // number of bits) 
       while (encodedKey.Length * 8 < tdes.KeySize) 
       { 
          byte[] tmp = new byte[encodedKey.Length + 1]; 
          encodedKey.CopyTo(tmp, 0); 
          tmp[tmp.Length - 1] = (byte) '\0'; 
          encodedKey = tmp; 
       } 
    
       tdes.Key = encodedKey; 
       tdes.Mode = CipherMode.ECB; 
       tdes.Padding = PaddingMode.Zeros; 
       ICryptoTransform ict = tdes.CreateEncryptor(); 
       byte[] result = ict.TransformFinalBlock(encodedSecret, 0, encodedSecret.Length); 
    
       // convert the encodedSecret to a Base64 string to return 
       return Convert.ToBase64String(result); 
    }
    

PHP 8 code:

public $cipher_method = "AES-256-ECB";
// Will not work:
//$secret = "id=jsmith12&timestamp=2022-07-06t11:10:43&expiration=2022-07-06t11:15:43";
    
// Will work:
//$secret = "id=jsmith&timestamp=2022-07-06t11:10:43&expiration=2022-07-06t11:15:43";
    
$key = "C000000000000000";
    
$token = openssl_encrypt($secret, $cipher_method, $key);

Solution

  • There are two things to be aware of:

    • The C# code pads the key with 0x00 values to the required length, i.e. 256 bits for AES-256 and 192 bits for 3DES. Since PHP/OpenSSL automatically pads keys that are too short with 0x00 values, this does not need to be implemented explicitly in the PHP code (although it would be more transparent).
    • The C# code uses Zero padding. PHP/OpenSSL on the other hand applies PKCS#7 padding. Since PHP/OpenSSL does not support Zero padding, the default PKCS#7 padding must be disabled with OPENSSL_ZERO_PADDING (note: this does not enable Zero padding, the name of the flag is poorly chosen) and Zero padding must be explicitly implemented, e.g. with:
    function zeropad($data, $bs) {
        $length = ($bs - strlen($data) % $bs) % $bs;
        return $data . str_repeat("\0", $length);
    }
    

    Here $bs is the block size (16 bytes for AES and 8 bytes for DES/3DES).

    Further changes are not necessary! A possible implementation is:

    $cipher_method = "aes-256-ecb"; // for AES (32 bytes key)
    //$cipher_method = "des-ede3";  // for 3DES (24 bytes key)
    
    // Zero pad plaintext (explicitly)
    $bs = 16;  // for AES
    //$bs = 8; // for 3DES
    $secret = zeropad($secret, $bs);
        
    // Zero pad key (implicitly)
    $key = "C000000000000000";
        
    $token = openssl_encrypt($secret, $cipher_method, $key, OPENSSL_ZERO_PADDING); // disable PKCS#7 default padding, Base64 encode (implicitly)
    print($token . PHP_EOL); 
    

    The ciphertexts generated in this way can be decrypted using the linked website (regardless of their length).


    The wrong padding causes decryption to fail on the web site (at least to not succeed reliably). However, the logic is not correct that decryption fails only if the plaintext is larger than 71 bytes (even if only the range between 65 and 79 bytes is considered). For example, decryption fails also with 66 bytes. The page source provides a bit more information than the GUI:

    Could not read \u0027expiration\u0027 as a date: 2022-07-06t11:15:43\u000e\u000e\u000e\u000e\u000e\u000e\u000e\u000e\u000e\u000e\u000e\u000e\u000e\u000e
    

    The problem is (as expected) the PKCS#7 padding bytes at the end: 14 0x0e values for 66 bytes.
    Why decryption works for some padding bytes and not for others can only be reliably answered if the decryption logic of the web site were known. In the end, however, the exact reason doesn't matter.


    Note that the applied key expansion is insecure. Also, ECB is insecure, 3DES is outdated, and Zero padding is unreliable.