Decrypting Apple Pay Payment Blob Using .NET – Part 3: Restore the symmetric key.

Series Intro: Decrypting Apple Pay Payment Blob Using .NET

Step 3 on Apple’s guide for decrypting the payment blob is where things really start to get interesting. Now we need to derive key material which will be fed into the encryption algorithm we’ll use to decrypt the cipher bytes. Don’t close your laptop! It’s not that bad.

I’ll touch on a couple of cryptography topics here, briefly. There are two types of encryption algorithms: Asymmetric & Symmetric. Asymmetric you can encrypt using a public key but you can only decrypt with the private key. Some things like digital signatures reverse the direction of how the keys are used, but the idea is the same. This is what X509 certificates use, typically with RSA keys. With a Symmetric algorithm, there is one key both sides must share which is used to encrypt and decrypt. Why use one over the other? Lot’s of reasons. Asymmetric is slow, and you are limited (based on the key size) to the amount of data you can transmit. Symmetric requires both parties have already established a key. SSL/TLS actually uses both, Asymmetric encryption to establish Symmetric keys, and then Symmetric encryption using those keys to transmit back and forth for the conversation (tunnel/channel). I’m sure people smarter than me will rip that description apart, but that’s my basic understanding!

For key derivation, Apple is using Elliptic Curve Diffie-Hellman (section 3a). Diffie-Hellman is an algorithm (named after its creators) for two parties to decide on shared key material without actually transmitting it over the wire. The idea being: if you don’t transmit it, then someone observing your conversation can’t steal it. Elliptic Curve Diffie-Hellman (ECDH) is a form of Diffie-Hellman that uses points on specific (named) elliptic curves for their public keys. If that wasn’t enough, it’s also great for impressing people at dinner parties! If you want to know more about how it works, there’s a great picture on Wikipedia that illustrates the concepts nicely, using paint colors.

The code to do this should be really simple:

        private static readonly byte[] s_ApplePayAlgorithmId = Encoding.UTF8.GetBytes((char)0x0d + "id-aes256-GCM");
        private static readonly byte[] s_ApplePayPartyUInfo = Encoding.UTF8.GetBytes("Apple");

        public static byte[] DeriveKeyMaterialUsingEllipticCurveDiffieHellmanAlgorithm(
            string headerEphemeralPublicKey,
            X509Certificate2 paymentProcessingCertificate)
        {
            string[] CommonNameParts = paymentProcessingCertificate.GetNameInfo(X509NameType.SimpleName, false)?.Split(':');
            if ((CommonNameParts?.Length ?? 0) != 2)
                throw new InvalidOperationException("PaymentProcessingCertificate Common Name could not be read or it does not contain Apple MerchantId.");

            byte[] PartyVInfo;
            using (HashAlgorithm SHA = new SHA256CryptoServiceProvider())
            {
                PartyVInfo = SHA.ComputeHash(Encoding.ASCII.GetBytes(CommonNameParts[1].Trim()));
            }

            using (CngKey PrivateKey = paymentProcessingCertificate.GetCngPrivateKey())
            {
                using (ECDiffieHellmanCng ECDH = new ECDiffieHellmanCng(PrivateKey))
                {
                    PublicKey EphemeralPublicKey = SecurityHelper.ParsePublicKeyFromX509Format(Convert.FromBase64String(headerEphemeralPublicKey));

                    return ECDH.DeriveKeyMaterial(
                        SecurityHelper.ParseECDiffieHellmanPublicKey256FromPublicKey(EphemeralPublicKey),
                        s_ApplePayAlgorithmId,
                        s_ApplePayPartyUInfo,
                        PartyVInfo);
                }
            }
        }

But there are some big problems:

  1. Apple’s Payment processing certificate (the one we loaded in the previous post) has an Elliptic Curve point as its key, .NET does not give us a way to read that into anything usable!
  2. Apple’s ephmeralPublicKey is in DER encoded X.509 SubjectPublicKeyInfo blob format, which .NET does not support!
  3. Apple is using NIST SP 800-56A, section 5.8.1 as its Key Derivation Function (KDF), which .NET does not support!

