A few weeks back, the performance testers found this issue when doing some resilience testing:
ID1073: A CryptographicException occurred when attempting to decrypt the cookie using the ProtectedData API (see inner exception for details). If you are using IIS 7.5, this could be due to the loadUserProfile setting on the Application Pool being set to false.
In essence, if the user started his session on server 1, then that server was stopped, when he connected to server 2, the issue would occur. Annoyingly, this would require removing cookies, which our testers are not able to do, on their locked down machines.
The deployment uses an NLB flag was checked for this deployment, but this seemed to make no difference, so we decided to encrypt the cookies using a certificate rather than the machine key.
This is a bit annoying as there are few things to consider:
- We're going to have to modify the MS Dynamics CRM web.config, which means that every new patch, update, etc.. might overwrite it.
- Following from that we need a deployment script to automate it as much as possible.
- We'll need to store a way to id the certificate to be used for the encryption somewhere easily accessible (We could hard code it but then when the certificate expires we'd been in trouble).
I decided to use the registry for 3.
This is the code that we've used to encrypt the cookies with a certificate.
using Microsoft.IdentityModel.Tokens;
using Microsoft.IdentityModel.Web;
using Microsoft.Win32;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Threading.Tasks;
namespace CRM.RSASessionCookie
{
/// <summary>
/// This class encrypts the session security token using the RSA key
/// of the relying party's service certificate.
/// </summary>
public class RsaEncryptedSessionSecurityTokenHandler : SessionSecurityTokenHandler
{
static List<CookieTransform> transforms;
static RsaEncryptedSessionSecurityTokenHandler()
{
string certThumbprint = (string)Registry.GetValue(@"HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\MSCRM",
"CookieCertificateThumbprint",
null);
if (!string.IsNullOrEmpty(certThumbprint))
{
X509Certificate2 serviceCertificate = CertificateUtil.GetCertificate(StoreName.My,
StoreLocation.LocalMachine, certThumbprint);
if (serviceCertificate == null)
{
throw new ApplicationException(string.Format("No certificate was found with thumbprint: {0}", certThumbprint));
}
transforms = new List<CookieTransform>()
{
new DeflateCookieTransform(),
new RsaEncryptionCookieTransform(serviceCertificate),
new RsaSignatureCookieTransform(serviceCertificate),
};
}
else
{
throw new ApplicationException(
@"Could not read Registry Key: HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\MSCRM\CookieCertificateThumbprint.\nPlease ensure that the key exists and that you have permission to read it.");
}
}
public RsaEncryptedSessionSecurityTokenHandler()
: base(transforms.AsReadOnly())
{
}
}
/// <summary>
/// A utility class which helps to retrieve an x509 certificate
/// </summary>
public class CertificateUtil
{
/// <summary>
/// Gets an X.509 certificate given the name, store location and the subject distinguished name of the X.509 certificate.
/// </summary>
/// <param name="name">Specifies the name of the X.509 certificate to open.</param>
/// <param name="location">Specifies the location of the X.509 certificate store.</param>
/// <param name="thumbprint">Subject distinguished name of the certificate to return.</param>
/// <returns>The specific X.509 certificate.</returns>
public static X509Certificate2 GetCertificate(StoreName name, StoreLocation location, string thumbprint)
{
X509Store store = null;
X509Certificate2Collection certificates = null;
X509Certificate2 result = null;
try
{
store = new X509Store(name, location);
store.Open(OpenFlags.ReadOnly);
//
// Every time we call store.Certificates property, a new collection will be returned.
//
certificates = store.Certificates;
for (int i = 0; i < certificates.Count; i++)
{
X509Certificate2 cert = certificates[i];
if (cert.Thumbprint.Equals(thumbprint, StringComparison.InvariantCultureIgnoreCase))
{
result = new X509Certificate2(cert);
break;
}
}
}
catch (Exception ex)
{
throw new ApplicationException(string.Format("An issue occurred opening cert store: {0}\\{1}. Exception:{2}.", name, location, ex));
}
finally
{
if (certificates != null)
{
for (int i = 0; i < certificates.Count; i++)
{
X509Certificate2 cert = certificates[i];
cert.Reset();
}
}
if (store != null)
{
store.Close();
}
}
return result;
}
}
}
Company standards dictate that this class should be deployed to GAC but it can be deployed to the CRM webpage bin folder instead.
This is the PowerShell function in our script that sets the certificate on the registry:
function SetCookieCertificateThumbprint
{
param ([string]$value)
$path = "hklm:\Software\microsoft\mscrm"
$name = "CookieCertificateThumbprint"
if( -not (Test-Path -Path $path -PathType Container) )
{
Write-Error "Cannot find MSCRM Registry Key: " + $path
}
else
{
$keys = Get-ItemProperty -Path $path
if ($keys.$name -or $keys.$name -ne $value)
{
Set-ItemProperty -path $path -name $name -value $value
}
}
}
I have not automated the rest, which is the really fiddly part, i.e. updating the web.config. Here are the relevant parts though:
Config Sections First:
<configSections>
<!-- COMMENT:START CRM Titan 28973
If you add any new section here , please ensure that section name is removed from help/web.config
End COMMENT:END-->
<section name="crm.authentication" type="Microsoft.Crm.Authentication.AuthenticationSettingsConfigurationSectionHandler, Microsoft.Crm.Authentication, Version=6.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35" />
<section name="microsoft.identityModel" type="Microsoft.IdentityModel.Configuration.MicrosoftIdentityModelSection, Microsoft.IdentityModel, Version=3.5.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35" />
</configSections>
The actual token handler:
<microsoft.identityModel>
<service>
<securityTokenHandlers>
<!-- Remove and replace the default SessionSecurityTokenHandler with your own -->
<remove type="Microsoft.IdentityModel.Tokens.SessionSecurityTokenHandler, Microsoft.IdentityModel, Version=3.5.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35" />
<add type="CRM.RSASessionCookie.RsaEncryptedSessionSecurityTokenHandler, CRM.RSASessionCookie, Version=1.0.0.0, Culture=neutral, PublicKeyToken=d10ca3d28ba8fa6e" />
</securityTokenHandlers>
</service>
</microsoft.identityModel>
It should, hopefully be obvious that this will need to be done across all CRM servers that have the Web Server role.
After all this work, we thought we would be ok for our dev and test environments sharing a single ADFS server, as the cookies would be encrypted with the same certificate, but it turns out that this is not supported by MS Dynamics CRM 2013 :(