Vincular un programa a propiedades en tiempo de ejecución

Como ejemplo de trabajo con las propiedades descritas en las secciones anteriores, veamos la popular tarea de vincular un programa MQL a un entorno de hardware para protegerlo de la copia. Cuando el programa se distribuye a través de MQL5 Market, la vinculación la proporciona el propio servicio. Sin embargo, si el programa se desarrolla a medida, puede vincularse al número de cuenta, al nombre del cliente o a las propiedades disponibles del terminal (ordenador). La primera no siempre es conveniente, porque muchos operadores de trading tienen varias cuentas reales (probablemente con distintos brokers), por no hablar de las cuentas demo con un periodo de validez limitado. La segunda puede ser ficticia o demasiado corriente. Por lo tanto, implementaremos un algoritmo prototipo para vincular un programa a un conjunto seleccionado de propiedades del entorno. Los esquemas de seguridad más serios probablemente podrían utilizar una DLL y leer directamente las etiquetas de hardware del dispositivo desde Windows, pero no todos los clientes aceptarán ejecutar bibliotecas potencialmente inseguras.

Nuestra opción de protección se presenta en el script EnvSignature.mq5. El script calcula hashes a partir de las propiedades dadas del entorno y crea una firma única (huella) basada en ellas.

El hashing es un procesamiento especial de información arbitraria, como resultado del cual se crea un nuevo bloque de datos que tiene las siguientes características (están garantizadas por el algoritmo utilizado):

  • La coincidencia de los valores hash de dos conjuntos de datos originales significa, con una probabilidad de casi el 100 %, que los datos son idénticos (la probabilidad de una coincidencia aleatoria es insignificante).
  • Si los datos originales cambian, su valor hash también cambiará.
  • Es imposible restaurar matemáticamente los datos originales a partir del valor hash (permanecen secretos) a menos que se realice una enumeración completa de los posibles valores iniciales (si su tamaño inicial aumenta y no hay información sobre su estructura, el problema es irresoluble en un futuro previsible).
  • El tamaño del hash es fijo (no depende de la cantidad de datos iniciales).

Supongamos que una de las propiedades del entorno está descrita por la cadena «TERMINAL_LANGUAGE=German». Se puede obtener con una simple sentencia como la siguiente (simplificada):

string language = EnumToString(TERMINAL_LANGUAGE) +
            "=" + TerminalInfoString(TERMINAL_LANGUAGE);

El idioma real coincidirá con la configuración. Teniendo una hipotética función Hash, podemos calcular la firma.

string signature = Hash(language);

Si hay más propiedades, simplemente repetimos el procedimiento para todas ellas, o solicitamos un hash a partir de las cadenas combinadas (hasta ahora esto es pseudocódigo, no forma parte del programa real).

string properties[];
// fill in the property lines as you wish
// ...
string signature;
for(int i = 0i < ArraySize(properties); ++i)
{
   signature += properties[i];
}
return Hash(signature);

La firma recibida puede ser comunicada por el usuario al desarrollador del programa, que la «firmará» de forma especial, al recibir una cadena de validación adecuada sólo para esta firma. La firma también se basa en hashing y requiere el conocimiento de algún secreto (frase de contraseña), conocido sólo por el desarrollador y codificado en el programa (para la fase de verificación).

El desarrollador pasará la cadena de validación al usuario, que podrá ejecutar el programa especificando esta cadena en los parámetros.

Cuando se lanza sin una cadena de validación, el programa debe generar una nueva firma para el entorno actual, imprimirla en el registro y salir (esta información debe pasarse al desarrollador). Con una cadena de validación no válida, el programa debe mostrar un mensaje de error y salir.

Se pueden proporcionar varios modos de lanzamiento para el propio desarrollador: con una firma, pero sin una cadena de validación (para generar la última), o con una firma y una cadena de validación (aquí el programa volverá a firmar la firma y la comparará con la cadena de validación especificada sólo para comprobar).

Calculemos cuán selectiva será dicha protección. Al fin y al cabo, la vinculación aquí no se realiza a un único identificador de nada.

En la siguiente tabla se ofrecen estadísticas sobre dos características: el tamaño de la pantalla y la memoria RAM. Obviamente, los valores cambiarán con el tiempo, pero la distribución aproximada seguirá siendo la misma: unos pocos valores característicos serán los más populares, mientras que algunos «nuevos» avanzados y otros «antiguos» que van saliendo de la circulación conformarán «colas» decrecientes.

Pantalla

1920x1080

1536x864

1440x900

1366x768

800x600

RAM

21 %

7 %

5 %

10 %

4 %

4 Gb 20 %

4.20

1.40

1.00

2.0

0.8

8 Gb 20 %

4.20

1.40

1.00

2.0

0.8