I think most people would have given up right there. Should you give up and just use a library like BouncyCastle or OpenSSL? Probably. My goal though is to make it work with as much .NET & Windows as I can, so let’s see how we can solve these three problems.

1. Loading the PrivateKey from the Payment processing certificate.

If you open your Payment processing certificate you can see in the details that the public key is ECC (256 Bits) format:

There are three extensions for reading PrivateKeys of specific types from X509 certificates: GetDSAPrivateKey, GetRSAPrivateKey, & GetECDsaPrivateKey. Those are close to what we need but don’t work for our purposes. I looked at the source to the GetECDsaPrivateKey and it is checking for specific Key Usages on the certificate, specifically those used for signing. Our certificate sadly has different usage flags, because it is for a different purpose (key derivation). Update: I have opened an issue on the dotnet/runtime repo to try and get the API expanded to support what we need to work with these certificates.

The thing to know about the security libraries in .NET is they really just wrap what is available on the platform. .NET on Windows is using Windows security, meaning it is calling into native libraries. We can do this too!

Here’s some code invoking the native library to load the private key just like those extensions are doing:

		// .NET does not have a way to get an ECC public/private key out of an X509 certificate. Remove this and Certificate NativeMethods if it ever gets one!
		public static CngKey GetCngPrivateKey(this X509Certificate2 certificate)
		{
			if (certificate == null)
				throw new ArgumentNullException(nameof(certificate));

			if (!certificate.HasPrivateKey)
				throw new InvalidOperationException("Certificate does not have a PrivateKey.");

			using (NativeMethods.SafeCertContextHandle certificateContext = NativeMethods.GetCertificateContext(certificate))
			{
				SafeNCryptKeyHandle privateKeyHandle = NativeMethods.TryAcquireCngPrivateKey(certificateContext, out CngKeyHandleOpenOptions openOptions);
				if (privateKeyHandle == null)
					throw new InvalidOperationException("Certificate PrivateKey could not be opened.");
				try
				{
					return CngKey.Open(privateKeyHandle, openOptions);
				}
				finally
				{
					privateKeyHandle.Dispose();
				}
			}
		}

That is calling into a helper class, NativeMethods, which I’ll post at the bottom of this entry. If you used the CertificateExtensions class from the last post, add this in there.

2. Working with Apple’s ephemeralPublicKey.

This took hours to crack. A few more words on the guide would have been great Apple! Ultimately this Stack Overflow answer had the best information, thank you bartonjs for the assist.

