Chiffrement d’un ApiController

J’ai eu un cas intéressant cette semaine: « sécuriser » les échanges entre WebApi internes, dont une partie des services est exposée en externe. Certains ApiControllers publics, d’autres internes. Je me suis orienté vers une solution simple, sans doute peu générique, malgré tout très testable.
Cela consiste à chiffrer les échanges de ces contrôleurs internes (les paramètres et le résultat).


UPDATE 08/09/2014 : Mon collègue (Clément pour ne point le nommer ;-)) m’a fait remarquer à juste titre que si l’on souhaite rendre l’algorithme de chiffrement symétrique configurable, le bloc CAB d’EntLib 5 peut être d’une bonne aide. Il suffirait de l’intégrer dans la classe Cipher abordée plus bas dans cet article. Notez cependant que ce bloc est obsolète depuis la version 6 d’EntLib. Pour l’avoir utilisée, la version 5 est tout à fait mature et je ne vois aucune raison de ne pas l’utiliser s’il répond à un cas d’utilisation. Dans le mien, il n’y avait pas de valeur ajoutée à pouvoir configurer le chiffrement car tous les programmes qui communiquent entre eux avec chiffrement des données font partie du périmètre de l’équipe.

UPDATE 03/08/2014 : Les routes WebApi 2 ne semblent pas supporter correctement un slash / dans un paramètre (même avec {*id}, notamment en cas de double slash). Je conseille donc de passer les éventuels paramètres encodés en BASE64 en paramètres d’URL (query string).
De plus, le code initial retournait un résultat JSON, ce qui est incorrect. Le code a été mis à jour pour retourner un type application/octet-stream (qui doit être inclus dans l’entête Accept du client).

Mon projet n’avait pas de mécanisme d’autorisation/authentification mis en place. Dans le cas contraire, je n’aurai pas adopté cette solution. Il me paraissait plus coûteux de mettre en place des autorisations, surtout pour contrôler les autorisations d’applications internes (pas de logique d’impersonation requise – usurpation d’identité pour les puristes de la langue française). Supposons que le projet soit constitué de contrôleurs, la plupart publics, quelques-uns « internes » au sens que d’autres contrôleurs ou d’autres projets peuvent y accéder, mais aucun client « public ».

Chiffrement des échanges avec deux algorithmes symétrique (Rijndael) et asymétrique (RSA)

Le principe consiste à :

  • Chiffrer les paramètres de la requête (côté client)
  • Déchiffrer les paramètres (serveur)
  • Traiter la requête
  • Chiffrer le résultat (serveur)
  • Déchiffrer le résultat (client)

Le principe est simple mais comme deux algorithmes de chiffrement sont utilisés, je vais préciser à chaque fois celui dont je parle. Les raisons pour lesquelles nous n’utilisons pas qu’un algorithme sont les suivantes:

  • Nous devons utiliser un algorithme de chiffrement asymétrique pour communiquer entre les deux parties: la clé privée n’étant connue que d’une partie, celle qui déchiffre, cela empêche à une partie qui connait la clé publique (un man-in-the-middle par exemple) de pouvoir déchiffrer un message.
  • Le chiffrement asymétrique est limité quant à la taille du message à chiffrer, en fonction de la taille de la clé utilisée. De façon approximative, le message doit être un peu plus court que la clé. Cette technique n’est donc pas adaptée pour chiffrer des données arbitraires, tel que le résultat d’une requête HTTP.

Nous utilisons un chiffrement symétrique pour le chiffrement des paramètres et du résultat (Rijndael). La clé est générée par le client et transmise en entête HTTP. Pour sécuriser celle-ci, nous la chiffrons avec une clé asymétrique (RSA): le client chiffre donc sa propre clé (Rijndael) avec la clé publique du serveur (RSA), avant de la transmettre en entête HTTP.

Le serveur déchiffre la clé (Rijndael) du client à l’aide de sa clé privée (RSA), puis déchiffre les arguments à l’aide de la clé du client (Rijndael).

