feat(license): add @xuqm/rn-license package

Implements device authorization for React Native:
- AES-256-GCM + PBKDF2 decryption via react-native-quick-crypto (lazy loaded)
- AsyncStorage persistence with @xuqm:license:* keys
- 10-minute in-memory + persistent cache with offline fallback
- LicenseResult discriminated union type

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
这个提交包含在:
XuqmGroup 2026-05-16 02:25:38 +08:00
父节点 dfb95dc9e0
当前提交 b04742fb9c
共有 7 个文件被更改,包括 333 次插入1 次删除

查看文件

@ -26,7 +26,8 @@
"@xuqm/rn-im": ">=0.2.0",
"@xuqm/rn-push": ">=0.2.0",
"@xuqm/rn-update": ">=0.2.0",
"@xuqm/rn-xwebview": ">=0.2.0"
"@xuqm/rn-xwebview": ">=0.2.0",
"@xuqm/rn-license": ">=0.2.0"
},
"devDependencies": {
"typescript": "^5.9.3",

查看文件

@ -0,0 +1,26 @@
{
"name": "@xuqm/rn-license",
"version": "0.2.0",
"description": "XuqmGroup RN SDK — License module (device registration & verification)",
"license": "UNLICENSED",
"main": "src/index.ts",
"react-native": "src/index.ts",
"types": "src/index.ts",
"private": false,
"publishConfig": {
"registry": "https://nexus.xuqinmin.com/repository/npm-hosted/"
},
"scripts": { "typecheck": "tsc --noEmit" },
"dependencies": {
"@xuqm/rn-common": ">=0.2.2"
},
"peerDependencies": {
"react-native": ">=0.76.0",
"@react-native-async-storage/async-storage": ">=1.21.0",
"react-native-quick-crypto": ">=0.7.0"
},
"devDependencies": {
"typescript": "^5.9.3",
"@types/react-native": "^0.73.0"
}
}

查看文件

@ -0,0 +1,59 @@
import type { LicenseFile } from './models'
const MAGIC = 'XUQM-LICENSE-V1'
const PASSPHRASE = 'xuqm-license-file-v1.2026.internal'
const PBKDF2_ITERATIONS = 120_000
// Accesses react-native-quick-crypto's SubtleCrypto implementation (peer dependency).
// Avoids a top-level import so that tree-shaking works and missing dep throws at call-time.
function getSubtle(): SubtleCrypto {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const qc = require('react-native-quick-crypto') as { subtle?: SubtleCrypto; default?: { subtle?: SubtleCrypto } }
const subtle = qc.subtle ?? qc.default?.subtle
if (!subtle) throw new Error('[XuqmLicense] react-native-quick-crypto not available')
return subtle
}
function base64UrlDecode(s: string): Uint8Array {
const padded = s.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - (s.length % 4)) % 4)
// atob is available on Hermes (React Native 0.71+)
const binary = atob(padded)
return Uint8Array.from({ length: binary.length }, (_, i) => binary.charCodeAt(i))
}
async function deriveKey(salt: Uint8Array): Promise<CryptoKey> {
const subtle = getSubtle()
const passphraseKey = await subtle.importKey(
'raw',
new TextEncoder().encode(PASSPHRASE),
{ name: 'PBKDF2' },
false,
['deriveKey'],
)
return subtle.deriveKey(
{ name: 'PBKDF2', salt, iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' },
passphraseKey,
{ name: 'AES-GCM', length: 256 },
false,
['decrypt'],
)
}
// Decrypts license file content. Format: MAGIC.base64UrlSalt.base64UrlIV.base64UrlCiphertext
// Ciphertext includes the 16-byte GCM tag appended (same as Android JCE output).
export async function decryptLicenseFile(content: string): Promise<LicenseFile> {
const parts = content.trim().split('.')
if (parts.length !== 4 || parts[0] !== MAGIC) {
throw new Error('[XuqmLicense] Invalid license file format')
}
const salt = base64UrlDecode(parts[1])
const iv = base64UrlDecode(parts[2])
const ciphertext = base64UrlDecode(parts[3])
const key = await deriveKey(salt)
const subtle = getSubtle()
// Web Crypto AES-GCM decrypt expects ciphertext with tag appended — matches Android JCE output
const plainBuffer = await subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext)
const json = new TextDecoder().decode(plainBuffer)
return JSON.parse(json) as LicenseFile
}

