QuickCrypto For .NET MAUI – Part 2

QuickCrypto For .NET MAUI – Part 2

Last time I laid out a new .NET MAUI based version of my CG.Tools.QuickCrypto tool. This time I’ll cover the internals with a walkthrough.

The complete project source code can be found HERE. Feel free to use that project directly, or, if you like, you can follow this walkthrough and create your own project as we go.

I started by creating a typical .NET MAUI application, in Visual Studio 2022, using the latest project creation wizard:

After selecting the proper wizard from the list, I pressed the Next button.

By the way, I am using the Preview 14 MAUI project wizard from Vijay Anand E G, which can be found HERE. You don’t have to use that particular wizard. That’s just the one I’m using.

Then I entered the project name and pressed the Next button again.

Then I chose “Shell” from the dropdown list, since I want my app to use the shell for navigation. Finally, I pressed the Create button, to create the project.

That gave me a project that looked something like this, in Visual Studio 2022:

At this point, I started moving things around, a bit. For instance, I like to keep all my pages in a folder called Views, so I created that folder and moved them there. After that, I went ahead and created another folder named ViewModels, since I know I’ll be using MVVM with this project.

That gave me a project that looked like this:

From there I started removing pages that I knew I wouldn’t need. So, in the Views folder, I deleted these files:

  • DateTimePicker
  • EventsPage
  • LoginPage
  • NewEventPage
  • SearchPage

Again, if you aren’t using the .NET MAUI project wizard from Vijay Anand E G, then you won’t have these pages, so just skip this part.

After that, I created three new pages, again in the Views folder: AboutPage, AesPage, and DataProtectionPage.

To create the new MAUI pages, I right clicked on the Views folder, in the Solution Explorer of Visual Studio 2022, then I chose the “Add” > “New Item” menu choice.

That, in turn, produces the “Add New Item” dialog:

Select “MAUI” from the list of the left, then select “Content Page (.NET MAUI) from the list in the center, since we want to create a new page. Next, set the page name you want to create at the bottom. Finally, press the “Add” button, to create the page.

After doing that for each of the pages we want to create, the project then looks a bit like this:

Next I created empty view-models for my pages, by right clicking on the ViewModel folder, in the solution explorer, and choosing the “Add” > “New Item” menu choice again. This time though, I chose to create a new class, like this:

That, in turn, produces the “Add New Item” dialog again. This time though, we select C# Items from the list on the left, then we select Class from the list of the right. Then, I set the class name at the bottom and pressed the “Add” button. I did that for each one of the view-models. The results looked something like this:

Repeat for all the view-models, which, in our case, are: AboutPageviewModel, AesPageViewModel, DataProtectionPageViewModel, SettingsPageViewModel, and AppShellViewModel.

The results, looked something like this:

Not bad, we’re making great progress!!

From there I reasoned that I would probably need, at the very least, a base view-model class, and another base class for the Data Protection and AES views. So, I went back again and created those two files, using the same approach we used earlier, to create our other view-models. The results should look something like this:

Don’t worry! We’ll go through each of these files, later, and write the code for each one. For now, I’m just putting the overall project together.

The next thing I knew I’d need are the application options. For that, I created a folder called “Options”, and then I added files to that folder called “AppSettings”, “CertSettings”, and “KeySettings”. That looked like this:

There are two folders still to add. The first is called “Events”, and it should have two files in it, called “ErrorRaisedArgs”, and “WarningRaisedArgs”. That should look like this:

The final folder is called “Extensions”, and in it, you should create three files named “AppSettingsExtensions”, “AssemblyExtensions”, and “ServiceCollectionExtensions”. That should look like this:

That’s it! We’re done creating files and folders! Whew!

Now we just have to add code to those files. As we do that, if at any point you find you’re missing a folder, or a file, either go back and re-read that part of the article, or, copy the relevant code from my project, to yours.

Let’s start with the files in the “Options” folder. Specifically, let’s start with the “AppSettings.cs” file. Here is the code:

namespace CG.Tools.QuickCrypto.Options;

