加密在 MQL 程序中很少使用。 在日常交易中,使用密码术的机会并不多。 一个例外就是偏执的信号跟单机希望保护发送的数据免于监听,仅此而已。 若数据不会离开终端,很难想象为什么需要加密/解密数据。 甚至,这可能代表开发人员能力低下,因其造成了终端的额外负载。
也许无需在交易中使用加密? 实际上,其实有。 例如,考虑许可。 可能会有一家小型公司,甚或一位广受欢迎产品的独立开发者。 这种情况与许可问题相关,因此需要许可证加密/解密。
可在许可证中指定用户数据,和产品的可编辑列表。 指标或智能交易系统开始操作,检查许可证的可用性,及给定产品的期限。 程序向服务器发送请求,更新许可证,如有必要,或可接收新的许可证。 这可能不是最有效和最安全的方式,但出于演示目的,我们将在本文中运用它。 显然,在这种情况下,许可证将通过不同的软件工具进行读取/写入 — 终端,远程服务器,控制模块和日志记录模块。 它们可以由不同的人,在不同的时间,以不同的语言编写。
本文的目的是研究加密/解密模式,在这种模式下,MetaTrader 终端可以解密由 C# 或 C++ 程序加密的对象,反之亦然。
本文适用于中等技能的程序员和初学者。
概述中已经提到了这一点。 我们将尝试模拟一个实际问题的解决方案,要求为若干种产品(指标和智能交易系统)的许可证创建、加密和解密。 对于我们而言,用哪个程序来加密和解密许可证并不重要。 例如,可以先在开发者的计算机上创建许可证,然后在销售部门对其进行纠正,然后在交易者的计算机上解密。 该过程必须针对性能低廉的算法有很强的容错性。
除了解决主要任务外,我们还将研究许可的复杂问题。 这不是一切就绪立即可用的许可证,而只是可能的变体之一,应对其进一步进行编辑和开发。
我们参考终端文档来获取操作的源数据。 有两个标准函数负责加密/解密过程:
int CryptEncode( ENUM_CRYPT_METHOD method, // conversion method const uchar& data[], // source array const uchar& key[], // encryption key uchar& result[] // destination array ); int CryptDecode( ENUM_CRYPT_METHOD method, // conversion method const uchar& data[], // source array const uchar& key[], // encryption key uchar& result[] // destination array );
加密/解密的具体方法由 method 参数确定。 在这种情况下,我们对 method 参数可以具有的三个值很感兴趣: CRYPT_AES128,CRYPT_AES256, CRYPT_DES。 这三个值代表具有不同密钥长度的对称加密算法。
在本文中,我们仅用其中之一 CRYPT_AES128。 这是一种具有 128 位(16 字节)密钥的对称分组密码算法。 其他算法的用法类似。
AES 算法(不仅是选定的算法,还有其他具有密钥长度的算法)拥有一些上述函数中没有提供的一些重要设置。 其中包括加密模式和填充。 我们不会深入介绍这些术语。 因此,终端使用电子密码簿(ECB)加密模式,且 Padding (填充模板)等于零。 我要感谢交易伙伴,因为他们在 MQL5 论坛上给与了解释。 现在,我们的任务将更容易解决。
鉴于我们研究如何将加密/解密应用于许可,故我们的操作对象是许可。 这应该是一些包含可适用在许可证的各种产品信息的结构。 这里需要以下数据:
我们用最简单的方法创建相应的结构:
#define PRODMAXLENGTH 255 struct ea_user { ea_user() {expired = -1;} datetime expired; //License expiration (-1 - unlimited) int namelength; //Product name length char uname[PRODMAXLENGTH]; //Product name void SetEAname(string name) { namelength = StringToCharArray(name, uname); } string GetEAname() { return CharArrayToString(uname, 0, namelength); } bool IsExpired() { if (expired == -1) return false; // NOT expired return expired <= TimeLocal(); } };//struct ea_user
此处是一些解释:
然而,仅有此结构是不够的。 显然,许可证必须包含用户详细信息。 该信息能被包括在已讲述过的结构中,但用户可能拥有多个产品许可证,因此效率低下。 一个更合理的解决方案是为用户创建一个单独的结构,并往其中添加所需数量的产品许可证。 因此,用户将只拥有一个许可证,但其中包含所有许可产品的权限。
可包含在用户描述结构中的信息:
#define COUNTACC 5 struct user_lic { user_lic() { uid = -1; log_count = 0; ea_count = 0; expired = -1; ArrayFill(logins, 0, COUNTACC, 0); } long uid; //User ID datetime expired; //End of user service (-1 - unlimited) int log_count; //The number of the user's accounts long logins[COUNTACC]; //User's accounts int ea_count; //The number of licensed products bool AddLogin(long lg){ if (log_count >= COUNTACC) return false; logins[log_count++] = lg; return true; } long GetLogin(int num) { if (num >= log_count) return -1; return logins[num]; } bool IsExpired() { if (expired == -1) return false; // NOT expired return expired <= TimeLocal(); } };//struct user_lic
以下是一些澄清:
到目前为止,此结构包含有关用户和相关产品的足够信息。 它们每个都是一种类型,可以通过 StructToCharArray 函数对其实例进行处理。
现在,我们需要将结构数据序列化为字节数组,以便进一步进行加密。 实现如下:
创建一个类来执行以下操作:
class CLic { public: static int iSizeEauser; static int iSizeUserlic; CLic() {} ~CLic() {} int SetUser(const user_lic& header){ Reset(); if (!StructToCharArray(header, dest) ) return 0; return ArraySize(dest); }//int SetUser(user_lic& header) int AddEA(const ea_user& ea) { int c = ArraySize(dest); if (c == 0) return 0; uchar tmp[]; if (!StructToCharArray(ea, tmp) ) return 0; ArrayInsert(dest, tmp, c); return ArraySize(dest); }//int AddEA(ea_user& ea) bool GetUser(user_lic& header) const { if (ArraySize(dest) < iSizeUserlic) return false; return CharArrayToStruct(header, dest); }//bool GetUser(user_lic& header) //num - 0 based bool GetEA(int num, ea_user& ea) const { int index = iSizeUserlic + num * iSizeEauser; if (ArraySize(dest) < index + iSizeEauser) return false; return CharArrayToStruct(ea, dest, index); }//bool GetEA(int num, ea_user& ea) int Encode(ENUM_CRYPT_METHOD method, string key, uchar& buffer[]) const { if (ArraySize(dest) < iSizeUserlic) return 0; if(!IsKeyCorrect(method, key) ) return 0; uchar k[]; StringToCharArray(key, k); return CryptEncode(method, dest, k, buffer); } int Decode(ENUM_CRYPT_METHOD method, string key, uchar& buffer[]) { Reset(); if(!IsKeyCorrect(method, key) ) return 0; uchar k[]; StringToCharArray(key, k); return CryptDecode(method, buffer, k, dest); } protected: void Reset() {ArrayResize(dest, 0);} bool IsKeyCorrect(ENUM_CRYPT_METHOD method, string key) const { int len = StringLen(key); switch (method) { case CRYPT_AES128: if (len == 16) return true; break; case CRYPT_AES256: if (len == 32) return true; break; case CRYPT_DES: if (len == 7) return true; break; } #ifdef __DEBUG_USERMQH__ Print("Key length is incorrect: ",len); #endif return false; }//bool IsKeyCorrect(ENUM_CRYPT_METHOD method, string key) private: uchar dest[]; };//class CLic static int CLic::iSizeEauser = sizeof(ea_user); static int CLic::iSizeUserlic = sizeof(user_lic);
该类中添加了两个函数,可以进行加密和解密,还有一个受保护函数用于检查密钥长度。 从其代码中可以看到,例如, CRYPT_AES128 方法的密钥长度必须等于 16 个字节。 实际上,它不能少于 16 个字节。 大概,以后会将其常规化,令其对开发人员隐藏。 我们不会依赖于此,而是会严格设置所需的密钥长度。
最后,已生成的字节数组能够进行加密,并将其保存到二进制文件当中。 根据一般规则,此文件应存储在终端的 File 文件夹中。 若有必要,可对其进行读取和解密:
bool CreateLic(ENUM_CRYPT_METHOD method, string key, CLic& li, string licname) { uchar cd[]; if (li.Encode(method, key, cd) == 0) return false; int h = FileOpen(licname, FILE_WRITE | FILE_BIN); if (h == INVALID_HANDLE) { #ifdef __DEBUG_USERMQH__ Print("File create failed: ",licname); #endif return false; } FileWriteArray(h, cd); FileClose(h); #ifdef __DEBUG_USERMQH__ li.SaveArray(); #endif return true; }// bool CreateLic(ENUM_CRYPT_METHOD method, string key, const CLic& li, string licname) bool ReadLic(ENUM_CRYPT_METHOD method, string key, CLic& li, string licname) { int h = FileOpen(licname, FILE_READ | FILE_BIN); if (h == INVALID_HANDLE) { #ifdef __DEBUG_USERMQH__ Print("File open failed: ",licname); #endif return false; } uchar cd[]; FileReadArray(h,cd); if (ArraySize(cd) < CLic::iSizeUserlic) { #ifdef __DEBUG_USERMQH__ Print("File too small: ",licname); #endif return false; } li.Decode(method, key, cd); FileClose(h); return true; }// bool ReadLic(ENUM_CRYPT_METHOD method, string key, CLic& li, string licname)
两个函数都很清楚,不需要其他说明。 随附的 CryptoMQL.zip 文件包含两个脚本和一个函数库文件,它们实现了加密/解密,还有加密的许可证文件 lic.txt。
我们创建一个简单的 C# 项目,从而模拟另一个程序的解密和编辑过程。 使用 Visual Studio 2017 并为 .NET Framework 平台创建一个控制台应用程序。 检查 System.Security 和 System.Security.Cryptography 的空间连接。
代码中出现以下问题:MQL 和 C# 具有不同的时间格式。 本文已经解决了该问题。 作者已完成了一个壮举,我们可以在项目中借用他的 MtConverter 类。
创建两个类,分别为 EaUser 和 UserLic ,其字段类似于 ea_user 和 user_lic 结构。 目的是解密由终端创建的许可证(lic.txt 文件),解析接收的数据,修改对象,并重新加密,以便创建新文件。 如果您仔细设置加密/解密模式,此任务必将易于实现。 此处是相应代码的样子:
using (Aes aesAlg = Aes.Create()) { aesAlg.Key = Key; aesAlg.IV = IV; aesAlg.Mode = CipherMode.ECB; aesAlg.Padding = PaddingMode.Zeros; ..................................
请注意最后两行,其中设置了加密模式(ECB)和填充。 我们为这些模式采用有关的设置信息。 模块中与密钥安装有关的第一行应该予以澄清。 它采用与终端加密时相同的密钥,但这次将其转换为字节数组:
string skey = "qwertyuiopasdfgh"; byte[] Key = Encoding.ASCII.GetBytes(s: skey);
注意设置“ IV” 参数的那一行。 这就是所谓的“初始化向量”,即,除 ECB 模式外参与所有加密模式的随机数字。 因此,我们此时简单地创建所需长度的数组:
byte[] iv = new byte[16];
另外,请注意,C# 中的密钥情况与 MQL 中的情况不同。 如果密钥长度(在本例中为 “qwertyuiopasdfgh” 行)大于 16,则将引发异常。 这就是为什么严格控制 MQL 代码中的密钥长度是一个不错决定的原因。
其余的非常简单。 读取二进制文件 -> 解密流 -> 利用 BinaryReader 填充创建的 UserLic 类实例。 也许,若序列化相应的类,可能会获得相似的结果。 您可自行测试这种可能性。
我们来修改某些字段,在这种情况下,我们将更改用户 ID。 然后,以相同的方式加密数据,并创建一个新文件 “lic_C#.txt”。 以上操作由项目中的两个静态函数 EncryptToFile_Aes 和 DecryptFromFile_Aes 执行。 为了进行调试,我添加了两个类似的函数,它们不操纵文件,而是针对字节数组起作用: EncryptToArray_Aes 和 DecryptFromArray_Aes。
以下附件,含有 CryptoC#.zip 项目的所有必需文件。
任何人都可能注意到以下项目缺陷:
我没有实现上述功能,因为本文目标并非创建功能齐全的应用程序。 如果我们实现了所有必需的部分,那么多余的代码部分会很庞大,并且会分散基本问题的注意力。
下一个项目是以 C++ 创建的。 我们在 Visual Studio 2017 环境中创建控制台应用程序。 在箱体之外我们不支持任何加密/解密。 因此,我们必须通过下载并安装 OpenSSL 安装包来连接著名的 OpenSSL 函数库。 结果就是,我们可以使用所有 Open SSL 函数库和包含项,它们应该连接到所创建项目。 有关如何将函数库连接到项目的详细信息,请阅读这篇文章。 不幸的是,OpenSSL 文档远未完善,然而没有更好的使用方法。
连接函数库后,继续编写代码。 首先要做的事情是再次讲述已知的两个结构:
constexpr size_t PRODMAXLENGTH = 255; #pragma pack(1) typedef struct EA_USER { EA_USER(); EA_USER(std::string name); EA_USER(EA_USER& eauser); std::time_t expired; long namelength; char eaname[PRODMAXLENGTH]; std::string GetName(); void SetName(std::string newName); std::string GetTimeExpired(); std::string ToString(); size_t ToArray(byte* pbyte); constexpr size_t GetSize() noexcept; friend std::ostream& operator<< (std::ostream& out, EA_USER& eauser); friend std::istream& operator>> (std::istream& in, EA_USER& eauser); }EAUSER, *PEAUSER; #pragma pack() constexpr size_t COUNTACC = 5; #pragma pack(1) typedef struct USER_LIC { using PEAUNIC = std::unique_ptr<EAUSER>; USER_LIC(); USER_LIC(USER_LIC&& ul); USER_LIC(const byte* pbyte); int64_t uid; std::time_t expired; long log_count; int64_t logins[COUNTACC]; long ea_count; std::vector<PEAUNIC> pEa; std::string GetTimeExpired(); std::string ToString(); size_t ToArray(byte* pbyte); void AddEA(EA_USER eau); bool AddAcc(long newAcc); size_t GetSize(); friend std::ostream& operator<< (std::ostream& out, USER_LIC& ul); friend std::istream& operator>> (std::istream& in, USER_LIC& ul); USER_LIC& operator = (const USER_LIC&) = delete; USER_LIC(const USER_LIC&) = delete; } USERLIC, *PUSERLIC; #pragma pack()
该代码比 C# 的要复杂一些。 某些字段类型有所不同。 例如,在此项目中,携带帐户数组的字段具有 int64_t 数组类型,而 MQL 包含文件具有 long 类型。 这与相应类型的大小有关。 如果您不控制此类功能,则可能导致难以捕获的错误。 有些部分则比较容易:此处我们不需要转换时间。
另外,在这个项目中,我们可能会遇到密钥长度不正确的问题。 若要解决此问题,请在项目中包括以下函数:
std::string AES_NormalizeKey(const void *const apBuffer, size_t aSize)
此函数会将 appBuffer 数组“修剪”为所需的 aSize 长度。 另外,我们编写以下辅助函数:
void handleErrors(void) { ERR_print_errors_fp(stderr); }
此函数将提供 OpenSSL 函数库中错误代码的说明。 以下两个函数实现了主要操作:
int aes_decrypt(const byte* key, const byte* iv, const byte* pCtext, byte* pRtext, int iTextsz) int aes_encrypt(const byte* key, const byte* iv, const byte* pPtext, byte* pCtext, int iTextsz)
附件中提供了方法的实现。 我只会提及一些基本要点:
EVP_CIPHER_CTX_set_padding(ctx.get(), 0);
确保传递“零”,而不是这样: EVP_CIPHER_CTX_set_padding(ctx.get(), EVP_PADDING_ZERO);也许看似与此相应。 更多问题是与添加的连接有关。 事实上,如果填充值为零(如同我们的项目中一样),则开发者必须注意确保加密对象的长度是 BLOCK_SIZE = AES_BLOCK_SIZE 的倍数,即 16 个字节。 这就是为什么在调用 aes_encrypt(......) 之前,必须要为加密数组提供相应的对齐方式。
按顺序执行与上一个项目相同的操作:
该项目的所有源文件都位于下面随附的 CryptoС++.zip 存档中。
现在,我们进入最后的操作步骤。 将最近加密的 lic_С++.txt 文件移至 MetaTrader 数据目录的 File 文件夹当中,并用先前编写的脚本 deleteuser.mq5 对其解密。 我们得到了预期的结果:尽管长度有所变化,但文件已成功解密。
那么,我们会得到什么结果呢? 最重要的是,我们检测到加密/解密参数,该参数允许将加密文件从一个程序传输到另一个程序。 显然,我们可以稍后假定,如果加密/解密失败,则该问题可能是由应用程序中的错误引起的。
如您一样的许多人可能都知道,加密不仅限于加密/解密。 我们来研究一个加密原语 - 哈希散列。 此过程意味着将某个任意数组转换为固定长度的数组。 这样的数组称为哈希值,转换函数称为 hash 函数。 两个初始数组至少在某一数位上彼此不同,将产生完全不同的哈希值,这些哈希值可用于识别和比较。
此为一个示例。 用户在站点上注册,并输入他的识别数据。 数据保存在一个非常机密的数据库当中。 现在,同一用户尝试在主页上输入登录名和密码,从而登录该站点。 网站应该怎么做? 它可以简单地从数据库中检索用户的密码,并将其与输入的密码进行比较。 但这并不安全。 安全的方式是取所存储密码的哈希值并和输入密码的哈希值进行比较。 即使存储的哈希值被盗,密码本身依然保持安全。 通过比较哈希值,我们可以确定输入的密码是否正确。
哈希是一个单向过程。 即使拿到哈希值,也不可能将其还原为数据数组。 因此,哈希对于密码学非常重要。 我们研究不同环境中的哈希计算。
我们的目的相同:找出在终端程序和其它第三方程序中进行计算时,如何确保相同初始数据的哈希值相同。
在 MQL 中,用我们之前所用的库函数 CryptEncode 计算哈希值。 为计算哈希值,应设置 method 函数的参数值。 我们采用 CRYPT_HASH_SHA256 值。 文档提供了其他值和其他哈希类型,因此您可以进一步阅读本主题。 采用现有密码行:“qwertyuiopasdfgh” 作为源数组。 计算其哈希值,并将哈希值写入文件。 所得代码非常简单;因此,我们简单地将其包含在附加的脚本文件 decryptuser.mq5 当中,而无需创建单独的类和函数:
string k = "qwertyuiopasdfgh"; uchar key[], result[], enc[]; StringToCharArray(k, enc); int sha = CryptEncode(CRYPT_HASH_SHA256,enc,key,result); string sha256; for(int i = 0; i < sha; i++) sha256 += StringFormat("%X ",result[i]); Print("SHA256 len: ",sha," Value: ",sha256); int h = FileOpen("sha256.bin", FILE_WRITE | FILE_BIN); if (h == INVALID_HANDLE) { Print("File create failed: sha256.bin"); }else { FileWriteArray(h, result); FileClose(h); }
此处未使用先前用于加密的 key 数组。 将生成的哈希值写入 result 数组,输出到窗口,并写入 sha256.bin 文件。 所得哈希值的长度固定为 32 个字节。 您可以更改源数组的大小,我们将其设置为一个字符长,但哈希值大小仍为 32 个字节。
在 C# 和 C++ 项目中添加所需的功能,来重现相同的计算。 所做的修改很小,非常简单。 我们采用来自密码字符串的相同源数组。 添加类似的代码行。 计算,然而... 您会得到令人沮丧的不同结果! 好吧,它们“并非完全不同”。 MQL 脚本和 C++ 项目计算的哈希值是相同的。 但 C# 项目给出了不同的结果。 我们尝试采用另一个只有一个字符 “a” 组成的字符串。 同样,C# 项目将计算并产生不同的哈希值。
该问题与调用 StringToCharArray 函数有关,该函数调用将字符串转换为数组。 如果在 StringToCharArray 调用之后查看结果数组,则会看到该数组的大小增加了一倍。 例如,在采用字符串 “a” 调用函数之后,结果数组将包含两个元素。 第二个元素为 “0”。 在 C# 中调用 Encoding.ASCII.GetBytes 则可以避免这种情况。 在这种情况下,数组中将不包含 “0”。
现在,我们可以向 C# 项目中添加一个代码块,该代码块将 “0” 附加到字节数组。 之后,我们可以采用此字节数组来计算哈希值。 现在我们获得了预期的结果。 针对相同的输入数据所有三个项目计算出相同的哈希值。 生成的哈希值在文件 sha256.bin, sha256_c#.bin, sha256_С++.bin 中可用,这些文件位于下面随附的 CryptoMQL.zip 存档之中。
请注意,上面的示例涉及文本数据。 显然,对于最初的二进制数组,不需要调用 StringToCharArray 和 Encoding.ASCII.GetBytes。 多余的 “0” 不会产生问题。 因此,另一个可能的选择是从 MQL 项目中删除 “0”,替代在 C# 中将其加入。
无论如何,我们已经解决了最初的问题 - 我们发现即使在不同环境里计算,在确定条件下对象哈希值也将是相同的。 我们还实现了本文开头列出的目标。 我们已确定应采用哪种加密/解密模式来确保不同环境中结果的兼容性。
尽管在 MetaTrader 5 终端的算法交易中不经常用到加密/解密操作,但当出现这种需求时,此任务可能会有所帮助。
在本文之外,还有什么? 创建档案 - 此选项为 CrypetEncode 函数提供。 Base64 编码标准,也已提供。 我认为,无需研究这些模式。 是的,创建档案时可以设置密码,但是:
我们操作的对象是许可证。 出于本文的目的,我选择了可能有助于理解加密/解密任务的方法。 因此,我使用了字节数组。 它们被加密,写入文件,解密,等等。 在实际情况下,这非常不便,并可能导致错误。 将原始结构封包到数组中并解压缩它们时,一个数位错误也会损坏整个许可证。 甚至,如上所述,考虑到不同类型的大小差异,这种情况很有可能。 因此,用于存储许可证的另一种可能的格式是文本。 它们是 xml 和 json。 可以考虑采用 json 格式,这是一个很好的解决方案,因为我们可以选用现有的 MQL、C# 和 C++ 实现的出色解析器。
# | 名称 | 类型 | 说明 |
---|---|---|---|
1 | CryptoMQL.zip | 存档 | 含有加密/解密脚本的存档。 |
2 | CryptoC#.zip | 存档 | C# 加密/解密项目。 |
3 | CryptoС++.zip | 存档 | C++ 加密/解密项目。 |
本社区仅针对特定人员开放
查看需注册登录并通过风险意识测评
5秒后跳转登录页面...
移动端课程