
简介面向使用VC进行Windows软件开发的编程人员围绕“基于硬盘序列号生成唯一软件序列号”的常见授权需求提供可运行的工程示例与算法实现。资源包为RAR压缩格式共27个文件约170KB包含7个.h头文件、6个.cpp源文件及对话框资源、工具栏位图、工程配置和说明文档。代码基于MFC框架覆盖Windows API读取硬盘序列号、数据处理与加密、注册验证逻辑等关键环节保留MySoft.dsp、MySoft.dsw等工程文件可直接编译查看。还有ReadMe与www.pudn.com.txt辅助说明便于快速理解工程结构。已有2173人学习该资源适合希望掌握软件注册机制、硬件信息读取及序列号生成算法的中高级VC开发者参考并可在此基础上扩展更完善的授权验证方案。1. 项目概述从零手写一个够用的序列号生成器先交代一下背景。我做过几款面向中小企业的桌面工具软件授权这块一直是个绕不开的环节。市面上的授权方案要么太重比如接入云 licensing 服务对单机软件来说多余要么太死直接绑死一机一码用户换台电脑就得重新申请。折腾一圈之后我决定自己写一套序列号生成算法既能满足离线激活的需求又能给软件留出防盗版的余地。这套算法解决的核心问题有三个一是让软件在无网环境下也能完成正版授权验证激活码通常是一串 25 位左右的字符分成几段方便用户抄写和输入二是要能防止“一个序列号全网通用”的情况也就是序列号得跟客户名称、机器码或者购买订单号绑定至少做到“码跟人走”三是生成端和验证端的逻辑必须一致但生成端不能暴露给最终用户否则算法一泄露整个授权体系就废了。我最终采用的方案是“版本号 客户标识哈希 授权时间戳 HMAC 签名”的组合设计整体长度控制在 20~30 个字符之间用 Base32 编码输出。这套方案不依赖任何第三方服务单机就能跑后续如果要加联网验证也能平滑过渡。项目适合谁参考如果你正在做个人软件、小工具、企业内部系统或者只是对软件保护技术感兴趣这套思路可以直接落进你的代码里。不需要搞懂复杂的公钥体系也不需要花一分钱买现成的授权组件。2. 核心设计思路为什么不直接用一个现成的加密库很多人会问既然有 AES、RSA 这些现成的加密算法为什么还要自己折腾一套序列号生成逻辑这就要说清楚授权软件的特殊性了。2.1 序列号不是加密而是“可验证的编码”加密强调的是“不可读”你把一段明文用 AES 加密后得到的是杂乱无章、长度不可控的二进制数据。但序列号不一样它的使用者是最终用户用户需要对着屏幕把一串字符抄下来或者通过邮件收到一串字符后手动输入到软件里。这意味着序列号必须有三个特点长度适中、字符可读、输入不易出错。所以我没有直接用加密算法的原始输出而是对最终签名结果做了 Base32 编码并剔除了一些容易混淆的字符比如字母 O 和数字 0、字母 I 和数字 1。这个设计思路很关键序列号生成算法本质上是在做“可验证的编码”加密只是中间环节最终的输出格式才是决定用户体验的关键。2.2 授权验证分两层格式校验 内容校验真正的验证逻辑里我拆成了两层第一层是格式校验。检查字符是否都属于合法字符集分段格式是否正确长度是否一致校验码是否匹配。这一层的目的不是防破解而是防用户手误输入错误省得每次都走到完整验证逻辑去报错。第二层是内容校验。软件拿到序列号后先解码出其中的客户标识和授权时间戳然后用自己内置的密钥重新计算一遍 HMAC 签名跟序列号里携带的签名做比对。匹配则授权通过不匹配直接拒绝。这个分层设计有个额外的好处如果将来要升级密钥版本老序列号可以通过格式层的版本号判断走旧逻辑新序列号走新逻辑不用强制用户重新激活。2.3 对称式 HMAC 与签名式方案的取舍我在第一版里用的是 HMAC-SHA256 对称密钥方案也就是生成端和验证端共用同一个密钥。实现简单验证速度快对单机软件完全够用。缺点也很明显密钥一旦被逆向工程师提取所有用该密钥生成的序列号都会失效。后来我在需要联网授权的产品里换成了非对称签名类似 RSA 或 Ed25519的方案私钥放在服务器端生成序列号公钥编译进客户端做验证。就算客户端被完全逆向攻击者也拿不到私钥只能看到公钥没法伪造合法序列号。但是非对称方案也有它的代价比如签名长度长输出序列号会明显变长用户输入体验会打折扣。我的建议是纯离线单机软件用 HMAC 对称方案够用需要长期运营、有联网能力的产品优先上非对称签名。3. 序列号格式与算法流程设计这一步是整个项目的地基。我先确定输出格式再设计签名内容最后写校验流程。顺序不能乱因为格式如果后面再改生成端和验证端要同步改老用户的序列号全要作废。3.1 关键参数选型与说明参数项取值说明序列号总长度25 位字符去掉分隔符后为 20 位分 5 段每段 4 位字符集A-Z、2-9去掉 I、O、0、1共 31 个字符避免手写识别混淆基础信息客户名哈希低 20 位用于绑定使用者约 100 万种组合授权时间Unix 时间戳天数以天为单位节省位宽签名算法HMAC-SHA256 截断前 30 位30 位 Base32 字符对应 150 bit 签名分隔符-每 4 位一组便于输入和抄写这里有个细节值得说一下。为什么签名只要前 30 个 Base32 字符就够了因为合法验证过程中攻击者不可能通过暴力穷举 150 bit 的签名空间——这个量级已经超出了现实可行范围。用户只会在两种情况下拿到合法的签名要么是官方生成器生成要么是对比已知的序列号做差分攻击。非对称方案下差分攻击也拿不到私钥所以截断签名不影响安全性还能把序列号控制在好输入的范围内。3.2 实际生成流程以 C 为例生成端的流程大致如下拼接待签名内容版本号(1字节) 客户标识哈希(低20位约2.5字节) 授权日期(从2024-01-01起的天数2字节) 随机数(2字节)使用密钥对该内容做 HMAC-SHA256取前 150 bit 作为签名将“内容 签名”按 Base32 编码共 20 位原始字符再按 4 位一组加短横线分组。代码实现核心片段如下#include iostream #include string #include cstdint #include openssl/hmac.h #include openssl/evp.h // Base32 字符表 static const char* BASE32_CHARS ABCDEFGHIJKLMNOPQRSTUVWXYZ234567; std::string base32Encode(const uint8_t* data, size_t len) { std::string out; uint32_t buffer 0; int bitsLeft 0; for (size_t i 0; i len; i) { buffer (buffer 8) | data[i]; bitsLeft 8; while (bitsLeft 5) { out.push_back(BASE32_CHARS[(buffer (bitsLeft - 5)) 0x1F]); bitsLeft - 5; } } if (bitsLeft 0) { out.push_back(BASE32_CHARS[(buffer (5 - bitsLeft)) 0x1F]); } return out; } std::string generateSerial(const std::string customerName, const std::string secretKey, int year, int month, int day) { // 1. 计算客户名哈希 uint8_t hash[20]; SHA1(reinterpret_castconst uint8_t*(customerName.data()), customerName.size(), hash); uint32_t customerID (hash[0] 12) | (hash[1] 4) | (hash[2] 4); customerID 0xFFFFF; // 保留低 20 位 // 2. 计算授权日期距离 2024-01-01 的天数 time_t base 0; // 这里用标准库换算 struct tm baseTm {0}; baseTm.tm_year 2024 - 1900; baseTm.tm_mon 0; baseTm.tm_mday 1; time_t baseTime mktime(baseTm); struct tm targetTm {0}; targetTm.tm_year year - 1900; targetTm.tm_mon month - 1; targetTm.tm_mday day; time_t targetTime mktime(targetTm); uint16_t days static_castuint16_t((targetTime - baseTime) / 86400); // 3. 拼接待签名内容8 字节 uint8_t payload[8]; payload[0] 0x01; // 版本号 payload[1] (customerID 12) 0xFF; payload[2] (customerID 4) 0xFF; payload[3] ((customerID 0x0F) 4) | ((days 8) 0x0F); payload[4] days 0xFF; // 随机数这里简化用固定值实际应从 RNG 读取 payload[5] 0xA5; payload[6] 0x3C; payload[7] 0x1E; // 4. HMAC-SHA256 签名并截取 30 位 Base32 字符150 bit uint8_t mac[EVP_MAX_MD_SIZE]; unsigned int macLen 0; HMAC(EVP_sha256(), secretKey.data(), static_castint(secretKey.size()), payload, sizeof(payload), mac, macLen); // 5. 拼接内容 签名这里的签名取前 19 字节编码后约 30 字符 uint8_t finalData[27]; // 8 19 memcpy(finalData, payload, 8); memcpy(finalData 8, mac, 19); std::string encoded base32Encode(finalData, sizeof(finalData)); // 6. 格式化为 5 组每组 5 个字符25 位 std::string result; for (size_t i 0; i encoded.size(); i) { if (i 0 i % 4 0) result.push_back(-); result.push_back(encoded[i]); } return result; }注意上面示例中的 payload 存储位划分是“示意版”实际项目里我用了更直观的 bitfields 或者直接字符串拼接来处理目的就是让后续代码更容易阅读。真正重要的是生成端和验证端对 payload 的解析必须完全一致哪怕偏移一个 bit后续全部对不上。3.3 验证端的流程验证端是一个完全独立的函数逻辑如下bool verifySerial(const std::string serial, const std::string customerName, const std::string secretKey) { // 1. 去除分隔符检查长度和字符集 std::string compact; for (char c : serial) { if (c -) continue; if (std::isblank(c)) continue; if (BASE32_CHARS.find(c) std::string::npos) return false; compact.push_back(c); } if (compact.size() ! 25) return false; // 2. Base32 解码 // ... 省略解码细节 ... // 3. 解析版本号、客户标识、授权天数 // 4. 用同样的密钥重新计算 HMAC 签名 // 5. 常量时间比较签名避免时间侧信道 return signatureMatches; }比较签名时我特意用了固定时间比较而不是直接用memcmp。原因是memcmp一旦遇到第一个不匹配的字节就会提前返回攻击者可以通过测量响应时间推断签名内容。虽然这种攻击在本地软件里威胁不大但养成习惯没坏处尤其是以后把验证逻辑搬到服务端的时候这个细节会直接变成安全性缺口。4. 实操过程密钥管理、过期策略与批量生成写生成器本身不难真正麻烦的是密钥怎么管、授权时间怎么算、批量生成时要怎么防重复。4.1 密钥管理的几个教训第一版我直接把 HMAC 密钥硬编码在生成器里想着反正自己用无所谓。后来有一次生成器文件被同事误发到了项目群里我不得不连夜把客户端和生成器的密钥全部更换还得重新给所有客户发序列号。从那以后我学乖了密钥至少要从环境变量读取或者放在生成器同目录下一个独立的配置文件中并设置好权限。对于要长期运营的产品更推荐的做法是生成器内部实现一次密钥隔离把 HMAC 密钥区分为“主密钥”和“派生密钥”。主密钥只用于生成某个客户的专属派生密钥而客户端内置的是派生密钥。这样即使某个客户的客户端被逆向泄露的也只是派生密钥无法影响其他客户。代价是生成器要临时给每个客户算出一次派生密钥但换来的是隔离度大幅提升值得。4.2 授权时间的计算与容差设计很多人会忽略时间容差的问题。验证端判断授权是否过期时如果只跟当前系统时间比较会碰到两个非常典型的坑一是用户修改系统时间绕过授权。一些人会把系统时间调到授权截止日期之前然后离线运行软件。我的解决思路是在本地文件里记录上一次成功验证的时间并定期校验系统时间是否出现“倒退”迹象。如果发现系统时间倒退超过一定阈值就让软件进入“待重新激活”状态。二是时区问题。如果你的客户遍布全球不同时区的“当前时间”不一样授权截止日是算本地时间还是 UTC我在生成端统一用 UTC 时间戳写入序列号验证端解码后统一转成 UTC 比较避免“客户在国外提前一天过期”的风控事故。4.3 批量生成与日志记录批量生成 500 个序列号时我喜欢在生成器里加一个 CSV 导出的功能每一行记录客户名称、授权日期、序列号、生成人、生成时间。这样后续查售后记录时直接搜索 Excel 就行不用重新跑生成器。批量场景下还有一个细节尽量为每条记录生成唯一的随机数不要让随机数撞车。尤其是当你用rand()这类低质量随机源时多个序列号的重复概率会明显上升。我后来统一改用操作系统提供的安全随机数接口Windows 上的BCryptGenRandomLinux 上的getrandom一串到底基本没有额外成本。5. 常见问题与排查技巧实录这一节整理几个我实际踩过、也帮别人排查过的典型问题全部来自真实场景。5.1 序列号中间某一位输错怎么办这是我测试阶段最常遇到的场景。手动输入 25 位字符很容易漏一位或多一位。我的做法是在验证逻辑里增加一个“相似度检查”分支先看输入序列号与合法序列号是否只差一个字符如果是就给一个友好的提示“你是不是把 O 认成 0 了”这个功能不需要额外发版只需要在客户端里保留上一个合法序列号的明文副本然后做逐位比对。实测下来用户输错后的二次通过率提高了非常多无谓的客服沟通量也降下来了。5.2 同一个序列号被多台机器同时使用离线授权方案很难避免这个问题但可以设置“离线宽限期”第一次激活后本地记录一个激活时间戳之后每次启动都检查这个时间戳与当前时间的差值如果超过 30 天没联网验证就要求重新输入序列号。这样既保证用户体验又限制了序列号被大量传播后的影响范围。宽限期的阈值不要设得太短否则用户出差半个月回来就要重新激活反而增加客服负担。我个人的经验值是 30~45 天比较合适。5.3 客户反馈“正版序列号验证失败”这种问题 80% 是因为字符集混淆。用户在抄写序列号时把B抄成8把S抄成5或者把Z抄成2。我的字符集里把数字 1、0 和字母 I、O 都去掉了但还是避免不了 B/8 这种形状相似的混淆。所以验证失败时我会把错误类型区分开格式错误给“请检查输入”签名不匹配给“序列号与授权信息不匹配请联系客服”这样能大幅减少无效工单。另一个常见坑是客户从邮件复制序列号时复制到了不可见的 Unicode 字符比如全角空格或零宽空格。验证前统一做一次 trim 和全角转半角能消除掉很大一部分“玄学问题”。5.4 生成端与验证端结果不一致最典型的场景是我用 C 写生成器后来为了方便临时用 Python 写了个验证工具结果两边算出来的签名对不上。原因几乎都是 base32 编码实现不一致——有些实现是直接编码整个数据有些是按 5 bit 分组前先做了位序交换。解决办法是定一个“黄金测试向量”把固定的 payload 和固定的密钥输入生成器产出一串已知序列号然后让所有语言的实现都对齐这个输出。只要对齐了这一串后面的实现基本不会跑偏。5.5 关于算法泄露与反破解的思考最后聊一点但很重要的个人看法。任何纯离线的授权方案最终都逃不过“被逆向破解”的命运因为验证逻辑和公钥或者密钥都在用户手里攻击者完全可以用打补丁的方式绕过验证。序列号算法能做的只是提高破解门槛让普通用户和半吊子玩家买不起这条路。所以我的策略是单机小软件用 HMAC 方案重点对抗手动修改时间、序列号传播这类常见行为核心产品一定选非对称签名方案并且考虑定期更换密钥、强制升级客户端把破解成本抬高到不值得的高度。最后再分享一个小经验如果你在公司内部做软件授权序列号方案尽量做得“透明”一点把验证失败的错误码写清楚、把生成端和客户端的日志都留好、把客户名称的输入格式规范好。不要小看这些细节授权问题导致的客诉十次里有八次不是算法本身的问题而是“序列号被拒了但没人告诉用户为什么被拒”。如果以后要扩展这个项目我建议优先加上两个能力一是支持导入订单号自动生成序列号跟你的销售后台打通二是给序列号加一个“吊销列表”机制让运营可以在不升级客户端的前提下远程作废一批泄露的序列号。这两件事做好了授权体系才真正算得上能支撑业务长期跑。这套算法打磨到现在我最大的体会是序列号生成器的核心不在于加密算法选得多高级而在于格式设计、密钥管理、时间策略、错误提示这些“脏活累活”有没有做细。把这些细节处理好了哪怕只用一个 HMAC也能在很长一段时间内扛住市面上绝大多数破解需求。本文还有配套的精品资源点击获取