离线授权码设计 对于自己的软件产品,希望别人付费、或者在我们授权的情况下才允许使用。那么我们应该如何去这个授权码 / 许可证的机制呢?
前言 离线授权的方案无非就是两种,一种是软件层面的授权,一种是硬件层面的授权。
软件层面 :我们向用户提供一串特定的字符串,用户在软件中输入我们提供的字符串,在使用端校验用户输入的授权码,即可完成软件的授权操作。
硬件层面 :我们向用户提供一个 USB Key(加密狗),用户在运行软件时我们可以通过硬件加密狗来判断授权信息,这种加密狗在网上 几块钱到十几块钱就能买一个,有的加密狗还内置独立时钟,可以杜绝用户进行时钟回拨等。这种加密狗可以用在对授权安全级别更高的场景中,但需要承受额外的成本:用户的使用成本,授权分发成本(加密狗+快递费+快递时间)。
软件层面的加密方案使用起来更加灵活,硬件层面加密狗安全级别更高,本篇文章主要是探讨软件层面的授权方式。
授权码要点 那么设计一个相对安全的离线授权码,需要满足那些哪些特征呢?
• 绑定性:每个授权码对应一台设备的授权,客户无法通过一个授权码激活多台设备。 • 可复现:当客户授权码遗失时,我们可以根据客户的机器码重新生成授权码,避免用户无法使用软件的情况。 • 时效性(可选):授权码可包含授权有效期,限定授权使用时长。 • 可扩展(可选):可根据自己需求来灵活扩展授权码内容,以方便携带更多信息。 你可以以任何形式生成一个满足上方条件的授权码,无需纠结生成的思路是否与我的思路一致
唯一标识 在离线授权场景中,我们需要确定授权设备的唯一标识,这个标识需要具有不可更改性质,在正常情况下该唯一标识都不会发生变化(例如重启软件、重启系统不会发生变化,但重装系统、更换硬件等行为会发生变化是可接受的)。
在这种场景中,我们唯一标识一般采用 CPUID、主板 BIOS UUID、网卡 MAC 地址、或自定义算法来生成唯一ID,提醒一下 如果产品是服务器应用,不应使用 硬盘序列号作为唯一标识的组成部分,因为服务器硬盘都有明确的生命周期会定期更换。当更换后就会造成授权失效的情况。
这个唯一标识没有明确的授权规定,大家可以根据自己的需求来选择,也可以通过现成的开源库来获取。
例如我开发的主语言是 Golang,我一般会使用 github.com/denisbrodbeck/machineid 这个库来获取硬件ID,并且可以在各个系统下运转良好。
授权码方案 对称方案 对称方案是指授权码的生成逻辑、校验逻辑完全一样。所谓校验就是客户端内部生成正确的授权码,然后和用户输入的授权码做对比,对比成功,则说明授权有效。
以我个人习惯为例,授权码的字符串一般包含“机器码”、“项目名称”、“到期时间”、“盐值”,其中盐值用于增加授权码的复杂性,即使客户知道了授权码的生成方式,在不知道盐值的情况下,无法自行伪造授权码。
生成过程 我们先生成一个明文的字符串,然后通过特定分隔符拼接出明文的授权码,格式可以是: 机器码:项目名称:到期时间:盐值
,例如:
123456:tpamis:281231:abcdef
• 123456
:客户提供的机器码,这里以 123456 代替 • tpamis
:授权项目名称,同一个机器上可能存在多个软件产品,可以加一个项目名称用于区分。 • 281231
:到期时间,这里取 2028-12-31
的后6位作为到期时间。这里也可以写成授权天数,先选定一个时间作为起始日期,然后在这个时间上叠加偏移时间,得到的最终时间作为授权到期时间。例如:我出生的时间是: 1998-11-11
,我以此作为起始时间。偏移时间:9628 天,得到授权到期时间为 2028-12-31
。 • abcdef
:盐值,可以防止彩虹表攻击,即使攻击者知道生成算法,也无法通过预计算破解授权码。 接着,我们通过一个 MD5 算法(也可使用其他 HASH 摘要算法)得到一个签名 "cfb9fc32230fa1a19423ef8b6af63a61",此签名就是一个授权码。
但这时的授权码有一个问题,签名是不可逆的,所以我们无法在验证签名时通过签名得到授权到期时间,所以我们需要人为的拼接一下:
• 直接拼接: cfb9fc32230fa1a19423ef8b6af63a61281231
• 使用分隔字符: cfb9fc32230fa1a19423ef8b6af63a61-281231
• 舍去后六位,以 UUID 的方式呈现: cfb9fc32-230f-a1a1-9423-ef8b6a281231
掩藏到期时间 这个时你会发现 后六位明晃晃的摆在那里,太容易被人猜到是到期时间了,虽然不会被篡改,但仍希望隐藏在授权码中,我们可以通过 十进制转十六进制来实现:281231 转十六进制为:44a8f,这时授权码变成: cfb9fc32-230f-a1a1-9423-ef8b6a44a8f
。
同时你可以将到期时间藏在授权码中间,替换掉对应位置的签名值,得到这样的授权码: cf44a8f2-230f-a1a1-9423ef8b6af63a61
。
验证授权码 到这一步,已经完成了授权码的生成过程,客户端程序需要提前内置盐值和同样的生成算法,在用户输入授权码激活时,先从授权码中截取出到期时间。再用客户端生成的授权码进行对比验证是否正确,以及是否过期。
用户体验优化 签名生成的授权码以及可以满足正常的离线授权的功能了,但有一些不允许插入U盘或联网的场景中,客户可能需要手动输入授权码,我们应该尽可能缩短授权码长度,方便用户输入。
我们可以在前面授权码的基础上,参考 Windows 的授权码长度 (AAAAA-BBBBB-CCCCC-DDDD-EEEEE),每组5个字符,5 组共25个字符,通过横杠分割方便用户输入。
前面 MD5 生成的签名是 128 位的二进制,转换为16进制后,长度为 32 个字符。其实我们可以 MD5 转为 36 进制得到: CAQ3DFUC0YPEHDZLA7ZKHHYLD
。
我们再拼接上前面得到的十六进制到期时间,并转为全大写,就得到了方便输入的授权码格式: CAQ3D-FUC0Y-PEHDZ-LA7ZK-HHYLD-44A8F
。
如果需要长度和 Windows 一模一样,则需要在 36 进制转换后,再截取掉 5 位,拼接时间戳。
这里提供一个 Go 语言 十六进制转36进制的方法示例:
package main import ( "crypto/md5" "encoding/hex" "fmt" "math/big" ) // 将 MD5 哈希值转换为 36 进制字符串 func md5ToBase36 (md5Hash string ) string { // 将 MD5 哈希值(十六进制)转换为大整数 bigInt := new (big.Int) bigInt.SetString(md5Hash, 16 ) // 定义 36 进制的字符集 const charset = "0123456789abcdefghijklmnopqrstuvwxyz" // 将大整数转换为 36 进制 base36 := "" base := big.NewInt( 36 ) zero := big.NewInt( 0 ) remainder := new (big.Int) for bigInt.Cmp(zero) > 0 { bigInt.DivMod(bigInt, base, remainder) base36 = string (charset[remainder.Int64()]) + base36 } return base36 } func main () { // 计算字符串的 MD5 哈希值 data := "123456:tpamis:281231:abcdef" hash := md5.Sum([] byte (data)) md5Hash := hex.EncodeToString(hash[:]) // 转换为十六进制字符串 fmt.Println( "MD5 哈希值:" , md5Hash) // 输出: cfb9fc32230fa1a19423ef8b6af63a61 // 将 MD5 哈希值转换为 36 进制 base36Code := md5ToBase36(md5Hash) fmt.Println( "36 进制编码:" , base36Code) // 输出: caq3dfuc0ypehdzla7zkhhyld }
几个问题补充 为什么要转 36进制而不是直接截取 MD5?
因为截取后过短的 MD5 增加了碰撞的概率,转为 36进制,会包含 0-9、A-Z 36 个字符。36进制相比 16进制更为紧凑,在缩短长度同时保留较高的信息密度。且 36 进制在客户输入时,不需要考虑大小写问题,用户体验更好。
摘要算法选择
MD5 算法已被证实存在碰撞漏洞,但在安全性要求相对不是很高的场景中,还是可以接受的。如果需要安全性更高的摘要算法,可以换成例如 SHA-256、国密 SM3 等更为安全算法,在得到签名后截取 32 位 当作 MD5 使用,实现安全性、用户体验的兼顾。
36进制字符集
因为 36 进制是自行实现的,所以可以自定义字符集,大家可以在这一步对字符集排布顺序进行打乱,实现混淆效果。
非对称方案 前面的授权码方案可以提供一个方便输入,长度较短的授权码字符串,但携带授权信息较少、密钥(盐值)需要内置在客户端中,遇到逆行破解场景,有盐值泄露的可能性。
授权码设计 非对称加密我们可以使用 RSA 算法来实现。当我们使用 RSA 算法时,我们可以通过授权码给客户端携带更多信息,所以我们就以 JSON 为许可证内容的载体。我们可以这样设计:
{ "iss" : "tpamis" , "sub" : "pord" , "aud" : "123456" , "exp" : "1861804800" , "nbf" : "1743436800" , "iat" : "1742728673" , "rge" : [ "功能a" , "功能b" , "功能b" ] }
接参考了 JWT 官方规定的 Payload 字段,设计的许可证 JSON 内容,且这些字段可以根据需求随意添加调整。
• sub (subject):授权方式,prod 正式授权、test 测试授权 • aud (audience):授权对象,客户的机器码 • exp (expiration time):过期时间 生成授权码 我们生成一对 RSA 公私钥,公钥保存在客户端,私钥在我们手里,我们根据用户的机器码,生成一个授权码,以上面为例,我额外扩展了授权模块功能,可以精确控制客户允许使用的功能范围。
{"iss":"tpamis","sub":"pord","aud":"123456","exp":"1861804800","nbf":"1743436800","iat":"1742728673","rge":["功能a","功能b","功能b"]}
2. 然后我们使用 RSA 私钥加密生成密文,这个密文就是我们需要提供给客户的授权码。 I0UQvjrw3achexYK/D2ciNbsN+d28meH56aQPvosR9ZAKX2xp+kFNMfOjgBH+ZCL5+ir0h+pibfyva5weFBEEy1WgPMSqSiFGL5jfNIpzRY+Ct8hqsrjZm20TONvEjE7gwhFHW0m0NvdpFmwvbOjQPLk5ipZkNWW2l/DvEkYyogVMxCAfcNmczv1x9c1MeyXp0ru7GQifF1q1wGn4SBljc61zfUbtsv5aHk7zibOrNu4DsXnGjnYmwRCqYogAhB7g4Wzxfx0chMED9ulakTC8G5rBwT2w+LNgxKP+Si/nsOL0PeBzwrLTYulJIQEoqNsjMkDJ4JbXa/uoWrRoIuMTg==
当许可证内容过多,导致密文过长时,可以考虑输出一个密钥文件给客户,客户在产品中上传、选择 密钥文件,我们通过读取密钥文件来获取授权码。这样体验更好。
授权码验证 用户将我们的授权码输入到软件中,客户端使用公钥解密,解密成功则读取进入进行进一步的授权校验,解密失败直接提示授权码错误。
说明:将公钥内置到客户端,在极端情况下,存在反编译的可能性。所以我们可以假设公钥已经泄露、不安全的状态。不过只要私钥不泄露,别人拿到公钥也只能解密我们的许可证密文,而不能伪造一个许可证,相对来说也是可以接受的。
代码示例 以 Go 语言为例 加密解密过程如下
package main import ( "encoding/json" "fmt" "github.com/dromara/dongle" ) func main () { // 声明 map[string]any 许可证结构 licenseData := map [ string ]any{ "iss" : "tpamis" , "sub" : "pord" , "aud" : "123456" , "exp" : "1861804800" , "nbf" : "1743436800" , "iat" : "1742728673" , "rge" : [] string { "功能a" , "功能b" , "功能b" }, } // 将 map 转换为 JSON 字符串 jsonData, _ := json.Marshal(licenseData) // 使用RSA 私钥 授权Json cipherText := dongle.Encrypt.FromBytes(jsonData).ByRsa(pkcs1PrivateKey).ToBase64String() fmt.Println( "RSA密文" , cipherText) // 客户端 使用 公钥进行解密 licenseJson := dongle.Decrypt.FromBase64String(cipherText).ByRsa(pkcs1PublicKey).ToString() fmt.Println( "许可证Json" , licenseJson) } var pkcs1PublicKey = [] byte ( `-----BEGIN RSA PUBLIC KEY----- MIGJAoGBAK12MTd84qkCZzp4iLUj8YSUglaFMsFlv9KlIL4+Xts40PK3+wbsXPEw cujGeUmdgMeZiK7SLLSz8QeE0v7Vs+cGK4Bs4qLtMGCiO6wEuyt10KsafTyBktFn dk/+gBLr7B/b+9+HaMIIoJUdsFksdAg3cxTSpwVApe98loFNRfqDAgMBAAE= -----END RSA PUBLIC KEY-----` ) var pkcs1PrivateKey = [] byte ( `-----BEGIN RSA PRIVATE KEY----- MIICXQIBAAKBgQCtdjE3fOKpAmc6eIi1I/GElIJWhTLBZb/SpSC+Pl7bONDyt/sG 7FzxMHLoxnlJnYDHmYiu0iy0s/EHhNL+1bPnBiuAbOKi7TBgojusBLsrddCrGn08 gZLRZ3ZP/oAS6+wf2/vfh2jCCKCVHbBZLHQIN3MU0qcFQKXvfJaBTUX6gwIDAQAB AoGAFwAfEo56t5JcAcLNzccQVVYj2jkbO820G8hNiSxYA5WLD0QaAxcAU/Lqqbb3 ii1aUB0ppJS13NgnU6nnGGdZzUYBG1Hai6EkVyCGrI4amQ93AaVdKncL8gJ4RZAm YzPPUwSMEESsu24pS1NF1G1Y8C+28b/Wr0oqOsCvL6PhsMECQQDwsPJJoWRx7ZJw E1K5KLT0cXKyrIpyXY3I6tyA5imCzOzccf3d1vDgB0L9sdSO7bG3ceSwpAeiWEbg 5jGZemPzAkEAuH6U4pEI4AMbWnatpK55Rc235NDgmT3VyIuRaKC02YXAZ+jznFep XMd4DTli4R9r3j2YVhUpyDVbdQpFH98DMQJAQpOvcU6DSkA80WOG7lCkPTlkUKgJ Y7kdDwZoF/+SW+vzWMbvQf3CgzV/Ak2+TgrRrbyDVZkJw45HjM4fyiRgoQJBALH/ /qlxgPyQQs3O/s2KQBsm1auAE5IF5MLuVUZ69sF/mBko2hEXSqHnGV645TuKU0pC Zz12ga9WO3z6gaK0SaECQQDah1pKt9ViBBy4USXK3OWXEloHuTwmyr9AbLqqI5tQ 2eNuH0NkuJYQmnXmHLbKOELoYocldEBXmkzPXSN+X9kV -----END RSA PRIVATE KEY-----` )
JWT 方案 JWT 方案则是直接使用标准的 JWT 库来实现授权码的签发功能,客户端和服务端共享 JWT 的密钥来验证授权签名的有效性。
本方案的优点是可以充分利用现有的标准化 JWT 库,只需定义一个密钥,而无需进行过多的开发,但许可证内容会直接暴露给客户,也可以考虑 JWE,需自行抉择。
JWT Header { "alg" : "HS256" , "typ" : "JWT" } JWT Payload { "iss" : "tpamis" , "sub" : "pord" , "aud" : "123456" , "exp" : "1861804800" , "nbf" : "1743436800" , "iat" : "1742728673" , "rge" : [ "功能a" , "功能b" , "功能b" ] } 密钥 dbkuaizi.com JWT Token eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ0cGFtaXMiLCJzdWIiOiJwb3JkIiwiYXVkIjoiMTIzNDU2IiwiZXhwIjoiMTg2MTgwNDgwMCIsIm5iZiI6IjE3NDM0MzY4MDAiLCJpYXQiOiIxNzQyNzI4NjczIiwicmdlIjpbIuWKn-iDvWEiLCLlip_og71iIiwi5Yqf6IO9YiJdfQ.izIAwMyiLxPiHrWf-FDGu3fHMvfaC7bqEh40ha8YYAA
结尾 没有绝对的安全 任何安全领域的手段都是相对安全,没有绝对的安全。再复杂的授权码的本质只能是 防君子不防小人 ,例如用户可以通过逆向 得到你的盐值、甚至直接修改你的程序逻辑跳过验证的逻辑,更何况我们的场景中只有离线校验的逻辑。
防止时钟回拨 时钟回拨是指,就是用户直接通过修改系统时间到软件授权到期之前,从而继续使用软件的目的。那么我们在离线场景中如何去避免这样的情况发生呢? 首先我需要声明的是,离线授权场景中,不能彻底避免这种情况(除了独立时钟的加密狗),我们能做的就是通过代码逻辑尽可能规避这种情况的发生,一般有这几种手段:
记录上一次运行时间 众所周知,时间是不会倒流的,也就是说软件的第二次运行时间, 不可能小于 上一次运行时的系统时间。 所以,在软件每次运行时我们可以先记录当前时间,并与上一次系统时间做对比,若第二次运行时的系统时间小于之前记录的时间,则认为出现了系统时钟回拨的问题,直接提示用户并终止软件运行。
安全的存储时间标记 那么如何保证我们存储的时间没有被篡改呢?我们可以通过 (时间戳.盐)+md5 的方式实现: 例如:时间戳为 1717487962 盐 是 abcdefg, 通过: md5('1717487962.abcdefg') 摘要算法,获得时间戳签名:a06de98fc28e45bf38a9a5f27630cd03。 然后将这样的字符串进行存储:1717487962.a06de98fc28e45bf38a9a5f27630cd03,启动时再通过上面的逻辑进行校验,即可保证 记录的时间不被修改。
如何防止用户删除时间标记 可以在打包的时候,就记录一个打包时间作为初始化时间戳,这样即使用户第一次运行也必须有时间戳。 若没读取不到时间戳标记,则说明用户人为清理了时间标记,结束运行。
与业务数据强关联 如果你产品运行过程中产生的业务数据很重要,也可以使用业务中的时间戳来做时钟回拨校验,例如使用最后一条订单的创建时间。 若业务数据很重要,用户总不可能为了继续使用软件而删掉业务数据吧。
抛砖引玉 授权码的设计方案有很多,这里只是整理了我用过的几种方案,大家可以根据自己的需要对逻辑进行调整,如果你有更好的方案,欢迎留言交流。
阅读原文:原文链接
该文章在 2025/3/25 10:29:44 编辑过