Pages

Monday, August 10, 2015

More Encrypted Config Files–ClickOnce Complications

Prologue

In my previous post, I detailed a solution for encrypting sections of the app.config (the technique is similar in a web app). Since then, I ran into some issues in a Windows Form application I was developing.
  • I needed to encrypt email SMTP data.
  • Initially, I’d get an error when trying to access the smtp section.
  • When published as ClickOnce, the application threw an error and wouldn’t open.

Act I

Let’s create a simple app. First, a Windows Form app with three labels. All the code will be in the same page as the Form1 class, to keep things simple.
image

Add connectionString, appSettings and smtp sections to the app.config.
<?xml version="1.0" encoding="utf-8" ?>
<configuration>
  <startup>
    <supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.5" />
  </startup>
  <connectionStrings>
    <add name="MashDb" 
         connectionString="Server=.\SQLExpress2012;Database=MASH;Trusted Connection=True;" 
         providerName="System.Data.SqlClient"/>
  </connectionStrings>
  <appSettings>
    <add key="Password" value="4077"/>
  </appSettings>
  <system.net>
    <mailSettings>
      <smtp deliveryMethod="Network" from="pierce@mash.net">
        <network
          host="ohmyseoul.kr"
          port="25"
          defaultCredentials="true"/>
      </smtp>
    </mailSettings>
  </system.net>
</configuration>


Add reference to System.Configuration to the project.

image


The static Settings class, to get the app.config data. This is personal preference; I know you can also use the Settings designer for this, but it’s kind of hidden and finicky.
public static class Settings
    {
        public static string MashDb { get { return ConfigurationManager.ConnectionStrings["MashDb"].Name; } }
        public static string Password { get { return ConfigurationManager.AppSettings["Password"]; } }
    }


The static ConfigSettings classes. (Or whatever you’d name your general security utilities.) This is different than what I showed previously. Specifically, they don’t optionally take an executable name. Instead, they take the full path to a config file, essential to solving the ClickOnce problem.
public static class ConfigSettings
    {

        /// <summary>
        /// Encrypts/decrypts the connectionStrings, appSettings and smtp sections.
        /// </summary>
        /// <param name="encryption"></param>
        /// <param name="configPath">Full path to config file</param>
        /// <remarks>https://msdn.microsoft.com/en-us/library/ms254494(v=vs.110).aspx</remarks>
        public static void SetDefaultConfigEncryption(bool encryption, string configPath = null)
        {
            SetConfigSectionEncryption(encryption, "appSettings", configPath);
            SetConfigSectionEncryption(encryption, "connectionStrings", configPath);
            SetConfigSectionEncryption(encryption, "system.net/mailSettings/smtp", configPath);
        }

        /// <summary>
        /// Encrypts/decrypts the connectionStrings, appSettings and smtp sections.
        /// </summary>
        /// <param name="encryption"></param>
        /// <param name="sectionName"></param>
        /// <param name="configPath">Full path to config file</param>
        /// <remarks></remarks>
        public static void SetConfigSectionEncryption(bool encryption, string sectionName, string configPath = null)
        {
            //if no configPath supplied, get the currently executing exe's config.
            if (string.IsNullOrWhiteSpace(configPath))
            {
                configPath = Assembly.GetEntryAssembly().Location + ".config";
#if (DEBUG)
                AppDomain domain = System.AppDomain.CurrentDomain;
                configPath = Path.Combine(domain.SetupInformation.ApplicationBase, domain.FriendlyName + ".config");
#endif
            }

            try
            {
                //Open the configuration file and retrieve the connectionStrings section.
                ExeConfigurationFileMap fileMap = new ExeConfigurationFileMap();
                fileMap.ExeConfigFilename = configPath;
                Configuration config = ConfigurationManager.OpenMappedExeConfiguration(fileMap, ConfigurationUserLevel.None);
                ConfigurationSection section = config.GetSection(sectionName);
                if (section == null)
                {
                    return; 
                }
                // Encrypt the section.
                if (encryption)
                {
                    if (!section.SectionInformation.IsProtected)
                    {
                        section.SectionInformation.ForceSave = true;
                        section.SectionInformation.ProtectSection("DataProtectionConfigurationProvider");
                    }
                }
                // Remove encryption.
                if (!encryption)
                {
                    if (section.SectionInformation.IsProtected)
                    {
                        section.SectionInformation.UnprotectSection();
                    }
                }
                // Save the current configuration.
                config.Save();
            }
            catch (Exception ex)
            {
                throw new Exception("Unable to encrypt. " + ex.GetBaseException().Message);
            }
        }
    }