That begot this helper method to parse X509 Subject Public Key format blobs into PublicKey instances:

    public static class SecurityHelper
    {
        public static PublicKey ParsePublicKeyFromX509Format(byte[] x509SubjectPublicKeyInfoBlob)
        {
            Oid Oid = null;
            AsnEncodedData Parameters = null;
            AsnEncodedData KeyValue = null;

            using (MemoryStream Stream = new MemoryStream(x509SubjectPublicKeyInfoBlob))
            {
                using (BinaryReader Reader = new BinaryReader(Stream))
                {
                    try
                    {
                        while (Stream.Position < Stream.Length)
                        {
                            byte Token = Reader.ReadByte();
                            switch (Token)
                            {
                                case 0x30: // SEQUENCE
                                case 0x03: // BIT STRING
                                case 0x06: // OID
                                    byte PayloadLength = Reader.ReadByte();
                                    if (PayloadLength > 0x7F)
                                        throw new InvalidOperationException($"Payload lengths > 0x74 are not supported. Found at position [{Stream.Position - 1}]");

                                    if (Token == 0x30)
                                        continue;

                                    byte UnusedBits = 0;
                                    if (Token == 0x03)
                                    {
                                        UnusedBits = Reader.ReadByte();
                                        if (UnusedBits > 0)
                                            throw new InvalidOperationException($"Bit strings with unused bits are not supported. Found at position [{Stream.Position - 1}]");
                                        PayloadLength--;
                                    }

                                    byte[] Payload = new byte[PayloadLength];

                                    int BytesRead = Reader.Read(Payload, 0, Payload.Length);
                                    if (BytesRead != PayloadLength)
                                        throw new InvalidOperationException($"Payload length did not match. Found at position [{Stream.Position - 1}]");

                                    if (Token == 0x06)
                                    {
                                        if (Oid == null)
                                            Oid = new Oid(ConvertOidByteArrayToStringValue(Payload));
                                        else
                                            Parameters = new AsnEncodedData(Payload);
                                    }
                                    if (Token == 0x03)
                                        KeyValue = new AsnEncodedData(Payload);
                                    break;
                                default:
                                    throw new InvalidOperationException($"Unknown token byte [{Token:X2}] found at position [{Stream.Position - 1}].");
                            }
                        }

                        if (Oid == null || Parameters == null || KeyValue == null)
                            throw new InvalidOperationException("Required information could not be found in blob.");

                        return new PublicKey(Oid, Parameters, KeyValue);
                    }
                    catch (Exception ParseException)
                    {
                        throw new InvalidOperationException("Public key could not be parsed from X.509 format blob.", ParseException);
                    }
                }
            }
        }

        public static string ConvertOidByteArrayToStringValue(byte[] oid)
        {
            StringBuilder retVal = new StringBuilder();

            for (int i = 0; i < oid.Length; i++)
            {
                if (i == 0)
                {
                    int b = oid[0] % 40;
                    int a = (oid[0] - b) / 40;
                    retVal.Append($"{a}.{b}");
                }
                else if (oid[i] < 128)
                    retVal.Append($".{oid[i]}");
                else
                {
                    retVal.Append($".{((oid[i] - 128) * 128) + oid[i + 1]}");
                    i++;
                }
            }

            return retVal.ToString();
        }
    }

3. NIST SP 800-56A, section 5.8.1 Key Derivation Function (KDF).

In this case Apple was crystal clear with their guide, but that didn’t really make it any easier!

The Windows Cryptographic API: Next Generation (CNG) library has an operation NCryptDeriveKey that supports a Key Derivation Function (KDF) SP800_56A_CONCAT which turns out to be exactly what we need. So our task is to stitch up a native call to this method in order to derive our key material.

First step is to turn our ephemeralPublicKey into something we can use for Diffie-Hellman:

		public static ECDiffieHellmanPublicKey ParseECDiffieHellmanPublicKey256FromPublicKey(PublicKey publicKey)
		{
			if (publicKey == null)
				throw new ArgumentNullException(nameof(publicKey));

			if (publicKey?.EncodedKeyValue?.RawData?.Length != 65)
				throw new InvalidOperationException("KeyData length is invalid.");

			byte[] FinalBuffer = new byte[72];

			using (MemoryStream Stream = new MemoryStream(FinalBuffer))
			{
				using (BinaryWriter Writer = new BinaryWriter(Stream))
				{
					Writer.Write(s_Win32_ECDH_Public_256_MagicNumber);
					Writer.Write(s_Win32_ECDH_Public_256_Length);
					Writer.Write(publicKey.EncodedKeyValue.RawData, 1, publicKey.EncodedKeyValue.RawData.Length - 1);
				}
			}

			return ECDiffieHellmanCngPublicKey.FromByteArray(FinalBuffer, CngKeyBlobFormat.EccPublicBlob);
		}

A good class to add to our SecurityHelper. The ECDiffieHellmanPublicKey returned from that method will be used to figure out the shared secret, the brownish color on the Wikipedia illustration (called Common Secret on there).

EccPublicBlob format is actually BCRYPT_ECCKEY_BLOB structure, that’s what is being built above.

That becomes the otherPartyPublicKey parameter passed into this method, which is getting everything set up to call into the NCryptDeriveKey native method:

using System;
using System.Collections.ObjectModel;
using System.Security.Cryptography;
using System.Runtime.InteropServices;

using Microsoft.Win32.SafeHandles;