/// <summary>
/// This class contains configuration settings for the application.
/// </summary>
public class AppSettings
{
    /// <summary>
    /// This property contains the settings for keys.
    /// </summary>
    public KeySettings Keys { get; set; } = null!;

    /// <summary>
    /// This property contains settings for certs.
    /// </summary>
    public CertSettings Certs { get; set; } = null!;
    #endregion

    /// <summary>
    /// This constructor creates a new instance of the <see cref="AppSettings"/>
    /// class.
    /// </summary>
    public AppSettings()
    {
        // Set default values.
        Keys = new KeySettings();
        Certs = new CertSettings();
    }
}

This is the top-level settings object for the application. It contains properties for the two sub-level settings objects, KeySettings and CertSettings. This is how we’ll keep track of the settings for the two crypto algorithms we’ll be using.

The next file is the “CertSettings.cs” file, in the “Options” folder. Here is the code for that:

namespace CG.Tools.QuickCrypto.Options;

/// <summary>
/// This class contains configuration settings for certificates.
/// </summary>
public class CertSettings
{
    /// <summary>
    /// This property contains the (optional) contents of an X509 PEM file, 
    /// used to secure the ASP.NET data protection keys, at rest, for this 
    /// application.
    /// </summary>
    public string? X509Pem { get; set; }
}

The X509Pem property, on the CertSettings class, is how we keep track of the X509 cert, when one is pasted into the settings page.

The next file is the “KeySettings.cs” file, in the Options folder. Here is the code for that file:

namespace CG.Tools.QuickCrypto.Options;

/// <summary>
/// This class contains configuration settings for keys.
/// </summary>
public class KeySettings
{
    /// <summary>
    /// This property contains the SALT for our encryption/decryption.
    /// </summary>
    public string Salt { get; set; } = null!;

    /// <summary>
    /// This property contains the password for our encryption/decryption.
    /// </summary>
    public string Password { get; set; } = null!;

    /// <summary>
    /// This property contains the number of Rfc2898 iterations to apply
    /// to the password before we use it for encryption/decryption.
    /// </summary>
    public double Iterations { get; set; }

    /// <summary>
    /// This constructor creates a new instance of the <see cref="KeySettings"/>
    /// class.
    /// </summary>
    public KeySettings()
    {
        // Set default values.
        Salt = "quickcrypto";
        Password = "quickcrypto";
        Iterations = 10000.0;
    }
}

Here we see properties for each one of the AES algorithm’s settings. This, along with every other property in these options classes, will be populated by the user and will control the overall behavior of the application, at runtime.

The next folder we’ll tackle is called “Extensions”. We’ll start with the “AppSettingsExtensions.cs” file. Here is the code for that file:

namespace CG.Tools.QuickCrypto.Extensions;

/// <summary>
/// This class contains extension methods related to the <see cref="AppSettings"/>
/// type.
/// </summary>
public static partial class AppSettingsExtensions
{
    /// <summary>
    /// This method writes the given application settings to disk.
    /// </summary>
    /// <param name="appSettings">The application settings to use for 
    /// the operation.</param>
    public static void WriteToDisk(
        this AppSettings appSettings
        )
    {
        try
        {
            // Create a default data protector so we can encrypt/decrypt the 
            //   settings file, at rest. This doesn't have to match the one
            //   we'll eventually create for the user, since we'll only use
            //   this one internally. Also, if we tried to use the one we'll
            //   eventually create for the user, we would have a serious
            //   chicken/egg problem with the data protector and the settings.
            var dataProtectionProvider = DataProtectionProvider.Create(
                AppDomain.CurrentDomain.FriendlyName
                );

            // Create a data protector.
            var dataProtector = dataProtectionProvider.CreateProtector(
                AppDomain.CurrentDomain.FriendlyName
                );

            // Get a path to our private folder.
            var appFolderPath = Path.Combine(
                Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
                "CodeGator",
                AppDomain.CurrentDomain.FriendlyName.Replace(".", "")
                );

            // Should we create the path?
            if (!Directory.Exists(appFolderPath))
            {
                // Make sure the path exists.
                Directory.CreateDirectory(appFolderPath);
            }

            // Get a path to the settings file.
            var appSettingsPath = Path.Combine(
                appFolderPath,
                "appSettings.json"
                );

            // Convert the settings to JSON.
            var json = JsonSerializer.Serialize(appSettings);

            // Encrypt the JSON.
            var pJson = dataProtector.Protect(json);

            // Write the json to disk.
            File.WriteAllText(appSettingsPath, pJson);
        }
        catch (Exception)
        {
            // TODO : figure out what to do with this.
        }
    }
}