Enfin, pour que la réponse soit sécurisée et déchiffrable par le client, nous utilisons la clé du client (Rijndael) pour chiffrer le résultat.

Tout cela en WebApi…

Le code source complet est sur GitHub (répertoire RsaRijndaelWebApi).

Pour que le contrôleur soit facilement testable, nous évitons de le rendre responsable de chiffrement. Il n’a donc pas à manipuler l’instance HttpResponseMessage retournée, ni à lire les entêtes HTTP de la requête. Un ActionFilter est parfaitement adapté à cette tâche, puisqu’il accède à la requête et à la réponse, avant et après l’exécution de l’action. Il peut même modifier la valeur des arguments.

Voici notre contrôleur d’exemple :

[InternalActionFilter]
public class SayHelloController : ApiController
{
    // GET /api/SayHello/id
    public string Get(string id)
    {
        return string.Format("Hello {0}", id);
    }
}

Notez l’attribut InternalActionFilter. C’est lui qui est chargé de déchiffrer les arguments (un seul ici) et de chiffrer la réponse :

public class InternalActionFilterAttribute : ActionFilterAttribute
    {
        private readonly static MediaTypeHeaderValue ApplicationOctetStreamMimeType = new MediaTypeHeaderValue("application/octet-stream");

        public const string HeaderKey = "X-Key";

        public const string HeaderIV = "X-IV";

        public override void OnActionExecuting(HttpActionContext actionContext)
        {
            if (!actionContext.Request.Headers.Contains(HeaderKey) ||
                !actionContext.Request.Headers.Contains(HeaderIV))
            {
                actionContext.Response = actionContext.Request.CreateResponse(System.Net.HttpStatusCode.Forbidden);
                return;
            }

            if (actionContext.ActionArguments.Count != 0)
            {
                try
                {
                    var cipher = (Cipher)actionContext.ControllerContext.Configuration.DependencyResolver.GetService(typeof(Cipher));
                    if (cipher == null)
                        throw new InvalidOperationException("Cipher service not found.");

                    var key1 = cipher.DecryptKey(Convert.FromBase64String(actionContext.Request.Headers.GetValues(HeaderKey).First()));
                    var key2 = cipher.DecryptKey(Convert.FromBase64String(actionContext.Request.Headers.GetValues(HeaderIV).First()));
                    using (var decryptor = cipher.CreateDataDecryptor(key1, key2))
                    {
                        for (int i = 0; i < actionContext.ActionArguments.Count; i++)
                        {
                            var arg = actionContext.ActionArguments.ElementAt(i);
                            var argType = arg.Value.GetType();
                            if (argType == typeof(string))
                            {
                                actionContext.ActionArguments[arg.Key] = decryptor.Decrypt((string)arg.Value);
                            }
                            else if (argType == typeof(byte[]))
                            {
                                actionContext.ActionArguments[arg.Key] = decryptor.Decrypt((byte[])arg.Value);
                            }
                            else
                            {
                                throw new NotSupportedException(string.Format("Type {0} not supported", argType.Name));
                            }
                        }
                    }
                }
                catch 
                {
                    actionContext.Response = actionContext.Request.CreateResponse(System.Net.HttpStatusCode.Forbidden);
                    return;
                }
            }
        }

        public override void OnActionExecuted(HttpActionExecutedContext actionExecutedContext)
        {
            if (actionExecutedContext.Response.IsSuccessStatusCode && actionExecutedContext.Response.Content != null)
            {
                if (actionExecutedContext.Response.Content.Headers.ContentType != null && actionExecutedContext.Response.Content.Headers.ContentType.MediaType != ApplicationOctetStreamMimeType.MediaType)
                {
                    actionExecutedContext.Response = actionExecutedContext.Request.CreateResponse(HttpStatusCode.NotAcceptable);
                    return;
                }

                HttpContent originalContent = actionExecutedContext.Response.Content;
                long? contentLength = originalContent.Headers.ContentLength;

                if (!contentLength.HasValue || contentLength.Value != 0)
                {
                    var request = actionExecutedContext.Request;
                    try
                    {
                        var actionContext = actionExecutedContext.ActionContext;
                        var cipher = (Cipher)actionContext.ControllerContext.Configuration.DependencyResolver.GetService(typeof(Cipher));
                        if (cipher == null)
                            throw new InvalidOperationException("Cipher service not found.");

                        var key1 = cipher.DecryptKey(Convert.FromBase64String(actionContext.Request.Headers.GetValues(HeaderKey).First()));
                        var key2 = cipher.DecryptKey(Convert.FromBase64String(actionContext.Request.Headers.GetValues(HeaderIV).First()));
                        using (var encryptor = cipher.CreateDataEncryptor(key1, key2))
                        {
                            using (var originalContentStream = contentLength.HasValue ? new MemoryStream((int)contentLength.Value) : new MemoryStream())
                            {
                                originalContent.CopyToAsync(originalContentStream);
                                originalContentStream.Position = 0;
                                actionExecutedContext.Response.Content = new ByteArrayContent(encryptor.Encrypt(originalContentStream));
                                actionExecutedContext.Response.Content.Headers.ContentType = ApplicationOctetStreamMimeType;
                            }
                        }
                    }
                    catch
                    {
                        actionExecutedContext.Response = request.CreateResponse(HttpStatusCode.Forbidden);
                        return;
                    }
                }
            }
        }
    }

