JavaScript 前端 SDK
[qxj-frontend-sdk](file:///c:/Users/etsuyou/Desktop/qxj-project/qxj-frontend-sdk) 是 网页端接入 QXJ 安全体系的统一 SDK,提供三类能力:国密算法(SM2/SM3/SM4)、 NSP-SM JWT 解析与校验、鸿蒙 Pad JSBridge 的 Promise 化封装。
- 包名:
qxj-frontend-sdk - 当前版本:0.2.3
- License:MIT
- 运行时依赖:
sm-crypto@^0.3.3(UMD 产物已内联) - 入口:ESM
dist/index.mjs· CJSdist/index.cjs· UMDdist/qxj-sdk.umd.js(全局QxjSdk) - 类型声明:
dist/index.d.ts(唯一对外类型数据源)
安装
pnpm add qxj-frontend-sdknpm install qxj-frontend-sdk<!-- UMD 已内联 sm-crypto,无需额外引入 -->
<script src="https://cdn.jsdelivr.net/npm/qxj-frontend-sdk@0.2.3/dist/qxj-sdk.umd.js"></script>
<script>
// window.QxjSdk.init() …
</script>2
3
4
5
初始化 init
调用任何国密函数前必须先 init,完成底层 sm-crypto 的加载与注入:
import { init } from 'qxj-frontend-sdk'
await init() // 自动:传入模块 → node_modules → window.smCrypto → CDN
await init(smCryptoModule) // 手动传入已加载的 sm-crypto2
3
4
加载优先级:
init(lib)传入模块对象 → 直接注入;- Node / 打包环境 →
import('sm-crypto'); - 浏览器 → 检查
window.smCrypto(页面已用<script>引入时命中); - 仍无 → 动态注入 CDN 脚本
cdn.jsdelivr.net/npm/sm-crypto@0.3.3/...; - 全部失败抛
无法加载 sm-crypto。
未 init 就调用国密函数会抛 sm-crypto 未加载,请先调用 init()。
国密算法
SM2 椭圆曲线
import {
sm2GenerateKeyPair, sm2Encrypt, sm2Decrypt, sm2Sign, sm2Verify
} from 'qxj-frontend-sdk'
interface Sm2KeyPair {
privateKey: string // 64 hex
publicKey: string // 130 hex(04 + x + y)
publicKeyNoPrefix: string // 128 hex(x + y)
}
sm2GenerateKeyPair(): Sm2KeyPair
sm2Encrypt(plaintext: string, publicKey: string): number[]
sm2Decrypt(ciphertextHex: string, privateKey: string): string
sm2Sign(data: string, privateKey: string): string // 先 SM3 摘要再签名,r||s = 128 hex
sm2Verify(data: string, signatureHex: string, publicKey: string): boolean2
3
4
5
6
7
8
9
10
11
12
13
14
15
公钥格式
所有接受公钥的函数同时支持 128 hex(无 04 前缀) 与 130 hex(带前缀); 后端 active_qrcode 返回的 public_key 为 128 hex,可直接使用。
实现与类型声明的偏差
sm2Encrypt 的类型声明为 number[](C1C2C3 字节数组),但实现直接透传 sm-crypto 的 doEncrypt 返回值,实际得到 C1C3C2 hex 字符串。以运行时为准; 如业务依赖字节数组,需自行 hex 解码。
SM3 摘要与 HMAC
import { sm3, sm3Hmac } from 'qxj-frontend-sdk'
sm3(data: string | number[]): string // 64 hex;字符串按 UTF-8
sm3Hmac(keyHex: string, messageBytes: number[]): string // 标准 RFC2104(块长 64),64 hex2
3
4
sm3Hmac 用于 NSP-SM token 的内层签名;密钥长于 64 字节先 SM3、短于补 0x00, ipad=0x36 / opad=0x5c。
SM4-CBC
import { sm4CbcEncrypt, sm4CbcDecrypt } from 'qxj-frontend-sdk'
sm4CbcEncrypt(plaintext: string, keyHex: string, ivHex: string): string // 密文 hex
sm4CbcDecrypt(ciphertextHex: string, keyHex: string, ivHex: string): string2
3
4
注意:登录信封与业务密文使用的是 SM4-GCM(带完整性 tag),其密钥在设备 会话密钥中,网页侧不直接持有——该能力经 JSBridge 调用原生命令,不在此列。
NSP-SM JWT
NSP-SM JWT 是 QXJ 自定义的双层签名 token:外层 SM2 签名 + 内层 SM3-HMAC。 标准工具(jwt.io 等)因不识别 NSP-SM 算法无法验签。
parseJwt(只解析,不校验)
import { parseJwt } from 'qxj-frontend-sdk'
const parsed = parseJwt(token: string): {
header: Record<string, unknown> // { alg: 'NSP-SM', typ: 'JWT' }
payload: Record<string, unknown> // jti / exp / token_type / sid / tv / custom
signatureHex: string
signingInput: string // 'header_b64.payload_b64'
}2
3
4
5
6
7
8
段数不为 3 时抛异常;做联调排障时可先用它肉眼检查 claims。
verifyJwt(双层校验,永不抛异常)
import { verifyJwt, VerifyCode, CODE_DESC } from 'qxj-frontend-sdk'
const result = verifyJwt(token, {
publicKey: '128 hex 服务器 SM2 公钥', // 必填(130 hex 亦可)
sessionKey: '设备会话密钥 hex', // 可选;device_id 非空时校验内层 HMAC 必传
leeway: 0, // 可选;过期宽容秒数
})
result.ok // code === 0
result.outerOk // 外层是否通过
result.code // 见下方码表
result.stages // parse/alg/outer/expiry/custom/inner 各阶段状态,便于定位失败点2
3
4
5
6
7
8
9
10
11
12
校验顺序(任一阶段失败立即返回对应 code):
JWT 校验码(VerifyCode)
| code | 常量 | 含义 |
|---|---|---|
| 0 | OK | 外层 SM2 + 内层 HMAC 全部通过 |
| 1 | OUTER_PASSED | 外层通过,device_id 为空,跳过内层 |
| 10 | TOKEN_EMPTY | token 为空 |
| 11 | TOKEN_PARSE_FAILED | 三段解析 / base64url / JSON 失败 |
| 12 | ALG_MISMATCH | alg 不是 NSP-SM |
| 20 | SIGN_VERIFY_FAILED | 外层 SM2 签名不匹配 |
| 21 | TOKEN_EXPIRED | 已过期 |
| 30 | CUSTOM_FIELD_MISSING | custom 缺失 / 非法(含 exp_s 与 exp 不一致) |
| 31 | HMAC_EMPTY | device_id 非空但 custom.hmac 为空 |
| 32 | SESSION_KEY_REQUIRED | 校验内层需要 sessionKey 但未传入 |
| 33 | HMAC_VERIFY_FAILED | 内层 HMAC 不匹配 |
| 99 | UNKNOWN_ERROR | 未知错误 |
可用 CODE_DESC[code] 取中文描述。
与 Python SDK 码表的区别
Python 后端 SDK 使用负数编码(0 / 1 / -1 … -8 / -99,见 REST API 码表);本 JS SDK 使用 正数编码(10/11/12…),语义一一对应,两者不可混用。
JSBridge 封装(鸿蒙 Pad 注入)
鸿蒙安全浏览器只向白名单内的业务页面注入原生对象 window.jsbridgeHandle (白名单由当前选中服务器的四个 URL 推导,非白名单页面不注入)。SDK 将其封装为 6 个 Promise 风格的高层函数。
环境检测
import { isJsBridgeAvailable } from 'qxj-frontend-sdk'
isJsBridgeAvailable(): boolean // typeof window.jsbridgeHandle === 'object'2
3
普通浏览器、Node 环境均返回 false。业务前端应先检测再决定走加密流程还是降级流程。
六个桥方法
import {
jsBridgeLogin, jsBridgeGetAccess, jsBridgeRefresh,
jsBridgeEncrypt, jsBridgeDecrypt, jsBridgeLogout
} from 'qxj-frontend-sdk'
// 登录:网页只给账密(+可选验证码/位置),客户端补齐设备ID并完成握手+加密登录
const login = await jsBridgeLogin(username, password, verificationCode?, position?)
// → { success, access, refresh, message }
// 取当前 access(客户端安全区缓存;未登录返回 code -31)
const access = await jsBridgeGetAccess() // → { code, message, accessToken? }
// 用 refresh 换新 access(网页全程看不到 refresh)
const refreshed = await jsBridgeRefresh() // → { code, message, accessToken? }
// SM4-GCM 加密(明文最长 64KB),成功 data = 64B 信封帧 hex
const enc = await jsBridgeEncrypt(plainText) // → { code, message, data? }
// SM4-GCM 解密(入参为帧 hex),成功 data = 明文
const dec = await jsBridgeDecrypt(cipherText) // → { code, message, data? }
// 登出(服务器拉黑失败不阻断本地清理)
await jsBridgeLogout() // → { code, message }2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
所有桥方法 Promise 均不 reject
失败通过 code !== 0(登录通过 success === false)表达,调用链上无需 try/catch 兜底“桥异常”;无桥环境下 jsBridgeLogin 返回固定提示 「未检测到专用设备,加密登录不可用」。
桥通信机制(实现细节)
原生方法签名(浏览器 4.1.42 / 5.2.88+):
window.jsbridgeHandle.login(payload: string): Promise<string> // payload = 登录 JSON 字符串
window.jsbridgeHandle.getAccessToken(): Promise<string>
window.jsbridgeHandle.refreshAccessToken(): Promise<string>
window.jsbridgeHandle.sm4GcmEncrypt(data: string): Promise<string>
window.jsbridgeHandle.sm4GcmDecrypt(cipher: string): Promise<string>
window.jsbridgeHandle.logout(): Promise<string>2
3
4
5
6
要点:
- Promise 风格:原生方法直接返回 Promise,参数为字符串位置参数(login 的入参 先
JSON.stringify({username, password, verification_code, position})); - 响应双格式自适应:SDK 拿到返回字符串后,若以
{开头且 JSON 中带数值型code字段,视为新格式({code, message, accessToken?/refreshToken?/data?})直接 透传;否则按旧客户端(≤4.1.41)裸字符串语义包装(非空即成功)。 因此同一套业务代码可在新老客户端版本上运行。
JSBridge 错误码(JsBridgeCode)
| code | 常量 | 含义 | 典型场景 |
|---|---|---|---|
| 0 | SUCCESS | 成功 | — |
| -11 | ERROR_EMPTY_INPUT | 明文为空 | encrypt |
| -12 | ERROR_INPUT_TOO_LONG | 明文超 64KB | encrypt |
| -14 | ERROR_ENCRYPT_FAILED | 密钥不可用 / 封帧失败 | encrypt |
| -21 | ERROR_INVALID_CIPHERTEXT | 帧为空 / 格式错 / 被篡改 | decrypt |
| -22 | ERROR_DECRYPT_AUTH_FAILED | GCM tag 校验失败 | decrypt |
| -31 | ERROR_TOKEN_NOT_FOUND | 无 access(未登录 / 过期) | getAccessToken |
| -41 | ERROR_REFRESH_TOKEN_NOT_FOUND | 无 refresh | refresh |
| -44 | ERROR_AUTH_SERVER_REJECT | 服务器拒绝刷新 | refresh |
| -51 | ERROR_TOKEN_CLEAR_FAILED | 本地凭据清理失败 | logout |
| -60 | ERROR_PERMISSION_DENIED | 非白名单页面调用 | 通用 |
| -70 | ERROR_LOGIN_PAYLOAD_INVALID | 登录参数无效 | login |
| -71 | ERROR_LOGIN_NOT_BOUND | 未绑定服务器(需先扫码) | login |
| -72 | ERROR_LOGIN_KEY_EXCHANGE_FAILED | SM2 握手失败 | login |
| -73 | ERROR_LOGIN_DEVICE_ID_FAILED | 设备 ID 获取失败 | login |
| -74 | ERROR_LOGIN_REJECTED | 服务器拒绝登录(账密 / 策略) | login |
| -75 | ERROR_LOGIN_EXCEPTION | 登录流程异常 | login |
| -99 | ERROR_UNKNOWN | 未知错误 | 通用 |
describeJsBridgeCode(code) 可取中文描述。
工具函数
import {
hexToBytes, bytesToHex, packUint64BE,
strToUtf8Bytes, utf8BytesToStr,
b64urlEncode, b64urlDecode, b64urlDecodeStr
} from 'qxj-frontend-sdk'
hexToBytes(hex: string): number[]
bytesToHex(bytes: ArrayLike<number>): string
packUint64BE(n: number | bigint): number[] // 等价 Python struct.pack('>Q', n)
strToUtf8Bytes(s: string): number[]
utf8BytesToStr(bytes: ArrayLike<number>): string
b64urlEncode(bytes: ArrayLike<number>): string // 注意入参是字节,无 = 填充
b64urlDecode(s: string): number[]
b64urlDecodeStr(s: string): string2
3
4
5
6
7
8
9
10
11
12
13
14
真实接入示例
以下两节直接对应工作区中的两个实际工程:业务前端参照 qxj-frontend-mock-demo/after, 管理平台参照 qxj-frontend-admin。
业务前端接入(qxj-frontend-mock-demo after)
after 工程演示业务前端在「网页零持久化」前提下的完整接入。页面刷新后登录态完全 由客户端安全区恢复,网页 localStorage 中不存任何凭据。
① 启动:先装模拟桥(仅浏览器演示),再 init
// src/main.ts
if (import.meta.env.VITE_ENABLE_MOCK_JSBRIDGE === 'true') {
installMockJsBridge(import.meta.env.VITE_MOCK_PAD_BASE || '')
}
await init()
createApp(App).use(router).mount('#app')2
3
4
5
6
模拟桥 installMockJsBridge 发现 window.jsbridgeHandle 已存在时不覆盖(保证同一套 代码在真机上走原生),否则把 6 个方法转发给后端 /mock/pad/*,并把旧格式响应转换 为带 code 的 JSON 字符串。
② 进入页面先探测登录态
// src/auth-flow.ts
export async function probeAccess(): Promise<string> {
if (!isJsBridgeAvailable()) return ''
const result = await jsBridgeGetAccess()
return result.code === 0 && result.accessToken ? result.accessToken : ''
}2
3
4
5
6
有 access → 渲染业务面板;无 → 渲染「跳转登录页」卡片。
③ 登录:整页跳鉴权登录页,登录页调 jsBridgeLogin
// 业务页:带一次性回跳标记跳转
const redirect = encodeURIComponent(`${location.origin}${location.pathname}?login=back`)
window.location.href = `${VITE_LOGIN_PAGE_URL}?redirect=${redirect}`
// 登录页(真机 = 鉴权前端 /m/login;演示 = MockLoginView)
const result = await jsBridgeLogin(username, password) // 成功后 access/refresh 只留在原生层
if (result.success) window.location.replace(safeRedirect)2
3
4
5
6
7
④ 业务请求:请求拦截器每次临时取 access,不做缓存
// src/api.ts
http.interceptors.request.use(async (config) => {
const result = await jsBridgeGetAccess()
if (result.code === 0 && result.accessToken) {
config.headers.Authorization = `Bearer ${result.accessToken}`
}
return config
})2
3
4
5
6
7
8
⑤ 401 自动刷新:并发去重 + 原请求单次重放
let refreshing: Promise<boolean> | null = null
function refreshAccess() {
if (!refreshing) { // 多个 401 并发时共享同一个刷新 Promise
refreshing = jsBridgeRefresh()
.then(r => r.code === 0)
.finally(() => { refreshing = null })
}
return refreshing
}
// 响应拦截器中:
if (error.response?.status === 401 && !config._retried) {
config._retried = true
const ok = await refreshAccess()
if (ok) {
const cur = await jsBridgeGetAccess()
if (cur.code === 0) {
config.headers.Authorization = `Bearer ${cur.accessToken}`
return http(config) // 重放原请求
}
}
window.dispatchEvent(new Event('qxj-session-expired')) // refresh 也失败 → 回未登录态
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
⑥ 登出与信封报文
await jsBridgeLogout() // 服务器拉黑 + 清安全区,随后重新探测登录态
// SM4-GCM 加密回声接口 /api/echo/:
const enc = await jsBridgeEncrypt(JSON.stringify({ message }))
const { data } = await http.post('/api/echo/', { frame: enc.data })
const dec = await jsBridgeDecrypt(data.frame)
// JSON.parse(dec.data).reply2
3
4
5
6
7
管理前端接入(qxj-frontend-admin)
管理后台同时支持 PC 与移动端,登录态层(Pinia store/modules/user.ts)维护 loginMode: 'jsbridge' | 'json' 双模式:
- jsbridge 模式:请求拦截器对
/api/v3/请求调jsBridgeGetAccess()附加 token; 401 时调jsBridgeRefresh()后重放一次;最终 401 经 3 秒防抖触发登出并提示。 - json 模式:账号密码直连
POST /api/v3/user/login/(受VITE_ALLOW_JSON_LOGIN控制,生产默认关闭),access 存 Pinia(持久化到 localStorage),刷新走POST /api/v3/token/refresh/。 - 移动端登录页路由
/m/login:两个 Tab 分别对应上述两种模式;短信验证码调/api/v3/sms/send_code/,60 秒倒计时;支持外域 redirect——业务 H5 跳来登录, 成功后location.replace跳回,URL 中不携带 token。
关键环境变量:VITE_API_URL(默认 /,dev 下 proxy 到 4607)、 VITE_USE_JSBRIDGE(生产 true)、VITE_ALLOW_JSON_LOGIN=false、 VITE_REQUIRE_DEDICATED_DEVICE(生产 true,普通浏览器进入 wrong-device 提示)。
before / after 对照(为什么值得接入)
| 维度 | before(传统做法) | after(SDK + JSBridge) |
|---|---|---|
| token 存储 | access / refresh 存 localStorage,XSS 可直接窃取 | 网页零存储;凭据在原生安全区,用时临时取 |
| 登录 | 页面表单 HTTPS 直传账密 | 原生 SM2 握手 + SM4-GCM 加密登录 |
| 请求附 token | 同步读 localStorage | 异步 jsBridgeGetAccess(),每次取最新 |
| 401 刷新 | 网页持有 refresh 自行刷新 | jsBridgeRefresh(),网页不见 refresh;并发去重 |
| 登出 | 仅清本地 | 服务器 jti 黑名单 + sid 撤销 + 清安全区 |
| 业务报文 | 明文 JSON | 可选 SM4-GCM 64B 信封,带完整性校验 |
| 业务后端密钥 | 前后端需共享对称密钥(难管理) | 只需配置认证服务器 SM2 公钥 |
排障建议
- 返回 code 20 / -3:先确认所用公钥为当前激活 ServerKey(换服务器后旧公钥不 匹配);用
parseJwt检查 alg 是否为 NSP-SM。 - code 21 / -4:access 过期;正常走刷新即可。若刚登录就过期,检查设备时钟。
- code 33 / -6:内层 HMAC 失败——会话密钥与签发时不一致(密钥被重新协商或 Redis 被清),需重新登录 / 握手。
- 桥方法返回 -60:当前页面不在选中服务器的白名单内,检查服务器配置与页面源。
- login 返回 -71:尚未扫码绑定服务器,先在服务器管理页扫码。