将程序绑定到运行时特性

在了解了前面章节中描述的特性后,我们通过一个例子来了解这样一个常见任务:将一个 MQL 程序绑定到一个硬件环境以防止其被复制。当程序通过 MQL5 市场分发时,绑定由服务本身提供。然而,如果程序是定制开发,则可能要链接到账号、客户端名称或者终端(计算机)的可用特性。第一种方式不一定总是很方便,因为很多交易员有若干个活动账户(可能有不同经纪商),更不用说还有有效期有限的演示账户了。第二种方式可能有不真实的感觉或太普通。因此,我们将实施一种原型算法,用于将程序绑定到选定的一组环境特性。更严谨的安全方案可能会使用 DLL 并直接从 Windows 读取设备硬件标签,但并非每个客户都会同意运行潜在不安全的库。

我们的保护方案见 EnvSignature.mq5 脚本。该脚本从环境给定特性计算哈希值,并基于这些哈希值创建一个唯一签名(指纹)。

哈希处理是一种对任意信息的特殊处理,其结果是创建一个具有以下特性的新的数据块(由使用的算法保证):

  • 两个原始数据集的哈希值匹配意味着数据几乎百分百是相同的(随机匹配的概率可忽略)。
  • 如果原始数据更改,则其哈希值也将更改。
  • 无法以数学方式从哈希值还原原始数据(它们保持加密),除非穷举所有可能的初始值(如果它们的初始大小增加,并且没有关于它们的结构的信息,则问题在可预见的将来无解)。
  • 哈希大小是固定的(与初始数据量无关)。

假设环境特性当中的一个由该字符串描述:"TERMINAL_LANGUAGE=German"。可获得类似下面的一个简单语句(简化):

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

实际语言将匹配设置。利用一个假设性的 Hash 函数,我们可以计算签名。

string signature = Hash(language);

有多个特性时,我们可以直接为所有特性重复步骤,或者从组合字符串请求哈希值(目前这是伪码,不是真实程序的一部分)。

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);

收到的签名可由用户报告给程序开发人员,开发人员在收到仅适合于该签名的验证字符串时以特殊方式签署。该签名也是基于哈希算法,需要知道某个密钥(密码短语),仅开发人员知道这个密钥,并硬编码到程序中(用于验证阶段)。

开发人员将把验证字符串传递给用户,用户然后将能够通过在参数中指定该字符串来运行程序。

如果没有验证字符串的情况下启动,程序应会针对当前环境生成一个新的签名,将其打印到日志,然后退出(该信息应传递给开发人员)。如果验证字符串无效,程序应会显示一条错误消息,然后退出。

可为开发人员提供若干种启动模式:有签名,但没有验证字符串(生成最后一个),或者有签名和验证字符串(程序将重新签署签名,并将其与指定验证字符串比较以进行校验)。

我们评估一下该保护的选择性。毕竟这里的绑定未对任何形式的唯一标识符执行。

下表列出了关于两个特性的统计数据:屏幕尺寸和 RAM。很明显,值将随时间推移而改变,但大致分布将保持不变:一些特性值将是最普遍的,而一些“新款”高端配置和“旧款”值将构成频率递减的“尾部”。

屏幕

1920x1080

1536x864

1440x900

1366x768

800x600

RAM

21%

7%

5%

10%

4%

4Gb 20%

4.20

1.40

1.00

2.0

0.8

8Gb 20%

4.20

1.40

1.00

2.0

0.8

16Gb 15%

3.15

1.05

0.75

1.5

0.6

32Gb 10%

2.10

0.70

0.50

1.0

0.4

64Gb 5%

1.05

0.35

0.25

0.5

0.2

注意具有最大值的单元格,因为它们表示相同的签名(除非我们对它们引入随机性元素,这将在下文讨论)。在本例中,左上角的两个特性组合是最可能的,各为 4.2%。但这些仅仅是两个特性。如果你向评估的环境添加界面语言、时区、核心数以及工作数据路径(首选共享路径,因为它包含 Windows 用户名),则潜在匹配数将明显减少。

对于哈希加密,我们使用内置 CryptEncode 函数(将在 加密 一节中描述),该函数支持 SHA256 哈希加密方法。顾名思义,它生成 256 位长度(即 32 个字节)的哈希值。如果需要将其显示给用户,则我们将其转换为十二进制表示的文本,并获得一个长度为 64 字符的字符串。

为使签名更短,我们使用 Base64 编码将其转换(CryptEncode 函数及其对应函数 CryptDecode 也支持这种编码),转换结果为一个长度为 44 字符的字符串。不同于单向哈希运算,Base64 编码是可逆的,即可以从该编码恢复原始数据。

主要运算由 EnvSignature 类实现。它定义 data 字符串,该字符串应累积描述环境的某些片段。公共接口由 append 函数的若干重载版本组成,以添加具有环境特性的字符串。本质上,它们使用由虚拟 'pepper' 方法返回的某个抽象元素作为链接,将请求的特性名称及其值连接起来。派生类将其定义为特定字符串(但不能为空)。

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)));
   }