namespace Macross
{
	public static class CryptoExtensions
	{
		public static byte[] DeriveKeyMaterial(
			this ECDiffieHellmanCng provider,
			ECDiffieHellmanPublicKey otherPartyPublicKey,
			byte[] algorithmId,
			byte[] partyUInfo,
			byte[] partyVInfo)
		{
			if (provider == null)
				throw new ArgumentNullException(nameof(provider));
			if (otherPartyPublicKey == null)
				throw new ArgumentNullException(nameof(otherPartyPublicKey));
			if (algorithmId == null)
				throw new ArgumentNullException(nameof(algorithmId));
			if (partyUInfo == null)
				throw new ArgumentNullException(nameof(partyUInfo));
			if (partyVInfo == null)
				throw new ArgumentNullException(nameof(partyVInfo));

			Collection<IntPtr> ResourcesToFree = new Collection<IntPtr>();
			try
			{
				using (SafeNCryptSecretHandle Agreement = provider.DeriveSecretAgreementHandle(otherPartyPublicKey))
				{
					IntPtr AlgorithmIdPtr = Marshal.AllocHGlobal(algorithmId.Length);
					ResourcesToFree.Add(AlgorithmIdPtr);
					Marshal.Copy(algorithmId, 0, AlgorithmIdPtr, algorithmId.Length);

					IntPtr PartyUPtr = Marshal.AllocHGlobal(partyUInfo.Length);
					ResourcesToFree.Add(PartyUPtr);
					Marshal.Copy(partyUInfo, 0, PartyUPtr, partyUInfo.Length);

					IntPtr PartyVPtr = Marshal.AllocHGlobal(partyVInfo.Length);
					ResourcesToFree.Add(PartyVPtr);
					Marshal.Copy(partyVInfo, 0, PartyVPtr, partyVInfo.Length);

					NativeMethods.NCryptBuffer[] Buffers = new NativeMethods.NCryptBuffer[]
					{
						new NativeMethods.NCryptBuffer
						{
							cbBuffer = (uint)algorithmId.Length,
							BufferType = NativeMethods.BufferType.KDF_ALGORITHMID,
							pvBuffer = AlgorithmIdPtr
						},
						new NativeMethods.NCryptBuffer
						{
							cbBuffer = (uint)partyUInfo.Length,
							BufferType = NativeMethods.BufferType.KDF_PARTYUINFO,
							pvBuffer = PartyUPtr
						},
						new NativeMethods.NCryptBuffer
						{
							cbBuffer = (uint)partyVInfo.Length,
							BufferType = NativeMethods.BufferType.KDF_PARTYVINFO,
							pvBuffer = PartyVPtr
						}
					};

					IntPtr BufferPtr = Marshal.AllocHGlobal(Buffers.Length * Marshal.SizeOf(typeof(NativeMethods.NCryptBuffer)));

					ResourcesToFree.Add(BufferPtr);

					IntPtr Location = BufferPtr;
					for (int i = 0; i < Buffers.Length; i++)
					{
						Marshal.StructureToPtr(Buffers[i], Location, false);
						Location = new IntPtr(Location.ToInt64() + Marshal.SizeOf(typeof(NativeMethods.NCryptBuffer)));
					}

					NativeMethods.NCryptBufferDesc ParameterList = new NativeMethods.NCryptBufferDesc
					{
						cBuffers = Buffers.Length,
						pBuffers = BufferPtr
					};

					byte[] DerivedKey = new byte[32];

					NativeMethods.ErrorCode ErrorCode = NativeMethods.NCryptDeriveKey(
						Agreement,
						"SP800_56A_CONCAT",
						ref ParameterList,
						DerivedKey,
						DerivedKey.Length,
						out int NumberOfBytesDerived,
						0);

					if (ErrorCode != NativeMethods.ErrorCode.Success)
						throw new InvalidOperationException($"KeyMaterial could not be derived. ErrorCode [{ErrorCode}], Win32Error [{Marshal.GetLastWin32Error()}] returned.");

					if (NumberOfBytesDerived != 32)
						throw new InvalidOperationException("KeyMaterial size was invalid.");

					return DerivedKey;
				}
			}
			finally
			{
				foreach (IntPtr Resource in ResourcesToFree)
				{
					Marshal.FreeHGlobal(Resource);
				}
			}
		}
	}
}