16 Gb 15 %

3.15

1.05

0.75

1.5

0.6

32 Gb 10 %

2.10

0.70

0.50

1.0

0.4

64 Gb 5 %.

1.05

0.35

0.25

0.5

0.2

Preste atención a las celdas con los valores más grandes, porque significan las mismas firmas (a menos que introduzcamos un elemento de aleatoriedad en ellas, que se discutirá más adelante). En este caso, las dos combinaciones de características de la esquina superior izquierda son las más probables, con un 4.2 % cada una. Pero estas son solo dos características. Si añade el idioma de la interfaz, la zona horaria, el número de núcleos y la ruta de datos de trabajo (preferiblemente compartida, ya que contiene el nombre de usuario de Windows) al entorno evaluado, el número de posibles coincidencias disminuirá notablemente.

Para el hashing, utilizamos la función integrada CryptEncode (se describirá en la sección Criptografía) que admita el método hash SHA256. Como su nombre indica, produce un hash de 256 bits, es decir, 32 bytes. Si necesitáramos mostrárselo al usuario, entonces lo traduciríamos a texto en representación hexadecimal y obtendríamos una cadena de 64 caracteres de longitud.

Para que la firma sea más corta, la convertiremos utilizando la codificación Base64 (también es compatible con la función CryptEncode y su homóloga CryptDecode), lo que dará como resultado una cadena de 44 caracteres de longitud. A diferencia de una operación hash unidireccional, la codificación Base64 es reversible, es decir, los datos originales pueden recuperarse a partir de ella.

Las operaciones principales se implementan mediante la clase EnvSignature, que define la cadena data que debe acumular ciertos fragmentos que describen el entorno. La interfaz pública consiste en varias versiones sobrecargadas de la función append para añadir cadenas con propiedades de entorno. Esencialmente, unen el nombre de la propiedad solicitada y su valor utilizando como enlace algún elemento abstracto devuelto por el método virtual 'pepper'. La clase derivada lo definirá como una cadena específica (pero puede estar vacía).

class EnvSignature
{
private:
   string data;
protected:
   virtual string pepper() = 0;
public:
   bool append(const ENUM_TERMINAL_INFO_STRING e)
   {
      return append(EnumToString(e) + pepper() + TerminalInfoString(e));
   }
   bool append(const ENUM_MQL_INFO_STRING e)
   {
      return append(EnumToString(e) + pepper() + MQLInfoString(e));
   }
   bool append(const ENUM_TERMINAL_INFO_INTEGER e)
   {
      return append(EnumToString(e) + pepper()
        + StringFormat("%d"TerminalInfoInteger(e)));
   }
   bool append(const ENUM_MQL_INFO_INTEGER e)
   {
      return append(EnumToString(e) + pepper()
        + StringFormat("%d"MQLInfoInteger(e)));
   }

Para añadir una cadena arbitraria a un objeto, existe un método genérico append, que se llama en los métodos anteriores.

   bool append(const string s)
   {
      data += s;
      return true;
   }

Opcionalmente, el desarrollador puede añadir, por así, «sal» a los datos hash. Se trata de un array con datos generados aleatoriamente, lo que complica aún más la inversión del hash. Cada generación de la firma será diferente de la anterior, aunque el entorno permanezca constante. La implementación de esta característica, así como de otros aspectos de protección más específicos (como el uso de cifrado simétrico y el cálculo dinámico del secreto) se dejan para un estudio independiente.

Dado que el entorno se compone de propiedades bien conocidas (su lista está limitada por las constantes de la API de MQL5), y no todas ellas son suficientemente únicas, nuestra defensa, tal y como la hemos calculado, puede generar las mismas firmas para distintos usuarios si no utilizamos la sal. La coincidencia de firmas no permitirá identificar el origen de la fuga de licencias si ésta se ha producido.

Por lo tanto, puede aumentar la eficacia de la protección cambiando el método de presentación de las propiedades antes del hash para cada cliente. Por supuesto, el método en sí no debe divulgarse. En el ejemplo analizado, esto implica cambiar el contenido del método pepper y volver a compilar el producto, lo cual puede resultar caro, pero permite evitar el uso de sal al azar.

Con la cadena de propiedades rellenada, podemos generar una firma. Para ello se utiliza el método emit.