The code starts by manually creating a data protection provider object. Now, to be sure, I could have tried to pass the data protection provider instance in via the .NET DI container. But, since this code is called as part of the startup process, I would have faced problems trying to use the DI container whilst simultaneously trying to configure the DI container. Easier just to create the object here, myself.

After creating the data protection provider, I then create the actual data-protector object. This is what we’ll use to protect our settings, at rest. That way, nobody can get sneaky and go find our settings file and peek at it’s secrets while we are away eating pizza, or something like that.

Since I’ve already alluded to the fact that I’ll be saving the setting to disk, the next line of code creates a place for me to write that file, to disk. For my purposes, I want to write my settings into a private folder, under CodeGator, since that’s common for all my software. I start by ensuring that the folder part of the path exists (which it won’t, first time the code runs). After that, I create the rest of the path for my “appSettings.json” file – which is where I’ll eventually write the settings.

Once we have the path to the file, we just need to create the JSON to fill that file with. I do that in the next line of code, by serializing an instance of our AppSettings class. Then, once we have the JSON we want, I use the data-protector we created earlier, to protect the JSON. The phrase “protect” is Data Protector speak for encrypt, in case that wasn’t obvious.

Finally, once the JSON is encrypted/protected, I then write it to the disk file.

The next file we’ll populate is named “ServiceCollectionExtensions.cs”, in the “Extensions” folder. Here is the code for that file:

namespace CG.Tools.QuickCrypto.Extensions;

/// <summary>
/// This class contains extension methods related to the <see cref="IServiceCollection"/>
/// type.
/// </summary>
public static partial class ServiceCollectionExtensions
{
    /// <summary>
    /// This method loads the custom options for the application.
    /// </summary>
    /// <param name="serviceCollection">The service collection to use for 
    /// the operation.</param>
    /// <param name="appSettings">The application settings created by this
    /// method.</param>
    /// <returns>The value of the <paramref name="serviceCollection"/> parameter,
    /// for chaining calls together, fluent style.</returns>
    public static IServiceCollection AddCustomOptions(
        this IServiceCollection serviceCollection,
        out AppSettings appSettings
        )
    {
        // Make the compiler happy.
        appSettings = new AppSettings();

        // In case it hasn't already been called.
        serviceCollection.AddOptions();

        // Create a default data protector so we can encrypt/decrypt the 
        //   settings file, at rest. This doesn't have to match the one
        //   we'll eventually create for the user, since we'll only use
        //   this one internally. Also, if we tried to use the one we'll
        //   eventually create for the user, we would have a serious
        //   chicken/egg problem.
        var dataProtectionProvider = DataProtectionProvider.Create(
            AppDomain.CurrentDomain.FriendlyName
            );

        // Create a data protector.
        var dataProtector = dataProtectionProvider.CreateProtector(
            AppDomain.CurrentDomain.FriendlyName
            );

        // Get a path to our private folder.
        var appFolderPath = Path.Combine(
            Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
            "CodeGator",
            AppDomain.CurrentDomain.FriendlyName.Replace(".", "")
            );

        // Should we create the path?
        if (!Directory.Exists(appFolderPath))
        {
            // Make sure the path exists.
            Directory.CreateDirectory(appFolderPath);
        }

        // Get a path to the settings file.
        var appSettingsPath = Path.Combine(
            appFolderPath,
            "appSettings.json"
            );        

        // Should we create the file?
        if (!File.Exists(appSettingsPath))
        {
            // Convert the settings to JSON.
            var json = JsonSerializer.Serialize(appSettings);

            // Encrypt the JSON.
            var pJson = dataProtector.Protect(json);

            // Write the settings to disk.
            File.WriteAllText(appSettingsPath, pJson);
        }
        else
        {
            // Read the settings from disk.
            var pJson = File.ReadAllText(appSettingsPath);

            // Decrypt the JSON.
            var json = dataProtector.Unprotect(pJson);

            try
            {
                // Convert the JSON back to a settings object.
                appSettings = (JsonSerializer.Deserialize<AppSettings>(json) 
                    ?? new AppSettings());
            }
            catch
            {
                // If we fail to deserialize the JSON, for any reason, we'll
                //   revert to the default settings here.
                appSettings = new AppSettings();
            }
        }

        // Register the settings as a service.
        serviceCollection.AddSingleton<IOptions<AppSettings>>(
            new OptionsWrapper<AppSettings>(
                appSettings
                )
            );

        // Return the service collection.
        return serviceCollection;
    }
}

