前言
对于客户端授权,我们提供客户端软件,并设定使用期限。商户在授权期满后需要重新购买才能继续使用。为此,我们会向客户端发送授权码,完成续期或重新授权的操作。
这边我们一起实现下,如何使用授权码,实现对软件的授权操作。
逻辑分析
1. 身份验证
在授权过程中,首先要验证客户端的身份,确保软件或用户是合法的。也就是当前我们这个授权码只能给特定的设备,也是就是授权码和设备绑定。这边和设备绑定的方式是通过设备ID或其他唯一标识符来确认客户端设备的合法性。也就是授权码里要包含设备ID,授权码设备ID和设备端的设备ID一致才能进行授权。这边为了防止被篡改还可以加上设备的MAC地址,也就是身份验证通过设备ID和MAC地址。
MAC地址很容易就被修改,如果只使用MAC那么用户直接修改MAC地址即可。这边就需要增加硬件信息。看了网上说的使用CPU序列号和硬盘序列号,这边执行命令都是固定的,没有意义。
cpu序列号
root@nld-master:~# cat /proc/cpuinfo | grep Serial | awk '{print $3}'
root@nld-master:~#
硬盘序列号
root@nld-master:~# lsblk -o SERIAL | tail -n 1
00000000000000000001
获取 MAC 地址
root@nld-virtual-machine:~# cat /sys/class/net/ens160/address
00-11-22-33-44-55
获取 BIOS UUID
root@nld-master:~# cat /sys/class/dmi/id/product_uuid
e1d2c3b4-5678-90ab-cdef-1234567890ab
通过上面可以看出CPU序列号和硬盘序列号没有实际意义。
DeviceFinger(设备指纹)=SHA256(MAC+BIOS UUID)
网卡MAC码是由全球惟一的一个固定组织来分配的,未经认证和授权的厂家无权生产网卡。
BIOS UUID(也称为 系统 UUID)是主板固件提供的唯一标识符,通常用于标识设备。在 Linux 或 Windows 上都可以获取 BIOS UUID。
在 Docker 容器环境 下,直接获取 MAC 地址 和 BIOS UUID 可能会受到限制,因此需要采取更稳妥的方案。
解决方案:
1.初始化时采集设备信息
在盒子初始化阶段,获取设备的 MAC 地址关键信息。
由于 Docker 容器内获取这些信息可能受限,应在 宿主机 运行时采集并存储。
2.加密存储 & 防篡改
将采集到的设备信息 加密存入文件,确保数据安全。
采用 数字签名 验证数据完整性,防止用户篡改信息绕过验证。
3.混淆文件路径 & 命名
采用 sha256(mac + 密钥) 生成文件名,避免用户轻易定位或删除文件。
4. 验证授权码设备和当前运行设备,读取生成的盒子文件
上面方案存在的问题
如果用户能够找到 MAC 地址文件的存储路径,他们可以通过替换成其他盒子的MAC地址文件,使系统识别为任意设备的 MAC 地址。这样,算法就能在其他设备(盒子)上运行,而不会受到原本的 MAC 地址绑定限制。尽管用户无法直接修改 MAC 地址文件,但如果可以替换它,就等同于绕过了原本的安全机制。因此,该方案存在安全漏洞,无法有效防止设备欺骗。
可行的解决方案
由于算法程序需要与内网摄像机通信,设备必须处于局域网环境才能正常运行。基于这一特性,我们可以利用 MAC 地址修改可能导致网络连接中断 的情况,增加一个 定时器机制:
每 10 分钟自动进行一次授权码校验,确保设备仍然符合授权条件。
如果用户尝试修改 MAC 地址,可能会导致网络断开,从而影响算法的正常运行。
频繁验证增加修改成本,使用户难以持续伪造 MAC 地址,最终放弃篡改尝试。
还有docker模式运行如何获取宿主机MAC地址,那么就是启动容器使用主机网络模式 --network host
docker run --rm -it --network host ubuntu /bin/bash
那么在容器内部算法程序,请求访问到就是宿主机mac地址,也就是盒子设备的地址。
2. 有效期控制
为了限制客户端的使用时间,授权码会被设定一个有效期,例如1个月、1年等。到期后,授权码将失效,客户端软件不能继续使用。商户或用户需要重新购买并获取新的授权码才能继续使用。也就是授权码里包含授权的结束时间,授权超过结束时间那么软件将无法使用。
为了防止用户通过修改系统时间绕过授权,我们可以采取以下策略:
时间记录与验证:每次软件运行时,记录当前系统时间,并将其存入文件。下次启动时,验证当前时间是否早于上次记录的时间,若检测到回退,则判定为异常。
数据加密与签名:对存储的时间数据进行加密,并使用数字签名防篡改,确保文件内容无法被恶意修改。
文件混淆与隐藏:文件名采用 sha256(mac + 算法ID + 密钥) 生成,确保难以被用户定位和删除。
还有一个问题就是用户找到了这个文件,把最新时间文件删除了。那么这该如何解决这个方案。
3.业务信息
有的软件会限制连接的设备数,比如当前软件只允许10个相机接入。也就是通过授权码限制相机接入数,这边就是授权码增加一个字段接入相机数。
4.服务端加密与签名
4.1 加密
为了防止用户看到授权码信息,如果用户看到授权码内容。那么会更容易被破解,那么我们这边需要将授权码加密。加密算法采用AES加密。
System.out.println("==========Ecdsa 签名==================");
final String signEcdsa = JiDigitUtil.signEcdsa(encryptAes, keyPair[1]);
System.out.println("signEcdsa:" + signEcdsa);
System.out.println("verifyEcdsa:" + JiDigitUtil.verifyEcdsa(encryptAes, signEcdsa, keyPair[0]));
System.out.println("decryptAes:" + decryptAes);
待加密数据:{"algorithmId":"1897464647821430784","boxId":"67c8ff47e3db1ae95498567d","channelNum":10,"createTime":"2025-03-06 09:49:59","endTime":"2026-03-06 09:49:59","eventTypes":"[\"PedestrianIntrusionNotStaff\",\"PedestrianIntrusion\"]","mac":"ab754de0-2386-421b-b2b2-60f48349d6be","nonce":"KY2LUXxOu9DZQgZS","tenantId":"67c8ff47e3db1ae95498567c"}
aesKey:eNuZkRPgko75xhEOU++aPJpZW9iZjcOfdZKNpVXfmUU=,encryptAes:rPiroTmI7pCv6JGGjkUjur1vzxiNy63vE5Y2DG78OdsXZXUOlgXOJEa0CSeAPyMgUV8xnl4p7M5R03/dRwU81eCb3JD4e5Ce7pifrTVXwgJ1vGkYaRdfe91jA9hrWel/na3My36mpU6LKUITf4WT/6f9FvDS4iNFiYzhvPrvpvLIC5XIqsIZhQ5AXM44HH44uYn6mqB59dlwTAQQI9cxKaUhq0/GgZ78tfYFoUEV8kCrIvGCiomvuTWu4kUCOQYZh1HefMh/fKHm+7PbMx7eXTm+2H8JWk2NnwTB2cg3mOcmTUODkwHBUaqUmpbsZrftpgJ45hz9nWedAiDELQI/Yjv/wngRZXPOIKboMG+k8JJfxtLaXu6BXDdFeymhsy2lbDFXFvnk63LEI3Ee/sZJNmUP5YNUc4Bgv2PDVuHzaJ4b/SZe+QJEDGyxyLM3xcjtsfZt142VsJlMf0HsMCrQoA==
4.2 签名
我们发送一串授权码,为了防止被篡改。那么需要给授权码加上签名,同时为了密钥的安全。这边采用非对称加密(ECC)生成摘要。(这边没有使用RSA算法,因为同样的密钥长度ECC密码强度更强)
System.out.println("==========Ecdsa 签名==================");
final String signEcdsa = JiDigitUtil.signEcdsa(encryptAes, keyPair[1]);
System.out.println("signEcdsa:" + signEcdsa);
System.out.println("verifyEcdsa:" + JiDigitUtil.verifyEcdsa(encryptAes, signEcdsa, keyPair[0]));
System.out.println("decryptAes:" + decryptAes);
signEcdsa:MIGIAkIBqYpOFyuZ6dVHExOnNyaC6rT52sJTrbtgQ98QMDQwXWv3tziRA4zix/9MalZ1UAGMpZaWMkFauuku5FOdMrejYVACQgHF7ODCic4Tvpz2449rlN/PJY1ZB6uVhD5Y4i1P1f1VZsDqDWGpgdfJ/55NAMkCW/lafMTWmKnCYk+H1+0VmhL2GA==
verifyEcdsa:true
4.3Base64编码
这边我们生成的授权码长这样:
System.out.println("==========base64==================");
final JSONObject userLicenseMap = new JSONObject();
userLicenseMap.put("authString", "v2.0/" + nonce);
userLicenseMap.put("encrypt", encryptAes);
userLicenseMap.put("sign", signEcdsa);
System.out.println("userLicenseMap看到的内容:" + userLicenseMap);
System.out.println("userLicenseMap看到的内容转base64:" + Base64Utils.encodeToString(userLicenseMap.toJSONString().getBytes(StandardCharsets.UTF_8)));
这边 “authString”:"{授权码版本}/{nonce},是为了方便授权的扩展,支持多版本运行,可以适配多种密码算法。
authString": "2.0/um2s2wVITNTrJxPB
2.0:授权码版本
um2s2wVITNTrJxPB:nonce授权码唯一标识和AES初始变量
密码学原则:相同明文每次生成的密文都不一样
Base64编码前
{"encrypt":"rPiroTmI7pCv6JGGjkUjur1vzxiNy63vE5Y2DG78OdsXZXUOlgXOJEa0CSeAPyMgUV8xnl4p7M5R03/dRwU81eCb3JD4e5Ce7pifrTVXwgJ1vGkYaRdfe91jA9hrWel/na3My36mpU6LKUITf4WT/6f9FvDS4iNFiYzhvPrvpvLIC5XIqsIZhQ5AXM44HH44uYn6mqB59dlwTAQQI9cxKaUhq0/GgZ78tfYFoUEV8kCrIvGCiomvuTWu4kUCOQYZh1HefMh/fKHm+7PbMx7eXTm+2H8JWk2NnwTB2cg3mOcmTUODkwHBUaqUmpbsZrftpgJ45hz9nWedAiDELQI/Yjv/wngRZXPOIKboMG+k8JJfxtLaXu6BXDdFeymhsy2lbDFXFvnk63LEI3Ee/sZJNmUP5YNUc4Bgv2PDVuHzaJ4b/SZe+QJEDGyxyLM3xcjtsfZt142VsJlMf0HsMCrQoA==","sign":"MIGIAkIBqYpOFyuZ6dVHExOnNyaC6rT52sJTrbtgQ98QMDQwXWv3tziRA4zix/9MalZ1UAGMpZaWMkFauuku5FOdMrejYVACQgHF7ODCic4Tvpz2449rlN/PJY1ZB6uVhD5Y4i1P1f1VZsDqDWGpgdfJ/55NAMkCW/lafMTWmKnCYk+H1+0VmhL2GA==","authString":"v2.0/KY2LUXxOu9DZQgZS"}
Base64编码后
eyJlbmNyeXB0IjoiclBpcm9UbUk3cEN2NkpHR2prVWp1cjF2enhpTnk2M3ZFNVkyREc3OE9kc1haWFVPbGdYT0pFYTBDU2VBUHlNZ1VWOHhubDRwN001UjAzL2RSd1U4MWVDYjNKRDRlNUNlN3BpZnJUVlh3Z0oxdkdrWWFSZGZlOTFqQTlocldlbC9uYTNNeTM2bXBVNkxLVUlUZjRXVC82ZjlGdkRTNGlORmlZemh2UHJ2cHZMSUM1WElxc0laaFE1QVhNNDRISDQ0dVluNm1xQjU5ZGx3VEFRUUk5Y3hLYVVocTAvR2daNzh0ZllGb1VFVjhrQ3JJdkdDaW9tdnVUV3U0a1VDT1FZWmgxSGVmTWgvZktIbSs3UGJNeDdlWFRtKzJIOEpXazJObndUQjJjZzNtT2NtVFVPRGt3SEJVYXFVbXBic1pyZnRwZ0o0NWh6OW5XZWRBaURFTFFJL1lqdi93bmdSWlhQT0lLYm9NRytrOEpKZnh0TGFYdTZCWERkRmV5bWhzeTJsYkRGWEZ2bms2M0xFSTNFZS9zWkpObVVQNVlOVWM0Qmd2MlBEVnVIemFKNGIvU1plK1FKRURHeXh5TE0zeGNqdHNmWnQxNDJWc0psTWYwSHNNQ3JRb0E9PSIsInNpZ24iOiJNSUdJQWtJQnFZcE9GeXVaNmRWSEV4T25OeWFDNnJUNTJzSlRyYnRnUTk4UU1EUXdYV3YzdHppUkE0eml4LzlNYWxaMVVBR01wWmFXTWtGYXV1a3U1Rk9kTXJlallWQUNRZ0hGN09EQ2ljNFR2cHoyNDQ5cmxOL1BKWTFaQjZ1VmhENVk0aTFQMWYxVlpzRHFEV0dwZ2RmSi81NU5BTWtDVy9sYWZNVFdtS25DWWsrSDErMFZtaEwyR0E9PSIsImF1dGhTdHJpbmciOiJ2Mi4wL0tZMkxVWHhPdTlEWlFnWlMifQ==
44.代码示例
以下加密算法工具【JiDigitUtil】可以参考笔者另一篇博客密码学之美——密码学总结和对外开放接口实现
@Test
public void license() {
final String[] keyPair = JiDigitUtil.genKeyPair(JiDigitUtil.ECC_ALGORITHM);
System.out.println("密钥对(公钥/私钥):" + JSON.toJSONString(keyPair));
final HashMap<String, Object> data = new HashMap<>();
final String nonce = JiDigitUtil.createNonce(16);
//租户id
data.put("tenantId", IdUtil.objectId());
// 盒子id 设备id
data.put("boxId", IdUtil.objectId());
// mac地址
data.put("mac", IdUtil.fastUUID());
// 授权码创建时间
data.put("createTime", new DateTime().toString());
data.put("nonce", nonce);
// 算法结束时间
data.put("endTime", DateUtil.offsetDay(new Date(), 365).toString());
// 算法id
data.put("algorithmId", IdUtil.getSnowflake(1, 1).nextIdStr());
// 算法接入相机个数
data.put("channelNum", 10);
String[] eventTypes = {"PedestrianIntrusionNotStaff", "PedestrianIntrusion"};
data.put("eventTypes", JSON.toJSONString(eventTypes));
System.out.println("==========AES_ALGORITHM加密==================");
String bodyDataStr = JiDigitUtil.getBodyDataStr(data);
final String aesKey = JiDigitUtil.genSecretKey(JiDigitUtil.AES_ALGORITHM);
final String encryptAes = JiDigitUtil.encryptAes(bodyDataStr, aesKey, nonce);
final String decryptAes = JiDigitUtil.decryptAes(encryptAes, aesKey, nonce);
System.out.println(String.format("aesKey:%s,encryptAes:%s", aesKey, encryptAes));
System.out.println("==========Ecdsa 签名==================");
final String signEcdsa = JiDigitUtil.signEcdsa(encryptAes, keyPair[1]);
System.out.println("signEcdsa:" + signEcdsa);
System.out.println("verifyEcdsa:" + JiDigitUtil.verifyEcdsa(encryptAes, signEcdsa, keyPair[0]));
System.out.println("decryptAes:" + decryptAes);
System.out.println("==========base64==================");
final JSONObject userLicenseMap = new JSONObject();
userLicenseMap.put("authString", "v2.0/" + nonce);
userLicenseMap.put("encrypt", encryptAes);
userLicenseMap.put("sign", signEcdsa);
System.out.println("userLicenseMap看到的内容:" + userLicenseMap);
System.out.println("userLicenseMap看到的内容转base64:" + Base64Utils.encodeToString(userLicenseMap.toJSONString().getBytes(StandardCharsets.UTF_8)));
}
5.客户端授权
5.1密钥分发
这边签名用的ECC密钥需要,在设备端给用户时提前放入软件中。同时AES加密的密钥也需要,提前放入设备软件中。这边为了方便维护密钥,统一采用同一个租户使用的密钥是一样。同时设备端的程序,需要是C和C++这样的编译型语言才行。因为如果是解释型语言,那么很容易被反编译。如果密钥被替换了,或者验签的逻辑被更改。(任何验签都返回true)那么验签也就是毫无意义了,所以一般客户端的验签都没有用python,Java等解释型语言,而是都采用C和C++这样不容易被反编译和破解。
5.1授权码验证
客户端的授权码验证流程优化如下:
1.签名校验
使用 公钥验签,确保授权码未被篡改,来源可信。
若签名无效,则直接拒绝授权,防止伪造授权码绕过验证。
2.解密授权码
只有通过验签的授权码才进行解密,提取业务信息(如设备标识、授权时长等)。
3.身份与时间验证
身份验证:检查授权码中的设备信息是否与当前设备匹配。
时间验证:校验授权码是否仍在有效期内,防止过期使用。
总结
为了确保授权系统的安全性,我们采用了 ECC 非对称加密 生成签名,并使用 AES 对称加密 保护授权码内容。服务器生成授权码并用 ECC 签名,而客户端通过公钥验证签名,确保数据完整性和来源可信。授权码内容则通过 AES 加密保护,确保敏感信息安全。由于客户端程序暴露在用户端,密钥需嵌入代码并进行编译,防止被替换或提取,同时可以通过代码混淆和反调试等技术增强密钥保护。此外,为防止用户通过修改系统时间绕过授权,系统加入了时间验证机制,同时支持多个设备使用授权码,确保授权管理的灵活性和安全性。此流程能有效防止伪造、篡改、密钥泄露和时间篡改,保障授权系统的安全。