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>
这个提交包含在:
父节点
dfb95dc9e0
当前提交
b04742fb9c
@ -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",
|
||||
|
||||
26
packages/license/package.json
普通文件
26
packages/license/package.json
普通文件
@ -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"
|
||||
}
|
||||
}
|
||||
59
packages/license/src/crypto.ts
普通文件
59
packages/license/src/crypto.ts
普通文件
@ -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'
|
||||
141
packages/license/src/license.ts
普通文件
141
packages/license/src/license.ts
普通文件
@ -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
|
||||
}
|
||||
49
packages/license/src/models.ts
普通文件
49
packages/license/src/models.ts
普通文件
@ -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 }
|
||||
54
packages/license/src/store.ts
普通文件
54
packages/license/src/store.ts
普通文件
@ -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])
|
||||
}
|
||||
正在加载...
在新工单中引用
屏蔽一个用户