The byte[] returned by DeriveKeyMaterial will be used to finally decrypt the Apple Pay data, in the next post.

As promised, here’s the NativeMethods class which contains all the platform definitions:

using System;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using System.Diagnostics;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;

using Microsoft.Win32.SafeHandles;

namespace Macross
{
	internal static partial class NativeMethods
	{
		public enum CertificateProperty
		{
			KeyProviderInfo = 2, // CERT_KEY_PROV_INFO_PROP_ID
			KeyContext = 5, // CERT_KEY_CONTEXT_PROP_ID
			NCryptKeyHandle = 78, // CERT_NCRYPT_KEY_HANDLE_PROP_ID
		}

		public enum AcquireCertificateKeyOptions
		{
			None = 0x00000000,
			AcquireOnlyNCryptKeys = 0x00040000,   // CRYPT_ACQUIRE_ONLY_NCRYPT_KEY_FLAG
		}

		[DllImport("crypt32.dll", SetLastError = true)]
		[return: MarshalAs(UnmanagedType.Bool)]
		public static extern bool CertGetCertificateContextProperty(
			SafeCertContextHandle pCertContext,
			CertificateProperty dwPropId,
			[Out] out IntPtr pvData,
			[In, Out] ref int pcbData);

		[DllImport("crypt32.dll")]
		public static extern SafeCertContextHandle CertDuplicateCertificateContext(IntPtr certContext);

		[DllImport("crypt32.dll", SetLastError = true), ResourceExposure(ResourceScope.None)]
		[return: MarshalAs(UnmanagedType.Bool)]
		public static extern bool CertFreeCertificateContext(IntPtr pCertContext);

		[DllImport("crypt32.dll", SetLastError = true)]
		[return: MarshalAs(UnmanagedType.Bool)]
		public static extern bool CryptAcquireCertificatePrivateKey(
			SafeCertContextHandle pCert,
			AcquireCertificateKeyOptions dwFlags,
			IntPtr pvReserved, // void *
			[Out] out SafeNCryptKeyHandle phCryptProvOrNCryptKey,
			[Out] out int dwKeySpec,
			[Out, MarshalAs(UnmanagedType.Bool)] out bool pfCallerFreeProvOrNCryptKey);

		internal static SafeNCryptKeyHandle TryAcquireCngPrivateKey(
			SafeCertContextHandle certificateContext,
			out CngKeyHandleOpenOptions openOptions)
		{
			Debug.Assert(certificateContext != null, "certificateContext != null");
			Debug.Assert(!certificateContext.IsClosed && !certificateContext.IsInvalid,
						 "!certificateContext.IsClosed && !certificateContext.IsInvalid");

			// If the certificate has a key handle instead of a key prov info, return the
			// ephemeral key
			{
				int cbData = IntPtr.Size;

				if (CertGetCertificateContextProperty(
					certificateContext,
					CertificateProperty.NCryptKeyHandle,
					out IntPtr privateKeyPtr,
					ref cbData))
				{
					openOptions = CngKeyHandleOpenOptions.EphemeralKey;
					return new SafeNCryptKeyHandle(privateKeyPtr, certificateContext);
				}
			}

			openOptions = CngKeyHandleOpenOptions.None;

			bool freeKey = true;
			SafeNCryptKeyHandle privateKey = null;
			RuntimeHelpers.PrepareConstrainedRegions();
			try
			{
				if (!CryptAcquireCertificatePrivateKey(
					certificateContext,
					AcquireCertificateKeyOptions.AcquireOnlyNCryptKeys,
					IntPtr.Zero,
					out privateKey,
					out int keySpec,
					out freeKey))
				{

					// The documentation for CryptAcquireCertificatePrivateKey says that freeKey
					// should already be false if "key acquisition fails", and it can be presumed
					// that privateKey was set to 0.  But, just in case:
					freeKey = false;
					privateKey?.SetHandleAsInvalid();
					return null;
				}
			}
			finally
			{
				// It is very unlikely that Windows will tell us !freeKey other than when reporting failure,
				// because we set neither CRYPT_ACQUIRE_CACHE_FLAG nor CRYPT_ACQUIRE_USE_PROV_INFO_FLAG, which are
				// currently the only two success situations documented. However, any !freeKey response means the
				// key's lifetime is tied to that of the certificate, so re-register the handle as a child handle
				// of the certificate.
				if (!freeKey && privateKey != null && !privateKey.IsInvalid)
				{
					SafeNCryptKeyHandle newKeyHandle = new SafeNCryptKeyHandle(privateKey.DangerousGetHandle(), certificateContext);
					privateKey.SetHandleAsInvalid();
					privateKey = newKeyHandle;
				}
			}

			return privateKey;
		}