This looks like a lot of code, but, I generally use tons of white space, so, it’s not as bad as it might seem. The class has a single method, AddCustomOptions, which is responsible for making sure the application has valid, populated settings, every time the application runs.

We start by creating an AppSettings model instance and assigning it to the ‘out’ parameter on the method. That keeps the compiler from complaining to us. After that, we call AddOptions, to ensure that the options related services we’ll need, from MAUI, are registered with the DI container.

Next we create an IDataProtectionProvider instance. We could have tried to have the DI container inject that object, but, doing so would have create a chicken and egg situation that is surely familiar to anyone who uses Microsoft’s DI container extensions, where you need to use something from the IServiceCollection, but, you haven’t yet finished configuring it, so, you’re stuck. You could create the IServiceProvider yourself, but, doing so would then create multiple copied of any singleton services you have registered, which is generally frowned upon.

So, yea, we create our own IDataProtectionProvider object here. Next we create the IDataProtector object we’ll be using to encrypt the application settings at rest. Next we create a path to our application settings file. Then we ensure that path actually exists, since it won’t the first time the application runs. Next, we serialize our application settings to JSON, encrypt that JSON, and write it to our appsettings.json file. That gives us a set of default application settings for the first time the application runs.

If the appsettings.json file already exists, we read the encrypted JSON from the file, decrypt it, deserialize it back to a .NET object, and pass out of the method. using the ‘out’ parameter.

Finally, we register our new application settings, as a service, with the DI container, so we can always get to them, while the application runs.

The next file we’ll populate is the “AppSettingsExtensions.cs” file, in the “Extensions” folder. The code that looks like this:

/// <summary>
/// This class contains extension methods related to the <see cref="AppSettings"/>
/// type.
/// </summary>
public static partial class AppSettingsExtensions
{
    /// <summary>
    /// This method writes the given application settings to disk.
    /// </summary>
    /// <param name="appSettings">The application settings to use for 
    /// the operation.</param>
    public static void WriteToDisk(
        this AppSettings appSettings
        )
    {
        try
        {
            // Create a default data protector so we can encrypt/decrypt the 
            //   settings file, at rest. This doesn't have to match the one
            //   we'll eventually create for the user, since we'll only use
            //   this one internally. Also, if we tried to use the one we'll
            //   eventually create for the user, we would have a serious
            //   chicken/egg problem with the data protector and the settings.
            var dataProtectionProvider = DataProtectionProvider.Create(
                AppDomain.CurrentDomain.FriendlyName
                );

            // Create a data protector.
            var dataProtector = dataProtectionProvider.CreateProtector(
                AppDomain.CurrentDomain.FriendlyName
                );

            // Get a path to our private folder.
            var appFolderPath = Path.Combine(
                Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
                "CodeGator",
                AppDomain.CurrentDomain.FriendlyName.Replace(".", "")
                );

            // Should we create the path?
            if (!Directory.Exists(appFolderPath))
            {
                // Make sure the path exists.
                Directory.CreateDirectory(appFolderPath);
            }

            // Get a path to the settings file.
            var appSettingsPath = Path.Combine(
                appFolderPath,
                "appSettings.json"
                );

            // Convert the settings to JSON.
            var json = JsonSerializer.Serialize(appSettings);

            // Encrypt the JSON.
            var pJson = dataProtector.Protect(json);

            // Write the json to disk.
            File.WriteAllText(appSettingsPath, pJson);
        }
        catch (Exception)
        {
            // TODO : figure out what to do with this.
        }
    }
}

