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-im": ">=0.2.0",
|
||||||
"@xuqm/rn-push": ">=0.2.0",
|
"@xuqm/rn-push": ">=0.2.0",
|
||||||
"@xuqm/rn-update": ">=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": {
|
"devDependencies": {
|
||||||
"typescript": "^5.9.3",
|
"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])
|
||||||
|
}
|
||||||
正在加载...
在新工单中引用
屏蔽一个用户