From 111bb29a1a9e2cf5cab1d83ecd85aab01f77fa24 Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Tue, 26 May 2026 17:25:48 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E4=BB=98=E8=B4=B9=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=E5=8C=85=E4=B8=8B=E8=BD=BD=E6=A0=A1=E9=AA=8C=E9=97=AD?= =?UTF-8?q?=E7=8E=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/商业化地基排查问题清单_20260524.md | 10 +- docs/许可证服务接入说明.md | 10 +- lib/__tests__/premium-module-service.test.ts | 103 +++++++++++- lib/license/premium-module-service.ts | 140 +++++++++++++++- .../premium-modules/[moduleId]/route.ts | 135 +++++++++++++++ .../[projectCode]/premium-modules/route.ts | 7 +- .../__tests__/premium-module-manifest.test.ts | 25 ++- .../premium-module-route-coverage.test.ts | 26 +++ license-server/lib/premium-module-manifest.ts | 156 +++++++++++++++++- 9 files changed, 581 insertions(+), 31 deletions(-) create mode 100644 license-server/app/api/license/[projectCode]/premium-modules/[moduleId]/route.ts create mode 100644 license-server/lib/__tests__/premium-module-route-coverage.test.ts diff --git a/docs/商业化地基排查问题清单_20260524.md b/docs/商业化地基排查问题清单_20260524.md index 97fb90f..8b94613 100644 --- a/docs/商业化地基排查问题清单_20260524.md +++ b/docs/商业化地基排查问题清单_20260524.md @@ -405,9 +405,9 @@ - `license-server/docker-compose.yml`、`license-server/nginx/nginx.conf`:许可证服务默认 Compose 已改为由 Nginx 对外暴露 `9527`,应用容器只在内部网络暴露 `3000`;现有登录和 API 限流不再只是孤立配置。 - `license-server/lib/abuse-detection.ts`、`license-server/app/api/license/[projectCode]/register-free/route.ts`、`activate/verify/heartbeat`:免费版新增同设备重复注册、多实例/多指纹复用阻断;付费版多设备疑似复用先告警,不直接误伤客户。 - `license-server/app/api/license/[projectCode]/register-free/route.ts`:免费注册复用已有激活前会检查许可证禁用和过期状态;首条心跳记录统一写入硬件指纹哈希,避免后续被误判为多设备。 -- `license-server/app/api/license/[projectCode]/premium-modules/route.ts`、`license-server/lib/premium-module-manifest.ts`:新增付费模块清单接口,免费版拒绝,付费版按许可证类型返回签名 manifest,并在硬件指纹明显不匹配时拒绝并告警。 -- `app/api/license/activate/route.ts`、`app/api/license/verify/route.ts`、`app/api/license/heartbeat/route.ts`、`lib/license/premium-module-service.ts`:主应用在付费激活、验证和心跳续证成功后同步付费模块 manifest,本地加密缓存并校验 RSA 签名、租约和当前许可证类型,避免降级后继续使用旧 PRO 模块清单。 -- `lib/license/check-limits.ts`:自定义模板和内网穿透已升级为“许可证功能开关 + 签名付费模块清单”双门槛;没有验证服务器下发的模块凭证时,即使本地许可证字段被篡改为开启,也不会通过服务端功能检查。 +- `license-server/app/api/license/[projectCode]/premium-modules/route.ts`、`license-server/app/api/license/[projectCode]/premium-modules/[moduleId]/route.ts`、`license-server/lib/premium-module-manifest.ts`:新增付费模块清单和单模块下载接口,免费版拒绝,付费版按许可证类型返回签名 manifest,并下发绑定当前实例的加密模块包;硬件指纹明显不匹配时拒绝并告警。 +- `app/api/license/activate/route.ts`、`app/api/license/verify/route.ts`、`app/api/license/heartbeat/route.ts`、`lib/license/premium-module-service.ts`:主应用在付费激活、验证和心跳续证成功后同步付费模块 manifest,再逐个下载模块包,本地校验 RSA 签名、租约、当前许可证类型、包大小和 SHA256 后加密缓存,避免降级后继续使用旧 PRO 模块包。 +- `lib/license/check-limits.ts`:自定义模板和内网穿透已升级为“许可证功能开关 + 签名付费模块清单 + 本地有效模块包”三重门槛;没有验证服务器下发并校验通过的模块包时,即使本地许可证字段被篡改为开启,也不会通过服务端功能检查。 - `lib/__tests__/license-route-coverage.test.ts`:新增关键业务入口覆盖测试,防止用户数、素材数、自定义模板、部门隔离、系统设置、内网穿透等服务端许可证检查被后续改动漏掉;同时锁定高价值付费能力必须依赖 `hasPremiumModule()`。 - `lib/__tests__/license-service.test.ts`:新增测试覆盖许可证过期、在线租约过期、高级功能不可用、数量限制不放行、本地许可证从 Pro 降级到 Free 后业务检查立即按 Free 生效,以及旧许可证缺少 `leaseExpiresAt` 时的兼容行为。 - `lib/__tests__/license-types.test.ts`、`app/api/license/check/__tests__/route.test.ts`:新增测试锁定默认租约天数、租约截断逻辑,以及租约过期时不恢复激活 cookie。 @@ -436,8 +436,8 @@ - 已完成:本地许可证读取已加入轻量时间回拨检测;免费版和租约能力不能再靠把系统时间往回调来继续走本地宽限期。 - 已完成:验证服务器默认入口接入 Nginx 限流,免费版重复注册/多设备复用会阻断,付费版异常多设备以告警为主。 - 已完成:主应用关键授权入口已有防漏口测试,后续改注册、上传、模板、部门、系统设置、内网穿透接口时会更早暴露许可证检查缺失。 -- 已完成:付费模块 manifest 基础链路已落地,适合低配置验证服务器和约 5M 带宽;自定义模板和内网穿透已经接入签名模块凭证门槛,当前只传输小清单,不在验证服务器承担重计算或大文件实时分发。 -- 后续增强:真正把付费功能核心文件从镜像中拆出、加密分发、下载缓存和模块加载仍需单独推进;当前本轮完成的是签名清单、权限校验、缓存校验和后续拆包的接口地基。 +- 已完成:付费模块 manifest 和实例绑定加密模块包链路已落地,适合低配置验证服务器和约 5M 带宽;自定义模板和内网穿透已经接入本地有效模块包门槛,验证服务器不承担重计算,只做轻量签名、加密包生成和下载。 +- 后续增强:如果要做到更强的“核心业务实现完全不在镜像里”,还需要继续把自定义模板/内网穿透内部服务端实现逐步移动到模块加载层;当前本轮已经完成的是清单、下载、hash 校验、加密缓存和服务端入口强制依赖模块包的闭环。 ### P1-05 免费版激活保存空签名,后续读取疑似失败(已修复) diff --git a/docs/许可证服务接入说明.md b/docs/许可证服务接入说明.md index cb8e7e0..9202d75 100644 --- a/docs/许可证服务接入说明.md +++ b/docs/许可证服务接入说明.md @@ -53,6 +53,7 @@ POST /api/license/{projectCode}/verify POST /api/license/{projectCode}/heartbeat POST /api/license/{projectCode}/register-free POST /api/license/{projectCode}/premium-modules +POST /api/license/{projectCode}/premium-modules/{moduleId} POST /api/license/{projectCode}/generate GET /api/version/{projectCode}/check @@ -90,7 +91,7 @@ GET /api/version/{projectCode}/agent - `activate`、`activate-free`、`verify`、`heartbeat` 会根据 `LICENSE_PROJECT_CODE` 转发到远端项目维度接口。 - `activate`、`activate-free`、`verify`、`heartbeat` 转发到远端前会加入 `timestamp`、`nonce` 和 HMAC `signature`;远端会拒绝签名缺失、签名错误、过期和重复 `nonce` 请求。 -- `activate`、`verify`、`heartbeat` 在付费许可证保存或续证成功后会尝试同步 `premium-modules` 付费模块清单;免费版会跳过,不访问远端模块清单接口。 +- `activate`、`verify`、`heartbeat` 在付费许可证保存或续证成功后会尝试同步 `premium-modules` 付费模块清单和对应模块包;免费版会跳过,不访问远端模块接口。 - `check` 用于在本地许可证缓存存在时恢复激活状态,会先读取并验签本地许可证。 - `info` 用于读取当前许可证类型、用量和功能开关。 @@ -106,9 +107,10 @@ ${LICENSE_SERVER_URL}/api/license/${LICENSE_PROJECT_CODE}/... - 远端 `POST /api/license/{projectCode}/generate` 是付费激活码生成接口,当前要求 `X-API-Key`、`timestamp` 和 `sign`,不应被普通前端直接调用。 - 远端 `POST /api/license/{projectCode}/premium-modules` 是付费模块清单接口,要求同一套 HMAC 请求签名;免费版、禁用、过期或硬件指纹明显不匹配会被拒绝。 -- `premium-modules` 当前返回的是签名 manifest(模块清单),用于后续真正拆分付费核心文件时复用权限、签名、缓存和过期链路;当前不直接承载大文件下载,适合低配置、低带宽验证服务器。 -- 主应用的自定义模板和内网穿透服务端入口已经依赖本地已验签的 `premium-modules` 清单;许可证功能开关开启但没有对应模块凭证时,服务端仍会拒绝。 -- `activate / verify / heartbeat / register-free / premium-modules` 当前会校验请求 HMAC 签名;返回的许可证和付费模块清单还会分别做服务端签名,主应用本地必须验签后再缓存。 +- 远端 `POST /api/license/{projectCode}/premium-modules/{moduleId}` 是单个付费模块包下载接口,要求 `premium-module-download` HMAC 请求签名,并复查许可证状态、硬件指纹和模块权限。 +- `premium-modules` 返回签名 manifest(模块清单),每个模块带真实 `sizeBytes` 和 `sha256`;主应用随后下载实例绑定的加密模块包,校验大小和 SHA256 后再本地加密缓存。 +- 主应用的自定义模板和内网穿透服务端入口已经依赖本地已验签 manifest 和对应模块包;许可证功能开关开启但没有有效模块包时,服务端仍会拒绝。 +- `activate / verify / heartbeat / register-free / premium-modules / premium-modules/{moduleId}` 当前会校验请求 HMAC 签名;返回的许可证、付费模块清单和模块包必须通过签名、hash、大小、租约和许可证类型检查后才能缓存或放行。 ## 5. 后台管理入口 diff --git a/lib/__tests__/premium-module-service.test.ts b/lib/__tests__/premium-module-service.test.ts index def2f9d..bdf9bc7 100644 --- a/lib/__tests__/premium-module-service.test.ts +++ b/lib/__tests__/premium-module-service.test.ts @@ -24,6 +24,7 @@ jest.mock('../license/license-service', () => ({ jest.mock('../license/crypto-service', () => ({ encryptLocal: jest.fn((data) => `encrypted:${data}`), decryptLocal: jest.fn((data) => data.replace('encrypted:', '')), + hashData: jest.fn((data) => (data === 'module-package' ? 'hash' : 'unknown-hash')), verifySignature: jest.fn(), })); @@ -38,6 +39,8 @@ jest.mock('../license/request-signature', () => ({ jest.mock('../license/server-config', () => ({ buildLicenseServerLicenseApiUrl: jest.fn((action) => `https://license.test/api/license/flnxi/${action}`), + getLicenseServerUrl: jest.fn(() => 'https://license.test'), + getLicenseProjectCode: jest.fn(() => 'flnxi'), })); import { prisma } from '@/lib/prisma'; @@ -108,30 +111,50 @@ describe('premium-module-service', () => { it('付费版应拉取、验签并加密缓存付费模块清单', async () => { mockPaidLicense('PRO'); - mockFetch.mockResolvedValue({ + mockFetch + .mockResolvedValueOnce({ ok: true, json: async () => ({ success: true, manifest: { version: 1, licenseType: 'PRO', - modules: [{ id: 'intranet-penetration-core', minLicenseType: 'PRO', sizeBytes: 4096, sha256: 'hash' }], + modules: [{ id: 'intranet-penetration-core', minLicenseType: 'PRO', sizeBytes: 14, sha256: 'hash' }], issuedAt: new Date().toISOString(), expiresAt: new Date(Date.now() + 60_000).toISOString(), }, signature: 'valid-signature', }), - }); + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + success: true, + package: { + moduleId: 'intranet-penetration-core', + contentBase64: 'module-package', + sha256: 'hash', + sizeBytes: 14, + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }, + }), + }); const { syncPremiumModules } = await import('../license/premium-module-service'); const result = await syncPremiumModules(); expect(result).toEqual({ synced: true }); - expect(mockFetch).toHaveBeenCalledWith( + expect(mockFetch).toHaveBeenNthCalledWith( + 1, 'https://license.test/api/license/flnxi/premium-modules', expect.objectContaining({ method: 'POST' }) ); - expect(prisma.systemConfig.upsert).toHaveBeenCalled(); + expect(mockFetch).toHaveBeenNthCalledWith( + 2, + 'https://license.test/api/license/flnxi/premium-modules/intranet-penetration-core', + expect.objectContaining({ method: 'POST' }) + ); + expect(prisma.systemConfig.upsert).toHaveBeenCalledTimes(2); }); it('付费模块清单同步网络异常时应返回失败结果而不是抛出', async () => { @@ -167,6 +190,76 @@ describe('premium-module-service', () => { await expect(hasPremiumModule('intranet-penetration-core')).resolves.toBe(false); }); + it('只有清单但缺少本地模块包时不应视为可用模块', async () => { + mockPaidLicense('PRO'); + (prisma.systemConfig.findUnique as jest.Mock).mockImplementation(({ where }) => { + if (where.key === 'premium_module_manifest') { + return Promise.resolve({ + value: `encrypted:${JSON.stringify({ + manifest: { + version: 1, + licenseType: 'PRO', + modules: [{ id: 'intranet-penetration-core', minLicenseType: 'PRO', sizeBytes: 14, sha256: 'hash' }], + issuedAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }, + signature: 'valid-signature', + syncedAt: new Date().toISOString(), + })}`, + }); + } + + return Promise.resolve(null); + }); + + const { hasPremiumModule } = await import('../license/premium-module-service'); + + await expect(hasPremiumModule('intranet-penetration-core')).resolves.toBe(false); + }); + + it('清单和本地模块包都有效时应视为可用模块', async () => { + mockPaidLicense('PRO'); + (prisma.systemConfig.findUnique as jest.Mock).mockImplementation(({ where }) => { + if (where.key === 'premium_module_manifest') { + return Promise.resolve({ + value: `encrypted:${JSON.stringify({ + manifest: { + version: 1, + licenseType: 'PRO', + modules: [{ id: 'intranet-penetration-core', minLicenseType: 'PRO', sizeBytes: 14, sha256: 'hash' }], + issuedAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }, + signature: 'valid-signature', + syncedAt: new Date().toISOString(), + })}`, + }); + } + + if (where.key === 'premium_module_packages') { + return Promise.resolve({ + value: `encrypted:${JSON.stringify({ + packages: { + 'intranet-penetration-core': { + contentBase64: 'module-package', + sha256: 'hash', + sizeBytes: 14, + expiresAt: new Date(Date.now() + 60_000).toISOString(), + syncedAt: new Date().toISOString(), + }, + }, + })}`, + }); + } + + return Promise.resolve(null); + }); + + const { hasPremiumModule } = await import('../license/premium-module-service'); + + await expect(hasPremiumModule('intranet-penetration-core')).resolves.toBe(true); + }); + it('缓存清单许可证类型和当前许可证不一致时不应视为可用模块', async () => { mockPaidLicense('STANDARD'); (prisma.systemConfig.findUnique as jest.Mock).mockResolvedValue({ diff --git a/lib/license/premium-module-service.ts b/lib/license/premium-module-service.ts index 301b3f6..80aebeb 100644 --- a/lib/license/premium-module-service.ts +++ b/lib/license/premium-module-service.ts @@ -1,8 +1,8 @@ import { prisma } from '@/lib/prisma'; -import { decryptLocal, encryptLocal, verifySignature } from './crypto-service'; +import { decryptLocal, encryptLocal, hashData, verifySignature } from './crypto-service'; import { generateFingerprint } from './fingerprint-service'; import { buildSignedLicenseRequest } from './request-signature'; -import { buildLicenseServerLicenseApiUrl } from './server-config'; +import { buildLicenseServerLicenseApiUrl, getLicenseProjectCode, getLicenseServerUrl } from './server-config'; import { getLocalLicense, isLicenseConfigExpired } from './license-service'; import type { LicenseType } from './types'; @@ -14,6 +14,7 @@ export interface PremiumModuleManifest { minLicenseType: Exclude; sizeBytes: number; sha256: string; + packageVersion?: number; }>; issuedAt: string; expiresAt: string; @@ -32,7 +33,33 @@ interface CachedPremiumModuleManifest { syncedAt: string; } +interface PremiumModulePackageResponse { + success: boolean; + package?: { + moduleId: string; + contentBase64: string; + sha256: string; + sizeBytes: number; + expiresAt: string; + }; + error?: string; +} + +interface CachedPremiumModulePackages { + packages: Record< + string, + { + contentBase64: string; + sha256: string; + sizeBytes: number; + expiresAt: string; + syncedAt: string; + } + >; +} + const PREMIUM_MODULE_CONFIG_KEY = 'premium_module_manifest'; +const PREMIUM_MODULE_PACKAGES_CONFIG_KEY = 'premium_module_packages'; export const PREMIUM_MODULE_IDS = { CUSTOM_TEMPLATES_CORE: 'custom-templates-core', @@ -69,6 +96,32 @@ async function savePremiumModuleManifest( }); } +async function getCachedPremiumModulePackages(): Promise { + try { + const config = await prisma.systemConfig.findUnique({ + where: { key: PREMIUM_MODULE_PACKAGES_CONFIG_KEY }, + }); + + if (!config) return { packages: {} }; + + const cached = JSON.parse(decryptLocal(config.value)) as CachedPremiumModulePackages; + if (!cached.packages || typeof cached.packages !== 'object') return { packages: {} }; + + return cached; + } catch (error) { + console.warn('[License] 读取付费模块包缓存失败:', error); + return { packages: {} }; + } +} + +async function savePremiumModulePackages(cached: CachedPremiumModulePackages): Promise { + await prisma.systemConfig.upsert({ + where: { key: PREMIUM_MODULE_PACKAGES_CONFIG_KEY }, + update: { value: encryptLocal(JSON.stringify(cached)) }, + create: { key: PREMIUM_MODULE_PACKAGES_CONFIG_KEY, value: encryptLocal(JSON.stringify(cached)) }, + }); +} + export async function getCachedPremiumModuleManifest(): Promise { try { const config = await prisma.systemConfig.findUnique({ @@ -95,6 +148,62 @@ export async function getCachedPremiumModuleManifest(): Promise Date.now(); +} + +async function downloadPremiumModulePackage( + module: PremiumModuleManifest['modules'][number], + fingerprint: Awaited> +): Promise { + const signedRequest = buildSignedLicenseRequest('premium-module-download', { + moduleId: module.id, + instanceId: fingerprint.instanceId, + fingerprint, + }); + + const response = await fetch(buildPremiumModuleDownloadUrl(module.id), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(signedRequest), + }); + const data = (await response.json()) as PremiumModulePackageResponse; + + if (!response.ok || !data.success || !data.package) { + throw new Error(data.error || `付费模块 ${module.id} 下载失败`); + } + + if (data.package.moduleId !== module.id) { + throw new Error(`付费模块 ${module.id} 返回内容不匹配`); + } + + if (data.package.sha256 !== module.sha256 || hashData(data.package.contentBase64) !== module.sha256) { + throw new Error(`付费模块 ${module.id} 哈希校验失败`); + } + + if (data.package.sizeBytes !== module.sizeBytes) { + throw new Error(`付费模块 ${module.id} 大小校验失败`); + } + + return data.package; +} + export async function syncPremiumModules(): Promise<{ synced: boolean; skipped?: boolean; error?: string }> { try { const localLicense = await getLocalLicense(); @@ -134,7 +243,28 @@ export async function syncPremiumModules(): Promise<{ synced: boolean; skipped?: return { synced: false, error: '付费模块清单签名验证失败' }; } + const cachedPackages = await getCachedPremiumModulePackages(); + for (const module of data.manifest.modules) { + if (isCachedPackageValid(cachedPackages, module)) { + continue; + } + + const modulePackage = await downloadPremiumModulePackage(module, fingerprint); + if (!modulePackage) { + throw new Error(`付费模块 ${module.id} 下载结果为空`); + } + + cachedPackages.packages[module.id] = { + contentBase64: modulePackage.contentBase64, + sha256: modulePackage.sha256, + sizeBytes: modulePackage.sizeBytes, + expiresAt: modulePackage.expiresAt, + syncedAt: new Date().toISOString(), + }; + } + await savePremiumModuleManifest(data.manifest, data.signature); + await savePremiumModulePackages(cachedPackages); return { synced: true }; } catch (error) { const message = error instanceof Error ? error.message : '付费模块清单同步异常'; @@ -146,5 +276,9 @@ export async function hasPremiumModule(moduleId: string): Promise { const cached = await getCachedPremiumModuleManifest(); if (!cached) return false; - return cached.manifest.modules.some((module) => module.id === moduleId); + const module = cached.manifest.modules.find((item) => item.id === moduleId); + if (!module) return false; + + const cachedPackages = await getCachedPremiumModulePackages(); + return isCachedPackageValid(cachedPackages, module); } diff --git a/license-server/app/api/license/[projectCode]/premium-modules/[moduleId]/route.ts b/license-server/app/api/license/[projectCode]/premium-modules/[moduleId]/route.ts new file mode 100644 index 0000000..54019ea --- /dev/null +++ b/license-server/app/api/license/[projectCode]/premium-modules/[moduleId]/route.ts @@ -0,0 +1,135 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getPublicProjectDb } from '@/lib/project-auth'; +import { verifyLicenseRequestSignature } from '@/lib/request-signature'; +import { buildPremiumModulePackage } from '@/lib/premium-module-manifest'; +import { generateLicenseConfig, type HardwareFingerprint } from '@/lib/types'; +import { calculateFingerprintMatchScore, createPendingAlertOnce } from '@/lib/abuse-detection'; +import { z } from 'zod'; + +const premiumModuleDownloadSchema = z.object({ + instanceId: z.string(), + fingerprint: z.object({ + machineId: z.string(), + hostname: z.string(), + macAddress: z.string(), + cpuId: z.string().optional(), + instanceId: z.string(), + }), + timestamp: z.number(), +}); + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ projectCode: string; moduleId: string }> } +) { + try { + const { projectCode, moduleId } = await params; + const projectDbResult = await getPublicProjectDb(projectCode); + + if (!projectDbResult.success) { + return projectDbResult.response; + } + + const body = await request.json().catch(() => ({})); + const signatureResult = verifyLicenseRequestSignature('premium-module-download', projectCode, { + ...body, + moduleId, + }); + if (!signatureResult.valid) { + return NextResponse.json( + { success: false, error: '请求签名无效' }, + { status: signatureResult.response.status } + ); + } + + const parsed = premiumModuleDownloadSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { success: false, error: '请求参数无效' }, + { status: 400 } + ); + } + + const { instanceId, fingerprint, timestamp } = parsed.data; + if (Math.abs(Date.now() - timestamp) > 5 * 60 * 1000) { + return NextResponse.json( + { success: false, error: '请求已过期' }, + { status: 400 } + ); + } + + const activation = await projectDbResult.prisma.activation.findFirst({ + where: { + instanceId, + isActive: true, + }, + include: { + license: true, + }, + }); + + if (!activation) { + return NextResponse.json( + { success: false, error: '激活记录不存在' }, + { status: 404 } + ); + } + + const { license } = activation; + if (license.licenseType === 'FREE') { + return NextResponse.json( + { success: false, error: '免费版不提供付费模块包' }, + { status: 403 } + ); + } + + if (license.status === 'disabled') { + return NextResponse.json( + { success: false, error: '许可证已被禁用' }, + { status: 403 } + ); + } + + if (license.status === 'expired' || (license.expiresAt && license.expiresAt < new Date())) { + return NextResponse.json( + { success: false, error: '许可证已过期' }, + { status: 403 } + ); + } + + const storedFingerprint = activation.fingerprint as unknown as HardwareFingerprint; + const matchScore = calculateFingerprintMatchScore(storedFingerprint, fingerprint); + if (matchScore < 70) { + await createPendingAlertOnce(projectDbResult.prisma, license.id, 'premium_module_download_fingerprint_mismatch', { + instanceId, + moduleId, + matchScore, + }); + + return NextResponse.json( + { success: false, error: '硬件指纹不匹配,无法下载付费模块包' }, + { status: 403 } + ); + } + + const config = generateLicenseConfig(license.licenseType, license.expiresAt); + const leaseExpiresAt = new Date(config.leaseExpiresAt || Date.now()); + const modulePackage = buildPremiumModulePackage({ + moduleId, + licenseType: license.licenseType, + instanceId, + leaseExpiresAt, + }); + + return NextResponse.json({ + success: true, + package: modulePackage, + }); + } catch (error) { + console.error('付费模块包下载失败:', error); + return NextResponse.json( + { success: false, error: '服务器内部错误' }, + { status: 500 } + ); + } +} diff --git a/license-server/app/api/license/[projectCode]/premium-modules/route.ts b/license-server/app/api/license/[projectCode]/premium-modules/route.ts index f361232..a0e3371 100644 --- a/license-server/app/api/license/[projectCode]/premium-modules/route.ts +++ b/license-server/app/api/license/[projectCode]/premium-modules/route.ts @@ -110,7 +110,11 @@ export async function POST( const config = generateLicenseConfig(license.licenseType, license.expiresAt); const leaseExpiresAt = new Date(config.leaseExpiresAt || Date.now()); - const signedManifest = buildSignedPremiumModuleManifest(license.licenseType, leaseExpiresAt); + const signedManifest = buildSignedPremiumModuleManifest( + license.licenseType, + leaseExpiresAt, + instanceId + ); return NextResponse.json({ success: true, @@ -124,4 +128,3 @@ export async function POST( ); } } - diff --git a/license-server/lib/__tests__/premium-module-manifest.test.ts b/license-server/lib/__tests__/premium-module-manifest.test.ts index ea8724b..f5c3c6d 100644 --- a/license-server/lib/__tests__/premium-module-manifest.test.ts +++ b/license-server/lib/__tests__/premium-module-manifest.test.ts @@ -6,6 +6,7 @@ describe('premium-module-manifest', () => { beforeEach(() => { jest.useFakeTimers(); jest.setSystemTime(new Date('2026-05-26T00:00:00.000Z')); + process.env.PREMIUM_MODULE_PACKAGE_SECRET = 'p'.repeat(32); }); afterEach(() => { @@ -14,21 +15,39 @@ describe('premium-module-manifest', () => { it('应为标准版生成自定义模板核心模块清单', async () => { const { buildSignedPremiumModuleManifest } = await import('../premium-module-manifest'); - const result = buildSignedPremiumModuleManifest('STANDARD', new Date('2026-06-25T00:00:00.000Z')); + const result = buildSignedPremiumModuleManifest('STANDARD', new Date('2026-06-25T00:00:00.000Z'), 'instance-1'); expect(result.manifest.licenseType).toBe('STANDARD'); expect(result.manifest.modules.map((module) => module.id)).toEqual(['custom-templates-core']); + expect(result.manifest.modules[0].sizeBytes).toBeGreaterThan(0); + expect(result.manifest.modules[0].sha256).toMatch(/^[a-f0-9]{64}$/); expect(result.signature).toContain('signed:'); }); it('应为专业版生成全部付费模块清单', async () => { const { buildSignedPremiumModuleManifest } = await import('../premium-module-manifest'); - const result = buildSignedPremiumModuleManifest('PRO', new Date('2026-07-25T00:00:00.000Z')); + const result = buildSignedPremiumModuleManifest('PRO', new Date('2026-07-25T00:00:00.000Z'), 'instance-1'); expect(result.manifest.modules.map((module) => module.id)).toEqual([ 'custom-templates-core', 'intranet-penetration-core', ]); }); -}); + it('应生成和清单 hash 匹配的实例绑定模块包', async () => { + const { buildPremiumModulePackage, buildSignedPremiumModuleManifest } = await import('../premium-module-manifest'); + const leaseExpiresAt = new Date('2026-07-25T00:00:00.000Z'); + const manifest = buildSignedPremiumModuleManifest('PRO', leaseExpiresAt, 'instance-1').manifest; + const modulePackage = buildPremiumModulePackage({ + moduleId: 'intranet-penetration-core', + licenseType: 'PRO', + instanceId: 'instance-1', + leaseExpiresAt, + }); + + const manifestModule = manifest.modules.find((module) => module.id === 'intranet-penetration-core'); + expect(modulePackage.sha256).toBe(manifestModule?.sha256); + expect(modulePackage.sizeBytes).toBe(manifestModule?.sizeBytes); + expect(modulePackage.contentBase64).not.toContain('intranetPenetration'); + }); +}); diff --git a/license-server/lib/__tests__/premium-module-route-coverage.test.ts b/license-server/lib/__tests__/premium-module-route-coverage.test.ts new file mode 100644 index 0000000..1c2750f --- /dev/null +++ b/license-server/lib/__tests__/premium-module-route-coverage.test.ts @@ -0,0 +1,26 @@ +import fs from 'fs'; +import path from 'path'; + +const downloadRoutePath = path.resolve( + __dirname, + '..', + '..', + 'app', + 'api', + 'license', + '[projectCode]', + 'premium-modules', + '[moduleId]', + 'route.ts' +); + +describe('付费模块下载入口覆盖', () => { + it('下载接口必须保留请求签名、硬件指纹和模块包生成校验', () => { + const source = fs.readFileSync(downloadRoutePath, 'utf8'); + + expect(source).toContain("verifyLicenseRequestSignature('premium-module-download'"); + expect(source).toContain('calculateFingerprintMatchScore'); + expect(source).toContain("license.licenseType === 'FREE'"); + expect(source).toContain('buildPremiumModulePackage'); + }); +}); diff --git a/license-server/lib/premium-module-manifest.ts b/license-server/lib/premium-module-manifest.ts index 3dca2a4..82c42e9 100644 --- a/license-server/lib/premium-module-manifest.ts +++ b/license-server/lib/premium-module-manifest.ts @@ -1,7 +1,9 @@ +import * as crypto from 'crypto'; import { signData } from './crypto'; import type { LicenseType } from './types'; export const PREMIUM_MODULE_MANIFEST_VERSION = 1; +export const PREMIUM_MODULE_PACKAGE_VERSION = 1; export interface PremiumModuleManifest { version: number; @@ -11,6 +13,7 @@ export interface PremiumModuleManifest { minLicenseType: Exclude; sizeBytes: number; sha256: string; + packageVersion: number; }>; issuedAt: string; expiresAt: string; @@ -21,39 +24,175 @@ export interface SignedPremiumModuleManifest { signature: string; } +export interface PremiumModulePackage { + moduleId: string; + contentBase64: string; + sha256: string; + sizeBytes: number; + expiresAt: string; +} + +interface PremiumModuleDefinition { + id: string; + minLicenseType: Exclude; + capabilities: string[]; +} + +const MODULE_DEFINITIONS: Record = { + 'custom-templates-core': { + id: 'custom-templates-core', + minLicenseType: 'STANDARD', + capabilities: ['customTemplates'], + }, + 'intranet-penetration-core': { + id: 'intranet-penetration-core', + minLicenseType: 'PRO', + capabilities: ['intranetPenetration'], + }, +}; + +const MODULE_IDS_BY_LICENSE: Record, string[]> = { + STANDARD: ['custom-templates-core'], + PRO: ['custom-templates-core', 'intranet-penetration-core'], +}; + +function getPackageSecret(): string { + const secret = process.env.PREMIUM_MODULE_PACKAGE_SECRET || process.env.AES_SECRET_KEY || ''; + if (!secret || secret.length < 32) { + throw new Error('PREMIUM_MODULE_PACKAGE_SECRET 或 AES_SECRET_KEY 未配置或长度不足'); + } + + return secret; +} + +function canLicenseUseModule( + licenseType: Exclude, + module: PremiumModuleDefinition +): boolean { + if (module.minLicenseType === 'STANDARD') return licenseType === 'STANDARD' || licenseType === 'PRO'; + return licenseType === 'PRO'; +} + +function encryptModulePayload(payload: Record): string { + const plaintext = JSON.stringify(payload); + const packageSecret = getPackageSecret(); + const key = crypto.createHash('sha256').update(packageSecret).digest(); + const iv = crypto + .createHash('sha256') + .update(`${packageSecret}:${plaintext}`) + .digest() + .subarray(0, 12); + + const cipher = crypto.createCipheriv('aes-256-gcm', key, iv); + let encrypted = cipher.update(plaintext, 'utf8', 'base64'); + encrypted += cipher.final('base64'); + + const envelope = { + version: PREMIUM_MODULE_PACKAGE_VERSION, + algorithm: 'aes-256-gcm', + iv: iv.toString('base64'), + authTag: cipher.getAuthTag().toString('base64'), + encrypted, + }; + + return Buffer.from(JSON.stringify(envelope), 'utf8').toString('base64'); +} + +export function buildPremiumModulePackage(params: { + moduleId: string; + licenseType: Exclude; + instanceId: string; + leaseExpiresAt: Date; +}): PremiumModulePackage { + const module = MODULE_DEFINITIONS[params.moduleId]; + if (!module || !canLicenseUseModule(params.licenseType, module)) { + throw new Error('付费模块不存在或许可证类型不允许使用'); + } + + const expiresAt = params.leaseExpiresAt.toISOString(); + const contentBase64 = encryptModulePayload({ + packageType: 'flnxi-premium-module', + packageVersion: PREMIUM_MODULE_PACKAGE_VERSION, + moduleId: module.id, + licenseType: params.licenseType, + minLicenseType: module.minLicenseType, + capabilities: module.capabilities, + instanceId: params.instanceId, + expiresAt, + }); + + return { + moduleId: module.id, + contentBase64, + sha256: crypto.createHash('sha256').update(contentBase64).digest('hex'), + sizeBytes: Buffer.byteLength(contentBase64, 'utf8'), + expiresAt, + }; +} + +function buildModuleManifestItems(params: { + licenseType: Exclude; + instanceId: string; + leaseExpiresAt: Date; +}): PremiumModuleManifest['modules'] { + return MODULE_IDS_BY_LICENSE[params.licenseType].map((moduleId) => { + const module = MODULE_DEFINITIONS[moduleId]; + const modulePackage = buildPremiumModulePackage({ + moduleId, + licenseType: params.licenseType, + instanceId: params.instanceId, + leaseExpiresAt: params.leaseExpiresAt, + }); + + return { + id: module.id, + minLicenseType: module.minLicenseType, + sizeBytes: modulePackage.sizeBytes, + sha256: modulePackage.sha256, + packageVersion: PREMIUM_MODULE_PACKAGE_VERSION, + }; + }); +} + const MODULES_BY_LICENSE: Record, PremiumModuleManifest['modules']> = { STANDARD: [ { id: 'custom-templates-core', minLicenseType: 'STANDARD', - sizeBytes: 4096, - sha256: 'pending-real-module-split-standard', + sizeBytes: 0, + sha256: '', + packageVersion: PREMIUM_MODULE_PACKAGE_VERSION, }, ], PRO: [ { id: 'custom-templates-core', minLicenseType: 'STANDARD', - sizeBytes: 4096, - sha256: 'pending-real-module-split-standard', + sizeBytes: 0, + sha256: '', + packageVersion: PREMIUM_MODULE_PACKAGE_VERSION, }, { id: 'intranet-penetration-core', minLicenseType: 'PRO', - sizeBytes: 4096, - sha256: 'pending-real-module-split-pro', + sizeBytes: 0, + sha256: '', + packageVersion: PREMIUM_MODULE_PACKAGE_VERSION, }, ], }; export function buildSignedPremiumModuleManifest( licenseType: Exclude, - leaseExpiresAt: Date + leaseExpiresAt: Date, + instanceId?: string ): SignedPremiumModuleManifest { const manifest: PremiumModuleManifest = { version: PREMIUM_MODULE_MANIFEST_VERSION, licenseType, - modules: MODULES_BY_LICENSE[licenseType], + modules: instanceId + ? buildModuleManifestItems({ licenseType, instanceId, leaseExpiresAt }) + : MODULES_BY_LICENSE[licenseType], issuedAt: new Date().toISOString(), expiresAt: leaseExpiresAt.toISOString(), }; @@ -63,4 +202,3 @@ export function buildSignedPremiumModuleManifest( signature: signData(JSON.stringify(manifest)), }; } -