flibitijibibo / MonoKickstart

Kick start executable for running stand-alone distributed Mono applications
Other
72 stars 14 forks source link

Handling SSL certificates #3

Open DanielGibson opened 7 years ago

DanielGibson commented 7 years ago

Hi, I'm trying to make a game updater/launcher (https://github.com/Nihlus/Launchpad) run self-contained on Linux (as long as libgtk2 and friends are installed on the system). Using MonoKickstart I got it to work - mostly (thanks for this project by the way, this is really cool!).

The only remaining problem seems to be, that I want the updater to download stuff via https, so it needs SSL certificates - and apparently mono can't just use the ones already installed on the system, but wants those to be copied/converted into its own directories via cert-sync first (which additionally is painful because the cert path that needs to be passed to cert-sync is different on different distros). Now having the user do that (or asking them to install mono or ca-certificates-mono) kinda defies the goal of providing something selfcontained that just works..

Do you have any idea on how to handle this with minimal pain? For example, can I maybe somehow make the mono of monokickstart use certificates I provide myself in a subdirectory of the game/updater? Then I could put our current SSL certificate (the public part of it) there and if we need to update it, we could hopefully get the new certificate version to customers in time via the updater - without recompiling it. But I'm open to other solutions as well, of course :)

Thanks in advance!

Cheers, Daniel

flibitijibibo commented 7 years ago

I struggled with this for a couple games and I do have a solution. It's a terrible, terrible solution.

It looks like this:

using System.Security.Cryptography.X509Certificates;
using System.Net.Security;

    private static bool MonoValidateCert(
        object sender,
        X509Certificate certificate,
        X509Chain chain,
        SslPolicyErrors sslPolicyErrors
    ) {
        // FIXME: This probably still isn't very secure, but it's better than nothing. -flibit
        if (    sslPolicyErrors != SslPolicyErrors.None &&
                sslPolicyErrors != SslPolicyErrors.RemoteCertificateChainErrors    )
        {
            System.Console.WriteLine("SSL POLICY ERROR: " + sslPolicyErrors.ToString());
            return false;
        }
        if (sslPolicyErrors == SslPolicyErrors.RemoteCertificateChainErrors)
        {
            foreach (X509ChainStatus status in chain.ChainStatus)
            {
                if (status.Status != X509ChainStatusFlags.PartialChain)
                {
                    System.Console.WriteLine("CHAIN STATUS ERROR: " + status.StatusInformation);
                    return false;
                }
            }
        }
        try
        {
            return (    certificate.GetCertHashString().Equals("YES A HARDCODED HASH") &&
                        certificate.Issuer.Equals("YES A HARDCODED ISSUER") &&
                        certificate.Subject.Equals("YES A HARDCODED URL THING") );
        }
        catch
        {
            return false;
        }
    }

    void SomeWebFunction()
    {
        ServicePointManager.ServerCertificateValidationCallback = MonoValidateCert;
        ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls;
        // The rest
    }

So we're hardcoding a cert that requires updating every time we renew, and it doesn't work for dynamic URLs. I'd love a better solution to this that uses the system certs or something.

DanielGibson commented 7 years ago

Thanks for the quick reply!

This is indeed kinda limited and hardcoding the certificate (or probably several certificates, esp. new and old before one expires) is a bit hacky.

