Thursday, 6 June 2024

PGP Encryption and Digital signature in Dynamics 365 Finance and Operations

In this blog post I am going to discuss about Digital signature and PGP encryption for txt/xml data. This is generally required for payment processing files being sent out of D365 FO. 

As a pre-requisite, you must have a PGP encrypted public key in format as

-----BEGIN PGP PUBLIC KEY BLOCK-----

key content

-----END PGP PUBLIC KEY BLOCK-----


, a private key (if you don't have, then generate it using kleopatra tool) in format as 

-----BEGIN PGP ARMORED FILE-----

key content

-----END PGP ARMORED FILE-----


and a passphrase for private key. 

These keys and passphrase must be securely stored. I have stored it over Azure key vault and is using D365 Key vault parameters to retrieve that using azure client app. (for Dev box, Same is possible via txt files stored locally too, Or as a resource file for Dev and other environments, Or as a txt file over azure storage account).

Although its the safest way to use azure key vault, but there is a minor problem as to how the whole key is stored. Generally key must include special characters '\r\n' (Not directly visible unless read via code or using tools such as notepad++), when pasting it over key vault it ignores the new lines and considers whole data as a single line. To overcome this, do use the line ending characters before pasting it to key vault as

-----BEGIN PGP PUBLIC KEY BLOCK-----\r\nVersion: PGP Command Line v10.1.0 (Build 52) (Linux)\r\n\r\nkey content\r\n-----END PGP PUBLIC KEY BLOCK----

Once you are finalized with all these, lets proceed for code. 

We will start with the PGP helper class/

 

using Org.BouncyCastle.Bcpg.OpenPgp;
using Org.BouncyCastle.Security;
using Org.BouncyCastle.Bcpg;

class PGPHelper
{
    public System.IO.Stream EncryptFile(System.IO.Stream inputFile, Str publicKeyString)
    {
        PgpPublicKey publicKey = this.ReadPublicKey(publicKeyString);
        System.IO.Stream outputStream = new System.IO.MemoryStream();

        PgpEncryptedDataGenerator encryptedData = new PgpEncryptedDataGenerator(Org.BouncyCastle.Bcpg.SymmetricKeyAlgorithmTag::Cast5, true, new SecureRandom());
        encryptedData.AddMethod(publicKey);

        int bufferSize = 1 << 16;
        ArmoredOutputStream armoredStream = new ArmoredOutputStream(outputStream);
        System.IO.Stream outStream = encryptedData.Open(armoredStream, bufferSize);

        //PgpCompressedDataGenerator compressedData = new PgpCompressedDataGenerator(Org.BouncyCastle.Bcpg.CompressionAlgorithmTag::Uncompressed);
        //var compressedOut = compressedData.Open(outStream);

        System.Byte[] buffer = new System.Byte[bufferSize]();
        int len = inputFile.Read(buffer, 0, bufferSize);
        if (len > 0)
        {
            outStream.write(buffer, 0, len);
        }

        armoredStream.Close();
        outputStream.Position = 0;
        return outputStream;
    }

    public System.IO.Stream SignFile(System.IO.Stream inputFile, Str privateKeyString, Str passphrase)
    {
        PgpPrivateKey privateKey = this.ReadPrivateKey(privateKeyString, passphrase);
        System.IO.Stream outputStream = new System.IO.MemoryStream();
        PgpSignatureGenerator pgpSignatureGenerator = new PgpSignatureGenerator(privateKey.PublicKeyPacket.Algorithm, Org.BouncyCastle.Bcpg.HashAlgorithmTag::Sha256);
        pgpSignatureGenerator.InitSign(0, privateKey);

        PgpLiteralDataGenerator literalData = new PgpLiteralDataGenerator();
        System.Char format = Global::toanytype('b');
        var literalOut = literalData.Open(outputStream, format, "filename", inputFile.Length, DateTimeUtil::utcNow());

        var signatureOut = pgpSignatureGenerator.GenerateOnePassVersion(false);
        signatureOut.Encode(literalOut);

        inputFile.CopyTo(literalOut);

        var signature = pgpSignatureGenerator.Generate();
        signature.Encode(literalOut);

        literalOut.Close();
        outputStream.Position = 0;

        return outputStream;
    }

    private PgpPublicKey ReadPublicKey(Str publicKeyString)
    {
        System.Byte[] getpublicKeyByte = System.Text.Encoding::UTF8.GetBytes(publicKeyString);

        System.IO.Stream publicKeyStream = new System.IO.MemoryStream(getpublicKeyByte);
        System.IO.Stream decodedStream  = PgpUtilities::GetDecoderStream(publicKeyStream);
        PgpPublicKeyRingBundle pgpPub = new PgpPublicKeyRingBundle(decodedStream);
        PgpPublicKeyRing kRing;
        System.Collections.IEnumerator kRingenumerator = pgpPub.GetKeyRings().GetEnumerator();
        while (kRingenumerator.MoveNext())
        {
            kRing = kRingenumerator.Current;
            PgpPublicKey k;
            System.Collections.IEnumerator kenumerator = kRing.GetPublicKeys().GetEnumerator();
            while (kenumerator.MoveNext())
            {
                k = kenumerator.Current;
                if (k.IsEncryptionKey)
                {
                    return k;
                }
            }
        }

        throw error('No encryption key found in public key ring.');
    }

    private PgpPrivateKey ReadPrivateKey(Str privateKeyString, Str passphrase)
    {
        System.Byte[] getprivateKeyByte = System.Text.Encoding::UTF8.GetBytes(privateKeyString);
        System.Byte[] getpassphraseByte = System.Text.Encoding::UTF8.GetBytes(passphrase);

        System.IO.Stream privateKeyStream = new System.IO.MemoryStream(getprivateKeyByte);
        System.IO.Stream decodedStream = PgpUtilities::GetDecoderStream(privateKeyStream);
        PgpSecretKeyRingBundle pgpSec = new PgpSecretKeyRingBundle(decodedStream);

        PgpSecretKey secretKey;
        PgpSecretKeyRing kRing;
        System.Collections.IEnumerator kRingenumerator = pgpSec.GetKeyRings().GetEnumerator();
        while (kRingenumerator.MoveNext())
        {
            kRing = kRingenumerator.Current;

            PgpSecretKey k;
            System.Collections.IEnumerator kenumerator = kRing.GetSecretKeys().GetEnumerator();
            while (kenumerator.MoveNext())
            {
                k = kenumerator.Current;
                if (k.IsSigningKey)
                {
                    secretKey = k;
                    break;
                }
            }

            if (secretKey != null)
            {
                break;
            }
        }

        if (secretKey == null)
        {
            throw error('No signing key found in secret key ring.');
        }
        
        System.Char[] passPhraseChar = System.Text.Encoding::UTF8.GetChars(getpassphraseByte);
        PgpPrivateKey privateKey = secretKey.ExtractPrivateKey(passPhraseChar);
        return privateKey;
    }

}

Code can be modified if compression is needed. (I have it commented, just uncomment it if needed).
Next to use the helper class to do the actual encryption and signature. 

We can read the file contents to be encrypted or use the data generated at runtime using io.stream.


internal final class RunnableClass1
{
	public static void main(Args _args)
    {
        KeyVaultParameters          keyVaultParameters;
                KeyVaultCertificateTable    certTable;
                //FinTechDotNetLib.PgpHelper pgpHelper = new FinTechDotNetLib.PgpHelper();
                PGPHelper   pgpHelper = new PGPHelper();
                System.IO.Stream encryptedStream;
                System.IO.Stream signedStream;
                
         select keyVaultParameters
            where  keyVaultParameters.Name = 'keyVaulttest';
            
            System.IO.Stream streamName = new System.IO.MemoryStream(System.Text.Encoding::UTF8.GetBytes(System.IO.File::ReadAllText(@"D:\Test.xml")));

                if (keyVaultParameters.DigitalSignature) //This is custom No/Yes field
                {
                    str privateKey = KeyVaultCertificateHelper::getManualSecretValue(KeyVaultCertificateTable::findByName('privatekey').RecId);
                    str formattedPrivateKey;
                    formattedPrivateKey = strReplace(privateKey, "\\r\\n", '\r\n');

                    signedStream = pgpHelper.SignFile(streamName, formattedPrivateKey, KeyVaultCertificateHelper::getManualSecretValue(KeyVaultCertificateTable::findByName('passphrase').RecId));
                }
                if (keyVaultParameters.DigitalSignature && keyVaultParameters.Encryption) //These are custom No/Yes field
                {
                    str publicKey = KeyVaultCertificateHelper::getManualSecretValue(KeyVaultCertificateTable::findByName('publickey').RecId);
                    str formattedPublicKey;
                    formattedPublicKey = strReplace(publicKey, "\\r\\n", '\r\n');
                    encryptedStream = pgpHelper.EncryptFile(signedStream, formattedPublicKey);
                }
                else if (keyVaultParameters.Encryption && !keyVaultParameters.DigitalSignature) //These are custom No/Yes field
                {
                    str publicKey = KeyVaultCertificateHelper::getManualSecretValue(KeyVaultCertificateTable::findByName('publickey').RecId);
                    str formattedPublicKey;
                    formattedPublicKey = strReplace(publicKey, "\\r\\n", '\r\n');
                    encryptedStream = pgpHelper.EncryptFile(streamName, formattedPublicKey);
                }
                
                if (keyVaultParameters.Encryption)
                {
                    System.IO.StreamReader streamReader = new System.IO.StreamReader(encryptedStream);
                	info (streamReader.ReadToEnd());
                }
                else
                {
                      System.IO.StreamReader streamReader = new System.IO.StreamReader(encryptedStream);
                      info (streamReader.ReadToEnd());
                }
    }
}

Helper class can also be designed using C# and .Net assembly can then be used to pass the file stream and key strings, but its just easier to manage that with x++ and is easy to debug in case of any issues. 

Below is equivalent C# Code for helper if  needed.
using System;
using System.Linq;
using System.Text;
using System.IO;
using Azure.Identity;
using Azure.Security.KeyVault.Secrets;
using Org.BouncyCastle.Bcpg.OpenPgp;
using Org.BouncyCastle.Security;

namespace FinTechDotNetLib
{
    public class PgpHelper
    {
        public Stream EncryptFile(Stream inputFile, string publicKeyString)
        {
            PgpPublicKey publicKey = ReadPublicKey(publicKeyString);

            MemoryStream outputStream = new MemoryStream();

            PgpEncryptedDataGenerator encryptedDataGenerator = new PgpEncryptedDataGenerator(Org.BouncyCastle.Bcpg.SymmetricKeyAlgorithmTag.Cast5, true, new SecureRandom());
            encryptedDataGenerator.AddMethod(publicKey);

            using (Org.BouncyCastle.Bcpg.ArmoredOutputStream armoredOut = new Org.BouncyCastle.Bcpg.ArmoredOutputStream(outputStream))
            {
                // Open the encrypted stream
                using (Stream encryptedOut = encryptedDataGenerator.Open(armoredOut, new byte[1 << 16]))
                {
                    //PgpCompressedDataGenerator compressedDataGenerator = new PgpCompressedDataGenerator(Org.BouncyCastle.Bcpg.CompressionAlgorithmTag.Uncompressed);
                    //Stream compressedOut = compressedDataGenerator.Open(encryptedOut);

                    // Write the input file data to the encrypted stream
                    byte[] buffer = new byte[1 << 16];
                    int len;
                    while ((len = inputFile.Read(buffer, 0, buffer.Length)) > 0)
                    {
                        encryptedOut.Write(buffer, 0, len);
                    }
                }
            }

            outputStream.Position = 0;

            return outputStream;
        }

        public Stream SignFile(Stream inputFile, string privateKeyString, string passphrase)
        {
            PgpPrivateKey privateKey = ReadPrivateKey(privateKeyString, passphrase);

            MemoryStream outputStream = new MemoryStream();

            PgpSignatureGenerator signatureGenerator = new PgpSignatureGenerator(Org.BouncyCastle.Bcpg.PublicKeyAlgorithmTag.RsaGeneral, Org.BouncyCastle.Bcpg.HashAlgorithmTag.Sha256);
            signatureGenerator.InitSign(PgpSignature.BinaryDocument, privateKey);

            PgpLiteralDataGenerator literalDataGenerator = new PgpLiteralDataGenerator();
            Stream literalOut = literalDataGenerator.Open(outputStream, PgpLiteralData.Binary, "filename", inputFile.Length, DateTime.UtcNow);

            byte[] buffer = new byte[1 << 16];
            int len;
            while ((len = inputFile.Read(buffer, 0, buffer.Length)) > 0)
            {
                literalOut.Write(buffer, 0, len);
                signatureGenerator.Update(buffer, 0, len);
            }

            //signatureGenerator.Generate().Encode(literalOut);
            PgpSignature signature = signatureGenerator.Generate();

            // Write the signature to the output stream
            using (Stream signatureStream = new MemoryStream())
            {
                signature.Encode(signatureStream);
                signatureStream.Position = 0;
                signatureStream.CopyTo(outputStream);
            }

            literalOut.Close();            

            outputStream.Position = 0;

            return outputStream;
        }

        private PgpPublicKey ReadPublicKey(string publicKeyString)
        {
            using (Stream keyIn = new MemoryStream(Encoding.UTF8.GetBytes(publicKeyString)))
            using (Stream inputStream = PgpUtilities.GetDecoderStream(keyIn))
            {
                PgpPublicKeyRingBundle publicKeyRingBundle = new PgpPublicKeyRingBundle(inputStream);
                foreach (PgpPublicKeyRing keyRing in publicKeyRingBundle.GetKeyRings())
                {
                    foreach (PgpPublicKey key in keyRing.GetPublicKeys())
                    {
                        if (key.IsEncryptionKey)
                        {
                            return key;
                        }
                    }
                }
            }
            throw new ArgumentException("Can't find encryption key in key ring.");
        }

        private PgpPrivateKey ReadPrivateKey(string privateKeyString, string passphrase)
        {
            using (Stream keyIn = new MemoryStream(Encoding.UTF8.GetBytes(privateKeyString)))
            using (Stream inputStream = PgpUtilities.GetDecoderStream(keyIn))
            {
                PgpSecretKeyRingBundle secretKeyRingBundle = new PgpSecretKeyRingBundle(inputStream);
                foreach (PgpSecretKeyRing keyRing in secretKeyRingBundle.GetKeyRings())
                {
                    foreach (PgpSecretKey key in keyRing.GetSecretKeys())
                    {
                        if (key.IsSigningKey)
                        {
                            PgpPrivateKey privateKey = key.ExtractPrivateKey(passphrase.ToCharArray());
                            if (privateKey != null)
                            {
                                return privateKey;
                            }
                        }
                    }
                }
            }
            throw new ArgumentException("Can't find signing key in key ring.");
        }
    }
}

Thanks for reading! Happy coding !!