更新记录

1.0.0(2026-10-28)

首个版本:三段式授权码 SZL1.载荷.HMAC签名;手写 SHA-256/HMAC-SHA256(不用 javax.crypto,可与 Node crypto 逐字节比对);17 位带校验设备码(厂商/品牌/机型/设备名/ANDROID_ID/包名,去除 I/L/O/U 并用 4 位分组);有效期 + 宽限期(0-90 天,默认 7);墙钟与单调时钟双源的时间回拨检测;功能位按项开关;只读解析与设备匹配提示;凭据存应用私有目录、零权限。UTS→Kotlin 编译通过(0 error),81 项纯逻辑测试全部通过;真机未执行(测试机未连接)。


平台兼容性

uni-app

Vue2 Vue3 Chrome Safari app-vue app-nvue Android iOS 鸿蒙
× √ × × √ × √ × ×
微信小程序 支付宝小程序 抖音小程序 百度小程序 快手小程序 京东小程序 鸿蒙元服务 QQ小程序 飞书小程序 小红书小程序 快应用-华为 快应用-联盟
× × × × × × × × × × × ×

uni-app x

Chrome Safari Android iOS 鸿蒙 微信小程序
× × × × × ×

离线授权码(设备绑定 + 到期宽限)

给做私有化部署、内网运行、不能联外网的 Android App 的开发者:一套离线授权码 SDK,一台设备一个码,到期自动宽限,改系统时间立刻失效,不用架授权服务器。

授权码是 HMAC-SHA256 签名的三段式字符串,客户粘贴即可激活,改一个字符就失效。签名与 SHA-256 全部用除余运算手写实现,不引任何三方库;凭据存在应用私有目录,不需要存储权限。

为什么值得用

离线授权的难点不在加密,在四件容易做错的事:

  • 设备码要能报读:客户打电话过来报设备码,字母 I / L / O / U 和数字 0 / 1 一定念错。本插件的设备码用 32 进制字母表(去掉这四个字母)+ 4 位分组 + 校验位,报错一位当场就能发现;
  • 到期不能一刀切:客户续费走流程要几天,到期当天就锁死业务会直接投诉。默认 7 天宽限期(可配 0–90 天),宽限期内仍可用,返回值区分 active / grace / expired,业务侧可以只弹提醒不停业务;
  • 改时间是最常见的绕过手段:把手机日期调回去年,System.currentTimeMillis() 就骗过了所有基于日期的校验。这里用墙钟 + 单调时钟(elapsedRealtime)双源比对,回拨超过 5 分钟且单调时钟没跟上,直接判 clock_rollback;
  • 客服要能核对:客户说「我的码用不了」,你要能一眼看出这张码是给哪个包名、绑了哪台设备、什么时候到期。getLicenseInfo 不验签也能解析出这些字段,并告诉你当前设备是否匹配。

快速开始

签发端(内部工具 / 服务端 / 演示页都行,只要密钥一致):

// 签发端:secret 必须 ≥16 位,且与 App 端配置的一字不差
import { issue } from '@/uni_modules/szhzy-license-guard'

issue({
  v: '1',                              // 协议版本,固定 1
  app: 'com.szhzy.mes',                // 应用标识,建议用包名
  dev: 'ABCDEFGHJKMNPQRS',             // 绑定的设备码;留空表示不绑定设备
  iat: String(Math.floor(Date.now() / 1000)),
  exp: String(Math.floor(Date.now() / 1000) + 365 * 86400), // 一年;0 表示永久
  g: '7',                              // 宽限期天数
  f: 'basic,batch',                    // 功能位;留空视为全部
  sn: 'SZHZY-0001',                    // 授权序号,对账用
  n: '江门工厂 1 号线'
})
// => { ok: true, code: 'SUCCESS', license: 'SZL1.xxxxx.yyyyy' }

App 端(3 行接入):

import { configure, activate, check, getDeviceCode } from '@/uni_modules/szhzy-license-guard'

// 密钥 ≥16 位,且与签发端一字不差。正式项目不要在客户端硬编码,
// 由服务端下发或放进白盒/native 层;演示工程里写常量即可。
configure({ secret: getSecret() })

// 首次:把设备码发给供应商,拿到码后激活
const dev = getDeviceCode()          // { deviceCode, display: 'ABCD-EFGH-JKMN-PQRS', appId }
const r = activate(inputCode)        // 成功后自动落盘
if (!r.ok) uni.showToast({ title: r.message })

// 每次启动:校验一次
const s = check()
if (!s.ok) { /* s.code: EXPIRED / DEVICE_MISMATCH / CLOCK_ROLLBACK ... */ }
if (s.status === 'grace') uni.showModal({ title: '授权即将到期,请尽快续费' })

API

所有接口同步执行,返回统一信封 { ok, code, message, ... },错误用 code 表达,不抛异常。