This class has a single method, named WriteToDisk.

We start this method by creating an IDataProtectionProvider and IDataProvider instance. Then we create the path to our appsettings.json file. Again, we check to ensure the path actually exists, since trying to write a file to a non-existent path generates an error. Finally, we serialize our AppSettings object to JSON, encrypt that JSON, then write the results to disk.

The next file we’ll populate is named “AssemblyExtensions”, and it also lives in the “Extensions” folder. The code for that looks like this:

/// <summary>
/// This class contains extension methods reated to the <see cref="Assembly"/>
/// type.
/// </summary>
public static partial class AssemblyExtensions
{
    /// <summary>
    /// Reads the value of the <see cref="AssemblyFileVersionAttribute"/>
    /// attribute for the given assembly.
    /// </summary>
    /// <param name="assembly">The assembly to read from.</param>
    /// <returns>The value of the given assembly's file version attribute.</returns>
    public static string ReadFileVersion(this Assembly assembly)
    {
        // Attempt to read the assembly's file version attribute.
        object[] attributes = assembly.GetCustomAttributes(
            typeof(AssemblyFileVersionAttribute),
            true
            );

        // Did we fail?
        if (attributes.Length == 0)
            return string.Empty;

        // Attempt to recover a reference to the attribute.
        AssemblyFileVersionAttribute? attr =
            attributes[0] as AssemblyFileVersionAttribute;

        // Did we fail?
        if (attr == null || attr.Version.Length == 0)
            return string.Empty;

        // Return the text for the attribute.
        return attr.Version;
    }

    // *******************************************************************

    /// <summary>
    /// Reads the value of the <see cref="AssemblyCopyrightAttribute"/> 
    /// attribute for the given assembly.
    /// </summary>
    /// <param name="assembly">The assembly to read from.</param>
    /// <returns>The value of the given assembly's copyright attribute.</returns>
    public static string ReadCopyright(this Assembly assembly)
    {
        // Attempt to read the assembly's copyright attribute.
        object[] attributes = assembly.GetCustomAttributes(
            typeof(AssemblyCopyrightAttribute),
            true
            );

        // Did we fail?
        if (attributes.Length == 0)
            return string.Empty;

        // Attempt to recover a reference to the attribute.
        AssemblyCopyrightAttribute? attr =
            attributes[0] as AssemblyCopyrightAttribute;

        // Did we fail?
        if (attr == null || attr.Copyright.Length == 0)
            return string.Empty;

        // Return the text for the attribute.
        return attr.Copyright;
    }

    // *******************************************************************

    /// <summary>
    /// Reads the value of the <see cref="AssemblyTitleAttribute"/>
    /// attribute for the given assembly.
    /// </summary>
    /// <param name="assembly">The assembly to read from.</param>
    /// <returns>The value of the given assembly's title attribute.</returns>
    public static string ReadTitle(this Assembly assembly)
    {
        // Attempt to read the assembly's title attribute.
        object[] attributes = assembly.GetCustomAttributes(
            typeof(AssemblyTitleAttribute),
            true
            );

        // Did we fail?
        if (attributes.Length == 0)
            return string.Empty;

        // Attempt to recover a reference to the attribute.
        AssemblyTitleAttribute? attr =
            attributes[0] as AssemblyTitleAttribute;

        // Did we fail?
        if (attr == null || attr.Title.Length == 0)
            return string.Empty;

        // Return the text for the attribute.
        return attr.Title;
    }

    // ******************************************************************

