diff --git a/package.json b/package.json index 5ba48af..3256c8b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/license/package.json b/packages/license/package.json new file mode 100644 index 0000000..2f33336 --- /dev/null +++ b/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" + } +} diff --git a/packages/license/src/crypto.ts b/packages/license/src/crypto.ts new file mode 100644 index 0000000..35f615b --- /dev/null +++ b/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 { + 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 { + 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 +} diff --git a/packages/license/src/index.ts b/packages/license/src/index.ts new file mode 100644 index 0000000..b74b48a --- /dev/null +++ b/packages/license/src/index.ts @@ -0,0 +1,2 @@ +export { initialize, initializeFromFile, checkLicense, getStatus, getDeviceId, clear } from './license' +export type { LicenseFile, LicenseUserInfo, LicenseStatus, LicenseResult } from './models' diff --git a/packages/license/src/license.ts b/packages/license/src/license.ts new file mode 100644 index 0000000..15fb245 --- /dev/null +++ b/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 { + const file = await decryptLicenseFile(encryptedContent) + initialize(file.appKey, { baseUrl: file.baseUrl }) +} + +export async function checkLicense(userInfo?: LicenseUserInfo): Promise { + 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(`${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(`${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 { + 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 { + return getOrCreateDeviceId() +} + +export async function clear(): Promise { + _cachedStatus = null + _cachedStatusTime = 0 + await store.clearAll() +} + +async function getOrCreateDeviceId(): Promise { + 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 { + const now = Date.now() + _cachedStatus = status + _cachedStatusTime = now + await Promise.all([store.setStatus(status), store.setStatusTime(now)]) +} + +async function post(url: string, body: unknown): Promise { + 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 +} diff --git a/packages/license/src/models.ts b/packages/license/src/models.ts new file mode 100644 index 0000000..6aa3ca9 --- /dev/null +++ b/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 } diff --git a/packages/license/src/store.ts b/packages/license/src/store.ts new file mode 100644 index 0000000..c9ac962 --- /dev/null +++ b/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 { + return AsyncStorage.getItem(KEY_TOKEN) +} + +export async function setToken(token: string | null): Promise { + if (token == null) { await AsyncStorage.removeItem(KEY_TOKEN); return } + await AsyncStorage.setItem(KEY_TOKEN, token) +} + +export async function getDeviceId(): Promise { + return AsyncStorage.getItem(KEY_DEVICE_ID) +} + +export async function setDeviceId(id: string): Promise { + await AsyncStorage.setItem(KEY_DEVICE_ID, id) +} + +export async function getStatus(): Promise { + return AsyncStorage.getItem(KEY_STATUS) +} + +export async function setStatus(status: string | null): Promise { + if (status == null) { await AsyncStorage.removeItem(KEY_STATUS); return } + await AsyncStorage.setItem(KEY_STATUS, status) +} + +export async function getStatusTime(): Promise { + const v = await AsyncStorage.getItem(KEY_STATUS_TIME) + return v ? Number(v) : 0 +} + +export async function setStatusTime(ms: number): Promise { + await AsyncStorage.setItem(KEY_STATUS_TIME, String(ms)) +} + +export async function getStoredAppKey(): Promise { + return AsyncStorage.getItem(KEY_APP_KEY) +} + +export async function setStoredAppKey(appKey: string): Promise { + await AsyncStorage.setItem(KEY_APP_KEY, appKey) +} + +export async function clearAll(): Promise { + await AsyncStorage.multiRemove([KEY_TOKEN, KEY_DEVICE_ID, KEY_STATUS, KEY_STATUS_TIME, KEY_APP_KEY]) +}