接口 说明
configure({ secret, appId, deviceSalt }) 必须在其它 API 之前调一次。secret 必填(≥16 位),appId 缺省取包名
isConfigured() 是否已配置密钥
getDeviceCode() 取设备码。返回 deviceCode(17 位)、display(4 位分组)、appId
activate(code) 验签 → 校验状态 → 落盘。返回体与 check() 同构
check() 校验已激活的授权,每次启动调一次;成功时刷新「上次校验时间」
isActivated() 本地是否存有授权码(不代表未过期)
clear() 清除本地授权(换机、退货、测试用)
getLicenseInfo(code) 只读解析,不验签。多返回 currentDevice / deviceMatched
issue(fields) 签发授权码。正规做法放服务端,客户端只保留校验密钥
checkFeature(name) 当前授权是否包含某项功能,返回 granted
getDefaultGraceDays() / clampGraceDays(days) 宽限期缺省值 / 归一(0–90)
getVersion() 插件版本

返回值里的 status

status 含义 建议处理
active 有效期内 正常放行,remainDays 可做到期提醒
grace 已过期但在宽限期内 放行,弹续费提醒
expired 已过期且超出宽限期 停业务
device_mismatch 码绑的是别的设备 让客户报当前设备码
app_mismatch 码不是发给这个包的 检查包名
clock_rollback 检测到时间被回拨 提示校准时间
signature_invalid 签名不匹配 确认密钥一致、码未被截断
format_invalid 不是三段式授权码 检查是否漏粘

设备码与绑定策略

设备码 = SHA-256(厂商 # 品牌 # 机型 # 设备名 # ANDROID_ID # 包名),取摘要前 80 位映射到 32 进制,得 16 位主体 + 1 位加权校验位。

刻意不含 Build.FINGERPRINT 与 Build.VERSION.RELEASE:这两个值会因一次 OTA 升级而变,把它们算进指纹等于「系统一升级,已发出的授权全部失效」。

几种常见策略:

  • 一设备一码(默认):dev 填客户报来的设备码,最严格;
  • 不绑设备:dev 留空,一张码可装多台(内部员工工具常用);
  • 同型号批量区分:configure({ deviceSalt }) 给每台机器不同的盐,避免同型号设备撞码。

购买前必读(安全边界,如实说明)

离线校验意味着校验密钥必须在 App 里,所以这个插件的定位是:

  • 能做到:防误用、防超期、防改系统时间、防把一台机器的码直接复制到另一台;
  • 做不到:防逆向。拿到 APK 并脱壳反编译的人,理论上能提取密钥伪造授权。

需要更强保护的场景,两点建议:① 签发放服务端,App 只做本地校验(密钥用白盒或 native 层保护);② 关键业务逻辑配合服务端二次校验。插件 README 与市场页都按这个口径描述,不夸大。

验证状态(如实说明)

  • 核心逻辑:81 项自动化测试全部通过,在 Node 环境真实执行,含 SHA-256 / HMAC-SHA256 与 Node crypto 的逐字节比对、授权码签发→篡改→验签、时间回拨、宽限期边界、设备码校验位等;
  • Kotlin 编译:cli launch app-android --compile true 通过,0 error;
  • 未执行:真机运行验证(设备指纹取值、私有目录读写、真实时间回拨)尚未在真机上跑通,测试记录里标注为「未执行」,不做真机结论。

常见错误码

code 原因
INVALID_SECRET 密钥长度不足 16 位,或与签发端不一致
INVALID_PARAM configure 未传 secret
EMPTY_VALUE 授权码为空
FORMAT_INVALID 不是 SZL1.载荷.签名 三段式
SIGNATURE_INVALID 签名不匹配:密钥不同、码被改动或被截断
APP_MISMATCH app 字段与当前包名不一致
DEVICE_MISMATCH dev 字段与当前设备码不一致
EXPIRED 已过期且超出宽限期
CLOCK_ROLLBACK 墙钟回拨超过 5 分钟且单调时钟未跟上
NOT_ACTIVATED 本地没有授权记录,需先 activate
STORE_ERROR 私有目录写入 / 删除失败

平台支持

平台 支持
Android App(uni-app Vue3 / Vue2) √(minSdk 23 / Android 6.0)
iOS / 鸿蒙 / Web / 小程序 / 快应用 ×

许可

商用授权见 license.md。普通授权可用于商业项目;源码授权可二次开发与分发。

隐私、权限声明

1. 本插件需要申请的系统权限列表:

无。授权凭据写入 context.getFilesDir() 下的私有文件,不需要存储权限,也不需要电话状态等敏感权限。

2. 本插件采集的数据、发送的服务器地址、以及数据用途说明:

插件不访问网络、不上传任何数据。设备指纹(厂商/品牌/机型/设备名/ANDROID_ID/包名)只在本机参与哈希,生成 17 位设备码后即丢弃原始值,不落盘、不外发。授权码与校验时间戳只写入应用私有目录。

3. 本插件是否包含广告,如包含需详细说明广告表达方式、展示频率:

无

暂无用户评论。