Cette classe s’appuie sur une classe Cipher qui est un helper que je décrirai un peu après.

La surcharge de méthode OnActionExecuting est responsable de vérifier que la requête contient les deux entêtes spéciales X-Key et X-IV. Il s’agit des paramètres de clé Rijndael fournis par le client. Si une entête manque, un statut HTTP 403 (Forbidden) est retourné immédiatement, avant l’invocation de l’action sur le contrôleur. Sinon, si l’action contient des arguments, ceux-ci sont déchiffrés sur place à l’aide de la clé du client (Rijndael). Cette clé est obtenue dans les entêtes X-Key et X-IV que l’on déchiffre à l’aide de la clé privée du serveur (RSA).

La seconde surcharge de méthode, OnActionExecuted, est exécutée comme son nom l’indique après que l’action du contrôleur ait été exécutée. Si le statut HTTP est un succès, alors le corps de la réponse est chiffré à l’aide de la clé du client (Rijndael).

La classe Cipher est un helper qui abstrait les deux algorithmes RSA et Rijndael. Les méthodes EncryptKey et DecryptKey utilisent l’algorithme RSA pour chiffrer/déchiffrer la clé symétrique Rijndael. Tandis que les méthodes CreateDataEncryptor et CreateDataDecryptor retournent une interface capable de chiffrer/déchiffrer à l’aide de l’algorithme Rijndael, à partir des paramètres de clé spécifiés (ceux transmis dans la requête par le client).

L’exemple téléchargeable sur GitHub contient un programme console (self-host OWIN). Il y a une petite subtilité qui mérite d’être précisée: le paramètre utilisé dans la route du contrôleur est susceptible de contenir des slash / après chiffrement et encodage en BASE64. Il faut donc s’assurer que cela est prévu au niveau de la route configurée. Un seul paramètre du contrôleur peut donc être inclus dans sa route :

config.Routes.MapHttpRoute(
                name: "DefaultApi",
                // noter {*id} et non {id} :
                routeTemplate: "api/{controller}/{*id}",
                defaults: new { id = RouteParameter.Optional });

Cela étant, comme dit en introduction, la présence d’un double slash dans un paramètre de route n’est pas supporté (WebApi 2.2). Je conseille donc d’utiliser des paramètres d’URL, compatibles avec la même route (/SayHello/?id=…).