完善付费模块包下载校验闭环

This commit is contained in:
WuYongJie 2026-05-26 17:25:48 +08:00
parent 2f6f051daf
commit 111bb29a1a
9 changed files with 581 additions and 31 deletions

View File

@ -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 免费版激活保存空签名,后续读取疑似失败(已修复)

View File

@ -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. 后台管理入口

View File

@ -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({

View File

@ -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<LicenseType, 'FREE'>;
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<CachedPremiumModulePackages> {
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<void> {
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<CachedPremiumModuleManifest | null> {
try {
const config = await prisma.systemConfig.findUnique({
@ -95,6 +148,62 @@ export async function getCachedPremiumModuleManifest(): Promise<CachedPremiumMod
}
}
function buildPremiumModuleDownloadUrl(moduleId: string): string {
const baseUrl = getLicenseServerUrl();
const projectCode = encodeURIComponent(getLicenseProjectCode());
return `${baseUrl}/api/license/${projectCode}/premium-modules/${encodeURIComponent(moduleId)}`;
}
function isCachedPackageValid(
cached: CachedPremiumModulePackages,
module: PremiumModuleManifest['modules'][number]
): boolean {
const modulePackage = cached.packages[module.id];
if (!modulePackage) return false;
if (modulePackage.sha256 !== module.sha256) return false;
if (modulePackage.sizeBytes !== module.sizeBytes) return false;
if (hashData(modulePackage.contentBase64) !== module.sha256) return false;
const packageExpiresAt = new Date(modulePackage.expiresAt).getTime();
return !Number.isNaN(packageExpiresAt) && packageExpiresAt > Date.now();
}
async function downloadPremiumModulePackage(
module: PremiumModuleManifest['modules'][number],
fingerprint: Awaited<ReturnType<typeof generateFingerprint>>
): Promise<PremiumModulePackageResponse['package']> {
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<boolean> {
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);
}

View File

@ -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 }
);
}
}

View File

@ -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(
);
}
}

View File

@ -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');
});
});

View File

@ -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');
});
});

View File

@ -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<LicenseType, 'FREE'>;
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<LicenseType, 'FREE'>;
capabilities: string[];
}
const MODULE_DEFINITIONS: Record<string, PremiumModuleDefinition> = {
'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<Exclude<LicenseType, 'FREE'>, 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<LicenseType, 'FREE'>,
module: PremiumModuleDefinition
): boolean {
if (module.minLicenseType === 'STANDARD') return licenseType === 'STANDARD' || licenseType === 'PRO';
return licenseType === 'PRO';
}
function encryptModulePayload(payload: Record<string, unknown>): 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<LicenseType, 'FREE'>;
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<LicenseType, 'FREE'>;
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<Exclude<LicenseType, 'FREE'>, 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<LicenseType, 'FREE'>,
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)),
};
}