離線授權碼設計 對于自己的軟件產品,希望別人付費、或者在我們授權的情況下才允許使用。那么我們應該如何去這個授權碼 / 許可證的機制呢?
前言 離線授權的方案無非就是兩種,一種是軟件層面的授權,一種是硬件層面的授權。
軟件層面 :我們向用戶提供一串特定的字符串,用戶在軟件中輸入我們提供的字符串,在使用端校驗用戶輸入的授權碼,即可完成軟件的授權操作。
硬件層面 :我們向用戶提供一個 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 編輯過