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×tamp=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×tamp=2022-07-06t11:10:43&expiration=2022-07-06t11:15:43"; // 69 chars-long
secret = "id=jsmith1×tamp=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.
The secret, a concatenated query string values, what needs to be encrypted:
string secret = "id=jsmith123×tamp=2022-07-06t11:10:43&expiration=2022-07-06t11:15:43";
The key:
string key = "C000000000000000"; //16 character-long
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);
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);
}
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);
}
public $cipher_method = "AES-256-ECB";
// Will not work:
//$secret = "id=jsmith12×tamp=2022-07-06t11:10:43&expiration=2022-07-06t11:15:43";
// Will work:
//$secret = "id=jsmith×tamp=2022-07-06t11:10:43&expiration=2022-07-06t11:15:43";
$key = "C000000000000000";
$token = openssl_encrypt($secret, $cipher_method, $key);
There are two things to be aware of:
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.