Have you tried copying needed (root?) certificate PEMs to ~/.config/.mono/new-certs/Trust/? Related: Any idea how their names are generated (on my system it's like 024dc131.0)? And what the binary data files in ~/.config/.mono/certs/Trust/are for? (If I rename that directory, SSL in mono still seems to work, so apparently only certs-new are needed?)

flibitijibibo commented 7 years ago

I haven't tried any of this and don't know how any of it works. The docs regarding this were pretty limited, and seemed to assume that we were just packaging the entirety of Mono and not just shipping the runtime.

DanielGibson commented 7 years ago

MonoKickstart seems to work with just those certs (and without knowing of a full mono installation that I keep in /opt/). The docs are indeed very limited, I think they tell you to just use cert-sync or certmgr and "This details the current behavior of Mono and could change between releases." (I don't care about that part much, as I decide which mono version is used)

I guess I'll look into that a bit further, if needed by reading the source (not that I've ever written any C# code, I guess I'll pick it up as I go..)

DanielGibson commented 7 years ago

Ok, I have a solution to read in given root CA certificates from .pem and install them in the users (CurrentUser) trust store (Root).

(Warning: may contain traces of rant)

Ok, theoretically it should go like described here: https://dotnetcodr.com/2015/06/08/https-and-x509-certificates-in-net-part-4-working-with-certificates-in-code/ using System.Security.Cryptography.X509Certificates.X509Store and .X509Certificate2. Unfortunately, this only adds the certificate to ~/.config/.mono/certs/Trust/, but not ~/.config/.mono/new-certs/Trust/ - and thus (at least for the code I'm using)is completely useless: Apparently System.Net.HttpWebRequest on Mono 5.0.1.1 needs the certificate to be in new-certs. I would have expected the standard .net classes to do the right thing and add it to both, but no idea wtf is happening there.

So I looked at what the cert-sync tool does: it uses Mono.Security.X509.X509Store and .X509Certificate which are a little less nice to use (X509Certificate has no constructor that just takes a filename, for example). It adds the loaded certificates to both Mono.Security.X509.X509StoreManager.CurrentUser.TrustedRoot and .NewCurrentUser.TrustedRoot which creates files in both directories and makes HttpWebRequest work with https links. The code I wrote looks like this:

#if USE_MONO
using Mono.Security.X509;
#endif
// ....

#if USE_MONO
  private static X509Certificate LoadPemCert(string filename)
  {
      try
      {
          Stream file = File.OpenRead(filename);
          StreamReader reader = new StreamReader(file);
          string certbase64 = "";
          bool inBase64block = false;
          for(string line = reader.ReadLine(); line != null; line = reader.ReadLine())
          {
              if(inBase64block)
              {
                  if(line.StartsWith("-----END CERTIFICATE-----"))
                  {
                      return new X509Certificate(Convert.FromBase64String(certbase64));
                  }
                  certbase64 += line;
              }
              else
              {
                  inBase64block = line.StartsWith("-----BEGIN CERTIFICATE-----");
              }
          }
      }
      catch(Exception e)
      {
          Log.Error("Failed to open " + filename + " : " + e.Message);
      }
      return null;
  }

  private static void InstallCerts()
  {
      // I /think/ we only need the new variant, but better safe than sorry...
      X509Store[] stores = { X509StoreManager.CurrentUser.TrustedRoot,
                             X509StoreManager.NewCurrentUser.TrustedRoot };
      string[] storeNames = {"old trusted user store", "new trusted user store"};

      string[] certpaths = Directory.GetFiles("Certs/", "*", SearchOption.TopDirectoryOnly);
      foreach(string certpath in certpaths)
      {
          X509Certificate cert = LoadPemCert(certpath);
          if(cert != null)
          {
              for(int i=0; i<stores.Length; ++i)
              {
                  X509Store store = stores[i];
                  string storeName = storeNames[i];
                  if(!store.Certificates.Contains(cert))
                  {
                      try
                      {
                          store.Import(cert);
                          Log.Info("Imported " + certpath + " into " + storeName);
                      }
                      catch(Exception e)
                      {
                          Log.Error("Failed to import " + certpath + " into "
                                        + storeName + ": " + e.Message);
                      }
                  }
              }
          }
      }
  }
#endif

And then, somewhere in Main():

#if USE_MONO
  InstallCerts();
#endif

USE_MONO must be defined in the solution somewhere, I didn't find any mono or Linux specific #define that's always available (sometimes __MonoCS__ was suggested, but that was only for the mcs compiler and nowadays mono uses csc by default and there it's not defined because CERTAINLY NO ONE WILL EVER NEED TO KNOW WHAT PLATFORM OR COMPILER IS USED). I defined it like this:

  <PropertyGroup Condition=" '$(OS)' == 'Unix' ">
    <DefineConstants>$(DefineConstants);USE_MONO</DefineConstants>
  </PropertyGroup>

I think this is mostly ok. Generally I'd prefer if I didn't have to install the certificates in a persistent store, but could just create a temporary X509Store, put the certificates in there and tell the program to use that while it's running. But I found no way to do that, so the certificates are copied to ~/.config/.mono/ and are available to all mono programs that the current user will ever run on that machine. I guess it doesn't matter all that much with Mono/Linux, but for Windows it would certainly be preferable to not having to litter the system-/user-wide certificate stores that are used by basically every program.

flibitijibibo commented 2 years ago

We've run into this once more today... we should implement the environment variables described by Jo here: https://github.com/mono/mono/issues/6388