    /// <summary>
    /// Reads the value of the <see cref="AssemblyDescriptionAttribute"/>
    /// attribute for the given assembly.
    /// </summary>
    /// <param name="assembly">The assembly to read from.</param>
    /// <returns>The value of the given assembly's description attribute.</returns>
    public static string ReadDescription(this Assembly assembly)
    {
        // Attempt to read the assembly's description attribute.
        object[] attributes = assembly.GetCustomAttributes(
            typeof(AssemblyDescriptionAttribute),
            true
            );

        // Did we fail?
        if (attributes.Length == 0)
            return string.Empty;

        // Attempt to recover a reference to the attribute.
        AssemblyDescriptionAttribute? attr =
            attributes[0] as AssemblyDescriptionAttribute;

        // Did we fail?
        if (attr == null || attr.Description.Length == 0)
            return string.Empty;

        // Return the text for the attribute.
        return attr.Description;
    }

    // ******************************************************************

    /// <summary>
    /// Reads the value of the <see cref="AssemblyVersionAttribute"/>
    /// attribute for the given assembly.
    /// </summary>
    /// <param name="assembly">The assembly to read from.</param>
    /// <returns>The value of the given assembly's version attribute.</returns>
    public static string ReadAssemblyVersion(this Assembly assembly)
    {
        // Attempt to read the assembly's version attribute.
        object[] attributes = assembly.GetCustomAttributes(
            typeof(AssemblyVersionAttribute),
            true
            );

        // Did we fail?
        if (attributes.Length == 0)
            return string.Empty;

        // Attempt to recover a reference to the attribute.
        AssemblyVersionAttribute? attr =
            attributes[0] as AssemblyVersionAttribute;

        // Did we fail?
        if (attr == null || attr.Version.Length == 0)
            return string.Empty;

        // Return the text for the attribute.
        return attr.Version;
    }

    // ******************************************************************

    /// <summary>
    /// Reads the value of the <see cref="AssemblyInformationalVersionAttribute"/>
    /// attribute for the given assembly.
    /// </summary>
    /// <param name="assembly">The assembly to read from.</param>
    /// <returns>The value of the given assembly's informational version attribute.</returns>
    public static string ReadInformationalVersion(this Assembly assembly)
    {
        // Attempt to read the assembly's version attribute.
        object[] attributes = assembly.GetCustomAttributes(
            typeof(AssemblyInformationalVersionAttribute),
            true
            );

        // Did we fail?
        if (attributes.Length == 0)
            return string.Empty;

        // Attempt to recover a reference to the attribute.
        AssemblyInformationalVersionAttribute? attr =
            attributes[0] as AssemblyInformationalVersionAttribute;

        // Did we fail?
        if (attr == null || attr.InformationalVersion.Length == 0)
            return string.Empty;

        // Look for a '+' character, which, if found, signifies the start
        //   of semver 2.0 version info and should be removed.
        var index = attr.InformationalVersion.IndexOf('+');

        // Did we find it?
        if (0 < index)
        {
            // Strip off everythign past the '+' character.
            return attr.InformationalVersion.Substring(
                0,
                attr.InformationalVersion.Length - index - 2
                );
        }
        else
        {
            // Return the text for the attribute.
            return attr.InformationalVersion;
        }
    }
}

This class is actually copied straight out of my CG.Reflections NUGET package. I added this code, rather than referencing my NUGET package, because I wanted to keep CG.Tools.QuickCrypto free from external entanglements, as much as possible.

The methods in here all do essentially the same thing, so I’ll only cover the first one, ReadFileVersion. This method starts by calling GetCustomAttributes on the assembly reference we got as the incoming parameter. That call gives us an array of all the custom attributes associated with the assembly. For this method, we’re only really interested in the AssemblyFileVersionAttribute attribute, so we use that type as the argument to the GetCustomAttributes method. The result is, an attribute, as you can imagine. From there, we do some checking, just in case the attribute isn’t associated with the assembly, or, the value of the attribute is missing. Assuming none of those things have happened, we end by passing the value of the attribute to the caller.

All of these attributes are part of the “package” for the project. You can edit them by right clicking on the CG.Tools.QuickCrypto project, in the Solution Explorer of Visual Studio, and choosing the “Properties” menu choice

Once you’ve done that, it should show the properties for the project. Navigate to the “Package” properties, and you can edit all these properties for yourself.

Then, you can use the methods in the AssemblyExtensions class to read those property values, at runtime.

I’ll pick the walkthrough up, next time, by populating more of the folders and files. See you then!

Photo by Umberto on Unsplash