查看文件

@ -0,0 +1,2 @@
export { initialize, initializeFromFile, checkLicense, getStatus, getDeviceId, clear } from './license'
export type { LicenseFile, LicenseUserInfo, LicenseStatus, LicenseResult } from './models'

查看文件

@ -0,0 +1,141 @@
import { Platform } from 'react-native'
import { getDeviceId as getCommonDeviceId, getDeviceInfo } from '@xuqm/rn-common'
import { decryptLicenseFile } from './crypto'
import * as store from './store'
import type { LicenseFile, LicenseResult, LicenseStatus, LicenseUserInfo, RegisterRequest, RegisterResponse, VerifyRequest, VerifyResponse } from './models'
const DEFAULT_BASE_URL = 'https://auth.dev.xuqinmin.com'
const CACHE_WINDOW_MS = 10 * 60 * 1000
const STATUS_OK = 'ok'
const STATUS_DENIED = 'denied'
interface LicenseConfig {
appKey: string
baseUrl: string
deviceName?: string
}
let _config: LicenseConfig | null = null
// In-memory cache to avoid repeated AsyncStorage reads within a session
let _cachedStatus: string | null = null
let _cachedStatusTime = 0
function normalize(url: string): string {
const s = url.trim()
return s.endsWith('/') ? s : s + '/'
}
export function initialize(appKey: string, options: { baseUrl?: string; deviceName?: string } = {}): void {
_config = {
appKey,
baseUrl: normalize(options.baseUrl ?? DEFAULT_BASE_URL),
deviceName: options.deviceName,
}
}
// Auto-initialize from license file asset. The asset is embedded as a string (e.g., via react-native-raw-text or require()).
export async function initializeFromFile(encryptedContent: string): Promise<void> {
const file = await decryptLicenseFile(encryptedContent)
initialize(file.appKey, { baseUrl: file.baseUrl })
}
export async function checkLicense(userInfo?: LicenseUserInfo): Promise<LicenseResult> {
if (!_config) return { type: 'error', message: 'LicenseSDK not initialized' }
const { appKey, baseUrl } = _config
// In-memory cache check
if (_cachedStatus === STATUS_OK && Date.now() - _cachedStatusTime < CACHE_WINDOW_MS) {
return { type: 'success', reason: 'Cached' }
}
// Persistent cache check
const persistedStatus = await store.getStatus()
const persistedTime = await store.getStatusTime()
if (persistedStatus === STATUS_OK && Date.now() - persistedTime < CACHE_WINDOW_MS) {
_cachedStatus = STATUS_OK
_cachedStatusTime = persistedTime
return { type: 'success', reason: 'Cached' }
}
const deviceId = await getOrCreateDeviceId()
const deviceInfo = await getDeviceInfo()
try {
const storedToken = await store.getToken()
if (storedToken) {
const verifyReq: VerifyRequest = { appKey, deviceId, token: storedToken, userInfo }
const verifyResp = await post<VerifyResponse>(`${baseUrl}api/license/verify`, verifyReq)
if (verifyResp.valid) {
await persistStatus(STATUS_OK)
return { type: 'success', reason: 'Verified' }
}
await store.setToken(null)
}
const regReq: RegisterRequest = {
appKey,
deviceId,
deviceName: _config.deviceName,
deviceModel: deviceInfo.model,
deviceVendor: Platform.OS === 'ios' ? 'Apple' : deviceInfo.brand,
osVersion: `${Platform.OS === 'ios' ? 'iOS' : 'Android'} ${deviceInfo.osVersion}`,
userInfo,
}
const regResp = await post<RegisterResponse>(`${baseUrl}api/license/register`, regReq)
if (regResp.success && regResp.token) {
await store.setToken(regResp.token)
await persistStatus(STATUS_OK)
return { type: 'success', reason: 'Registered' }
}
await persistStatus(STATUS_DENIED)
return { type: 'error', message: regResp.message ?? 'Registration denied' }
} catch (e) {
if (persistedStatus === STATUS_OK) return { type: 'success', reason: 'Offline - cached ok' }
return { type: 'error', message: e instanceof Error ? e.message : 'Network error' }
}
}
export async function getStatus(): Promise<LicenseStatus> {
const s = _cachedStatus ?? (await store.getStatus())
if (s === STATUS_OK) return 'ok'
if (s === STATUS_DENIED) return 'denied'
return 'unknown'
}
export async function getDeviceId(): Promise<string> {
return getOrCreateDeviceId()
}
export async function clear(): Promise<void> {
_cachedStatus = null
_cachedStatusTime = 0
await store.clearAll()
}
async function getOrCreateDeviceId(): Promise<string> {
const existing = await store.getDeviceId()
if (existing) return existing
const id = await getCommonDeviceId()
await store.setDeviceId(id)
return id
}
async function persistStatus(status: string): Promise<void> {
const now = Date.now()
_cachedStatus = status
_cachedStatusTime = now
await Promise.all([store.setStatus(status), store.setStatusTime(now)])
}
async function post<T>(url: string, body: unknown): Promise<T> {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!res.ok) {
const err = await res.json().catch(() => ({ message: res.statusText })) as { message?: string }
throw new Error(err.message ?? `HTTP ${res.status}`)
}
const json = await res.json() as { data?: T }
return (json.data ?? json) as T
}