   string emit() const
   {
      uchar pack[];
      if(StringToCharArray(data + secret(), pack0
         StringLen(data) + StringLen(secret()), CP_UTF8) <= 0return NULL;
   
      uchar key[], result[];
      if(CryptEncode(CRYPT_HASH_SHA256packkeyresult) <= 0return NULL;
      Print("Hash bytes:");
      ArrayPrint(result);
   
      uchar text[];
      CryptEncode(CRYPT_BASE64resultkeytext);
      return CharArrayToString(text);
   }

El método añade un cierto secreto (una secuencia de bytes que sólo conoce el desarrollador y que se encuentra dentro del programa) a los datos y calcula el hash de la cadena compartida. El secreto se obtiene del método virtual secret, que también definirá la clase derivada.

El array de bytes resultante con el hash se codifica en una cadena utilizando Base64.

Ahora viene la función de clase más importante: check. Es esta función la que implementa la firma desde el desarrollador y la comprueba desde el usuario.

   bool check(const string sigstring &validation)
   {
      uchar bytes[];
      const int n = StringToCharArray(sig + secret(), bytes0
         StringLen(sig) + StringLen(secret()), CP_UTF8);
      if(n <= 0return false;
      
      uchar key[], result1[], result2[];
      if(CryptEncode(CRYPT_HASH_SHA256byteskeyresult1) <= 0return false;
      
      /*
        WARNING
        The following code should only be present in the developer utility.
        The program supplied to the user must compile without this if.
      */
      #ifdef I_AM_DEVELOPER
      if(StringLen(validation) == 0)
      {
         if(CryptEncode(CRYPT_BASE64result1keyresult2) <= 0return false;
         validation = CharArrayToString(result2);
         return true;
      }
      #endif
      uchar values[];
      // the exact length is needed to not append terminating '0'
      if(StringToCharArray(validationvalues0
         StringLen(validation)) <= 0return false;
      if(CryptDecode(CRYPT_BASE64valueskeyresult2) <= 0return false;
      
      return ArrayCompare(result1result2) == 0;
   }

Durante el funcionamiento normal (para el usuario), el método calcula el hash a partir de la firma recibida, complementada con el secreto, y lo compara con el valor de la cadena de validación (primero debe descodificarse de Base64 en la representación binaria en bruto del hash). Si los dos hashes coinciden, la validación es correcta: la cadena de validación coincide con el conjunto de propiedades. Obviamente, una cadena de validación vacía (o una cadena introducida al azar) no pasará la prueba.

En la máquina del desarrollador, la macro I_AM_DEVELOPER debe definirse en el código fuente de la utilidad de firma, lo que provoca que una cadena de validación vacía se trate de forma diferente. En este caso, el hash resultante se codifica en Base64, y esta cadena se pasa a través del parámetro validation. De este modo, la utilidad podrá mostrar al desarrollador una cadena de validación ya preparada para la firma dada.

Para crear un objeto, se necesita una determinada clase derivada que defina cadenas con el secreto y con «pepper».

// WARNING: change the macro to your own set of random bytes
#define PROGRAM_SPECIFIC_SECRET "<PROGRAM-SPECIFIC-SECRET>"
// WARNING: choose your characters to link in pairs name'='value 
#define INSTANCE_SPECIFIC_PEPPER "=" // obvious single sign is selected for demo
// WARNING: the following macro needs to be disabled in the real product,
//          it should only be in the signature utility
#define I_AM_DEVELOPER
#ifdef I_AM_DEVELOPER
#define INPUT input
#else
#define INPUT const
#endif
 
INPUT string Signature = "";
INPUT string Secret = PROGRAM_SPECIFIC_SECRET;
INPUT string Pepper = INSTANCE_SPECIFIC_PEPPER;
 
class MyEnvSignature : public EnvSignature
{
protected:
   virtual string secret() override
   {
      return Secret;
   }
   virtual string pepper() override
   {
      return Pepper;
   }
};

Escojamos rápidamente algunas propiedades para rellenar la firma.

void FillEnvironment(EnvSignature &env)
{
   // the order is not important, you can mix
   env.append(TERMINAL_LANGUAGE);
   env.append(TERMINAL_COMMONDATA_PATH);
   env.append(TERMINAL_CPU_CORES);
   env.append(TERMINAL_MEMORY_PHYSICAL);
   env.append(TERMINAL_SCREEN_DPI);
   env.append(TERMINAL_SCREEN_WIDTH);
   env.append(TERMINAL_SCREEN_HEIGHT);
   env.append(TERMINAL_VPS);
   env.append(MQL_PROGRAM_TYPE);
}

Ahora todo está listo para probar nuestro esquema de protección en la función OnStart. Pero primero, veamos las variables de entrada. Dado que el mismo programa se compilará en dos versiones, para el usuario final y para el desarrollador, existen dos conjuntos de variables de entrada: para la introducción de los datos de registro por parte del usuario y para la generación de estos datos a partir de la firma del desarrollador. Las variables de entrada destinadas al desarrollador se han descrito anteriormente utilizando la macro INPUT. Sólo la cadena de validación está disponible para el usuario.

input string Validation = "";

Cuando la cadena esté vacía, el programa recogerá los datos del entorno, generará una nueva firma y la imprimirá en el registro. Esto completa el trabajo del script, ya que aún no se ha confirmado el acceso al código útil.

void OnStart()
{
   MyEnvSignature env;
    string signature;
   if(StringLen(Signature) > 0)
   {
     // ... here will be the code to be signed by the author
   }
   else
   {
      FillEnvironment(env);
      signature = env.emit();
   }
   
   if(StringLen(Validation) == 0)
   {
      Print("Validation string from developer is required to run this script");
      Print("Environment Signature is generated for current state...");
      Print("Signature:"signature);
      return;
   }
   else
   {
     // ... check the validation string here
   }
   Print("The script is validated and running normally");
   // ... actual working code is here
}

Si se rellena la variable Validation, se comprueba su conformidad con la firma y se finaliza el trabajo en caso de fallo.

   if(StringLen(Validation) == 0)
   {
      ...
   }
   else
   {
      validation = Validation// need a non-const argument
      const bool accessGranted = env.check(Signaturevalidation);
      if(!accessGranted)
      {
         Print("Wrong validation string, terminating");
         return;
      }
      // success
   }
   Print("The script is validated and running normally");
   // ... actual working code is here
}

Si no hay discrepancias, el algoritmo pasa al código de trabajo del programa.

Por parte del desarrollador (en la versión del programa que se diseñó con la macro I_AM_DEVELOPER), se puede introducir una firma. Restauramos el estado del objeto MyEnvSignature utilizando la firma y calculamos la cadena de validación.

void OnStart()
{
   ...
   if(StringLen(Signature) > 0)
   {
      #ifdef I_AM_DEVELOPER
      if(StringLen(Validation) == 0)
      {
         string validation;
         if(env.check(Signaturevalidation))
           Print("Validation:"validation);
         return;
      }
      signature = Signature
      #endif
   }
   ...

El desarrollador no sólo puede especificar la firma, sino también validarla: en este caso, la ejecución del código continuará en modo usuario (a efectos de depuración).

Si lo desea, puede simular un cambio en el entorno, por ejemplo, de la siguiente manera:

      FillEnvironment(env);
      // artificially make a change in the environment (add a time zone)
      // env.append("Dummy" + (string)(TimeGMTOffset() - TimeDaylightSavings()));
      const string update = env.emit();
      if(update != signature)
      {
         Print("Signature and environment mismatch");
         return;
      }

Veamos algunos registros de prueba.

Cuando ejecute por primera vez el script EnvSignature.mq5, el «usuario» verá algo como el siguiente registro (los valores variarán debido a las diferencias de entorno):

Hash bytes:
  4 249 194 161 242  28  43  60 180 195  54 254  97 223 144 247 216 103 238 245 244 224   7  68 101 253 248 134  27 102 202 153
Validation string from developer is required to run this script
Environment Signature is generated for current state...
Signature:BPnCofIcKzy0wzb+Yd+Q99hn7vX04AdEZf34hhtmypk=

Envía la firma generada al «desarrollador» (no hay usuarios reales durante la prueba, por lo que se citan todos los roles de «usuario» y «desarrollador»), que la introduce en la utilidad de firma (compilada con la macro I_AM_DEVELOPER), en el parámetro Signature. Como resultado, el programa generará una cadena de validación:

Validation:YBpYpQ0tLIpUhBslIw+AsPhtPG48b0qut9igJ+Tk1fQ=

El «desarrollador» lo devuelve al «usuario», y éste, al introducirlo en el parámetro Validation, obtendrá el script activado:

Hash bytes:
  4 249 194 161 242  28  43  60 180 195  54 254  97 223 144 247 216 103 238 245 244 224   7  68 101 253 248 134  27 102 202 153
The script is validated and running normally

Para demostrar la eficacia de la protección, dupliquemos el script como servicio: para ello, copiemos el archivo en la carpeta MQL5/Services/MQL5Book/p4/ y sustituyamos la siguiente línea en el código fuente:

#property script_show_inputs

con la siguiente línea:

#property service

Vamos a compilar el servicio, crear y ejecutar su instancia, y especificar la cadena de validación recibida previamente en los parámetros de entrada. Como resultado, el servicio abortará (antes de llegar a las sentencias con el código requerido) con el siguiente mensaje:

Hash bytes:
147 131  69  39  29 254  83 141  90 102 216 180 229 111   2 246 245  19  35 205 223 145 194 245  67 129  32 108 178 187 232 113
Wrong validation string, terminating

La cuestión es que entre las propiedades del entorno hemos utilizado la cadena MQL_PROGRAM_TYPE. Por lo tanto, una licencia emitida para un tipo de programa no funcionará para otro tipo de programa, aunque se esté ejecutando en el ordenador del mismo usuario.