为将任意字符串添加到对象,使用普通方法 append,在上述方法中已经调用过。

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

或者,开发人员可向哈希化数据中添加所谓的“盐”。这是一个具有随机生成数据的数组,进一步复杂化哈希逆转。每次签名生成都将不同于前一次,即使环境保持不变。该功能的实现以及其他更具体的保护措施(如使用对称加密和密钥的动态计算)留待自学。

由于环境由我们熟知的特性(特性列表由 MQL5 API 常量限制)组成,并且它们并非都充分唯一,因此,如果不使用盐,我们的防护机制(如我们计算的那样)会为不同的用户生成相同的签名。如果发生许可证泄露,签名匹配将无法识别泄露源。

因此,你可以通过为每个客户更改实施哈希加密之前提供特性这一方法来增加保护的有效性。当然,该方法本身不应被披露。在研究的示例中,这意味着更改 pepper 方法的内容以及重新编译产品。这可能代价高昂,但可避免使用随机盐。

填充了特性字符串之后,我们便能够生成签名。这使用 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);
   }

该方法向数据中添加某个密钥(一个字节序列,只有开发人员知道,且位于程序中),并计算共享字符串的哈希值。该密钥从虚拟 secret 方法获得,该方法也将定义派生类。

生成的带有哈希值的字节数组使用 Base64 编码为字符串。

现在我们了解最重要的类函数:check。正是该函数实现了“由开发人员签名并在用户端检查签名”这一机制。

   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;
   }

在正常运行期间(对于用户),该方法计算接收签名的哈希值(需用密钥补充),并将其与来自验证字符串的值进行比较(验证字符串必须首先从 Base64 解码为哈希值的原始二进制表示)。如果两个哈希值匹配,则验证成功:验证字符串匹配特性集。很明显,空验证字符串(或者随机输入的字符串)将不能通过测试。

在开发人员的机器上,必须在源代码中为签名实用工具定义 I_AM_DEVELOPER 宏,这导致空验证字符串被以不同的方式处理。在此情况下,生成的哈希值是 Base64 编码的,该字符串通过 validation 参数传递。这样,实用工具将能够向开发人员显示给定签名的现成验证字符串。

要创建一个对象,你需要使用密钥和盐值来定义字符串的某个派生类。

// 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;
   }
};

我们快速挑选几个特性以填充签名。

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);
}

现在一切就绪,可以在 OnStart 函数中测试我们的保护方案了。但首先我们看看输入变量。由于同一程序将以两个版本编译,即供最终用户使用的版本和供开发人员使用的版本,因此有两组输入变量:一组供用户输入注册数据,另一组基于开发人员的签名生成该数据。预期由开发人员使用的输入变量已在上文使用 INPU 宏进行了描述。用户只能获得验证字符串。

input string Validation = "";

当该字符串为空时,程序将收集环境数据,生成新的签名,并将其打印到日志。此时脚本的工作便已完成,因为对有用代码的访问尚未被确认。

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
}

如果变量 Validation 已填充,我们检查其与签名的符合性,如果失败,终止工作。

   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
}

如果不存在不一致性,算法继续进行程序的工作代码。

在开发人员侧(在使用 I_AM_DEVELOPER 宏构建的程序版本中),将引入一个签名。我们使用签名还原 MyEnvSignature 对象的状态,并计算验证字符串。

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
   }
   ...

开发人员不仅可以指定签名,而且可以验证前面:在此情况下,代码执行将以用户模式继续(用于调试目的)。

如果你希望,你可以通过以下方式来模拟环境中的变化:

      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;
      }

我们看几个测试日志。

第一次运行 EnvSignature.mq5 脚本时,“用户”将看到类似以下日志的内容(值将因环境差异而不同):

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=

它将生成的签名发送给“开发人员”(在测试期间没有实际的用户,因此所有“用户”和“开发人员”角色均加了引号),开发人员在 Signature 参数中将其输入到使用 I_AM_DEVELOPER 宏编译的签名实用工具。结果,程序将生成验证字符串:

Validation:YBpYpQ0tLIpUhBslIw+AsPhtPG48b0qut9igJ+Tk1fQ=

“开发人员”将其发回给“用户”,“用户”将其输入到 Validation 参数中,将得到激活的脚本:

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

为证实保护的有效性,我们将该脚本复制为服务:为此,我们将该文件复制到文件夹 MQL5/Services/MQL5Book/p4/,并将源代码中的以下行:

#property script_show_inputs

替换为以下行:

#property service

我们编译该服务,创建并运行其实例,然后在输入参数中指定先前接收到的验证字符串。结果,服务将中止(在到达具有要求的代码的语句之前)并显示以下消息:

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

重点是在环境的特性中,我们已经使用了字符串 MQL_PROGRAM_TYPE。因此,为某一类型程序颁发的许可证不适用于另一类型的程序,即使该程序运行在同一用户的计算机上。