查看文件

@ -0,0 +1,49 @@
export interface LicenseFile {
appKey: string
appName?: string
companyName?: string
baseUrl?: string
issuedAt?: string
expiresAt?: string
}
export interface LicenseUserInfo {
userId?: string
name?: string
email?: string
phone?: string
}
export interface RegisterRequest {
appKey: string
deviceId: string
deviceName?: string
deviceModel: string
deviceVendor: string
osVersion: string
userInfo?: LicenseUserInfo
}
export interface VerifyRequest {
appKey: string
deviceId: string
token: string
userInfo?: LicenseUserInfo
}
export interface RegisterResponse {
success: boolean
token?: string
message?: string
}
export interface VerifyResponse {
valid: boolean
error?: string
}
export type LicenseStatus = 'ok' | 'denied' | 'unknown'
export type LicenseResult =
| { type: 'success'; reason: string }
| { type: 'error'; message: string }

查看文件

@ -0,0 +1,54 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
const KEY_TOKEN = '@xuqm:license:token'
const KEY_DEVICE_ID = '@xuqm:license:deviceId'
const KEY_STATUS = '@xuqm:license:status'
const KEY_STATUS_TIME = '@xuqm:license:statusTime'
const KEY_APP_KEY = '@xuqm:license:appKey'
export async function getToken(): Promise<string | null> {
return AsyncStorage.getItem(KEY_TOKEN)
}
export async function setToken(token: string | null): Promise<void> {
if (token == null) { await AsyncStorage.removeItem(KEY_TOKEN); return }
await AsyncStorage.setItem(KEY_TOKEN, token)
}
export async function getDeviceId(): Promise<string | null> {
return AsyncStorage.getItem(KEY_DEVICE_ID)
}
export async function setDeviceId(id: string): Promise<void> {
await AsyncStorage.setItem(KEY_DEVICE_ID, id)
}
export async function getStatus(): Promise<string | null> {
return AsyncStorage.getItem(KEY_STATUS)
}
export async function setStatus(status: string | null): Promise<void> {
if (status == null) { await AsyncStorage.removeItem(KEY_STATUS); return }
await AsyncStorage.setItem(KEY_STATUS, status)
}
export async function getStatusTime(): Promise<number> {
const v = await AsyncStorage.getItem(KEY_STATUS_TIME)
return v ? Number(v) : 0
}
export async function setStatusTime(ms: number): Promise<void> {
await AsyncStorage.setItem(KEY_STATUS_TIME, String(ms))
}
export async function getStoredAppKey(): Promise<string | null> {
return AsyncStorage.getItem(KEY_APP_KEY)
}
export async function setStoredAppKey(appKey: string): Promise<void> {
await AsyncStorage.setItem(KEY_APP_KEY, appKey)
}
export async function clearAll(): Promise<void> {
await AsyncStorage.multiRemove([KEY_TOKEN, KEY_DEVICE_ID, KEY_STATUS, KEY_STATUS_TIME, KEY_APP_KEY])
}