In the Form1 class, add the form Load event, and also the method for encrypting the config. You might be tempted to use System.AppDomain.CurrentDomain.FriendlyName, but in a ClickOnce application this will return “DefaultDomain,” and no site I’ve seen has an answer why.

public partial class Form1 : Form
    {
        public Form1()
        {
            EncryptClickOnceConfig();
            InitializeComponent();
        }

        private void Form1_Load(object sender, EventArgs e)
        {
            label1.Text = Settings.MashDb;
            label2.Text = Settings.Password;
            label3.Text = new System.Net.Mail.SmtpClient().Host;
        }

        private void EncryptClickOnceConfig()
        {
            //Encrypt config. This didn't work properly in Load. Would lead to error "Unrecognized attribute ‘configProtectionProvider’
            //after encrypting app.config when reading smtp
            //Weird stuff for ClickOnce Deployment. 
            //https://social.msdn.microsoft.com/Forums/windows/en-US/3d7ba97f-684c-46a2-9f7b-5d17e989baa8/encrypting-connection-string-with-clickonce-deployment?forum=winformssetup
            //ClickOnce writes two config files for some reason.
            {
                if (ApplicationDeployment.IsNetworkDeployed && ApplicationDeployment.CurrentDeployment.IsFirstRun)
                {
                    //GetPaths
                    string exeFile = Assembly.GetEntryAssembly().Location;
                    //get directory above the executable's.
                    string parentPath = Path.GetDirectoryName(Path.GetDirectoryName(exeFile));
                    string configName = Path.GetFileName(exeFile) + ".config";
                    //get the matching files
                    foreach (string configFile in Directory.GetDirectories(parentPath, "*" + configName, SearchOption.AllDirectories))
                    {
                        ConfigSettings.SetDefaultConfigEncryption(true, configFile);
                    }
                }
                else
                {
                    //good for debugging, sets the vshost config
                    ConfigSettings.SetDefaultConfigEncryption(true);
                }
            }
        }
    }

Act II

Publish the application. This will automatically create a self-signed certificate. (In a follow up post, I’ll document (for my easy reference) how to create a certificate that lasts more than a year.) There are two important things to do here:
  • Set the project to Release.  This is critical. It should be obvious, but it’s easy to forget and leads to one of the errors.
  • It’s important to install it from a network location, which you can fake by sharing your publish folder and using the UNC path to it. I published to the solution folder. Give the share full permissions (for testing only).

image



You should also enable automatic updating.

image



Install from the UNC path. The app should open.

image



With the app open, open Task Manager, right-click the app, and choose “open file location.”

image



The folder will contain a EncryptedConfigClickOnceSample.exe.config file, which should be encrypted. But you’ll also find another folder, a sibling above or below the one you’re in, also with a EncryptedConfigClickOnceSample.exe.config, which should also be encrypted.

So, fine, it works. How were the problems fixed?

Act III

Encrypt the SMTP Info

This one’s pretty easy. Pass the SetConfigSectionEncryption() method the string “system.net/mailSettings/smtp”. What I found interesting is that you can’t submit just “system.net”, or “system.net/mailSettings” Apparently, in some  Orwellian programming community, some sections are more equal than others.

Error Accessing the SMTP Info

Set the program to Debug mode and republish. Run the app, letting it self-update. You should receive an error
Unrecognized attribute 'configProtectionProvider'

image

Some sites say to call ConfigurationManager.RefreshSection, but the problem really seems to be the behavior when in Debug mode. The error seems to be caused by how configuration info is accessed after the application starts. It’s not clear to me, and no web page mentioned debug mode. But it worked.

ClickOnce Error

In one application, when I tried to install it simply wouldn’t open. I believe this was also due to publishing in Debug mode. However, I also moved my call to configure the config file from Form_Load to the constructor. In my testing app, that’s not making any difference.

Epilogue

Encrypting the app.config could be critical for certain organizations where the highest security is preferred, even if the risk is very low.

References

http://stackoverflow.com/questions/7856951/unrecognized-attribute-configprotectionprovider-after-encrypting-app-config

https://social.msdn.microsoft.com/Forums/windows/en-US/3d7ba97f-684c-46a2-9f7b-5d17e989baa8/encrypting-connection-string-with-clickonce-deployment?forum=winformssetup

Source Code

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Forms;

using System.Net.Mail;
using System.Configuration;
using System.IO;
using System.Deployment.Application;
using System.Reflection;

namespace EncryptedConfigClickOnceSample
{
    public partial class Form1 : Form
    {
        public Form1()
        {
            EncryptClickOnceConfig();
            InitializeComponent();
        }