		internal static SafeCertContextHandle GetCertificateContext(X509Certificate certificate)
		{
			SafeCertContextHandle certificateContext = CertDuplicateCertificateContext(certificate.Handle);
			// Make sure to keep the X509Certificate object alive until after its certificate context is
			// duplicated, otherwise it could end up being closed out from underneath us before we get a
			// chance to duplicate the handle.
			GC.KeepAlive(certificate);
			return certificateContext;
		}

		internal sealed class SafeCertContextHandle : SafeHandleZeroOrMinusOneIsInvalid
		{
			private SafeCertContextHandle() : base(true) { }

			protected override bool ReleaseHandle() => CertFreeCertificateContext(handle);
		}

		public enum ErrorCode
		{
			Success = 0x00000000, // STATUS_SUCCESS

			InvalidHandle = unchecked((int)0xC0000008), // STATUS_INVALID_HANDLE
			InvalidParameter = unchecked((int)0xC000000D), // STATUS_INVALID_PARAMETER
			NotEnoughMemoryAvailable = unchecked((int)0xC0000017), // STATUS_NO_MEMORY
			BufferTooSmall = unchecked((int)0xC0000023), //STATUS_BUFFER_TOO_SMALL
			NotSupported = unchecked((int)0xC00000BB), //STATUS_NOT_SUPPORTED
			InvalidBufferSize = unchecked((int)0xC0000206), // STATUS_INVALID_BUFFER_SIZE
			ObjectNotFound = unchecked((int)0xC0000225), // STATUS_NOT_FOUND

			AuthTagMismatch = unchecked((int)0xC000A002), // STATUS_AUTH_TAG_MISMATCH

			SecurityInvalidHandle = unchecked((int)0x80090026), // NTE_INVALID_HANDLE
			SecurityInvalidParameter = unchecked((int)0x80090027) // NTE_INVALID_PARAMETER
		}

		[StructLayout(LayoutKind.Sequential)]
		public struct NCryptBufferDesc
		{
			public uint ulVersion;

			public int cBuffers;

			public IntPtr pBuffers;
		}

		public enum BufferType
		{
			KDF_ALGORITHMID = 8,
			KDF_PARTYUINFO = 9,
			KDF_PARTYVINFO = 10,
			KDF_SUPPPUBINFO = 11,
			KDF_SUPPPRIVINFO = 12,
		}

		[StructLayout(LayoutKind.Sequential)]
		public struct NCryptBuffer
		{
			public uint cbBuffer;

			public BufferType BufferType;

			public IntPtr pvBuffer;
		}

		[DllImport("ncrypt.dll", CharSet = CharSet.Unicode, SetLastError = true)]
		public static extern ErrorCode NCryptDeriveKey(
			SafeNCryptSecretHandle hSharedSecret,
			[In] string pwszKDF,
			[In] ref NCryptBufferDesc pParameterList,
			[MarshalAs(UnmanagedType.LPArray), In, Out] byte[] pbDerivedKey,
			int cbDerivedKey,
			[Out] out int pcbResult,
			int dwFlags);
	}
}

Leave a Reply

Your email address will not be published. Required fields are marked *

This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.