        private void Form1_Load(object sender, EventArgs e)
        {
            label1.Text = Settings.MashDb;
            label2.Text = Settings.Password;
            label3.Text = new System.Net.Mail.SmtpClient().Host;
        }

        private void EncryptClickOnceConfig()
        {
            //Encrypt config. This didn't work properly in Load. Would lead to error "Unrecognized attribute ‘configProtectionProvider’
            //after encrypting app.config when reading smtp
            //Weird stuff for ClickOnce Deployment. 
            //https://social.msdn.microsoft.com/Forums/windows/en-US/3d7ba97f-684c-46a2-9f7b-5d17e989baa8/encrypting-connection-string-with-clickonce-deployment?forum=winformssetup
            //ClickOnce writes two config files for some reason.
            {
                if (ApplicationDeployment.IsNetworkDeployed && ApplicationDeployment.CurrentDeployment.IsFirstRun)
                {
                    //GetPaths
                    string exeFile = Assembly.GetEntryAssembly().Location;
                    //get directory above the executable's.
                    string parentPath = Path.GetDirectoryName(Path.GetDirectoryName(exeFile));
                    string configName = Path.GetFileName(exeFile) + ".config";
                    //get the matching files
                    foreach (string configFile in Directory.GetFiles(parentPath, "*" + configName, SearchOption.AllDirectories))
                    {
                        ConfigSettings.SetDefaultConfigEncryption(true, configFile);
                    }
                    
                }
                else
                {
#if (DEBUG)
                    //good for debugging, sets the vshost config
                    ConfigSettings.SetDefaultConfigEncryption(true);
#endif
                }
            }
        }
    }

    public static class Settings
    {
        public static string MashDb { get { return ConfigurationManager.ConnectionStrings["MashDb"].Name; } }
        public static string Password { get { return ConfigurationManager.AppSettings["Password"]; } }
    }


    public static class ConfigSettings
    {

        /// <summary>
        /// Encrypts/decrypts the connectionStrings, appSettings and smtp sections.
        /// </summary>
        /// <param name="encryption"></param>
        /// <param name="configPath">Full path to config file</param>
        /// <remarks>https://msdn.microsoft.com/en-us/library/ms254494(v=vs.110).aspx</remarks>
        public static void SetDefaultConfigEncryption(bool encryption, string configPath = null)
        {
            SetConfigSectionEncryption(encryption, "appSettings", configPath);
            SetConfigSectionEncryption(encryption, "connectionStrings", configPath);
            SetConfigSectionEncryption(encryption, "system.net/mailSettings/smtp", configPath);
        }

        /// <summary>
        /// Encrypts/decrypts the connectionStrings, appSettings and smtp sections.
        /// </summary>
        /// <param name="encryption"></param>
        /// <param name="sectionName"></param>
        /// <param name="configPath">Full path to config file</param>
        /// <remarks></remarks>
        public static void SetConfigSectionEncryption(bool encryption, string sectionName, string configPath = null)
        {
            //if no configPath supplied, get the currently executing exe's config.
            if (string.IsNullOrWhiteSpace(configPath))
            {
                configPath = Assembly.GetEntryAssembly().Location + ".config";
#if (DEBUG)
                AppDomain domain = System.AppDomain.CurrentDomain;
                configPath = Path.Combine(domain.SetupInformation.ApplicationBase, domain.FriendlyName + ".config");
#endif
            }

            try
            {
                //Open the configuration file and retrieve the connectionStrings section.
                ExeConfigurationFileMap fileMap = new ExeConfigurationFileMap();
                fileMap.ExeConfigFilename = configPath;
                Configuration config = ConfigurationManager.OpenMappedExeConfiguration(fileMap, ConfigurationUserLevel.None);
                ConfigurationSection section = config.GetSection(sectionName);
                if (section == null)
                {
                    return; 
                }
                // Encrypt the section.
                if (encryption)
                {
                    if (!section.SectionInformation.IsProtected)
                    {
                        section.SectionInformation.ForceSave = true;
                        section.SectionInformation.ProtectSection("DataProtectionConfigurationProvider");
                    }
                }
                // Remove encryption.
                if (!encryption)
                {
                    if (section.SectionInformation.IsProtected)
                    {
                        section.SectionInformation.UnprotectSection();
                    }
                }
                // Save the current configuration.
                config.Save();
            }
            catch (Exception ex)
            {
                throw new Exception("Unable to encrypt. " + ex.GetBaseException().Message);
            }
        }
    }
}

No comments:

Post a Comment